diff --git a/packages/roleypoly-bot/Bot.js b/packages/roleypoly-bot/Bot.js index e69de29..ce76cbf 100644 --- a/packages/roleypoly-bot/Bot.js +++ b/packages/roleypoly-bot/Bot.js @@ -0,0 +1,236 @@ +// @flow +import Eris, { type Message, type TextChannel } from 'eris' +import logger from './logger.js' +import RPCClient from '@roleypoly/rpc-client' +import randomEmoji from './random-emoji' +import DMCommands from './commands/dm' +import TextCommands from './commands/text' +import RootCommands from './commands/root' +import type { Command } from './commands/_types' +import retry from 'async-retry' +const log = logger(__filename) + +export type BotConfig = $Shape<{ + sharedSecret: string, + botToken: string, + rootUsers: Set, + appUrl: string, + logChannel: string +}> + +export default class Bot { + config: BotConfig = { + sharedSecret: process.env.SHARED_SECRET || '', + botToken: process.env.DISCORD_BOT_TOKEN || '', + rootUsers: new Set((process.env.ROOT_USERS || '').split(',')), + appUrl: process.env.APP_URL || '', + logChannel: process.env.LOG_CHANNEL || '' + } + + client: Eris + + $RPC: RPCClient + + rpc: * + + commandCheck: RegExp + + constructor (config: BotConfig = {}) { + if (config != null) { + this.config = { + ...this.config, + ...config + } + } + + this.client = new Eris(this.config.botToken, { + disableEveryone: true, + maxShards: 'auto', + messageLimit: 10, + disableEvents: { + CHANNEL_PINS_UPDATE: true, + USER_SETTINGS_UPDATE: true, + USER_NOTE_UPDATE: true, + RELATIONSHIP_ADD: true, + RELATIONSHIP_REMOVE: true, + GUILD_BAN_ADD: true, + GUILD_BAN_REMOVE: true, + TYPING_START: true, + MESSAGE_UPDATE: true, + MESSAGE_DELETE: true, + MESSAGE_DELETE_BULK: true, + VOICE_STATE_UPDATE: true + } + }) + + this.$RPC = new RPCClient({ forceDev: false, retry: true }) + this.rpc = this.$RPC.withBotAuth(this.config.sharedSecret) + this.botPing() + + if (this.config.sharedSecret === '') { + log.fatal('configuration incomplete: SHARED_SECRET missing') + } + + if (this.config.botToken === '') { + log.fatal('configuration incomplete: DISCORD_BOT_TOKEN missing') + } + + // <@botid> AND <@!botid> both are valid mentions. + retry(() => { this.commandCheck = new RegExp(`^<@!?${this.client.user.id}>`) }) + } + + isRoot (u: string) { + return this.config.rootUsers.has(u) + } + + sendErrorLog (msg: Message, err: Error) { + if (this.config.logChannel === '') return + + this.client.createMessage( + this.config.logChannel, + `❌ **Command errored:**\nUser > <@${msg.author.id}> (${msg.author.username}#${msg.author.discriminator}) \nChannel > <#${msg.channel.id}> in ${(msg.channel: any)?.guild?.name || 'DM'}\nInput > \`${msg.content}\`\nTrace > \`\`\`${err.stack}\`\`\`` + ) + } + + botPing = async () => { + try { + await this.rpc.botPing() + } catch (e) { + log.fatal('bot failed to connect to RPC server.', e) + } + } + + buildLogMsg (msg: Message, startTime: number) { + const endTime = Date.now() + return `${msg.channel.id} [${msg.channel.type === 0 ? 'TEXT' : 'DM'}] | ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}) {${endTime - startTime}ms}: ${msg.cleanContent}` + } + + gatherMsgInfo (msg: Message) { + const { id, author, channel, type } = msg + const o = { + id, + type: channel.type, + author: { + id: author.id, + name: `${author.username}#${author.discriminator}` + }, + channel: { + id: channel.id, + name: channel.name || '' + }, + guild: {} + } + + try { + if (type === 0) { + const { id, name } = ((msg.channel: any): TextChannel).guild + o.guild = { + id, + name + } + } + } catch (e) { + log.warn('type was 0 but no guild info') + } + + return o + } + + // this is a very silly O(n) matcher. i bet we can do better later. + findCmdIn (text: string, set: Set): ?{cmd: Command, matches: string[]} { + for (const cmd of set) { + const matches = text.match(cmd.regex) + if (matches != null) { + return { cmd, matches } + } + } + } + + async tryCommand (msg: Message, text: string, startTime: number, ...sets: Array | null>): Promise { + for (const set of sets) { + if (set == null) { + continue + } + + for (const cmd of set) { + const matches = text.match(cmd.regex) + if (matches != null) { + // handle! + try { + const result = await cmd.handler(this, msg, matches.slice(1)) + if (result != null) { + await msg.channel.createMessage(result) + } + } catch (e) { + log.error('command failed.', msg, e) + this.sendErrorLog(msg, e) + msg.channel.createMessage(`:x: Something went terribly wrong!`) + } + + log.bot(this.buildLogMsg(msg, startTime)) + return true + } + } + } + + return false + } + + async handleChannelMessage (msg: Message) { + const startTime = Date.now() + + // only react if there's a mention of myself. + const check = msg.mentions.find(v => v.id === this.client.user.id) + if (check == null) { + return + } + + // if root, we'll mixin some new commands + const isRoot = this.isRoot(msg.author.id) + + const text = msg.content + + // check what consititutes a command prefix, <@botid> AND <@!botid> both are valid mentions. + // this is reflected in the regex. + if (this.commandCheck.test(text)) { + const cmd = text.replace(this.commandCheck, '').trim() + if (cmd !== '') { // empty? probably not good. + const success = await this.tryCommand(msg, cmd, startTime, TextCommands, isRoot ? RootCommands : null) + + if (success === true) { + log.debug('reached success', msg.content) + } else { + log.debug('no success.') + } + } + } + + const channel: TextChannel = (msg.channel: any) + channel.createMessage(`${randomEmoji()} Assign your roles here! ${this.config.appUrl}/s/${channel.guild.id}`) + } + + async handleDMMessage (msg: Message) { + const startTime = Date.now() + const isRoot = this.isRoot(msg.author.id) + await this.tryCommand(msg, msg.content, startTime, DMCommands, isRoot ? RootCommands : null) + } + + handleMessage = (msg: Message) => { + if (msg.author.bot) { // no bots + return + } + + switch (msg.channel.type) { + case 0: // text channel + return this.handleChannelMessage(msg) + case 1: // dm channel + return this.handleDMMessage(msg) + } + } + + async start () { + this.client.on('messageCreate', this.handleMessage) + await this.client.connect() + log.info('started bot') + } +} diff --git a/packages/roleypoly-bot/commands/_types.js.flow b/packages/roleypoly-bot/commands/_types.js.flow new file mode 100644 index 0000000..d80a1d4 --- /dev/null +++ b/packages/roleypoly-bot/commands/_types.js.flow @@ -0,0 +1,12 @@ +// @flow +import type { Message } from 'eris' +import type Bot from '../Bot' + +export type Command = { + regex: RegExp, + usage: string, + description: string, + handler: (bot: Bot, message: Message, matches: string[]) => Promise | string | void +} + +export type CommandSet = Set \ No newline at end of file diff --git a/packages/roleypoly-bot/commands/dm.js b/packages/roleypoly-bot/commands/dm.js new file mode 100644 index 0000000..5251f84 --- /dev/null +++ b/packages/roleypoly-bot/commands/dm.js @@ -0,0 +1,30 @@ +// @flow +import type { Command } from './_types' +import { withTyping } from '../utils' +import type Bot from '../Bot' +import type { Message } from 'eris' + +const cmds: Command[] = [ + { + regex: /^\blog ?in|auth\b/, + usage: 'login', + description: 'responds with a login token and link', + + handler: withTyping(async (bot: Bot, msg: Message) => { + const chall = await bot.rpc.issueAuthChallenge(msg.author.id) + return chall + }) + }, + { + regex: /^\blog ?out\b/, + usage: 'logout', + description: 'removes all sessions', + + handler: withTyping(async (bot: Bot, msg: Message) => { + await bot.rpc.removeUserSessions(msg.author.id) + return `I've logged you out!` + }) + } +] + +export default new Set(cmds) diff --git a/packages/roleypoly-bot/commands/root.js b/packages/roleypoly-bot/commands/root.js new file mode 100644 index 0000000..ce19faa --- /dev/null +++ b/packages/roleypoly-bot/commands/root.js @@ -0,0 +1,30 @@ +// @flow +import type { Command } from './_types' +import type Bot from '../Bot' +import type { Message } from 'eris' +import { withTyping } from '../utils' + +const cmds: Command[] = [ + { + regex: /^remove sessions for/, + usage: 'remove sessions for ', + description: 'removes all sessions for a given user.', + + handler: withTyping(async (bot: Bot, msg: Message) => { + const u = msg.mentions[0] + await bot.rpc.removeUserSessions(u.id) + return `${u.mention} should no longer be logged in on any device.` + }) + }, + { + regex: /^always error/, + usage: 'always error', + description: 'creates an error', + + handler: () => { + throw new Error('not an error, this is actually a success.') + } + } +] + +export default new Set(cmds) diff --git a/packages/roleypoly-bot/commands/text.js b/packages/roleypoly-bot/commands/text.js new file mode 100644 index 0000000..4f080b8 --- /dev/null +++ b/packages/roleypoly-bot/commands/text.js @@ -0,0 +1,18 @@ +// @flow +import type { Command } from './_types' +import type Bot from '../Bot' +import type { Message } from 'eris' + +const cmds: Command[] = [ + { + regex: /^hi/, + usage: 'hi', + description: 'says hello', + + handler: (bot: Bot, msg: Message) => { + msg.channel.createMessage('Hi there!') + } + } +] + +export default new Set(cmds) diff --git a/packages/roleypoly-bot/index.js b/packages/roleypoly-bot/index.js index 15fb82c..a63d9b3 100644 --- a/packages/roleypoly-bot/index.js +++ b/packages/roleypoly-bot/index.js @@ -1,4 +1,5 @@ // @flow +import 'dotenv/config' import Bot from './Bot' const B = new Bot() diff --git a/packages/roleypoly-bot/random-emoji.js b/packages/roleypoly-bot/random-emoji.js new file mode 100644 index 0000000..a14ce77 --- /dev/null +++ b/packages/roleypoly-bot/random-emoji.js @@ -0,0 +1,16 @@ +// @flow +export const emojis = [ + ':beginner:', + ':beginner:', + ':beginner:', + ':beginner:', + ':beginner:', + ':beginner:', + ':sunglasses:', + ':gay_pride_flag:', + ':gift_heart:', + // ':lobster:', + ':squid:' +] + +export default () => { return emojis[Math.floor(Math.random() * emojis.length)] } diff --git a/packages/roleypoly-bot/utils.js b/packages/roleypoly-bot/utils.js new file mode 100644 index 0000000..9cfca2d --- /dev/null +++ b/packages/roleypoly-bot/utils.js @@ -0,0 +1,8 @@ +// @flow +import type Bot from './Bot' +import type { Message } from 'eris' + +export const withTyping = (fn: Function) => async (bot: Bot, msg: Message, ...args: any[]) => { + msg.channel.sendTyping() + return fn(bot, msg, ...args) +}