clank.io
Version:
Twitch Chat Bot
1,147 lines (982 loc) • 56.2 kB
JavaScript
var api = require("./api");
var commands = require("./commands");
var eventEmitter = require("./events").EventEmitter;
var logger = require("./logger");
var parse = require("./parser");
var timer = require("./timer");
var ws = global.WebSocket || global.MozWebSocket || require("ws");
var _ = require("./utils");
// Client instance..
var client = function client(opts) {
if (this instanceof client === false) { return new client(opts); }
this.setMaxListeners(0);
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.maxReconnectAttempts = _.get(this.opts.connection.maxReconnectAttempts, Infinity);
this.maxReconnectInterval = _.get(this.opts.connection.maxReconnectInterval, 30000);
this.reconnect = _.get(this.opts.connection.reconnect, false);
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, false);
// 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..
var level = "error";
if (this.opts.options.debug) { level = "info"; }
this.log = this.opts.logger || logger;
try { logger.setLevel(level); } catch(e) {};
// Format the channel names..
this.opts.channels.forEach(function(part, index, theArray) {
theArray[index] = _.channel(part);
});
eventEmitter.call(this);
}
_.inherits(client, eventEmitter);
client.prototype.api = api;
// Put all commands in prototype..
for(var methodName in commands) {
client.prototype[methodName] = commands[methodName];
}
// Handle parsed chat server message..
client.prototype.handleMessage = function handleMessage(message) {
if (_.isNull(message)) {
return;
}
this.emit("raw_message", JSON.parse(JSON.stringify(message)), message);
var channel = _.channel(_.get(message.params[0], null));
var msg = _.get(message.params[1], null);
var msgid = _.get(message.tags["msg-id"], null);
// Parse badges, badge-info and emotes..
message.tags = parse.badges(parse.badgeInfo(parse.emotes(message.tags)));
// Transform IRCv3 tags..
if (message.tags) {
var tags = message.tags;
for(var key in tags) {
if (key !== "emote-sets" && key !== "ban-duration" && key !== "bits") {
var value = tags[key];
if (_.isBoolean(value)) { value = null; }
else if (value === "1") { value = true; }
else if (value === "0") { value = false; }
else if(_.isString(value)) { value = _.unescapeIRC(value); }
tags[key] = value;
}
}
}
// Messages with no prefix..
if (_.isNull(message.prefix)) {
switch(message.command) {
// Received PING from server..
case "PING":
this.emit("ping");
if (!_.isNull(this.ws) && this.ws.readyState !== 2 && this.ws.readyState !== 3) {
this.ws.send("PONG");
}
break;
// Received PONG from server, return current latency..
case "PONG":
var 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 "375":
case "376":
case "CAP":
break;
// Retrieve username from server..
case "001":
this.username = message.params[0];
break;
// Connected to server..
case "372":
this.log.info("Connected to server.");
this.userstate["#tmijs"] = {};
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 (!_.isNull(this.ws) && this.ws.readyState !== 2 && this.ws.readyState !== 3) {
this.ws.send("PING");
}
this.latency = new Date();
this.pingTimeout = setTimeout(() => {
if (!_.isNull(this.ws)) {
this.wasCloseCalled = false;
this.log.error("Ping timeout.");
this.ws.close();
clearInterval(this.pingLoop);
clearTimeout(this.pingTimeout);
}
}, _.get(this.opts.connection.timeout, 9999));
}, 60000);
// Join all the channels from configuration with a 2 seconds interval..
var joinQueue = new timer.queue(2000);
var joinChannels = _.union(this.opts.channels, this.channels);
this.channels = [];
for (var i = 0; i < joinChannels.length; i++) {
var self = this;
joinQueue.add(function(i) {
if (!_.isNull(self.ws) && self.ws.readyState !== 2 && self.ws.readyState !== 3) {
self.ws.send(`JOIN ${_.channel(joinChannels[i])}`);
}
}.bind(this, i))
}
joinQueue.run();
break;
// https://github.com/justintv/Twitch-API/blob/master/chat/capabilities.md#notice
case "NOTICE":
var nullArr = [null];
var noticeArr = [channel, msgid, msg];
var msgidArr = [msgid];
var channelTrueArr = [channel, true];
var channelFalseArr = [channel, false];
var noticeAndNull = [noticeArr, nullArr];
var noticeAndMsgid = [noticeArr, msgidArr];
var 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":
var mods = msg.split(": ")[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);
}
var vips = msg.split(": ")[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_broadcaster":
case "bad_ban_global_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":
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);
var 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);
// 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_broadcaster":
case "bad_timeout_duration":
case "bad_timeout_global_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_recipient":
this.log.info(basicLog);
this.emits(["notice", "_promiseWhisper"], noticeAndMsgid);
break;
// Permission error..
case "no_permission":
case "msg_banned":
case "msg_room_not_found":
// Channel is suspended..
case "msg_channel_suspended":
case "tos_ban":
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"
], noticeAndMsgid);
break;
// Unrecognized command..
case "unrecognized_cmd":
this.log.info(basicLog);
this.emit("notice", channel, msgid, msg);
if (msg.split(" ").splice(-1)[0] === "/w") {
this.log.warn("You must be connected to a group server to send or receive whispers.");
}
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_rejected":
case "msg_slowmode":
case "msg_suspended":
case "no_help":
case "usage_disconnect":
case "usage_help":
case "usage_me":
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)}`);
}
break;
}
break;
// Handle subanniversary / resub..
case "USERNOTICE":
var username = message.tags["display-name"] || message.tags["login"];
var plan = message.tags["msg-param-sub-plan"] || "";
var planName = _.unescapeIRC(_.get(message.tags["msg-param-sub-plan-name"], "")) || null;
var prime = plan.includes("Prime");
var methods = { prime, plan, planName };
var userstate = message.tags;
var streakMonths = ~~(message.tags["msg-param-streak-months"] || 0);
var recipient = message.tags["msg-param-recipient-display-name"] || message.tags["msg-param-recipient-user-name"];
var giftSubCount = ~~message.tags["msg-param-mass-gift-count"];
userstate["message-type"] = msgid;
switch(msgid) {
// Handle resub
case "resub":
this.emits(["resub", "subanniversary"], [
[channel, username, streakMonths, msg, userstate, methods]
]);
break;
// Handle sub
case "sub":
this.emit("subscription", channel, username, methods, msg, userstate);
break;
// Handle gift sub
case "subgift":
this.emit("subgift", channel, username, streakMonths, recipient, methods, userstate);
break;
// Handle anonymous gift sub
// Need proof that this event occur
case "anonsubgift":
this.emit("anonsubgift", channel, streakMonths, recipient, methods, userstate);
break;
// Handle random gift subs
case "submysterygift":
this.emit("submysterygift", channel, username, giftSubCount, methods, userstate);
break;
// Handle anonymous random gift subs
// Need proof that this event occur
case "anonsubmysterygift":
this.emit("anonsubmysterygift", channel, giftSubCount, methods, userstate);
break;
// Handle user upgrading from Prime to a normal tier sub
case "primepaidupgrade":
this.emit("primepaidupgrade", channel, username, methods, userstate);
break;
// Handle user upgrading from a gifted sub
case "giftpaidupgrade":
var sender = message.tags["msg-param-sender-name"] || message.tags["msg-param-sender-login"];
this.emit("giftpaidupgrade", channel, username, sender, userstate);
break;
// Handle user upgrading from an anonymous gifted sub
case "anongiftpaidupgrade":
this.emit("anongiftpaidupgrade", channel, username, userstate);
break;
// Handle raid
case "raid":
var username = message.tags["msg-param-displayName"] || message.tags["msg-param-login"];
var viewers = message.tags["msg-param-viewerCount"];
this.emit("raided", channel, username, viewers);
break;
}
break;
// Channel is now hosting another channel or exited host mode..
case "HOSTTARGET":
var msgSplit = msg.split(" ");
var 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..
var duration = _.get(message.tags["ban-duration"], null);
if (_.isNull(duration)) {
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) {
var username = message.tags["login"];
var deletedMessage = msg;
var userstate = message.tags;
userstate["message-type"] = "messagedeleted";
this.log.info(`[${channel}] ${username}'s message has been deleted.`);
this.emit("messagedeleted", channel, username, deletedMessage, userstate);
}
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();
setTimeout(() => { this.connect(); }, 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[this.lastJoined]) { this.moderators[this.lastJoined] = []; }
if (!this.moderators[this.lastJoined].includes(this.username)) { this.moderators[this.lastJoined].push(this.username); }
}
// Logged in and username doesn't start with justinfan..
if (!_.isJustinfan(this.getUsername()) && !this.userstate[channel]) {
this.userstate[channel] = message.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] = message.tags;
break;
// Describe non-channel-specific state informations..
case "GLOBALUSERSTATE":
this.globaluserstate = message.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(message.params[0])) { this.emit("_promiseJoin", null); }
// Provide the channel name in the tags before emitting it..
message.tags.channel = _.channel(message.params[0]);
this.emit("roomstate", _.channel(message.params[0]), message.tags);
if (!message.tags.hasOwnProperty("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 (message.tags.hasOwnProperty("slow")) {
if (typeof message.tags.slow === "boolean" && !message.tags.slow) {
var 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 {
var minutes = ~~message.tags.slow;
var enabled = [channel, true, minutes];
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 <duration> 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 (message.tags.hasOwnProperty("followers-only")) {
if (message.tags["followers-only"] === "-1") {
var 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 {
var minutes = ~~message.tags["followers-only"];
var enabled = [channel, true, minutes];
this.log.info(`[${channel}] This room is now in follower-only mode.`);
this.emits(["followersonly", "followersmode", "_promiseFollowers"], [disabled, disabled, [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) => { return 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":
var 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":
var isSelf = false;
var nick = message.prefix.split("!")[0];
// Client left a channel..
if (this.username === nick) {
isSelf = true;
if (this.userstate[channel]) { delete this.userstate[channel]; }
var index = this.channels.indexOf(channel);
if (index !== -1) { this.channels.splice(index, 1); }
var 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":
var nick = message.prefix.split("!")[0];
this.log.info(`[WHISPER] <${nick}>: ${msg}`);
// Update the tags to provide the username..
if (!message.tags.hasOwnProperty("username")) { message.tags.username = nick; }
message.tags["message-type"] = "whisper";
var 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") {
var name = _.username(msg.split(" ")[0]);
var autohost = msg.includes("auto");
// Someone is hosting the channel and the message contains how many viewers..
if (msg.includes("hosting you for")) {
var 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 {
// Message is an action (/me <message>)..
var actionMessage = _.actionMessage(msg);
if (actionMessage) {
message.tags["message-type"] = "action";
this.log.info(`[${channel}] *<${message.tags.username}>: ${actionMessage[1]}`);
this.emits(["action", "message"], [
[channel, message.tags, actionMessage[1], false]
]);
}
else {
if (message.tags.hasOwnProperty("bits")) {
this.emit("cheer", channel, message.tags, msg);
}
// Message is a regular chat message..
else {
message.tags["message-type"] = "chat";
this.log.info(`[${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() {
this.ws = new ws(`${this.secure ? "wss" : "ws"}://${this.server}:${this.port}/`, "irc");
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 (!_.isNull(this.ws) && this.ws.readyState === 1) {
// 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.password = _.password(_.get(this.opts.identity.password, "SCHMOOPIIE"));
// Emitting "logon" event..
this.log.info("Sending authentication to server..");
this.emit("logon");
// Authentication..
this.ws.send("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership");
this.ws.send(`PASS ${this.password}`);
this.ws.send(`NICK ${this.username}`);
}
};
// Called when a message is received from the server..
client.prototype._onMessage = function _onMessage(event) {
var parts = event.data.split("\r\n");
parts.forEach((str) => {
if (!_.isNull(str)) { this.handleMessage(parse.msg(str)); }
});
};
// 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);
this.reason = !_.isNull(this.ws) ? "Unable to connect." : "Connection closed.";
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(); }, 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);
// 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(); }, this.reconnectTimer);
}
}
this.ws