UNPKG

twitch2ma

Version:

Twitch chat bot that runs commands on the MA GrandMA2 using Telnet.

364 lines (304 loc) 14.8 kB
import {EventEmitter} from '@d-fischer/typed-event-emitter'; import {EventBinder} from "@d-fischer/typed-event-emitter/lib/EventEmitter"; import Twitch from "twitch"; import TwitchChat from "twitch-chat-client"; import TwitchClient from "twitch"; import TwitchPrivateMessage from "twitch-chat-client/lib/StandardCommands/TwitchPrivateMessage"; import {Config, Command, Parameter} from "./Config"; import {RuntimeInformation} from "./RuntimeInformation"; import {PermissionCollector, PermissionController, PermissionError} from "./PermissionController"; import SACNPermission, { SACNError, SACNLost, SACNReceiving, SACNStatus, SACNStopped, SACNWaiting } from "./permissions/SACNPermission"; import CooldownPermission from "./permissions/CooldownPermission"; import OwnerPermission from "./permissions/OwnerPermission"; import ModeratorPermission from "./permissions/ModeratorPermission"; import sentry, {sentryMessage} from "./sentry"; const Sentry = require("@sentry/node"); import * as Bluebird from "bluebird"; global.Promise = Bluebird as any; import type Telnet from "telnet-client"; import SourceMapSupport = require("source-map-support"); import _ = require("lodash"); const TelnetClient = require("telnet-client"); SourceMapSupport.install(); export class TelnetError extends Error { constructor(message: string) { super(message); Object.setPrototypeOf(this, TelnetError.prototype); this.name = TelnetError.name; } } export class ChannelError extends Error { constructor() { super("Joining channel failed. Did you type the channel name correctly?"); Object.setPrototypeOf(this, ChannelError.prototype); this.name = ChannelError.name; } } export default class Twitch2Ma extends EventEmitter { private config: Config; private telnet: Telnet; private permissionController: PermissionController; private twitchClient: TwitchClient; private chatClient: TwitchChat; private initializing: boolean; constructor(config: Config) { super(); this.config = config; this.telnet = new TelnetClient(); this.initializing = false; this.permissionController = new PermissionController() .withPermissionInstance(new SACNPermission(config) .withOnStatusHandler(status => this.handleSACNStatus(status)) .withOnErrorHandler(error => this.handleSACNError(error))) .withPermissionInstance(new CooldownPermission()) .withPermissionInstance(new OwnerPermission()) .withPermissionInstance(new ModeratorPermission()); } reloadConfig(config: Config) { if (this.initializing) { throw new Error("twitch2ma is initializing!"); } let needsTelnetReload = !_.isEqual(this.config.ma, config.ma); let needsTwitchReload = !_.isEqual(this.config.twitch, config.twitch) || needsTelnetReload; this.initializing = true; let reloadChain = Promise.resolve(); if (needsTwitchReload) { reloadChain = reloadChain .then(() => this.emit(this.onNotice, "Disconnecting Twitch...")) .then(() => this.chatClient.removeListener()) .then(() => this.stopTwitch()); } if (needsTelnetReload) { reloadChain = reloadChain .then(() => this.emit(this.onNotice, "Reconnecting telnet...")) .then(() => this.telnet.end()) .then(() => this.startTelnet(config.ma.host, config.ma.user, config.ma.password)); } if (needsTwitchReload) { reloadChain = reloadChain .then(() => this.emit(this.onNotice, "Connecting Twitch...")) .then(() => this.initTwitch(config)); } return reloadChain .then(() => this.permissionController.reloadConfig(config)) .then(() => this.config = config) .then(() => this.initializing = false) .then(() => this.emit(this.onConfigReloaded)) .catch(error => sentry(error, () => this.stopWithError(error))); } private startTelnet(host: string, user: string, password: string) { return this.telnet .connect({ host: host, port: 30000, shellPrompt: /\[.+]>.../, echoLines: 0, ors: "\r\n", }) .catch(() => { throw new TelnetError("Could not connect to desk! Check Telnet enabled, MA IP address and firewall " + "settings if using onPC!"); }) .then(() => this.telnetLogin(host, user, password)); } private stopTwitch() { let stopChain = Promise.resolve(); if (_.isObject(this.chatClient)) { stopChain.then(() => this.chatClient.quit()); } return stopChain; } start() { this.initializing = true; return this.permissionController.start() .then(() => this.startTelnet(this.config.ma.host, this.config.ma.user, this.config.ma.password)) .then(() => this.initTwitch()) .then(() => this.initializing = false); } stop(): Promise<void> { return this.stopTwitch() .then(() => this.telnet.end()) .then(() => this.permissionController.stop()); } stopWithError(error: Error) { this.emit(this.onError, error); return this.stop(); } telnetLogin(host: string, user: string, password: string) { return this.telnet.exec(`Login ${user} ${password}`) .then((message: string) => { if (message.match(`Logged in as User '${user}'`)) { this.emit(this.onTelnetConnected, host, user); } else { throw new TelnetError(`Could not log into the desk as user ${user}! Check password!`); } }); } initTwitch(config: Config = this.config) { return Promise.resolve() .then(() => new Promise((resolve, reject) => { this.twitchClient = Twitch.withCredentials(config.twitch.clientId, config.twitch.accessToken); this.chatClient = TwitchChat.forTwitchClient(this.twitchClient, { logger: { minLevel: "CRITICAL" } }); this.chatClient.onPrivmsg((channel, user, message, rawMessage) => this.handleMessage(channel, user, message, rawMessage)); this.chatClient.onRegister(() => { this.chatClient.join(config.twitch.channel) .then(() => resolve()) .catch(() => reject(new ChannelError())); }); this.chatClient.connect() .catch(error => reject(error)); })).then(() => this.emit(this.onTwitchConnected, config.twitch.channel)); } async handleMessage(channel: string, user: string, message: string, rawMessage: TwitchPrivateMessage) { if(this.initializing) { this.chatClient.say(channel, "The bot is currently reloading its config, please wait a moment " + "and try it again!"); this.emit(this.onNotice, `${channel}: ${user} tried to execute something but it was denied due to reloading config.`); return; } let raw = message.match(/!([a-zA-Z0-9-]+)( !?([a-zA-Z0-9-]+))?/); if (_.isArray(raw)) { let chatCommand = raw[1]; let parameterName = raw[3]; if (chatCommand === "lights") { this.sendHelp(channel, user, parameterName); } else { let command = this.config.getCommand(chatCommand); if (command instanceof Command) { let instructions: Command | Parameter = command; if (_.isString(parameterName)) { let parameter = command.getParameter(parameterName); if (parameter instanceof Parameter) { instructions = parameter; } else { this.chatClient.say(channel, `Parameter ${parameterName} does not exist! Type !lights !${chatCommand} for help!`); return; } } return this.sendCommand(instructions, channel, user, rawMessage) .then(() => { if (_.isString(instructions.message)) { this.chatClient.say(channel, instructions.message .replace("{user}", `@${user}`) .replace("{parameterList}", this.getParametersHelp(command)) .trim()); } }) .then(() => this.emit(this.onCommandExecuted, channel, user, chatCommand, parameterName, instructions.consoleCommand)) .catch(PermissionError, error => { let command = _.isString(parameterName) ? `!${chatCommand} ${parameterName}` : `!${chatCommand}`; let reason = error.permissionCollector.permissionDeniedReasons.shift(); this.chatClient.say(channel, reason.viewerMessage.replace("{command}", command)); this.emit(this.onPermissionDenied, channel, user, command, reason.name); }) .catch(TelnetError, error => this.stopWithError(error)) .catch(error => sentry(error, error => this.stopWithError(error))); } } } } async sendCommand(instructions: any, channel: string, user: string, rawMessage: TwitchPrivateMessage) { return this.permissionController.checkPermissions(new RuntimeInformation(this.config, user, rawMessage, instructions)) .then((permissionCollector: PermissionCollector) => { if (permissionCollector.permissionDeniedReasons.length > 0 && permissionCollector.godMode) { this.emit(this.onGodMode, channel, user, permissionCollector.godModeReasons.shift()); } }) .then(() => { if (_.isString(instructions.consoleCommand)) { return this.telnet.send(instructions.consoleCommand) .catch(() => { throw new TelnetError("Sending telnet command failed! Is MA still running?"); }) .then(() => this.permissionController.setAdditionalRuntimeInformation("lastCall", new Date().getTime())); } }); } sendHelp(channel: string, user: string, helpCommand: string) { let message: string; if (_.isString(helpCommand)) { let command = this.config.getCommand(helpCommand); if (command instanceof Command) { if (_.isString(command.help)) { message = `Help for !${helpCommand}: ${command.help.replace("{parameterList}", this.getParametersHelp(command)).trim()}`; } else { message = `No help for !${helpCommand} available!`; } this.emit(this.onHelpExecuted, channel, user, command.chatCommand); } else { message = `Command !${helpCommand} does not exist! Type !lights for a list of available commands.`; } } else { if (_.size(this.config.commands)) { message = "Available commands are: " + this.config.availableCommands + ". Type !lights !command for help."; } else { message = "There are no commands available."; } this.emit(this.onHelpExecuted, channel, user); } this.chatClient.say(channel, message); } getParametersHelp(command: Command) { return _.isString(command.availableParameters) ? `Available parameters: ${command.availableParameters}` : ""; } private handleSACNStatus(status: SACNStatus) { switch(status.constructor) { case SACNWaiting: this.emit(this.onNotice, `sACN status: Waiting for universes: ${status.universes.join(", ")}`); break; case SACNReceiving: this.emit(this.onNotice, `sACN status: Receiving universes: ${status.universes.join(", ")}`); break; case SACNLost: this.emit(this.onNotice, `sACN status: Lost universes: ${status.universes.join(", ")}`); break; case SACNStopped: this.emit(this.onNotice, "sACN status: Stopped listening."); break; default: this.emit(this.onNotice, `sACN status: Received unknown status: ${typeof status}`); sentryMessage(`Unknown sACN status received: ${typeof status}`, Sentry.Severity.Warning); break; } } private handleSACNError(error: Error) { switch (error.constructor) { case SACNError: this.emit(this.onNotice, error.message); break; default: // @ts-ignore this.emit(this.onNotice, "sACN error: unknown error" + (error.code ? ` (${error.code})` : "")); sentry(error); break; } } protected emit<Args extends any[]>(event: EventBinder<Args>, ...args: Args) { try { super.emit(event, ...args); } catch (error) { sentry(error); } } onTelnetConnected = this.registerEvent<(host: string, user: string) => any>(); onTwitchConnected = this.registerEvent<(channel: string) => any>(); onError = this.registerEvent<(error: Error) => any>(); onCommandExecuted = this.registerEvent<(channel: string, user: string, chatCommand: string, parameter: string, consoleCommand: string) => any>(); onHelpExecuted = this.registerEvent<(channel: string, user: string, helpCommand?: string) => any>(); onPermissionDenied = this.registerEvent<(channel: string, user: string, command: string, reason: string) => any>(); onGodMode = this.registerEvent<(channel: string, user: string, reason: string) => any>(); onNotice = this.registerEvent<(message: string) => any>(); onConfigReloaded = this.registerEvent<() => any>(); }