Browse Source

Port codebase to TypeScript

ghorsington 4 năm trước cách đây
mục cha
commit
8a25581c5d

+ 3 - 0
.gitignore

@@ -1,3 +1,6 @@
+*.js
+*.js.map
+
 # ---> Node
 # Logs
 logs

commands/animu.xml → build/animu.xml


+ 0 - 3
client.js

@@ -1,3 +0,0 @@
-const Discord = require("discord.js");
-
-module.exports = new Discord.Client();

+ 0 - 25
commands/dead_chat.js

@@ -1,25 +0,0 @@
-const db = require("../db.js");
-
-const triggers = [
-    "dead server",
-    "dead chat",
-    "ded chat",
-    "ded server"
-];
-
-const onMessage = (msg, content, actionsDone) => {
-    if (actionsDone)
-        return false;
-
-    let lowerContent = content.toLowerCase();
-    
-    if(!triggers.some(s => lowerContent.includes(s)))
-        return false;
-
-    msg.channel.send(db.get("deadChatReplies").randomElement().value());
-    return true;
-};
-
-module.exports = {
-    onMessage: onMessage
-};

+ 0 - 165
commands/guide.js

@@ -1,165 +0,0 @@
-const db = require("../db.js");
-const util = require("../util.js");
-
-const 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."
-    }
-};
-
-const VALID_GUIDE_TYPES = new Set(["meme", "guide", "misc"]);
-
-const makePattern = /^make (\w+)\s+name:(.+)\s*keywords:(.+)\s*contents:((.*[\n\r]*)+)$/i;
-const deletePattern = /^delete (\w+)\s+(.+)$/i;
-
-function listGuides(msg, guideType, message){
-    let guides = db
-            .get(guideType)
-            .reduce((p, c) => `${p}\n${c.displayName} -- ${c.name}`, "\n")
-            .value();
-    msg.channel.send(`${msg.author.toString()} ${message}\n\`\`\`${guides}\`\`\`\n\nTo display the guides, ping me with one or more keywords, like \`@NoctBot sybaris com\``);
-}
-
-const commands = [
-    {
-        pattern: makePattern,
-        action: (msg, s, match) => {
-            if (!util.isAuthorised(msg.member)) return;
-            let type = match[1].toLowerCase();
-            let name = match[2].trim();
-            let keywords = match[3].trim().toLowerCase();
-            let contents = match[4].trim();
-
-            if(contents.length == 0){
-                msg.channel.send(
-                    `${msg.author.toString()} The guide must have some content!`
-                );
-                return;
-            }
-    
-            if(!VALID_GUIDE_TYPES.has(type)){
-                msg.channel.send(
-                    `${msg.author.toString()} The type ${type} is not a valid guide type!`
-                );
-                return;
-            }
-    
-            let typeDB = `${type}s`;
-    
-            let guide = db.get(typeDB).find({
-                name: keywords
-            });
-    
-            if (!guide.isUndefined().value()) {
-                guide.assign({
-                    displayName: name,
-                    content: contents
-                }).write();
-            } else {
-                db.get(typeDB)
-                    .push({
-                        name: keywords,
-                        displayName: name,
-                        content: contents
-                    })
-                    .write();
-            }
-    
-            msg.channel.send(
-                `${msg.author.toString()} Added/updated "${name}" (keywords \`${keywords}\`)!`
-            );
-        }
-    },
-    {
-        pattern: deletePattern,
-        action: (msg, s, match) => {
-            if (!util.isAuthorised(msg.member)) return;
-            let type = match[1];
-            let keywords = match[2].trim();
-    
-            if(!VALID_GUIDE_TYPES.has(type)){
-                msg.channel.send(
-                    `${msg.author.toString()} The type ${type} is not a valid guide type!`
-                );
-                return;
-            }
-    
-            let typeDB = `${type}s`;
-    
-            let val = db.get(typeDB).find({
-                name: keywords
-            });
-    
-            if (val.isUndefined().value()) {
-                msg.channel.send(`${msg.author.toString()} No ${type} "${keywords}"!`);
-                return;
-            }
-    
-            db.get(typeDB)
-                .remove({
-                    name: keywords
-                })
-                .write();
-    
-            msg.channel.send(
-                `${msg.author.toString()} Removed ${type} "${keywords}"!`
-            );
-        }
-    },
-    { pattern: "guides", action: msg => listGuides(msg, "guides", "Here are the guides I have:") },
-    { pattern: "memes", action: msg => listGuides(msg, "memes", "Here are some random memes I have:") },
-    { pattern: "misc", action: msg => listGuides(msg, "misc", "These are some misc stuff I can also do:") },
-];
-
-const onDirectMention = (msg, content, actionsDone) => {
-    if (actionsDone)
-        return false;
-
-    if(msg.attachments.size > 0 || content.length == 0)
-        return false;
-
-    let parts = content.trim().split(" ");
-    let guide = db
-        .get("guides")
-        .clone()
-        .concat(db.get("memes").value(), db.get("miscs").value())
-        .map(g => Object.assign({
-            parts: g.name.toLowerCase().split(" ")
-        }, g))
-        .sortBy(g => g.parts.length)
-        .maxBy(k => db._.intersection(parts, k.parts).length)
-        .value();
-    
-    let hits =
-        guide !== undefined &&
-        db._.intersection(guide.name.toLowerCase().split(" "), parts).length > 0;
-    
-    if (hits) {
-        msg.channel.send(guide.content);
-        return true;
-    }
-    return false;
-};
-
-module.exports = {
-    commands: commands,
-    onDirectMention: onDirectMention,
-    documentation: documentation
-};

+ 0 - 33
commands/help.js

@@ -1,33 +0,0 @@
-const util = require("../util.js");
-
-const commands = [{
-    pattern: "help",
-    action: msg => {
-        let isAuthed = util.isAuthorised(msg.member);
-
-        let baseCommands = "\n";
-        let modCommands = "\n";
-
-        for(let command in util.documentation) {
-            if(!util.documentation.hasOwnProperty(command))
-                continue;
-            
-            let doc = util.documentation[command];
-            if(isAuthed && doc.auth)
-                modCommands = `${modCommands}${command}  -  ${doc.description}\n`;
-            else if(!doc.auth)
-                baseCommands = `${baseCommands}${command} - ${doc.description}\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}\`\`\``;
-        
-        if(isAuthed)
-            message = `${msg.author.toString()} ${message}\n👑**Moderator commands**👑\n\`\`\`${modCommands}\`\`\``;
-
-        msg.channel.send(message);
-    }
-}];
-
-module.exports = {
-    commands: commands
-};

+ 0 - 28
commands/inspire.js

@@ -1,28 +0,0 @@
-const request = require("request-promise-native");
-
-const documentation = {
-    "inspire me": {
-        auth: false,
-        description: "Generates an inspiring quote just for you"
-    }
-};
-
-
-async function inspire(msg) {
-    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 ]
-    });
-}
-
-const commands = [{
-    pattern: "inspire me",
-    action: msg => {
-        inspire(msg);    
-    }
-}];
-
-module.exports = {
-    commands: commands,
-    documentation: documentation
-};

+ 0 - 102
commands/quote.js

@@ -1,102 +0,0 @@
-const db = require("../db.js");
-const util = require("../util.js");
-
-const 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..."
-    }
-};
-
-const quotePattern = /add quote by "([^"]+)"\s*(.*)/i;
-
-function minify(str, maxLength) {
-    let result = str.replace("\n", "");
-    if (result.length > maxLength)
-        result = `${result.substring(0, maxLength - 3)}...`;
-    return result;
-}
-
-const commands = [
-    {
-        pattern: "add quote",
-        action: (msg, c) => {
-            if (!util.isAuthorised(msg.member))
-                return;
-    
-            let result = quotePattern.exec(c);
-    
-            if (result == null)
-                return;
-    
-            let author = result[1].trim();
-            let message = result[2].trim();
-    
-            db.get("quotes").push({
-                author: author,
-                message: message
-            }).write();
-    
-            msg.channel.send(`${msg.author.toString()} Added quote #${db.get("quotes").size().value()}!`);
-        }
-    },
-    {
-        pattern: "random quote",
-        action: (msg) => {
-            if (db.get("quotes").size().value() == 0) {
-                msg.channel.send("I have no quotes!");
-                return;
-            }
-            let quote = db.get("quotes").randomElement().value();
-            let index = db.get("quotes").indexOf(quote).value();
-            msg.channel.send(`Quote #${index + 1}:\n*"${quote.message}"*\n- ${quote.author}`);
-        }
-    },
-    {
-        pattern: "remove quote",
-        action: (msg, c) => {
-            let quoteNum = c.substring("remove quote".length).trim();
-            let val = parseInt(quoteNum);
-            if (isNaN(val) || db.get("quotes").size().value() < val - 1)
-                return;
-    
-            db.get("quotes").pullAt(val - 1).write();
-            msg.channel.send(`${msg.author.toString()} Removed quote #${val}!`);
-        }
-    },
-    {
-        pattern: "quotes",
-        action: msg => {
-            if (!util.isAuthorised(msg.member)) {
-                msg.channel.send(`${msg.author.toString()} To prevent spamming, only bot moderators can view all quotes!`);
-                return;
-            }
-    
-            if (db.get("quotes").size().value() == 0) {
-                msg.channel.send("I have no quotes!");
-                return;
-            }
-    
-            let quotes = db.get("quotes").reduce((prev, curr, i) => `${prev}[${i+1}] "${minify(curr.message, 10)}" by ${curr.author}\n`, "\n").value();
-            msg.channel.send(`${msg.author.toString()}I know the following quotes:\n\`\`\`${quotes}\`\`\``);
-        }
-    }
-];
-
-
-module.exports = {
-    commands: commands,
-    documentation: documentation
-};

+ 0 - 32
commands/rcg.js

@@ -1,32 +0,0 @@
-const request = require("request-promise-native");
-
-const rcgRe = /<input id="rcg_image".+value="([^"]+)".*\/>/i;
-
-const documentation = {
-    "random comic": {
-        auth: false,
-        description: "Generates a comic just for you!"
-    }
-};
-
-async function randomComic(msg) {
-    let result = await request("http://explosm.net/rcg/view/");
-    
-    let regexResult = rcgRe.exec(result);
-
-    msg.channel.send(`${msg.author.toString()} I find this very funny:`, {
-        files: [ regexResult[1].trim() ]
-    });
-}
-
-const commands = [{
-    pattern: "random comic",
-    action: msg => {
-        randomComic(msg);
-    }
-}];
-
-module.exports = {
-    commands: commands,
-    documentation: documentation
-};

+ 0 - 134
commands/react.js

@@ -1,134 +0,0 @@
-const db = require("../db.js");
-const util = require("../util.js");
-const client = require("../client.js");
-
-const pattern = /^react to\s+"([^"]+)"\s+with\s+\<:[^:]+:([^\>]+)\>$/i;
-
-const 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."
-    }
-};
-
-const commands = [
-    {
-        pattern: "react to",
-        action: (msg, s) => {
-            if (!util.isAuthorised(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;
-                }
-                db.get("messageReactions").set(reactable, reactionEmoji).write();
-                msg.channel.send(`${msg.author.toString()} Added reaction!`);
-            }
-        }
-    },
-    {
-        pattern: "remove reaction to",
-        action: (msg, s) => {
-            if (!util.isAuthorised(msg.member)) return;
-            let content = s.substring("remove reaction to ".length).trim().toLowerCase();
-            if (!db.get("messageReactions").has(content).value()) {
-                msg.channel.send(`${msg.author.toString()} No such reaction available!`);
-                return;
-            }
-            db.get("messageReactions").unset(content).write();
-            msg.channel.send(`${msg.author.toString()} Removed reaction!`);
-        }
-    },
-    {
-        pattern: "reactions",
-        action: msg => {
-            let reactions = db.get("messageReactions").keys().value().reduce((p, c) => `${p}\n${c}`, "");
-            msg.channel.send(`I'll react to the following messages:\n\`\`\`${reactions}\`\`\``);
-        }
-    }
-];
-
-const onMessage = (msg, content, actionsDone) => {
-    if (actionsDone)
-        return false;
-
-    let lowerContent = content.toLowerCase();
-    if (db.get("messageReactions").has(lowerContent).value()) {
-        msg.react(client.emojis.get(db.get("messageReactions").get(lowerContent).value()));
-        return true;
-    }
-
-    if (msg.mentions.users.size == 0)
-        return false;
-
-    if (!db.get("reactableMentionedUsers").intersectionWith(msg.mentions.users.map(u => u.id)).isEmpty().value()) {
-        const emoteId = db
-            .get("emotes")
-            .get("angery")
-            .randomElement()
-            .value();
-        msg.react(client.emojis.find(e => e.id == emoteId));
-        return true;
-    }
-
-    return false;
-};
-
-const onIndirectMention = (msg, actionsDone) => {
-    if(actionsDone)
-        return false;
-    let emoteType = "angery";
-    if (db.get("specialUsers").includes(msg.author.id).value())
-        emoteType = "hug";
-    else if (db.get("bigUsers").includes(msg.author.id).value())
-        emoteType = "big";
-    else if (db.get("dedUsers").includes(msg.author.id).value())
-        emoteType = "ded";
-
-    let id = db
-        .get("emotes")
-        .get(emoteType)
-        .randomElement()
-        .value();
-
-    let emote = client.emojis.find(e => e.id == id);
-
-    if(!emote) {
-        console.log(`WARNING: Emote ${id} no longer is valid. Deleting invalid emojis from the list...`);
-        db.get("emotes")
-            .get(emoteType)
-            .remove(id => !client.emojis.has(id))
-            .write();
-
-        id = db
-            .get("emotes")
-            .get(emoteType)
-            .randomElement()
-            .value();
-        emote = client.emojis.find(e => e.id == id);
-    }
-
-    if(!emote)
-        return false;
-
-    msg.channel.send(emote.toString());
-    return true;
-};
-
-module.exports = {
-    commands: commands,
-    onMessage: onMessage,
-    onIndirectMention: onIndirectMention,
-    documentation: documentation
-};

+ 8 - 1
package.json

@@ -5,7 +5,8 @@
   "main": "main.js",
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
-    "start": "node main.js"
+    "build": "tsc",
+    "start": "npm run build && node main.js"
   },
   "repository": {
     "type": "git",
@@ -20,6 +21,11 @@
   "author": "Geoffrey Horsington <geoffrey.hoooooorse@gmail.com>",
   "license": "MIT",
   "dependencies": {
+    "@types/dotenv": "^6.1.1",
+    "@types/lowdb": "^1.0.9",
+    "@types/request-promise-native": "^1.0.16",
+    "@types/sha1": "^1.1.2",
+    "@types/turndown": "^5.0.0",
     "axios": "^0.19.0",
     "discord.js": "^11.4.2",
     "dotenv": "^8.0.0",
@@ -34,6 +40,7 @@
     "sha1": "^1.1.1",
     "translate-google": "^1.3.5",
     "turndown": "^5.0.1",
+    "typescript": "^3.5.2",
     "uws": "^99.0.0"
   },
   "devDependencies": {

+ 2 - 0
src/client.ts

@@ -0,0 +1,2 @@
+import { Client } from "discord.js";
+export default new Client();

+ 22 - 0
src/commands/aggregators/aggregator.ts

@@ -0,0 +1,22 @@
+
+export interface INewsItem {
+    id: string,
+    link: string | "",
+    title: string | "",
+    author: string,
+    contents: string,
+    embedColor: number | 0xffffff
+}
+
+export interface INewsPostData {
+    hash?: string,
+    cacheMessageId?: string,
+    postedMessageId?: string,
+}
+
+export type NewsPostItem = INewsItem & INewsPostData;
+
+export interface IAggregator {
+    aggregate() : Promise<INewsItem[]>;
+    init?(): void;
+}

+ 18 - 13
commands/aggregators/com3d2_updates.js

@@ -1,11 +1,13 @@
-const html = require("node-html-parser"); 
-const axios = require("axios");
-const db = require("../../db.js");
+import html, { HTMLElement } from "node-html-parser";
+import request from "request-promise-native";
+import { db } from "../../db";
+import { Response } from "request";
+import { IAggregator, INewsItem } from "./aggregator";
 
 const updatePage = "http://com3d2.jp/update/";
 const changeLogPattern = /\[\s*([^\s\]]+)\s*\]\s*((・.*)\s+)+/gim;
 
-function getVersionNumber(verStr) {
+function getVersionNumber(verStr: string) {
     let verPart = verStr.replace(/[\.\s]/g, "");
     if(verPart.length < 4)
         verPart += "0";
@@ -13,20 +15,23 @@ function getVersionNumber(verStr) {
 }
 
 async function aggregate() {
-    let lastVersion = db.get("lastCOMJPVersion").value();
+    let lastVersion = db.get("lastCOMJPVersion").value() as number;
     
     try {
-        let mainPageRes = await axios.get(updatePage);
-        
-        if(mainPageRes.status != 200)
-            return [];
+        let mainPageRes = await request(updatePage, {resolveWithFullResponse: true}) as Response;
 
-        let rootNode = html.parse(mainPageRes.data, {
+        if(mainPageRes.statusCode != 200)
+            return;
+        
+        let rootNode = html.parse(mainPageRes.body, {
                 pre: true,
                 script: false,
                 style: false
         });
 
+        if(!(rootNode instanceof HTMLElement))
+            return;
+
         let readme = rootNode.querySelector("div.readme");
 
         if(!readme) {
@@ -53,12 +58,12 @@ async function aggregate() {
             author: "COM3D2 UPDATE",
             contents: text,
             embedColor: 0xcccccc
-        }];
+        }] as INewsItem[];
     } catch(err) {
         return [];
     }
 }
 
-module.exports = {
+export default {
     aggregate: aggregate
-};
+} as IAggregator;

+ 19 - 11
commands/aggregators/com3d2_world.js

@@ -1,31 +1,36 @@
-const html = require("node-html-parser"); 
-const axios = require("axios");
-const db = require("../../db.js");
+import html, { HTMLElement } from "node-html-parser";
+import request from "request-promise-native";
+import { db } from "../../db";
+import { Response } from "request";
+import { INewsItem } from "./aggregator";
 
 const kissDiaryRoot = "https://com3d2.world/r18/notices.php";
 
 async function aggregate() {
-    let lastDiary = db.get("latestCom3D2WorldDiaryEntry").value();
+    let lastDiary = db.get("latestCom3D2WorldDiaryEntry").value() as number;
     
     try {
-        let mainPageRes = await axios.get(kissDiaryRoot);
+        let mainPageRes = await request(kissDiaryRoot, {resolveWithFullResponse: true}) as Response;
         
-        if(mainPageRes.status != 200)
+        if(mainPageRes.statusCode != 200)
             return [];
 
-        let rootNode = html.parse(mainPageRes.data, {
+        let rootNode = html.parse(mainPageRes.body, {
                 pre: true,
                 script: false,
                 style: false
         });
 
+        if(!(rootNode instanceof HTMLElement))
+            return;
+
         let diaryEntries = rootNode.querySelectorAll("div.frame a");
 
         if(!diaryEntries) {
             console.log("[COM3D2 WORLD BLOG] Failed to find listing!");
         }
 
-        let result = [];
+        let result : INewsItem[] = [];
         let latestEntry = lastDiary;
 
         for(let a of diaryEntries) {
@@ -41,16 +46,19 @@ async function aggregate() {
                 latestEntry = id;
 
             let diaryLink = `${kissDiaryRoot}?no=${id}`;
-            let res = await axios.get(diaryLink);
-            if(res.status != 200)
+            let res = await request(diaryLink, {resolveWithFullResponse: true}) as Response;
+            if(res.statusCode != 200)
                 continue;
 
-            let node = html.parse(res.data, {
+            let node = html.parse(res.body, {
                 pre: true,
                 script: false,
                 style: false
             });
 
+            if(!(node instanceof HTMLElement))
+                continue;
+
             let title = node.querySelector("div.frame div.notice_title th");
             let contents = node.querySelectorAll("div.frame div")[1];
 

+ 26 - 15
commands/aggregators/kiss_diary.js

@@ -1,32 +1,37 @@
-const html = require("node-html-parser"); 
-const axios = require("axios");
-const db = require("../../db.js");
+import html, { HTMLElement } from "node-html-parser";
+import request from "request-promise-native";
+import { db } from "../../db";
+import { Response } from "request";
+import { INewsItem, IAggregator } from "./aggregator";
 
 const urlPattern = /diary\.php\?no=(\d+)/i;
 const kissDiaryRoot = "http://www.kisskiss.tv/kiss";
 
 async function aggregate() {
-    let lastDiary = db.get("latestKissDiaryEntry").value();
+    let lastDiary = db.get("latestKissDiaryEntry").value() as number;
     
     try {
-        let mainPageRes = await axios.get(`${kissDiaryRoot}/diary.php`);
+        let mainPageRes = await request(`${kissDiaryRoot}/diary.php`, {resolveWithFullResponse: true}) as Response;
         
-        if(mainPageRes.status != 200)
+        if(mainPageRes.statusCode != 200)
             return [];
 
-        let rootNode = html.parse(mainPageRes.data, {
+        let rootNode = html.parse(mainPageRes.body, {
                 pre: true,
                 script: false,
                 style: false
         });
 
+        if(!(rootNode instanceof HTMLElement))
+            return;
+
         let diaryEntries = rootNode.querySelectorAll("div.blog_frame_middle ul.disc li a");
 
         if(!diaryEntries) {
             console.log("[KISS DIARY] Failed to find listing!");
         }
 
-        let result = [];
+        let result : INewsItem[] = [];
         let latestEntry = lastDiary;
 
         for(let a of diaryEntries) {
@@ -43,21 +48,27 @@ async function aggregate() {
                 latestEntry = id;
 
             let diaryLink = `${kissDiaryRoot}/${a.rawAttributes.href}`;
-            let res = await axios.get(diaryLink);
-            if(res.status != 200)
+            let res = await request(diaryLink, {resolveWithFullResponse: true}) as Response;
+            if(res.statusCode != 200)
                 continue;
 
-            let node = html.parse(res.data, {
+            let node = html.parse(res.body, {
                 pre: true,
                 script: false,
                 style: false
             });
 
+            if(!(node instanceof HTMLElement))
+                continue;
+
             let title = node.querySelector("table.blog_frame_top tr td a");
             let contents = node.querySelector("div.blog_frame_middle");
             let bottomFrame = contents.querySelector("div.blog_data");
-            if(bottomFrame)
-                contents.childNodes[0].removeChild(bottomFrame);
+            if(bottomFrame) {
+                let child = contents.childNodes[0];
+                if(child instanceof HTMLElement)
+                    child.removeChild(bottomFrame);
+            }
 
             result.push({
                 id: `kisskisstv-diary-${id}`,
@@ -76,6 +87,6 @@ async function aggregate() {
     }
 }
 
-module.exports = {
+export default {
     aggregate: aggregate
-};
+} as IAggregator;

+ 27 - 0
src/commands/command.ts

@@ -0,0 +1,27 @@
+import { Message } from "discord.js";
+
+export type BotEvent = (actionsDone: boolean, ...params: any[]) => boolean;
+
+export interface IDocumentation {
+    auth: boolean;
+    description: string;
+};
+
+export type DocumentationSet = {
+    [command: string] : IDocumentation;
+};
+
+export interface IBotCommand {
+    pattern: string | RegExp;
+    action(message: Message, strippedContents: string, matches?: RegExpMatchArray) : void;
+};
+
+export interface ICommand {
+    commands?: Array<IBotCommand>;
+    documentation?: DocumentationSet;
+    onMessage?(actionsDone: boolean, m : Message, content: string) : boolean;
+    onIndirectMention?(actionsDone: boolean, m: Message) : boolean;
+    onDirectMention?(actionsDone: boolean, m: Message, content: string) : boolean;
+    postMessage?(actionsDone: boolean, m: Message) : boolean;
+    onStart?(): void;
+};

+ 26 - 0
src/commands/dead_chat.ts

@@ -0,0 +1,26 @@
+import { db, IRandomElementMixin } from "../db";
+import { ICommand } from "./command";
+
+const triggers = [
+    "dead server",
+    "dead chat",
+    "ded chat",
+    "ded server"
+];
+
+export default {
+    onMessage: (actionsDone, msg, content) => {
+        if (actionsDone)
+            return false;
+    
+        let lowerContent = content.toLowerCase();
+        
+        if(!triggers.some(s => lowerContent.includes(s)))
+            return false;
+        
+        let deadChatReplies = db.get("deadChatReplies") as IRandomElementMixin;
+
+        msg.channel.send(deadChatReplies.randomElement().value());
+        return true;
+    }
+} as ICommand;

+ 89 - 90
commands/facemorph.js

@@ -1,17 +1,22 @@
-const db = require("../db.js");
-const util = require("../util.js");
-const Jimp = require("jimp");
-const client = require("../client.js");
-const cv = require("opencv4nodejs");
-const path = require("path");
-const request = require("request-promise-native");
+import { db, IRandomElementMixin } from "../db";
+import { isValidImage } from "../util";
+import Jimp from "jimp";
+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 { ObjectChain } from "lodash";
 
 const EMOTE_GUILD = "505333548694241281";
 
-const animeCascade = new cv.CascadeClassifier(path.resolve(__dirname, "./animu.xml"));
+const animeCascade = new cv.CascadeClassifier(path.resolve(__dirname, "../animu.xml"));
 const faceCascade = new cv.CascadeClassifier(cv.HAAR_FRONTALFACE_ALT2);
 
-function intersects(r1, r2) {
+type ImageProcessor = (faces: cv.Rect[], data: Buffer) => Promise<Jimp>;
+
+function intersects(r1: cv.Rect, r2: cv.Rect) {
     return (
         r1.x <= r2.x + r2.width &&
         r1.x + r1.width >= r2.x &&
@@ -19,7 +24,7 @@ function intersects(r1, r2) {
     );
 }
 
-async function morphFaces(faces, data) {
+async function morphFaces(faces: cv.Rect[], data: Buffer) {
     let padoru = Math.random() <= getPadoruChance();
     let jimpImage = await Jimp.read(data);
     let emojiKeys = [
@@ -55,7 +60,7 @@ async function morphFaces(faces, data) {
 
 const CAPTION_OFFSET = 5;
 
-async function captionFace(faces, data) {
+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();
@@ -78,7 +83,7 @@ async function captionFace(faces, data) {
 
     let font = await Jimp.loadFont(padoru ? Jimp.FONT_SANS_16_WHITE : Jimp.FONT_SANS_16_BLACK);
 
-    let text = padoru ? "PADORU PADORU" : `${db.get("faceCaptions.pre").randomElement().value()} ${db.get("faceCaptions.post").randomElement().value()}`;
+    let text = padoru ? "PADORU PADORU" : `${(db.get("faceCaptions.pre") as IRandomElementMixin).randomElement().value()} ${(db.get("faceCaptions.post") as IRandomElementMixin).randomElement().value()}`;
 
     let h = Jimp.measureTextHeight(font, text, targetSize - CAPTION_OFFSET * 2);
 
@@ -101,13 +106,13 @@ async function captionFace(faces, data) {
  */
 function getPadoruChance() {
     let now = new Date();
-    if(now.getUTCMonth() != 11 || now.getUTCDate() > 25)
+    if (now.getUTCMonth() != 11 || now.getUTCDate() > 25)
         return 0;
     return 1 / (27.0 - now.getUTCDate());
 }
 
-async function processFaceSwap(message, attachmentUrl, processor, failMessage, successMessage) {
-    let data = await request(attachmentUrl, { encoding: null });
+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);
 
@@ -160,10 +165,10 @@ async function processFaceSwap(message, attachmentUrl, processor, failMessage, s
     }
 
     let jimpImage;
-    if(processor)
+    if (processor)
         jimpImage = await processor(faces, data);
     else {
-        if(Math.random() <= db.get("faceEditConfig.captionProbability").value())
+        if (Math.random() <= db.get("faceEditConfig.captionProbability").value())
             jimpImage = await captionFace(faces, data);
         else
             jimpImage = await morphFaces(faces, data);
@@ -171,7 +176,7 @@ async function processFaceSwap(message, attachmentUrl, processor, failMessage, s
 
     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()}`;
@@ -181,48 +186,15 @@ async function processFaceSwap(message, attachmentUrl, processor, failMessage, s
     });
 }
 
-const onMessage = (msg, contents, actionsDone) => {
-    if (actionsDone) return false;
-
-    if (msg.mentions.users.size > 0 && msg.mentions.users.first().id == client.user.id)
-        return false;
-
-    let imageAttachment = msg.attachments.find(v => util.isValidImage(v.filename));
-
-    if (imageAttachment) {
-        let probValue = db.get("faceEditChannels").get(msg.channel.id);
-        if (probValue.isUndefined().value() || probValue.isNull().value()) return false;
-
-        if (Math.random() > probValue.value()) return false;
-
-        processFaceSwap(msg, imageAttachment.url).catch(err =>
-            console.log(`Failed to run faceapp because ${err}`)
-        );
-        return true;
-    }
-
-    return false;
-};
+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();
 
-const onDirectMention = (msg, content, actionsDone) => {
-    if (actionsDone) return false;
-
-    let image = msg.attachments.find(v => util.isValidImage(v.filename));
-    if (!image) {
-        if (msg.attachments.size > 0) {
-            msg.channel.send(
-                `${msg.author.toString()} Nice, but I can't do anything to it! (Invalid file type)`
-            );
-            return true;
-        }
-        return false;
+    if (!lastImagedMessage) {
+        msg.channel.send(`${msg.author.toString()} Sorry, I couldn't find any recent messages with images.`);
+        return;
     }
 
-    let processor;
-    if(content.startsWith("caption this"))
-        processor = captionFace;
-    else
-        processor = morphFaces;
+    let image = lastImagedMessage.attachments.find(v => isValidImage(v.filename));
 
     processFaceSwap(
         msg,
@@ -231,41 +203,68 @@ const onDirectMention = (msg, content, actionsDone) => {
         `${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}`));
+}
 
-    return true;
-};
+export default {
+    onMessage: (actionsDone, msg, contents) => {
+        if (actionsDone) return false;
 
-function processLastImage(msg, processor) {
-    let lastImagedMessage = msg.channel.messages.filter(m => !m.author.bot && m.attachments.find(v => util.isValidImage(v.filename) !== undefined)).last();
+        if (msg.mentions.users.size > 0 && msg.mentions.users.first().id == client.user.id)
+            return false;
 
-    if(!lastImagedMessage) {
-        msg.channel.send(`${msg.author.toString()} Sorry, I couldn't find any recent messages with images.`);
-        return;
-    }
+        let imageAttachment = msg.attachments.find(v => isValidImage(v.filename));
 
-    let image = lastImagedMessage.attachments.find(v => util.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}`));
-}
+        if (imageAttachment) {
+            let probValue = (db.get("faceEditChannels") as ObjectChain<any>).get(msg.channel.id);
+            if (probValue.isUndefined().value() || probValue.isNull().value()) return false;
+
+            if (Math.random() > probValue.value()) return false;
 
-const commands = [
-{
-    pattern: "caption last image",
-    action: msg => processLastImage(msg, captionFace)
-},
-{
-    pattern: "look at last image",
-    action: msg => processLastImage(msg, morphFaces)
-}];
-
-module.exports = {
-    onMessage: onMessage,
-    onDirectMention: onDirectMention,
-    commands: commands
-};
+            processFaceSwap(msg, imageAttachment.url).catch(err =>
+                console.log(`Failed to run faceapp because ${err}`)
+            );
+            return true;
+        }
+
+        return false;
+    },
+    onDirectMention: (actionsDone, msg, content) => {
+        if (actionsDone) return false;
+
+        let image = msg.attachments.find(v => isValidImage(v.filename));
+        if (!image) {
+            if (msg.attachments.size > 0) {
+                msg.channel.send(
+                    `${msg.author.toString()} Nice, but I can't do anything to it! (Invalid file type)`
+                );
+                return true;
+            }
+            return false;
+        }
+
+        let processor;
+        if (content.startsWith("caption this"))
+            processor = captionFace;
+        else
+            processor = morphFaces;
+
+        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}`));
+
+        return true;
+    },
+    commands: [
+        {
+            pattern: "caption last image",
+            action: msg => processLastImage(msg, captionFace)
+        },
+        {
+            pattern: "look at last image",
+            action: msg => processLastImage(msg, morphFaces)
+        }]
+} as ICommand;

+ 171 - 0
src/commands/guide.ts

@@ -0,0 +1,171 @@
+import { db } from "../db";
+import { isAuthorised } from "../util";
+import { ICommand } from "./command";
+import { CollectionChain } from "lodash";
+import { Message } from "discord.js";
+import { ObjectChain } from "lodash";
+
+const VALID_GUIDE_TYPES = new Set(["meme", "guide", "misc"]);
+
+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
+};
+
+function listGuides(msg: Message, guideType: string, message: string){
+    let guidesForType = db.get(guideType) as CollectionChain<IGuide>;
+    let guides = guidesForType
+            .reduce((p, c) => `${p}\n${c.displayName} -- ${c.name}`, "\n")
+            .value();
+    msg.channel.send(`${msg.author.toString()} ${message}\n\`\`\`${guides}\`\`\`\n\nTo display the guides, ping me with one or more keywords, like \`@NoctBot sybaris com\``);
+}
+
+export default {
+    onDirectMention: (actionsDone, msg, content) => {
+        if (actionsDone)
+            return false;
+    
+        if(msg.attachments.size > 0 || content.length == 0)
+            return false;
+    
+        let parts = content.trim().split(" ");
+
+        let guides = db.get("guides") as CollectionChain<IGuide>;
+
+        let guide = guides
+            .clone()
+            .concat(db.get("memes").value(), db.get("miscs").value())
+            .map(g => Object.assign({
+                parts: g.name.toLowerCase().split(" ")
+            }, g))
+            .sortBy(g => g.parts.length)
+            .maxBy(k => db._.intersection(parts, k.parts).length)
+            .value();
+        
+        let hits =
+            guide !== undefined &&
+            db._.intersection(guide.name.toLowerCase().split(" "), parts).length > 0;
+        
+        if (hits) {
+            msg.channel.send(guide.content);
+            return true;
+        }
+        return false;
+    },
+    commands: [
+        {
+            pattern: makePattern,
+            action: (msg, s, match) => {
+                if (!isAuthorised(msg.member)) return;
+                let type = match[1].toLowerCase();
+                let name = match[2].trim();
+                let keywords = match[3].trim().toLowerCase();
+                let contents = match[4].trim();
+    
+                if(contents.length == 0){
+                    msg.channel.send(
+                        `${msg.author.toString()} The guide must have some content!`
+                    );
+                    return;
+                }
+        
+                if(!VALID_GUIDE_TYPES.has(type)){
+                    msg.channel.send(
+                        `${msg.author.toString()} The type ${type} is not a valid guide type!`
+                    );
+                    return;
+                }
+        
+                let typeDB = `${type}s`;
+        
+                let guide = (db.get(typeDB) as ObjectChain<any>).find({
+                    name: keywords
+                }) as ObjectChain<any>;
+        
+                if (!guide.isUndefined().value()) {
+                    guide.assign({
+                        displayName: name,
+                        content: contents
+                    }).write();
+                } else {
+                    (db.get(typeDB) as CollectionChain<any>)
+                        .push({
+                            name: keywords,
+                            displayName: name,
+                            content: contents
+                        })
+                        .write();
+                }
+        
+                msg.channel.send(
+                    `${msg.author.toString()} Added/updated "${name}" (keywords \`${keywords}\`)!`
+                );
+            }
+        },
+        {
+            pattern: deletePattern,
+            action: (msg, s, match) => {
+                if (!isAuthorised(msg.member)) return;
+                let type = match[1];
+                let keywords = match[2].trim();
+        
+                if(!VALID_GUIDE_TYPES.has(type)){
+                    msg.channel.send(
+                        `${msg.author.toString()} The type ${type} is not a valid guide type!`
+                    );
+                    return;
+                }
+        
+                let typeDB = `${type}s`;
+        
+                let val = (db.get(typeDB) as ObjectChain<any>).find({
+                    name: keywords
+                });
+        
+                if (val.isUndefined().value()) {
+                    msg.channel.send(`${msg.author.toString()} No ${type} "${keywords}"!`);
+                    return;
+                }
+        
+                (db.get(typeDB) as CollectionChain<any>)
+                    .remove({
+                        name: keywords
+                    })
+                    .write();
+        
+                msg.channel.send(
+                    `${msg.author.toString()} Removed ${type} "${keywords}"!`
+                );
+            }
+        },
+        { pattern: "guides", action: msg => listGuides(msg, "guides", "Here are the guides I have:") },
+        { pattern: "memes", action: msg => listGuides(msg, "memes", "Here are some random memes I have:") },
+        { pattern: "misc", action: msg => 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."
+        }
+    }
+} as ICommand;

+ 32 - 0
src/commands/help.ts

@@ -0,0 +1,32 @@
+import { isAuthorised, documentation } from "../util";
+import { ICommand } from "./command";
+
+export default {
+    commands: [{
+        pattern: "help",
+        action: msg => {
+            let isAuthed = isAuthorised(msg.member);
+
+            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`;
+            }
+
+            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}\`\`\``;
+
+            msg.channel.send(message);
+        }
+    }]
+} as ICommand;

+ 25 - 0
src/commands/inspire.ts

@@ -0,0 +1,25 @@
+import request from "request-promise-native";
+import { ICommand } from "./command";
+import { Message } from "discord.js";
+
+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 ]
+    });
+}
+
+export default {
+    commands: [{
+        pattern: "inspire me",
+        action: msg => {
+            inspire(msg);    
+        }
+    }],
+    documentation: {
+        "inspire me": {
+            auth: false,
+            description: "Generates an inspiring quote just for you"
+        }
+    }
+} as ICommand;

+ 49 - 43
commands/news_aggregator.js

@@ -1,18 +1,21 @@
-const TurndownService = require("turndown");
-const RSSParser = require("rss-parser");
-const db = require("../db.js");
-const interval = require("interval-promise");
-const client = require("../client.js");
-const sha1 = require("sha1");
-const path = require("path");
-const fs = require("fs");
-const Discord = require("discord.js");
+import TurndownService, { Options } from "turndown";
+import { db } from "../db";
+import interval from "interval-promise";
+import client from "../client";
+import sha1 from "sha1";
+import * as path from "path";
+import * as fs from "fs";
+import { ObjectChain } from "lodash";
+
+import { IAggregator, NewsPostItem, INewsPostData } from "./aggregators/aggregator";
+import { ICommand } from "./command";
+import { RichEmbed, TextChannel, Message, Channel } from "discord.js";
 
 const UPDATE_INTERVAL = 5;
 const MAX_PREVIEW_LENGTH = 300; 
 
-const aggregators = [];
-const aggregateChannelID = db.get("aggregateChannel").value();
+const aggregators : IAggregator[] = [];
+const aggregateChannelID = db.get("aggregateChannel").value() as string;
 
 // TODO: Run BBCode converter instead
 const turndown = new TurndownService();
@@ -21,11 +24,11 @@ turndown.addRule("image", {
     replacement: () => ""
 });
 turndown.addRule("link", {
-    filter: node => node.nodeName === "A" &&node.getAttribute("href"),
-    replacement: (content, node) => node.getAttribute("href")
+    filter: (node : HTMLElement, opts: Options) => node.nodeName === "A" &&node.getAttribute("href") != null,
+    replacement: (content: string, node: HTMLElement) => node.getAttribute("href")
 });
 
-function markdownify(htmStr, link) {
+function markdownify(htmStr: string) {
     return turndown.turndown(htmStr)/*.replace(/( {2}\n|\n\n){2,}/gm, "\n").replace(link, "")*/;
 }
 
@@ -35,24 +38,18 @@ async function checkFeeds() {
     let aggregatorJobs = [];
 
     for(let aggregator of aggregators) {
-        if(aggregator.aggregate)
-            aggregatorJobs.push(aggregator.aggregate());    
+        aggregatorJobs.push(aggregator.aggregate());    
     }
     let aggregatedItems = await Promise.all(aggregatorJobs);
 
     for(let itemSet of aggregatedItems) {
         for(let item of itemSet) {
             let itemObj = {
-                id: item.id,
-                link: item.link || "",
-                title: item.title || "",
-                author: item.author,
-                contents: markdownify(item.contents, item.link),
-                hash: null,
+                ...item,
                 cacheMessageId: null,
-                postedMessageId: null,
-                embedColor: item.embedColor || 0xffffffff
-            };
+                postedMessageId: null
+            } as NewsPostItem;
+            itemObj.contents = markdownify(item.contents);
             itemObj.hash = sha1(itemObj.contents);
 
             await addNewsItem(itemObj);
@@ -60,7 +57,7 @@ async function checkFeeds() {
     }
 }
 
-function clipText(text) {
+function clipText(text: string) {
     if(text.length <= MAX_PREVIEW_LENGTH)
         return text;
 
@@ -68,11 +65,11 @@ function clipText(text) {
 }
 
 // TODO: Replace with proper forum implementation
-async function addNewsItem(item) {
-    let aggrItems = db.get("aggregatedItemsCache");
+async function addNewsItem(item: NewsPostItem) {
+    let aggrItems = db.get("aggregatedItemsCache") as ObjectChain<any>;
 
     if(aggrItems.has(item.id).value()) {
-        let postedItem = aggrItems.get(item.id).value();
+        let postedItem = aggrItems.get(item.id).value() as INewsPostData;
 
         // No changes, skip
         if(postedItem.hash == item.hash)
@@ -82,8 +79,11 @@ async function addNewsItem(item) {
     }
 
     let ch = client.channels.get(aggregateChannelID);
+    
+    if(!(ch instanceof TextChannel))
+        return;
 
-    let msg = await ch.send(new Discord.RichEmbed({
+    let msg = await ch.send(new RichEmbed({
         title: item.title,
         url: item.link,
         color: item.embedColor,
@@ -95,24 +95,28 @@ async function addNewsItem(item) {
         footer: {
             text: "NoctBot News Aggregator"
         }
-    }));
+    })) as Message;
 
     aggrItems.set(item.id, {
         hash: item.hash,
         cacheMessageId: msg.id,
         postedMessageId: null
-    }).write();
+    });
+    db.write();
 }
 
-async function deleteCacheMessage(messageId) {
+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, messageId) {
+async function tryFetchMessage(channel : TextChannel, messageId: string) {
     try {
         return await channel.fetchMessage(messageId);
     }catch(error){
@@ -126,10 +130,14 @@ function initAggregators() {
 
     for(let file of files) {
         let ext  = path.extname(file);
+        let name = path.basename(file);
+
+        if(name == "aggregator.js")
+            continue;
         if(ext != ".js")
             continue;
 
-        let obj = require(path.resolve(aggregatorsPath, file));
+        let obj = require(path.resolve(aggregatorsPath, file)) as IAggregator;
 
         if(obj)
             aggregators.push(obj);
@@ -139,11 +147,9 @@ function initAggregators() {
     }
 }
 
-function onStart() {
-    initAggregators();
-    interval(checkFeeds, UPDATE_INTERVAL * 60 * 1000);
-};
-
-module.exports = {
-    onStart: onStart
-};
+export default {
+    onStart : () => {
+        initAggregators();
+        interval(checkFeeds, UPDATE_INTERVAL * 60 * 1000);
+    }
+} as ICommand;

+ 111 - 0
src/commands/quote.ts

@@ -0,0 +1,111 @@
+import { db, IRandomElementMixin } from "../db";
+import { isAuthorised } from "../util";
+import { ICommand } from "./command";
+import { Collection } from "discord.js";
+import { CollectionChain } from "lodash";
+
+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;
+}
+
+interface Quote {
+    author: string,
+    message: string
+}
+
+export default {
+    commands: [
+        {
+            pattern: "add quote",
+            action: (msg, c) => {
+                if (!isAuthorised(msg.member))
+                    return;
+        
+                let result = quotePattern.exec(c);
+        
+                if (result == null)
+                    return;
+        
+                let author = result[1].trim();
+                let message = result[2].trim();
+        
+                (db.get("quotes") as CollectionChain<any>).push({
+                    author: author,
+                    message: message
+                }).write();
+        
+                msg.channel.send(`${msg.author.toString()} Added quote #${db.get("quotes").size().value()}!`);
+            }
+        },
+        {
+            pattern: "random quote",
+            action: (msg) => {
+                if (db.get("quotes").size().value() == 0) {
+                    msg.channel.send("I have no quotes!");
+                    return;
+                }
+                let quote = (db.get("quotes") as IRandomElementMixin).randomElement().value();
+                let index = (db.get("quotes") as CollectionChain<any>).indexOf(quote).value();
+                msg.channel.send(`Quote #${index + 1}:\n*"${quote.message}"*\n- ${quote.author}`);
+            }
+        },
+        {
+            pattern: "remove quote",
+            action: (msg, c) => {
+                let quoteNum = c.substring("remove quote".length).trim();
+                let val = parseInt(quoteNum);
+                if (isNaN(val) || db.get("quotes").size().value() < val - 1)
+                    return;
+        
+                (db.get("quotes") as CollectionChain<any>).pullAt(val - 1).write();
+                msg.channel.send(`${msg.author.toString()} Removed quote #${val}!`);
+            }
+        },
+        {
+            pattern: "quotes",
+            action: msg => {
+                if (!isAuthorised(msg.member)) {
+                    msg.channel.send(`${msg.author.toString()} To prevent spamming, only bot moderators can view all quotes!`);
+                    return;
+                }
+        
+                if (db.get("quotes").size().value() == 0) {
+                    msg.channel.send("I have no quotes!");
+                    return;
+                }
+                
+                let quotes = (db.get("quotes") as CollectionChain<any>).reduce((prev: string, curr: Quote, i: number) => `${prev}[${i+1}] "${minify(curr.message, 10)}" by ${curr.author}\n`, "\n").value();
+                msg.channel.send(`${msg.author.toString()}I know the following quotes:\n\`\`\`${quotes}\`\`\``);
+            }
+        }
+    ],
+    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..."
+        }
+    }
+} as ICommand;
+
+
+// module.exports = {
+//     commands: commands,
+//     documentation: documentation
+// };

+ 30 - 0
src/commands/rcg.ts

@@ -0,0 +1,30 @@
+import request from "request-promise-native";
+import { ICommand } from "./command";
+import { Message } from "discord.js";
+
+const rcgRe = /<input id="rcg_image".+value="([^"]+)".*\/>/i;
+
+async function randomComic(msg: Message) {
+    let result = await request("http://explosm.net/rcg/view/");
+    
+    let regexResult = rcgRe.exec(result);
+
+    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!"
+        }
+    }
+} as ICommand;

+ 129 - 0
src/commands/react.ts

@@ -0,0 +1,129 @@
+import { db, IRandomElementMixin } from "../db";
+import { isAuthorised } from "../util";
+import client from "../client";
+import { ICommand } from "./command";
+import { ObjectChain, CollectionChain } from "lodash";
+
+const pattern = /^react to\s+"([^"]+)"\s+with\s+\<:[^:]+:([^\>]+)\>$/i;
+
+export default {
+    commands: [
+        {
+            pattern: "react to",
+            action: (msg, s) => {
+                if (!isAuthorised(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;
+                    }
+                    db.get("messageReactions").set(reactable, reactionEmoji);
+                    db.write();
+                    msg.channel.send(`${msg.author.toString()} Added reaction!`);
+                }
+            }
+        },
+        {
+            pattern: "remove reaction to",
+            action: (msg, s) => {
+                if (!isAuthorised(msg.member)) return;
+                let content = s.substring("remove reaction to ".length).trim().toLowerCase();
+                if (!db.get("messageReactions").has(content).value()) {
+                    msg.channel.send(`${msg.author.toString()} No such reaction available!`);
+                    return;
+                }
+                db.get("messageReactions").unset(content).write();
+                msg.channel.send(`${msg.author.toString()} Removed reaction!`);
+            }
+        },
+        {
+            pattern: "reactions",
+            action: msg => {
+                let reactions = db.get("messageReactions").keys().value().reduce((p: string, c: string) => `${p}\n${c}`, "");
+                msg.channel.send(`I'll react to the following messages:\n\`\`\`${reactions}\`\`\``);
+            }
+        }
+    ],
+    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."
+        }
+    },
+    onMessage: (actionsDone, msg, content) => {
+        if (actionsDone)
+            return false;
+
+        let lowerContent = content.toLowerCase();
+        if (db.get("messageReactions").has(lowerContent).value()) {
+            msg.react(client.emojis.get((db.get("messageReactions") as ObjectChain<any>).get(lowerContent).value()));
+            return true;
+        }
+
+        if (msg.mentions.users.size == 0)
+            return false;
+
+        if (!(db.get("reactableMentionedUsers") as CollectionChain<any>).intersectionWith(msg.mentions.users.map(u => u.id)).isEmpty().value()) {
+            const emoteId = ((db
+                .get("emotes") as ObjectChain<any>)
+                .get("angery") as IRandomElementMixin)
+                .randomElement()
+                .value();
+            msg.react(client.emojis.find(e => e.id == emoteId));
+            return true;
+        }
+
+        return false;
+    },
+    onIndirectMention: (actionsDone, msg) => {
+        if (actionsDone)
+            return false;
+        let emoteType = "angery";
+        if ((db.get("specialUsers") as CollectionChain<any>).includes(msg.author.id).value())
+            emoteType = "hug";
+        else if ((db.get("bigUsers") as CollectionChain<any>).includes(msg.author.id).value())
+            emoteType = "big";
+        else if ((db.get("dedUsers") as CollectionChain<any>).includes(msg.author.id).value())
+            emoteType = "ded";
+
+        let id = ((db
+            .get("emotes") as ObjectChain<string>)
+            .get(emoteType) as IRandomElementMixin)
+            .randomElement()
+            .value();
+
+        let emote = client.emojis.find(e => e.id == id);
+
+        if (!emote) {
+            console.log(`WARNING: Emote ${id} no longer is valid. Deleting invalid emojis from the list...`);
+            ((db.get("emotes") as ObjectChain<any>)
+                .get(emoteType) as CollectionChain<any>)
+                .remove((id: string) => !client.emojis.has(id))
+                .write();
+
+            id = ((db
+                .get("emotes") as ObjectChain<any>)
+                .get(emoteType) as IRandomElementMixin)
+                .randomElement()
+                .value();
+            emote = client.emojis.find(e => e.id == id);
+        }
+
+        if (!emote)
+            return false;
+
+        msg.channel.send(emote.toString());
+        return true;
+    }
+} as ICommand;

+ 107 - 81
commands/rss_checker.js

@@ -1,17 +1,21 @@
-const TurndownService = require("turndown");
-const RSSParser = require("rss-parser");
-const db = require("../db.js");
-const interval = require("interval-promise");
-const client = require("../client.js");
-const sha1 = require("sha1");
-const html = require("node-html-parser"); 
-const axios = require("axios");
+import TurndownService, { Options } from "turndown";
+import RSSParser from "rss-parser";
+import { db } from "../db";
+import interval from "interval-promise";
+import client from "../client";
+import sha1 from "sha1";
+import html from "node-html-parser";
+import request from "request-promise-native";
+import { ICommand } from "./command";
+import { Response } from "request";
+import { TextChannel, Message, ReactionCollector, MessageReaction, Collector, User, Channel } from "discord.js";
+import { Dictionary, ObjectChain } from "lodash";
 
 const PREVIEW_CHAR_LIMIT = 300;
 const verifyChannelID = db.get("newsPostVerifyChannel").value();
 
-const reactionCollectors = {};
-const verifyMessageIdToPostId = {};
+const reactionCollectors : Dictionary<ReactionCollector> = {};
+const verifyMessageIdToPostId : Dictionary<string> = {};
 
 const turndown = new TurndownService();
 turndown.addRule("image", {
@@ -19,47 +23,62 @@ turndown.addRule("image", {
     replacement: () => ""
 });
 turndown.addRule("link", {
-    filter: node => node.nodeName === "A" &&node.getAttribute("href"),
-    replacement: (content, node) => node.getAttribute("href")
+    filter: (node : HTMLElement, opts: Options) => node.nodeName === "A" && node.getAttribute("href") != null,
+    replacement: (content: string, node: HTMLElement) => node.getAttribute("href")
 });
 
 const parser = new RSSParser();
 const RSS_UPDATE_INTERVAL_MIN = 5;
 
-function getThreadId(url) {
+function getThreadId(url: string) {
     let result = url.substring(url.lastIndexOf(".") + 1);
     if(result.endsWith("/"))
         result = result.substring(0, result.length - 1);
     return result;
 }
 
+interface PostItem {
+    id: string,
+    title: string,
+    link: string,
+    creator: string,
+    contents: string,
+    hash: string,
+    messageId?: string,
+    verifyMessageId?: string,
+    type?: string
+}
+
 async function checkFeeds() {
     console.log(`Checking feeds on ${new Date().toISOString()}`);
     let feeds = db.get("rssFeeds").value();
-    let oldNews = db.get("postedNewsGuids");
+    let oldNews = db.get("postedNewsGuids") as ObjectChain<any>;
     for(let feedEntry of feeds) {
         let feed = await parser.parseURL(feedEntry.url);
         if(feed.items.length == 0)
             continue;
-        let printableItems = feed.items.sort((a, b) => a.isoDate.localeCompare(b.isoDate));
+        let printableItems = feed.items.sort((a : any, b: any) => a.isoDate.localeCompare(b.isoDate));
         if(printableItems.length > 0) {
             for(let item of printableItems) {
                 let itemID = getThreadId(item.guid);
                 let contents = null;
                 
                 try {
-                    let res = await axios.get(item.link);
-                    if(res.status != 200) {
-                        console.log(`Post ${itemID} could not be loaded because request returned status ${res.status}`);
+                    let res = await request(item.link, {resolveWithFullResponse: true}) as Response;
+                    if(res.statusCode != 200) {
+                        console.log(`Post ${itemID} could not be loaded because request returned status ${res.statusCode}`);
                         continue;
                     }
 
-                    let rootNode = html.parse(res.data, {
+                    let rootNode = html.parse(res.body, {
                         pre: true,
                         script: false,
                         style: false
                     });
 
+                    if(!(rootNode instanceof HTMLElement))
+                        continue;
+
                     let opDiv = rootNode.querySelector("div.bbWrapper");
 
                     if (!opDiv) {
@@ -83,7 +102,7 @@ async function checkFeeds() {
                     messageId: null,
                     verifyMessageId: null,
                     type: null
-                };
+                } as PostItem;
 
                 if(oldNews.has(itemObj.id).value()){
                     let data = oldNews.get(itemObj.id).value();
@@ -105,14 +124,16 @@ async function checkFeeds() {
             }
             let lastUpdateDate = printableItems[printableItems.length - 1].isoDate;
             console.log(`Setting last update marker on ${feedEntry.url} to ${lastUpdateDate}`);
-            db.get("rssFeeds").find({ url: feedEntry.url}).assign({lastUpdate: lastUpdateDate}).write();
+            let rssFeeds = db.get("rssFeeds") as ObjectChain<any>;
+            let entry = rssFeeds.find({ url: feedEntry.url}) as ObjectChain<any>;
+            entry.assign({lastUpdate: lastUpdateDate}).write();
         }
     }
 }
 
 function initPendingReactors() {
     let verifyChannel = client.channels.get(verifyChannelID);
-    db.get("newsCache").forOwn(async i => {
+    db.get("newsCache").forOwn(async (i : any) => {
         let m = await tryFetchMessage(verifyChannel, i.verifyMessageId);
         let collector = m.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
         collector.on("collect", collectReaction)
@@ -121,9 +142,9 @@ function initPendingReactors() {
     }).value();
 }
 
-async function addVerifyMessage(item) {
-    let verifyChannel = client.channels.get(verifyChannelID);
-    let cache = db.get("newsCache");
+async function addVerifyMessage(item : PostItem) {
+    let verifyChannel = client.channels.get(verifyChannelID) as TextChannel;
+    let cache = db.get("newsCache") as ObjectChain<any>;
     let oldNews = db.get("postedNewsGuids");
     let postedNews = db.get("postedNewsGuids");
     item.type = "🆕 ADD";
@@ -138,31 +159,32 @@ async function addVerifyMessage(item) {
             await oldMessage.delete();
     }
 
-    let newMessage = await verifyChannel.send(toVerifyString(item));
+    let newMessage = await verifyChannel.send(toVerifyString(item)) as Message;
     
     await newMessage.react("✅");
     await newMessage.react("❌");
 
-    var collector = newMessage.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
+    let collector = newMessage.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
     collector.on("collect", collectReaction)
     reactionCollectors[newMessage.id] = collector;
     verifyMessageIdToPostId[newMessage.id] = item.id;
     item.verifyMessageId = newMessage.id;
-    cache.set(item.id, item).write();
+    cache.set(item.id, item);
 
     oldNews.set(item.id, {
         hash: item.hash,
         messageId: null
-    }).write();
+    });
+    db.write();
 }
 
-function collectReaction(reaction, collector) {
-    let cache = db.get("newsCache");
+function collectReaction(reaction : MessageReaction, collector: Collector<string, MessageReaction>) {
+    let cache = db.get("newsCache") as ObjectChain<any>;
     let m = reaction.message;
     collector.stop();
     delete reactionCollectors[m.id];
     let postId = verifyMessageIdToPostId[m.id];
-    let item = cache.get(postId).value();
+    let item = cache.get(postId).value() as PostItem;
     cache.unset(postId).write();
     m.delete();
 
@@ -170,7 +192,7 @@ function collectReaction(reaction, collector) {
         sendNews(item);
 }
 
-async function sendNews(item) {
+async function sendNews(item : PostItem) {
     let outChannel = db.get("feedOutputChannel").value();
     let oldNews = db.get("postedNewsGuids");
 
@@ -178,15 +200,18 @@ async function sendNews(item) {
     oldNews.set(item.id, {
                     hash: item.hash,
                     messageId: sentMessage.id
-                }).write();
+                });
+    db.write();
 }
 
-function isVerifyReaction(reaction, user) {
+function isVerifyReaction(reaction : MessageReaction, user: User) {
     return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot;
 }
 
-async function tryFetchMessage(channel, messageId) {
+async function tryFetchMessage(channel: Channel, messageId: string)  {
     try {
+        if(!(channel instanceof TextChannel))
+            return null;
         return await channel.fetchMessage(messageId);
     }catch(error){
         return null;
@@ -197,26 +222,29 @@ function shouldVerify() {
     return verifyChannelID != "";
 }
 
-async function postNewsItem(channel, item) {
+async function postNewsItem(channel: string, item: PostItem) : Promise<Message | null> {
     let newsMessage = toNewsString(item);
     let ch = client.channels.get(channel);
 
+    if(!(ch instanceof TextChannel))
+        return null;
+
     if(item.messageId) {
         let message = await tryFetchMessage(ch, item.messageId);
         if(message)
             return await message.edit(newsMessage);
         else 
-            return await ch.send(newsMessage);
+            return await ch.send(newsMessage) as Message;
     } 
     else 
-        return await ch.send(newsMessage);
+        return await ch.send(newsMessage) as Message;
 }
 
-function markdownify(htmStr, link) {
+function markdownify(htmStr: string, link: string) {
     return turndown.turndown(htmStr).replace(/( {2}\n|\n\n){2,}/gm, "\n").replace(link, "");
 }
 
-function toNewsString(item) {
+function toNewsString(item: PostItem) {
     return `**${item.title}**
 Posted by ${item.creator}
 ${item.link}
@@ -224,7 +252,7 @@ ${item.link}
 ${item.contents}`;
 }
 
-function toVerifyString(item) {
+function toVerifyString(item: PostItem) {
     return `[${item.type}]
 Post ID: **${item.id}**
     
@@ -233,45 +261,43 @@ ${toNewsString(item)}
 React with ✅ (approve) or ❌ (deny).`;
 }
 
-const onStart = () => {
-    initPendingReactors();
-    interval(checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
-};
-
-const 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();
-            
-            if(!db.get("newsCache").has(id).value()) {
-                msg.channel.send(`${msg.author.toString()} No unapproved news items with id ${id}!`);
-                return;
-            }
-
-            let item = db.get("newsCache").get(id).value();
-
-            let editMsg = await tryFetchMessage(client.channels.get(verifyChannelID), item.verifyMessageId);
-
-            if(!editMsg){
-                msg.channel.send(`${msg.author.toString()} No verify messafe found for ${id}! This is a bug: report to horse.`);
-                return;
+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();
+                
+                if(!db.get("newsCache").has(id).value()) {
+                    msg.channel.send(`${msg.author.toString()} No unapproved news items with id ${id}!`);
+                    return;
+                }
+                
+                let newsCache = db.get("newsCache") as ObjectChain<any>;
+                let item = newsCache.get(id).value() as PostItem;
+    
+                let editMsg = await tryFetchMessage(client.channels.get(verifyChannelID), item.verifyMessageId);
+    
+                if(!editMsg){
+                    msg.channel.send(`${msg.author.toString()} No verify messafe found for ${id}! This is a bug: report to horse.`);
+                    return;
+                }
+    
+                item.contents = newContents;
+                
+                db.get("newsCache").set(id, item);
+                db.write();
+                await editMsg.edit(toVerifyString(item));
+                await msg.delete();
             }
-
-            item.contents = newContents;
-
-            db.get("newsCache").set(id, item).write();
-            await editMsg.edit(toVerifyString(item));
-            await msg.delete();
         }
+    ],
+    onStart: () => {
+        initPendingReactors();
+        interval(checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
     }
-];
-
-module.exports = {
-    onStart: onStart,
-    commands: commands
-};
+} as ICommand;

+ 9 - 6
db.js

@@ -1,11 +1,16 @@
-const lowdb = require("lowdb");
-const FileSync = require("lowdb/adapters/FileSync");
+import lowdb from "lowdb";
+import FileSync from "lowdb/adapters/FileSync";
+import { CollectionChain } from "lodash";
 
 const adapter = new FileSync(__dirname + "/db.json");
-const db = lowdb(adapter);
+export const db = lowdb(adapter);
+
+export interface IRandomElementMixin extends CollectionChain<any> {
+    randomElement() : any;
+}
 
 db._.mixin({
-    randomElement: array => array[Math.floor(Math.random() * array.length)]
+    randomElement: (array : any[]) => array[Math.floor(Math.random() * array.length)]
 });
 
 db.defaults({
@@ -139,5 +144,3 @@ db.defaults({
     latestCom3D2WorldDiaryEntry: 11,
     lastCOMJPVersion: 1320
 }).write();
-
-module.exports = db;

+ 29 - 26
main.js

@@ -1,27 +1,28 @@
-require("dotenv").config();
-
-const fs = require("fs");
-const path = require("path");
-const client = require("./client.js");
-const util = require("./util.js");
-
+import * as dotenv from "dotenv";
+import * as fs from "fs";
+import * as path from "path";
+import client from "./client";
+import { shouldShowMaintenanceMessage, documentation } from "./util";
+import { ICommand, BotEvent, IBotCommand } from "./commands/command"
+
+dotenv.config();
 const REACT_PROBABILITY = 0.3;
 
-function trigger(actions, ...params) {
+function trigger<T extends (actionDone: boolean, ...args: any[]) => boolean>(actions : Array<T>, ...params: any[]) {
     let actionDone = false;
     for (let i = 0; i < actions.length; i++) {
         const action = actions[i];
-        actionDone |= action(...params, actionDone);
+        actionDone = action(actionDone, ...params) || actionDone;
     }
     return actionDone;
 }
 
-let commands = [];
-let msgActions = [];
-let indirectMentionActions = [];
-let startActions = [];
-let directMessageActions = [];
-let postActions = [];
+let commands : Array<IBotCommand> = [];
+let msgActions : Array<BotEvent> = [];
+let indirectMentionActions : Array<BotEvent> = [];
+let startActions : Array<() => void> = [];
+let directMessageActions : Array<BotEvent> = [];
+let postActions : Array<BotEvent> = [];
 
 client.on("ready", () => {
     console.log("Starting up NoctBot!");
@@ -41,12 +42,12 @@ client.on("message", m => {
 
     let content = m.cleanContent.trim();
 
-    if (!util.shouldShowMaintenanceMessage(m.guild.id) && trigger(msgActions, m, content))
+    if (!shouldShowMaintenanceMessage(m.guild.id) && trigger(msgActions, m, content))
         return;
 
     if (m.mentions.users.size > 0 && m.mentions.users.first().id == client.user.id) {
 
-        if(util.shouldShowMaintenanceMessage(m.guild.id)) {
+        if(shouldShowMaintenanceMessage(m.guild.id)) {
             m.channel.send(`${m.author.toString()} I'm currently being maintained; please wait.`);
             return;
         }
@@ -77,7 +78,7 @@ client.on("message", m => {
             return;
     }
 
-    if(!util.shouldShowMaintenanceMessage(m.guild.id))
+    if(!shouldShowMaintenanceMessage(m.guild.id))
         trigger(postActions);
 });
 
@@ -92,27 +93,29 @@ function main() {
     let commandsPath = path.resolve(path.dirname(module.filename), "commands");
     let files = fs.readdirSync(commandsPath);
 
-    for (let i = 0; i < files.length; i++) {
-        const file = files[i];
+    for (const file of files) {
         let ext = path.extname(file);
+        let name = path.basename(file);
+        
+        if(name == "command.js")
+            continue;
+
         if (ext != ".js")
             continue;
 
-        let obj = require(path.resolve(commandsPath, file));
-        if (obj.commands) {
+        let obj = require(path.resolve(commandsPath, file)) as ICommand;
+        if (obj.commands)
             for (let command of obj.commands) {
                 // if (obj.commands.hasOwnProperty(command))
                     // commands[command] = obj.commands[command];
                 commands.push(command);
             }
-        }
 
-        if (obj.documentation) {
+        if (obj.documentation)
             for (let command in obj.documentation) {
                 if (obj.documentation.hasOwnProperty(command))
-                    util.documentation[command] = obj.documentation[command];
+                    documentation[command] = obj.documentation[command];
             }
-        }
 
         if (obj.onMessage)
             msgActions.push(obj.onMessage);

+ 1 - 0
src/rss_parser.d.ts

@@ -0,0 +1 @@
+declare module "rss-parser";

+ 43 - 0
src/util.ts

@@ -0,0 +1,43 @@
+import { db } from "./db"
+import { CollectionChain } from "lodash";
+import { GuildMember } from "discord.js";
+import { DocumentationSet } from "./commands/command";
+
+const VALID_EXTENSIONS = new Set([
+    "png",
+    "jpg",
+    "jpeg",
+    "bmp",
+]);
+
+export let documentation : DocumentationSet = {};
+
+export function isDevelopment() {
+    return process.env.NODE_ENV == "dev";
+}
+
+export function shouldShowMaintenanceMessage(serverId : string) {
+    if(process.env.NODE_ENV != "dev")
+        return false;
+
+    let devServers = db.get("devServers") as CollectionChain<string>;
+    return !devServers.includes(serverId).value();
+}
+
+export function isValidImage(fileName: string) {
+    let extPosition = fileName.lastIndexOf(".");
+    if(extPosition < 0)
+        return false;
+    let ext = fileName.substring(extPosition + 1).toLowerCase();
+    return VALID_EXTENSIONS.has(ext);
+}
+
+export function isAuthorised(member : GuildMember) {
+    let users = db.get("editors.users") as CollectionChain<string>;
+    let roles = db.get("editors.roles") as CollectionChain<string>;
+    if (users.includes(member.id).value())
+        return true;
+    if (roles.intersectionWith(member.roles.keyArray()).isEmpty().value())
+        return false;
+    return true;
+}

+ 21 - 0
tsconfig.json

@@ -0,0 +1,21 @@
+{
+    "compileOnSave": true,
+    "compilerOptions": {
+        "module": "commonjs",
+        "noImplicitAny": true,
+        "removeComments": true,
+        "preserveConstEnums": true,
+        "sourceMap": true,
+        "outDir": "build",
+        "lib": ["es2018", "dom"],
+        "esModuleInterop": true,
+        "target": "es2018"
+    },
+    "include": [
+        "src/**/*.ts",
+        "src/**/*.js"
+    ],
+    "exclude": [
+        "node_modules"
+    ]
+}

+ 0 - 43
util.js

@@ -1,43 +0,0 @@
-const db = require("./db.js");
-
-const VALID_EXTENSIONS = new Set([
-    "png",
-    "jpg",
-    "jpeg",
-    "bmp",
-]);
-
-function isDevelopment() {
-    return process.env.NODE_ENV == "dev";
-}
-
-function shouldShowMaintenanceMessage(serverId) {
-    if(process.env.NODE_ENV != "dev")
-        return false;
-    
-    return !db.get("devServers").includes(serverId).value();
-}
-
-function isValidImage(fileName) {
-    let extPosition = fileName.lastIndexOf(".");
-    if(extPosition < 0)
-        return false;
-    let ext = fileName.substring(extPosition + 1).toLowerCase();
-    return VALID_EXTENSIONS.has(ext);
-}
-
-function isAuthorised(member) {
-    if (db.get("editors.users").includes(member.id).value())
-        return true;
-    if (db.get("editors.roles").intersectionWith(member.roles.keyArray()).isEmpty().value())
-        return false;
-    return true;
-}
-
-module.exports = {
-    isAuthorised: isAuthorised,
-    isValidImage: isValidImage,
-    shouldShowMaintenanceMessage: shouldShowMaintenanceMessage,
-    isDevelopment: isDevelopment,
-    documentation: {}
-};