twitch-chat-bot
Version:
an attempt to provide a generic, but highly-configurable platform for developers intending to create Twitch chat bots in Node.js
1,410 lines (1,091 loc) • 32.2 kB
JavaScript
/**
* twitch-chat-bot
*
* Copyright (c) 2020 WildcardSearch
*/
/* internal data */
const {
EVENT_PRIORITY_DEFAULT, EVENT_PRIORITY_HIGH, EVENT_PRIORITY_EXTERNAL_MODULE_MAX,
} = require("../data/event-priority.js");
const {
PERMISSIONS_ALL, PERMISSIONS_VIPS, PERMISSIONS_SUBS, PERMISSIONS_MODS, PERMISSIONS_STREAMER,
} = require("../data/permissions.js");
const {
COOLDOWN_COMMAND_USAGE_GLOBAL,
COOLDOWN_WARNINGS_TILL_BLOCK,
COOLDOWN_WARNINGS_TILL_TIMEOUT,
COOLDOWN_WARNINGS_TILL_BAN,
COOLDOWN_BLOCK_MATRIX,
COOLDOWN_TIMEOUT_MATRIX,
BOT_MOD_PERMISSION_NOTIFY,
} = require("../data/moderation.js");
const {
milliseconds, seconds, minutes,
hours, days, weeks,
months, years, decades, centuries,
} = require("../data/time.js");
const timeMap = require("../data/time.js");
/* internal libraries */
const TwitchChatBotErrorHandler = require("./error-handler.js");
const {
copyObject, checkSub,
} = require("./functions.js");
/* external libraries */
const StreamElements = require("nodejs-streamelements");
const tmi = require("tmi.js");
const Polyglot = require("node-polyglot");
/* service integrations */
const StreamElementsCurrencyIntegration = require("../services/currency/stream-elements.js");
const JSON_DatabaseIntegration = require("../services/database/json-database-integration.js");
const MYSQL_DatabaseIntegration = require("../services/database/mysql-database-integration.js");
/* internal modules */
const TwitchChatBotModule = require("./twitch-chat-bot-module.js");
const BlockList_TwitchChatBotModule = require("../modules/block-list/block-list.js");
const MessageQueue_TwitchChatBotModule = require("../modules/message-queue/message-queue.js");
const Dictionary_TwitchChatBotModule = require("../modules/dictionary/dictionary.js");
const UserTracker_TwitchChatBotModule = require("../modules/user-tracker/user-tracker.js");
const Permissions_TwitchChatBotModule = require("../modules/command-center/permissions.js");
const CommandCenter_TwitchChatBotModule = require("../modules/command-center/command-center.js");
const StreamTimer_TwitchChatBotModule = require("../modules/timer/timer.js");
const Documentation_TwitchChatBotModule = require("../modules/docs/docs.js");
/* Default Values */
const TWITCH_USERNAME_MIN_LENGTH = 4;
const TWITCH_USERNAME_MAX_LENGTH = 25;
const MESSAGING_COOLDOWN = 4*seconds;
const SUB_CHECK_DEFAULT_MAX_ATTEMPTS = 10;
const SUB_CHECK_DEFAULT_RETRY_DELAY = 3*seconds;
const SUB_CHECK_DEFAULT_DECAY = 1.1;
const DELAY_CRASH_MAX = 10*minutes;
const DELAY_ACTIVE_USERS_CHECK = 5000;
const DELAY_ACTIVE_USERS_CHECK_MIN = 2000;
const DELAY_ACTIVE_TIMEOUT = 180000;
const DEFAULT_DISPLAY_NAME = "TwitchChatBot by WildcardSearch";
const VERSION = "0.4.1";
const DEFAULT_INSTANCE_VERSION = "1.0.0";
const TWITCH_BOT_DEFAULT_OPTIONS = {
newStreamMaxDelay: DELAY_CRASH_MAX,
verboseLogin: true,
verboseLogging: true,
forceDebug: false,
currency: {
type: null,
implementation: null,
},
permissions: [],
permissionOverrides: null,
userTracker: {
activeStatusCheckDelay: DELAY_ACTIVE_USERS_CHECK,
activeStatusTimeout: DELAY_ACTIVE_TIMEOUT,
},
timer: {
livetime: null,
},
docs: {
path: '',
},
database: {
type: "JSON",
table: "streams",
path: 'data.json',
},
personalization: {
displayName: DEFAULT_DISPLAY_NAME,
version: DEFAULT_INSTANCE_VERSION,
},
subCheck: {
maxAttempts: SUB_CHECK_DEFAULT_MAX_ATTEMPTS,
delay: SUB_CHECK_DEFAULT_RETRY_DELAY,
decay: SUB_CHECK_DEFAULT_DECAY,
},
blocking: {
blocked: [],
blockBots: true,
blockStreamer: true,
},
moderation: {
globalCooldown: COOLDOWN_COMMAND_USAGE_GLOBAL,
cooldownExemptionLevel: PERMISSIONS_VIPS,
warningsTillBlock: COOLDOWN_WARNINGS_TILL_BLOCK,
warningsTillTimeout: COOLDOWN_WARNINGS_TILL_TIMEOUT,
warningsTillBan: COOLDOWN_WARNINGS_TILL_BAN,
cooldownBlockMatrix: COOLDOWN_BLOCK_MATRIX,
cooldownTimeoutMatrix: COOLDOWN_TIMEOUT_MATRIX,
permissions: BOT_MOD_PERMISSION_NOTIFY,
},
messaging: {
cooldown: MESSAGING_COOLDOWN,
},
};
class TwitchChatBot {
#valid = false;
#connected = false;
#subbed = false;
username = null;
#oauth = null;
#subChecks = 1;
#subCheckWait = SUB_CHECK_DEFAULT_RETRY_DELAY;
#eventList = [];
#eventHandlers = {};
#firedEvents = [];
#singularEvents = [];
#globals = {};
#globalKeys = [];
#internalModuleKeys = [];
#boundClientConnectEvent = this.onClientConnect.bind(this);
#boundSubCheckEvent = this.subCheck.bind(this);
#boundChatEvent = this.onChat.bind(this);
options = {};
displayName = "";
channel = "";
localeList = [];
locale = "en";
client = null;
messaging = null;
blockedList = null;
permissions = null;
commandCenter = null;
timer = null;
db = null;
streamId = null;
streamData = null;
currency = null;
hasCurrencySystem = false;
dictionary = null;
userTracker = null;
docs = null;
errorHandler = new TwitchChatBotErrorHandler(this);
forceDebug = false;
debugList = [];
version = VERSION;
instanceVersion = DEFAULT_INSTANCE_VERSION;
/**
* @param Object
* @param Object
* @return void
*/
constructor(options = {})
{
/* options */
this.options = { ...TWITCH_BOT_DEFAULT_OPTIONS, ...(options || {}) };
/* i18n */
this.localeList = require("../locales/locales.json");
if (typeof this.options.language === "object" &&
typeof this.options.language.locale === "string" &&
this.options.language.locale.length > 1 &&
this.localeList.includes(this.options.language.locale) === true) {
this.locale = this.options.language.locale;
}
this.polyglot = new Polyglot({
locale: this.locale,
phrases: require(`../locales/${this.locale}/core.json`),
});
if (typeof options.credentials !== "object" ||
options.credentials === null) {
this.errorHandler.throwError("ERROR_CONSTRUCTOR_NO_BOT_CREDENTIALS");
return;
}
/* credentials */
if (typeof options.credentials.username !== "string" ||
options.credentials.username.length === 0) {
this.errorHandler.throwError("ERROR_CONSTRUCTOR_NO_USERNAME");
return;
}
if (typeof options.credentials.oauth !== "string" ||
options.credentials.oauth.length === 0) {
this.errorHandler.throwError("ERROR_CONSTRUCTOR_NO_OAUTH");
return;
}
if (typeof options.credentials.channel !== "string" ||
options.credentials.channel.length < TWITCH_USERNAME_MIN_LENGTH ||
options.credentials.channel.length > TWITCH_USERNAME_MAX_LENGTH) {
this.errorHandler.throwError("ERROR_CONSTRUCTOR_NO_CHANNELS");
return;
}
/* store credentials */
this.username = options.credentials.username;
this.#oauth = options.credentials.oauth;
this.channel = options.credentials.channel;
this.#valid = true;
/** personalization and defaults **/
if (typeof this.options.personalization !== "object") {
this.options.personalization = {
displayName: DEFAULT_DISPLAY_NAME,
version: DEFAULT_INSTANCE_VERSION,
};
}
if (typeof this.options.personalization.displayName === "string" &&
this.options.personalization.displayName.length > 0) {
this.displayName = this.options.personalization.displayName.trim();
}
if (typeof this.options.personalization.version === "string" &&
this.options.personalization.version.length > 0) {
this.instanceVersion = this.options.personalization.version.trim();
}
/** moderation **/
if (typeof this.options.moderation !== "object") {
this.options.moderation = {
globalCooldown: COOLDOWN_COMMAND_USAGE_GLOBAL,
cooldownExemptionLevel: PERMISSIONS_VIPS,
warningsTillBlock: COOLDOWN_WARNINGS_TILL_BLOCK,
warningsTillTimeout: COOLDOWN_WARNINGS_TILL_TIMEOUT,
warningsTillBan: COOLDOWN_WARNINGS_TILL_BAN,
cooldownBlockMatrix: COOLDOWN_BLOCK_MATRIX,
cooldownTimeoutMatrix: COOLDOWN_TIMEOUT_MATRIX,
permissions: BOT_MOD_PERMISSION_NOTIFY,
};
}
if (typeof this.options.moderation.globalCooldown !== "number" ||
this.options.moderation.globalCooldown < 0) {
this.options.moderation.globalCooldown = COOLDOWN_COMMAND_USAGE_GLOBAL;
}
if (typeof this.options.moderation.cooldownExemptionLevel !== "number" ||
this.options.moderation.cooldownExemptionLevel < PERMISSIONS_ALL) {
this.options.moderation.cooldownExemptionLevel = PERMISSIONS_VIPS;
}
if (typeof this.options.moderation.warningsTillBlock !== "number" ||
this.options.moderation.warningsTillBlock < 0) {
this.options.moderation.warningsTillBlock = COOLDOWN_WARNINGS_TILL_BLOCK;
}
if (typeof this.options.moderation.warningsTillTimeout !== "number" ||
this.options.moderation.warningsTillTimeout < 0) {
this.options.moderation.warningsTillTimeout = COOLDOWN_WARNINGS_TILL_TIMEOUT;
}
if (typeof this.options.moderation.warningsTillBan !== "number" ||
this.options.moderation.warningsTillBan < 0) {
this.options.moderation.warningsTillBan = COOLDOWN_WARNINGS_TILL_BAN;
}
if (typeof this.options.moderation.permissions !== "number" ||
this.options.moderation.permissions < 0) {
this.options.moderation.permissions = BOT_MOD_PERMISSION_NOTIFY;
}
/** sub check options **/
if (typeof this.options.subCheck !== "object") {
this.options.subCheck = {
delay: SUB_CHECK_DEFAULT_RETRY_DELAY,
decay: SUB_CHECK_DEFAULT_DECAY,
maxAttempts: SUB_CHECK_DEFAULT_MAX_ATTEMPTS,
};
}
if (typeof this.options.subCheck.maxAttempts !== "number" ||
this.options.subCheck.maxAttempts <= 1) {
this.options.subCheck.maxAttempts = SUB_CHECK_DEFAULT_MAX_ATTEMPTS;
}
if (typeof this.options.subCheck.delay !== "number" ||
this.options.subCheck.delay <= 1*seconds) {
this.#subCheckWait = this.options.subCheck.delay = SUB_CHECK_DEFAULT_RETRY_DELAY;
}
if (typeof this.options.subCheck.decay !== "number" ||
this.options.subCheck.decay <= 1) {
this.options.subCheck.decay = SUB_CHECK_DEFAULT_DECAY;
}
/** debugging **/
if (typeof this.options.forceDebug !== "undefined") {
switch (typeof this.options.forceDebug) {
case "boolean":
this.forceDebug = this.options.forceDebug === true;
break;
case "object":
if (Array.isArray(this.options.forceDebug)) {
for (const k of this.options.forceDebug) {
if (typeof k !== "string" ||
k.length === 0) {
continue;
}
this.debugList.push(k);
}
}
break;
case "string":
this.debugList.push(this.options.forceDebug);
break;
}
}
/* currency */
if (typeof this.options.currency !== "object") {
this.options.currency = {
type: null,
implementation: null,
};
}
switch (this.options.currency.type) {
case "se":
case "streamelements":
this.currency = new StreamElementsCurrencyIntegration(this, this.options.currency.implementation);
break;
}
if (typeof this.currency !== null) {
this.hasCurrencySystem = true;
}
/* userTracker */
if (typeof this.options.userTracker !== "object") {
this.options.userTracker = {
activeStatusCheckDelay: DELAY_ACTIVE_USERS_CHECK,
activeStatusTimeout: DELAY_ACTIVE_TIMEOUT,
};
}
if (typeof this.options.userTracker.activeStatusCheckDelay !== "number") {
this.options.userTracker.activeStatusCheckDelay = DELAY_ACTIVE_USERS_CHECK;
}
if (this.options.userTracker.activeStatusCheckDelay < DELAY_ACTIVE_USERS_CHECK_MIN) {
this.options.userTracker.activeStatusCheckDelay = DELAY_ACTIVE_USERS_CHECK_MIN;
}
if (typeof this.options.userTracker.activeStatusTimeout !== "number" ||
this.options.userTracker.activeStatusTimeout <= 0) {
this.options.userTracker.activeStatusTimeout = DELAY_ACTIVE_TIMEOUT;
}
/* messaging */
if (typeof this.options.messaging !== "object") {
this.options.messaging = {
cooldown: MESSAGING_COOLDOWN,
};
}
if (typeof this.options.messaging.cooldown !== "number" ||
this.options.messaging.cooldown < 0) {
this.options.messaging.cooldown = MESSAGING_COOLDOWN;
}
this.registerEvent("initialized", true);
this.registerEvent("ready", true);
this.registerEvent("disconnect");
this.registerEvent("reconnect");
// Database
let dbClass = null;
if (typeof this.options.database.type === "string" &&
this.options.database.type.length > 0) {
switch(this.options.database.type) {
case "MYSQL":
dbClass = MYSQL_DatabaseIntegration;
break;
case "JSON":
default:
dbClass = JSON_DatabaseIntegration;
break;
}
}
if (dbClass === null) {
this.errorHandler.throwError("ERROR_DB_INVALID");
return;
}
this.db = new dbClass(this, this.connectClient.bind(this));
}
/* initialization */
/**
* connect to Twitch IRC servers using tmi.js
*
* @return void
*/
connectClient()
{
this.client = new tmi.client({
options: {
debug: true,
},
connection: {
cluster: "aws",
reconnect: true,
},
identity: {
username: this.username,
password: "oauth:"+this.#oauth,
},
channels: [ this.channel ],
});
this.client.on("connected", this.#boundClientConnectEvent);
this.client.connect();
}
/**
* tmi.js connect event handler
*
* @param String
* @param String
* @return void
*/
onClientConnect(address, port)
{
this.client.off("connected", this.#boundClientConnectEvent);
this.#connected = true;
this.log(`Twitch chat client connected (tmi.js) @ ${address || "NO_ADDRESS"}:${port || "NO_PORT"}`);
this.client.on("chat", this.#boundSubCheckEvent); // bound this.subCheck()
let initMessage = this.polyglot.t("core.initial_sub_test");
if (this.options.verboseLogin === true) {
initMessage = this.polyglot.t("core.init_message", {
"implementation_name": this.displayName,
"implementation_version": this.instanceVersion,
"core_name": DEFAULT_DISPLAY_NAME,
"core_version": this.version,
});
}
this.client.say(this.channel, initMessage);
}
/**
* determine whether the bot is subbed to the connected channel, if possible
*
* @param String
* @param Object
* @param String
* @param Boolean
* @return void
*/
subCheck(channel, userstate, message, self)
{
if (!self) {
return;
}
if (checkSub(userstate) === null) {
if (this.#subChecks >= this.options.subCheck.maxAttempts) {
this.log("Subscription test failed.");
userstate.subscriber = false;
} else {
this.#subCheckWait *= this.options.subCheck.decay;
setTimeout(() => {
this.client.say(
this.channel,
this.polyglot.t("core.sub_test", {
"count": ++this.#subChecks,
},
));
}, this.#subCheckWait);
return;
}
}
this.client.off("chat", this.#boundSubCheckEvent);
this.#subbed = checkSub(userstate) === true;
this.init();
}
/* internal events */
/**
* attach event handlers, register events, register internal modules,
* fire the initialized event, and initialize the database
*
* @return void
*/
init()
{
this.client.on("connected", this.onReconnect.bind(this));
this.client.on("disconnected", this.onDisconnect.bind(this));
this.registerEvent("lostsub");
this.registerEvent("gotsub");
this.registerEvent("chat");
this.registerInternalModule(BlockList_TwitchChatBotModule, "blockedList", EVENT_PRIORITY_HIGH+8);
this.registerInternalModule(MessageQueue_TwitchChatBotModule, "messaging", EVENT_PRIORITY_HIGH+7);
this.registerInternalModule(Permissions_TwitchChatBotModule, "permissions", EVENT_PRIORITY_HIGH+6);
this.registerInternalModule(CommandCenter_TwitchChatBotModule, "commandCenter", EVENT_PRIORITY_HIGH+5);
this.registerInternalModule(StreamTimer_TwitchChatBotModule, "timer", EVENT_PRIORITY_HIGH+4);
this.registerInternalModule(UserTracker_TwitchChatBotModule, "userTracker", EVENT_PRIORITY_HIGH+3);
this.registerInternalModule(Dictionary_TwitchChatBotModule, "dictionary", EVENT_PRIORITY_HIGH+2);
this.registerInternalModule(Documentation_TwitchChatBotModule, "docs", EVENT_PRIORITY_HIGH+1);
this.fireEvent("initialized");
// initialize the DB
this.db.init(this.ready.bind(this));
}
/**
* register ready event, perform command permission overrides,
* and start watching chat
*
* @return void
*/
ready()
{
this.fireEvent("ready");
/* Command Permission Overrides */
if (typeof this.options.permissionOverrides === "object" &&
this.options.permissionOverrides !== null &&
Object.keys(this.options.permissionOverrides).length > 0) {
for (let [k, p] of Object.entries(this.options.permissionOverrides)) {
if (typeof k !== "string" ||
k.length === 0) {
continue;
}
k = k.replace(/^!/, "").trim();
if (k.length === 0 ||
typeof p !== "number" ||
p < 0 ||
this.commandCenter.commandList.includes(k) !== true) {
continue;
}
this.commandCenter.commands[k].permissionLevel = p;
}
}
this.client.on("chat", this.#boundChatEvent);
}
/* modules */
/**
* determine if a module has the correct prototype
*
* @param TwitchChatBotModule
* @return void
*/
validateModule(module)
{
if (module.prototype instanceof TwitchChatBotModule !== true) {
this.errorHandler.throwError("ERROR_MODULE_INVALID_CONSTRUCTOR");
return false;
}
return true;
}
/**
* instance and configure an external TwitchChatBotModule
*
* @param TwitchChatBotModule
* @param Boolean
* @return void
*/
registerModule(module, debugMode = false, priority = EVENT_PRIORITY_DEFAULT)
{
let p = EVENT_PRIORITY_DEFAULT;
if (typeof priority === "number" &&
priority >= 0) {
p = parseInt(priority, 10);
}
if (p > EVENT_PRIORITY_EXTERNAL_MODULE_MAX) {
p = EVENT_PRIORITY_EXTERNAL_MODULE_MAX;
}
if (this.validateModule(module) !== true) {
this.errorHandler.throwError("ERROR_EXTERNAL_MODULE_INVALID");
return;
}
let m = new module(
this,
priority || EVENT_PRIORITY_DEFAULT,
debugMode === true || this.forceDebug === true
);
this.log(`Registered External Module: ${m.id}`);
}
/**
* instance and configure an internal TwitchChatBotModule
*
* @param TwitchChatBotModule
* @param String
* @return void
*/
registerInternalModule(module, key = "", priority = EVENT_PRIORITY_DEFAULT)
{
if (this.validateModule(module) !== true) {
this.errorHandler.throwError("ERROR_INTERNAL_MODULE_INVALID");
return;
}
if (typeof key !== "string" ||
key.length === 0) {
this.errorHandler.throwError("ERROR_INTERNAL_MODULE_NO_KEY");
return;
}
if (key.length < 3) {
this.errorHandler.throwError("ERROR_INTERNAL_MODULE_KEY_LENGTH_BELOW_MINIMUM");
return;
}
if (typeof this[key] === "undefined") {
this.errorHandler.throwError("ERROR_INTERNAL_MODULE_KEY_INVALID");
return;
}
if (this.#internalModuleKeys.includes(key) === true) {
this.errorHandler.throwError("ERROR_INTERNAL_MODULE_DUPLICATE_KEY");
return;
}
this[key] = new module(
this,
priority || EVENT_PRIORITY_DEFAULT,
this.debugList.includes(key) || this.forceDebug === true
);
this.#internalModuleKeys.push(key);
this.log(`Registered Internal Module: ${key}`);
}
/* event handlers */
/**
* do a sub check; compile user data; and call chat events (tmi.js chat event)
*
* @param String
* @param Object
* @param String
* @param Boolean
* @return void
*/
onChat(channel, userstate, message, self)
{
const sender = userstate["display-name"];
if (!sender || !message) {
return;
}
if (self) {
let subCheck = checkSub(userstate);
if (subCheck === null) {
this.log("bad sub check", userstate);
return;
}
if (this.#subbed !== true &&
subCheck) {
this.fireEvent("gotsub");
}
if (this.#subbed === true &&
!subCheck) {
this.fireEvent("lostsub");
}
this.#subbed = subCheck;
return;
}
// vip
userstate.vip = false;
if (typeof userstate !== "undefined" &&
typeof userstate.badges !== "undefined" &&
userstate.badges !== null &&
typeof userstate.badges.vip !== "undefined" &&
userstate.badges.vip === "1") {
userstate.vip = true;
}
this.fireEvent("chat", arguments);
}
/**
* log disconnects (tmi.js disconnected event)
*
* @param String
* @return void
*/
onDisconnect(reason)
{
this.log("Disconnected", reason);
this.#connected = false;
this.client.off("chat", this.#boundChatEvent);
this.fireEvent("disconnect");
}
/**
* log reconnects (tmi.js connected event)
*
* @param String
* @param String
* @return void
*/
onReconnect(address, port)
{
this.log("Reconnected");
this.#connected = true;
this.client.on("chat", this.#boundChatEvent);
if (this.options.verboseLogin === true) {
this.client.say(this.channel, "I'm back!");
}
this.fireEvent("reconnect");
}
/* event management */
/**
* register stream event
*
* @param String
* @param Boolean
* @return void
*/
registerEvent(event, onetimeonly = false)
{
if (typeof event !== "string" ||
event.length === 0) {
this.errorHandler.throwError("ERROR_EVENT_REGISTER_BAD_INFO", { eventinfo: event } );
return;
}
event = event.trim().toLowerCase();
if (this.#eventList.includes(event) === true) {
this.errorHandler.warn("ERROR_EVENT_REGISTER_DUPLICATE", { event: event } );
return;
}
this.#eventList.push(event);
this.#eventHandlers[event] = [];
if (onetimeonly === true) {
this.#singularEvents.push(event);
}
}
/**
* register an event handler
*
* @param String
* @param Function
* @param Number
* @param Boolean
* @return void
*/
on(event = "", handler, priority = EVENT_PRIORITY_DEFAULT, once = false)
{
let handlerName = "anonymous function",
onlyOnce = once === true;
if (typeof event !== "string" ||
event.length === 0) {
this.errorHandler.warn("ERROR_EVENT_ON_BAD_INFO", { args: arguments } );
return this;
}
if (this.#eventList.includes(event) === false) {
this.errorHandler.warn("ERROR_EVENT_ON_INVALID_EVENT", { event: event } );
return this;
}
if (typeof handler !== "function") {
this.errorHandler.warn("ERROR_EVENT_ON_BAD_INFO", { event: event } );
return this;
}
if (typeof handler.name === "string" &&
handler.name.length > 0) {
handlerName = handler.name+"()";
}
if (typeof priority !== "number") {
priority = EVENT_PRIORITY_DEFAULT;
}
if (this.#singularEvents.includes(event) === true &&
this.#firedEvents.includes(event.trim().toLowerCase()) === true) {
this.log(`handler attached to ${event} after firing — handler: "${handlerName}"`);
handler();
return this;
}
this.#eventHandlers[event].push({
handler: handler,
priority: priority,
once: onlyOnce,
});
this.log(`attached handler for ${event} "${handlerName}"`);
return this;
}
/**
* register an event handler for only one trigger
*
* @param String
* @param Function
* @param Number
* @return void
*/
once(event = "", handler, priority = EVENT_PRIORITY_DEFAULT)
{
return this.on(event, handler, priority, true);
}
/**
* unregister an event handler
*
* @param String
* @param Function
* @return void
*/
off(event = "", handler)
{
if (typeof event !== "string" ||
event.length === 0) {
this.errorHandler.warn("ERROR_EVENT_OFF_BAD_INFO", arguments);
return this;
}
if (this.#eventList.includes(event) === false) {
this.errorHandler.warn("ERROR_EVENT_OFF_INVALID_EVENT", event);
return this;
}
if (typeof handler !== "function") {
this.errorHandler.warn("ERROR_EVENT_OFF_BAD_HANDLER", handler);
return this;
}
let handlers = [];
for (const f of this.#eventHandlers[event]) {
if (f.handler === handler) {
continue;
}
handlers.push(f);
}
this.#eventHandlers[event] = handlers;
this.log(`detached handler for ${event}`, handler);
return this;
}
/**
* fire an event
*
* @param String
* @param Array-like
* @return void
*/
fireEvent(event, a)
{
if (typeof event !== "string" ||
event.length === 0) {
this.errorHandler.warn("ERROR_EVENT_FIRE_BAD_INFO", arguments);
return;
}
if (this.#eventList.includes(event) === false) {
this.errorHandler.warn("ERROR_EVENT_FIRE_INVALID_EVENT", event);
return;
}
if (typeof a === "undefined") {
a = [];
}
// sort events according to priority (high-to-low)
this.#eventHandlers[event].sort((a, b) => b.priority - a.priority);
this.log(`"${event}" event fired`);
for (const f of this.#eventHandlers[event]) {
if (typeof f.handler !== "function") {
this.errorHandler.warn("ERROR_EVENT_FIRE_BAD_HANDLER", f.handler, typeof f.handler);
continue;
}
this.log("event fired", {
event: event,
name: f.handler.name,
function: f.handler,
priority: f.priority,
});
if (f.once === true) {
this.#eventHandlers[event] = this.#eventHandlers[event].filter(h => h.handler !== f.handler);
}
f.handler.apply(this, a);
}
if (this.#firedEvents.includes(event) !== true) {
this.#firedEvents.push(event);
}
this.log(`"${event}" event complete`);
}
/* globals */
/**
* register a global value
*
* @param Object
* @return void
*/
registerGlobal(g)
{
if (Array.isArray(g) !== true) {
g = [ g ];
}
for (const globalVar of g) {
let handlerName = "anonymous function";
if (typeof globalVar !== "object" ||
Object.keys(globalVar).length === 0) {
this.errorHandler.warn("ERROR_GLOBALS_REGISTER_INVALID_INFO", globalVar);
continue;
}
if (typeof globalVar.key !== "string" ||
globalVar.key.length === 0) {
this.errorHandler.warn("ERROR_GLOBALS_REGISTER_INVALID_KEY", globalVar);
continue;
}
if (typeof globalVar.get !== "function") {
this.errorHandler.warn("ERROR_GLOBALS_REGISTER_INVALID_HANDLER", globalVar);
continue;
}
let k = globalVar.key.toLowerCase().trim();
let handler = globalVar.get;
if (this.#globalKeys.includes(k) === true) {
this.errorHandler.warn("ERROR_GLOBALS_REGISTER_DUPLICATE", k);
continue;
}
if (typeof handler !== "function") {
this.errorHandler.warn("ERROR_GLOBALS_REGISTER_INVALID_HANDLER");
continue;
}
if (typeof handler.name === "string" &&
handler.name.length > 0) {
handlerName = handler.name+"()";
}
this.#globalKeys.push(k);
this.#globals[k] = {
key: k,
get: handler,
};
this.log(`Registered Global "${k}" w/handler: ${handlerName}`);
}
}
/**
* register event handler
*
* @param String
* @return void
*/
getGlobal(k)
{
if (typeof k !== "string" ||
k.length === 0) {
this.errorHandler.warn(
"ERROR_GLOBALS_GET_INVALID_KEY",
k || typeof k
);
}
if (this.#globalKeys.includes(k.toLowerCase()) === false) {
this.errorHandler.warn(
"ERROR_GLOBALS_GET_UNKNOWN_KEY",
k || typeof k
);
}
this.log(`Accessed Global "${k}"`);
return this.#globals[k.toLowerCase()].get();
}
/* services */
/**
* shortcut to send a message to chat via the message queue
*
* @param String
* @param Object
* @return void
*/
sendMessage(text, options)
{
this.messaging.queueMessage(text, options);
}
/**
* break a time stamp (in milliseconds) down to a count of each components, eg. days/hours/mins/secs/ms
*
* @param Number
* @param Object
* @return Boolean
*/
formatTimeStamp(ts, options)
{
let components = [],
componentCount = 0,
description = "",
sep = "",
tsTemp = ts,
largest = null,
o = {
showCenturies: true,
showDecades: true,
showYears: true,
showMonths: true,
showWeeks: true,
showDays: true,
showHours: true,
showMinutes: true,
showSeconds: true,
showMilliseconds: false,
},
componentLabels = {
centuries: "century",
decades: "decade",
years: "year",
months: "month",
weeks: "week",
days: "day",
hours: "hour",
minutes: "minute",
seconds: "second",
milliseconds: "millisecond",
},
data = {
centuries: 0,
decades: 0,
years: 0,
months: 0,
weeks: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
},
totals = copyObject(data);
if (typeof ts !== "number" ||
isNaN(ts) === true ||
ts < milliseconds) {
return;
}
// merge options
o = { ...o, ...options };
// dissect the time stamp
for (const [k, v] of Object.entries(totals)) {
const sv = this.constructShowVar(k);
if (ts < timeMap[k]) {
continue;
}
if (largest === null) {
largest = k;
}
totals[k] = Math.floor(ts/timeMap[k]);
// eg. showCenturies
if (o[sv] === true) {
data[k] = Math.floor(tsTemp/timeMap[k]);
tsTemp -= data[k]*timeMap[k];
componentLabels[k] = this.polyglot.t(`core.format_time_stamp.${k}`, data[k]);
components.push(`${data[k]} ${componentLabels[k]}`);
}
// done? then be done
if (tsTemp <= 0) {
break;
}
}
if (components.length === 0) {
console.log("no output: formatTimeStamp", ts, data, totals);
return false;
}
// build the description
for (const c of components) {
description += `${sep}${c}`;
sep = this.getSeparator(componentCount, components.length);
componentCount++;
}
return {
description: description,
data: data,
totals: totals,
largest: largest,
};
}
/**
* build the element display option var eg. o.showCenturies
*
* @param Object
* @return String
*/
constructShowVar(c)
{
const l = c.slice(0, 1).toUpperCase();
const remainder = c.slice(1);
return `show${l}${remainder}`;
}
/**
* get the correct separator for a string item list
*
* @param Number
* @param Number
* @return String
*/
getSeparator(current, total)
{
let sep = this.polyglot.t("core.get_separator.standard_separator");
if (total === 2) {
sep = this.polyglot.t("core.get_separator.two_items_separator");
} else {
if (total > 2 &&
(total - 1) - current === 1) {
sep = this.polyglot.t("core.get_separator.three_or_more_oxford_comma");
}
}
return sep;
}
/* getters */
/**
* getter for this.#valid
*
* @return Boolean
*/
isValid()
{
return this.#valid === true;
}
/**
* getter for this.#connected
*
* @return Boolean
*/
isConnected()
{
return this.#connected === true;
}
/**
* getter for this.#subbed
*
* @return Boolean
*/
isSubbed()
{
return this.#subbed === true;
}
/* error handling & reporting */
/**
* log runtime data
*
* @param ...args
* @return void
*/
log()
{
if (this.options.verboseLogging !== true ||
typeof arguments === "undefined" ||
arguments.length === 0) {
return;
}
for (const a of arguments) {
console.log(a);
}
}
};
module.exports = TwitchChatBot;