import * as debugLogger from "debug-logger" ; import { getMods , getVips } from "../operations/get-mods-vips" ; import { ClientConfiguration } from "../config/config" ; import { Color } from "../message/color" ; import { ClientMixin , ConnectionMixin } from "../mixins/base-mixin" ; import { IgnoreUnhandledPromiseRejectionsMixin } from "../mixins/ignore-promise-rejections" ; import { ConnectionRateLimiter } from "../mixins/ratelimiters/connection" ; import { PrivmsgMessageRateLimiter } from "../mixins/ratelimiters/privmsg" ; import { RoomStateTracker } from "../mixins/roomstate-tracker" ; import { UserStateTracker } from "../mixins/userstate-tracker" ; import { joinChannel , joinNothingToDo } from "../operations/join" ; import { joinAll } from "../operations/join-all" ; import { partChannel , partNothingToDo } from "../operations/part" ; import { sendPing } from "../operations/ping" ; import { sendPrivmsg } from "../operations/privmsg" ; import { me , say } from "../operations/say" ; import { setColor } from "../operations/set-color" ; import { timeout } from "../operations/timeout" ; import { ban } from "../operations/ban" ; import { whisper } from "../operations/whisper" ; import { anyCauseInstanceof } from "../utils/any-cause-instanceof" ; import { findAndPushToEnd } from "../utils/find-and-push-to-end" ; import { removeInPlace } from "../utils/remove-in-place" ; import { unionSets } from "../utils/union-sets" ; import { validateChannelName , correctChannelName } from "../validation/channel" ; import { BaseClient } from "./base-client" ; import { SingleConnection } from "./connection" ; import { ClientError } from "./errors" ; const log = debugLogger ( "dank-twitch-irc:client" ) ; export type ConnectionPredicate = ( conn : SingleConnection ) => boolean ; const alwaysTrue = ( ) : true => true as const ; export class ChatClient extends BaseClient { public get wantedChannels ( ) : Set < string > { return unionSets ( this . connections . map ( ( c ) => c . wantedChannels ) ) ; } public get joinedChannels ( ) : Set < string > { return unionSets ( this . connections . map ( ( c ) => c . joinedChannels ) ) ; } public roomStateTracker ? : RoomStateTracker ; public userStateTracker ? : UserStateTracker ; public readonly connectionMixins : ConnectionMixin [ ] = [ ] ; public readonly connections : SingleConnection [ ] = [ ] ; private activeWhisperConn : SingleConnection | undefined ; public constructor ( configuration ? ) { super ( configuration ) ; if ( this . configuration . installDefaultMixins ) { this . use ( new UserStateTracker ( this ) ) ; this . use ( new RoomStateTracker ( ) ) ; this . use ( new ConnectionRateLimiter ( this ) ) ; this . use ( new PrivmsgMessageRateLimiter ( this ) ) ; } if ( this . configuration . ignoreUnhandledPromiseRejections ) { this . use ( new IgnoreUnhandledPromiseRejectionsMixin ( ) ) ; } this . on ( "error" , ( error ) => { if ( anyCauseInstanceof ( error , ClientError ) ) { process . nextTick ( ( ) => { this . emitClosed ( error ) ; this . connections . forEach ( ( conn ) => conn . destroy ( error ) ) ; } ) ; } } ) ; this . on ( "close" , ( ) => { this . connections . forEach ( ( conn ) => conn . close ( ) ) ; } ) ; } public async connect ( ) { this . requireConnection ( ) ; if ( ! this . ready ) { await new Promise < void > ( ( resolve ) => this . once ( "ready" , ( ) => resolve ( ) ) ) ; } } public close ( ) { this . emitClosed ( ) ; } public destroy ( error ? ) { if ( error != null ) { this . emitError ( error ) ; this . emitClosed ( error ) ; } else { this . emitClosed ( ) ; } } param command public sendRaw ( command ) { this . requireConnection ( ) . sendRaw ( command ) ; } public async join ( channelName ) { channelName = correctChannelName ( channelName ) ; validateChannelName ( channelName ) ; if ( this . connections . some ( ( c ) => joinNothingToDo ( c , channelName ) ) ) { return ; } const conn = this . requireConnection ( maxJoinedChannels ( this . configuration . maxChannelCountPerConnection ) ) ; await joinChannel ( conn , channelName ) ; } public async part ( channelName ) { channelName = correctChannelName ( channelName ) ; validateChannelName ( channelName ) ; if ( this . connections . every ( ( c ) => partNothingToDo ( c , channelName ) ) ) { return ; } const conn = this . requireConnection ( ( c ) => ! partNothingToDo ( c , channelName ) ) ; await partChannel ( conn , channelName ) ; } public async joinAll ( channelNames ) { channelNames = channelNames . map ( ( v ) => { v = correctChannelName ( v ) ; validateChannelName ( v ) ; return v ; } ) ; const needToJoin = channelNames . filter ( ( channelName ) => ! this . connections . some ( ( c ) => joinNothingToDo ( c , channelName ) ) ) ; const promises = [ ] ; let idx = 0 ; while ( idx < needToJoin . length ) { const conn = this . requireConnection ( maxJoinedChannels ( this . configuration . maxChannelCountPerConnection ) ) ; const canJoin = this . configuration . maxChannelCountPerConnection - conn . wantedChannels . size ; const channelsSlice = needToJoin . slice ( idx , ( idx += canJoin ) ) ; promises . push ( joinAll ( conn , channelsSlice ) ) ; } const errorChunks = await Promise . all ( promises ) ; return Object . assign ( { } , ... errorChunks ) ; } public async privmsg ( channelName , message ) { channelName = correctChannelName ( channelName ) ; validateChannelName ( channelName ) ; return sendPrivmsg ( this . requireConnection ( ) , channelName , message ) ; } public async say ( channelName , message ) { channelName = correctChannelName ( channelName ) ; validateChannelName ( channelName ) ; await say ( this . requireConnection ( mustNotBeJoined ( channelName ) ) , channelName , message ) ; } public async me ( channelName , message ) { channelName = correctChannelName ( channelName ) ; validateChannelName ( channelName ) ; await me ( this . requireConnection ( mustNotBeJoined ( channelName ) ) , channelName , message ) ; } public async timeout ( channelName , username , length , reason ? ) { await timeout ( this . requireConnection ( ) , channelName , username , length , reason ) ; } public async ban ( channelName , username , reason ? ) { await ban ( this . requireConnection ( ) , channelName , username , reason ) ; } public async whisper ( username , message ) { validateChannelName ( username ) ; await whisper ( this . requireConnection ( ) , username , message ) ; } public async setColor ( color ) { await setColor ( this . requireConnection ( ) , color ) ; } public async getMods ( channelName ) { channelName = correctChannelName ( channelName ) ; validateChannelName ( channelName ) ; return await getMods ( this . requireConnection ( ) , channelName ) ; } public async getVips ( channelName ) { channelName = correctChannelName ( channelName ) ; validateChannelName ( channelName ) ; return await getVips ( this . requireConnection ( ) , channelName ) ; } public async ping ( ) { await sendPing ( this . requireConnection ( ) ) ; } public newConnection ( ) { const conn = new SingleConnection ( this . configuration ) ; log . debug ( `Creating new connection (ID ${ conn . connectionID })` ) ; for ( const mixin of this . connectionMixins ) { conn . use ( mixin ) ; } conn . on ( "connecting" , ( ) => this . emitConnecting ( ) ) ; conn . on ( "connect" , ( ) => this . emitConnected ( ) ) ; conn . on ( "ready" , ( ) => this . emitReady ( ) ) ; conn . on ( "error" , ( error ) => this . emitError ( error ) ) ; conn . on ( "close" , ( hadError ) => { if ( hadError ) { log . warn ( `Connection ${ conn . connectionID } was closed due to error` ) ; } else { log . debug ( `Connection ${ conn . connectionID } closed normally` ) ; } removeInPlace ( this . connections , conn ) ; if ( this . activeWhisperConn === conn ) { this . activeWhisperConn = undefined ; } if ( ! this . closed ) { this . reconnectFailedConnection ( conn ) ; } } ) ; conn . on ( "rawCommmand" , ( cmd ) => this . emit ( "rawCommmand" , cmd ) ) ; conn . on ( "message" , ( message ) => { if ( message . ircCommand === "WHISPER" ) { if ( this . activeWhisperConn == null ) { this . activeWhisperConn = conn ; } if ( this . activeWhisperConn !== conn ) { return ; } } this . emitMessage ( message ) ; } ) ; conn . connect ( ) ; this . connections . push ( conn ) ; return conn ; } public use ( mixin ) { mixin . applyToClient ( this ) ; } private reconnectFailedConnection ( conn ) { const channels = Array . from ( conn . wantedChannels ) ; if ( channels . length > 0 ) { this . joinAll ( channels ) ; } else if ( this . connections . length <= 0 ) { this . requireConnection ( ) ; } this . emit ( "reconnect" , conn ) ; } param predicate private requireConnection ( predicate = alwaysTrue ) { return ( findAndPushToEnd ( this . connections , predicate ) || this . newConnection ( ) ) ; } } function maxJoinedChannels ( maxChannelCount ) { return ( conn ) => conn . wantedChannels . size < maxChannelCount ; } function mustNotBeJoined ( channelName ) { return ( conn ) => ! conn . wantedChannels . has ( channelName ) && ! conn . joinedChannels . has ( channelName ) ; }