Browse Source

Move commands parsing to use decorators

ghorsington 5 years ago
parent
commit
a05987dd68

bot/src/commands/command.ts → bot/src/commands/command.ts.b


+ 368 - 380
bot/src/commands/contest.ts

@@ -1,6 +1,5 @@
-import { ICommand } from "./command";
 import { Dict, compareNumbers, isAuthorisedAsync } from "../util";
-import { ReactionCollector, Message, TextChannel, RichEmbed, MessageReaction, User, Collector, Collection, SnowflakeUtil } from "discord.js";
+import { Message, TextChannel, RichEmbed, MessageReaction, User, Collector, Collection, SnowflakeUtil } from "discord.js";
 import yaml from "yaml";
 import { getRepository, getManager } from "typeorm";
 import { Contest } from "@db/entity/Contest";
@@ -9,139 +8,10 @@ import { client } from "../client";
 import { scheduleJob } from "node-schedule";
 import { ContestEntry } from "@db/entity/ContestEntry";
 import { ContestVote } from "@db/entity/ContestVote";
+import { CommandSet, Command, Action, ActionType } from "src/model/command";
 
 const CHANNEL_ID_PATTERN = /<#(\d+)>/;
 
-let activeContests: Dict<ActiveContest> = {};
-
-async function init() {
-    let contestRepo = getRepository(Contest);
-    let contests = await contestRepo.find({
-        where: { active: true },
-        relations: ["entries"]
-    });
-
-    for (let contest of contests)
-        await updateContestStatus(contest);
-
-    client.on("messageReactionAdd", onReact);
-}
-
-function diffEntryVotes(votes: ContestVote[], currentUsers: Collection<string, User>) {
-    let votedUsersIds = new Set(votes.map(v => v.userId));
-    let currentUsersIds = new Set(currentUsers.keys());
-
-    for (let currentUserId of currentUsersIds)
-        if (votedUsersIds.has(currentUserId))
-            votedUsersIds.delete(currentUserId);
-
-    for (let votedUserId of votedUsersIds)
-        if (currentUsersIds.has(votedUserId))
-            currentUsersIds.delete(votedUserId);
-
-    return [currentUsersIds, votedUsersIds];
-}
-
-type ContestEntryMessage = {
-    msgId?: string;
-    timestamp?: Date;
-};
-
-//TODO: Convert into a transaction
-async function updateContestStatus(contest: Contest) {
-    let voteRepo = getRepository(ContestVote);
-    let entryRepo = getRepository(ContestEntry);
-
-    let channel = client.channels.get(contest.channel) as TextChannel;
-    if (!channel) {
-        console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
-        await removeContest(contest.id);
-        return;
-    }
-
-    let newestEntry: Date = null;
-    let contestEntryMessageIds = new Set<string>(contest.entries.map(e => e.msgId));
-    for (let entry of contest.entries) {
-        try {
-            let msg = await channel.fetchMessage(entry.msgId);
-            let existingVotes = await voteRepo.find({ where: { contest: contest, contestEntry: entry } });
-            
-            let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
-            if(!voteReaction) {
-                await voteRepo.remove(existingVotes);
-                continue;
-            }
-
-            let users = await voteReaction.fetchUsers();
-            let [newVotes, removedVotes] = diffEntryVotes(existingVotes, users);
-            voteRepo.remove(existingVotes.filter(v => removedVotes.has(v.userId)));
-            let newVoteEntries = [...newVotes].map(i => voteRepo.create({
-                userId: i,
-                contest: contest,
-                contestEntry: entry
-            }));
-            await voteRepo.save(newVoteEntries);
-
-            entry.votes = [
-                ...newVoteEntries,
-                ...existingVotes.filter(v => !removedVotes.has(v.userId))
-            ];
-            await entryRepo.save(entry);
-
-            if (!newestEntry || msg.createdAt > newestEntry)
-                newestEntry = msg.createdAt;
-        } catch (err) {
-            console.log(`Failed to update entry ${entry.msgId} for contest ${contest.id} because ${err}!`);
-            
-            await voteRepo.delete({ contestEntry: entry });
-            await entryRepo.delete({ msgId: entry.msgId });
-            contestEntryMessageIds.delete(entry.msgId);
-        }
-    }
-
-    let newEntries = (await channel.fetchMessages({
-        after: SnowflakeUtil.generate(newestEntry || contest.startDate)
-    })).filter(m => m.attachments.size != 0 && !contestEntryMessageIds.has(m.id));
-
-    for (let [_, msg] of newEntries)
-        await registerEntry(msg, contest);
-
-    if (contest.endDate < new Date()) {
-        await stopContest(contest.id);
-    } else {
-        scheduleJob(contest.endDate, stopContest.bind(null, contest.id));
-        activeContests[channel.id] = {
-            id: contest.id,
-            voteReaction: contest.voteReaction
-        };
-    }
-}
-
-async function registerEntry(msg: Message, contest: Contest) {
-    let entryRepo = getRepository(ContestEntry);
-    let voteRepo = getRepository(ContestVote);
-
-    let entry = entryRepo.create({
-        msgId: msg.id,
-        contest: contest
-    });
-
-    await entryRepo.save(entry);
-
-    let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
-
-    if(!voteReaction)
-        return;
-
-    let votedUsers = await voteReaction.fetchUsers();
-
-    await voteRepo.save(votedUsers.map(u => voteRepo.create({
-        userId: u.id,
-        contest: contest,
-        contestEntry: entry
-    })));
-}
-
 interface ActiveContest {
     id: number;
     voteReaction: string;
@@ -188,312 +58,430 @@ function parseDuration(duration: string): number | undefined {
     return +num * DURATION_MULTIPLIERS[unit];
 }
 
-async function removeContest(contestId: number) {
-    await getManager().transaction(async em => {
-        let contestRepo = em.getRepository(Contest);
-        let contestEntryRepo = em.getRepository(ContestEntry);
-        let contestVoteRepo = em.getRepository(ContestVote);
+type ContestEntryWithMessage = ContestEntry & { message: Message };
 
-        let contest = contestRepo.create({
-            id: contestId
-        });
+function numberToOrdered(num: number) {
+    const prefixes = ["st", "nd", "rd"];
+    let s = num % 10;
+    return 0 < s && s <= prefixes.length ? `${num}${prefixes[s - 1]}` : `${num}th`;
+}
 
-        await contestRepo.delete({
-            id: contestId
-        });
-        await contestVoteRepo.delete({
+@CommandSet
+export class ContestCommands {
+
+    activeContests: Dict<ActiveContest> = {};
+
+    diffEntryVotes(votes: ContestVote[], currentUsers: Collection<string, User>) {
+        let votedUsersIds = new Set(votes.map(v => v.userId));
+        let currentUsersIds = new Set(currentUsers.keys());
+
+        for (let currentUserId of currentUsersIds)
+            if (votedUsersIds.has(currentUserId))
+                votedUsersIds.delete(currentUserId);
+
+        for (let votedUserId of votedUsersIds)
+            if (currentUsersIds.has(votedUserId))
+                currentUsersIds.delete(votedUserId);
+
+        return [currentUsersIds, votedUsersIds];
+    }
+
+    async updateContestStatus(contest: Contest) {
+        let voteRepo = getRepository(ContestVote);
+        let entryRepo = getRepository(ContestEntry);
+
+        let channel = client.channels.get(contest.channel) as TextChannel;
+        if (!channel) {
+            console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
+            await this.removeContest(contest.id);
+            return;
+        }
+
+        let newestEntry: Date = null;
+        let contestEntryMessageIds = new Set<string>(contest.entries.map(e => e.msgId));
+        for (let entry of contest.entries) {
+            try {
+                let msg = await channel.fetchMessage(entry.msgId);
+                let existingVotes = await voteRepo.find({ where: { contest: contest, contestEntry: entry } });
+
+                let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
+                if (!voteReaction) {
+                    await voteRepo.remove(existingVotes);
+                    continue;
+                }
+
+                let users = await voteReaction.fetchUsers();
+                let [newVotes, removedVotes] = this.diffEntryVotes(existingVotes, users);
+                voteRepo.remove(existingVotes.filter(v => removedVotes.has(v.userId)));
+                let newVoteEntries = [...newVotes].map(i => voteRepo.create({
+                    userId: i,
+                    contest: contest,
+                    contestEntry: entry
+                }));
+                await voteRepo.save(newVoteEntries);
+
+                entry.votes = [
+                    ...newVoteEntries,
+                    ...existingVotes.filter(v => !removedVotes.has(v.userId))
+                ];
+                await entryRepo.save(entry);
+
+                if (!newestEntry || msg.createdAt > newestEntry)
+                    newestEntry = msg.createdAt;
+            } catch (err) {
+                console.log(`Failed to update entry ${entry.msgId} for contest ${contest.id} because ${err}!`);
+
+                await voteRepo.delete({ contestEntry: entry });
+                await entryRepo.delete({ msgId: entry.msgId });
+                contestEntryMessageIds.delete(entry.msgId);
+            }
+        }
+
+        let newEntries = (await channel.fetchMessages({
+            after: SnowflakeUtil.generate(newestEntry || contest.startDate)
+        })).filter(m => m.attachments.size != 0 && !contestEntryMessageIds.has(m.id));
+
+        for (let [_, msg] of newEntries)
+            await this.registerEntry(msg, contest);
+
+        if (contest.endDate < new Date()) {
+            await this.stopContest(contest.id);
+        } else {
+            scheduleJob(contest.endDate, this.stopContest.bind(null, contest.id));
+            this.activeContests[channel.id] = {
+                id: contest.id,
+                voteReaction: contest.voteReaction
+            };
+        }
+    }
+
+    async registerEntry(msg: Message, contest: Contest) {
+        let entryRepo = getRepository(ContestEntry);
+        let voteRepo = getRepository(ContestVote);
+
+        let entry = entryRepo.create({
+            msgId: msg.id,
             contest: contest
         });
-        await contestEntryRepo.delete({
-            contest: contest
+
+        await entryRepo.save(entry);
+
+        let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
+
+        if (!voteReaction)
+            return;
+
+        let votedUsers = await voteReaction.fetchUsers();
+
+        await voteRepo.save(votedUsers.map(u => voteRepo.create({
+            userId: u.id,
+            contest: contest,
+            contestEntry: entry
+        })));
+    }
+
+    async removeContest(contestId: number) {
+        await getManager().transaction(async em => {
+            let contestRepo = em.getRepository(Contest);
+            let contestEntryRepo = em.getRepository(ContestEntry);
+            let contestVoteRepo = em.getRepository(ContestVote);
+
+            let contest = contestRepo.create({
+                id: contestId
+            });
+
+            await contestRepo.delete({
+                id: contestId
+            });
+            await contestVoteRepo.delete({
+                contest: contest
+            });
+            await contestEntryRepo.delete({
+                contest: contest
+            });
         });
-    });
-}
+    }
 
-type ContestEntryWithMessage = ContestEntry & { message: Message };
+    async pickValidEntries(channel: TextChannel, contestEntries: ContestEntry[], max: number, unique = true) {
+        let addedUsers = new Set<string>();
+        let result: ContestEntryWithMessage[] = [];
+        let maxResults = Math.min(max, contestEntries.length);
+
+        for (let entry of contestEntries) {
+            try {
+                let msg = await channel.fetchMessage(entry.msgId);
+                if (unique && addedUsers.has(msg.author.id))
+                    continue;
+
+                result.push({ ...entry, message: msg });
+                addedUsers.add(msg.author.id);
+                if (result.length == maxResults)
+                    break;
+            } catch (err) { }
+        }
 
-async function pickValidEntries(channel: TextChannel, contestEntries: ContestEntry[], max: number, unique = true) {
-    let addedUsers = new Set<string>();
-    let result: ContestEntryWithMessage[] = [];
-    let maxResults = Math.min(max, contestEntries.length);
-
-    for (let entry of contestEntries) {
-        try {
-            let msg = await channel.fetchMessage(entry.msgId);
-            if (unique && addedUsers.has(msg.author.id))
-                continue;
-
-            result.push({ ...entry, message: msg });
-            addedUsers.add(msg.author.id);
-            if (result.length == maxResults)
-                break;
-        } catch (err) { }
+        return result;
     }
 
-    return result;
-}
+    async printResults(contest: Contest, channel: TextChannel) {
+        let entryRepo = getRepository(ContestEntry);
 
-function numberToOrdered(num: number) {
-    const prefixes = ["st", "nd", "rd"];
-    let s = num % 10;
-    return 0 < s && s <= prefixes.length ? `${num}${prefixes[s - 1]}` : `${num}th`;
-}
+        let entries = await entryRepo.find({
+            where: { contest: contest },
+            relations: ["votes"]
+        });
 
-async function printResults(contest: Contest, channel: TextChannel) {
-    let entryRepo = getRepository(ContestEntry);
+        if (entries.length == 0) {
+            // Hmmm... maybe rich embeds?
+            await channel.send("No entries were sent into this contest! Therefore I declare myself a winner!");
+            return;
+        }
 
-    let entries = await entryRepo.find({
-        where: { contest: contest },
-        relations: ["votes"]
-    });
+        let winningEntries = await this.pickValidEntries(channel, entries.sort(compareNumbers(o => o.votes.length)), contest.maxWinners, contest.uniqueWinners);
+        let totalVotes = entries.reduce((p, c) => p + c.votes.length, 0);
+
+        let embed = new RichEmbed({
+            title: "🎆 Contest results 🎆",
+            color: 0x3b8dc4,
+            timestamp: new Date(),
+            description: `The contest has ended!\nCollected ${totalVotes} votes.\nHere are the results:`,
+            fields: winningEntries.map((e, i) => ({
+                name: `${numberToOrdered(i + 1)} place (${e.votes.length} votes)`,
+                value: `${e.message.author.toString()} ([View entry](${e.message.url}))`
+            }))
+        });
 
-    if (entries.length == 0) {
-        // Hmmm... maybe rich embeds?
-        await channel.send("No entries were sent into this contest! Therefore I declare myself a winner!");
-        return;
+        await channel.send(embed);
     }
 
-    let winningEntries = await pickValidEntries(channel, entries.sort(compareNumbers(o => o.votes.length)), contest.maxWinners, contest.uniqueWinners);
-    let totalVotes = entries.reduce((p, c) => p + c.votes.length, 0);
-
-    let embed = new RichEmbed({
-        title: "🎆 Contest results 🎆",
-        color: 0x3b8dc4,
-        timestamp: new Date(),
-        description: `The contest has ended!\nCollected ${totalVotes} votes.\nHere are the results:`,
-        fields: winningEntries.map((e, i) => ({
-            name: `${numberToOrdered(i + 1)} place (${e.votes.length} votes)`,
-            value: `${e.message.author.toString()} ([View entry](${e.message.url}))`
-        }))
-    });
-
-    await channel.send(embed);
-}
+    async stopContest(contestId: number) {
+        let repo = getRepository(Contest);
+        let contest = await repo.findOne(contestId);
 
-async function stopContest(contestId: number) {
-    let repo = getRepository(Contest);
-    let contest = await repo.findOne(contestId);
+        let channel = client.channels.get(contest.channel) as TextChannel;
+        if (!channel) {
+            // TODO: Don't remove; instead report in web manager
+            console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
+            await this.removeContest(contestId);
+            return;
+        }
+
+        await channel.send(`Current contest has ended! Thank you for your participation!`);
 
-    let channel = client.channels.get(contest.channel) as TextChannel;
-    if (!channel) {
-        // TODO: Don't remove; instead report in web manager
-        console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
-        await removeContest(contestId);
-        return;
+        if (contest.announceWinners)
+            await this.printResults(contest, channel);
+
+        await repo.update(contestId, { active: false });
     }
 
-    await channel.send(`Current contest has ended! Thank you for your participation!`);
+    async createContest(msg: Message, info: ContestCreationOptions) {
+        if (info.in) {
+            let matches = CHANNEL_ID_PATTERN.exec(info.in);
+            if (matches.length == 0) {
+                await msg.channel.send(`${msg.author.toString()} I can't see such a channel!`);
+                return;
+            }
 
-    if (contest.announceWinners)
-        await printResults(contest, channel);
+            let channelId = matches[1];
+            if (!msg.guild.channels.exists("id", channelId)) {
+                await msg.channel.send(`${msg.author.toString()} This channel is not in the current guild!`);
+                return;
+            }
 
-    await repo.update(contestId, { active: false });
-}
+            info.in = channelId;
+        } else
+            info.in = msg.channel.id;
 
-async function createContest(msg: Message, info: ContestCreationOptions) {
-    if (info.in) {
-        let matches = CHANNEL_ID_PATTERN.exec(info.in);
-        if (matches.length == 0) {
-            await msg.channel.send(`${msg.author.toString()} I can't see such a channel!`);
+        if (info.max_winners < 1) {
+            await msg.channel.send(`${msg.author.toString()} The contest must have at least one possible winner!`);
             return;
         }
 
-        let channelId = matches[1];
-        if (!msg.guild.channels.exists("id", channelId)) {
-            await msg.channel.send(`${msg.author.toString()} This channel is not in the current guild!`);
+        let dur = parseDuration(info.duration);
+        if (!dur) {
+            await msg.channel.send(`${msg.author.toString()} Duration format is invalid!`);
             return;
         }
 
-        info.in = channelId;
-    } else
-        info.in = msg.channel.id;
+        if (!msg.guild.emojis.find(e => e.toString() == info.vote_reaction) && !emoji_regex().exec(info.vote_reaction)) {
+            await msg.channel.send(`${msg.author.toString()} The vote emote must be accessible by everyone on the server!`);
+            return;
+        }
 
-    if (info.max_winners < 1) {
-        await msg.channel.send(`${msg.author.toString()} The contest must have at least one possible winner!`);
-        return;
-    }
+        let repo = getRepository(Contest);
 
-    let dur = parseDuration(info.duration);
-    if (!dur) {
-        await msg.channel.send(`${msg.author.toString()} Duration format is invalid!`);
-        return;
-    }
+        let contest = await repo.findOne({
+            where: { channel: info.in, active: true }
+        });
+
+        if (contest) {
+            await msg.channel.send(`${msg.author.toString()} The channel already has a contest running!`);
+            return;
+        }
 
-    if (!msg.guild.emojis.find(e => e.toString() == info.vote_reaction) && !emoji_regex().exec(info.vote_reaction)) {
-        await msg.channel.send(`${msg.author.toString()} The vote emote must be accessible by everyone on the server!`);
-        return;
+        contest = repo.create({
+            channel: info.in,
+            startDate: new Date(),
+            endDate: new Date(Date.now() + dur),
+            announceWinners: info.announce_winners,
+            voteReaction: info.vote_reaction,
+            maxWinners: info.max_winners,
+            uniqueWinners: info.unique_winners,
+            active: true
+        });
+
+        await repo.save(contest);
+        await msg.channel.send(`${msg.author.toString()} Started contest (ID: ${contest.id})`);
+
+        scheduleJob(contest.endDate, this.stopContest.bind(null, contest.id));
+        this.activeContests[contest.channel] = {
+            id: contest.id,
+            voteReaction: contest.voteReaction
+        };
     }
 
-    let repo = getRepository(Contest);
+    async onReact(reaction: MessageReaction, user: User) {
+        if (user.bot)
+            return;
+
+        let channel = reaction.message.channel;
+        let activeContest = this.activeContests[channel.id];
+        if (!activeContest || reaction.emoji.toString() != activeContest.voteReaction)
+            return;
+
+        let entryRepo = getRepository(ContestEntry);
+        let voteRepo = getRepository(ContestVote);
+
+        let entry = await entryRepo.findOne({
+            where: { msgId: reaction.message.id }
+        });
+        if (!entry)
+            return;
 
-    let contest = await repo.findOne({
-        where: { channel: info.in, active: true }
-    });
+        let vote = await voteRepo.findOne({
+            where: { userId: user.id, contestId: activeContest.id }
+        });
+        if (!vote)
+            vote = voteRepo.create({ userId: user.id, contestId: activeContest.id });
 
-    if (contest) {
-        await msg.channel.send(`${msg.author.toString()} The channel already has a contest running!`);
-        return;
+        vote.contestEntry = entry;
+        await voteRepo.save(vote);
     }
 
-    contest = repo.create({
-        channel: info.in,
-        startDate: new Date(),
-        endDate: new Date(Date.now() + dur),
-        announceWinners: info.announce_winners,
-        voteReaction: info.vote_reaction,
-        maxWinners: info.max_winners,
-        uniqueWinners: info.unique_winners,
-        active: true
-    });
-
-    await repo.save(contest);
-    await msg.channel.send(`${msg.author.toString()} Started contest (ID: ${contest.id})`);
-
-    scheduleJob(contest.endDate, stopContest.bind(null, contest.id));
-    activeContests[contest.channel] = {
-        id: contest.id,
-        voteReaction: contest.voteReaction
-    };
-}
+    @Action(ActionType.MESSAGE)
+    async addEntry(actionsDone: boolean, m: Message, content: string) {
+        if (m.attachments.size == 0)
+            return false;
 
-async function onMessage(actionsDone: boolean, m: Message, content: string) {
-    if (m.attachments.size == 0)
-        return false;
+        let channel = m.channel;
 
-    let channel = m.channel;
+        let contestRepo = getRepository(Contest);
 
-    let contestRepo = getRepository(Contest);
+        let contest = await contestRepo.findOne({
+            where: {
+                channel: channel.id,
+                active: true
+            },
+            select: ["id", "voteReaction"]
+        });
 
-    let contest = await contestRepo.findOne({
-        where: {
-            channel: channel.id,
-            active: true
-        },
-        select: ["id", "voteReaction"]
-    });
+        if (!contest)
+            return false;
 
-    if (!contest)
+        await this.registerEntry(m, contest);
+
+        // Don't prevent further actions
         return false;
+    }
 
-    await registerEntry(m, contest);
+    async onStart() {
+        let contestRepo = getRepository(Contest);
+        let contests = await contestRepo.find({
+            where: { active: true },
+            relations: ["entries"]
+        });
 
-    // Don't prevent further actions
-    return false;
-}
+        for (let contest of contests)
+            await this.updateContestStatus(contest);
 
-async function onReact(reaction: MessageReaction, user: User) {
-    if(user.bot)
-        return;
+        client.on("messageReactionAdd", this.onReact);
+    }
 
-    let channel = reaction.message.channel;
-    let activeContest = activeContests[channel.id];
-    if (!activeContest || reaction.emoji.toString() != activeContest.voteReaction)
-        return;
+    @Command({ pattern: "create contest", auth: true })
+    async startContest(m: Message) {
+        if (!await isAuthorisedAsync(m.member)) {
+            m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
+            return;
+        }
 
-    let entryRepo = getRepository(ContestEntry);
-    let voteRepo = getRepository(ContestVote);
+        let message = m.content.trim().substr(client.user.toString().length).trim().substr("create contest".length).trim();
+        let contestData: ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
+        await this.createContest(m, contestData);
+    }
 
-    let entry = await entryRepo.findOne({
-        where: { msgId: reaction.message.id }
-    });
-    if (!entry)
-        return;
+    @Command({ pattern: "contests" })
+    async listContests(m: Message) {
+        let repo = getRepository(Contest);
+        let contests = await repo.find({ where: { active: true } });
 
-    let vote = await voteRepo.findOne({
-        where: { userId: user.id, contestId: activeContest.id }
-    });
-    if (!vote)
-        vote = voteRepo.create({ userId: user.id, contestId: activeContest.id });
+        let contestsData = contests.map(c => ({
+            contest: c,
+            channel: client.channels.get(c.channel) as TextChannel
+        })).filter(c => c.channel);
 
-    vote.contestEntry = entry;
-    await voteRepo.save(vote);
-}
+        if (contestsData.length == 0)
+            await m.channel.send(`${m.author.toString()} There are no currently running contests!`);
+        else
+            await m.channel.send(`${m.author.toString()} Currently there are contests active in the following channels:\n${contestsData.map((c, i) => `${i + 1}. ${c.channel.toString()}`).join("\n")}`);
+    }
 
-export default <ICommand>{
-    commands: [
-        {
-            pattern: "create contest",
-            action: async (m, contents) => {
-                if (!await isAuthorisedAsync(m.member)) {
-                    m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
-                    return;
-                }
+    @Command({ pattern: /end contest( (\d*))?/, auth: true })
+    async endContest(m: Message, contents: string, matches: RegExpMatchArray) {
+        if (!await isAuthorisedAsync(m.member)) {
+            m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
+            return;
+        }
 
-                let message = m.content.trim().substr(client.user.toString().length).trim().substr("create contest".length).trim();
-                let contestData: ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
-                await createContest(m, contestData);
-            }
-        },
-        {
-            pattern: "contests",
-            action: async (m) => {
-                let repo = getRepository(Contest);
-                let contests = await repo.find({ where: { active: true } });
-
-                let contestsData = contests.map(c => ({
-                    contest: c,
-                    channel: client.channels.get(c.channel) as TextChannel
-                })).filter(c => c.channel);
-
-                if(contestsData.length == 0)
-                    await m.channel.send(`${m.author.toString()} There are no currently running contests!`);
-                else 
-                    await m.channel.send(`${m.author.toString()} Currently there are contests active in the following channels:\n${contestsData.map((c, i) => `${i + 1}. ${c.channel.toString()}`).join("\n")}`);
-            }
-        },
-        {
-            pattern: /end contest( (\d*))?/,
-            action: async (m, contents, matches) => {
-                if (!await isAuthorisedAsync(m.member)) {
-                    m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
-                    return;
-                }
+        let repo = getRepository(Contest);
+        let contestId = +matches[1];
 
-                let repo = getRepository(Contest);
-                let contestId = +matches[1];
+        let contest = await repo.findOne({
+            where: { id: contestId },
+            select: ["id", "active"]
+        });
 
-                let contest = await repo.findOne({
-                    where: { id: contestId },
-                    select: ["id", "active"]
-                });
+        if (!contest) {
+            await m.channel.send(`${m.author.toString()} Can't find contest with ID ${contestId}`);
+            return;
+        }
 
-                if (!contest) {
-                    await m.channel.send(`${m.author.toString()} Can't find contest with ID ${contestId}`);
-                    return;
-                }
+        if (!contest.active) {
+            await m.channel.send(`${m.author.toString()} The contest with ID ${contestId} is already inactive!`);
+            return;
+        }
 
-                if (!contest.active) {
-                    await m.channel.send(`${m.author.toString()} The contest with ID ${contestId} is already inactive!`);
-                    return;
-                }
+        await this.stopContest(contest.id);
+    }
 
-                await stopContest(contest.id);
-            }
-        },
-        {
-            pattern: "announce winners",
-            action: async (m, contents) => {
-                let repo = getRepository(Contest);
-
-                let contest = await repo.findOne({
-                    where: { channel: m.channel.id },
-                    order: { endDate: "DESC" }
-                });
-
-                if(!contest) {
-                    await m.channel.send(`${m.author.toString()} There have never been any contests!`);
-                    return;
-                }
+    @Command({ pattern: "announce winners", auth: true })
+    async announceWinners(m: Message) {
+        let repo = getRepository(Contest);
 
-                if(contest.active) {
-                    await stopContest(contest.id);
-                    if(contest.announceWinners)
-                        return;
-                }
-                await printResults(contest, m.channel as TextChannel);
-            }
+        let contest = await repo.findOne({
+            where: { channel: m.channel.id },
+            order: { endDate: "DESC" }
+        });
+
+        if (!contest) {
+            await m.channel.send(`${m.author.toString()} There have never been any contests!`);
+            return;
+        }
+
+        if (contest.active) {
+            await this.stopContest(contest.id);
+            if (contest.announceWinners)
+                return;
         }
-    ],
-    onStart: init,
-    onMessage: onMessage
+        await this.printResults(contest, m.channel as TextChannel);
+    }
 };

+ 14 - 10
bot/src/commands/dead_chat.ts

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

+ 197 - 188
bot/src/commands/facemorph.ts

@@ -4,11 +4,11 @@ import { client } from "../client";
 import * as cv from "opencv4nodejs";
 import * as path from "path";
 import request from "request-promise-native";
-import { ICommand } from "./command";
 import { Message } from "discord.js";
 import { getRepository } from "typeorm";
 import { FaceCaptionMessage, FaceCaptionType } from "@db/entity/FaceCaptionMessage";
 import { KnownChannel } from "@db/entity/KnownChannel";
+import { CommandSet, Action, ActionType, Command } from "src/model/command";
 
 const EMOTE_GUILD = "505333548694241281";
 
@@ -19,199 +19,201 @@ const CAPTION_IMG_SIZE = 300;
 const CAPTION_PROBABILITY = 0.33;
 
 type ImageProcessor = (faces: cv.Rect[], data: Buffer) => Promise<Jimp>;
+const CAPTION_OFFSET = 5;
+
+@CommandSet
+export class Facemorph {
 
-function intersects(r1: cv.Rect, r2: cv.Rect) {
-    return (
-        r1.x <= r2.x + r2.width &&
-        r1.x + r1.width >= r2.x &&
-        (r1.y <= r2.y + r2.height && r1.y + r1.height >= r2.y)
-    );
-}
-
-async function morphFaces(faces: cv.Rect[], data: Buffer) {
-    let padoru = Math.random() <= getPadoruChance();
-    let jimpImage = await Jimp.read(data);
-    let emojiKeys = [
-        ...client.guilds
-            .get(EMOTE_GUILD)
-            .emojis.filter(e => !e.animated && e.name.startsWith("PADORU") == padoru)
-            .keys()
-    ];
-
-    for (const rect of faces) {
-        let dx = rect.x + rect.width / 2;
-        let dy = rect.y + rect.height / 2;
-        let emojiKey = emojiKeys[Math.floor(Math.random() * emojiKeys.length)];
-        let emoji = client.emojis.get(emojiKey);
-        let emojiImage = await Jimp.read(emoji.url);
-        let ew = emojiImage.getWidth();
-        let eh = emojiImage.getHeight();
-
-        const CONSTANT_SCALE = 1.1;
-        let scaleFactor = (Math.max(rect.width, rect.height) / Math.min(ew, eh)) * CONSTANT_SCALE;
-        ew *= scaleFactor;
-        eh *= scaleFactor;
-
-        emojiImage = emojiImage.scale(scaleFactor);
-
-        jimpImage = jimpImage.composite(emojiImage, dx - ew / 2, dy - eh / 2);
+    intersects(r1: cv.Rect, r2: cv.Rect) {
+        return (
+            r1.x <= r2.x + r2.width &&
+            r1.x + r1.width >= r2.x &&
+            (r1.y <= r2.y + r2.height && r1.y + r1.height >= r2.y)
+        );
     }
 
-    return jimpImage;
-}
+    async morphFaces(faces: cv.Rect[], data: Buffer) {
+        let padoru = Math.random() <= this.getPadoruChance();
+        let jimpImage = await Jimp.read(data);
+        let emojiKeys = [
+            ...client.guilds
+                .get(EMOTE_GUILD)
+                .emojis.filter(e => !e.animated && e.name.startsWith("PADORU") == padoru)
+                .keys()
+        ];
+
+        for (const rect of faces) {
+            let dx = rect.x + rect.width / 2;
+            let dy = rect.y + rect.height / 2;
+            let emojiKey = emojiKeys[Math.floor(Math.random() * emojiKeys.length)];
+            let emoji = client.emojis.get(emojiKey);
+            let emojiImage = await Jimp.read(emoji.url);
+            let ew = emojiImage.getWidth();
+            let eh = emojiImage.getHeight();
+
+            const CONSTANT_SCALE = 1.1;
+            let scaleFactor = (Math.max(rect.width, rect.height) / Math.min(ew, eh)) * CONSTANT_SCALE;
+            ew *= scaleFactor;
+            eh *= scaleFactor;
+
+            emojiImage = emojiImage.scale(scaleFactor);
+
+            jimpImage = jimpImage.composite(emojiImage, dx - ew / 2, dy - eh / 2);
+        }
 
-const CAPTION_OFFSET = 5;
+        return jimpImage;
+    }
+
+    async getRandomCaption(type: FaceCaptionType) {
+        let repo = getRepository(FaceCaptionMessage);
+        let caption = await repo.query(`select message
+                                        from face_caption_message
+                                        where type = $1
+                                        order by random()
+                                        limit 1`, [type]) as FaceCaptionMessage[];
+        if (caption.length == 0)
+            return null;
+        return caption[0];
+    }
+
+    async captionFace(faces: cv.Rect[], data: Buffer) {
+        let padoru = Math.random() <= this.getPadoruChance();
+        let face = faces[Math.floor(Math.random() * faces.length)];
+        let squaredFace = await face.toSquareAsync();
+        let targetSize = CAPTION_IMG_SIZE;
+        let img = await Jimp.read(data);
+
+        let tempImg = await Jimp.create(squaredFace.width, squaredFace.height);
+        tempImg = await tempImg.blit(
+            img,
+            0,
+            0,
+            squaredFace.x,
+            squaredFace.y,
+            squaredFace.width,
+            squaredFace.height
+        );
+        tempImg = await tempImg.scale(targetSize / squaredFace.width);
+
+        let font = await Jimp.loadFont(padoru ? Jimp.FONT_SANS_16_WHITE : Jimp.FONT_SANS_16_BLACK);
+        let text = padoru ? "PADORU PADORU" : `${(await this.getRandomCaption(FaceCaptionType.PREFIX)).message} ${(await this.getRandomCaption(FaceCaptionType.POSTFIX)).message}`;
+        let h = Jimp.measureTextHeight(font, text, targetSize - CAPTION_OFFSET * 2);
+        let finalImage = await Jimp.create(targetSize, targetSize + h + CAPTION_OFFSET * 2, padoru ? "#FD2027" : "#FFFFFF");
+
+        finalImage = await finalImage.print(
+            font,
+            CAPTION_OFFSET,
+            CAPTION_OFFSET,
+            text,
+            finalImage.getWidth() - CAPTION_OFFSET
+        );
+        finalImage = await finalImage.composite(tempImg, 0, CAPTION_OFFSET * 2 + h);
+
+        return finalImage;
+    }
 
-async function getRandomCaption(type: FaceCaptionType) {
-    let repo = getRepository(FaceCaptionMessage);
-    let caption = await repo.query(`select message
-                                    from face_caption_message
-                                    where type = $1
-                                    order by random()
-                                    limit 1`, [ type ]) as FaceCaptionMessage[];
-    if(caption.length == 0)
-        return null;
-    return caption[0];
-}
-
-async function captionFace(faces: cv.Rect[], data: Buffer) {
-    let padoru = Math.random() <= getPadoruChance();
-    let face = faces[Math.floor(Math.random() * faces.length)];
-    let squaredFace = await face.toSquareAsync();
-    let targetSize = CAPTION_IMG_SIZE;
-    let img = await Jimp.read(data);
-
-    let tempImg = await Jimp.create(squaredFace.width, squaredFace.height);
-    tempImg = await tempImg.blit(
-        img,
-        0,
-        0,
-        squaredFace.x,
-        squaredFace.y,
-        squaredFace.width,
-        squaredFace.height
-    );
-    tempImg = await tempImg.scale(targetSize / squaredFace.width);
-
-    let font = await Jimp.loadFont(padoru ? Jimp.FONT_SANS_16_WHITE : Jimp.FONT_SANS_16_BLACK);
-    let text = padoru ? "PADORU PADORU" : `${(await getRandomCaption(FaceCaptionType.PREFIX)).message} ${(await getRandomCaption(FaceCaptionType.POSTFIX)).message}`;
-    let h = Jimp.measureTextHeight(font, text, targetSize - CAPTION_OFFSET * 2);
-    let finalImage = await Jimp.create(targetSize, targetSize + h + CAPTION_OFFSET * 2, padoru ? "#FD2027" : "#FFFFFF");
-
-    finalImage = await finalImage.print(
-        font,
-        CAPTION_OFFSET,
-        CAPTION_OFFSET,
-        text,
-        finalImage.getWidth() - CAPTION_OFFSET
-    );
-    finalImage = await finalImage.composite(tempImg, 0, CAPTION_OFFSET * 2 + h);
-
-    return finalImage;
-}
-
-/**
+    /**
  * PADORU PADORU
  */
-function getPadoruChance() {
-    let now = new Date();
-    if (now.getUTCMonth() != 11 || now.getUTCDate() > 25)
-        return 0;
-    return 1 / (27.0 - now.getUTCDate());
-}
-
-async function processFaceSwap(message: Message, attachmentUrl: string, processor?: ImageProcessor, failMessage?: string, successMessage?: string) {
-    let data = await request(attachmentUrl, { encoding: null }) as Buffer;
-    let im = await cv.imdecodeAsync(data, cv.IMREAD_COLOR);
-    let gray = await im.cvtColorAsync(cv.COLOR_BGR2GRAY);
-    let normGray = await gray.equalizeHistAsync();
-    let animeFaces = await animeCascade.detectMultiScaleAsync(
-        normGray,
-        1.1,
-        5,
-        0,
-        new cv.Size(24, 24)
-    );
-    let normalFaces = await faceCascade.detectMultiScaleAsync(gray);
-
-    if (animeFaces.objects.length == 0 && normalFaces.objects.length == 0) {
-        if (failMessage) message.channel.send(failMessage);
-        return;
+    getPadoruChance() {
+        let now = new Date();
+        if (now.getUTCMonth() != 11 || now.getUTCDate() > 25)
+            return 0;
+        return 1 / (27.0 - now.getUTCDate());
     }
 
-    let faces = [...normalFaces.objects, ...animeFaces.objects];
+    async processFaceSwap(message: Message, attachmentUrl: string, processor?: ImageProcessor, failMessage?: string, successMessage?: string) {
+        let data = await request(attachmentUrl, { encoding: null }) as Buffer;
+        let im = await cv.imdecodeAsync(data, cv.IMREAD_COLOR);
+        let gray = await im.cvtColorAsync(cv.COLOR_BGR2GRAY);
+        let normGray = await gray.equalizeHistAsync();
+        let animeFaces = await animeCascade.detectMultiScaleAsync(
+            normGray,
+            1.1,
+            5,
+            0,
+            new cv.Size(24, 24)
+        );
+        let normalFaces = await faceCascade.detectMultiScaleAsync(gray);
+
+        if (animeFaces.objects.length == 0 && normalFaces.objects.length == 0) {
+            if (failMessage) message.channel.send(failMessage);
+            return;
+        }
+
+        let faces = [...normalFaces.objects, ...animeFaces.objects];
 
-    let normalCount = normalFaces.objects.length;
-    let animeCount = animeFaces.objects.length;
+        let normalCount = normalFaces.objects.length;
+        let animeCount = animeFaces.objects.length;
 
-    for (let i = 0; i < normalCount; i++) {
-        const rNormal = faces[i];
+        for (let i = 0; i < normalCount; i++) {
+            const rNormal = faces[i];
 
-        if (animeCount == 0) break;
+            if (animeCount == 0) break;
 
-        for (let j = normalCount; j < faces.length; j++) {
-            const rAnime = faces[j];
+            for (let j = normalCount; j < faces.length; j++) {
+                const rAnime = faces[j];
 
-            if (intersects(rAnime, rNormal)) {
-                let animeA = rAnime.width * rAnime.height;
-                let faceA = rNormal.width * rNormal.height;
+                if (this.intersects(rAnime, rNormal)) {
+                    let animeA = rAnime.width * rAnime.height;
+                    let faceA = rNormal.width * rNormal.height;
 
-                if (animeA > faceA) {
-                    faces.splice(i, 1);
-                    normalCount--;
-                    i--;
-                    break;
-                } else {
-                    faces.splice(j, 1);
-                    animeCount--;
-                    j--;
+                    if (animeA > faceA) {
+                        faces.splice(i, 1);
+                        normalCount--;
+                        i--;
+                        break;
+                    } else {
+                        faces.splice(j, 1);
+                        animeCount--;
+                        j--;
+                    }
                 }
             }
         }
-    }
 
-    let jimpImage;
-    if (processor)
-        jimpImage = await processor(faces, data);
-    else {
-        if (Math.random() <= CAPTION_PROBABILITY)
-            jimpImage = await captionFace(faces, data);
-        else
-            jimpImage = await morphFaces(faces, data);
-    }
+        let jimpImage;
+        if (processor)
+            jimpImage = await processor(faces, data);
+        else {
+            if (Math.random() <= CAPTION_PROBABILITY)
+                jimpImage = await this.captionFace(faces, data);
+            else
+                jimpImage = await this.morphFaces(faces, data);
+        }
 
-    jimpImage.quality(90);
-    let buffer = await jimpImage.getBufferAsync(Jimp.MIME_JPEG);
-    let messageContents =
-        successMessage ||
-        `I noticed a face in the image. I think this looks better ${client.emojis.get("505076258753740810").toString()}`;
+        jimpImage.quality(90);
+        let buffer = await jimpImage.getBufferAsync(Jimp.MIME_JPEG);
+        let messageContents =
+            successMessage ||
+            `I noticed a face in the image. I think this looks better ${client.emojis.get("505076258753740810").toString()}`;
 
-    message.channel.send(messageContents, {
-        files: [buffer]
-    });
-}
+        message.channel.send(messageContents, {
+            files: [buffer]
+        });
+    }
 
-function processLastImage(msg: Message, processor: ImageProcessor) {
-    let lastImagedMessage = msg.channel.messages.filter(m => !m.author.bot && m.attachments.find(v => isValidImage(v.filename) !== undefined) != undefined).last();
+    processLastImage(msg: Message, processor: ImageProcessor) {
+        let lastImagedMessage = msg.channel.messages.filter(m => !m.author.bot && m.attachments.find(v => isValidImage(v.filename) !== undefined) != undefined).last();
 
-    if (!lastImagedMessage) {
-        msg.channel.send(`${msg.author.toString()} Sorry, I couldn't find any recent messages with images.`);
-        return;
-    }
+        if (!lastImagedMessage) {
+            msg.channel.send(`${msg.author.toString()} Sorry, I couldn't find any recent messages with images.`);
+            return;
+        }
 
-    let image = lastImagedMessage.attachments.find(v => isValidImage(v.filename));
+        let image = lastImagedMessage.attachments.find(v => isValidImage(v.filename));
 
-    processFaceSwap(
-        msg,
-        image.url,
-        processor,
-        `${msg.author.toString()} Nice image! I don't see anything interesting, though.`,
-        `${msg.author.toString()} ${client.emojis.get("505076258753740810").toString()}`
-    ).catch(err => console.log(`Failed to run faceapp because ${err}`));
-}
+        this.processFaceSwap(
+            msg,
+            image.url,
+            processor,
+            `${msg.author.toString()} Nice image! I don't see anything interesting, though.`,
+            `${msg.author.toString()} ${client.emojis.get("505076258753740810").toString()}`
+        ).catch(err => console.log(`Failed to run faceapp because ${err}`));
+    }
 
-export default {
-    onMessage: async (actionsDone, msg, contents) => {
+    @Action(ActionType.MESSAGE)
+    async morphRandomImage(actionsDone: boolean, msg: Message, contests: string) {
         if (actionsDone) return false;
 
         if (msg.mentions.users.size > 0 && msg.mentions.users.first().id == client.user.id)
@@ -225,21 +227,23 @@ export default {
 
             let knownChannel = await repo.findOne({
                 where: { channelId: msg.channel.id },
-                select: [ "faceMorphProbability" ]
+                select: ["faceMorphProbability"]
             });
 
-            if(!knownChannel || Math.random() > knownChannel.faceMorphProbability)
+            if (!knownChannel || Math.random() > knownChannel.faceMorphProbability)
                 return false;
-            
-            processFaceSwap(msg, imageAttachment.url).catch(err =>
+
+            this.processFaceSwap(msg, imageAttachment.url).catch(err =>
                 console.log(`Failed to run faceapp because ${err}`)
             );
             return true;
         }
 
         return false;
-    },
-    onDirectMention: (actionsDone, msg, content) => {
+    }
+
+    @Action(ActionType.DIRECT_MENTION)
+    async morphProvidedImage(actionsDone: boolean, msg: Message, content: string) {
         if (actionsDone) return false;
 
         let image = msg.attachments.find(v => isValidImage(v.filename));
@@ -255,14 +259,14 @@ export default {
 
         let processor;
         if (content.startsWith("caption this"))
-            processor = captionFace;
+            processor = this.captionFace;
         else
-            processor = morphFaces;
+            processor = this.morphFaces;
 
         let replyEmoji = client.emojis.get("505076258753740810");
         let emojiText = replyEmoji ? replyEmoji.toString() : "Jiiii~";
 
-        processFaceSwap(
+        this.processFaceSwap(
             msg,
             image.url,
             processor,
@@ -271,14 +275,19 @@ export default {
         ).catch(err => console.log(`Failed to run faceapp because ${err}`));
 
         return true;
-    },
-    commands: [
-        {
-            pattern: "caption last image",
-            action: msg => processLastImage(msg, captionFace)
-        },
-        {
-            pattern: "look at last image",
-            action: msg => processLastImage(msg, morphFaces)
-        }]
-} as ICommand;
+    }
+
+    @Command({
+        pattern: "caption last image"
+    })
+    captionLastImage(msg: Message) {
+        this.processLastImage(msg, this.captionFace);
+    }
+
+    @Command({
+        pattern: "look at last image"
+    })
+    lookLastImage(msg: Message) {
+        this.processLastImage(msg, this.morphFaces)
+    }
+};

+ 224 - 227
bot/src/commands/forums_news_checker.ts

@@ -2,7 +2,6 @@ import TurndownService, { Options } from "turndown";
 import interval from "interval-promise";
 import { client, forumClient, FORUMS_DOMAIN } from "../client";
 import sha1 from "sha1";
-import { ICommand } from "./command";
 import { TextChannel, Message, ReactionCollector, MessageReaction, Collector, User, Channel } from "discord.js";
 import { Dict } from "../util";
 import { getRepository, Not, IsNull } from "typeorm";
@@ -10,294 +9,292 @@ import { PostedForumNewsItem } from "@db/entity/PostedForumsNewsItem";
 import { KnownChannel } from "@db/entity/KnownChannel";
 import { PostVerifyMessage } from "@db/entity/PostVerifyMessage";
 import { render } from "../bbcode-parser/bbcode-js";
+import { CommandSet, Command } from "src/model/command";
 
 const PREVIEW_CHAR_LIMIT = 300;
 const NEWS_POST_VERIFY_CHANNEL = "newsPostVerify";
-
-let verifyChannelId: string = null;
-const reactionCollectors: Dict<ReactionCollector> = {};
-const verifyMessageIdToPost: Dict<string> = {};
 const NEWS_FEED_CHANNEL = "newsFeed";
-
-let botUserId = 0;
-
-const turndown = new TurndownService();
-turndown.addRule("image", {
-    filter: "img",
-    replacement: () => ""
-});
-turndown.addRule("link", {
-    filter: (node: HTMLElement, opts: Options) => node.nodeName === "A" && node.getAttribute("href") != null,
-    replacement: (content: string, node: HTMLElement) => node.getAttribute("href")
-});
-
 const RSS_UPDATE_INTERVAL_MIN = 5;
 const NEWS_FORUM_ID = 49;
 
-function bbCodeToMarkdown(bbCode: string) {
-    let html = render(bbCode).replace(/\n/gm, "</br>");
-
-    return turndown.turndown(html).trim();//.replace(/( {2}\n|\n\n){2,}/gm, "\n");
-}
-
-async function checkFeeds() {
-    console.log(`Checking feeds on ${new Date().toISOString()}`);
-    let forumsNewsRepo = getRepository(PostedForumNewsItem);
-    let postVerifyMessageRepo = getRepository(PostVerifyMessage);
-
-    let forumThreads = await forumClient.getForumThreads(NEWS_FORUM_ID);
-
-    for (let thread of [...forumThreads.threads, ...forumThreads.sticky]) {
-        let firstPost = await forumClient.getPost(thread.first_post_id);
-
-        let contents = bbCodeToMarkdown(firstPost.message);
-        let itemObj = forumsNewsRepo.create({
-            id: thread.thread_id.toString(),
-            hash: sha1(firstPost.message),
-            verifyMessage: postVerifyMessageRepo.create({
-                author: thread.username,
-                link: `${FORUMS_DOMAIN}/index.php?threads/${thread.thread_id}/`,
-                title: thread.title,
-                text: `${contents.substr(0, Math.min(contents.length, PREVIEW_CHAR_LIMIT))}...`,
-                isNew: true
-            })
-        });
+@CommandSet
+export class ForumsNewsChecker {
 
-        let postItem = await forumsNewsRepo.findOne({
-            where: { id: itemObj.id },
-            relations: ["verifyMessage"]
+    verifyMessageIdToPost: Dict<string> = {}
+    reactionCollectors: Dict<ReactionCollector> = {};
+    verifyChannelId: string = null;
+    botUserId = 0;
+    turndown = new TurndownService();
+
+    constructor() {
+        this.turndown.addRule("image", {
+            filter: "img",
+            replacement: () => ""
+        });
+        this.turndown.addRule("link", {
+            filter: (node: HTMLElement, opts: Options) => node.nodeName === "A" && node.getAttribute("href") != null,
+            replacement: (content: string, node: HTMLElement) => node.getAttribute("href")
         });
+    }
 
-        if (postItem) {
 
-            if (process.env.IGNORE_CHANGED_NEWS === "TRUE") {
-                await forumsNewsRepo.update({
-                    id: postItem.id
-                }, {
-                        hash: itemObj.hash
-                    });
-                continue;
-            }
+    bbCodeToMarkdown(bbCode: string) {
+        let html = render(bbCode).replace(/\n/gm, "</br>");
+
+        return this.turndown.turndown(html).trim();//.replace(/( {2}\n|\n\n){2,}/gm, "\n");
+    }
+
+    async checkFeeds() {
+        console.log(`Checking feeds on ${new Date().toISOString()}`);
+        let forumsNewsRepo = getRepository(PostedForumNewsItem);
+        let postVerifyMessageRepo = getRepository(PostVerifyMessage);
+
+        let forumThreads = await forumClient.getForumThreads(NEWS_FORUM_ID);
+
+        for (let thread of [...forumThreads.threads, ...forumThreads.sticky]) {
+            let firstPost = await forumClient.getPost(thread.first_post_id);
+
+            let contents = this.bbCodeToMarkdown(firstPost.message);
+            let itemObj = forumsNewsRepo.create({
+                id: thread.thread_id.toString(),
+                hash: sha1(firstPost.message),
+                verifyMessage: postVerifyMessageRepo.create({
+                    author: thread.username,
+                    link: `${FORUMS_DOMAIN}/index.php?threads/${thread.thread_id}/`,
+                    title: thread.title,
+                    text: `${contents.substr(0, Math.min(contents.length, PREVIEW_CHAR_LIMIT))}...`,
+                    isNew: true
+                })
+            });
+
+            let postItem = await forumsNewsRepo.findOne({
+                where: { id: itemObj.id },
+                relations: ["verifyMessage"]
+            });
+
+            if (postItem) {
+
+                if (process.env.IGNORE_CHANGED_NEWS === "TRUE") {
+                    await forumsNewsRepo.update({
+                        id: postItem.id
+                    }, {
+                            hash: itemObj.hash
+                        });
+                    continue;
+                }
 
-            // Add message ID to mark for edit
-            if (postItem.hash != itemObj.hash) {
-                let newHash = itemObj.hash;
-                if (!postItem.verifyMessage)
-                    postItem.verifyMessage = itemObj.verifyMessage;
+                // Add message ID to mark for edit
+                if (postItem.hash != itemObj.hash) {
+                    let newHash = itemObj.hash;
+                    if (!postItem.verifyMessage)
+                        postItem.verifyMessage = itemObj.verifyMessage;
 
-                itemObj = postItem;
-                itemObj.verifyMessage.isNew = false;
-                itemObj.hash = newHash;
+                    itemObj = postItem;
+                    itemObj.verifyMessage.isNew = false;
+                    itemObj.hash = newHash;
+                }
+                else
+                    continue;
             }
+
+            if (!this.shouldVerify() || firstPost.user_id == this.botUserId)
+                await this.sendNews(itemObj);
             else
-                continue;
+                await this.addVerifyMessage(itemObj);
         }
-
-        if (!shouldVerify() || firstPost.user_id == botUserId)
-            await sendNews(itemObj);
-        else
-            await addVerifyMessage(itemObj);
     }
-}
 
-async function initPendingReactors() {
-    let verifyChannel = client.channels.get(verifyChannelId);
+    async initPendingReactors() {
+        let verifyChannel = client.channels.get(this.verifyChannelId);
 
-    let repo = getRepository(PostedForumNewsItem);
-    let verifyMessageRepo = getRepository(PostVerifyMessage);
+        let repo = getRepository(PostedForumNewsItem);
+        let verifyMessageRepo = getRepository(PostVerifyMessage);
 
-    let pendingVerifyMessages = await repo.find({
-        where: { verifyMessage: Not(IsNull()) },
-        select: ["id"],
-        relations: ["verifyMessage"]
-    });
+        let pendingVerifyMessages = await repo.find({
+            where: { verifyMessage: Not(IsNull()) },
+            select: ["id"],
+            relations: ["verifyMessage"]
+        });
 
-    for (let msg of pendingVerifyMessages) {
-        let m = await tryFetchMessage(verifyChannel, msg.verifyMessage.messageId);
+        for (let msg of pendingVerifyMessages) {
+            let m = await this.tryFetchMessage(verifyChannel, msg.verifyMessage.messageId);
 
-        if (!m) {
-            await repo.update({ id: msg.id }, { verifyMessage: null });
-            await verifyMessageRepo.delete(msg.verifyMessage);
-            continue;
-        }
+            if (!m) {
+                await repo.update({ id: msg.id }, { verifyMessage: null });
+                await verifyMessageRepo.delete(msg.verifyMessage);
+                continue;
+            }
 
-        let collector = m.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
-        collector.on("collect", collectReaction);
-        reactionCollectors[m.id] = collector;
-        verifyMessageIdToPost[m.id] = msg.id;
+            let collector = m.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
+            collector.on("collect", this.collectReaction);
+            this.reactionCollectors[m.id] = collector;
+            this.verifyMessageIdToPost[m.id] = msg.id;
+        }
     }
-}
 
-async function addVerifyMessage(item: PostedForumNewsItem) {
-    let verifyChannel = client.channels.get(verifyChannelId) as TextChannel;
+    async addVerifyMessage(item: PostedForumNewsItem) {
+        let verifyChannel = client.channels.get(this.verifyChannelId) as TextChannel;
 
-    if (!verifyChannel) {
-        console.log(`Skipping adding item ${item.id} because no verify channel is set up!`);
-        return;
-    }
+        if (!verifyChannel) {
+            console.log(`Skipping adding item ${item.id} because no verify channel is set up!`);
+            return;
+        }
 
-    let verifyMessageRepo = getRepository(PostVerifyMessage);
-    let forumsNewsRepo = getRepository(PostedForumNewsItem);
+        let verifyMessageRepo = getRepository(PostVerifyMessage);
+        let forumsNewsRepo = getRepository(PostedForumNewsItem);
 
-    if (item.verifyMessage.messageId) {
-        let oldMessage = await tryFetchMessage(verifyChannel, item.verifyMessage.messageId);
-        if (oldMessage)
-            await oldMessage.delete();
-    }
+        if (item.verifyMessage.messageId) {
+            let oldMessage = await this.tryFetchMessage(verifyChannel, item.verifyMessage.messageId);
+            if (oldMessage)
+                await oldMessage.delete();
+        }
 
-    let newMessage = await verifyChannel.send(toVerifyString(item.id, item.verifyMessage)) as Message;
-    item.verifyMessage.messageId = newMessage.id;
+        let newMessage = await verifyChannel.send(this.toVerifyString(item.id, item.verifyMessage)) as Message;
+        item.verifyMessage.messageId = newMessage.id;
 
-    await newMessage.react("✅");
-    await newMessage.react("❌");
+        await newMessage.react("✅");
+        await newMessage.react("❌");
 
-    let collector = newMessage.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
-    collector.on("collect", collectReaction)
-    reactionCollectors[newMessage.id] = collector;
-    verifyMessageIdToPost[newMessage.id] = item.id;
+        let collector = newMessage.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
+        collector.on("collect", this.collectReaction)
+        this.reactionCollectors[newMessage.id] = collector;
+        this.verifyMessageIdToPost[newMessage.id] = item.id;
 
-    item.verifyMessage = await verifyMessageRepo.save(item.verifyMessage);
-    await forumsNewsRepo.save(item);
-}
+        item.verifyMessage = await verifyMessageRepo.save(item.verifyMessage);
+        await forumsNewsRepo.save(item);
+    }
 
-async function collectReaction(reaction: MessageReaction, collector: Collector<string, MessageReaction>) {
-    let verifyMessageRepo = getRepository(PostVerifyMessage);
-    let postRepo = getRepository(PostedForumNewsItem);
+    async collectReaction(reaction: MessageReaction, collector: Collector<string, MessageReaction>) {
+        let verifyMessageRepo = getRepository(PostVerifyMessage);
+        let postRepo = getRepository(PostedForumNewsItem);
 
-    let m = reaction.message;
-    collector.stop();
-    delete reactionCollectors[m.id];
-    let postId = verifyMessageIdToPost[m.id];
+        let m = reaction.message;
+        collector.stop();
+        delete this.reactionCollectors[m.id];
+        let postId = this.verifyMessageIdToPost[m.id];
 
-    let post = await postRepo.findOne({
-        where: { id: postId },
-        relations: [ "verifyMessage" ]
-    });
+        let post = await postRepo.findOne({
+            where: { id: postId },
+            relations: ["verifyMessage"]
+        });
 
-    await postRepo.update({ id: post.id }, { verifyMessage: null });
-    await verifyMessageRepo.delete({ id: post.verifyMessage.id });
-    await reaction.message.delete();
+        await postRepo.update({ id: post.id }, { verifyMessage: null });
+        await verifyMessageRepo.delete({ id: post.verifyMessage.id });
+        await reaction.message.delete();
 
-    if (reaction.emoji.name == "✅")
-        sendNews(post);
+        if (reaction.emoji.name == "✅")
+            this.sendNews(post);
 
-    delete verifyMessageIdToPost[m.id];
-}
+        delete this.verifyMessageIdToPost[m.id];
+    }
 
-async function sendNews(item: PostedForumNewsItem) {
-    let channelRepo = getRepository(KnownChannel);
-    let newsPostRepo = getRepository(PostedForumNewsItem);
+    async sendNews(item: PostedForumNewsItem) {
+        let channelRepo = getRepository(KnownChannel);
+        let newsPostRepo = getRepository(PostedForumNewsItem);
 
-    let outChannel = await channelRepo.findOne({
-        where: { channelType: NEWS_FEED_CHANNEL }
-    });
+        let outChannel = await channelRepo.findOne({
+            where: { channelType: NEWS_FEED_CHANNEL }
+        });
 
-    let sentMessage = await postNewsItem(outChannel.channelId, item);
+        let sentMessage = await this.postNewsItem(outChannel.channelId, item);
 
-    item.postedMessageId = sentMessage.id;
-    item.verifyMessage = null;
+        item.postedMessageId = sentMessage.id;
+        item.verifyMessage = null;
 
-    await newsPostRepo.save(item);
-}
+        await newsPostRepo.save(item);
+    }
 
-function isVerifyReaction(reaction: MessageReaction, user: User) {
-    return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot;
-}
+    isVerifyReaction(reaction: MessageReaction, user: User) {
+        return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot;
+    }
 
-async function tryFetchMessage(channel: Channel, messageId: string) {
-    try {
-        if (!(channel instanceof TextChannel))
+    async tryFetchMessage(channel: Channel, messageId: string) {
+        try {
+            if (!(channel instanceof TextChannel))
+                return null;
+            return await channel.fetchMessage(messageId);
+        } catch (error) {
             return null;
-        return await channel.fetchMessage(messageId);
-    } catch (error) {
-        return null;
+        }
     }
-}
 
-function shouldVerify() {
-    return verifyChannelId != null;
-}
+    shouldVerify() {
+        return this.verifyChannelId != null;
+    }
 
-async function postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
-    let newsMessage = toNewsString(item.verifyMessage);
-    let ch = client.channels.get(channel);
+    async postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
+        let newsMessage = this.toNewsString(item.verifyMessage);
+        let ch = client.channels.get(channel);
 
-    if (!(ch instanceof TextChannel))
-        return null;
+        if (!(ch instanceof TextChannel))
+            return null;
 
-    if (item.postedMessageId) {
-        let message = await tryFetchMessage(ch, item.postedMessageId);
-        if (message)
-            return await message.edit(newsMessage);
+        if (item.postedMessageId) {
+            let message = await this.tryFetchMessage(ch, item.postedMessageId);
+            if (message)
+                return await message.edit(newsMessage);
+            else
+                return await ch.send(newsMessage) as Message;
+        }
         else
             return await ch.send(newsMessage) as Message;
     }
-    else
-        return await ch.send(newsMessage) as Message;
-}
-
-function markdownify(htmStr: string, link: string) {
-    return turndown.turndown(htmStr).replace(/( {2}\n|\n\n){2,}/gm, "\n").replace(link, "");
-}
 
-function toNewsString(item: PostVerifyMessage) {
-    return `**${item.title}**
+    toNewsString(item: PostVerifyMessage) {
+        return `**${item.title}**
 Posted by ${item.author}
 ${item.link}
-
+    
 ${item.text}`;
-}
+    }
 
-function toVerifyString(postId: string, item: PostVerifyMessage) {
-    return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}]
+    toVerifyString(postId: string, item: PostVerifyMessage) {
+        return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}]
 Post ID: **${postId}**
-    
-${toNewsString(item)}
-            
+        
+${this.toNewsString(item)}
+                
 React with ✅ (approve) or ❌ (deny).`;
-}
-
-export default {
-    commands: [
-        {
-            pattern: /^edit (\d+)\s+((.*[\n\r]*)+)$/i,
-            action: async (msg, s, match) => {
-                if (msg.channel.id != verifyChannelId)
-                    return;
-
-                let id = match[1];
-                let newContents = match[2].trim();
-
-                let repo = getRepository(PostedForumNewsItem);
-                let verifyRepo = getRepository(PostVerifyMessage);
-
-                let post = await repo.findOne({
-                    where: { id: id },
-                    relations: ["verifyMessage"]
-                });
-
-                if (!post || !post.verifyMessage) {
-                    msg.channel.send(`${msg.author.toString()} No unapproved news items with id ${id}!`);
-                    return;
-                }
+    }
 
-                let editMsg = await tryFetchMessage(client.channels.get(verifyChannelId), post.verifyMessage.messageId);
+    @Command({
+        pattern: /^edit (\d+)\s+((.*[\n\r]*)+)$/i
+    })
+    async editPreview(msg: Message, contests: string, match: RegExpMatchArray) {
+        if (msg.channel.id != this.verifyChannelId)
+            return;
 
-                if (!editMsg) {
-                    msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);
-                    return;
-                }
+        let id = match[1];
+        let newContents = match[2].trim();
 
-                post.verifyMessage.text = newContents;
+        let repo = getRepository(PostedForumNewsItem);
+        let verifyRepo = getRepository(PostVerifyMessage);
 
-                await verifyRepo.save(post.verifyMessage);
-                await editMsg.edit(toVerifyString(post.id, post.verifyMessage));
-                await msg.delete();
-            }
+        let post = await repo.findOne({
+            where: { id: id },
+            relations: ["verifyMessage"]
+        });
+
+        if (!post || !post.verifyMessage) {
+            msg.channel.send(`${msg.author.toString()} No unapproved news items with id ${id}!`);
+            return;
+        }
+
+        let editMsg = await this.tryFetchMessage(client.channels.get(this.verifyChannelId), post.verifyMessage.messageId);
+
+        if (!editMsg) {
+            msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);
+            return;
         }
-    ],
-    onStart: async () => {
 
+        post.verifyMessage.text = newContents;
+
+        await verifyRepo.save(post.verifyMessage);
+        await editMsg.edit(this.toVerifyString(post.id, post.verifyMessage));
+        await msg.delete();
+    }
+
+    async onStart() {
         let repo = getRepository(KnownChannel);
 
         let verifyChannel = await repo.findOne({
@@ -307,12 +304,12 @@ export default {
         if (!verifyChannel)
             return;
 
-        verifyChannelId = verifyChannel.channelId;
+        this.verifyChannelId = verifyChannel.channelId;
 
         let user = await forumClient.getMe();
-        botUserId = user.user_id;
+        this.botUserId = user.user_id;
 
-        await initPendingReactors();
-        interval(checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
+        await this.initPendingReactors();
+        interval(this.checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
     }
-} as ICommand;
+};

+ 190 - 203
bot/src/commands/guide.ts

@@ -1,226 +1,213 @@
 import { isAuthorisedAsync } from "../util";
-import { ICommand } from "./command";
 import { Message } from "discord.js";
 import { getRepository } from "typeorm";
 import { Guide, GuideType, GuideKeyword } from "@db/entity/Guide";
+import { CommandSet, Action, ActionType, Command } from "src/model/command";
+
+@CommandSet
+export class GuideCommands {
+    async matchGuide(keywords: string[]) {
+        let a = await getRepository(Guide).query(
+            `select guide.*
+             from guide
+             inner join (select gk."guideId", count("guideKeywordId") as gc
+                        from guide_keywords_guide_keyword as gk
+                        where
+                        gk."guideKeywordId" in    (select id
+                                                from guide_keyword
+                                                where
+                                                guide_keyword.keyword in (${keywords.map((v, i) => `$${i + 1}`).join(",")}))
+                        group by gk."guideId") as gks
+            on gks."guideId" = guide.id
+            order by gc desc
+            limit 1`,
+            keywords
+        ) as Guide[];
+        if (a.length == 0)
+            return null;
+        return a[0];
+    }
 
-const makePattern = /^make (\w+)\s+name:(.+)\s*keywords:(.+)\s*contents:((.*[\n\r]*)+)$/i;
-const deletePattern = /^delete (\w+)\s+(.+)$/i;
-
-interface IGuide {
-    name: string,
-    displayName: string,
-    content: string
-};
-
-async function matchGuide(keywords: string[]) {
-    let a = await getRepository(Guide).query(
-        `select guide.*
-         from guide
-         inner join (select gk."guideId", count("guideKeywordId") as gc
-                    from guide_keywords_guide_keyword as gk
-                    where
-                    gk."guideKeywordId" in    (select id
-                                            from guide_keyword
-                                            where
-                                            guide_keyword.keyword in (${keywords.map((v, i) => `$${i + 1}`).join(",")}))
-                    group by gk."guideId") as gks
-        on gks."guideId" = guide.id
-        order by gc desc
-        limit 1`,
-        keywords
-    ) as Guide[];
-    if(a.length == 0)
-        return null;
-    return a[0];
-}
-
-async function listGuides(msg: Message, guideType: string, message: string) {
-    let repo = getRepository(Guide);
-
-    let allGuides = await repo.createQueryBuilder("guide")
-                            .select(["guide.displayName"])
-                            .leftJoinAndSelect("guide.keywords", "keyword")
-                            .where("guide.type = :type", { type: guideType })
-                            .getMany();
-
-    const MAX_GUIDES_PER_MSG = 30;
-    let guides = `${msg.author.toString()} ${message}\n\`\`\``;
-    let guideNum = 0;
-    for(let guide of allGuides) {
-        guides += `${guide.displayName} -- ${guide.keywords.map(k => k.keyword).join(" ")}\n`;
-        guideNum++;
-
-        if(guideNum == MAX_GUIDES_PER_MSG) {
-            guides += "```";
-            await msg.channel.send(guides);
-            guides = "```\n";
-            guideNum = 0;
+    async listGuides(msg: Message, guideType: string, message: string) {
+        let repo = getRepository(Guide);
+
+        let allGuides = await repo.createQueryBuilder("guide")
+            .select(["guide.displayName"])
+            .leftJoinAndSelect("guide.keywords", "keyword")
+            .where("guide.type = :type", { type: guideType })
+            .getMany();
+
+        const MAX_GUIDES_PER_MSG = 30;
+        let guides = `${msg.author.toString()} ${message}\n\`\`\``;
+        let guideNum = 0;
+        for (let guide of allGuides) {
+            guides += `${guide.displayName} -- ${guide.keywords.map(k => k.keyword).join(" ")}\n`;
+            guideNum++;
+
+            if (guideNum == MAX_GUIDES_PER_MSG) {
+                guides += "```";
+                await msg.channel.send(guides);
+                guides = "```\n";
+                guideNum = 0;
+            }
         }
-    }
 
-    if(guideNum != 0) {
-        guides += "```\n\nTo display the guides, ping me with one or more keywords, like `@NoctBot sybaris com`";
-        await msg.channel.send(guides);
+        if (guideNum != 0) {
+            guides += "```\n\nTo display the guides, ping me with one or more keywords, like `@NoctBot sybaris com`";
+            await msg.channel.send(guides);
+        }
     }
-}
 
-export default {
-    onDirectMention: async (actionsDone, msg, content) => {
+    @Action(ActionType.DIRECT_MENTION)
+    async displayGuide(actionsDone: boolean, msg: Message, content: string) {
         if (actionsDone)
             return false;
-    
-        if(msg.attachments.size > 0 || content.length == 0)
+
+        if (msg.attachments.size > 0 || content.length == 0)
             return false;
-    
+
         let parts = content.split(" ").map(s => s.trim()).filter(s => s.length != 0);
 
-        let guide = await matchGuide(parts);
-        
+        let guide = await this.matchGuide(parts);
+
         if (guide) {
             msg.channel.send(guide.content);
             return true;
         }
         return false;
-    },
-    commands: [
-        {
-            pattern: makePattern,
-            action: async (msg, s, match) => {
-                if (!await isAuthorisedAsync(msg.member)) return;
-                let type = match[1].toLowerCase();
-                let name = match[2].trim();
-                let keywords = match[3].toLowerCase().split(" ").map(s => s.trim()).filter(s => s.length != 0);
-                let contents = match[4].trim();
-    
-                if(contents.length == 0){
-                    msg.channel.send(
-                        `${msg.author.toString()} The guide must have some content!`
-                    );
-                    return;
-                }
-        
-                if(!Object.values(GuideType).includes(type)){
-                    msg.channel.send(
-                        `${msg.author.toString()} The type ${type} is not a valid guide type!`
-                    );
-                    return;
-                }
-                
-                let repo = getRepository(GuideKeyword);
-                let guideRepo = getRepository(Guide);
-                
-                let existingKeywords = await repo.find({
-                    where: [
-                        ...keywords.map(k => ({ keyword: k }))
-                    ]
-                });
-                
-                let existingGuide = await matchGuide(keywords);
-
-                let addGuide = async () => {
-                    let newKeywords = new Set<string>();
-                    let knownKeywords = new Set(existingKeywords.map(e => e.keyword));
-                    for(let word of keywords) {
-                        if(!knownKeywords.has(word))
-                            newKeywords.add(word);
-                    }
-
-                    let addedKeywords = await repo.save([...newKeywords].map(k => repo.create({
-                        keyword: k
-                    })));
-
-                    await guideRepo.save(guideRepo.create({
-                        content: contents,
-                        displayName: name,
-                        keywords: [...existingKeywords, ...addedKeywords],
-                        type: type as GuideType
-                    }));
-                };
-
-                if(existingGuide) {
-                    let guideKeywordsCount = await repo
-                        .createQueryBuilder("keywords")
-                        .leftJoinAndSelect("keywords.relatedGuides", "guide")
-                        .where("guide.id = :id", {id: existingGuide.id })
-                        .getCount();
-
-                    if(guideKeywordsCount == existingKeywords.length)
-                        await guideRepo.update({id: existingGuide.id}, {
-                            displayName: name,
-                            content: contents
-                        });
-                    else
-                        await addGuide();
-                } else
-                    await addGuide();
-        
-                msg.channel.send(
-                    `${msg.author.toString()} Added/updated "${name}" (keywords \`${keywords.join(" ")}\`)!`
-                );
+    }
+
+    @Command({
+        pattern: /^make (\w+)\s+name:(.+)\s*keywords:(.+)\s*contents:((.*[\n\r]*)+)$/i,
+        auth: true,
+        documentation: "Creates a new guide of the specified type, the specified keywords and content."
+    })
+    async makeGuide(msg: Message, content: string, match: RegExpMatchArray) {
+        if (!await isAuthorisedAsync(msg.member)) return;
+        let type = match[1].toLowerCase();
+        let name = match[2].trim();
+        let keywords = match[3].toLowerCase().split(" ").map(s => s.trim()).filter(s => s.length != 0);
+        let contents = match[4].trim();
+
+        if (contents.length == 0) {
+            msg.channel.send(
+                `${msg.author.toString()} The guide must have some content!`
+            );
+            return;
+        }
+
+        if (!Object.values(GuideType).includes(type)) {
+            msg.channel.send(
+                `${msg.author.toString()} The type ${type} is not a valid guide type!`
+            );
+            return;
+        }
+
+        let repo = getRepository(GuideKeyword);
+        let guideRepo = getRepository(Guide);
+
+        let existingKeywords = await repo.find({
+            where: [
+                ...keywords.map(k => ({ keyword: k }))
+            ]
+        });
+
+        let existingGuide = await this.matchGuide(keywords);
+
+        let addGuide = async () => {
+            let newKeywords = new Set<string>();
+            let knownKeywords = new Set(existingKeywords.map(e => e.keyword));
+            for (let word of keywords) {
+                if (!knownKeywords.has(word))
+                    newKeywords.add(word);
             }
-        },
-        {
-            pattern: deletePattern,
-            action: async (msg, s, match) => {
-                if (!await isAuthorisedAsync(msg.member)) return;
-                let type = match[1];
-                let keywords = match[2].toLowerCase().split(" ").map(s => s.trim()).filter(s => s.length != 0);
-        
-                if(!Object.values(GuideType).includes(type)){
-                    await msg.channel.send(
-                        `${msg.author.toString()} The type ${type} is not a valid guide type!`
-                    );
-                    return;
-                }
-
-                let dedupedKeywords = [...new Set(keywords)];
-                
-                let repo = getRepository(GuideKeyword);
-                let guideRepo = getRepository(Guide);
-                let existingGuide = await matchGuide(keywords);
-
-                if(existingGuide) {
-                    let guideKeywordsCount = await repo
-                        .createQueryBuilder("keywords")
-                        .leftJoinAndSelect("keywords.relatedGuides", "guide")
-                        .where("guide.id = :id", {id: existingGuide.id })
-                        .getCount();
-
-                    if(guideKeywordsCount == dedupedKeywords.length) {
-                        await guideRepo.delete({ id: existingGuide.id });
-                        await msg.channel.send(`${msg.author.toString()} 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?`);
+
+            let addedKeywords = await repo.save([...newKeywords].map(k => repo.create({
+                keyword: k
+            })));
+
+            await guideRepo.save(guideRepo.create({
+                content: contents,
+                displayName: name,
+                keywords: [...existingKeywords, ...addedKeywords],
+                type: type as GuideType
+            }));
+        };
+
+        if (existingGuide) {
+            let guideKeywordsCount = await repo
+                .createQueryBuilder("keywords")
+                .leftJoinAndSelect("keywords.relatedGuides", "guide")
+                .where("guide.id = :id", { id: existingGuide.id })
+                .getCount();
+
+            if (guideKeywordsCount == existingKeywords.length)
+                await guideRepo.update({ id: existingGuide.id }, {
+                    displayName: name,
+                    content: contents
+                });
+            else
+                await addGuide();
+        } else
+            await addGuide();
+
+        msg.channel.send(
+            `${msg.author.toString()} Added/updated "${name}" (keywords \`${keywords.join(" ")}\`)!`
+        );
+    }
+
+    @Command({
+        pattern: /^delete (\w+)\s+(.+)$/i,
+        auth: true,
+        documentation: "delete <guidetype> <keywords>"
+    })
+    async deleteGuide(msg: Message, content: string, match: RegExpMatchArray) {
+        if (!await isAuthorisedAsync(msg.member)) return;
+        let type = match[1];
+        let keywords = match[2].toLowerCase().split(" ").map(s => s.trim()).filter(s => s.length != 0);
+
+        if (!Object.values(GuideType).includes(type)) {
+            await msg.channel.send(
+                `${msg.author.toString()} The type ${type} is not a valid guide type!`
+            );
+            return;
+        }
+
+        let dedupedKeywords = [...new Set(keywords)];
+
+        let repo = getRepository(GuideKeyword);
+        let guideRepo = getRepository(Guide);
+        let existingGuide = await this.matchGuide(keywords);
+
+        if (existingGuide) {
+            let guideKeywordsCount = await repo
+                .createQueryBuilder("keywords")
+                .leftJoinAndSelect("keywords.relatedGuides", "guide")
+                .where("guide.id = :id", { id: existingGuide.id })
+                .getCount();
+
+            if (guideKeywordsCount == dedupedKeywords.length) {
+                await guideRepo.delete({ id: existingGuide.id });
+                await msg.channel.send(`${msg.author.toString()} Removed ${type} "${keywords.join(" ")}"!`);
+                return;
             }
-        },
-        { pattern: "guides", action: async msg => await listGuides(msg, "guide", "Here are the guides I have:") },
-        { pattern: "memes", action: async msg => await listGuides(msg, "meme", "Here are some random memes I have:") },
-        { pattern: "misc", action: async msg => await listGuides(msg, "misc", "These are some misc stuff I can also do:") },
-    ],
-    documentation: {
-        "make <guidetype> <NEWLINE>name: <name> <NEWLINE>keywords: <keywords> <NEWLINE>contents: <content>": {
-            auth: true,
-            description: "Creates a new guide of the specified type, the specified keywords and content."
-        },
-        "delete <guidetype> <keywords>": {
-            auth: true,
-            description: "Deletes a guide of the specified type."
-        },
-        "guides": {
-            auth: false,
-            description: "Lists all guides and keywords that trigger them."
-        },
-        "memes": {
-            auth: false,
-            description: "Lists all memes and keywords that trigger them."
-        },
-        "miscs": {
-            auth: false,
-            description: "Lists all additional keywords the bot reacts to."
         }
+
+        await msg.channel.send(`${msg.author.toString()} No such ${type} with keywords \`${keywords.join(" ")}\`! Did you forget to specify all keywords?`);
+    }
+
+    @Command({ pattern: "guides", documentation: "Lists all guides and keywords that trigger them." })
+    async showGuides(msg: Message) {
+        await this.listGuides(msg, "guide", "Here are the guides I have:");
+    }
+
+    @Command({ pattern: "memes", documentation: "Lists all memes and keywords that trigger them." })
+    async showMemes(msg: Message) {
+        await this.listGuides(msg, "meme", "Here are some random memes I have:")
+    }
+
+    @Command({ pattern: "misc", documentation: "Lists all additional keywords the bot reacts to." })
+    async showMisc(msg: Message) {
+        await this.listGuides(msg, "misc", "These are some misc stuff I can also do:")
     }
-} as ICommand;
+};

+ 23 - 26
bot/src/commands/help.ts

@@ -1,32 +1,29 @@
-import { isAuthorisedAsync, documentation } from "../util";
-import { ICommand } from "./command";
+import { isAuthorisedAsync } from "../util";
+import { CommandSet, Command } from "src/model/command";
+import { Message } from "discord.js";
+import { getDocumentation } from "src/main";
 
-export default {
-    commands: [{
-        pattern: "help",
-        action: async msg => {
-            let isAuthed = await isAuthorisedAsync(msg.member);
+@CommandSet
+export class Help {
+    @Command({ pattern: "help" })
+    async showHelp(msg: Message) {
+        let isAuthed = await isAuthorisedAsync(msg.member);
 
-            let baseCommands = "\n";
-            let modCommands = "\n";
+        let baseCommands = "\n";
+        let modCommands = "\n";
 
-            for (let command in documentation) {
-                if (!documentation.hasOwnProperty(command))
-                    continue;
-
-                let doc = documentation[command];
-                if (isAuthed && doc.auth)
-                    modCommands = `${modCommands}${command}  -  ${doc.description}\n`;
-                else if (!doc.auth)
-                    baseCommands = `${baseCommands}${command} - ${doc.description}\n`;
-            }
+        for (let doc of getDocumentation()) {
+            if (isAuthed && doc.auth)
+                modCommands = `${modCommands}${doc.name}  -  ${doc.doc}\n`;
+            else if (!doc.auth)
+                baseCommands = `${baseCommands}${doc.name} - ${doc.doc}\n`;
+        }
 
-            let message = `Hello! I am NoctBot! My job is to help with C(O)M-related problems!\nPing me with one of the following commands:\n\`\`\`${baseCommands}\`\`\``;
+        let message = `Hello! I am NoctBot! 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}\`\`\``;
+        if (isAuthed)
+            message = `${msg.author.toString()} ${message}\n👑**Moderator commands**👑\n\`\`\`${modCommands}\`\`\``;
 
-            msg.channel.send(message);
-        }
-    }]
-} as ICommand;
+        msg.channel.send(message);
+    }
+}

+ 14 - 20
bot/src/commands/inspire.ts

@@ -1,25 +1,19 @@
 import request from "request-promise-native";
-import { ICommand } from "./command";
 import { Message } from "discord.js";
+import { CommandSet, Command } from "src/model/command";
 
-async function inspire(msg: Message) {
-    let result = await request("https://inspirobot.me/api?generate=true");
-    msg.channel.send(`${msg.author.toString()} Here is a piece of my wisdom:`, {
-        files: [ result ]
-    });
-}
+@CommandSet
+export class Inspire {
 
-export default {
-    commands: [{
-        pattern: "inspire me",
-        action: msg => {
-            inspire(msg);    
-        }
-    }],
-    documentation: {
-        "inspire me": {
-            auth: false,
-            description: "Generates an inspiring quote just for you"
-        }
+    async doInspire(msg: Message) {
+        let result = await request("https://inspirobot.me/api?generate=true");
+        msg.channel.send(`${msg.author.toString()} Here is a piece of my wisdom:`, {
+            files: [ result ]
+        });
     }
-} as ICommand;
+
+    @Command({ pattern: "inspire me", documentation: "Generates an inspiring quote just for you"})
+    inspire(msg: Message) {
+        this.doInspire(msg);
+    }
+}

+ 210 - 218
bot/src/commands/news_aggregator.ts

@@ -6,289 +6,281 @@ import * as path from "path";
 import * as fs from "fs";
 import { HTML2BBCode } from "html2bbcode";
 import { Dict } from "../util";
-
-import { IAggregator, NewsPostItem, INewsPostData } from "./aggregators/aggregator";
-import { ICommand } from "./command";
+import { IAggregator, NewsPostItem } from "./aggregators/aggregator";
 import { RichEmbed, TextChannel, Message, Channel, ReactionCollector, MessageReaction, User, Collector } from "discord.js";
 import { getRepository, IsNull, Not } from "typeorm";
 import { KnownChannel } from "@db/entity/KnownChannel";
 import { AggroNewsItem } from "@db/entity/AggroNewsItem";
-
 import { v3beta1 } from "@google-cloud/translate";
-
+import { CommandSet } from "src/model/command";
 const { TranslationServiceClient } = v3beta1;
-const tlClient = new TranslationServiceClient();
 
 const UPDATE_INTERVAL = 5;
-const MAX_PREVIEW_LENGTH = 300; 
-
-const aggregators : IAggregator[] = [];
-let aggregateChannelID : string = null;
+const MAX_PREVIEW_LENGTH = 300;
 const AGGREGATOR_MANAGER_CHANNEL = "aggregatorManager";
-
 const FORUMS_STAGING_ID = 54;
 const FORUMS_NEWS_ID = 49;
-const bbCodeParser = new HTML2BBCode();
-
-const reactionCollectors: Dict<ReactionCollector> = {};
-const verifyMessageIdToPost: Dict<AggroNewsItem> = {};
 
-// TODO: Run BBCode converter instead
-const turndown = new TurndownService();
-turndown.addRule("image", {
-    filter: "img",
-    replacement: () => ""
-});
-turndown.addRule("link", {
-    filter: (node : HTMLElement, opts: Options) => node.nodeName === "A" &&node.getAttribute("href") != null,
-    replacement: (content: string, node: HTMLElement) => node.getAttribute("href")
-});
-
-function markdownify(htmStr: string) {
-    return turndown.turndown(htmStr)/*.replace(/( {2}\n|\n\n){2,}/gm, "\n").replace(link, "")*/;
-}
+@CommandSet
+export class NewsAggregator {
+    tlClient = new TranslationServiceClient();
+    aggregators: IAggregator[] = [];
+    aggregateChannelID: string = null;
+    bbCodeParser = new HTML2BBCode();
+    turndown = new TurndownService();
+    reactionCollectors: Dict<ReactionCollector> = {};
+    verifyMessageIdToPost: Dict<AggroNewsItem> = {};
+
+    constructor() {
+        this.turndown.addRule("image", {
+            filter: "img",
+            replacement: () => ""
+        });
+        this.turndown.addRule("link", {
+            filter: (node: HTMLElement, opts: Options) => node.nodeName === "A" && node.getAttribute("href") != null,
+            replacement: (content: string, node: HTMLElement) => node.getAttribute("href")
+        });
+    }
 
-async function checkFeeds() {
-    console.log(`Aggregating feeds on ${new Date().toISOString()}`);
+    async checkFeeds() {
+        console.log(`Aggregating feeds on ${new Date().toISOString()}`);
 
-    let aggregatorJobs = [];
+        let aggregatorJobs = [];
 
-    for(let aggregator of aggregators) {
-        aggregatorJobs.push(aggregator.aggregate());    
-    }
-    let aggregatedItems = await Promise.all(aggregatorJobs);
+        for (let aggregator of this.aggregators) {
+            aggregatorJobs.push(aggregator.aggregate());
+        }
+        let aggregatedItems = await Promise.all(aggregatorJobs);
 
-    for(let itemSet of aggregatedItems) {
-        for(let item of itemSet) {
-            let itemObj = {
-                ...item,
-                cacheMessageId: null,
-                postedMessageId: null
-            } as NewsPostItem;
+        for (let itemSet of aggregatedItems) {
+            for (let item of itemSet) {
+                let itemObj = {
+                    ...item,
+                    cacheMessageId: null,
+                    postedMessageId: null
+                } as NewsPostItem;
 
-            itemObj.hash = sha1(itemObj.contents);
+                itemObj.hash = sha1(itemObj.contents);
 
-            await addNewsItem(itemObj);
+                await this.addNewsItem(itemObj);
+            }
         }
     }
-}
-
-function clipText(text: string) {
-    if(text.length <= MAX_PREVIEW_LENGTH)
-        return text;
-
-    return `${text.substring(0, MAX_PREVIEW_LENGTH)}...`;
-}
 
-// TODO: Replace with proper forum implementation
-async function addNewsItem(item: NewsPostItem) {
-    let repo = getRepository(AggroNewsItem);
+    clipText(text: string) {
+        if (text.length <= MAX_PREVIEW_LENGTH)
+            return text;
 
-    let ch = client.channels.get(aggregateChannelID);
+        return `${text.substring(0, MAX_PREVIEW_LENGTH)}...`;
+    }
 
-    if(!(ch instanceof TextChannel))
-        return;
+    async addNewsItem(item: NewsPostItem) {
+        let repo = getRepository(AggroNewsItem);
 
-    let isNew = true;
-    let newsItem = await repo.findOne({
-        where: { feedName: item.feedId, newsId: item.newsId }
-    });
+        let ch = client.channels.get(this.aggregateChannelID);
 
-    if(newsItem) {
-        if(process.env.IGNORE_CHANGED_NEWS === "TRUE") {
-            newsItem.hash = item.hash;
-            await repo.save(newsItem);
+        if (!(ch instanceof TextChannel))
             return;
-        }
 
-        // No changes, skip
-        if(newsItem.hash == item.hash)
-            return;
-        else
-            await deleteCacheMessage(newsItem.editMessageId);
-        isNew = false;
-    } else {
-        newsItem = repo.create({
-            newsId: item.newsId,
-            feedName: item.feedId,
-            hash: item.hash
+        let isNew = true;
+        let newsItem = await repo.findOne({
+            where: { feedName: item.feedId, newsId: item.newsId }
         });
-    }
 
-    if(item.needsTranslation)
-        try {
-            let request = {
-                parent: tlClient.locationPath(process.env.GOOGLE_APP_ID, "global"),
-                contents: [ item.title, item.contents ],
-                mimeType: "text/html",
-                sourceLanguageCode: "ja",
-                targetLanguageCode: "en"
-            };
-
-            let [ res ] = await tlClient.translateText(request);
-
-            item.title = res.translations[0].translatedText
-            item.contents = res.translations[1].translatedText;
-        } catch(err) {
-            console.log(`Failed to translate because ${err}`);
+        if (newsItem) {
+            if (process.env.IGNORE_CHANGED_NEWS === "TRUE") {
+                newsItem.hash = item.hash;
+                await repo.save(newsItem);
+                return;
+            }
+
+            // No changes, skip
+            if (newsItem.hash == item.hash)
+                return;
+            else
+                await this.deleteCacheMessage(newsItem.editMessageId);
+            isNew = false;
+        } else {
+            newsItem = repo.create({
+                newsId: item.newsId,
+                feedName: item.feedId,
+                hash: item.hash
+            });
         }
 
-    item.contents = bbCodeParser.feed(item.contents).toString();
-
-    if(!newsItem.forumsEditPostId) {
-        let createResponse = await forumClient.createThread(FORUMS_STAGING_ID, item.title, item.contents);
-        newsItem.forumsEditPostId = createResponse.thread.thread_id;
-    } else {
-        await forumClient.postReply(newsItem.forumsNewsPostId, item.contents);
-    }
-    
-
-    let msg = await ch.send(new RichEmbed({
-        title: item.title,
-        url: item.link,
-        color: item.embedColor,
-        timestamp: new Date(),
-        description: `${(isNew ? "**[NEW]**" : "**[EDIT]**")}\n[**Edit on forums**](${FORUMS_DOMAIN}/index.php?threads/.${newsItem.forumsEditPostId}/)`,
-        author: {
-            name: item.author
-        },
-        footer: {
-            text: "NoctBot News Aggregator"
+        if (item.needsTranslation)
+            try {
+                let request = {
+                    parent: this.tlClient.locationPath(process.env.GOOGLE_APP_ID, "global"),
+                    contents: [item.title, item.contents],
+                    mimeType: "text/html",
+                    sourceLanguageCode: "ja",
+                    targetLanguageCode: "en"
+                };
+
+                let [res] = await this.tlClient.translateText(request);
+
+                item.title = res.translations[0].translatedText
+                item.contents = res.translations[1].translatedText;
+            } catch (err) {
+                console.log(`Failed to translate because ${err}`);
+            }
+
+        item.contents = this.bbCodeParser.feed(item.contents).toString();
+
+        if (!newsItem.forumsEditPostId) {
+            let createResponse = await forumClient.createThread(FORUMS_STAGING_ID, item.title, item.contents);
+            newsItem.forumsEditPostId = createResponse.thread.thread_id;
+        } else {
+            await forumClient.postReply(newsItem.forumsNewsPostId, item.contents);
         }
-    })) as Message;
-
-    newsItem.editMessageId = msg.id;
 
-    await msg.react("✅");
-    await msg.react("❌");
 
-    let collector = msg.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
-    collector.on("collect", collectReaction)
-    reactionCollectors[msg.id] = collector;
-    verifyMessageIdToPost[msg.id] = newsItem;
+        let msg = await ch.send(new RichEmbed({
+            title: item.title,
+            url: item.link,
+            color: item.embedColor,
+            timestamp: new Date(),
+            description: `${(isNew ? "**[NEW]**" : "**[EDIT]**")}\n[**Edit on forums**](${FORUMS_DOMAIN}/index.php?threads/.${newsItem.forumsEditPostId}/)`,
+            author: {
+                name: item.author
+            },
+            footer: {
+                text: "NoctBot News Aggregator"
+            }
+        })) as Message;
 
-    await repo.save(newsItem);
-}
+        newsItem.editMessageId = msg.id;
 
-function isVerifyReaction(reaction: MessageReaction, user: User) {
-    return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot && user.id != client.user.id;
-}
+        await msg.react("✅");
+        await msg.react("❌");
 
-async function collectReaction(reaction: MessageReaction, collector: Collector<string, MessageReaction>) {
-    let repo = getRepository(AggroNewsItem);
+        let collector = msg.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
+        collector.on("collect", this.collectReaction)
+        this.reactionCollectors[msg.id] = collector;
+        this.verifyMessageIdToPost[msg.id] = newsItem;
 
-    let m = reaction.message;
-    collector.stop();
-    delete reactionCollectors[m.id];
-    let post = verifyMessageIdToPost[m.id];
-    
-    if (reaction.emoji.name == "✅") {
-        let res = await forumClient.getThread(post.forumsEditPostId);
-        let forumPost = await forumClient.getPost(res.thread.first_post_id);
+        await repo.save(newsItem);
+    }
 
-        if(!post.forumsNewsPostId) {
-            let newThread = await forumClient.createThread(FORUMS_NEWS_ID, res.thread.title, forumPost.message);
-            post.forumsNewsPostId = newThread.thread.thread_id;
-        } else {
-            let curThread = await forumClient.editThread(post.forumsNewsPostId, {
-                title: res.thread.title
-            });
+    isVerifyReaction(reaction: MessageReaction, user: User) {
+        return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot && user.id != client.user.id;
+    }
 
-            await forumClient.editPost(curThread.thread.first_post_id, {
-                message: forumPost.message
-            });
+    async collectReaction(reaction: MessageReaction, collector: Collector<string, MessageReaction>) {
+        let repo = getRepository(AggroNewsItem);
+
+        let m = reaction.message;
+        collector.stop();
+        delete this.reactionCollectors[m.id];
+        let post = this.verifyMessageIdToPost[m.id];
+
+        if (reaction.emoji.name == "✅") {
+            let res = await forumClient.getThread(post.forumsEditPostId);
+            let forumPost = await forumClient.getPost(res.thread.first_post_id);
+
+            if (!post.forumsNewsPostId) {
+                let newThread = await forumClient.createThread(FORUMS_NEWS_ID, res.thread.title, forumPost.message);
+                post.forumsNewsPostId = newThread.thread.thread_id;
+            } else {
+                let curThread = await forumClient.editThread(post.forumsNewsPostId, {
+                    title: res.thread.title
+                });
+
+                await forumClient.editPost(curThread.thread.first_post_id, {
+                    message: forumPost.message
+                });
+            }
         }
+
+        await forumClient.deleteThread(post.forumsEditPostId);
+        await repo.update({ newsId: post.newsId, feedName: post.feedName }, { editMessageId: null, forumsEditPostId: null, forumsNewsPostId: post.forumsNewsPostId });
+        await reaction.message.delete();
+        delete this.verifyMessageIdToPost[m.id];
+    }
+
+    async deleteCacheMessage(messageId: string) {
+        let ch = client.channels.get(this.aggregateChannelID);
+        if (!(ch instanceof TextChannel))
+            return;
+
+        let msg = await this.tryFetchMessage(ch, messageId);
+
+        if (msg)
+            await msg.delete();
     }
-    
-    await forumClient.deleteThread(post.forumsEditPostId);
-    await repo.update({ newsId: post.newsId, feedName: post.feedName }, { editMessageId: null, forumsEditPostId: null, forumsNewsPostId: post.forumsNewsPostId });
-    await reaction.message.delete();
-    delete verifyMessageIdToPost[m.id];
-}
-
-async function deleteCacheMessage(messageId: string) {
-    let ch = client.channels.get(aggregateChannelID);
-    if(!(ch instanceof TextChannel))
-        return;
-
-    let msg = await tryFetchMessage(ch, messageId);
-
-    if(msg)
-        await msg.delete();
-}
-
-async function tryFetchMessage(channel: Channel, messageId: string) {
-    try {
-        if (!(channel instanceof TextChannel))
+
+    async tryFetchMessage(channel: Channel, messageId: string) {
+        try {
+            if (!(channel instanceof TextChannel))
+                return null;
+            return await channel.fetchMessage(messageId);
+        } catch (error) {
             return null;
-        return await channel.fetchMessage(messageId);
-    } catch (error) {
-        return null;
+        }
     }
-}
 
-function initAggregators() {
-    let aggregatorsPath = path.join(path.dirname(module.filename), "aggregators");
-    let files = fs.readdirSync(aggregatorsPath);
+    initAggregators() {
+        let aggregatorsPath = path.join(path.dirname(module.filename), "aggregators");
+        let files = fs.readdirSync(aggregatorsPath);
 
-    for(let file of files) {
-        let ext  = path.extname(file);
-        let name = path.basename(file);
+        for (let file of files) {
+            let ext = path.extname(file);
+            let name = path.basename(file);
 
-        if(name == "aggregator.js")
-            continue;
-        if(ext != ".js")
-            continue;
+            if (name == "aggregator.js")
+                continue;
+            if (ext != ".js")
+                continue;
 
-        let obj = require(path.resolve(aggregatorsPath, file)).default as IAggregator;
+            let obj = require(path.resolve(aggregatorsPath, file)).default as IAggregator;
 
-        if(obj)
-            aggregators.push(obj);
+            if (obj)
+                this.aggregators.push(obj);
 
-        if(obj.init)
-            obj.init();
+            if (obj.init)
+                obj.init();
+        }
     }
-}
 
-async function initPendingReactors() {
-    let verifyChannel = client.channels.get(aggregateChannelID);
+    async initPendingReactors() {
+        let verifyChannel = client.channels.get(this.aggregateChannelID);
 
-    let repo = getRepository(AggroNewsItem);
+        let repo = getRepository(AggroNewsItem);
 
-    let pendingVerifyMessages = await repo.find({
-        where: { editMessageId: Not(IsNull()) }
-    });
+        let pendingVerifyMessages = await repo.find({
+            where: { editMessageId: Not(IsNull()) }
+        });
 
-    for (let msg of pendingVerifyMessages) {
-        let m = await tryFetchMessage(verifyChannel, msg.editMessageId);
+        for (let msg of pendingVerifyMessages) {
+            let m = await this.tryFetchMessage(verifyChannel, msg.editMessageId);
 
-        if (!m) {
-            await repo.update({ feedName: msg.feedName, newsId: msg.newsId }, { editMessageId: null });
-            continue;
-        }
+            if (!m) {
+                await repo.update({ feedName: msg.feedName, newsId: msg.newsId }, { editMessageId: null });
+                continue;
+            }
 
-        let collector = m.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
-        collector.on("collect", collectReaction);
-        reactionCollectors[m.id] = collector;
-        verifyMessageIdToPost[m.id] = msg;
+            let collector = m.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
+            collector.on("collect", this.collectReaction);
+            this.reactionCollectors[m.id] = collector;
+            this.verifyMessageIdToPost[m.id] = msg;
+        }
     }
-}
 
-export default {
-    onStart : async () => {
+    async onStart() {
         let repo = getRepository(KnownChannel);
 
         let ch = await repo.findOne({
             where: { channelType: AGGREGATOR_MANAGER_CHANNEL }
         });
 
-        if(!ch)
+        if (!ch)
             return;
-        
-        aggregateChannelID = ch.channelId;
 
-        await initPendingReactors();
-        initAggregators();
-        interval(checkFeeds, UPDATE_INTERVAL * 60 * 1000);
+        this.aggregateChannelID = ch.channelId;
+
+        await this.initPendingReactors();
+        this.initAggregators();
+        interval(this.checkFeeds, UPDATE_INTERVAL * 60 * 1000);
     }
-} as ICommand;
+}

+ 82 - 104
bot/src/commands/quote.ts

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

+ 19 - 21
bot/src/commands/rcg.ts

@@ -1,30 +1,28 @@
 import request from "request-promise-native";
-import { ICommand } from "./command";
 import { Message } from "discord.js";
+import { Command, CommandSet, Action, ActionType } from "../model/command";
 
 const rcgRe = /<input id="rcg_image".+value="([^"]+)".*\/>/i;
 
-async function randomComic(msg: Message) {
-    let result = await request("http://explosm.net/rcg/view/?promo=false");
+@CommandSet
+export class Rcg {
+    @Command({
+        pattern: "random comid",
+        auth: false,
+        documentation: "Generates a comic just for you!"
+    })
+    async randomComic(msg: Message) {
+        let result = await request("http://explosm.net/rcg/view/?promo=false");
     
-    let regexResult = rcgRe.exec(result);
+        let regexResult = rcgRe.exec(result);
 
-    msg.channel.send(`${msg.author.toString()} I find this very funny:`, {
-        files: [ regexResult[1].trim() ]
-    });
-}
+        msg.channel.send(`${msg.author.toString()} I find this very funny:`, {
+            files: [ regexResult[1].trim() ]
+        });
+    }
 
-export default {
-    commands: [{
-        pattern: "random comic",
-        action: msg => {
-            randomComic(msg);
-        }
-    }],
-    documentation: {
-        "random comic": {
-            auth: false,
-            description: "Generates a comic just for you!"
-        }
+    @Action(ActionType.DIRECT_MENTION)
+    async testEvent() {
+        console.log("asd");
     }
-} as ICommand;
+};

+ 78 - 92
bot/src/commands/react.ts

@@ -1,10 +1,11 @@
 import { client } from "../client";
-import { ICommand } from "./command";
 import { getRepository } from "typeorm";
 import { MessageReaction } from "@db/entity/MessageReaction";
 import { KnownUser } from "@db/entity/KnownUser";
 import { ReactionType, ReactionEmote } from "@db/entity/ReactionEmote";
 import { isAuthorisedAsync } from "../util";
+import { CommandSet, Command, Action, ActionType } from "src/model/command";
+import { Message } from "discord.js";
 
 const pattern = /^react to\s+"([^"]+)"\s+with\s+\<:[^:]+:([^\>]+)\>$/i;
 
@@ -24,83 +25,66 @@ async function getRandomEmotes(allowedTypes: ReactionType[], limit: number) {
     return a;
 }
 
-export default {
-    commands: [
-        {
-            pattern: "react to",
-            action: async (msg, s) => {
-                if (!await isAuthorisedAsync(msg.member))
-                    return;
-                let contents = pattern.exec(s);
-
-                if (contents != null) {
-                    let reactable = contents[1].trim().toLowerCase();
-                    let reactionEmoji = contents[2];
-
-                    if (!client.emojis.has(reactionEmoji)) {
-                        msg.channel.send(`${msg.author.toString()} I cannot react with this emoji :(`);
-                        return;
-                    }
-
-                    let repo = getRepository(MessageReaction);
-
-                    let message = repo.create({
-                        message: reactable,
-                        reactionEmoteId: reactionEmoji
-                    });
-                    await repo.save(message);
-
-                    msg.channel.send(`${msg.author.toString()} Added reaction!`);
-                }
-            }
-        },
-        {
-            pattern: "remove reaction to",
-            action: async (msg, s) => {
-                if (!await isAuthorisedAsync(msg.member))
-                    return;
-
-                let content = s.substring("remove reaction to ".length).trim().toLowerCase();
-                let repo = getRepository(MessageReaction);
-                let result = await repo.delete({ message: content });
-
-                if (result.affected == 0) {
-                    msg.channel.send(`${msg.author.toString()} No such reaction available!`);
-                    return;
-                }
-                msg.channel.send(`${msg.author.toString()} Removed reaction!`);
-            }
-        },
-        {
-            pattern: "reactions",
-            action: async msg => {
-
-                let reactionsRepo = getRepository(MessageReaction);
-                
-                let messages = await reactionsRepo.find({
-                    select: [ "message" ]
-                });
-
-                let reactions = messages.reduce((p, c) => `${p}\n${c.message}`, "");
-                msg.channel.send(`I'll react to the following messages:\n\`\`\`${reactions}\n\`\`\``);
+@CommandSet
+export class ReactCommands {
+
+    @Command({ pattern: "react to", auth: true, documentation: "React to <message> with <emote>." })
+    async addReaction(msg: Message, s: string) {
+        if (!await isAuthorisedAsync(msg.member))
+            return;
+        let contents = pattern.exec(s);
+
+        if (contents != null) {
+            let reactable = contents[1].trim().toLowerCase();
+            let reactionEmoji = contents[2];
+
+            if (!client.emojis.has(reactionEmoji)) {
+                msg.channel.send(`${msg.author.toString()} I cannot react with this emoji :(`);
+                return;
             }
+
+            let repo = getRepository(MessageReaction);
+
+            let message = repo.create({
+                message: reactable,
+                reactionEmoteId: reactionEmoji
+            });
+            await repo.save(message);
+
+            msg.channel.send(`${msg.author.toString()} Added reaction!`);
         }
-    ],
-    documentation: {
-        "react to \"<message>\" with <emote>": {
-            auth: true,
-            description: "React to <message> with <emote>."
-        },
-        "remove reaction to <message>": {
-            auth: true,
-            description: "Stops reacting to <message>."
-        },
-        "reactions": {
-            auth: false,
-            description: "Lists all known messages this bot can react to."
+    }
+
+    @Command({ pattern: "remove reaction to", auth: true, documentation: "Stops reacting to <message>." })
+    async removeReaction(msg: Message, s: string) {
+        if (!await isAuthorisedAsync(msg.member))
+            return;
+
+        let content = s.substring("remove reaction to ".length).trim().toLowerCase();
+        let repo = getRepository(MessageReaction);
+        let result = await repo.delete({ message: content });
+
+        if (result.affected == 0) {
+            msg.channel.send(`${msg.author.toString()} No such reaction available!`);
+            return;
         }
-    },
-    onMessage: async (actionsDone, msg, content) => {
+        msg.channel.send(`${msg.author.toString()} Removed reaction!`);
+    }
+
+    @Command({ pattern: "reactions", documentation: "Lists all known messages this bot can react to." })
+    async listReactions(msg: Message) {
+        let reactionsRepo = getRepository(MessageReaction);
+
+        let messages = await reactionsRepo.find({
+            select: ["message"]
+        });
+
+        let reactions = messages.reduce((p, c) => `${p}\n${c.message}`, "");
+        msg.channel.send(`I'll react to the following messages:\n\`\`\`${reactions}\n\`\`\``);
+    }
+
+    @Action(ActionType.MESSAGE)
+    async reactToMentions(actionsDone: boolean, msg: Message, content: string) {
         if (actionsDone)
             return false;
 
@@ -111,7 +95,7 @@ export default {
 
         let message = await reactionRepo.findOne({ message: lowerContent });
 
-        if(message) {
+        if (message) {
             msg.react(client.emojis.get(message.reactionEmoteId));
             return true;
         }
@@ -120,54 +104,56 @@ export default {
             return false;
 
         let knownUsers = await usersRepo.find({
-            select: [ "mentionReactionType" ],
+            select: ["mentionReactionType"],
             where: [...msg.mentions.users.map(u => ({ userID: u.id }))]
         });
 
-        if(knownUsers.length == 0)
+        if (knownUsers.length == 0)
             return false;
 
         let reactionEmoteTypes = new Set<ReactionType>();
-        
-        for(let user of knownUsers) {
-            if(user.mentionReactionType == ReactionType.NONE)
+
+        for (let user of knownUsers) {
+            if (user.mentionReactionType == ReactionType.NONE)
                 continue;
             reactionEmoteTypes.add(user.mentionReactionType);
         }
 
         let randomEmotes = await getRandomEmotes([...reactionEmoteTypes], 5);
 
-        if(randomEmotes.length == 0)
+        if (randomEmotes.length == 0)
             return false;
 
-        for(let emote of randomEmotes)
+        for (let emote of randomEmotes)
             await msg.react(client.emojis.find(e => e.id == emote.reactionId));
 
         return true;
-    },
-    onIndirectMention: async (actionsDone, msg) => {
+    }
+
+    @Action(ActionType.INDIRECT_MENTION)
+    async reactToPing(actionsDone: boolean, msg: Message) {
         if (actionsDone)
             return false;
         let emoteType = ReactionType.ANGERY;
 
         let repo = getRepository(KnownUser);
 
-        let knownUser = await repo.findOne({ 
-            select: [ "replyReactionType" ],
+        let knownUser = await repo.findOne({
+            select: ["replyReactionType"],
             where: [{
                 userID: msg.author.id
             }]
         });
 
-        if(knownUser){
-            if(knownUser.replyReactionType == ReactionType.NONE)
+        if (knownUser) {
+            if (knownUser.replyReactionType == ReactionType.NONE)
                 return false;
             emoteType = knownUser.replyReactionType;
         }
 
-        let emotes = await getRandomEmotes([ emoteType ], 1);
+        let emotes = await getRandomEmotes([emoteType], 1);
 
-        if(emotes.length != 1)
+        if (emotes.length != 1)
             return false;
 
         let emote = client.emojis.find(e => e.id == emotes[0].reactionId);
@@ -183,4 +169,4 @@ export default {
         msg.channel.send(emote.toString());
         return true;
     }
-} as ICommand;
+};

+ 61 - 52
bot/src/main.ts

@@ -2,7 +2,7 @@
 require("module-alias/register");
 
 import dotenv from "dotenv";
-if(process.env.NODE_ENV == "dev") {
+if (process.env.NODE_ENV == "dev") {
     dotenv.config({
         path: "../.env"
     });
@@ -18,21 +18,23 @@ if(process.env.NODE_ENV == "dev") {
 import * as fs from "fs";
 import * as path from "path";
 import { client } from "./client";
-import { ICommand, BotEvent, IBotCommand } from "./commands/command"
+import * as mCmd from "./model/command";
 import "reflect-metadata";
-import {createConnection, getConnectionOptions} from "typeorm";
-import { documentation } from "./util";
+import { createConnection, getConnectionOptions } from "typeorm";
+import { getNumberEnums } from "./util";
 import { DB_ENTITIES } from "@db/entities";
+import { BOT_COMMAND_DESCRIPTOR } from "./model/command";
 
 
 const REACT_PROBABILITY = 0.3;
 
-async function trigger(actions : BotEvent[], ...params: any[]) {
+async function trigger(type: mCmd.ActionType, ...params: any[]) {
     let actionDone = false;
+    let actions = botEvents[type];
     for (let i = 0; i < actions.length; i++) {
-        const action = actions[i];
+        const action = actions[i] as (...args: any[]) => boolean | Promise<boolean>;
         let actionResult = action(actionDone, ...params);
-        if(actionResult instanceof Promise)
+        if (actionResult instanceof Promise)
             actionDone = (await actionResult) || actionDone;
         else
             actionDone = actionResult || actionDone;
@@ -40,12 +42,10 @@ async function trigger(actions : BotEvent[], ...params: any[]) {
     return actionDone;
 }
 
-let commands : IBotCommand[] = [];
-let msgActions : BotEvent[] = [];
-let indirectMentionActions : BotEvent[] = [];
-let startActions : Array<() => void | Promise<void>> = [];
-let directMessageActions : BotEvent[] = [];
-let postActions : BotEvent[] = [];
+let commandSets: mCmd.ICommand[] = [];
+let botCommands: mCmd.IBotCommand[] = [];
+let botEvents: { [event in mCmd.ActionType]?: mCmd.BotAction[] } = getNumberEnums(mCmd.ActionType).reduce((p, c) => { p[c] = []; return p; }, {} as any);
+let startActions: Array<() => void | Promise<void>> = [];
 
 client.on("ready", async () => {
     console.log("Starting up NoctBot!");
@@ -55,19 +55,19 @@ client.on("ready", async () => {
     for (let i = 0; i < startActions.length; i++) {
         const action = startActions[i];
         let val = action();
-        if(val instanceof Promise)
+        if (val instanceof Promise)
             await val;
     }
     console.log("NoctBot is ready!");
 });
 
 client.on("message", async m => {
-    if (m.author.id == client.user.id) 
+    if (m.author.id == client.user.id)
         return;
 
     let content = m.cleanContent.trim();
 
-    if (await trigger(msgActions, m, content))
+    if (await trigger(mCmd.ActionType.MESSAGE, m, content))
         return;
 
     if (m.mentions.users.size > 0 && m.mentions.users.has(client.user.id)) {
@@ -76,29 +76,29 @@ client.on("message", async m => {
             content = content.substring(`@${client.user.username}`.length).trim();
 
             let lowerCaseContent = content.toLowerCase();
-            for (let c of commands) {
-                if (typeof(c.pattern) == "string" && lowerCaseContent.startsWith(c.pattern)) {
+            for (let c of botCommands) {
+                if (typeof (c.pattern) == "string" && lowerCaseContent.startsWith(c.pattern)) {
                     c.action(m, content);
                     return;
                 }
-                else if(c.pattern instanceof RegExp){
+                else if (c.pattern instanceof RegExp) {
                     let result = c.pattern.exec(content);
-                    if(result != null){
+                    if (result != null) {
                         c.action(m, content, result);
                         return;
                     }
                 }
             }
 
-            if (await trigger(directMessageActions, m, lowerCaseContent))
+            if (await trigger(mCmd.ActionType.DIRECT_MENTION, m, lowerCaseContent))
                 return;
         }
 
-        if (await trigger(indirectMentionActions, m))
+        if (await trigger(mCmd.ActionType.INDIRECT_MENTION, m))
             return;
     }
 
-    await trigger(postActions);
+    await trigger(mCmd.ActionType.POST_MESSAGE);
 });
 
 client.on("messageReactionAdd", (r, u) => {
@@ -108,10 +108,44 @@ client.on("messageReactionAdd", (r, u) => {
     }
 });
 
+function loadCommand(mod: any) {
+    for (let i in mod) {
+        if (!mod.hasOwnProperty(i))
+            continue;
+
+        let commandClass = mod[i] as any;
+        // Ensure this is indeed a command class
+        if (!commandClass.prototype || commandClass.prototype.BOT_COMMAND !== BOT_COMMAND_DESCRIPTOR)
+            continue;
+
+        let cmd = new commandClass() as mCmd.ICommand;
+        commandSets.push(cmd);
+
+        if (cmd._botCommands)
+            botCommands.concat(cmd._botCommands.map(c => ({ ...c, action: c.action.bind(cmd) })));
+
+        if (cmd._botEvents)
+            for (let [i, event] of Object.entries(cmd._botEvents)) {
+                botEvents[+i as mCmd.ActionType].push((event as Function).bind(cmd));
+            }
+
+        if(cmd.onStart)
+            startActions.push(cmd.onStart);
+    }
+}
+
+export function getDocumentation() {
+    return botCommands.filter(m => m.documentation !== undefined).map(m => ({
+        name: m.pattern.toString(),
+        doc: m.documentation,
+        auth: m.auth || false
+    }));
+}
+
 async function main() {
     await createConnection({
         ...await getConnectionOptions(),
-        entities: DB_ENTITIES 
+        entities: DB_ENTITIES
     });
 
     let commandsPath = path.resolve(path.dirname(module.filename), "commands");
@@ -120,39 +154,14 @@ async function main() {
     for (const file of files) {
         let ext = path.extname(file);
         let name = path.basename(file);
-        
-        if(name == "command.js")
+
+        if (name == "command.js")
             continue;
 
         if (ext != ".js")
             continue;
 
-        let obj = require(path.resolve(commandsPath, file)).default as ICommand;
-        if (obj.commands)
-            for (let command of obj.commands) {
-                commands.push(command);
-            }
-
-        if (obj.documentation)
-            for (let command in obj.documentation) {
-                if (obj.documentation.hasOwnProperty(command))
-                    documentation[command] = obj.documentation[command];
-            }
-
-        if (obj.onMessage)
-            msgActions.push(obj.onMessage);
-
-        if (obj.onIndirectMention)
-            indirectMentionActions.push(obj.onIndirectMention);
-
-        if (obj.onDirectMention)
-            directMessageActions.push(obj.onDirectMention);
-
-        if (obj.postMessage)
-            postActions.push(obj.postMessage);
-
-        if (obj.onStart)
-            startActions.push(obj.onStart);
+        loadCommand(require(path.resolve(commandsPath, file)));
     }
 
     client.login(process.env.BOT_TOKEN);

+ 8 - 12
bot/src/model/command.ts

@@ -2,8 +2,8 @@ import { Message } from "discord.js";
 
 export interface CommandOptions {
     pattern: string | RegExp;
-    documentation: string;
-    auth: boolean;
+    documentation?: string;
+    auth?: boolean;
 };
 
 export type BotAction = (actionsDone: boolean, m : Message, content: string) => boolean | Promise<boolean>;
@@ -11,7 +11,7 @@ export type BotAction = (actionsDone: boolean, m : Message, content: string) =>
 export interface ICommand {
     onStart?(): void | Promise<void>;
     _botCommands?: IBotCommand[];
-    _botEvents?: { [action: number]: BotAction };
+    _botEvents?: { [action in ActionType]?: BotAction };
     BOT_COMMAND?: string;
 };
 
@@ -21,11 +21,7 @@ export interface IBotCommand extends CommandOptions {
 
 export const BOT_COMMAND_DESCRIPTOR = "BOT_COMMAND";
 export function CommandSet<T extends {new(...params: any[]): {}}>(base: T) {
-    return class extends base {
-        BOT_COMMAND = BOT_COMMAND_DESCRIPTOR;
-        _botCommands: IBotCommand[] = [];
-        _botEvents: { [action: number]: BotAction } = {};
-    } 
+    base.prototype.BOT_COMMAND = BOT_COMMAND_DESCRIPTOR;
 }
 
 export enum ActionType {
@@ -36,8 +32,8 @@ export enum ActionType {
 }
 export function Action(type: ActionType) {
     return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
-        if(target.BOT_COMMAND !== BOT_COMMAND_DESCRIPTOR)
-            throw "@Action is only usable on classes with @CommandSet!";
+        if(!target._botEvents)
+            target._botEvents= {};
 
         target._botEvents[type] = descriptor.value;
     };
@@ -45,8 +41,8 @@ export function Action(type: ActionType) {
 
 export function Command(opts: CommandOptions) {
     return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
-        if(target.BOT_COMMAND !== BOT_COMMAND_DESCRIPTOR)
-            throw "@Command is only usable on classes with @CommandSet!";
+        if(!target._botCommands)
+            target._botCommands = [];
 
         target._botCommands.push(<IBotCommand>{
             action: descriptor.value,

+ 4 - 0
bot/src/util.ts

@@ -80,4 +80,8 @@ export class NeighBuilder {
     toString() {
         return this.data.reduce((prev, cur) => cur + `${prev}`, "");
     }
+}
+
+export function getNumberEnums(e: any) : number[] {
+    return Object.keys(e).filter(k => typeof e[k as any] === "number").map(k => e[k as any]);
 }