import * as storage from 'localforage' ; import _ from 'lodash' ; import { EmoteSets } from 'twitch-js' ; import RequestMethod from 'constants/requestMethod' ; import { getRandomString } from 'utils/crypto' ; import { subDays , subMonths } from 'utils/date' ; /**
 * Twitch various APIs.
 */ enum TwitchApi { Auth = 'https://id.twitch.tv/oauth2' , Badges = 'https://badges.twitch.tv/v1/badges' , Helix = 'https://api.twitch.tv/helix' , Kraken = 'https://api.twitch.tv/kraken' , Tmi = 'https://tmi.twitch.tv' } /**
 * Twitch broadcast type.
 */ export enum BroadcastType { All = 'all' , Archive = 'archive' , Highlight = 'highlight' , Upload = 'upload' } /**
 * Twitch clip discovery period.
 */ export enum ClipPeriod { All = 'all' , Day = 'day' , Month = 'month' , Week = 'week' } /**
 * Twitch status.
 */ export enum Status { Disrupted , Online , Unknown } /**
 * Twitch commercial durations.
 */ export type CommercialDuration = 30 | 60 | 90 | 120 | 150 | 180 ; /**
 * CORS proxy URL.
 */ const ProxyURL = 'https://cors.bridged.cc/' ; /**
 * The key used to persist a state used while authenticating with Twitch.
 */ const AuthStorageKey = 'persist:YaTA:auth' ; /**
 * Twitch class.
 */ export default class Twitch { param userId param token public static setAuthDetails ( userId , token = null ) { Twitch . userId = userId ; Twitch . token = token ; } param redirect return public static getAuthURL ( redirect ) { const { REACT_APP_TWITCH_CLIENT_ID , REACT_APP_TWITCH_REDIRECT_URI } = process . env ; const state = { token : getRandomString ( ) , redirect : redirect , } ; storage . setItem ( AuthStorageKey , state ) ; const params = { client_id : REACT_APP_TWITCH_CLIENT_ID , redirect_uri : REACT_APP_TWITCH_REDIRECT_URI , response_type : 'token id_token' , scope : 'openid chat:read chat:edit channel:moderate whispers:read whispers:edit user_blocks_edit clips:edit user:edit:follows user:edit:broadcast channel:edit:commercial user_subscriptions moderator:manage:automod' , state : encodeURIComponent ( JSON . stringify ( state ) ) , } ; return Twitch . getUrl ( TwitchApi . Auth , '/authorize' , params ) ; } param hash return public static async getAuthTokens ( hash ) { const params = new URLSearchParams ( hash . substring ( 1 ) ) ; if ( ! params . has ( 'access_token' ) || ! params . has ( 'id_token' ) || ! params . has ( 'state' ) ) { throw new Error ( 'Invalid auth response.' ) ; } const persistedState = await storage . getItem < AuthState > ( AuthStorageKey ) ; storage . removeItem ( AuthStorageKey ) ; if ( _ . isNil ( persistedState ) ) { throw new Error ( 'No persisted state in storage.' ) ; } const stateStr = params . get ( 'state' ) ; if ( _ . isNil ( stateStr ) ) { throw new Error ( 'No state in response.' ) ; } let state ; try { state = JSON . parse ( decodeURIComponent ( stateStr ) ) ; } catch ( error ) { throw new Error ( 'Unable to parse state from response.' ) ; } if ( state . token !== persistedState . token ) { throw new Error ( 'Invalid state from response.' ) ; } return { access : params . get ( 'access_token' ) as string , id : params . get ( 'id_token' ) as string , redirect : state . redirect , } ; } param token return public static async verifyIdToken ( token ) { const jwk = await Twitch . fetchJWK ( ) ; const jose = await import ( 'node-jose' ) ; const keystore = await jose . JWK . asKeyStore ( jwk ) ; const jws = await jose . JWS . createVerify ( keystore ) . verify ( token ) ; const idToken = JSON . parse ( jws . payload . toString ( ) ) as IdToken ; if ( _ . get ( idToken , 'aud' ) !== process . env . REACT_APP_TWITCH_CLIENT_ID || _ . get ( idToken , 'iss' ) !== TwitchApi . Auth ) { throw new Error ( 'Unable to verify ID token.' ) ; } return idToken ; } param channel return public static sanitizeChannel ( channel ) { if ( channel . charAt ( 0 ) === '#' ) { return channel . substr ( 1 ) ; } return channel ; } param url return public static getTwitchEmbedUrl ( url ) { return url . concat ( '&parent=yata.vercel.app' ) ; } param templateUrl param params return public static getTwitchTemplatedUrl ( templateUrl , params ) { let url = templateUrl ; _ . forEach ( params , ( value , key ) => { const regExp = new RegExp ( `%{${ key }}` , 'g' ) ; url = url . replace ( regExp , value ) ; } ) ; return url ; } param channel public static openChannel ( channel ) { window . open ( `https://www.twitch.tv/${ channel }` ) ; } param channel param username public static openViewerCard ( channel , username ) { window . open ( `https://www.twitch.tv/popout/${ channel }/viewercard/${ username }?popout=` , 'twitchToolsPopupWindow' , 'height=600,width=500' ) ; } param channel public static openRewardsQueue ( channel ) { window . open ( `https://www.twitch.tv/popout/${ channel }/reward-queue` , 'twitchRewardsQueuePopupWindow' , 'height=500,width=800' ) ; } param channel public static openModView ( channel ) { window . open ( `https://www.twitch.tv/moderator/${ channel }` , 'twitchModViewPopupWindow' , 'height=800,width=1200' ) ; } param channel public static openStreamManager ( channel ) { window . open ( `https://dashboard.twitch.tv/u/${ channel }/stream-manager` , 'twitchStreamManagerPopupWindow' , 'height=800,width=1200' ) ; } param channel public static openStreamSummary ( channel ) { window . open ( `https://dashboard.twitch.tv/u/${ channel }/stream-summary` , 'twitchStreamSummaryPopupWindow' , 'height=800,width=1200' ) ; } param channel public static openStreamInfos ( channel ) { window . open ( `https://www.twitch.tv/popout/${ channel }/dashboard/live/stream-info` , 'twitchStreamInfosPopupWindow' , 'height=600,width=500' ) ; } param channel public static openActivityFeed ( channel ) { window . open ( `https://www.twitch.tv/popout/${ channel }/dashboard/live/activity-feed` , 'twitchActivityFeedPopupWindow' , 'height=800,width=500' ) ; } param channel public static openVideoPlayer ( channel ) { window . open ( Twitch . getTwitchEmbedUrl ( `https://player.twitch.tv/?muted=false&channel=${ channel }` ) , 'videoPopupWindow' , 'height=360,width=600' ) ; } param channelId return public static async fetchBadges ( channelId ) { const response = await Promise . all ( [ ( await Twitch . fetch ( TwitchApi . Badges , '/global/display' ) ) . json ( ) , ( await Twitch . fetch ( TwitchApi . Badges , `/channels/${ channelId }/display` ) ) . json ( ) , ] ) ; const [ globalBadges , channelBadges ] = response ; return { ... globalBadges . badge_sets , ... channelBadges . badge_sets } ; } param name return public static async fetchUserByName ( name ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/users' , { login : name } , true , RequestMethod . Get ) ; const { data : users } = await response . json ( ) ; if ( users . length === 1 ) { return _ . first ( users ) ; } return ; } param channelId return public static async fetchChannelInformations ( channelId ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/channels' , { broadcaster_id : channelId } , true , RequestMethod . Get ) ; return ( await response . json ( ) ) . data [ 0 ] ; } param channelId param title param categoryId return public static async updateChannelInformations ( channelId , title , categoryId ? ) { const params = { broadcaster_id : channelId , title } ; if ( categoryId ) { params [ 'game_id' ] = categoryId ; } return Twitch . fetch ( TwitchApi . Helix , '/channels' , params , true , RequestMethod . Patch ) ; } param channelId param duration public static async startCommercial ( channelId , duration ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/channels/commercial' , undefined , true , RequestMethod . Post , { broadcaster_id : channelId , length : duration , } ) ; return response . json ( ) ; } param channelId param description public static async createMarker ( channelId , description ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/streams/markers' , undefined , true , RequestMethod . Post , { user_id : channelId , description , } ) ; return response . json ( ) ; } param channelId return public static async fetchUserEmotes ( ) { if ( _ . isNil ( Twitch . userId ) ) { throw new Error ( 'Missing user id for emotes fetching.' ) ; } const response = await Twitch . fetch ( TwitchApi . Kraken , `/users/${ Twitch . userId }/emotes` , undefined , true , RequestMethod . Get ) ; return response . json ( ) ; } param messageId param action public static manageAutoModMessage ( messageId , action ) { if ( _ . isNil ( Twitch . userId ) ) { throw new Error ( 'Missing source user id for relationship fetching.' ) ; } return Twitch . fetch ( TwitchApi . Helix , '/moderation/automod/message' , { action : action . toUpperCase ( ) , msg_id : messageId , user_id : Twitch . userId , } , true , RequestMethod . Post ) ; } param channelId param period param limit return public static async fetchTopClips ( channelId , period , limit = 10 ) { const params = { broadcaster_id : channelId , first : limit . toString ( ) , } ; if ( period !== ClipPeriod . All ) { const now = new Date ( ) ; params [ 'ended_at' ] = now . toISOString ( ) ; if ( period === ClipPeriod . Day ) { params [ 'started_at' ] = subDays ( now , 1 ) . toISOString ( ) ; } else if ( period === ClipPeriod . Week ) { params [ 'started_at' ] = subDays ( now , 7 ) . toISOString ( ) ; } else if ( period === ClipPeriod . Month ) { params [ 'started_at' ] = subMonths ( now , 1 ) . toISOString ( ) ; } } const response = await Twitch . fetch ( TwitchApi . Helix , '/clips' , params , true , RequestMethod . Get ) ; return ( await response . json ( ) ) . data ; } param channelId return public static async fetchStream ( channelId ) { const response = await Twitch . fetch ( TwitchApi . Kraken , `/streams/${ channelId }` ) ; return response . json ( ) ; } param channelId param limit param type return public static async fetchChannelVideos ( channelId , limit = 10 , type = BroadcastType . Archive ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/videos' , { first : limit . toString ( ) , type , user_id : channelId , } , true , RequestMethod . Get ) ; return ( await response . json ( ) ) . data ; } param channelId param withDelay return public static async createClip ( channelId , withDelay = false ) { const params = { broadcaster_id : channelId , has_delay : withDelay . toString ( ) , } ; const response = await Twitch . fetch ( TwitchApi . Helix , '/clips' , params , true , RequestMethod . Post ) ; return response . json ( ) ; } param channelId return public static async fetchCheermotes ( channelId ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/bits/cheermotes' , { broadcaster_id : channelId , } , true , RequestMethod . Get ) ; return response . json ( ) ; } param clipId return public static async fetchClip ( clipId ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/clips' , { id : clipId , } , true , RequestMethod . Get ) ; return ( await response . json ( ) ) . data [ 0 ] ; } param videoId return public static async fetchVideo ( videoId ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/videos' , { id : videoId , } , true , RequestMethod . Get ) ; return ( await response . json ( ) ) . data [ 0 ] ; } param channel return public static async fetchChatters ( channel ) { const response = await fetch ( Twitch . getUrl ( TwitchApi . Tmi , `/group/user/${ channel }/chatters` , undefined , true ) , { headers : { 'X-Requested-With' : '' , } , } ) ; return response . json ( ) ; } return public static async fetchFollows ( ) { const follows = await Twitch . fetchAuthenticatedUserFollows ( ) ; const streams = await Twitch . fetchAuthenticatedUserStreams ( ) ; const offline = _ . reduce ( follows , ( offlines , follow ) => { if ( _ . isNil ( _ . find ( streams , [ 'channel.name' , follow . channel . name ] ) ) ) { offlines . push ( follow . channel ) ; } return offlines ; } , [ ] as RawChannel [ ] ) ; let own = null ; if ( ! _ . isNil ( this . userId ) ) { const { stream } = await Twitch . fetchStream ( this . userId ) ; if ( ! _ . isNil ( stream ) ) { own = stream ; } } return { offline , online : streams , own } ; } param targetId return public static async fetchRelationship ( targetId ) { if ( _ . isNil ( Twitch . userId ) ) { throw new Error ( 'Missing source user id for relationship fetching.' ) ; } const params = { from_id : Twitch . userId , to_id : targetId , } ; const response = await Twitch . fetch ( TwitchApi . Helix , '/users/follows' , params ) ; const relationships = ( await response . json ( ) ) as RawRelationships ; if ( relationships . total === 1 ) { const relationship = _ . head ( relationships . data ) ; if ( ! _ . isNil ( relationship ) ) { return relationship ; } } return null ; } param targetId return public static async fetchFollowersCount ( targetId ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/users/follows' , { to_id : targetId } ) ; const relationships = ( await response . json ( ) ) as RawRelationships ; return relationships . total ; } param offset param limit return public static async fetchAuthenticatedUserFollows ( offset = 0 , limit = 100 ) { const params = { limit : limit . toString ( ) , offset : offset . toString ( ) , sortby : 'last_broadcast' , } ; const response = await Twitch . fetch ( TwitchApi . Kraken , `/users/${ Twitch . userId }/follows/channels` , params , true ) ; const { follows } = ( await response . json ( ) ) as RawFollows ; let allFollows = [ ... follows ] ; if ( follows . length === limit ) { const nextFollows = await Twitch . fetchAuthenticatedUserFollows ( offset + limit , limit ) ; allFollows = [ ... allFollows , ... nextFollows ] ; } return allFollows ; } param offset param limit return public static async fetchAuthenticatedUserStreams ( offset = 0 , limit = 100 ) { const params = { limit : limit . toString ( ) , offset : offset . toString ( ) , stream_type : 'live' , } ; const response = await Twitch . fetch ( TwitchApi . Kraken , '/streams/followed' , params , true ) ; const { streams } = ( await response . json ( ) ) as RawStreams ; let allStreams = [ ... streams ] ; if ( allStreams . length === limit ) { const nextStreams = await Twitch . fetchAuthenticatedUserStreams ( offset + limit , limit ) ; allStreams = [ ... allStreams , ... nextStreams ] ; } return allStreams ; } param targetId public static blockUser ( targetId ) { return Twitch . fetch ( TwitchApi . Helix , '/users/blocks' , { target_user_id : targetId , source_context : 'chat' } , true , RequestMethod . Put ) ; } param targetId public static unblockUser ( targetId ) { return Twitch . fetch ( TwitchApi . Helix , '/users/blocks' , { target_user_id : targetId } , true , RequestMethod . Delete ) ; } param channelId return public static async fetchSchedule ( channelId ) { const response = await Twitch . fetch ( TwitchApi . Helix , '/schedule' , { broadcaster_id : channelId , first : '25' , utc_offset : new Date ( ) . getTimezoneOffset ( ) . toString ( ) , } ) ; const schedule = ( await response . json ( ) ) as RawSchedule ; return schedule . data ; } param streamOrChannel return public static isStream ( streamOrChannel ) { return ! _ . isNil ( _ . get ( streamOrChannel , 'stream_type' ) ) ; } private static token : string | null ; private static userId : string | null ; return public static getAuthenticatedUserId ( ) { if ( _ . isNil ( Twitch . userId ) ) { throw new Error ( 'No user ID found.' ) ; } return Twitch . userId ; } return public static getAuthenticatedUserToken ( ) { if ( _ . isNil ( Twitch . token ) ) { throw new Error ( 'No token found.' ) ; } return Twitch . token ; } param api param endpoint param searchParams param proxy return private static getUrl ( api , endpoint , searchParams = { } , proxy = false ) { const url = new URL ( `${ proxy ? ProxyURL : '' }${ api }${ endpoint }` ) ; _ . forEach ( searchParams , ( value , key ) => url . searchParams . set ( key , value ) ) ; return url . toString ( ) ; } param api param endpoint param searchParams param authenticated param method param body param signal return private static async fetch ( api , endpoint , searchParams = { } , authenticated = false , method = RequestMethod . Get , body ? , signal ? ) { const url = Twitch . getUrl ( api , endpoint , searchParams ) ; const headers = new Headers ( { Accept : 'application/vnd.twitchtv.v5+json' , 'Client-ID' : process . env . REACT_APP_TWITCH_CLIENT_ID , 'Content-Type' : 'application/json; charset=UTF-8' , } ) ; if ( authenticated || api === TwitchApi . Helix ) { const authHeader = Twitch . getAuthHeader ( api ) ; _ . forEach ( authHeader , ( value , name ) => { headers . append ( name , value ) ; } ) ; } const init = { headers , method } ; if ( ! _ . isNil ( body ) ) { init . body = JSON . stringify ( body ) ; } if ( ! _ . isNil ( signal ) ) { init . signal = signal ; } const request = new Request ( url , init ) ; const response = await fetch ( request ) ; if ( response . status >= 400 ) { const json = await response . json ( ) ; const originalMessage = _ . get ( json , 'message' , 'Something went wrong.' ) ; const message = originalMessage . charAt ( 0 ) === '{' ? _ . get ( JSON . parse ( originalMessage ) , 'message' , 'Something went wrong.' ) : originalMessage ; throw new Error ( message ) ; } return response ; } return private static async fetchJWK ( ) { const jwkReponse = await fetch ( Twitch . getUrl ( TwitchApi . Auth , '/keys' ) ) ; const jwk = await jwkReponse . json ( ) ; return jwk as JsonWebKey ; } param api return private static getAuthHeader ( api ) { if ( _ . isNil ( Twitch . token ) ) { throw new Error ( 'Missing token for authenticated request.' ) ; } return { Authorization : `${ api === TwitchApi . Helix ? 'Bearer' : 'OAuth' } ${ Twitch . token }` } ; } } /**
 * ID token.
 */ export type IdToken = { aud : string ; azp : string ; exp : number ; iat : number ; iss : string ; preferred_username : string ; sub : string ; } ; /**
 * Twitch badges.
 */ export type RawBadges = Record < string , { versions : Record < string , RawBadge > ; } > ; /**
 * Twitch badge.
 */ export type RawBadge = { click_action : string ; click_url : string ; description : string ; image_url_1x : string ; image_url_2x : string ; image_url_4x : string ; title : string ; } ; /**
 * Twitch user details returned by the Helix API.
 */ export type RawHelixUser = { broadcaster_type : 'partner' | 'affiliate' | '' ; created_at : string ; description : string ; display_name : string ; email ? : string ; id : string ; login : string ; offline_image_url : string ; profile_image_url : string ; type : 'staff' | 'admin' | 'global_mod' | '' ; view_count : number ; } ; /**
 * Twitch channel details.
 */ export type RawChannel = { mature : boolean ; status : string | null ; broadcaster_language : string ; display_name : string ; game : string | null ; language : string ; _id : string ; name : string ; created_at : string ; updated_at : string ; partner : boolean ; logo : string ; video_banner : string | null ; profile_banner : string | null ; profile_banner_background_color : string | null ; url : string ; views : number ; followers : number ; broadcaster_type : string ; description : string ; private_video : boolean ; privacy_options_enabled : boolean ; } ; /**
 * Twitch channel informations.
 */ export type RawChannelInformations = { broadcaster_id : string ; broadcaster_name : string ; broadcaster_language : string ; game_id : string ; game_name : string ; title : string ; } ; /**
 * Twitch chatters details.
 */ type RawChattersDetails = { chatter_count : number ; chatters : RawChatters ; } ; /**
 * Twitch chatters.
 */ export type RawChatters = { admins : string [ ] ; broadcaster : string [ ] ; global_mods : string [ ] ; moderators : string [ ] ; staff : string [ ] ; viewers : string [ ] ; vips : string [ ] ; } ; /**
 * Twitch clip.
 */ export type RawClip = { broadcaster_id : string ; broadcaster_name : string ; created_at : string ; creator_id : string ; creator_name : string ; embed_url : string ; game_id : string ; id : string ; language : string ; thumbnail_url : string ; title : string ; url : string ; video_id : string ; view_count : number ; } ; /**
 * Twitch follows.
 */ export type RawFollows = { follows : RawFollow [ ] ; _total : number ; } ; /**
 * Twitch follow.
 */ export type RawFollow = { created_at : string ; notifications : true ; channel : RawChannel ; } ; /**
 * Twitch streams.
 */ export type RawStreams = { streams : RawStream [ ] ; _total : number ; } ; /**
 * Twitch stream.
 */ export type RawStream = { average_fps : number ; broadcast_platform : string ; channel : RawChannel ; community_id : string ; community_ids : string [ ] ; created_at : string ; delay : number ; game : number ; is_playlist : boolean ; preview : RawPreview ; stream_type : string ; video_height : number ; viewers : number ; _id : string ; } ; /**
 * Twitch Cheermote.
 */ export type RawCheermote = { is_charitable : boolean ; last_updated : string ; order : number ; prefix : string ; tiers : RawCheermoteTier [ ] ; type : 'global_first_party' | 'global_third_party' | 'channel_custom' | 'display_only' | 'sponsored' ; } ; /**
 * Twitch Cheermote tier.
 */ type RawCheermoteTier = { can_cheer : boolean ; color : string ; id : string ; images : Record < CheermoteImageBackground , RawCheermoteImages > ; min_bits : number ; show_in_bits_card : boolean ; } ; /**
 * Twitch video.
 */ export type RawVideo = { id : string ; user_id : string ; user_name : string ; title : string ; description : string ; created_at : string ; published_at : string ; url : string ; thumbnail_url : string ; viewable : 'public' | 'private' ; view_count : number ; language : string ; type : Exclude < BroadcastType , BroadcastType . All > ; duration : string ; } ; /**
 * Twitch preview.
 */ type RawPreview = { large : string ; medium : string ; small : string ; template : string ; } ; /**
 * Twitch new clip.
 */ type RawNewClips = { data : Array < { edit_url : string ; id : string ; } > ; } ; /**
 * Twitch follow relationships.
 */ type RawRelationships = { data : RawRelationship [ ] ; total : number ; pagination : { cursor : string ; } ; } ; /**
 * Twitch follow relationship.
 */ export type RawRelationship = { from_id : string ; to_id : string ; followed_at : string ; } ; /**
 * Twitch host.
 */ export type RawHost = { host_id : string ; targer_id : string ; host_login : string ; target_login : string ; host_display_name : string ; target_display_name : string ; } ; /**
 * Twitch schedule.
 */ export type RawSchedule = { data : { segments : RawScheduleSegment [ ] ; broadcaster_id : string ; broadcaster_name : String ; broadcaster_login : string ; vacation : null | { start_time : string ; end_time : string ; } ; } ; pagination : { cursor : string ; } ; } ; /**
 * Twitch schedule segment.
 */ export type RawScheduleSegment = { id : string ; start_time : string ; end_time : string ; title : string ; canceled_until : string | null ; category : null | { id : string ; name : string ; } ; is_recurring : boolean ; } ; /**
 * Twitch Cheermote images.
 */ type RawCheermoteImages = Record < CheermoteImageType , RawCheermoteImage > ; /**
 * Twitch Cheermote image.
 */ export type RawCheermoteImage = Record < CheermoteImageScales , string > ; /**
 * Cheermotes related types.
 */ export type CheermoteImageBackground = 'dark' | 'light' ; type CheermoteImageType = 'static' | 'animated' ; type CheermoteImageScales = '1' | '1.5' | '2' | '3' | '4' ; /**
 * Online stream, offline channel and own stream if online.
 */ export type Followers = { offline : RawChannel [ ] ; online : RawStream [ ] ; own : RawStream | null ; } ; /**
 * Online stream or offline channel.
 */ export type Follower = RawStream | RawChannel ; /**
 * State send to Twitch while authenticating.
 */ interface AuthState { token : string ; redirect ? : string ; }