const _global = typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : {}; const _WebSocket = _global.WebSocket || require('ws'); const _fetch = _global.fetch || require('node-fetch'); const api = require('./api'); const commands = require('./commands'); const EventEmitter = require('./events').EventEmitter; const logger = require('./logger'); const parse = require('./parser'); const Queue = require('./timer'); const _ = require('./utils'); let _apiWarned = false; // Client instance.. const client = function client(opts) { if(this instanceof client === false) { return new client(opts); } this.opts = _.get(opts, {}); this.opts.channels = this.opts.channels || []; this.opts.connection = this.opts.connection || {}; this.opts.identity = this.opts.identity || {}; this.opts.options = this.opts.options || {}; this.clientId = _.get(this.opts.options.clientId, null); this._globalDefaultChannel = _.channel(_.get(this.opts.options.globalDefaultChannel, '#tmijs')); this._skipMembership = _.get(this.opts.options.skipMembership, false); this._skipUpdatingEmotesets = _.get(this.opts.options.skipUpdatingEmotesets, false); this._updateEmotesetsTimer = null; this._updateEmotesetsTimerDelay = _.get(this.opts.options.updateEmotesetsTimer, 60000); this.maxReconnectAttempts = _.get(this.opts.connection.maxReconnectAttempts, Infinity); this.maxReconnectInterval = _.get(this.opts.connection.maxReconnectInterval, 30000); this.reconnect = _.get(this.opts.connection.reconnect, true); this.reconnectDecay = _.get(this.opts.connection.reconnectDecay, 1.5); this.reconnectInterval = _.get(this.opts.connection.reconnectInterval, 1000); this.reconnecting = false; this.reconnections = 0; this.reconnectTimer = this.reconnectInterval; this.secure = _.get( this.opts.connection.secure, !this.opts.connection.server && !this.opts.connection.port ); // Raw data and object for emote-sets.. this.emotes = ''; this.emotesets = {}; this.channels = []; this.currentLatency = 0; this.globaluserstate = {}; this.lastJoined = ''; this.latency = new Date(); this.moderators = {}; this.pingLoop = null; this.pingTimeout = null; this.reason = ''; this.username = ''; this.userstate = {}; this.wasCloseCalled = false; this.ws = null; // Create the logger.. let level = 'error'; if(this.opts.options.debug) { level = 'info'; } this.log = this.opts.logger || logger; try { logger.setLevel(level); } catch(err) {} // Format the channel names.. this.opts.channels.forEach((part, index, theArray) => theArray[index] = _.channel(part) ); EventEmitter.call(this); this.setMaxListeners(0); }; _.inherits(client, EventEmitter); // Put all commands in prototype.. for(const methodName in commands) { client.prototype[methodName] = commands[methodName]; } // Emit multiple events.. client.prototype.emits = function emits(types, values) { for(let i = 0; i < types.length; i++) { const val = i < values.length ? values[i] : values[values.length - 1]; this.emit.apply(this, [ types[i] ].concat(val)); } }; /** @deprecated */ client.prototype.api = function(...args) { if(!_apiWarned) { this.log.warn('Client.prototype.api is deprecated and will be removed for version 2.0.0'); _apiWarned = true; } api(...args); }; // Handle parsed chat server message.. client.prototype.handleMessage = function handleMessage(message) { if(!message) { return; } if(this.listenerCount('raw_message')) { this.emit('raw_message', JSON.parse(JSON.stringify(message)), message); } const channel = _.channel(_.get(message.params[0], null)); let msg = _.get(message.params[1], null); const msgid = _.get(message.tags['msg-id'], null); // Parse badges, badge-info and emotes.. const tags = message.tags = parse.badges(parse.badgeInfo(parse.emotes(message.tags))); // Transform IRCv3 tags.. for(const key in tags) { if(key === 'emote-sets' || key === 'ban-duration' || key === 'bits') { continue; } let value = tags[key]; if(typeof value === 'boolean') { value = null; } else if(value === '1') { value = true; } else if(value === '0') { value = false; } else if(typeof value === 'string') { value = _.unescapeIRC(value); } tags[key] = value; } // Messages with no prefix.. if(message.prefix === null) { switch(message.command) { // Received PING from server.. case 'PING': this.emit('ping'); if(this._isConnected()) { this.ws.send('PONG'); } break; // Received PONG from server, return current latency.. case 'PONG': { const currDate = new Date(); this.currentLatency = (currDate.getTime() - this.latency.getTime()) / 1000; this.emits([ 'pong', '_promisePing' ], [ [ this.currentLatency ] ]); clearTimeout(this.pingTimeout); break; } default: this.log.warn(`Could not parse message with no prefix:\n${JSON.stringify(message, null, 4)}`); break; } } // Messages with "tmi.twitch.tv" as a prefix.. else if(message.prefix === 'tmi.twitch.tv') { switch(message.command) { case '002': case '003': case '004': case '372': case '375': case 'CAP': break; // Retrieve username from server.. case '001': this.username = message.params[0]; break; // Connected to server.. case '376': { this.log.info('Connected to server.'); this.userstate[this._globalDefaultChannel] = {}; this.emits([ 'connected', '_promiseConnect' ], [ [ this.server, this.port ], [ null ] ]); this.reconnections = 0; this.reconnectTimer = this.reconnectInterval; // Set an internal ping timeout check interval.. this.pingLoop = setInterval(() => { // Make sure the connection is opened before sending the message.. if(this._isConnected()) { this.ws.send('PING'); } this.latency = new Date(); this.pingTimeout = setTimeout(() => { if(this.ws !== null) { this.wasCloseCalled = false; this.log.error('Ping timeout.'); this.ws.close(); clearInterval(this.pingLoop); clearTimeout(this.pingTimeout); clearTimeout(this._updateEmotesetsTimer); } }, _.get(this.opts.connection.timeout, 9999)); }, 60000); // Join all the channels from the config with an interval.. let joinInterval = _.get(this.opts.options.joinInterval, 2000); if(joinInterval < 300) { joinInterval = 300; } const joinQueue = new Queue(joinInterval); const joinChannels = [ ...new Set([ ...this.opts.channels, ...this.channels ]) ]; this.channels = []; for(let i = 0; i < joinChannels.length; i++) { const channel = joinChannels[i]; joinQueue.add(() => { if(this._isConnected()) { this.join(channel).catch(err => this.log.error(err)); } }); } joinQueue.next(); break; } // https://github.com/justintv/Twitch-API/blob/master/chat/capabilities.md#notice case 'NOTICE': { const nullArr = [ null ]; const noticeArr = [ channel, msgid, msg ]; const msgidArr = [ msgid ]; const channelTrueArr = [ channel, true ]; const channelFalseArr = [ channel, false ]; const noticeAndNull = [ noticeArr, nullArr ]; const noticeAndMsgid = [ noticeArr, msgidArr ]; const basicLog = `[${channel}] ${msg}`; switch(msgid) { // This room is now in subscribers-only mode. case 'subs_on': this.log.info(`[${channel}] This room is now in subscribers-only mode.`); this.emits([ 'subscriber', 'subscribers', '_promiseSubscribers' ], [ channelTrueArr, channelTrueArr, nullArr ]); break; // This room is no longer in subscribers-only mode. case 'subs_off': this.log.info(`[${channel}] This room is no longer in subscribers-only mode.`); this.emits([ 'subscriber', 'subscribers', '_promiseSubscribersoff' ], [ channelFalseArr, channelFalseArr, nullArr ]); break; // This room is now in emote-only mode. case 'emote_only_on': this.log.info(`[${channel}] This room is now in emote-only mode.`); this.emits([ 'emoteonly', '_promiseEmoteonly' ], [ channelTrueArr, nullArr ]); break; // This room is no longer in emote-only mode. case 'emote_only_off': this.log.info(`[${channel}] This room is no longer in emote-only mode.`); this.emits([ 'emoteonly', '_promiseEmoteonlyoff' ], [ channelFalseArr, nullArr ]); break; // Do not handle slow_on/off here, listen to the ROOMSTATE notice instead as it returns the delay. case 'slow_on': case 'slow_off': break; // Do not handle followers_on/off here, listen to the ROOMSTATE notice instead as it returns the delay. case 'followers_on_zero': case 'followers_on': case 'followers_off': break; // This room is now in r9k mode. case 'r9k_on': this.log.info(`[${channel}] This room is now in r9k mode.`); this.emits([ 'r9kmode', 'r9kbeta', '_promiseR9kbeta' ], [ channelTrueArr, channelTrueArr, nullArr ]); break; // This room is no longer in r9k mode. case 'r9k_off': this.log.info(`[${channel}] This room is no longer in r9k mode.`); this.emits([ 'r9kmode', 'r9kbeta', '_promiseR9kbetaoff' ], [ channelFalseArr, channelFalseArr, nullArr ]); break; // The moderators of this room are: [..., ...] case 'room_mods': { const listSplit = msg.split(': '); const mods = (listSplit.length > 1 ? listSplit[1] : '').toLowerCase() .split(', ') .filter(n => n); this.emits([ '_promiseMods', 'mods' ], [ [ null, mods ], [ channel, mods ] ]); break; } // There are no moderators for this room. case 'no_mods': this.emits([ '_promiseMods', 'mods' ], [ [ null, [] ], [ channel, [] ] ]); break; // The VIPs of this channel are: [..., ...] case 'vips_success': { if(msg.endsWith('.')) { msg = msg.slice(0, -1); } const listSplit = msg.split(': '); const vips = (listSplit.length > 1 ? listSplit[1] : '').toLowerCase() .split(', ') .filter(n => n); this.emits([ '_promiseVips', 'vips' ], [ [ null, vips ], [ channel, vips ] ]); break; } // There are no VIPs for this room. case 'no_vips': this.emits([ '_promiseVips', 'vips' ], [ [ null, [] ], [ channel, [] ] ]); break; // Ban command failed.. case 'already_banned': case 'bad_ban_admin': case 'bad_ban_anon': case 'bad_ban_broadcaster': case 'bad_ban_global_mod': case 'bad_ban_mod': case 'bad_ban_self': case 'bad_ban_staff': case 'usage_ban': this.log.info(basicLog); this.emits([ 'notice', '_promiseBan' ], noticeAndMsgid); break; // Ban command success.. case 'ban_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseBan' ], noticeAndNull); break; // Clear command failed.. case 'usage_clear': this.log.info(basicLog); this.emits([ 'notice', '_promiseClear' ], noticeAndMsgid); break; // Mods command failed.. case 'usage_mods': this.log.info(basicLog); this.emits([ 'notice', '_promiseMods' ], [ noticeArr, [ msgid, [] ] ]); break; // Mod command success.. case 'mod_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseMod' ], noticeAndNull); break; // VIPs command failed.. case 'usage_vips': this.log.info(basicLog); this.emits([ 'notice', '_promiseVips' ], [ noticeArr, [ msgid, [] ] ]); break; // VIP command failed.. case 'usage_vip': case 'bad_vip_grantee_banned': case 'bad_vip_grantee_already_vip': case 'bad_vip_max_vips_reached': case 'bad_vip_achievement_incomplete': this.log.info(basicLog); this.emits([ 'notice', '_promiseVip' ], [ noticeArr, [ msgid, [] ] ]); break; // VIP command success.. case 'vip_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseVip' ], noticeAndNull); break; // Mod command failed.. case 'usage_mod': case 'bad_mod_banned': case 'bad_mod_mod': this.log.info(basicLog); this.emits([ 'notice', '_promiseMod' ], noticeAndMsgid); break; // Unmod command success.. case 'unmod_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseUnmod' ], noticeAndNull); break; // Unvip command success... case 'unvip_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseUnvip' ], noticeAndNull); break; // Unmod command failed.. case 'usage_unmod': case 'bad_unmod_mod': this.log.info(basicLog); this.emits([ 'notice', '_promiseUnmod' ], noticeAndMsgid); break; // Unvip command failed.. case 'usage_unvip': case 'bad_unvip_grantee_not_vip': this.log.info(basicLog); this.emits([ 'notice', '_promiseUnvip' ], noticeAndMsgid); break; // Color command success.. case 'color_changed': this.log.info(basicLog); this.emits([ 'notice', '_promiseColor' ], noticeAndNull); break; // Color command failed.. case 'usage_color': case 'turbo_only_color': this.log.info(basicLog); this.emits([ 'notice', '_promiseColor' ], noticeAndMsgid); break; // Commercial command success.. case 'commercial_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseCommercial' ], noticeAndNull); break; // Commercial command failed.. case 'usage_commercial': case 'bad_commercial_error': this.log.info(basicLog); this.emits([ 'notice', '_promiseCommercial' ], noticeAndMsgid); break; // Host command success.. case 'hosts_remaining': { this.log.info(basicLog); const remainingHost = (!isNaN(msg[0]) ? parseInt(msg[0]) : 0); this.emits([ 'notice', '_promiseHost' ], [ noticeArr, [ null, ~~remainingHost ] ]); break; } // Host command failed.. case 'bad_host_hosting': case 'bad_host_rate_exceeded': case 'bad_host_error': case 'usage_host': this.log.info(basicLog); this.emits([ 'notice', '_promiseHost' ], [ noticeArr, [ msgid, null ] ]); break; // r9kbeta command failed.. case 'already_r9k_on': case 'usage_r9k_on': this.log.info(basicLog); this.emits([ 'notice', '_promiseR9kbeta' ], noticeAndMsgid); break; // r9kbetaoff command failed.. case 'already_r9k_off': case 'usage_r9k_off': this.log.info(basicLog); this.emits([ 'notice', '_promiseR9kbetaoff' ], noticeAndMsgid); break; // Timeout command success.. case 'timeout_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseTimeout' ], noticeAndNull); break; case 'delete_message_success': this.log.info(`[${channel} ${msg}]`); this.emits([ 'notice', '_promiseDeletemessage' ], noticeAndNull); break; // Subscribersoff command failed.. case 'already_subs_off': case 'usage_subs_off': this.log.info(basicLog); this.emits([ 'notice', '_promiseSubscribersoff' ], noticeAndMsgid); break; // Subscribers command failed.. case 'already_subs_on': case 'usage_subs_on': this.log.info(basicLog); this.emits([ 'notice', '_promiseSubscribers' ], noticeAndMsgid); break; // Emoteonlyoff command failed.. case 'already_emote_only_off': case 'usage_emote_only_off': this.log.info(basicLog); this.emits([ 'notice', '_promiseEmoteonlyoff' ], noticeAndMsgid); break; // Emoteonly command failed.. case 'already_emote_only_on': case 'usage_emote_only_on': this.log.info(basicLog); this.emits([ 'notice', '_promiseEmoteonly' ], noticeAndMsgid); break; // Slow command failed.. case 'usage_slow_on': this.log.info(basicLog); this.emits([ 'notice', '_promiseSlow' ], noticeAndMsgid); break; // Slowoff command failed.. case 'usage_slow_off': this.log.info(basicLog); this.emits([ 'notice', '_promiseSlowoff' ], noticeAndMsgid); break; // Timeout command failed.. case 'usage_timeout': case 'bad_timeout_admin': case 'bad_timeout_anon': case 'bad_timeout_broadcaster': case 'bad_timeout_duration': case 'bad_timeout_global_mod': case 'bad_timeout_mod': case 'bad_timeout_self': case 'bad_timeout_staff': this.log.info(basicLog); this.emits([ 'notice', '_promiseTimeout' ], noticeAndMsgid); break; // Unban command success.. // Unban can also be used to cancel an active timeout. case 'untimeout_success': case 'unban_success': this.log.info(basicLog); this.emits([ 'notice', '_promiseUnban' ], noticeAndNull); break; // Unban command failed.. case 'usage_unban': case 'bad_unban_no_ban': this.log.info(basicLog); this.emits([ 'notice', '_promiseUnban' ], noticeAndMsgid); break; // Delete command failed.. case 'usage_delete': case 'bad_delete_message_error': case 'bad_delete_message_broadcaster': case 'bad_delete_message_mod': this.log.info(basicLog); this.emits([ 'notice', '_promiseDeletemessage' ], noticeAndMsgid); break; // Unhost command failed.. case 'usage_unhost': case 'not_hosting': this.log.info(basicLog); this.emits([ 'notice', '_promiseUnhost' ], noticeAndMsgid); break; // Whisper command failed.. case 'whisper_invalid_login': case 'whisper_invalid_self': case 'whisper_limit_per_min': case 'whisper_limit_per_sec': case 'whisper_restricted': case 'whisper_restricted_recipient': this.log.info(basicLog); this.emits([ 'notice', '_promiseWhisper' ], noticeAndMsgid); break; // Permission error.. case 'no_permission': case 'msg_banned': case 'msg_room_not_found': case 'msg_channel_suspended': case 'tos_ban': case 'invalid_user': this.log.info(basicLog); this.emits([ 'notice', '_promiseBan', '_promiseClear', '_promiseUnban', '_promiseTimeout', '_promiseDeletemessage', '_promiseMods', '_promiseMod', '_promiseUnmod', '_promiseVips', '_promiseVip', '_promiseUnvip', '_promiseCommercial', '_promiseHost', '_promiseUnhost', '_promiseJoin', '_promisePart', '_promiseR9kbeta', '_promiseR9kbetaoff', '_promiseSlow', '_promiseSlowoff', '_promiseFollowers', '_promiseFollowersoff', '_promiseSubscribers', '_promiseSubscribersoff', '_promiseEmoteonly', '_promiseEmoteonlyoff', '_promiseWhisper' ], [ noticeArr, [ msgid, channel ] ]); break; // Automod-related.. case 'msg_rejected': case 'msg_rejected_mandatory': this.log.info(basicLog); this.emit('automod', channel, msgid, msg); break; // Unrecognized command.. case 'unrecognized_cmd': this.log.info(basicLog); this.emit('notice', channel, msgid, msg); break; // Send the following msg-ids to the notice event listener.. case 'cmds_available': case 'host_target_went_offline': case 'msg_censored_broadcaster': case 'msg_duplicate': case 'msg_emoteonly': case 'msg_verified_email': case 'msg_ratelimit': case 'msg_subsonly': case 'msg_timedout': case 'msg_bad_characters': case 'msg_channel_blocked': case 'msg_facebook': case 'msg_followersonly': case 'msg_followersonly_followed': case 'msg_followersonly_zero': case 'msg_slowmode': case 'msg_suspended': case 'no_help': case 'usage_disconnect': case 'usage_help': case 'usage_me': case 'unavailable_command': this.log.info(basicLog); this.emit('notice', channel, msgid, msg); break; // Ignore this because we are already listening to HOSTTARGET.. case 'host_on': case 'host_off': break; default: if(msg.includes('Login unsuccessful') || msg.includes('Login authentication failed')) { this.wasCloseCalled = false; this.reconnect = false; this.reason = msg; this.log.error(this.reason); this.ws.close(); } else if(msg.includes('Error logging in') || msg.includes('Improperly formatted auth')) { this.wasCloseCalled = false; this.reconnect = false; this.reason = msg; this.log.error(this.reason); this.ws.close(); } else if(msg.includes('Invalid NICK')) { this.wasCloseCalled = false; this.reconnect = false; this.reason = 'Invalid NICK.'; this.log.error(this.reason); this.ws.close(); } else { this.log.warn(`Could not parse NOTICE from tmi.twitch.tv:\n${JSON.stringify(message, null, 4)}`); this.emit('notice', channel, msgid, msg); } break; } break; } // Handle subanniversary / resub.. case 'USERNOTICE': { const username = tags['display-name'] || tags['login']; const plan = tags['msg-param-sub-plan'] || ''; const planName = _.unescapeIRC(_.get(tags['msg-param-sub-plan-name'], '')) || null; const prime = plan.includes('Prime'); const methods = { prime, plan, planName }; const streakMonths = ~~(tags['msg-param-streak-months'] || 0); const recipient = tags['msg-param-recipient-display-name'] || tags['msg-param-recipient-user-name']; const giftSubCount = ~~tags['msg-param-mass-gift-count']; tags['message-type'] = msgid; switch(msgid) { // Handle resub case 'resub': this.emits([ 'resub', 'subanniversary' ], [ [ channel, username, streakMonths, msg, tags, methods ] ]); break; // Handle sub case 'sub': this.emits([ 'subscription', 'sub' ], [ [ channel, username, methods, msg, tags ] ]); break; // Handle gift sub case 'subgift': this.emit('subgift', channel, username, streakMonths, recipient, methods, tags); break; // Handle anonymous gift sub // Need proof that this event occur case 'anonsubgift': this.emit('anonsubgift', channel, streakMonths, recipient, methods, tags); break; // Handle random gift subs case 'submysterygift': this.emit('submysterygift', channel, username, giftSubCount, methods, tags); break; // Handle anonymous random gift subs // Need proof that this event occur case 'anonsubmysterygift': this.emit('anonsubmysterygift', channel, giftSubCount, methods, tags); break; // Handle user upgrading from Prime to a normal tier sub case 'primepaidupgrade': this.emit('primepaidupgrade', channel, username, methods, tags); break; // Handle user upgrading from a gifted sub case 'giftpaidupgrade': { const sender = tags['msg-param-sender-name'] || tags['msg-param-sender-login']; this.emit('giftpaidupgrade', channel, username, sender, tags); break; } // Handle user upgrading from an anonymous gifted sub case 'anongiftpaidupgrade': this.emit('anongiftpaidupgrade', channel, username, tags); break; // Handle raid case 'raid': { const username = tags['msg-param-displayName'] || tags['msg-param-login']; const viewers = +tags['msg-param-viewerCount']; this.emit('raided', channel, username, viewers, tags); break; } // Handle ritual case 'ritual': { const ritualName = tags['msg-param-ritual-name']; switch(ritualName) { // Handle new chatter ritual case 'new_chatter': this.emit('newchatter', channel, username, tags, msg); break; // All unknown rituals should be passed through default: this.emit('ritual', ritualName, channel, username, tags, msg); break; } break; } // All other msgid events should be emitted under a usernotice event // until it comes up and needs to be added.. default: this.emit('usernotice', msgid, channel, tags, msg); break; } break; } // Channel is now hosting another channel or exited host mode.. case 'HOSTTARGET': { const msgSplit = msg.split(' '); const viewers = ~~msgSplit[1] || 0; // Stopped hosting.. if(msgSplit[0] === '-') { this.log.info(`[${channel}] Exited host mode.`); this.emits([ 'unhost', '_promiseUnhost' ], [ [ channel, viewers ], [ null ] ]); } // Now hosting.. else { this.log.info(`[${channel}] Now hosting ${msgSplit[0]} for ${viewers} viewer(s).`); this.emit('hosting', channel, msgSplit[0], viewers); } break; } // Someone has been timed out or chat has been cleared by a moderator.. case 'CLEARCHAT': // User has been banned / timed out by a moderator.. if(message.params.length > 1) { // Duration returns null if it's a ban, otherwise it's a timeout.. const duration = _.get(message.tags['ban-duration'], null); if(duration === null) { this.log.info(`[${channel}] ${msg} has been banned.`); this.emit('ban', channel, msg, null, message.tags); } else { this.log.info(`[${channel}] ${msg} has been timed out for ${duration} seconds.`); this.emit('timeout', channel, msg, null, ~~duration, message.tags); } } // Chat was cleared by a moderator.. else { this.log.info(`[${channel}] Chat was cleared by a moderator.`); this.emits([ 'clearchat', '_promiseClear' ], [ [ channel ], [ null ] ]); } break; // Someone's message has been deleted case 'CLEARMSG': if(message.params.length > 1) { const deletedMessage = msg; const username = tags['login']; tags['message-type'] = 'messagedeleted'; this.log.info(`[${channel}] ${username}'s message has been deleted.`); this.emit('messagedeleted', channel, username, deletedMessage, tags); } break; // Received a reconnection request from the server.. case 'RECONNECT': this.log.info('Received RECONNECT request from Twitch..'); this.log.info(`Disconnecting and reconnecting in ${Math.round(this.reconnectTimer / 1000)} seconds..`); this.disconnect().catch(err => this.log.error(err)); setTimeout(() => this.connect().catch(err => this.log.error(err)), this.reconnectTimer); break; // Received when joining a channel and every time you send a PRIVMSG to a channel. case 'USERSTATE': message.tags.username = this.username; // Add the client to the moderators of this room.. if(message.tags['user-type'] === 'mod') { if(!this.moderators[channel]) { this.moderators[channel] = []; } if(!this.moderators[channel].includes(this.username)) { this.moderators[channel].push(this.username); } } // Logged in and username doesn't start with justinfan.. if(!_.isJustinfan(this.getUsername()) && !this.userstate[channel]) { this.userstate[channel] = tags; this.lastJoined = channel; this.channels.push(channel); this.log.info(`Joined ${channel}`); this.emit('join', channel, _.username(this.getUsername()), true); } // Emote-sets has changed, update it.. if(message.tags['emote-sets'] !== this.emotes) { this._updateEmoteset(message.tags['emote-sets']); } this.userstate[channel] = tags; break; // Describe non-channel-specific state informations.. case 'GLOBALUSERSTATE': this.globaluserstate = tags; this.emit('globaluserstate', tags); // Received emote-sets.. if(typeof message.tags['emote-sets'] !== 'undefined') { this._updateEmoteset(message.tags['emote-sets']); } break; // Received when joining a channel and every time one of the chat room settings, like slow mode, change. // The message on join contains all room settings. case 'ROOMSTATE': // We use this notice to know if we successfully joined a channel.. if(_.channel(this.lastJoined) === channel) { this.emit('_promiseJoin', null, channel); } // Provide the channel name in the tags before emitting it.. message.tags.channel = channel; this.emit('roomstate', channel, message.tags); if(!_.hasOwn(message.tags, 'subs-only')) { // Handle slow mode here instead of the slow_on/off notice.. // This room is now in slow mode. You may send messages every slow_duration seconds. if(_.hasOwn(message.tags, 'slow')) { if(typeof message.tags.slow === 'boolean' && !message.tags.slow) { const disabled = [ channel, false, 0 ]; this.log.info(`[${channel}] This room is no longer in slow mode.`); this.emits([ 'slow', 'slowmode', '_promiseSlowoff' ], [ disabled, disabled, [ null ] ]); } else { const seconds = ~~message.tags.slow; const enabled = [ channel, true, seconds ]; this.log.info(`[${channel}] This room is now in slow mode.`); this.emits([ 'slow', 'slowmode', '_promiseSlow' ], [ enabled, enabled, [ null ] ]); } } // Handle followers only mode here instead of the followers_on/off notice.. // This room is now in follower-only mode. // This room is now in followers-only mode. // This room is no longer in followers-only mode. // duration is in minutes (string) // -1 when /followersoff (string) // false when /followers with no duration (boolean) if(_.hasOwn(message.tags, 'followers-only')) { if(message.tags['followers-only'] === '-1') { const disabled = [ channel, false, 0 ]; this.log.info(`[${channel}] This room is no longer in followers-only mode.`); this.emits([ 'followersonly', 'followersmode', '_promiseFollowersoff' ], [ disabled, disabled, [ null ] ]); } else { const minutes = ~~message.tags['followers-only']; const enabled = [ channel, true, minutes ]; this.log.info(`[${channel}] This room is now in follower-only mode.`); this.emits([ 'followersonly', 'followersmode', '_promiseFollowers' ], [ enabled, enabled, [ null ] ]); } } } break; // Wrong cluster.. case 'SERVERCHANGE': break; default: this.log.warn(`Could not parse message from tmi.twitch.tv:\n${JSON.stringify(message, null, 4)}`); break; } } // Messages from jtv.. else if(message.prefix === 'jtv') { switch(message.command) { case 'MODE': if(msg === '+o') { // Add username to the moderators.. if(!this.moderators[channel]) { this.moderators[channel] = []; } if(!this.moderators[channel].includes(message.params[2])) { this.moderators[channel].push(message.params[2]); } this.emit('mod', channel, message.params[2]); } else if(msg === '-o') { // Remove username from the moderators.. if(!this.moderators[channel]) { this.moderators[channel] = []; } this.moderators[channel].filter(value => value !== message.params[2]); this.emit('unmod', channel, message.params[2]); } break; default: this.log.warn(`Could not parse message from jtv:\n${JSON.stringify(message, null, 4)}`); break; } } // Anything else.. else { switch(message.command) { case '353': this.emit('names', message.params[2], message.params[3].split(' ')); break; case '366': break; // Someone has joined the channel.. case 'JOIN': { const nick = message.prefix.split('!')[0]; // Joined a channel as a justinfan (anonymous) user.. if(_.isJustinfan(this.getUsername()) && this.username === nick) { this.lastJoined = channel; this.channels.push(channel); this.log.info(`Joined ${channel}`); this.emit('join', channel, nick, true); } // Someone else joined the channel, just emit the join event.. if(this.username !== nick) { this.emit('join', channel, nick, false); } break; } // Someone has left the channel.. case 'PART': { let isSelf = false; const nick = message.prefix.split('!')[0]; // Client left a channel.. if(this.username === nick) { isSelf = true; if(this.userstate[channel]) { delete this.userstate[channel]; } let index = this.channels.indexOf(channel); if(index !== -1) { this.channels.splice(index, 1); } index = this.opts.channels.indexOf(channel); if(index !== -1) { this.opts.channels.splice(index, 1); } this.log.info(`Left ${channel}`); this.emit('_promisePart', null); } // Client or someone else left the channel, emit the part event.. this.emit('part', channel, nick, isSelf); break; } // Received a whisper.. case 'WHISPER': { const nick = message.prefix.split('!')[0]; this.log.info(`[WHISPER] <${nick}>: ${msg}`); // Update the tags to provide the username.. if(!_.hasOwn(message.tags, 'username')) { message.tags.username = nick; } message.tags['message-type'] = 'whisper'; const from = _.channel(message.tags.username); // Emit for both, whisper and message.. this.emits([ 'whisper', 'message' ], [ [ from, message.tags, msg, false ] ]); break; } case 'PRIVMSG': // Add username (lowercase) to the tags.. message.tags.username = message.prefix.split('!')[0]; // Message from JTV.. if(message.tags.username === 'jtv') { const name = _.username(msg.split(' ')[0]); const autohost = msg.includes('auto'); // Someone is hosting the channel and the message contains how many viewers.. if(msg.includes('hosting you for')) { const count = _.extractNumber(msg); this.emit('hosted', channel, name, count, autohost); } // Some is hosting the channel, but no viewer(s) count provided in the message.. else if(msg.includes('hosting you')) { this.emit('hosted', channel, name, 0, autohost); } } else { const messagesLogLevel = _.get(this.opts.options.messagesLogLevel, 'info'); // Message is an action (/me ).. const actionMessage = _.actionMessage(msg); message.tags['message-type'] = actionMessage ? 'action' : 'chat'; msg = actionMessage ? actionMessage[1] : msg; // Check for Bits prior to actions message if(_.hasOwn(message.tags, 'bits')) { this.emit('cheer', channel, message.tags, msg); } else { //Handle Channel Point Redemptions (Require's Text Input) if(_.hasOwn(message.tags, 'msg-id')) { if(message.tags['msg-id'] === 'highlighted-message') { const rewardtype = message.tags['msg-id']; this.emit('redeem', channel, message.tags.username, rewardtype, message.tags, msg); } else if(message.tags['msg-id'] === 'skip-subs-mode-message') { const rewardtype = message.tags['msg-id']; this.emit('redeem', channel, message.tags.username, rewardtype, message.tags, msg); } } else if(_.hasOwn(message.tags, 'custom-reward-id')) { const rewardtype = message.tags['custom-reward-id']; this.emit('redeem', channel, message.tags.username, rewardtype, message.tags, msg); } if(actionMessage) { this.log[messagesLogLevel](`[${channel}] *<${message.tags.username}>: ${msg}`); this.emits([ 'action', 'message' ], [ [ channel, message.tags, msg, false ] ]); } // Message is a regular chat message.. else { this.log[messagesLogLevel](`[${channel}] <${message.tags.username}>: ${msg}`); this.emits([ 'chat', 'message' ], [ [ channel, message.tags, msg, false ] ]); } } } break; default: this.log.warn(`Could not parse message:\n${JSON.stringify(message, null, 4)}`); break; } } }; // Connect to server.. client.prototype.connect = function connect() { return new Promise((resolve, reject) => { this.server = _.get(this.opts.connection.server, 'irc-ws.chat.twitch.tv'); this.port = _.get(this.opts.connection.port, 80); // Override port if using a secure connection.. if(this.secure) { this.port = 443; } if(this.port === 443) { this.secure = true; } this.reconnectTimer = this.reconnectTimer * this.reconnectDecay; if(this.reconnectTimer >= this.maxReconnectInterval) { this.reconnectTimer = this.maxReconnectInterval; } // Connect to server from configuration.. this._openConnection(); this.once('_promiseConnect', err => { if(!err) { resolve([ this.server, ~~this.port ]); } else { reject(err); } }); }); }; // Open a connection.. client.prototype._openConnection = function _openConnection() { const url = `${this.secure ? 'wss' : 'ws'}://${this.server}:${this.port}/`; /** @type {import('ws').ClientOptions} */ const connectionOptions = {}; if('agent' in this.opts.connection) { connectionOptions.agent = this.opts.connection.agent; } this.ws = new _WebSocket(url, 'irc', connectionOptions); this.ws.onmessage = this._onMessage.bind(this); this.ws.onerror = this._onError.bind(this); this.ws.onclose = this._onClose.bind(this); this.ws.onopen = this._onOpen.bind(this); }; // Called when the WebSocket connection's readyState changes to OPEN. // Indicates that the connection is ready to send and receive data.. client.prototype._onOpen = function _onOpen() { if(!this._isConnected()) { return; } // Emitting "connecting" event.. this.log.info(`Connecting to ${this.server} on port ${this.port}..`); this.emit('connecting', this.server, ~~this.port); this.username = _.get(this.opts.identity.username, _.justinfan()); this._getToken() .then(token => { const password = _.password(token); // Emitting "logon" event.. this.log.info('Sending authentication to server..'); this.emit('logon'); let caps = 'twitch.tv/tags twitch.tv/commands'; if(!this._skipMembership) { caps += ' twitch.tv/membership'; } this.ws.send('CAP REQ :' + caps); // Authentication.. if(password) { this.ws.send(`PASS ${password}`); } else if(_.isJustinfan(this.username)) { this.ws.send('PASS SCHMOOPIIE'); } this.ws.send(`NICK ${this.username}`); }) .catch(err => { this.emits([ '_promiseConnect', 'disconnected' ], [ [ err ], [ 'Could not get a token.' ] ]); }); }; // Fetches a token from the option. client.prototype._getToken = function _getToken() { const passwordOption = this.opts.identity.password; let password; if(typeof passwordOption === 'function') { password = passwordOption(); if(password instanceof Promise) { return password; } return Promise.resolve(password); } return Promise.resolve(passwordOption); }; // Called when a message is received from the server.. client.prototype._onMessage = function _onMessage(event) { const parts = event.data.trim().split('\r\n'); parts.forEach(str => { const msg = parse.msg(str); if(msg) { this.handleMessage(msg); } }); }; // Called when an error occurs.. client.prototype._onError = function _onError() { this.moderators = {}; this.userstate = {}; this.globaluserstate = {}; // Stop the internal ping timeout check interval.. clearInterval(this.pingLoop); clearTimeout(this.pingTimeout); clearTimeout(this._updateEmotesetsTimer); this.reason = this.ws === null ? 'Connection closed.' : 'Unable to connect.'; this.emits([ '_promiseConnect', 'disconnected' ], [ [ this.reason ] ]); // Reconnect to server.. if(this.reconnect && this.reconnections === this.maxReconnectAttempts) { this.emit('maxreconnect'); this.log.error('Maximum reconnection attempts reached.'); } if(this.reconnect && !this.reconnecting && this.reconnections <= this.maxReconnectAttempts - 1) { this.reconnecting = true; this.reconnections = this.reconnections + 1; this.log.error(`Reconnecting in ${Math.round(this.reconnectTimer / 1000)} seconds..`); this.emit('reconnect'); setTimeout(() => { this.reconnecting = false; this.connect().catch(err => this.log.error(err)); }, this.reconnectTimer); } this.ws = null; }; // Called when the WebSocket connection's readyState changes to CLOSED.. client.prototype._onClose = function _onClose() { this.moderators = {}; this.userstate = {}; this.globaluserstate = {}; // Stop the internal ping timeout check interval.. clearInterval(this.pingLoop); clearTimeout(this.pingTimeout); clearTimeout(this._updateEmotesetsTimer); // User called .disconnect(), don't try to reconnect. if(this.wasCloseCalled) { this.wasCloseCalled = false; this.reason = 'Connection closed.'; this.log.info(this.reason); this.emits([ '_promiseConnect', '_promiseDisconnect', 'disconnected' ], [ [ this.reason ], [ null ], [ this.reason ] ]); } // Got disconnected from server.. else { this.emits([ '_promiseConnect', 'disconnected' ], [ [ this.reason ] ]); // Reconnect to server.. if(this.reconnect && this.reconnections === this.maxReconnectAttempts) { this.emit('maxreconnect'); this.log.error('Maximum reconnection attempts reached.'); } if(this.reconnect && !this.reconnecting && this.reconnections <= this.maxReconnectAttempts - 1) { this.reconnecting = true; this.reconnections = this.reconnections + 1; this.log.error(`Could not connect to server. Reconnecting in ${Math.round(this.reconnectTimer / 1000)} seconds..`); this.emit('reconnect'); setTimeout(() => { this.reconnecting = false; this.connect().catch(err => this.log.error(err)); }, this.reconnectTimer); } } this.ws = null; }; // Minimum of 600ms for command promises, if current latency exceeds, add 100ms to it to make sure it doesn't get timed out.. client.prototype._getPromiseDelay = function _getPromiseDelay() { if(this.currentLatency <= 600) { return 600; } else { return this.currentLatency + 100; } }; // Send command to server or channel.. client.prototype._sendCommand = function _sendCommand(delay, channel, command, fn) { // Race promise against delay.. return new Promise((resolve, reject) => { // Make sure the socket is opened.. if(!this._isConnected()) { // Disconnected from server.. return reject('Not connected to server.'); } else if(delay === null || typeof delay === 'number') { if(delay === null) { delay = this._getPromiseDelay(); } _.promiseDelay(delay).then(() => reject('No response from Twitch.')); } // Executing a command on a channel.. if(channel !== null) { const chan = _.channel(channel); this.log.info(`[${chan}] Executing command: ${command}`); this.ws.send(`PRIVMSG ${chan} :${command}`); } // Executing a raw command.. else { this.log.info(`Executing command: ${command}`); this.ws.send(command); } if(typeof fn === 'function') { fn(resolve, reject); } else { resolve(); } }); }; // Send a message to channel.. client.prototype._sendMessage = function _sendMessage(delay, channel, message, fn) { // Promise a result.. return new Promise((resolve, reject) => { // Make sure the socket is opened and not logged in as a justinfan user.. if(!this._isConnected()) { return reject('Not connected to server.'); } else if(_.isJustinfan(this.getUsername())) { return reject('Cannot send anonymous messages.'); } const chan = _.channel(channel); if(!this.userstate[chan]) { this.userstate[chan] = {}; } // Split long lines otherwise they will be eaten by the server.. if(message.length >= 500) { const msg = _.splitLine(message, 500); message = msg[0]; setTimeout(() => { this._sendMessage(delay, channel, msg[1], () => {}); }, 350); } this.ws.send(`PRIVMSG ${chan} :${message}`); const emotes = {}; // Parse regex and string emotes.. Object.keys(this.emotesets).forEach(id => this.emotesets[id].forEach(emote => { const emoteFunc = _.isRegex(emote.code) ? parse.emoteRegex : parse.emoteString; return emoteFunc(message, emote.code, emote.id, emotes); }) ); // Merge userstate with parsed emotes.. const userstate = Object.assign( this.userstate[chan], parse.emotes({ emotes: parse.transformEmotes(emotes) || null }) ); const messagesLogLevel = _.get(this.opts.options.messagesLogLevel, 'info'); // Message is an action (/me ).. const actionMessage = _.actionMessage(message); if(actionMessage) { userstate['message-type'] = 'action'; this.log[messagesLogLevel](`[${chan}] *<${this.getUsername()}>: ${actionMessage[1]}`); this.emits([ 'action', 'message' ], [ [ chan, userstate, actionMessage[1], true ] ]); } // Message is a regular chat message.. else { userstate['message-type'] = 'chat'; this.log[messagesLogLevel](`[${chan}] <${this.getUsername()}>: ${message}`); this.emits([ 'chat', 'message' ], [ [ chan, userstate, message, true ] ]); } if(typeof fn === 'function') { fn(resolve, reject); } else { resolve(); } }); }; // Grab the emote-sets object from the API.. client.prototype._updateEmoteset = function _updateEmoteset(sets) { let setsChanges = sets !== undefined; if(setsChanges) { if(sets === this.emotes) { setsChanges = false; } else { this.emotes = sets; } } if(this._skipUpdatingEmotesets) { if(setsChanges) { this.emit('emotesets', sets, {}); } return; } const setEmotesetTimer = () => { if(this._updateEmotesetsTimerDelay > 0) { clearTimeout(this._updateEmotesetsTimer); this._updateEmotesetsTimer = setTimeout(() => this._updateEmoteset(sets), this._updateEmotesetsTimerDelay); } }; this._getToken() .then(token => { const url = `https://api.twitch.tv/kraken/chat/emoticon_images?emotesets=${sets}`; /** @type {import('node-fetch').RequestInit} */ const fetchOptions = {}; if('fetchAgent' in this.opts.connection) { fetchOptions.agent = this.opts.connection.fetchAgent; } /** @type {import('node-fetch').Response} */ return _fetch(url, { ...fetchOptions, headers: { 'Accept': 'application/vnd.twitchtv.v5+json', 'Authorization': `OAuth ${_.token(token)}`, 'Client-ID': this.clientId } }); }) .then(res => res.json()) .then(data => { this.emotesets = data.emoticon_sets || {}; this.emit('emotesets', sets, this.emotesets); setEmotesetTimer(); }) .catch(() => setEmotesetTimer()); }; // Get current username.. client.prototype.getUsername = function getUsername() { return this.username; }; // Get current options.. client.prototype.getOptions = function getOptions() { return this.opts; }; // Get current channels.. client.prototype.getChannels = function getChannels() { return this.channels; }; // Check if username is a moderator on a channel.. client.prototype.isMod = function isMod(channel, username) { const chan = _.channel(channel); if(!this.moderators[chan]) { this.moderators[chan] = []; } return this.moderators[chan].includes(_.username(username)); }; // Get readyState.. client.prototype.readyState = function readyState() { if(this.ws === null) { return 'CLOSED'; } return [ 'CONNECTING', 'OPEN', 'CLOSING', 'CLOSED' ][this.ws.readyState]; }; // Determine if the client has a WebSocket and it's open.. client.prototype._isConnected = function _isConnected() { return this.ws !== null && this.ws.readyState === 1; }; // Disconnect from server.. client.prototype.disconnect = function disconnect() { return new Promise((resolve, reject) => { if(this.ws !== null && this.ws.readyState !== 3) { this.wasCloseCalled = true; this.log.info('Disconnecting from server..'); this.ws.close(); this.once('_promiseDisconnect', () => resolve([ this.server, ~~this.port ])); } else { this.log.error('Cannot disconnect from server. Socket is not opened or connection is already closing.'); reject('Cannot disconnect from server. Socket is not opened or connection is already closing.'); } }); }; client.prototype.off = client.prototype.removeListener; // Expose everything, for browser and Node.. if(typeof module !== 'undefined' && module.exports) { module.exports = client; } if(typeof window !== 'undefined') { window.tmi = { client, Client: client }; }