twitch-commando
Version:
Twitch Bot Commando Client
621 lines (515 loc) • 17.4 kB
JavaScript
const tmi = require("tmi.js");
const EventEmitter = require("events");
const readdir = require("recursive-readdir-sync");
const path = require("path");
const { createLogger, format, transports } = require("winston");
const { combine, timestamp, prettyPrint, simple, splat, colorize } = format;
var Queue = require('better-queue');
const TwitchChatMessage = require("../messages/TwitchChatMessage");
const TwitchChatChannel = require("../channels/TwitchChatChannel");
const TwitchChatUser = require("../users/TwitchChatUser");
const CommandParser = require("../commands/CommandParser");
const TwitchChatCommand = require("../commands/TwitchChatCommand");
const EmotesManager = require("../emotes/EmotesManager");
const CommandoConstants = require("./CommandoConstants");
/**
* Client configuration options
* @typedef {Object} ClientOptions
* @property {Boolean} verboseLogging Enable verbose logging (default: false)
* @property {String} username Bot username
* @property {String} oauth Bot oauth password (without oauth:)
* @property {Array<String>} botOwners List of bot owners username (default: empty array)
* @property {String} prefix Default command prefix (default: !)
* @property {Boolean} greetOnJoin Denotes if the bot must send a message when join a channel (default: false)
* @property {Array<String>} channels Initials channels to join (default: empty array)
* @property {String} onJoinMessage On Join message (sent if greetOnJoin = true)
* @property {Boolean} autoJoinBotChannel Denotes if the bot must autojoin its own channel (default: true)
* @property {Boolean} enableJoinCommand Denotes if enable the !join and !part command in bot channel (default: true)
* @property {String} botType Define the bot type, will be used for message limits control. See CommandoConstants for available bot type values (default: BOT_TYPE_NORMAL)
* @property {Boolean} enableRateLimitingControl Enable Rate Limiting control (default: true)
* @property {Boolean} skipMembership Skip PART\JOIN events (default: true)
* @property {Boolean} enableVerboseLogging Enable Verbose Logging up to debug level (default: false)
* @property {Number} joinInterval TMI Join Interval in milliseconds (default: 350ms instead of default tmi 2000ms)
*/
/**
* The Commando Client class
* @class
* @extends {EventEmitter}
* @fires TwitchCommandoClient#connected
* @fires TwitchCommandoClient#join
* @fires TwitchCommandoClient#disconnected
* @fires TwitchCommandoClient#timeout
* @fires TwitchCommandoClient#error
* @fires TwitchCommandoClient#commandExecuted
* @fires TwitchCommandoClient#commandError
* @fires TwitchCommandoClient#message
* @fires TwitchCommandoClient#reconnect
*/
class TwitchCommandoClient extends EventEmitter {
/**
*Creates an instance of TwitchCommandoClient.
* @param {ClientOptions} options Client configuration options
* @memberof TwitchCommandoClient
*/
constructor(options) {
super();
let defaultOptions = {
enableVerboseLogging: false,
channels: [],
prefix: "!",
greetOnJoin: false,
onJoinMessage: '',
botOwners: [],
autoJoinBotChannel: true,
enableJoinCommand: true,
botType: CommandoConstants.BOT_TYPE_NORMAL,
enableRateLimitingControl: true,
skipMembership: true,
joinInterval: 350
};
options = Object.assign(defaultOptions, options);
this.options = options;
this.tmi = null;
this.verboseLogging = this.options.enableVerboseLogging;
this.commands = [];
this.emotesManager = null;
this.logger = createLogger({
format: combine(simple(), splat(), timestamp(), colorize()),
transports: [
new transports.Console({
level: this.verboseLogging ? "debug" : "info",
colorized: true
})
]
});
this.channelsWithMod = [];
this.messagesCounterInterval = null;
this.messagesCount = 0;
}
/**
* Enable verbose logging
*
* @memberof TwitchCommandoClient
*/
enableVerboseLogging() {
this.verboseLogging = true;
}
configureClient() { }
checkOptions() {
if (this.options.prefix == "/")
throw new Error("Invalid prefix. Cannot be /");
if (this.options.username == undefined)
throw new Error("Username not specified");
if (this.options.oauth == undefined)
throw new Error("Oauth password not specified");
}
/**
* Connect the bot to Twitch Chat
*
* @memberof TwitchCommandoClient
*/
async connect() {
this.checkOptions();
this.configureClient();
this.emotesManager = new EmotesManager(this);
//await this.emotesManager.getGlobalEmotes();
this.logger.info('Current default prefix is ' + this.options.prefix);
this.logger.info("Connecting to Twitch Chat");
var autoJoinChannels = this.options.channels;
var channelsFromSettings = await this.settingsProvider.get(CommandoConstants.GLOBAL_SETTINGS_KEY, "channels", []);
var channels = [...autoJoinChannels, ...channelsFromSettings];
if (this.options.autoJoinBotChannel) {
channels.push("#" + this.options.username);
}
this.logger.info('Autojoining ' + channels.length + ' channels');
this.tmi = new tmi.client({
options: {
debug: this.verboseLogging,
skipMembership: this.options.skipMembership,
joinInterval: this.options.joinInterval
},
connection: {
secure: true,
reconnect: true
},
identity: {
username: this.options.username,
password: "oauth:" + this.options.oauth
},
channels: channels,
logger: this.logger
});
this.tmi.on("connected", this.onConnect.bind(this));
this.tmi.on("disconnected", this.onDisconnect.bind(this));
this.tmi.on("join", this.onJoin.bind(this));
this.tmi.on("reconnect", this.onReconnect.bind(this));
this.tmi.on("timeout", this.onTimeout.bind(this));
this.tmi.on("mod", this.onMod.bind(this));
this.tmi.on("unmod", this.onUnmod.bind(this));
this.tmi.on('notice', async (channel, msgid, message) => {
if (message.includes("You are permanently banned from talking in")) {
this.logger.info(`Removing ${channel} because the bot has been banned`);
await this.removeChannelFromSettings(channel.replace("#", ""));
}
});
this.tmi.on("error", err => {
this.logger.error(err.message);
this.emit("error", err);
});
this.tmi.on('ban', async (channel, username, reason) => {
this.logger.info(`Bot banned from ${channel} by ${username} with reason ${reason}`);
await this.removeChannelFromSettings(channel);
});
this.tmi.on('part', async (channel, username, self) => {
this.logger.info(`Bot left ${channel}`);
await this.removeChannelFromSettings(channel);
});
this.tmi.on("message", this.onMessage.bind(this));
await this.tmi.connect();
}
/**
* Send a text message in the channel
*
* @param {String} channel Channel destination
* @param {String} message Message text
* @param {Boolean} addRandomEmote Add random emote to avoid message duplication
* @memberof TwitchCommandoClient
* @async
*/
async say(channel, message, addRandomEmote = false) {
if (this.checkRateLimit()) {
/*if (addRandomEmote)
message += " " + this.emotesManager.getRandomEmote().code;*/
var serverResponse = await this.tmi.say(channel, message);
if (this.messagesCount == 0) this.startMessagesCounterInterval();
this.messagesCount = this.messagesCount + 1;
return serverResponse;
} else this.logger.warn("Rate limit excedeed. Wait for timer reset.");
}
/**
* Send an action message in the channel
*
* @param {String} channel
* @param {String} message
* @param {Boolean} addRandomEmote Add random emote to avoid message duplication
* @returns {String}
* @async
* @memberof TwitchCommandoClient
*/
async action(channel, message, addRandomEmote = false) {
if (this.checkRateLimit()) {
/*if (addRandomEmote)
message += " " + this.emotesManager.getRandomEmote().code;*/
var serverResponse = await this.tmi.action(channel, message);
if (this.messagesCount == 0) this.startMessagesCounterInterval();
this.messagesCount = this.messagesCount + 1;
return serverResponse;
} else this.logger.warn("Rate limit excedeed. Wait for timer reset.");
}
/**
* Send a private message to the user with given text
* @param {String} username
* @param {String} message
* @returns
* @async
* @memberof TwitchCommandoClient
*/
async whisper(username, message) {
var serverResponse = await this.tmi.whisper(username, message);
return serverResponse;
}
/**
* Register commands in given path (recursive)
*
* @param {String} path
* @memberof TwitchCommandoClient
*/
registerCommandsIn(path) {
var files = readdir(path);
//if (this.verboseLogging) this.logger.info(files);
files.forEach(f => {
var commandFile = require(f);
if (typeof commandFile === "function") {
var command = new commandFile(this);
this.logger.info(
`Register command ${command.options.group}:${command.options.name}`
);
//if (this.verboseLogging) this.logger.info(this.command);
this.commands.push(command);
}
}, this);
this.parser = new CommandParser(this.commands, this);
}
findCommand(parserResult) {
var command = null;
this.commands.forEach(c => {
if (parserResult.command == c.options.name) command = c;
if (
command == null &&
c.options.aliases &&
c.options.aliases.length > 0
) {
if (c.options.aliases.includes(parserResult.command)) command = c;
}
}, this);
return command;
}
/**
* Bot connected
* @event TwitchCommandoClient#connected
* @memberof TwitchCommandoClient
* @instance
*/
onConnect() {
this.emit("connected");
}
chunk(arr, size) {
return Array.from({ length: Math.ceil(arr.length / size) }, (v, i) =>
arr.slice(i * size, i * size + size)
);
}
/**
* Channel joined or someone join the channel
* @event TwitchCommandoClient#join
* @type {TwitchChatChannel} channel
* @type {String} username
* @memberof TwitchCommandoClient
* @instance
*/
onJoin(channel, username) {
var channelObject = new TwitchChatChannel(
{
name: channel
},
this
);
if (
this.options.greetOnJoin &&
this.getUsername() == username &&
this.options.onJoinMessage &&
this.options.onJoinMessage != ""
) {
this.action(channel, this.options.onJoinMessage);
}
this.emit("join", channelObject, username);
}
/**
* Bot disonnects
* @event TwitchCommandoClient#disconnected
* @type {object}
* @memberof TwitchCommandoClient
* @instance
*/
onDisconnect() {
this.emit("disconnected");
}
/**
* Message received
* @event TwitchCommandoClient#message
* @type {TwitchChatMessage}
* @memberof TwitchCommandoClient
* @instance
*/
/**
* Command executed
* @event TwitchCommandoClient#commandExecuted
* @type {object}
* @memberof TwitchCommandoClient
* @instance
*/
/**
* Command error
* @event TwitchCommandoClient#commandError
* @type {Error}
* @memberof TwitchCommandoClient
* @instance
*/
async onMessage(channel, userstate, messageText, self) {
if (self) return;
var message = new TwitchChatMessage(userstate, channel, this);
if (this.verboseLogging) this.logger.info(message);
this.emit("message", message);
var prefix = await this.settingsProvider.get(
message.channel.name,
"prefix", this.options.prefix
);
var parserResult = this.parser.parse(messageText, prefix);
if (parserResult != null) {
if (this.verboseLogging) this.logger.info(parserResult);
var command = this.findCommand(parserResult);
if (command != null) {
let preValidateResponse = command.preValidate(message);
if (preValidateResponse == "") {
command
.prepareRun(message, parserResult.args)
.then(commandResult => {
this.emit("commandExecuted", commandResult);
})
.catch(err => {
message.reply("Unexpected error: " + err);
this.emit("commandError", err);
});
} else message.reply(preValidateResponse, true);
}
}
}
onAction(action) { }
onBan(user, reason) { }
onUnban(user) { }
/**
* Connection timeout
* @event TwitchCommandoClient#timeout
* @type {object}
* @memberof TwitchCommandoClient
* @instance
*/
onTimeout(channel, username, reason, duration) {
this.emit("timeout", channel, username, reason, duration);
}
/**
* Reconnection
* @event TwitchCommandoClient#reconnect
* @memberof TwitchCommandoClient
* @instance
*/
onReconnect() {
this.emit("reconnect");
}
/**
* Register default commands, like !help
*
* @memberof TwitchCommandoClient
*/
registerDetaultCommands() {
this.registerCommandsIn(path.join(__dirname, "../defaultCommands"));
}
/**
* Set Settings Provider class
*
* @async
* @memberof TwitchCommandoClient
*/
async setProvider(provider) {
this.settingsProvider = provider;
await this.settingsProvider.init(this);
}
/**
* Request the bot to join a channel
*
* @param {String} channel Channel to join
* @async
* @returns {Promise<String>}
* @memberof TwitchCommandoClient
*/
async join(channel) {
return this.tmi.join(channel);
}
/**
* Request the bot to leave a channel
*
* @param {String} channel Channel to leave
* @async
* @returns {Promise<String>}
* @memberof TwitchCommandoClient
*/
async part(channel) {
return this.tmi.part(channel);
}
/**
* Gets the bot username
*
* @returns {String}
* @memberof TwitchCommandoClient
*/
getUsername() {
return this.tmi.getUsername();
}
/**
* Gets the bot channels
*
* @returns {Array<String>}
* @memberof TwitchCommandoClient
*/
getChannels() {
return this.tmi.getChannels();
}
/**
* Checks if the message author is one of bot owners
*
* @param {TwitchChatUser} author Message author
* @returns {Boolean}
* @memberof TwitchCommandoClient
*/
isOwner(author) {
return this.options.botOwners.includes(author.username);
}
onMod(channel, username) {
console.log("mod");
if (
username == this.getUsername() &&
!this.channelsWithMod.includes(channel)
) {
this.logger.debug("Bot has received mod role");
this.channelsWithMod.push(channel);
}
this.emit("mod", channel, username);
}
onUnmod(channel, username) {
if (username == this.getUsername()) {
this.logger.debug("Bot has received unmod");
this.channelsWithMod = this.channelsWithMod.filter(c => {
return c != channel;
});
}
this.emit("onumod", channel, username);
}
/**
* @private
*
* @memberof TwitchCommandoClient
*/
startMessagesCounterInterval() {
if (this.options.enableRateLimitingControl) {
if (this.verboseLogging)
this.logger.debug("Starting messages counter interval");
let messageLimits = CommandoConstants.MESSAGE_LIMITS[this.options.botType];
this.messagesCounterInterval = setInterval(
this.resetMessageCounter.bind(this),
messageLimits.timespan * 1000
);
}
}
/**
* @private
*
* @memberof TwitchCommandoClient
*/
resetMessageCounter() {
if (this.verboseLogging) this.logger.debug("Resetting messages count");
this.messagesCount = 0;
}
/**
* Check if the bot sent too many messages in timespan limit
* @private
*
* @returns {Boolean}
* @memberof TwitchCommandoClient
*/
checkRateLimit() {
if (this.options.enableRateLimitingControl) {
let messageLimits = CommandoConstants.MESSAGE_LIMITS[this.options.botType];
if (this.verboseLogging)
this.logger.warn('Messages count: ' + this.messagesCount);
if (this.messagesCount < messageLimits.messages) return true;
else return false;
}
else
return true;
}
async removeChannelFromSettings(channel) {
let channels = await this.settingsProvider.get(CommandoConstants.GLOBAL_SETTINGS_KEY, 'channels', []);
channels = channels.filter((c) => { return c != channel });
await this.settingsProvider.set(CommandoConstants.GLOBAL_SETTINGS_KEY, 'channels', channels);
}
}
module.exports = TwitchCommandoClient;