Quellcode durchsuchen

Refactor commands into plugins

ghorsington vor 4 Jahren
Ursprung
Commit
f7e1c8f69d

+ 0 - 30
bot/src/commands/help.ts

@@ -1,30 +0,0 @@
-import { isAuthorisedAsync } from "../util";
-import { CommandSet, Command } from "src/model/command";
-import { Message } from "discord.js";
-import { cmdMgr } from "src/main";
-
-@CommandSet
-export class Help {
-    @Command({ pattern: "help" })
-    async showHelp(msg: Message): Promise<void> {
-        const isAuthed = await isAuthorisedAsync(msg.member);
-
-        let baseCommands = "\n";
-        let modCommands = "\n";
-        
-        for (const doc of cmdMgr.documentation) {
-            if (isAuthed && doc.auth)
-                modCommands = `${modCommands}${doc.example}  -  ${doc.doc}\n`;
-            else if (!doc.auth)
-                baseCommands = `${baseCommands}${doc.example} - ${doc.doc}\n`;
-        }
-
-        const name = process.env.FOOLS == "TRUE" ? "HorseBot" : "NoctBot";
-        let message = `Hello! I am ${name}! My job is to help with C(O)M-related problems!\nPing me with one of the following commands:\n\`\`\`${baseCommands}\`\`\``;
-
-        if (isAuthed)
-            message = `${msg.author.toString()} ${message}\n👑**Moderator commands**👑\n\`\`\`${modCommands}\`\`\``;
-
-        msg.channel.send(message);
-    }
-}

+ 0 - 25
bot/src/commands/inspire.ts

@@ -1,25 +0,0 @@
-import { Message } from "discord.js";
-import { CommandSet, Command } from "src/model/command";
-import got from "got";
-import { logger } from "src/logging";
-
-@CommandSet
-export class Inspire {
-
-    async doInspire(msg: Message): Promise<void> {
-        const result = await got.get("https://inspirobot.me/api?generate=true");
-        if(result.statusCode != 200) {
-            logger.error("Failed to get inspiration, status code: %s", result.statusCode);
-            await msg.channel.send(`${msg.author.toString()} Sorry, couldn't get inspiration :(.`);
-            return;
-        }
-        msg.channel.send(`${msg.author.toString()} Here is a piece of my wisdom:`, {
-            files: [ result.body ]
-        });
-    }
-
-    @Command({ pattern: "inspire me", documentation: {description: "Generates an inspiring quote just for you", example: "inspire me"}})
-    inspire(msg: Message): void {
-        this.doInspire(msg);
-    }
-}

+ 0 - 95
bot/src/commands/quote.ts

@@ -1,95 +0,0 @@
-import { isAuthorisedAsync } from "../util";
-import { getRepository } from "typeorm";
-import { Quote } from "@shared/db/entity/Quote";
-import { CommandSet, Command } from "src/model/command";
-import { Message } from "discord.js";
-
-const quotePattern = /add quote by "([^"]+)"\s*(.*)/i;
-
-@CommandSet
-export class QuoteCommand {
-
-    minify(str: string, maxLength: number): string {
-        let result = str.replace("\n", "");
-        if (result.length > maxLength)
-            result = `${result.substring(0, maxLength - 3)}...`;
-        return result;
-    }
-
-    @Command({ pattern: "add quote", auth: true, documentation: {description: "Adds a quote", example: "add quote by \"<NAME>\" <NEWLINE> <QUOTE>"} })
-    async addQuote(msg: Message, c: string): Promise<void> {
-        if (!isAuthorisedAsync(msg.member))
-            return;
-
-        const result = quotePattern.exec(c);
-
-        if (result == null)
-            return;
-
-        const author = result[1].trim();
-        const message = result[2].trim();
-
-        const repo = getRepository(Quote);
-
-        const newQuote = await repo.save(repo.create({
-            author: author,
-            message: message
-        }));
-
-        msg.channel.send(`${msg.author.toString()} Added quote (ID: ${newQuote.id})!`);
-    }
-
-    @Command({ pattern: "random quote", documentation: {description: "Shows a random quote by someone special...", example: "random quote"} })
-    async postRandomQuote(msg: Message): Promise<void> {
-        const repo = getRepository(Quote);
-
-        const quotes = await repo.query(`  select *
-                                                from quote
-                                                order by random()
-                                                limit 1`) as Quote[];
-
-        if (quotes.length == 0) {
-            msg.channel.send("I have no quotes!");
-            return;
-        }
-
-        const quote = quotes[0];
-        msg.channel.send(`Quote #${quote.id}:\n*"${quote.message}"*\n- ${quote.author}`);
-    }
-
-    @Command({ pattern: "remove quote", auth: true, documentation: {description: "Removes quote. Use \"quotes\" to get the <quote_index>!", example: "remove quote <quote_index>"} })
-    async removeQuote(msg: Message, c: string): Promise<void> {
-        const quoteNum = c.substring("remove quote".length).trim();
-        const val = parseInt(quoteNum);
-        if (isNaN(val))
-            return;
-
-        const repo = getRepository(Quote);
-
-        const res = await repo.delete({ id: val });
-        if (res.affected == 0)
-            return;
-
-        msg.channel.send(`${msg.author.toString()} Removed quote #${val}!`);
-    }
-
-    @Command({ pattern: "quotes", documentation: {description: "Lists all known quotes.", example: "quotes"}, auth: true })
-    async listQuotes(msg: Message): Promise<void> {
-        if (!isAuthorisedAsync(msg.member)) {
-            msg.channel.send(`${msg.author.toString()} To prevent spamming, only bot moderators can view all quotes!`);
-            return;
-        }
-
-        const repo = getRepository(Quote);
-
-        const quotes = await repo.find();
-
-        if (quotes.length == 0) {
-            msg.channel.send("I have no quotes!");
-            return;
-        }
-
-        const quotesListing = quotes.reduce((p, c) => `${p}[${c.id}] "${this.minify(c.message, 10)}" by ${c.author}\n`, "\n");
-        msg.channel.send(`${msg.author.toString()}I know the following quotes:\n\`\`\`${quotesListing}\`\`\``);
-    }
-}

+ 0 - 51
bot/src/commands/rcg.ts

@@ -1,51 +0,0 @@
-import { Message } from "discord.js";
-import { Command, CommandSet } from "src/model/command";
-import got from "got";
-import { logger } from "src/logging";
-
-const rcgRe = /<input id="rcg_image".+value="([^"]+)".*\/>/i;
-
-interface XkcdResponse {
-    img: string;
-}
-
-@CommandSet
-export class Rcg {
-    async sendErrorMessage(msg: Message): Promise<void> {
-        const xkcdResult = await got.get<XkcdResponse>("https://xkcd.com/info.0.json", { responseType: "json" });
-        if(xkcdResult.statusCode != 200) {
-            await msg.channel.send(`${msg.author.toString()} Sorry, I couldn't get any comics :(.`);
-            return;
-        }
-        await msg.channel.send(`${msg.author.toString()} Sorry, I couldn't get a random comic! Here is today's XKCD instead:`, {
-            files: [ xkcdResult.body.img ]
-        });
-    }
-
-    @Command({
-        pattern: "random comic",
-        auth: false,
-        documentation: {description: "Generates a comic just for you!", example: "random comic"}
-    })
-    async randomComic(msg: Message): Promise<void> {
-        const result = await got.get("http://explosm.net/rcg/view/?promo=false");
-    
-        if (result.statusCode != 200) {
-            logger.error("Failed to get RCG. Got status: %s", result.statusCode);
-            await this.sendErrorMessage(msg);
-            return;
-        }
-
-        const regexResult = rcgRe.exec(result.body);
-
-        if(!regexResult) {
-            logger.error("Could not find RCG from body. Got response body: %s", result.body);
-            await this.sendErrorMessage(msg);
-            return;
-        }
-
-        await msg.channel.send(`${msg.author.toString()} I find this very funny:`, {
-            files: [ regexResult[1].trim() ]
-        });
-    }
-}

+ 8 - 9
bot/src/main.ts

@@ -4,23 +4,22 @@ require("module-alias/register");
 import "./environment";
 import * as path from "path";
 import { client } from "./client";
-import * as mCmd from "./model/command";
 import { createConnection, getConnectionOptions, getRepository } from "typeorm";
 import { formatString, assertOk } from "./util";
 import { DB_ENTITIES } from "@shared/db/entities";
 import { logger } from "./logging";
 import { GuildGreeting } from "@shared/db/entity/GuildGreeting";
 import { TextChannel, GuildMember, PartialGuildMember } from "discord.js";
-import { CommandManager } from "./command_manager";
+import { PluginManager } from "./plugin_manager";
 
-export const cmdMgr: CommandManager = new CommandManager(path.resolve(path.dirname(module.filename), "commands"));
+export const plgMgr: PluginManager = new PluginManager(path.resolve(path.dirname(module.filename), "plugins"));
 
 const REACT_PROBABILITY = 0.3;
 
 client.bot.on("ready", async () => {
     logger.info("Starting up NoctBot");
     await client.botUser.setActivity(`@${client.botUser.username} help`, { type: "PLAYING" });
-    await assertOk(cmdMgr.onStart());
+    await assertOk(plgMgr.start(client.bot));
     logger.info("NoctBot is ready");
 });
 
@@ -42,7 +41,7 @@ client.bot.on("message", async m => {
 
     let content = m.cleanContent.trim();
 
-    if (await cmdMgr.trigger(mCmd.ActionType.MESSAGE, m, content))
+    if (await plgMgr.trigger("message", m, content))
         return;
 
     if (m.mentions.users.size > 0 && m.mentions.users.has(client.botUser.id)) {
@@ -51,18 +50,18 @@ client.bot.on("message", async m => {
             content = content.substring(`@${client.botUser.username}`.length).trim();
             const lowerCaseContent = content.toLowerCase();
             
-            if (cmdMgr.runCommand(m, content))
+            if (plgMgr.runCommand(m, content))
                 return;
 
-            if (await cmdMgr.trigger(mCmd.ActionType.DIRECT_MENTION, m, lowerCaseContent))
+            if (await plgMgr.trigger("directMention", m, lowerCaseContent))
                 return;
         }
 
-        if (await cmdMgr.trigger(mCmd.ActionType.INDIRECT_MENTION, m))
+        if (await plgMgr.trigger("indirectMention", m))
             return;
     }
 
-    await cmdMgr.trigger(mCmd.ActionType.POST_MESSAGE);
+    await plgMgr.trigger("postMessage", m);
 });
 
 client.bot.on("messageReactionAdd", (r, u) => {

+ 89 - 0
bot/src/model/plugin.ts

@@ -0,0 +1,89 @@
+import { Message, ClientEvents } from "discord.js";
+import { EsModuleClass, isModuleClass } from "../util";
+
+export interface ICommand extends CommandOptions {
+    action : MessageCommand;
+}
+
+export interface CommandOptions {
+    pattern: string | RegExp;
+    documentation?: CommandDocumentation;
+    auth?: boolean;
+}
+
+export interface CommandDocumentation {
+    description: string;
+    example: string;
+}
+
+export type MessageCommand = (data: ICommandData) => void | Promise<void>;
+export interface ICommandData {
+    message: Message;
+    contents: string | RegExpMatchArray;
+}
+
+export type BotEvent = (data: BotEventData, ...params: unknown[]) => void | Promise<void>;
+export interface BotEventData {
+    actionsDone: boolean;
+}
+
+interface CustomEventType {
+    message: [string];
+    indirectMention: [string];
+    directMention: [string];
+    postMessage: [string];
+}
+const customEvents : Set<keyof CustomEventType> = new Set(["directMention", "indirectMention", "message", "postMessage"]);
+export type EventType = keyof ClientEvents | keyof CustomEventType;
+
+export function isCustomEvent(s: string): s is keyof CustomEventType {
+    return customEvents.has(s as keyof CustomEventType);
+}
+
+// export class PluginBase {
+//     botCommands: ICommand[] = [];
+//     botEvents: { [action in EventType]?: BotEvent } = {};
+//     async start(): Promise<void> {
+//         // empty on purpose
+//     }
+// }
+export interface IPlugin {
+    PLUGIN_TYPE?: string;
+    botCommands?: ICommand[];
+    botEvents?: { [action in EventType]?: BotEvent };
+    start?(): Promise<void>;
+}
+
+export function Command(opts: CommandOptions) : MethodDecorator {
+    return function<T>(target: unknown, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>): void {
+        if(!descriptor.value)
+            throw new Error("The decorator value must be initialized!");
+        const plg = target as IPlugin;
+        if (!plg.botCommands)
+            plg.botCommands = [];
+        plg.botCommands.push({
+            action: descriptor.value as unknown as MessageCommand,
+            ...opts
+        });
+    };
+}
+
+export function Event(type: EventType): MethodDecorator {
+    return function<T>(target: unknown, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>): void {
+        if(!descriptor.value)
+            throw new Error("The decorator value must be initialized!");
+        const plg = target as IPlugin;
+        if(!plg.botEvents)
+            plg.botEvents = {};
+        plg.botEvents[type] = descriptor.value as unknown as BotEvent;
+    };
+}
+
+export const PLUGIN_TYPE_DESCRIPTOR = "PLUGIN_TYPE_DESCRIPTOR";
+export function Plugin<T extends {new(...params: unknown[]): unknown}>(base: T): void {
+    base.prototype.PLUGIN_TYPE = PLUGIN_TYPE_DESCRIPTOR;
+}
+
+export function isPlugin(obj: unknown): obj is EsModuleClass<IPlugin> {
+    return isModuleClass<IPlugin>(obj) && obj.prototype.PLUGIN_TYPE == PLUGIN_TYPE_DESCRIPTOR;
+}

+ 139 - 0
bot/src/plugin_manager.ts

@@ -0,0 +1,139 @@
+import path from "path";
+import fs from "fs";
+import { Message, Client, ClientEvents } from "discord.js";
+import { EventType, BotEvent, ICommand, isPlugin, BotEventData, isCustomEvent, IPlugin } from "./model/plugin";
+
+
+interface IDocumentationData {
+    name: string;
+    doc?: string;
+    example?: string;
+    auth: boolean;
+}
+
+type BotEventCollection = { [event in EventType]?: BotEvent[] };
+
+export class PluginManager {
+    private plugins: IPlugin[] = [];
+    private commands: ICommand[] = [];
+    private botEvents: BotEventCollection = {};
+
+    constructor(private cmdPath: string) {
+        this.init();
+    }
+
+    private init(): void {
+        const files = fs.readdirSync(this.cmdPath);
+
+        for (const file of files) {
+            const ext = path.extname(file);
+            if (ext != ".js")
+                continue;
+
+            // eslint-disable-next-line @typescript-eslint/no-var-requires
+            this.loadCommand(require(path.resolve(this.cmdPath, file)));
+        }
+    }
+
+    private loadCommand(mod: Record<string, unknown>) {
+        const getBotEventArray = (evtType: EventType) => {
+            if (!this.botEvents[evtType])
+                this.botEvents[evtType] = [];
+            return this.botEvents[evtType];
+        };
+
+        for (const i in mod) {
+            if (!Object.prototype.hasOwnProperty.call(mod, i))
+                continue;
+    
+            const commandClass = mod[i] as unknown;
+            // Ensure this is indeed a command class
+            if (!isPlugin(commandClass))
+                continue;
+    
+            const cmd = new commandClass();
+            this.plugins.push(cmd);
+    
+            if (cmd.botCommands)
+                this.commands.push(...cmd.botCommands.map(c => ({ ...c, action: c.action.bind(cmd) })));
+    
+            if (cmd.botEvents)
+                for (const [i, event] of Object.entries(cmd.botEvents)) {
+                    getBotEventArray(i as EventType)?.push((event as BotEvent).bind(cmd));
+                }
+        }
+    }
+
+    get documentation(): IDocumentationData[] {
+        return this.commands.filter(m => m.documentation !== undefined).map(m => ({
+            name: m.pattern.toString(),
+            doc: m.documentation?.description,
+            example: m.documentation?.example,
+            auth: m.auth || false
+        }));
+    }
+
+    async start(client: Client): Promise<void> {
+        for (const evtName of Object.keys(this.botEvents)) {
+            if (Object.prototype.hasOwnProperty.call(this.botEvents, evtName) || isCustomEvent(evtName))
+                continue;
+            client.on(evtName as keyof ClientEvents, async (...args: unknown[]) =>
+                await this.trigger(evtName as EventType, ...args)
+            );
+        }
+    
+        for (const plugin of this.plugins) {
+            if (plugin.start)
+                await plugin.start();
+        }
+    }
+
+    async trigger(event: EventType, ...params: unknown[]): Promise<boolean> {
+        const eventData: BotEventData = {
+            actionsDone: false
+        };
+        const eventHandlers = this.botEvents[event];
+        if (!eventHandlers)
+            return eventData.actionsDone;
+
+        for (const handler of eventHandlers) {
+            const actionResult = handler(eventData, ...params);
+            if (actionResult instanceof Promise)
+                await actionResult;
+        }
+        
+        return eventData.actionsDone;
+    }
+
+    async runCommand(m: Message, content: string): Promise<boolean> {
+        const lowerCaseContent = content.toLowerCase();
+        for (const c of this.commands) {
+            let match = false;
+            let eventResult: void | Promise<void> = undefined;
+            if (typeof (c.pattern) == "string" && lowerCaseContent.startsWith(c.pattern)) {
+                match = true;
+                eventResult = c.action({
+                    message: m,
+                    contents: content
+                });
+            }
+            else if (c.pattern instanceof RegExp) {
+                const result = c.pattern.exec(content);
+                if (result != null) {
+                    match = true;
+                    eventResult = c.action({
+                        message: m,
+                        contents: result
+                    });
+                }
+            }
+
+            if (match) {
+                if (eventResult instanceof Promise)
+                    await eventResult;
+                return true;
+            }
+        }
+        return false;
+    }
+}

bot/src/commands/aggregators/aggregator.ts → bot/src/plugins/aggregators/aggregator.ts


bot/src/commands/aggregators/com3d2_updates.ts → bot/src/plugins/aggregators/com3d2_updates.ts


bot/src/commands/aggregators/com3d2_world.ts → bot/src/plugins/aggregators/com3d2_world.ts


bot/src/commands/aggregators/kiss_diary.ts → bot/src/plugins/aggregators/kiss_diary.ts


+ 12 - 13
bot/src/commands/dead_chat.ts

@@ -1,7 +1,7 @@
 import { getRepository } from "typeorm";
 import { DeadChatReply } from "@shared/db/entity/DeadChatReply";
-import { CommandSet, Action, ActionType } from "src/model/command";
 import { Message } from "discord.js";
+import { Event, BotEventData, Plugin } from "src/model/plugin";
 
 const triggers = [
     "dead server",
@@ -10,30 +10,29 @@ const triggers = [
     "ded server"
 ];
 
-@CommandSet
+@Plugin
 export class DeadChat {
-    @Action(ActionType.MESSAGE)
-    async onMessage(actionsDone: boolean, msg: Message, content: string): Promise<boolean> {
-        if (actionsDone)
-            return false;
-
-        const lowerContent = content.toLowerCase();
+    @Event("message")
+    async onMessage(data: BotEventData, msg: Message): Promise<void> {
+        if (data.actionsDone)
+            return;
+        
+        const lowerContent = msg.cleanContent.trim().toLowerCase();
 
         if (!triggers.some(s => lowerContent.includes(s)))
-            return false;
+            return;
 
         const repo = getRepository(DeadChatReply);
 
-        const reply = await repo.query(`  select message
+        const reply = await repo.query(`select message
                                         from dead_chat_reply
                                         order by random()
                                         limit 1`) as DeadChatReply[];
 
         if (reply.length == 0)
-            return false;
+            return;
 
         msg.channel.send(reply[0].message);
-
-        return true;
+        data.actionsDone = true;
     }
 }

+ 21 - 22
bot/src/commands/facemorph.ts

@@ -5,10 +5,10 @@ import { Message, MessageAttachment } from "discord.js";
 import { getRepository } from "typeorm";
 import { FaceCaptionMessage, FaceCaptionType } from "@shared/db/entity/FaceCaptionMessage";
 import { KnownChannel } from "@shared/db/entity/KnownChannel";
-import { CommandSet, Action, ActionType, Command } from "src/model/command";
 import { logger } from "src/logging";
 import got from "got";
 import FormData from "form-data";
+import { Event, BotEventData, Command, ICommandData, Plugin } from "src/model/plugin";
 
 const EMOTE_GUILD = "505333548694241281";
 
@@ -42,7 +42,7 @@ function isError(resp: FaceDetectionResponse): resp is ErrorInfo {
 type ImageProcessor = (faces: Rect[], data: Buffer) => Promise<Jimp>;
 const CAPTION_OFFSET = 5;
 
-@CommandSet
+@Plugin
 export class Facemorph {
 
     squareFace(rect: Rect): Rect {
@@ -260,7 +260,7 @@ export class Facemorph {
             .filter(v => !v.msg.author.bot && v.att != undefined).last() as AttachedMessage;
 
         if (!lastImagedMessage) {
-            msg.channel.send(`${msg.author.toString()} Sorry, I couldn't find any recent messages with images.`);
+            msg.reply("sorry, I couldn't find any recent messages with images.");
             return;
         }
 
@@ -276,17 +276,17 @@ export class Facemorph {
         ).catch(err => logger.error(`Failed to run faceapp on message ${msg.id}`, err));
     }
 
-    @Action(ActionType.MESSAGE)
-    async morphRandomImage(actionsDone: boolean, msg: Message): Promise<boolean> {
-        if (actionsDone) return false;
+    @Event("message")
+    async morphRandomImage(data: BotEventData, msg: Message): Promise<void> {
+        if (data.actionsDone) 
+            return;
 
         if (msg.mentions.users.size > 0 && msg.mentions.users.first()?.id == client.botUser.id)
-            return false;
+            return;
 
         const imageAttachment = msg.attachments.find(v => isValidImage(v.name));
 
         if (imageAttachment) {
-
             const repo = getRepository(KnownChannel);
 
             const knownChannel = await repo.findOne({
@@ -295,20 +295,19 @@ export class Facemorph {
             });
 
             if (!knownChannel || Math.random() > knownChannel.faceMorphProbability)
-                return false;
+                return;
 
             this.processFaceSwap(msg, imageAttachment.url).catch(err =>
                 logger.error(`Failed to run faceapp on message ${msg.id}`, err)
             );
-            return true;
+            data.actionsDone = true;
         }
-
-        return false;
     }
 
-    @Action(ActionType.DIRECT_MENTION)
-    async morphProvidedImage(actionsDone: boolean, msg: Message, content: string): Promise<boolean> {
-        if (actionsDone) return false;
+    @Event("directMention")
+    async morphProvidedImage(data: BotEventData, msg: Message, content: string): Promise<void> {
+        if (data.actionsDone) 
+            return;
 
         const image = msg.attachments.find(v => isValidImage(v.name));
         if (!image) {
@@ -316,9 +315,9 @@ export class Facemorph {
                 msg.channel.send(
                     `${msg.author.toString()} Nice, but I can't do anything to it! (Invalid file type)`
                 );
-                return true;
+                data.actionsDone = true;
             }
-            return false;
+            return;
         }
 
         let processor;
@@ -338,20 +337,20 @@ export class Facemorph {
             `${msg.author.toString()} ${emojiText}`
         ).catch(err => logger.error(`Failed to run faceapp because ${msg.id}`, err));
 
-        return true;
+        data.actionsDone = true;
     }
 
     @Command({
         pattern: "caption last image"
     })
-    captionLastImage(msg: Message): void {
-        this.processLastImage(msg, this.captionFace);
+    captionLastImage({ message }: ICommandData): void {
+        this.processLastImage(message, this.captionFace);
     }
 
     @Command({
         pattern: "look at last image"
     })
-    lookLastImage(msg: Message): void {
-        this.processLastImage(msg, this.morphFaces);
+    lookLastImage({ message }: ICommandData): void {
+        this.processLastImage(message, this.morphFaces);
     }
 }

+ 10 - 10
bot/src/commands/file_only_channel_checker.ts

@@ -1,29 +1,29 @@
-import { CommandSet, Action, ActionType } from "src/model/command";
 import { Message, TextChannel } from "discord.js";
 import { getRepository } from "typeorm";
 import { FileOnlyChannel } from "@shared/db/entity/FileOnlyChannel";
+import { Event, BotEventData, Plugin } from "src/model/plugin";
 
-@CommandSet
+@Plugin
 export class FileOnlyChannelChecker {
     private urlPattern = /(?:((?:https?|ftp):\/\/)|ww)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?/;
 
-    @Action(ActionType.MESSAGE)
-    async processMessage(actionsDone: boolean, msg: Message): Promise<boolean> {
-        if (actionsDone)
-            return false;
+    @Event("message")
+    async processMessage(data: BotEventData, msg: Message): Promise<void> {
+        if (data.actionsDone)
+            return;
 
         const repo = getRepository(FileOnlyChannel);
         const entry = await repo.findOne(msg.channel.id);
         if (!entry)
-            return false;
+            return;
 
         // Has attachments; is fine
         if (msg.attachments.size > 0)
-            return false;
+            return;
 
         // Has a link
         if(this.urlPattern.test(msg.content))
-            return false;
+            return;
 
         msg.delete();
 
@@ -32,6 +32,6 @@ export class FileOnlyChannelChecker {
         if(ch instanceof TextChannel)
             ch.send(`> ${msg.content.replace(/\n/g, "\n> ")}\n\n${msg.author.toString()} Channel ${msg.channel.toString()} is only meant for sharing links and files! If you want to post text-only messages, do so in ${ch.toString()}!`);
 
-        return true;
+        data.actionsDone = true;
     }
 }

+ 11 - 11
bot/src/commands/forums_news_checker.ts

@@ -2,15 +2,15 @@ import TurndownService from "turndown";
 import interval from "interval-promise";
 import { client, FORUMS_DOMAIN } from "../client";
 import sha1 from "sha1";
-import { TextChannel, Message, ReactionCollector, MessageReaction, Collector, User, Channel } from "discord.js";
+import { TextChannel, Message, ReactionCollector, MessageReaction, User, Channel } from "discord.js";
 import { Dict } from "../util";
 import { getRepository, Not, IsNull } from "typeorm";
 import { PostedForumNewsItem } from "@shared/db/entity/PostedForumsNewsItem";
 import { KnownChannel } from "@shared/db/entity/KnownChannel";
 import { PostVerifyMessage } from "@shared/db/entity/PostVerifyMessage";
 import { render } from "../bbcode-parser/bbcode-js";
-import { CommandSet, Command } from "src/model/command";
 import { logger } from "src/logging";
+import { Command, ICommandData, Plugin } from "src/model/plugin";
 
 const PREVIEW_CHAR_LIMIT = 300;
 const NEWS_POST_VERIFY_CHANNEL = "newsPostVerify";
@@ -18,7 +18,7 @@ const NEWS_FEED_CHANNEL = "newsFeed";
 const RSS_UPDATE_INTERVAL_MIN = process.env.NODE_ENV == "dev" ? 60 : 5;
 const NEWS_FORUM_ID = 49;
 
-@CommandSet
+@Plugin
 export class ForumsNewsChecker {
 
     verifyMessageIdToPost: Dict<string> = {}
@@ -292,13 +292,13 @@ React with ✅ (approve) or ❌ (deny).`;
     @Command({
         pattern: /^edit (\d+)\s+((.*[\n\r]*)+)$/i
     })
-    async editPreview(msg: Message, contests: string, match: RegExpMatchArray): Promise<void> {
-        if (msg.channel.id != this.verifyChannelId)
+    async editPreview({ message, contents }: ICommandData): Promise<void> {
+        if (message.channel.id != this.verifyChannelId)
             return;
-
+        
+        const match = contents as RegExpMatchArray;
         const id = match[1];
         const newContents = match[2].trim();
-
         const repo = getRepository(PostedForumNewsItem);
         const verifyRepo = getRepository(PostVerifyMessage);
 
@@ -308,14 +308,14 @@ React with ✅ (approve) or ❌ (deny).`;
         });
 
         if (!post || !post.verifyMessage) {
-            msg.channel.send(`${msg.author.toString()} No unapproved news items with id ${id}!`);
+            message.reply(`no unapproved news items with id ${id}!`);
             return;
         }
 
         const editMsg = await this.tryFetchMessage(client.bot.channels.resolve(this.verifyChannelId) ?? undefined, post.verifyMessage.messageId);
 
         if (!editMsg) {
-            msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);
+            message.reply(`no verify message found for ${id}! This is a bug: report to horse.`);
             return;
         }
 
@@ -323,10 +323,10 @@ React with ✅ (approve) or ❌ (deny).`;
 
         await verifyRepo.save(post.verifyMessage);
         await editMsg.edit(this.toVerifyString(post.id, post.verifyMessage));
-        await msg.delete();
+        await message.delete();
     }
 
-    async onStart(): Promise<void> {
+    async start(): Promise<void> {
         const repo = getRepository(KnownChannel);
 
         const verifyChannel = await repo.findOne({

+ 55 - 40
bot/src/commands/guide.ts

@@ -2,9 +2,9 @@ import { isAuthorisedAsync } from "../util";
 import { Message } from "discord.js";
 import { getRepository } from "typeorm";
 import { Guide, GuideType, GuideKeyword } from "@shared/db/entity/Guide";
-import { CommandSet, Action, ActionType, Command } from "src/model/command";
+import { Event, BotEventData, Command, ICommandData, Plugin } from "src/model/plugin";
 
-@CommandSet
+@Plugin
 export class GuideCommands {
     async matchGuide(keywords: string[]): Promise<Guide | null> {
         const a = await getRepository(Guide).query(
@@ -58,23 +58,22 @@ export class GuideCommands {
         }
     }
 
-    @Action(ActionType.DIRECT_MENTION)
-    async displayGuide(actionsDone: boolean, msg: Message, content: string): Promise<boolean> {
-        if (actionsDone)
-            return false;
+    @Event("directMention")
+    async displayGuide(data: BotEventData, msg: Message, lowerCaseContent: string): Promise<void> {
+        if (data.actionsDone)
+            return;
 
-        if (msg.attachments.size > 0 || content.length == 0)
-            return false;
+        if (msg.attachments.size > 0 || lowerCaseContent.length == 0)
+            return;
 
-        const parts = content.split(" ").map(s => s.trim()).filter(s => s.length != 0);
+        const parts = lowerCaseContent.split(" ").map(s => s.trim()).filter(s => s.length != 0);
 
         const guide = await this.matchGuide(parts);
 
         if (guide) {
             msg.channel.send(guide.content);
-            return true;
+            data.actionsDone = true;
         }
-        return false;
     }
 
     @Command({
@@ -85,24 +84,21 @@ export class GuideCommands {
             example: "make <GUIDE TYPE> <NEWLINE>name: <NAME> <NEWLINE> keywords: <KEYWORDS> <NEWLINE> contents: <CONTENTS>"
         }
     })
-    async makeGuide(msg: Message, content: string, match: RegExpMatchArray): Promise<void> {
-        if (!await isAuthorisedAsync(msg.member)) return;
+    async makeGuide({ message, contents }: ICommandData): Promise<void> {
+        if (!await isAuthorisedAsync(message.member)) return;
+        const match = contents as RegExpMatchArray;
         const type = match[1].toLowerCase();
         const name = match[2].trim();
         const keywords = match[3].toLowerCase().split(" ").map(s => s.trim()).filter(s => s.length != 0);
-        const contents = match[4].trim();
+        const msgContents = match[4].trim();
 
-        if (contents.length == 0) {
-            msg.channel.send(
-                `${msg.author.toString()} The guide must have some content!`
-            );
+        if (msgContents.length == 0) {
+            message.reply("the guide must have some content!");
             return;
         }
         
         if (!(<string[]>Object.values(GuideType)).includes(type)) {
-            msg.channel.send(
-                `${msg.author.toString()} The type ${type} is not a valid guide type!`
-            );
+            message.reply(`the type ${type} is not a valid guide type!`);
             return;
         }
 
@@ -130,7 +126,7 @@ export class GuideCommands {
             })));
 
             await guideRepo.save(guideRepo.create({
-                content: contents,
+                content: msgContents,
                 displayName: name,
                 keywords: [...existingKeywords, ...addedKeywords],
                 type: type as GuideType
@@ -147,15 +143,15 @@ export class GuideCommands {
             if (guideKeywordsCount == existingKeywords.length)
                 await guideRepo.update({ id: existingGuide.id }, {
                     displayName: name,
-                    content: contents
+                    content: msgContents
                 });
             else
                 await addGuide();
         } else
             await addGuide();
 
-        msg.channel.send(
-            `${msg.author.toString()} Added/updated "${name}" (keywords \`${keywords.join(" ")}\`)!`
+        message.reply(
+            `Added/updated "${name}" (keywords \`${keywords.join(" ")}\`)!`
         );
     }
 
@@ -167,14 +163,15 @@ export class GuideCommands {
             description: "Deletes a guide with the specified keywords"
         }
     })
-    async deleteGuide(msg: Message, content: string, match: RegExpMatchArray): Promise<void> {
-        if (!await isAuthorisedAsync(msg.member)) return;
+    async deleteGuide({ message, contents }: ICommandData): Promise<void> {
+        if (!await isAuthorisedAsync(message.member)) return;
+        const match = contents as RegExpMatchArray;
         const type = match[1];
         const keywords = match[2].toLowerCase().split(" ").map(s => s.trim()).filter(s => s.length != 0);
 
         if (!(<string[]>Object.values(GuideType)).includes(type)) {
-            await msg.channel.send(
-                `${msg.author.toString()} The type ${type} is not a valid guide type!`
+            await message.reply(
+                `The type ${type} is not a valid guide type!`
             );
             return;
         }
@@ -194,26 +191,44 @@ export class GuideCommands {
 
             if (guideKeywordsCount == dedupedKeywords.length) {
                 await guideRepo.delete({ id: existingGuide.id });
-                await msg.channel.send(`${msg.author.toString()} Removed ${type} "${keywords.join(" ")}"!`);
+                await message.reply(`removed ${type} "${keywords.join(" ")}"!`);
                 return;
             }
         }
 
-        await msg.channel.send(`${msg.author.toString()} No such ${type} with keywords \`${keywords.join(" ")}\`! Did you forget to specify all keywords?`);
+        await message.reply(`no such ${type} with keywords \`${keywords.join(" ")}\`! Did you forget to specify all keywords?`);
     }
 
-    @Command({ pattern: "guides", documentation: { description: "Lists all guides and keywords that trigger them.", example: "guides" } })
-    async showGuides(msg: Message): Promise<void> {
-        await this.listGuides(msg, "guide", "Here are the guides I have:");
+    @Command({ 
+        pattern: "guides", 
+        documentation: { 
+            description: "Lists all guides and keywords that trigger them.", 
+            example: "guides" 
+        } 
+    })
+    async showGuides({ message }: ICommandData): Promise<void> {
+        await this.listGuides(message, "guide", "Here are the guides I have:");
     }
 
-    @Command({ pattern: "memes", documentation: {description: "Lists all memes and keywords that trigger them.", example: "memes"} })
-    async showMemes(msg: Message): Promise<void> {
-        await this.listGuides(msg, "meme", "Here are some random memes I have:");
+    @Command({ 
+        pattern: "memes", 
+        documentation: {
+            description: "Lists all memes and keywords that trigger them.", 
+            example: "memes"
+        } 
+    })
+    async showMemes({ message }: ICommandData): Promise<void> {
+        await this.listGuides(message, "meme", "Here are some random memes I have:");
     }
 
-    @Command({ pattern: "misc", documentation: {description: "Lists all additional keywords the bot reacts to.", example: "misc"} })
-    async showMisc(msg: Message): Promise<void> {
-        await this.listGuides(msg, "misc", "These are some misc stuff I can also do:");
+    @Command({
+        pattern: "misc", 
+        documentation: {
+            description: "Lists all additional keywords the bot reacts to.", 
+            example: "misc"
+        } 
+    })
+    async showMisc({ message }: ICommandData): Promise<void> {
+        await this.listGuides(message, "misc", "These are some misc stuff I can also do:");
     }
 }

+ 29 - 0
bot/src/plugins/help.ts

@@ -0,0 +1,29 @@
+import { isAuthorisedAsync } from "../util";
+import { plgMgr } from "src/main";
+import { Command, ICommandData, Plugin } from "src/model/plugin";
+import { client } from "src/client";
+
+@Plugin
+export class Help {
+    @Command({ pattern: "help" })
+    async showHelp({ message }: ICommandData): Promise<void> {
+        const isAuthed = await isAuthorisedAsync(message.member);
+
+        let baseCommands = "\n";
+        let modCommands = "\n";
+        
+        for (const doc of plgMgr.documentation) {
+            if (isAuthed && doc.auth)
+                modCommands = `${modCommands}${doc.example}  -  ${doc.doc}\n`;
+            else if (!doc.auth)
+                baseCommands = `${baseCommands}${doc.example} - ${doc.doc}\n`;
+        }
+
+        let msg = `Hello! I am ${client.botUser.username}! My job is to help with C(O)M-related problems!\nPing me with one of the following commands:\n\`\`\`${baseCommands}\`\`\``;
+
+        if (isAuthed)
+            msg = `${msg}\n👑**Moderator commands**👑\n\`\`\`${modCommands}\`\`\``;
+
+        message.reply(msg);
+    }
+}

+ 26 - 0
bot/src/plugins/inspire.ts

@@ -0,0 +1,26 @@
+import got from "got";
+import { logger } from "src/logging";
+import { Command, ICommandData, Plugin } from "src/model/plugin";
+import { tryDo } from "src/util";
+
+@Plugin
+export class Inspire {
+    @Command({ 
+        pattern: "inspire me", 
+        documentation: {
+            description: "Generates an inspiring quote just for you", 
+            example: "inspire me"
+        }
+    })
+    async inspire({ message }: ICommandData): Promise<void> {
+        const result = await tryDo(got.get("https://inspirobot.me/api?generate=true"));
+        if(!result.ok || !result.result) {
+            logger.error("Failed to get inspiration, error %s", result.error);
+            await message.reply("sorry, couldn't get inspiration :(.");
+            return;
+        }
+        message.reply("here is a piece of my wisdom:", {
+            files: [ result.result.body ]
+        });
+    }
+}

+ 4 - 4
bot/src/commands/news_aggregator.ts

@@ -7,13 +7,13 @@ import * as fs from "fs";
 import { HTML2BBCode } from "html2bbcode";
 import { Dict } from "../util";
 import { IAggregator, NewsPostItem } from "./aggregators/aggregator";
-import { TextChannel, Message, Channel, ReactionCollector, MessageReaction, User, Collector, MessageEmbed } from "discord.js";
+import { TextChannel, Message, Channel, ReactionCollector, MessageReaction, User, MessageEmbed } from "discord.js";
 import { getRepository, IsNull, Not } from "typeorm";
 import { KnownChannel } from "@shared/db/entity/KnownChannel";
 import { AggroNewsItem } from "@shared/db/entity/AggroNewsItem";
 import { v3beta1 } from "@google-cloud/translate";
-import { CommandSet } from "src/model/command";
 import { logger } from "src/logging";
+import { Plugin } from "src/model/plugin";
 const { TranslationServiceClient } = v3beta1;
 
 const UPDATE_INTERVAL = process.env.NODE_ENV == "dev" ? 60 : 5;
@@ -22,7 +22,7 @@ const AGGREGATOR_MANAGER_CHANNEL = "aggregatorManager";
 const FORUMS_STAGING_ID = 54;
 const FORUMS_NEWS_ID = 49;
 
-@CommandSet
+@Plugin
 export class NewsAggregator {
     tlClient = new TranslationServiceClient();
     aggregators: IAggregator[] = [];
@@ -291,7 +291,7 @@ export class NewsAggregator {
         }
     }
 
-    async onStart(): Promise<void> {
+    async start(): Promise<void> {
         const repo = getRepository(KnownChannel);
 
         const ch = await repo.findOne({

+ 121 - 0
bot/src/plugins/quote.ts

@@ -0,0 +1,121 @@
+import { isAuthorisedAsync } from "../util";
+import { getRepository } from "typeorm";
+import { Quote } from "@shared/db/entity/Quote";
+import { Command, ICommandData, Plugin } from "src/model/plugin";
+
+const quotePattern = /add quote by "([^"]+)"\s*(.*)/i;
+
+@Plugin
+export class QuoteCommand {
+
+    minify(str: string, maxLength: number): string {
+        let result = str.replace("\n", "");
+        if (result.length > maxLength)
+            result = `${result.substring(0, maxLength - 3)}...`;
+        return result;
+    }
+
+    @Command({ 
+        pattern: "add quote", 
+        auth: true, 
+        documentation: {
+            description: "Adds a quote", 
+            example: "add quote by \"<NAME>\" <NEWLINE> <QUOTE>"
+        }
+    })
+    async addQuote({ message, contents }: ICommandData): Promise<void> {
+        if (!isAuthorisedAsync(message.member))
+            return;
+
+        const result = quotePattern.exec(contents as string);
+
+        if (result == null)
+            return;
+
+        const author = result[1].trim();
+        const msg = result[2].trim();
+
+        const repo = getRepository(Quote);
+
+        const newQuote = await repo.save(repo.create({
+            author: author,
+            message: msg
+        }));
+
+        message.reply(`added quote (ID: ${newQuote.id})!`);
+    }
+
+    @Command({
+        pattern: "random quote", 
+        documentation: {
+            description: "Shows a random quote by someone special...", 
+            example: "random quote"
+        }
+    })
+    async postRandomQuote({ message }: ICommandData): Promise<void> {
+        const repo = getRepository(Quote);
+
+        const quotes = await repo.query(`  select *
+                                                from quote
+                                                order by random()
+                                                limit 1`) as Quote[];
+
+        if (quotes.length == 0) {
+            message.reply("I have no quotes!");
+            return;
+        }
+
+        const quote = quotes[0];
+        message.channel.send(`Quote #${quote.id}:\n*"${quote.message}"*\n- ${quote.author}`);
+    }
+
+    @Command({
+        pattern: "remove quote",
+        auth: true,
+        documentation: {
+            description: "Removes quote. Use \"quotes\" to get the <quote_index>!", 
+            example: "remove quote <quote_index>"
+        }
+    })
+    async removeQuote({ message, contents }: ICommandData): Promise<void> {
+        const quoteNum = (contents as string).substring("remove quote".length).trim();
+        const val = parseInt(quoteNum);
+        if (isNaN(val))
+            return;
+
+        const repo = getRepository(Quote);
+
+        const res = await repo.delete({ id: val });
+        if (res.affected == 0)
+            return;
+
+        message.reply(`removed quote #${val}!`);
+    }
+
+    @Command({
+        pattern: "quotes", 
+        documentation: {
+            description: "Lists all known quotes.", 
+            example: "quotes"
+        }, 
+        auth: true 
+    })
+    async listQuotes({ message }: ICommandData): Promise<void> {
+        if (!isAuthorisedAsync(message.member)) {
+            message.reply("to prevent spamming, only bot moderators can view all quotes!");
+            return;
+        }
+
+        const repo = getRepository(Quote);
+
+        const quotes = await repo.find();
+
+        if (quotes.length == 0) {
+            message.reply("I have no quotes!");
+            return;
+        }
+
+        const quotesListing = quotes.reduce((p, c) => `${p}[${c.id}] "${this.minify(c.message, 10)}" by ${c.author}\n`, "\n");
+        message.reply(`I know the following quotes:\n\`\`\`${quotesListing}\`\`\``);
+    }
+}

+ 7 - 9
bot/src/commands/random_react.ts

@@ -1,35 +1,33 @@
-import { CommandSet, Action, ActionType } from "src/model/command";
 import { Message } from "discord.js";
 import { getRepository } from "typeorm";
 import { RandomMessageReaction } from "@shared/db/entity/RandomMesssageReaction";
 import { client } from "src/client";
+import { Event, BotEventData, Plugin } from "src/model/plugin";
 
 const timeout = (ms: number) => new Promise(r => setTimeout(r, ms));
 
-@CommandSet
+@Plugin
 export class RandomReact {
-    @Action(ActionType.MESSAGE)
-    async showHelp(actionsDone: boolean, msg: Message): Promise<boolean> {
+    @Event("message")
+    async showHelp({ actionsDone }: BotEventData, msg: Message): Promise<void> {
         if(actionsDone)
-            return false;
+            return;
 
         const repo = getRepository(RandomMessageReaction);
 
         const reactInfo = await repo.findOne({ where: { userId: msg.author.id } });
 
         if(!reactInfo)
-            return false;
+            return;
 
         const emote = client.bot.emojis.resolve(reactInfo.reactionEmoteId);
 
         if(!emote)
-            return false;
+            return;
 
         if(Math.random() < reactInfo.reactProbability) {
             await timeout(Math.random() * reactInfo.maxWaitMs);
             await msg.react(emote);
         }
-        
-        return false;
     }
 }

+ 52 - 0
bot/src/plugins/rcg.ts

@@ -0,0 +1,52 @@
+import { Message } from "discord.js";
+import got from "got";
+import { logger } from "src/logging";
+import { Command, ICommandData, Plugin } from "src/model/plugin";
+import { tryDo } from "src/util";
+
+const rcgRe = /<input id="rcg_image".+value="([^"]+)".*\/>/i;
+
+interface XkcdResponse {
+    img: string;
+}
+
+@Plugin
+export class Rcg {
+    async sendErrorMessage(msg: Message): Promise<void> {
+        const xkcdResult = await tryDo(got.get<XkcdResponse>("https://xkcd.com/info.0.json", { responseType: "json" }));
+        if(!xkcdResult.ok || !xkcdResult.result) {
+            await msg.reply("sorry, I couldn't get any comics :(.");
+            return;
+        }
+        await msg.reply("sorry, I couldn't get a random comic! Here is today's XKCD instead:", {
+            files: [ xkcdResult.result.body.img ]
+        });
+    }
+
+    @Command({
+        pattern: "random comic",
+        auth: false,
+        documentation: {description: "Generates a comic just for you!", example: "random comic"}
+    })
+    async randomComic({ message }: ICommandData): Promise<void> {
+        const result = await tryDo(got.get("http://explosm.net/rcg/view/?promo=false"));
+    
+        if (!result.ok || !result.result) {
+            logger.error("Failed to get RCG. Got error: %s", result.error);
+            await this.sendErrorMessage(message);
+            return;
+        }
+
+        const regexResult = rcgRe.exec(result.result.body);
+
+        if(!regexResult) {
+            logger.error("Could not find RCG from body. Got response body: %s", result.result.body);
+            await this.sendErrorMessage(message);
+            return;
+        }
+
+        await message.reply("I find this very funny:", {
+            files: [ regexResult[1].trim() ]
+        });
+    }
+}

+ 64 - 41
bot/src/commands/react.ts

@@ -4,9 +4,9 @@ import { MessageReaction } from "@shared/db/entity/MessageReaction";
 import { KnownUser } from "@shared/db/entity/KnownUser";
 import { ReactionType, ReactionEmote } from "@shared/db/entity/ReactionEmote";
 import { isAuthorisedAsync } from "../util";
-import { CommandSet, Command, Action, ActionType } from "src/model/command";
 import { Message } from "discord.js";
 import { logger } from "src/logging";
+import { Command, ICommandData, Event, BotEventData, Plugin } from "src/model/plugin";
 
 const pattern = /^react to\s+"([^"]+)"\s+with\s+<:[^:]+:([^>]+)>$/i;
 
@@ -26,54 +26,74 @@ async function getRandomEmotes(allowedTypes: ReactionType[], limit: number) {
     return a;
 }
 
-@CommandSet
+@Plugin
 export class ReactCommands {
 
-    @Command({ pattern: "react to", auth: true, documentation: {description: "React to <message> with <emote>.", example: "react to \"<message>\" with <emote>"} })
-    async addReaction(msg: Message, s: string): Promise<void> {
-        if (!await isAuthorisedAsync(msg.member))
+    @Command({
+        pattern: "react to",
+        auth: true,
+        documentation: {
+            description: "React to <message> with <emote>.",
+            example: "react to \"<message>\" with <emote>"
+        }
+    })
+    async addReaction({ message, contents }: ICommandData): Promise<void> {
+        if (!await isAuthorisedAsync(message.member))
             return;
-        const contents = pattern.exec(s);
+        const reactContents = pattern.exec(contents as string);
 
-        if (contents != null) {
-            const reactable = contents[1].trim().toLowerCase();
-            const reactionEmoji = contents[2];
+        if (reactContents != null) {
+            const reactable = reactContents[1].trim().toLowerCase();
+            const reactionEmoji = reactContents[2];
 
             if (!client.bot.emojis.cache.has(reactionEmoji)) {
-                msg.channel.send(`${msg.author.toString()} I cannot react with this emoji :(`);
+                message.reply("I cannot react with this emoji :(");
                 return;
             }
 
             const repo = getRepository(MessageReaction);
 
-            const message = repo.create({
+            const msgReaction = repo.create({
                 message: reactable,
                 reactionEmoteId: reactionEmoji
             });
-            await repo.save(message);
+            await repo.save(msgReaction);
 
-            msg.channel.send(`${msg.author.toString()} Added reaction!`);
+            message.reply("added reaction!");
         }
     }
 
-    @Command({ pattern: "remove reaction to", auth: true, documentation: {description: "Stops reacting to <message>.", example: "remove reaction to <message>"} })
-    async removeReaction(msg: Message, s: string): Promise<void> {
-        if (!await isAuthorisedAsync(msg.member))
+    @Command({ 
+        pattern: "remove reaction to", 
+        auth: true, 
+        documentation: {
+            description: "Stops reacting to <message>.", 
+            example: "remove reaction to <message>"
+        } 
+    })
+    async removeReaction({message, contents}: ICommandData): Promise<void> {
+        if (!await isAuthorisedAsync(message.member))
             return;
 
-        const content = s.substring("remove reaction to ".length).trim().toLowerCase();
+        const content = (contents as string).substring("remove reaction to ".length).trim().toLowerCase();
         const repo = getRepository(MessageReaction);
         const result = await repo.delete({ message: content });
 
         if (result.affected == 0) {
-            msg.channel.send(`${msg.author.toString()} No such reaction available!`);
+            message.reply("no such reaction available!");
             return;
         }
-        msg.channel.send(`${msg.author.toString()} Removed reaction!`);
+        message.reply("removed reaction!");
     }
 
-    @Command({ pattern: "reactions", documentation: {description: "Lists all known messages this bot can react to.", example: "reactions"} })
-    async listReactions(msg: Message): Promise<void> {
+    @Command({ 
+        pattern: "reactions", 
+        documentation: {
+            description: "Lists all known messages this bot can react to.", 
+            example: "reactions"
+        } 
+    })
+    async listReactions({ message }: ICommandData): Promise<void> {
         const reactionsRepo = getRepository(MessageReaction);
 
         const messages = await reactionsRepo.find({
@@ -81,14 +101,16 @@ export class ReactCommands {
         });
 
         const reactions = messages.reduce((p, c) => `${p}\n${c.message}`, "");
-        msg.channel.send(`I'll react to the following messages:\n\`\`\`${reactions}\n\`\`\``);
+        message.reply(`I'll react to the following messages:\n\`\`\`${reactions}\n\`\`\``);
     }
 
-    @Action(ActionType.MESSAGE)
-    async reactToMentions(actionsDone: boolean, msg: Message, content: string): Promise<boolean> {
-        if (actionsDone)
-            return false;
-
+    // actionsDone: boolean, msg: Message, content: string
+    @Event("message")
+    async reactToMentions(data: BotEventData, msg: Message): Promise<void> {
+        if (data.actionsDone)
+            return;
+        
+        const content = msg.cleanContent.trim();
         const lowerContent = content.toLowerCase();
 
         const reactionRepo = getRepository(MessageReaction);
@@ -100,11 +122,12 @@ export class ReactCommands {
             const emoji = client.bot.emojis.resolve(message.reactionEmoteId);
             if (emoji)
                 msg.react(emoji);
-            return true;
+            data.actionsDone = true;
+            return;
         }
 
         if (msg.mentions.users.size == 0)
-            return false;
+            return;
 
         const knownUsers = await usersRepo.find({
             select: ["mentionReactionType"],
@@ -112,7 +135,7 @@ export class ReactCommands {
         });
 
         if (knownUsers.length == 0)
-            return false;
+            return;
 
         const reactionEmoteTypes = new Set<ReactionType>();
 
@@ -123,12 +146,12 @@ export class ReactCommands {
         }
 
         if(reactionEmoteTypes.size == 0)
-            return false;
+            return;
 
         const randomEmotes = await getRandomEmotes([...reactionEmoteTypes], 5);
 
         if (randomEmotes.length == 0)
-            return false;
+            return;
 
         for (const emote of randomEmotes) {
             const emoji = client.bot.emojis.resolve(emote.reactionId);
@@ -136,13 +159,13 @@ export class ReactCommands {
                 await msg.react(emoji);
         }
 
-        return true;
+        data.actionsDone = true;
     }
 
-    @Action(ActionType.INDIRECT_MENTION)
-    async reactToPing(actionsDone: boolean, msg: Message): Promise<boolean> {
-        if (actionsDone)
-            return false;
+    @Event("indirectMention")
+    async reactToPing(data: BotEventData, msg: Message): Promise<void> {
+        if (data.actionsDone)
+            return;
         let emoteType = ReactionType.ANGERY;
 
         const repo = getRepository(KnownUser);
@@ -156,14 +179,14 @@ export class ReactCommands {
 
         if (knownUser) {
             if (knownUser.replyReactionType == ReactionType.NONE)
-                return false;
+                return;
             emoteType = knownUser.replyReactionType;
         }
 
         const emotes = await getRandomEmotes([emoteType], 1);
 
         if (emotes.length != 1)
-            return false;
+            return;
 
         const emote = client.bot.emojis.resolve(emotes[0].reactionId);
 
@@ -172,10 +195,10 @@ export class ReactCommands {
 
             const emotesRepo = getRepository(ReactionEmote);
             await emotesRepo.delete({ reactionId: emotes[0].reactionId });
-            return false;
+            return;
         }
 
         msg.channel.send(emote.toString());
-        return true;
+        data.actionsDone = true;
     }
 }