Browse Source

Upgrade to use Docker; move to PostgreSQL

ghorsington 4 years ago
parent
commit
0a320e8aeb
54 changed files with 9555 additions and 9322 deletions
  1. 14 0
      .dockerignore
  2. 8 0
      .env.template
  3. 45 43
      .gitignore
  4. 5 4
      .vscode/launch.json
  5. 16 0
      .vscode/tasks.json
  6. 8 8
      LICENSE
  7. 12 0
      Makefile
  8. 0 3
      README.md
  9. 14 0
      bot/.dockerignore
  10. 29 29
      .eslintrc.js
  11. 3 3
      .prettierrc
  12. 81 0
      bot/Dockerfile
  13. 3 0
      bot/README.md
  14. 6692 6692
      animu.xml
  15. 0 4
      ormconfig.json
  16. 6 5
      package.json
  17. 7 7
      src/client.ts
  18. 22 22
      src/commands/aggregators/aggregator.ts
  19. 80 80
      src/commands/aggregators/com3d2_updates.ts
  20. 95 95
      src/commands/aggregators/com3d2_world.ts
  21. 104 104
      src/commands/aggregators/kiss_diary.ts
  22. 26 26
      src/commands/command.ts
  23. 34 34
      src/commands/dead_chat.ts
  24. 292 292
      src/commands/facemorph.ts
  25. 357 357
      src/commands/forums_news_checker.ts
  26. 209 209
      src/commands/guide.ts
  27. 31 31
      src/commands/help.ts
  28. 24 24
      src/commands/inspire.ts
  29. 170 168
      src/commands/news_aggregator.ts
  30. 116 116
      src/commands/quote.ts
  31. 29 29
      src/commands/rcg.ts
  32. 180 182
      src/commands/react.ts
  33. 0 0
      bot/src/entity/AggroNewsItem.ts
  34. 0 0
      bot/src/entity/DeadChatReply.ts
  35. 0 0
      bot/src/entity/FaceCaptionMessage.ts
  36. 0 0
      bot/src/entity/Guide.ts
  37. 0 0
      bot/src/entity/KnownChannel.ts
  38. 0 0
      bot/src/entity/KnownUser.ts
  39. 0 0
      bot/src/entity/MessageReaction.ts
  40. 0 0
      bot/src/entity/PostVerifyMessage.ts
  41. 18 18
      src/entity/PostedForumsNewsItem.ts
  42. 0 0
      bot/src/entity/Quote.ts
  43. 0 0
      bot/src/entity/ReactionEmote.ts
  44. 256 256
      src/lowdb_migrator.ts
  45. 154 143
      src/main.ts
  46. 2 0
      bot/src/typedefs/bobb.d.ts
  47. 0 0
      bot/src/typedefs/rss_parser.d.ts
  48. 47 47
      src/util.ts
  49. 267 267
      src/xenforo.ts
  50. 22 22
      tsconfig.json
  51. 8 0
      db.env.template
  52. 46 0
      docker-compose.yml
  53. 23 0
      install-compose.sh
  54. 0 2
      src/typedefs/bobb.d.ts

+ 14 - 0
.dockerignore

@@ -0,0 +1,14 @@
+**/node_modules
+**/npm-debug.log
+**/Dockerfile*
+bot/build
+docker-compose*
+.dockerignore
+.git
+.gitignore
+.env
+*/bin
+*/obj
+README.md
+LICENSE
+.vscode

+ 8 - 0
.env.template

@@ -0,0 +1,8 @@
+BOT_TOKEN=
+FORUM_PASS=
+FORUM_API_KEY=
+IGNORE_CHANGED_NEWS=
+
+DB_USERNAME=
+DB_PASSWORD=
+DB_NAME=

+ 45 - 43
.gitignore

@@ -1,43 +1,45 @@
-build/
-
-*.sqlite
-
-# ---> Node
-# Logs
-logs
-*.log
-npm-debug.log*
-
-# Runtime data
-pids
-*.pid
-*.seed
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-
-# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (http://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directory
-# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
-node_modules
-
-token.js
-package-lock.json
-db.json
-imagestats.csv
-clarifai_keys.js
-.env
-db_old.json
-db_migrated.json
-*.old
+build/
+data/
+
+*.sqlite
+*.env
+
+# ---> Node
+# Logs
+logs
+*.log
+npm-debug.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
+node_modules
+
+token.js
+package-lock.json
+db.json
+imagestats.csv
+clarifai_keys.js
+.env
+db_old.json
+db_migrated.json
+*.old

+ 5 - 4
.vscode/launch.json

@@ -7,11 +7,12 @@
         {
             "type": "node",
             "request": "launch",
-            "name": "Launch Program",
-            "program": "${workspaceFolder}/src/main.ts",
-            "preLaunchTask": "tsc: build - tsconfig.json",
+            "name": "Launch NoctBot",
+            "program": "${workspaceFolder}/bot/src/main.ts",
+            "preLaunchTask": "tsc-build",
+            "cwd": "${workspaceFolder}/bot",
             "outFiles": [
-                "${workspaceFolder}/build/**/*.js"
+                "${workspaceFolder}/bot/build/**/*.js"
             ],
             "env": {
                 "NODE_ENV": "dev"

+ 16 - 0
.vscode/tasks.json

@@ -0,0 +1,16 @@
+{
+    // See https://go.microsoft.com/fwlink/?LinkId=733558 
+    // for the documentation about the tasks.json format
+    "version": "2.0.0",
+    "tasks": [
+        {
+            "label": "tsc-build",
+            "command": ["npm"],
+            "args": ["run", "build"],
+            "type": "shell",
+            "options": {
+                "cwd": "${workspaceFolder}/bot"
+            }
+        },
+    ]
+}

+ 8 - 8
LICENSE

@@ -1,8 +1,8 @@
-MIT License
-Copyright (c) <year> <copyright holders>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+MIT License
+Copyright (c) <year> <copyright holders>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 12 - 0
Makefile

@@ -0,0 +1,12 @@
+
+build:
+	docker-compose build
+
+start_db: build
+	docker-compose up db adminer
+
+start: build
+	docker-compose up db adminer noctbot
+
+start_bot: build
+	docker-compose up db adminer noctbot

+ 0 - 3
README.md

@@ -1,3 +0,0 @@
-# noctbot
-
-He is a robot; his name is Noct. His name is ROBO-NOCT!

+ 14 - 0
bot/.dockerignore

@@ -0,0 +1,14 @@
+node_modules
+npm-debug.log
+Dockerfile*
+bot/build
+docker-compose*
+.dockerignore
+.git
+.gitignore
+.env
+*/bin
+*/obj
+README.md
+LICENSE
+.vscode

+ 29 - 29
.eslintrc.js

@@ -1,30 +1,30 @@
-module.exports = {
-    "env": {
-        "browser": true,
-        "commonjs": true,
-        "es6": true
-    },
-    "extends": "eslint:recommended",
-    "parserOptions": {
-        "sourceType": "module",
-        "ecmaVersion": 2017
-    },
-    "rules": {
-        "indent": [
-            "error",
-            4
-        ],
-        "linebreak-style": [
-            "error",
-            "windows"
-        ],
-        "quotes": [
-            "error",
-            "double"
-        ],
-        "semi": [
-            "error",
-            "always"
-        ]
-    }
+module.exports = {
+    "env": {
+        "browser": true,
+        "commonjs": true,
+        "es6": true
+    },
+    "extends": "eslint:recommended",
+    "parserOptions": {
+        "sourceType": "module",
+        "ecmaVersion": 2017
+    },
+    "rules": {
+        "indent": [
+            "error",
+            4
+        ],
+        "linebreak-style": [
+            "error",
+            "windows"
+        ],
+        "quotes": [
+            "error",
+            "double"
+        ],
+        "semi": [
+            "error",
+            "always"
+        ]
+    }
 };

+ 3 - 3
.prettierrc

@@ -1,4 +1,4 @@
-{
-    "tabWidth": 4,
-    "printWidth": 100
+{
+    "tabWidth": 4,
+    "printWidth": 100
 }

+ 81 - 0
bot/Dockerfile

@@ -0,0 +1,81 @@
+FROM alpine AS opencv-builder
+
+ARG OPENCV_VERSION
+
+RUN apk --no-cache add python make g++ cmake linux-headers
+
+RUN mkdir opencv && \
+    cd opencv && \
+    wget https://github.com/Itseez/opencv/archive/${OPENCV_VERSION}.zip --no-check-certificate -O opencv-${OPENCV_VERSION}.zip && \
+    unzip opencv-${OPENCV_VERSION}.zip && \
+    mkdir opencv-${OPENCV_VERSION}/build && \
+    cd opencv-${OPENCV_VERSION}/build && \
+    cmake_flags="-D CMAKE_BUILD_TYPE=RELEASE \
+    -D BUILD_EXAMPLES=OFF \
+	-D BUILD_DOCS=OFF \
+	-D BUILD_TESTS=OFF \
+	-D BUILD_PERF_TESTS=OFF \
+	-D BUILD_JAVA=OFF \
+	-D BUILD_opencv_apps=OFF \
+	-D BUILD_opencv_aruco=OFF \
+	-D BUILD_opencv_bgsegm=OFF \
+	-D BUILD_opencv_bioinspired=OFF \
+	-D BUILD_opencv_ccalib=OFF \
+	-D BUILD_opencv_datasets=OFF \
+	-D BUILD_opencv_dnn_objdetect=OFF \
+	-D BUILD_opencv_dpm=OFF \
+	-D BUILD_opencv_fuzzy=OFF \
+	-D BUILD_opencv_hfs=OFF \
+	-D BUILD_opencv_java_bindings_generator=OFF \
+	-D BUILD_opencv_js=OFF \
+    -D BUILD_opencv_img_hash=OFF \
+    -D BUILD_opencv_line_descriptor=OFF \
+    -D BUILD_opencv_optflow=OFF \
+    -D BUILD_opencv_phase_unwrapping=OFF \
+	-D BUILD_opencv_python3=OFF \
+	-D BUILD_opencv_python_bindings_generator=OFF \
+	-D BUILD_opencv_reg=OFF \
+	-D BUILD_opencv_rgbd=OFF \
+	-D BUILD_opencv_saliency=OFF \
+	-D BUILD_opencv_shape=OFF \
+	-D BUILD_opencv_stereo=OFF \
+	-D BUILD_opencv_stitching=OFF \
+	-D BUILD_opencv_structured_light=OFF \
+	-D BUILD_opencv_superres=OFF \
+	-D BUILD_opencv_surface_matching=OFF \
+	-D BUILD_opencv_ts=OFF \
+	-D BUILD_opencv_xobjdetect=OFF \
+	-D BUILD_opencv_xphoto=OFF" && \
+    echo $cmake_flags && \
+    cmake $cmake_flags .. && \
+    make -j $(nproc) && \
+    make install
+
+FROM node:10-alpine AS builder
+
+ARG OPENCV_VERSION
+ENV OPENCV4NODEJS_DISABLE_AUTOBUILD=1
+
+RUN apk --no-cache add python make g++
+
+COPY --from=opencv-builder /opencv/opencv-${OPENCV_VERSION}/build/lib/libopencv* /usr/local/lib/
+COPY --from=opencv-builder /usr/local/include/opencv2 /usr/local/include/opencv2
+COPY --from=opencv-builder /usr/local/share/OpenCV /usr/local/share/OpenCV
+COPY package.json ./
+RUN npm install
+
+FROM node:10-alpine
+
+ARG OPENCV_VERSION
+WORKDIR /app/noctbot
+
+COPY --from=builder node_modules node_modules
+COPY --from=opencv-builder /opencv/opencv-${OPENCV_VERSION}/build/lib/libopencv* /usr/local/lib/
+COPY --from=opencv-builder /usr/local/include/opencv2 /usr/local/include/opencv2
+COPY --from=opencv-builder /usr/local/share/OpenCV /usr/local/share/OpenCV
+COPY . .
+
+RUN npm run build
+
+EXPOSE 3000
+CMD npm run start

+ 3 - 0
bot/README.md

@@ -0,0 +1,3 @@
+# noctbot
+
+He is a robot; his name is Noct. His name is ROBO-NOCT!

File diff suppressed because it is too large
+ 6692 - 6692
animu.xml


+ 0 - 4
ormconfig.json

@@ -1,8 +1,4 @@
 {
-   "type": "sqlite",
-   "database": "database.sqlite",
-   "synchronize": true,
-   "logging": false,
    "entities": [
       "build/entity/**/*.js"
    ],

+ 6 - 5
package.json

@@ -2,11 +2,11 @@
    "name": "noctbot",
    "version": "1.0.0",
    "description": "He is a Robot, his name is Noct. He is the Robo-Noct!",
-   "main": "main.js",
+   "main": "build/main.js",
    "scripts": {
-      "test": "echo \"Error: no test specified\" && exit 1",
+      "watch": "rimraf build && tsc -w",
       "build": "rimraf build && tsc",
-      "start": "rimraf build && ts-node src/main.ts",
+      "start": "node ./build/main.js",
       "typeorm": "rimraf build && ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js"
    },
    "repository": {
@@ -38,13 +38,13 @@
       "lowdb": "^1.0.0",
       "node-html-parser": "^1.1.16",
       "opencv4nodejs": "^4.9.0",
+      "pg": "^7.11.0",
       "reflect-metadata": "^0.1.10",
       "request": "^2.88.0",
       "request-promise-native": "^1.0.5",
       "rimraf": "^2.6.3",
       "rss-parser": "^3.4.3",
       "sha1": "^1.1.1",
-      "sqlite3": "^4.0.3",
       "translate-google": "^1.3.5",
       "tsconfig-paths": "^3.8.0",
       "turndown": "^5.0.1",
@@ -53,9 +53,10 @@
       "uws": "^99.0.0"
    },
    "devDependencies": {
+      "@types/node": "^8.0.29",
       "eslint": "^5.16.0",
+      "nodemon": "^1.19.1",
       "ts-node": "3.3.0",
-      "@types/node": "^8.0.29",
       "typescript": "3.3.3333"
    }
 }

+ 7 - 7
src/client.ts

@@ -1,7 +1,7 @@
-import { Client } from "discord.js";
-import { XenforoClient } from "./xenforo";
-
-
-export const client = new Client();
-export const forumClient = new XenforoClient("https://custommaid3d2.com/api", process.env.FORUM_API_KEY);
-
+import { Client } from "discord.js";
+import { XenforoClient } from "./xenforo";
+
+
+export const client = new Client();
+export const forumClient = new XenforoClient("https://custommaid3d2.com/api", process.env.FORUM_API_KEY);
+

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

@@ -1,23 +1,23 @@
-
-export interface INewsItem {
-    feedId: string,
-    newsId: number,
-    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;
+
+export interface INewsItem {
+    feedId: string,
+    newsId: number,
+    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;
 }

+ 80 - 80
src/commands/aggregators/com3d2_updates.ts

@@ -1,81 +1,81 @@
-import * as html from "node-html-parser";
-import request from "request-promise-native";
-import { Response } from "request";
-import { IAggregator, INewsItem } from "./aggregator";
-import { getRepository } from "typeorm";
-import { AggroNewsItem } from "../../entity/AggroNewsItem";
-
-const updatePage = "http://com3d2.jp/update/";
-const changeLogPattern = /\[\s*([^\s\]]+)\s*\]\s*((・.*)\s+)+/gim;
-const FEED_NAME = "com3d2-jp-updates";
-
-function getVersionNumber(verStr: string) {
-    let verPart = verStr.replace(/[\.\s]/g, "");
-    if(verPart.length < 4)
-        verPart += "0";
-    return +verPart;
-}
-
-async function aggregate() {
-    let repo = getRepository(AggroNewsItem);
-    
-    let lastPost = await repo.findOne({
-        select: [ "newsId" ],
-        where: { feedName: FEED_NAME },
-        order: { newsId: "DESC" }
-    });
-
-    if(!lastPost)
-        lastPost = repo.create({
-            newsId: 0
-        });
-    
-    try {
-        let mainPageRes = await request(updatePage, {resolveWithFullResponse: true}) as Response;
-
-        if(mainPageRes.statusCode != 200)
-            return;
-        
-        let rootNode = html.parse(mainPageRes.body, {
-                pre: true,
-                script: false,
-                style: false
-        });
-
-        if(!(rootNode instanceof html.HTMLElement))
-            return;
-
-        let readme = rootNode.querySelector("div.readme");
-
-        if(!readme) {
-            console.log("[COM3D2 JP UPDATE] Failed to find listing!");
-        }
-
-        let latestVersionChangelog = changeLogPattern.exec(readme.text);
-
-        if(!latestVersionChangelog)
-            return [];
-
-        let version = getVersionNumber(latestVersionChangelog[1]);
-        let text = latestVersionChangelog[0];
-
-        if(version <= lastPost.newsId)
-            return [];
-        
-        return [{
-            feedId: FEED_NAME,
-            newsId: version,
-            link: updatePage,
-            title: latestVersionChangelog[1],
-            author: "COM3D2 UPDATE",
-            contents: text,
-            embedColor: 0xcccccc
-        }] as INewsItem[];
-    } catch(err) {
-        return [];
-    }
-}
-
-export default {
-    aggregate: aggregate
+import * as html from "node-html-parser";
+import request from "request-promise-native";
+import { Response } from "request";
+import { IAggregator, INewsItem } from "./aggregator";
+import { getRepository } from "typeorm";
+import { AggroNewsItem } from "../../entity/AggroNewsItem";
+
+const updatePage = "http://com3d2.jp/update/";
+const changeLogPattern = /\[\s*([^\s\]]+)\s*\]\s*((・.*)\s+)+/gim;
+const FEED_NAME = "com3d2-jp-updates";
+
+function getVersionNumber(verStr: string) {
+    let verPart = verStr.replace(/[\.\s]/g, "");
+    if(verPart.length < 4)
+        verPart += "0";
+    return +verPart;
+}
+
+async function aggregate() {
+    let repo = getRepository(AggroNewsItem);
+    
+    let lastPost = await repo.findOne({
+        select: [ "newsId" ],
+        where: { feedName: FEED_NAME },
+        order: { newsId: "DESC" }
+    });
+
+    if(!lastPost)
+        lastPost = repo.create({
+            newsId: 0
+        });
+    
+    try {
+        let mainPageRes = await request(updatePage, {resolveWithFullResponse: true}) as Response;
+
+        if(mainPageRes.statusCode != 200)
+            return;
+        
+        let rootNode = html.parse(mainPageRes.body, {
+                pre: true,
+                script: false,
+                style: false
+        });
+
+        if(!(rootNode instanceof html.HTMLElement))
+            return;
+
+        let readme = rootNode.querySelector("div.readme");
+
+        if(!readme) {
+            console.log("[COM3D2 JP UPDATE] Failed to find listing!");
+        }
+
+        let latestVersionChangelog = changeLogPattern.exec(readme.text);
+
+        if(!latestVersionChangelog)
+            return [];
+
+        let version = getVersionNumber(latestVersionChangelog[1]);
+        let text = latestVersionChangelog[0];
+
+        if(version <= lastPost.newsId)
+            return [];
+        
+        return [{
+            feedId: FEED_NAME,
+            newsId: version,
+            link: updatePage,
+            title: latestVersionChangelog[1],
+            author: "COM3D2 UPDATE",
+            contents: text,
+            embedColor: 0xcccccc
+        }] as INewsItem[];
+    } catch(err) {
+        return [];
+    }
+}
+
+export default {
+    aggregate: aggregate
 } as IAggregator;

+ 95 - 95
src/commands/aggregators/com3d2_world.ts

@@ -1,96 +1,96 @@
-import * as html from "node-html-parser";
-import request from "request-promise-native";
-import { Response } from "request";
-import { INewsItem, IAggregator } from "./aggregator";
-import { getRepository } from "typeorm";
-import { AggroNewsItem } from "../../entity/AggroNewsItem";
-
-const kissDiaryRoot = "https://com3d2.world/r18/notices.php";
-const FEED_NAME = "com3d2-world-notices";
-
-async function aggregate() {
-    let repo = getRepository(AggroNewsItem);
-    
-    let lastPost = await repo.findOne({
-        select: [ "newsId" ],
-        where: { feedName: FEED_NAME },
-        order: { newsId: "DESC" }
-    });
-
-    if(!lastPost)
-        lastPost = repo.create({
-            newsId: 0
-        });
-
-    try {
-        let mainPageRes = await request(kissDiaryRoot, {resolveWithFullResponse: true}) as Response;
-        
-        if(mainPageRes.statusCode != 200)
-            return [];
-
-        let rootNode = html.parse(mainPageRes.body, {
-                pre: true,
-                script: false,
-                style: false
-        });
-
-        if(!(rootNode instanceof html.HTMLElement))
-            return;
-
-        let diaryEntries = rootNode.querySelectorAll("div.frame a");
-
-        if(!diaryEntries) {
-            console.log("[COM3D2 WORLD BLOG] Failed to find listing!");
-        }
-
-        let result : INewsItem[] = [];
-        let latestEntry = lastPost.newsId;
-
-        for(let a of diaryEntries) {
-            if(!a.rawAttributes.id)
-                continue;
-            
-            let id = +a.rawAttributes.id;
-
-            if(id <= lastPost.newsId)
-                continue;
-
-            if(id > latestEntry)
-                latestEntry = id;
-
-            let diaryLink = `${kissDiaryRoot}?no=${id}`;
-            let res = await request(diaryLink, {resolveWithFullResponse: true}) as Response;
-            if(res.statusCode != 200)
-                continue;
-
-            let node = html.parse(res.body, {
-                pre: true,
-                script: false,
-                style: false
-            });
-
-            if(!(node instanceof html.HTMLElement))
-                continue;
-
-            let title = node.querySelector("div.frame div.notice_title th");
-            let contents = node.querySelectorAll("div.frame div")[1];
-
-            result.push({
-                newsId: id,
-                feedId: FEED_NAME,
-                link: diaryLink,
-                title: title.text,
-                author: "com3d2.world",
-                contents: contents.outerHTML,
-                embedColor: 0xa39869
-            });
-        }
-        return result;
-    } catch(err) {
-        return [];
-    }
-}
-
-export default {
-    aggregate: aggregate
+import * as html from "node-html-parser";
+import request from "request-promise-native";
+import { Response } from "request";
+import { INewsItem, IAggregator } from "./aggregator";
+import { getRepository } from "typeorm";
+import { AggroNewsItem } from "../../entity/AggroNewsItem";
+
+const kissDiaryRoot = "https://com3d2.world/r18/notices.php";
+const FEED_NAME = "com3d2-world-notices";
+
+async function aggregate() {
+    let repo = getRepository(AggroNewsItem);
+    
+    let lastPost = await repo.findOne({
+        select: [ "newsId" ],
+        where: { feedName: FEED_NAME },
+        order: { newsId: "DESC" }
+    });
+
+    if(!lastPost)
+        lastPost = repo.create({
+            newsId: 0
+        });
+
+    try {
+        let mainPageRes = await request(kissDiaryRoot, {resolveWithFullResponse: true}) as Response;
+        
+        if(mainPageRes.statusCode != 200)
+            return [];
+
+        let rootNode = html.parse(mainPageRes.body, {
+                pre: true,
+                script: false,
+                style: false
+        });
+
+        if(!(rootNode instanceof html.HTMLElement))
+            return;
+
+        let diaryEntries = rootNode.querySelectorAll("div.frame a");
+
+        if(!diaryEntries) {
+            console.log("[COM3D2 WORLD BLOG] Failed to find listing!");
+        }
+
+        let result : INewsItem[] = [];
+        let latestEntry = lastPost.newsId;
+
+        for(let a of diaryEntries) {
+            if(!a.rawAttributes.id)
+                continue;
+            
+            let id = +a.rawAttributes.id;
+
+            if(id <= lastPost.newsId)
+                continue;
+
+            if(id > latestEntry)
+                latestEntry = id;
+
+            let diaryLink = `${kissDiaryRoot}?no=${id}`;
+            let res = await request(diaryLink, {resolveWithFullResponse: true}) as Response;
+            if(res.statusCode != 200)
+                continue;
+
+            let node = html.parse(res.body, {
+                pre: true,
+                script: false,
+                style: false
+            });
+
+            if(!(node instanceof html.HTMLElement))
+                continue;
+
+            let title = node.querySelector("div.frame div.notice_title th");
+            let contents = node.querySelectorAll("div.frame div")[1];
+
+            result.push({
+                newsId: id,
+                feedId: FEED_NAME,
+                link: diaryLink,
+                title: title.text,
+                author: "com3d2.world",
+                contents: contents.outerHTML,
+                embedColor: 0xa39869
+            });
+        }
+        return result;
+    } catch(err) {
+        return [];
+    }
+}
+
+export default {
+    aggregate: aggregate
 } as IAggregator;

+ 104 - 104
src/commands/aggregators/kiss_diary.ts

@@ -1,105 +1,105 @@
-import * as html from "node-html-parser";
-import request from "request-promise-native";
-import { Response } from "request";
-import { INewsItem, IAggregator } from "./aggregator";
-import { getRepository } from "typeorm";
-import { AggroNewsItem } from "../../entity/AggroNewsItem";
-
-const urlPattern = /diary\.php\?no=(\d+)/i;
-const kissDiaryRoot = "http://www.kisskiss.tv/kiss";
-const FEED_NAME = "kisskisstv-diary";
-
-async function aggregate() {
-    let repo = getRepository(AggroNewsItem);
-
-    let lastPost = await repo.findOne({
-        select: [ "newsId" ],
-        where: { feedName: FEED_NAME },
-        order: { newsId: "DESC" }
-    });
-
-    if(!lastPost)
-        lastPost = repo.create({
-            newsId: 0
-        });
-    
-    try {
-        let mainPageRes = await request(`${kissDiaryRoot}/diary.php`, {resolveWithFullResponse: true}) as Response;
-        
-        if(mainPageRes.statusCode != 200)
-            return [];
-
-        let rootNode = html.parse(mainPageRes.body, {
-                pre: true,
-                script: false,
-                style: false
-        });
-
-        if(!(rootNode instanceof html.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 : INewsItem[] = [];
-        let latestEntry = lastPost.newsId;
-
-        for(let a of diaryEntries) {
-            let matches = urlPattern.exec(a.rawAttributes.href);
-            if(!matches)
-                continue;
-            
-            let id = +matches[1];
-
-            if(id <= lastPost.newsId)
-                continue;
-
-            if(id > latestEntry)
-                latestEntry = id;
-
-            let diaryLink = `${kissDiaryRoot}/${a.rawAttributes.href}`;
-            let res = await request(diaryLink, {resolveWithFullResponse: true}) as Response;
-            if(res.statusCode != 200)
-                continue;
-
-            let node = html.parse(res.body, {
-                pre: true,
-                script: false,
-                style: false
-            });
-
-            if(!(node instanceof html.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) {
-                let child = contents.childNodes[0];
-                if(child instanceof html.HTMLElement)
-                    child.removeChild(bottomFrame);
-            }
-
-            result.push({
-                newsId: id,
-                feedId: FEED_NAME,
-                link: diaryLink,
-                title: title.text,
-                author: "KISS BLOG",
-                contents: contents.innerHTML,
-                embedColor: 0xf4c100
-            });
-        }
-
-        return result;
-    } catch(err) {
-        return [];
-    }
-}
-
-export default {
-    aggregate: aggregate
+import * as html from "node-html-parser";
+import request from "request-promise-native";
+import { Response } from "request";
+import { INewsItem, IAggregator } from "./aggregator";
+import { getRepository } from "typeorm";
+import { AggroNewsItem } from "../../entity/AggroNewsItem";
+
+const urlPattern = /diary\.php\?no=(\d+)/i;
+const kissDiaryRoot = "http://www.kisskiss.tv/kiss";
+const FEED_NAME = "kisskisstv-diary";
+
+async function aggregate() {
+    let repo = getRepository(AggroNewsItem);
+
+    let lastPost = await repo.findOne({
+        select: [ "newsId" ],
+        where: { feedName: FEED_NAME },
+        order: { newsId: "DESC" }
+    });
+
+    if(!lastPost)
+        lastPost = repo.create({
+            newsId: 0
+        });
+    
+    try {
+        let mainPageRes = await request(`${kissDiaryRoot}/diary.php`, {resolveWithFullResponse: true}) as Response;
+        
+        if(mainPageRes.statusCode != 200)
+            return [];
+
+        let rootNode = html.parse(mainPageRes.body, {
+                pre: true,
+                script: false,
+                style: false
+        });
+
+        if(!(rootNode instanceof html.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 : INewsItem[] = [];
+        let latestEntry = lastPost.newsId;
+
+        for(let a of diaryEntries) {
+            let matches = urlPattern.exec(a.rawAttributes.href);
+            if(!matches)
+                continue;
+            
+            let id = +matches[1];
+
+            if(id <= lastPost.newsId)
+                continue;
+
+            if(id > latestEntry)
+                latestEntry = id;
+
+            let diaryLink = `${kissDiaryRoot}/${a.rawAttributes.href}`;
+            let res = await request(diaryLink, {resolveWithFullResponse: true}) as Response;
+            if(res.statusCode != 200)
+                continue;
+
+            let node = html.parse(res.body, {
+                pre: true,
+                script: false,
+                style: false
+            });
+
+            if(!(node instanceof html.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) {
+                let child = contents.childNodes[0];
+                if(child instanceof html.HTMLElement)
+                    child.removeChild(bottomFrame);
+            }
+
+            result.push({
+                newsId: id,
+                feedId: FEED_NAME,
+                link: diaryLink,
+                title: title.text,
+                author: "KISS BLOG",
+                contents: contents.innerHTML,
+                embedColor: 0xf4c100
+            });
+        }
+
+        return result;
+    } catch(err) {
+        return [];
+    }
+}
+
+export default {
+    aggregate: aggregate
 } as IAggregator;

+ 26 - 26
src/commands/command.ts

@@ -1,27 +1,27 @@
-import { Message } from "discord.js";
-
-export type BotEvent = (actionsDone: boolean, ...params: any[]) => boolean | Promise<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 | Promise<boolean>;
-    onIndirectMention?(actionsDone: boolean, m: Message) : boolean | Promise<boolean>;
-    onDirectMention?(actionsDone: boolean, m: Message, content: string) : boolean | Promise<boolean>;
-    postMessage?(actionsDone: boolean, m: Message) : boolean | Promise<boolean>;
-    onStart?(): void | Promise<void>;
+import { Message } from "discord.js";
+
+export type BotEvent = (actionsDone: boolean, ...params: any[]) => boolean | Promise<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 | Promise<boolean>;
+    onIndirectMention?(actionsDone: boolean, m: Message) : boolean | Promise<boolean>;
+    onDirectMention?(actionsDone: boolean, m: Message, content: string) : boolean | Promise<boolean>;
+    postMessage?(actionsDone: boolean, m: Message) : boolean | Promise<boolean>;
+    onStart?(): void | Promise<void>;
 };

+ 34 - 34
src/commands/dead_chat.ts

@@ -1,35 +1,35 @@
-import { ICommand } from "./command";
-import { getRepository } from "typeorm";
-import { DeadChatReply } from "../entity/DeadChatReply";
-
-const triggers = [
-    "dead server",
-    "dead chat",
-    "ded chat",
-    "ded server"
-];
-
-export default {
-    onMessage: async (actionsDone, msg, content) => {
-        if (actionsDone)
-            return false;
-    
-        let lowerContent = content.toLowerCase();
-        
-        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)
-            return false;
-
-        msg.channel.send(reply[0]);
-        return true;
-    }
+import { ICommand } from "./command";
+import { getRepository } from "typeorm";
+import { DeadChatReply } from "../entity/DeadChatReply";
+
+const triggers = [
+    "dead server",
+    "dead chat",
+    "ded chat",
+    "ded server"
+];
+
+export default {
+    onMessage: async (actionsDone, msg, content) => {
+        if (actionsDone)
+            return false;
+    
+        let lowerContent = content.toLowerCase();
+        
+        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)
+            return false;
+
+        msg.channel.send(reply[0]);
+        return true;
+    }
 } as ICommand;

+ 292 - 292
src/commands/facemorph.ts

@@ -1,293 +1,293 @@
-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 { getRepository } from "typeorm";
-import { FaceCaptionMessage, FaceCaptionType } from "../entity/FaceCaptionMessage";
-import { KnownChannel } from "../entity/KnownChannel";
-
-const EMOTE_GUILD = "505333548694241281";
-
-const animeCascade = new cv.CascadeClassifier(path.resolve(process.cwd(), "animu.xml"));
-const faceCascade = new cv.CascadeClassifier(cv.HAAR_FRONTALFACE_ALT2);
-
-const CAPTION_IMG_SIZE = 300;
-const CAPTION_PROBABILITY = 0.33;
-
-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 &&
-        (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);
-    }
-
-    return jimpImage;
-}
-
-const CAPTION_OFFSET = 5;
-
-async function getRandomCaption(type: FaceCaptionType) {
-    let repo = getRepository(FaceCaptionMessage);
-
-    let caption = await repo.query(`select message
-                                    from face_caption_message
-                                    where type = ?
-                                    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;
-    }
-
-    let faces = [...normalFaces.objects, ...animeFaces.objects];
-
-    let normalCount = normalFaces.objects.length;
-    let animeCount = animeFaces.objects.length;
-
-    for (let i = 0; i < normalCount; i++) {
-        const rNormal = faces[i];
-
-        if (animeCount == 0) break;
-
-        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 (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);
-    }
-
-    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]
-    });
-}
-
-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();
-
-    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));
-
-    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) => {
-        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 => isValidImage(v.filename));
-
-        if (imageAttachment) {
-
-            let repo = getRepository(KnownChannel);
-
-            let knownChannel = await repo.findOne({
-                where: { channelId: msg.channel.id },
-                select: [ "faceMorphProbability" ]
-            });
-
-            if(!knownChannel || Math.random() > knownChannel.faceMorphProbability)
-                return false;
-            
-            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)
-        }]
+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 { getRepository } from "typeorm";
+import { FaceCaptionMessage, FaceCaptionType } from "../entity/FaceCaptionMessage";
+import { KnownChannel } from "../entity/KnownChannel";
+
+const EMOTE_GUILD = "505333548694241281";
+
+const animeCascade = new cv.CascadeClassifier(path.resolve(process.cwd(), "animu.xml"));
+const faceCascade = new cv.CascadeClassifier(cv.HAAR_FRONTALFACE_ALT2);
+
+const CAPTION_IMG_SIZE = 300;
+const CAPTION_PROBABILITY = 0.33;
+
+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 &&
+        (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);
+    }
+
+    return jimpImage;
+}
+
+const CAPTION_OFFSET = 5;
+
+async function getRandomCaption(type: FaceCaptionType) {
+    let repo = getRepository(FaceCaptionMessage);
+
+    let caption = await repo.query(`select message
+                                    from face_caption_message
+                                    where type = ?
+                                    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;
+    }
+
+    let faces = [...normalFaces.objects, ...animeFaces.objects];
+
+    let normalCount = normalFaces.objects.length;
+    let animeCount = animeFaces.objects.length;
+
+    for (let i = 0; i < normalCount; i++) {
+        const rNormal = faces[i];
+
+        if (animeCount == 0) break;
+
+        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 (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);
+    }
+
+    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]
+    });
+}
+
+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();
+
+    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));
+
+    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) => {
+        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 => isValidImage(v.filename));
+
+        if (imageAttachment) {
+
+            let repo = getRepository(KnownChannel);
+
+            let knownChannel = await repo.findOne({
+                where: { channelId: msg.channel.id },
+                select: [ "faceMorphProbability" ]
+            });
+
+            if(!knownChannel || Math.random() > knownChannel.faceMorphProbability)
+                return false;
+            
+            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;

+ 357 - 357
src/commands/forums_news_checker.ts

@@ -1,358 +1,358 @@
-import TurndownService, { Options } from "turndown";
-import RSSParser from "rss-parser";
-import interval from "interval-promise";
-import { client, forumClient } 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";
-import { PostedForumNewsItem } from "../entity/PostedForumsNewsItem";
-import { KnownChannel } from "../entity/KnownChannel";
-import { PostVerifyMessage } from "../entity/PostVerifyMessage";
-import bbobHTML from '@bbob/html'
-import presetHTML5 from '@bbob/preset-html5'
-
-const PREVIEW_CHAR_LIMIT = 300;
-const NEWS_POST_VERIFY_CHANNEL = "newsPostVerify";
-
-let verifyChannelId: string = null;
-const reactionCollectors: Dict<ReactionCollector> = {};
-const verifyMessageIdToPost: Dict<PostedForumNewsItem> = {};
-const NEWS_FEED_CHANNEL = "newsFeed";
-
-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 parser = new RSSParser();
-const RSS_UPDATE_INTERVAL_MIN = 5;
-
-function getThreadId(url: string) {
-    let result = url.substring(url.lastIndexOf(".") + 1);
-    if (result.endsWith("/"))
-        result = result.substring(0, result.length - 1);
-    return result;
-}
-
-const NEWS_FORUM_ID = 49;
-
-const FEEDS = [
-    {
-        url: "http://custommaid3d2.com/index.php?forums/news.49/index.rss",
-        contentElement: "content:encoded"
-    }
-];
-
-function bbCodeToMarkdown(bbCode: string) {
-    return turndown.turndown(bbobHTML(bbCode, presetHTML5())).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) {
-        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: `https://custommaid3d2.com/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.INGORE_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;
-
-                itemObj = postItem;
-                itemObj.verifyMessage.isNew = false;
-                itemObj.hash = newHash;
-            }
-            else
-                continue;
-        }
-
-        if (!shouldVerify())
-            await sendNews(itemObj);
-        else
-            await addVerifyMessage(itemObj);
-    }
-
-    // for(let feedEntry of FEEDS) {
-    //     let feed = await parser.parseURL(feedEntry.url);
-    //     if(feed.items.length == 0)
-    //         continue;
-    //     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 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.body, {
-    //                     pre: true,
-    //                     script: false,
-    //                     style: false
-    //                 });
-
-    //                 if(!(rootNode instanceof html.HTMLElement))
-    //                     continue;
-
-    //                 let opDiv = rootNode.querySelector("div.bbWrapper");
-
-    //                 if (!opDiv) {
-    //                     console.log(`No posts found for ${itemID}!`);
-    //                     continue;
-    //                 }
-
-    //                 contents = markdownify(opDiv.outerHTML, item.link);
-    //             } catch(err){
-    //                 console.log(`Failed to get html for item ${itemID} because ${err}`);
-    //                 continue;
-    //             }
-
-
-    //         }
-    //     }
-    // }
-}
-
-async function initPendingReactors() {
-    let verifyChannel = client.channels.get(verifyChannelId);
-
-    let repo = getRepository(PostedForumNewsItem);
-    let verifyMessageRepo = getRepository(PostVerifyMessage);
-
-    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);
-
-        if (!m) {
-            await verifyMessageRepo.delete(msg.verifyMessage);
-            await repo.update({ id: m.id }, { verifyMessage: null })
-            continue;
-        }
-
-        let collector = m.createReactionCollector(isVerifyReaction, { maxEmojis: 1 });
-        collector.on("collect", collectReaction);
-        reactionCollectors[m.id] = collector;
-        verifyMessageIdToPost[m.id] = msg;
-    }
-}
-
-async function addVerifyMessage(item: PostedForumNewsItem) {
-    let verifyChannel = client.channels.get(verifyChannelId) as TextChannel;
-    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();
-    }
-
-    let newMessage = await verifyChannel.send(toVerifyString(item.id, item.verifyMessage)) as Message;
-    item.verifyMessage.messageId = newMessage.id;
-
-    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;
-
-    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);
-
-    let m = reaction.message;
-    collector.stop();
-    delete reactionCollectors[m.id];
-    let post = verifyMessageIdToPost[m.id];
-
-    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);
-}
-
-async function sendNews(item: PostedForumNewsItem) {
-    let channelRepo = getRepository(KnownChannel);
-    let newsPostRepo = getRepository(PostedForumNewsItem);
-
-    let outChannel = await channelRepo.findOne({
-        where: { channelType: NEWS_FEED_CHANNEL }
-    });
-
-    let sentMessage = await postNewsItem(outChannel.channelId, item);
-
-    item.postedMessageId = sentMessage.id;
-    item.verifyMessage = null;
-
-    await newsPostRepo.save(item);
-}
-
-function 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))
-            return null;
-        return await channel.fetchMessage(messageId);
-    } catch (error) {
-        return null;
-    }
-}
-
-function shouldVerify() {
-    return verifyChannelId != null;
-}
-
-async function postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
-    let newsMessage = toNewsString(item.verifyMessage);
-    let ch = client.channels.get(channel);
-
-    if (!(ch instanceof TextChannel))
-        return null;
-
-    if (item.postedMessageId) {
-        let message = await 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;
-}
-
-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}**
-Posted by ${item.author}
-${item.link}
-
-${item.text}`;
-}
-
-function toVerifyString(postId: string, item: PostVerifyMessage) {
-    return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}]
-Post ID: **${postId}**
-    
-${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);
-
-                if (!editMsg) {
-                    msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);
-                    return;
-                }
-
-                post.verifyMessage.text = newContents;
-
-                await verifyRepo.save(post.verifyMessage);
-                await editMsg.edit(toVerifyString(post.id, post.verifyMessage));
-                await msg.delete();
-            }
-        }
-    ],
-    onStart: async () => {
-
-        let repo = getRepository(KnownChannel);
-
-        let verifyChannel = await repo.findOne({
-            channelType: NEWS_POST_VERIFY_CHANNEL
-        });
-
-        if (verifyChannel)
-            verifyChannelId = verifyChannel.channelId;
-
-        await initPendingReactors();
-        interval(checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
-    }
+import TurndownService, { Options } from "turndown";
+import RSSParser from "rss-parser";
+import interval from "interval-promise";
+import { client, forumClient } 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";
+import { PostedForumNewsItem } from "../entity/PostedForumsNewsItem";
+import { KnownChannel } from "../entity/KnownChannel";
+import { PostVerifyMessage } from "../entity/PostVerifyMessage";
+import bbobHTML from '@bbob/html'
+import presetHTML5 from '@bbob/preset-html5'
+
+const PREVIEW_CHAR_LIMIT = 300;
+const NEWS_POST_VERIFY_CHANNEL = "newsPostVerify";
+
+let verifyChannelId: string = null;
+const reactionCollectors: Dict<ReactionCollector> = {};
+const verifyMessageIdToPost: Dict<PostedForumNewsItem> = {};
+const NEWS_FEED_CHANNEL = "newsFeed";
+
+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 parser = new RSSParser();
+const RSS_UPDATE_INTERVAL_MIN = 5;
+
+function getThreadId(url: string) {
+    let result = url.substring(url.lastIndexOf(".") + 1);
+    if (result.endsWith("/"))
+        result = result.substring(0, result.length - 1);
+    return result;
+}
+
+const NEWS_FORUM_ID = 49;
+
+const FEEDS = [
+    {
+        url: "http://custommaid3d2.com/index.php?forums/news.49/index.rss",
+        contentElement: "content:encoded"
+    }
+];
+
+function bbCodeToMarkdown(bbCode: string) {
+    return turndown.turndown(bbobHTML(bbCode, presetHTML5())).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) {
+        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: `https://custommaid3d2.com/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.INGORE_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;
+
+                itemObj = postItem;
+                itemObj.verifyMessage.isNew = false;
+                itemObj.hash = newHash;
+            }
+            else
+                continue;
+        }
+
+        if (!shouldVerify())
+            await sendNews(itemObj);
+        else
+            await addVerifyMessage(itemObj);
+    }
+
+    // for(let feedEntry of FEEDS) {
+    //     let feed = await parser.parseURL(feedEntry.url);
+    //     if(feed.items.length == 0)
+    //         continue;
+    //     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 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.body, {
+    //                     pre: true,
+    //                     script: false,
+    //                     style: false
+    //                 });
+
+    //                 if(!(rootNode instanceof html.HTMLElement))
+    //                     continue;
+
+    //                 let opDiv = rootNode.querySelector("div.bbWrapper");
+
+    //                 if (!opDiv) {
+    //                     console.log(`No posts found for ${itemID}!`);
+    //                     continue;
+    //                 }
+
+    //                 contents = markdownify(opDiv.outerHTML, item.link);
+    //             } catch(err){
+    //                 console.log(`Failed to get html for item ${itemID} because ${err}`);
+    //                 continue;
+    //             }
+
+
+    //         }
+    //     }
+    // }
+}
+
+async function initPendingReactors() {
+    let verifyChannel = client.channels.get(verifyChannelId);
+
+    let repo = getRepository(PostedForumNewsItem);
+    let verifyMessageRepo = getRepository(PostVerifyMessage);
+
+    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);
+
+        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;
+    }
+}
+
+async function addVerifyMessage(item: PostedForumNewsItem) {
+    let verifyChannel = client.channels.get(verifyChannelId) as TextChannel;
+    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();
+    }
+
+    let newMessage = await verifyChannel.send(toVerifyString(item.id, item.verifyMessage)) as Message;
+    item.verifyMessage.messageId = newMessage.id;
+
+    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;
+
+    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);
+
+    let m = reaction.message;
+    collector.stop();
+    delete reactionCollectors[m.id];
+    let post = verifyMessageIdToPost[m.id];
+
+    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);
+}
+
+async function sendNews(item: PostedForumNewsItem) {
+    let channelRepo = getRepository(KnownChannel);
+    let newsPostRepo = getRepository(PostedForumNewsItem);
+
+    let outChannel = await channelRepo.findOne({
+        where: { channelType: NEWS_FEED_CHANNEL }
+    });
+
+    let sentMessage = await postNewsItem(outChannel.channelId, item);
+
+    item.postedMessageId = sentMessage.id;
+    item.verifyMessage = null;
+
+    await newsPostRepo.save(item);
+}
+
+function 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))
+            return null;
+        return await channel.fetchMessage(messageId);
+    } catch (error) {
+        return null;
+    }
+}
+
+function shouldVerify() {
+    return verifyChannelId != null;
+}
+
+async function postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
+    let newsMessage = toNewsString(item.verifyMessage);
+    let ch = client.channels.get(channel);
+
+    if (!(ch instanceof TextChannel))
+        return null;
+
+    if (item.postedMessageId) {
+        let message = await 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;
+}
+
+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}**
+Posted by ${item.author}
+${item.link}
+
+${item.text}`;
+}
+
+function toVerifyString(postId: string, item: PostVerifyMessage) {
+    return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}]
+Post ID: **${postId}**
+    
+${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);
+
+                if (!editMsg) {
+                    msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);
+                    return;
+                }
+
+                post.verifyMessage.text = newContents;
+
+                await verifyRepo.save(post.verifyMessage);
+                await editMsg.edit(toVerifyString(post.id, post.verifyMessage));
+                await msg.delete();
+            }
+        }
+    ],
+    onStart: async () => {
+
+        let repo = getRepository(KnownChannel);
+
+        let verifyChannel = await repo.findOne({
+            channelType: NEWS_POST_VERIFY_CHANNEL
+        });
+
+        if (verifyChannel)
+            verifyChannelId = verifyChannel.channelId;
+
+        await initPendingReactors();
+        interval(checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
+    }
 } as ICommand;

+ 209 - 209
src/commands/guide.ts

@@ -1,210 +1,210 @@
-import { isAuthorisedAsync } from "../util";
-import { ICommand } from "./command";
-import { Message } from "discord.js";
-import { getRepository } from "typeorm";
-import { Guide, GuideType, GuideKeyword } from "../entity/Guide";
-
-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(s => "?").join(",")}))
-                    group by gk.guideId
-                    order by gc desc) as gks
-        on gks.guideId = guide.id
-        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();
-
-    let guides = allGuides
-            .reduce((p, c) => `${p}\n${c.displayName} -- ${c.keywords.map(c => c.keyword).join(" ")}`, "\n");
-    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: async (actionsDone, msg, content) => {
-        if (actionsDone)
-            return false;
-    
-        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);
-        
-        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(" ")}\`)!`
-                );
-            }
-        },
-        {
-            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?`);
-            }
-        },
-        { 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."
-        }
-    }
+import { isAuthorisedAsync } from "../util";
+import { ICommand } from "./command";
+import { Message } from "discord.js";
+import { getRepository } from "typeorm";
+import { Guide, GuideType, GuideKeyword } from "../entity/Guide";
+
+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();
+
+    let guides = allGuides
+            .reduce((p, c) => `${p}\n${c.displayName} -- ${c.keywords.map(c => c.keyword).join(" ")}`, "\n");
+    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: async (actionsDone, msg, content) => {
+        if (actionsDone)
+            return false;
+    
+        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);
+        
+        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(" ")}\`)!`
+                );
+            }
+        },
+        {
+            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?`);
+            }
+        },
+        { 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."
+        }
+    }
 } as ICommand;

+ 31 - 31
src/commands/help.ts

@@ -1,32 +1,32 @@
-import { isAuthorisedAsync, documentation } from "../util";
-import { ICommand } from "./command";
-
-export default {
-    commands: [{
-        pattern: "help",
-        action: async msg => {
-            let isAuthed = await isAuthorisedAsync(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);
-        }
-    }]
+import { isAuthorisedAsync, documentation } from "../util";
+import { ICommand } from "./command";
+
+export default {
+    commands: [{
+        pattern: "help",
+        action: async msg => {
+            let isAuthed = await isAuthorisedAsync(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;

+ 24 - 24
src/commands/inspire.ts

@@ -1,25 +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"
-        }
-    }
+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;

+ 170 - 168
src/commands/news_aggregator.ts

@@ -1,169 +1,171 @@
-import TurndownService, { Options } from "turndown";
-import interval from "interval-promise";
-import { client } from "../client";
-import sha1 from "sha1";
-import * as path from "path";
-import * as fs from "fs";
-
-import { IAggregator, NewsPostItem, INewsPostData } from "./aggregators/aggregator";
-import { ICommand } from "./command";
-import { RichEmbed, TextChannel, Message, Channel } from "discord.js";
-import { getRepository } from "typeorm";
-import { KnownChannel } from "../entity/KnownChannel";
-import { AggroNewsItem } from "../entity/AggroNewsItem";
-
-const UPDATE_INTERVAL = 5;
-const MAX_PREVIEW_LENGTH = 300; 
-
-const aggregators : IAggregator[] = [];
-let aggregateChannelID : string = null;
-const AGGREGATOR_MANAGER_CHANNEL = "aggregatorManager";
-
-// 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, "")*/;
-}
-
-async function checkFeeds() {
-    console.log(`Aggregating feeds on ${new Date().toISOString()}`);
-
-    let aggregatorJobs = [];
-
-    for(let aggregator of 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;
-            itemObj.contents = markdownify(item.contents);
-            itemObj.hash = sha1(itemObj.contents);
-
-            await 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);
-
-    let newsItem = await repo.findOne({
-        where: { feedName: item.feedId, newsId: item.newsId }
-    });
-
-    if(newsItem) {
-        // No changes, skip
-        if(newsItem.hash == item.hash)
-            return;
-        else
-            await deleteCacheMessage(newsItem.editMessageId);
-    } else {
-        newsItem = repo.create({
-            newsId: item.newsId,
-            feedName: item.feedId,
-            hash: item.hash
-        });
-    }
-
-    let ch = client.channels.get(aggregateChannelID);
-    
-    if(!(ch instanceof TextChannel))
-        return;
-
-    let msg = await ch.send(new RichEmbed({
-        title: item.title,
-        url: item.link,
-        color: item.embedColor,
-        timestamp: new Date(),
-        description: clipText(item.contents),
-        author: {
-            name: item.author
-        },
-        footer: {
-            text: "NoctBot News Aggregator"
-        }
-    })) as Message;
-
-    newsItem.editMessageId = msg.id;
-
-    await repo.save(newsItem);
-}
-
-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 : TextChannel, messageId: string) {
-    try {
-        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);
-
-    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)).default as IAggregator;
-
-        if(obj)
-            aggregators.push(obj);
-
-        if(obj.init)
-            obj.init();
-    }
-}
-
-export default {
-    onStart : async () => {
-        let repo = getRepository(KnownChannel);
-
-        let ch = await repo.findOne({
-            where: { channelType: AGGREGATOR_MANAGER_CHANNEL }
-        });
-        aggregateChannelID = ch.channelId;
-
-        initAggregators();
-        interval(checkFeeds, UPDATE_INTERVAL * 60 * 1000);
-    }
+import TurndownService, { Options } from "turndown";
+import interval from "interval-promise";
+import { client } from "../client";
+import sha1 from "sha1";
+import * as path from "path";
+import * as fs from "fs";
+
+import { IAggregator, NewsPostItem, INewsPostData } from "./aggregators/aggregator";
+import { ICommand } from "./command";
+import { RichEmbed, TextChannel, Message, Channel } from "discord.js";
+import { getRepository } from "typeorm";
+import { KnownChannel } from "../entity/KnownChannel";
+import { AggroNewsItem } from "../entity/AggroNewsItem";
+
+const UPDATE_INTERVAL = 5;
+const MAX_PREVIEW_LENGTH = 300; 
+
+const aggregators : IAggregator[] = [];
+let aggregateChannelID : string = null;
+const AGGREGATOR_MANAGER_CHANNEL = "aggregatorManager";
+
+// 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, "")*/;
+}
+
+async function checkFeeds() {
+    console.log(`Aggregating feeds on ${new Date().toISOString()}`);
+
+    let aggregatorJobs = [];
+
+    for(let aggregator of 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;
+            itemObj.contents = markdownify(item.contents);
+            itemObj.hash = sha1(itemObj.contents);
+
+            await 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);
+
+    let newsItem = await repo.findOne({
+        where: { feedName: item.feedId, newsId: item.newsId }
+    });
+
+    if(newsItem) {
+        // No changes, skip
+        if(newsItem.hash == item.hash)
+            return;
+        else
+            await deleteCacheMessage(newsItem.editMessageId);
+    } else {
+        newsItem = repo.create({
+            newsId: item.newsId,
+            feedName: item.feedId,
+            hash: item.hash
+        });
+    }
+
+    let ch = client.channels.get(aggregateChannelID);
+    
+    if(!(ch instanceof TextChannel))
+        return;
+
+    let msg = await ch.send(new RichEmbed({
+        title: item.title,
+        url: item.link,
+        color: item.embedColor,
+        timestamp: new Date(),
+        description: clipText(item.contents),
+        author: {
+            name: item.author
+        },
+        footer: {
+            text: "NoctBot News Aggregator"
+        }
+    })) as Message;
+
+    newsItem.editMessageId = msg.id;
+
+    await repo.save(newsItem);
+}
+
+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 : TextChannel, messageId: string) {
+    try {
+        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);
+
+    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)).default as IAggregator;
+
+        if(obj)
+            aggregators.push(obj);
+
+        if(obj.init)
+            obj.init();
+    }
+}
+
+export default {
+    onStart : async () => {
+        let repo = getRepository(KnownChannel);
+
+        let ch = await repo.findOne({
+            where: { channelType: AGGREGATOR_MANAGER_CHANNEL }
+        });
+
+        if(ch)
+            aggregateChannelID = ch.channelId;
+
+        initAggregators();
+        interval(checkFeeds, UPDATE_INTERVAL * 60 * 1000);
+    }
 } as ICommand;

+ 116 - 116
src/commands/quote.ts

@@ -1,117 +1,117 @@
-import { isAuthorisedAsync } from "../util";
-import { ICommand } from "./command";
-import { getRepository } from "typeorm";
-import { Quote } from "../entity/Quote";
-
-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 *
-                                                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}\`\`\``);
-            }
-        }
-    ],
-    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..."
-        }
-    }
+import { isAuthorisedAsync } from "../util";
+import { ICommand } from "./command";
+import { getRepository } from "typeorm";
+import { Quote } from "../entity/Quote";
+
+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 *
+                                                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}\`\`\``);
+            }
+        }
+    ],
+    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;

+ 29 - 29
src/commands/rcg.ts

@@ -1,30 +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!"
-        }
-    }
+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;

+ 180 - 182
src/commands/react.ts

@@ -1,183 +1,181 @@
-import { client } from "../client";
-import { ICommand } from "./command";
-import { getRepository } from "typeorm";
-import { MessageReaction } from "../entity/MessageReaction";
-import { KnownUser } from "../entity/KnownUser";
-import { ReactionType, ReactionEmote } from "../entity/ReactionEmote";
-import { isAuthorisedAsync } from "../util";
-
-const pattern = /^react to\s+"([^"]+)"\s+with\s+\<:[^:]+:([^\>]+)\>$/i;
-
-async function getRandomEmotes(allowedTypes: ReactionType[], limit: number) {
-    let reactionEmotesRepo = getRepository(ReactionEmote);
-
-    let a = await reactionEmotesRepo.query(`
-        select reactionId 
-        from (  select type, reactionId
-                from reaction_emote
-                where type in (${allowedTypes.map(s => "?").join(",")})
-                order by type, random() )
-        group by type
-        limit ${limit}`, allowedTypes) as ReactionEmote[];
-
-    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\`\`\``);
-            }
-        }
-    ],
-    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: async (actionsDone, msg, content) => {
-        if (actionsDone)
-            return false;
-
-        let lowerContent = content.toLowerCase();
-
-        let reactionRepo = getRepository(MessageReaction);
-        let usersRepo = getRepository(KnownUser);
-
-        let message = await reactionRepo.findOne({ message: lowerContent });
-
-        if(message) {
-            msg.react(client.emojis.get(message.reactionEmoteId));
-            return true;
-        }
-
-        if (msg.mentions.users.size == 0)
-            return false;
-
-        let knownUsers = await usersRepo.find({
-            select: [ "mentionReactionType" ],
-            where: [...msg.mentions.users.map(u => ({ userID: u.id }))]
-        });
-
-        if(knownUsers.length == 0)
-            return false;
-
-        let reactionEmoteTypes = new Set<ReactionType>();
-        
-        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)
-            return false;
-
-        for(let emote of randomEmotes)
-            await msg.react(client.emojis.find(e => e.id == emote.reactionId));
-
-        return true;
-    },
-    onIndirectMention: async (actionsDone, msg) => {
-        if (actionsDone)
-            return false;
-        let emoteType = ReactionType.ANGERY;
-
-        let repo = getRepository(KnownUser);
-
-        let knownUser = await repo.findOne({ 
-            select: [ "mentionReactionType" ],
-            where: [{userID: msg.id}]
-        });
-
-        if(knownUser){
-            if(knownUser.mentionReactionType == ReactionType.NONE)
-                return false;
-            emoteType = knownUser.mentionReactionType;
-        }
-
-        let emotes = await getRandomEmotes([ emoteType ], 1);
-
-        if(emotes.length != 1)
-            return false;
-
-        let emote = client.emojis.find(e => e.id == emotes[0].reactionId);
-
-        if (!emote) {
-            console.log(`WARNING: Emote ${emotes[0]} no longer is valid. Deleting invalid emojis from the list...`);
-
-            let emotesRepo = getRepository(ReactionEmote);
-            await emotesRepo.delete({ reactionId: emotes[0].reactionId });
-            return false;
-        }
-
-        msg.channel.send(emote.toString());
-        return true;
-    }
+import { client } from "../client";
+import { ICommand } from "./command";
+import { getRepository } from "typeorm";
+import { MessageReaction } from "../entity/MessageReaction";
+import { KnownUser } from "../entity/KnownUser";
+import { ReactionType, ReactionEmote } from "../entity/ReactionEmote";
+import { isAuthorisedAsync } from "../util";
+
+const pattern = /^react to\s+"([^"]+)"\s+with\s+\<:[^:]+:([^\>]+)\>$/i;
+
+async function getRandomEmotes(allowedTypes: ReactionType[], limit: number) {
+    let reactionEmotesRepo = getRepository(ReactionEmote);
+
+    let a = await reactionEmotesRepo.query(`
+        select "reactionId"
+                from reaction_emote
+                where type in (${allowedTypes.map((s, i) => `$${i + 1}`).join(",")})
+                order by random(), random()
+        limit ${limit}`, allowedTypes) as ReactionEmote[];
+
+    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\`\`\``);
+            }
+        }
+    ],
+    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: async (actionsDone, msg, content) => {
+        if (actionsDone)
+            return false;
+
+        let lowerContent = content.toLowerCase();
+
+        let reactionRepo = getRepository(MessageReaction);
+        let usersRepo = getRepository(KnownUser);
+
+        let message = await reactionRepo.findOne({ message: lowerContent });
+
+        if(message) {
+            msg.react(client.emojis.get(message.reactionEmoteId));
+            return true;
+        }
+
+        if (msg.mentions.users.size == 0)
+            return false;
+
+        let knownUsers = await usersRepo.find({
+            select: [ "mentionReactionType" ],
+            where: [...msg.mentions.users.map(u => ({ userID: u.id }))]
+        });
+
+        if(knownUsers.length == 0)
+            return false;
+
+        let reactionEmoteTypes = new Set<ReactionType>();
+        
+        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)
+            return false;
+
+        for(let emote of randomEmotes)
+            await msg.react(client.emojis.find(e => e.id == emote.reactionId));
+
+        return true;
+    },
+    onIndirectMention: async (actionsDone, msg) => {
+        if (actionsDone)
+            return false;
+        let emoteType = ReactionType.ANGERY;
+
+        let repo = getRepository(KnownUser);
+
+        let knownUser = await repo.findOne({ 
+            select: [ "mentionReactionType" ],
+            where: [{userID: msg.id}]
+        });
+
+        if(knownUser){
+            if(knownUser.mentionReactionType == ReactionType.NONE)
+                return false;
+            emoteType = knownUser.mentionReactionType;
+        }
+
+        let emotes = await getRandomEmotes([ emoteType ], 1);
+
+        if(emotes.length != 1)
+            return false;
+
+        let emote = client.emojis.find(e => e.id == emotes[0].reactionId);
+
+        if (!emote) {
+            console.log(`WARNING: Emote ${emotes[0]} no longer is valid. Deleting invalid emojis from the list...`);
+
+            let emotesRepo = getRepository(ReactionEmote);
+            await emotesRepo.delete({ reactionId: emotes[0].reactionId });
+            return false;
+        }
+
+        msg.channel.send(emote.toString());
+        return true;
+    }
 } as ICommand;

src/entity/AggroNewsItem.ts → bot/src/entity/AggroNewsItem.ts


src/entity/DeadChatReply.ts → bot/src/entity/DeadChatReply.ts


src/entity/FaceCaptionMessage.ts → bot/src/entity/FaceCaptionMessage.ts


src/entity/Guide.ts → bot/src/entity/Guide.ts


src/entity/KnownChannel.ts → bot/src/entity/KnownChannel.ts


src/entity/KnownUser.ts → bot/src/entity/KnownUser.ts


src/entity/MessageReaction.ts → bot/src/entity/MessageReaction.ts


src/entity/PostVerifyMessage.ts → bot/src/entity/PostVerifyMessage.ts


+ 18 - 18
src/entity/PostedForumsNewsItem.ts

@@ -1,19 +1,19 @@
-import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from "typeorm";
-import { PostVerifyMessage } from "./PostVerifyMessage";
-
-@Entity()
-export class PostedForumNewsItem {
-
-    @PrimaryColumn()
-    id: string;
-
-    @Column()
-    hash: string;
-
-    @Column({ nullable: true })
-    postedMessageId?: string;
-
-    @OneToOne(type => PostVerifyMessage, { nullable: true })
-    @JoinColumn()
-    verifyMessage?: PostVerifyMessage;
+import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from "typeorm";
+import { PostVerifyMessage } from "./PostVerifyMessage";
+
+@Entity()
+export class PostedForumNewsItem {
+
+    @PrimaryColumn()
+    id: string;
+
+    @Column()
+    hash: string;
+
+    @Column({ nullable: true })
+    postedMessageId?: string;
+
+    @OneToOne(type => PostVerifyMessage, { nullable: true })
+    @JoinColumn()
+    verifyMessage?: PostVerifyMessage;
 };

src/entity/Quote.ts → bot/src/entity/Quote.ts


src/entity/ReactionEmote.ts → bot/src/entity/ReactionEmote.ts


+ 256 - 256
src/lowdb_migrator.ts

@@ -1,257 +1,257 @@
-import { existsSync, readFileSync, renameSync } from "fs";
-import { ReactionType, ReactionEmote } from "./entity/ReactionEmote";
-import { getRepository, Repository } from "typeorm";
-import { KnownUser, User, UserRole } from "./entity/KnownUser";
-import { Dictionary } from "lodash";
-import { Guide, GuideKeyword, GuideType } from "./entity/Guide";
-import { Quote } from "./entity/Quote";
-import { MessageReaction } from "./entity/MessageReaction";
-import { FaceCaptionMessage, FaceCaptionType } from "./entity/FaceCaptionMessage";
-import { PostedForumNewsItem } from "./entity/PostedForumsNewsItem";
-import { KnownChannel } from "./entity/KnownChannel";
-import { DeadChatReply } from "./entity/DeadChatReply";
-
-type EmoteTable = { [reactionType: string]: string[] };
-
-interface IGuide {
-    name: string;
-    displayName: string;
-    content: string;
-}
-
-interface IEditors {
-    roles: string[];
-    users: string[];
-}
-
-interface INewsItem {
-    hash: string;
-    messageId: string;
-}
-
-interface IQuote {
-    author: string;
-    message: string;
-}
-
-interface IFaceCaptionTable {
-    pre: string[];
-    post: string[];
-}
-
-type NewsTable = { [id: string] : INewsItem | boolean};
-type MessageReactionsTable = { [message: string] : string };
-type FaceEditProbabilityTable = { [channel: string] : number };
-
-interface IOldDatabase {
-    emotes: EmoteTable;
-    reactableMentionedUsers: string[];
-    specialUsers: string[];
-    bigUsers: string[];
-    dedUsers: string[];
-    editors: IEditors;
-    memes: IGuide[];
-    miscs: IGuide[];
-    guides: IGuide[];
-    quotes: IQuote[];
-    messageReactions: MessageReactionsTable;
-    faceCaptions: IFaceCaptionTable;
-    postedNewsGuids: NewsTable;
-    faceEditChannels: FaceEditProbabilityTable;
-    deadChatReplies: string[];
-    newsPostVerifyChannel: string;
-    feedOutputChannel: string;
-    aggregateChannel: string;
-    latestKissDiaryEntry: number;
-    latestCom3D2WorldDiaryEntry: number;
-    lastCOMJPVersion: number;
-}
-
-async function migrateEmotes(db: IOldDatabase) {
-    let repo = getRepository(ReactionEmote);
-
-    for (const emoteType in db.emotes) {
-        if(!Object.values(ReactionType).includes(emoteType)) {
-            console.log(`WARN: emote type ${emoteType} is not a predefined emote type!`)
-            continue;
-        }
-
-        await repo.save(db.emotes[emoteType].map(id => repo.create({
-            reactionId: id,
-            type: emoteType as ReactionType
-        })));
-    }
-}
-
-async function migrateUsers(db: IOldDatabase) {
-    let userRepo = getRepository(User);
-    let roleRepo = getRepository(UserRole);
-
-    let users : Dictionary<User> = {};
-    let roles : Dictionary<UserRole> = {};
-
-    let iterator = <T extends KnownUser>(targetDict : Dictionary<T>, repo: Repository<T>, ids: string[], action: (u: T) => void) => {
-        for(let userId of ids) {
-            let u : T;
-
-            if(userId in users)
-                u = targetDict[userId];
-            else{
-                u = targetDict[userId] = repo.create();
-                u.userID = userId;
-            }
-
-            action(u);
-        }
-    };
-
-    iterator(users, userRepo, db.reactableMentionedUsers, u => u.mentionReactionType = ReactionType.ANGERY);
-    iterator(users, userRepo, db.specialUsers, u => u.replyReactionType = ReactionType.HUG);
-    iterator(users, userRepo, db.bigUsers, u => u.replyReactionType = ReactionType.BIG);
-    iterator(users, userRepo, db.dedUsers, u => u.replyReactionType = ReactionType.DED);
-
-    iterator(users, userRepo, db.editors.users, u => u.canModerate = true);
-    iterator(roles, roleRepo, db.editors.roles, r => r.canModerate = true);
-
-    await userRepo.save(Object.values(users));
-    await roleRepo.save(Object.values(roles));
-}
-
-async function migrateGuides(db : IOldDatabase) {
-    let guideRepo = getRepository(Guide);
-    let keywordsRepo = getRepository(GuideKeyword);
-
-    let keywords : Dictionary<GuideKeyword> = {};
-    let dbGuides : Guide[] = [];
-
-    let process = async (guides: IGuide[], type: GuideType) => {
-        for(let guide of guides){
-            if(!guide.displayName)
-                continue;
-
-            let guideKeywords = guide.name.split(" ").map(s => s.trim().toLowerCase());
-            let keywordsObjects : GuideKeyword[] = [];
-            
-            for(let keyword of guideKeywords) {
-                let keywordObj : GuideKeyword;
-    
-                if(keyword in keywords)
-                    keywordObj = keywords[keyword];
-                else
-                    keywordObj = keywords[keyword] = await keywordsRepo.save(keywordsRepo.create({ keyword: keyword }));
-                keywordsObjects.push(keywordObj);
-            }
-    
-            let guideObj = guideRepo.create({
-                keywords: keywordsObjects,
-                type: type,
-                displayName: guide.displayName,
-                content: guide.content
-            });
-    
-            dbGuides.push(guideObj);
-        }
-    };
-
-    await process(db.memes, GuideType.MEME);
-    await process(db.guides, GuideType.GUIDE);
-    await process(db.miscs, GuideType.MISC);
-
-    // await keywordsRepo.save(Object.values(keywords));
-    await guideRepo.save(dbGuides);
-}
-
-async function migrateQuotes(db: IOldDatabase) {
-    let repo = getRepository(Quote);
-
-    await repo.save(db.quotes.map(q => repo.create({
-        author: q.author,
-        message: q.message
-    })));
-}
-
-async function migrateMessageReactions(db: IOldDatabase) {
-    let repo = getRepository(MessageReaction);
-
-    await repo.save(Object.entries(db.messageReactions).map(([message, emoteId]) => repo.create({
-        message: message,
-        reactionEmoteId: emoteId
-    })));
-}
-
-async function migrateFaceCaptions(db: IOldDatabase) {
-    let repo = getRepository(FaceCaptionMessage);
-
-    await repo.save([
-        ...db.faceCaptions.pre.map(s => repo.create( {
-            message: s,
-            type: FaceCaptionType.PREFIX
-        })),
-        ...db.faceCaptions.post.map(s => repo.create( {
-            message: s,
-            type: FaceCaptionType.POSTFIX
-        }))
-    ]);
-}
-
-async function migrateNews(db: IOldDatabase) {
-    let repo = getRepository(PostedForumNewsItem);
-
-    await repo.save(Object.entries(db.postedNewsGuids).filter(([id, item]) => typeof item != "boolean").map(([id, item]) => repo.create({
-        hash: (item as INewsItem).hash,
-        postedMessageId: (item as INewsItem).messageId,
-        id: id
-    })));
-}
-
-async function migrateChannels(db: IOldDatabase) {
-    let repo = getRepository(KnownChannel);
-
-    await repo.save([
-        repo.create({
-            channelId: db.newsPostVerifyChannel,
-            channelType: "newsPostVerify"            
-        }),
-        repo.create({
-            channelId: db.aggregateChannel,
-            channelType: "aggregatorManager"
-        }),
-        repo.create({
-            channelId: db.feedOutputChannel,
-            channelType: "newsFeed"
-        }),
-        ...Object.entries(db.faceEditChannels).map(([channelId, prob]) => repo.create({
-            channelId: channelId,
-            faceMorphProbability: prob
-        }))
-    ]);
-}
-
-async function migrateDeadMessages(db: IOldDatabase) {
-    let repo = getRepository(DeadChatReply);
-
-    await repo.save(db.deadChatReplies.map(r => repo.create({
-        message: r
-    })));
-}
-
-export async function migrate() {
-    if(!existsSync("./db.json"))
-        return;
-
-    let json = readFileSync("./db.json", { encoding: "utf-8" });
-
-    let db = JSON.parse(json) as IOldDatabase;
-
-    await migrateEmotes(db);
-    await migrateUsers(db);
-    await migrateGuides(db);
-    await migrateQuotes(db);
-    await migrateMessageReactions(db);
-    await migrateFaceCaptions(db);
-    await migrateNews(db);
-    await migrateChannels(db);
-    await migrateDeadMessages(db);
-
-    renameSync("./db.json", "./db_migrated.json");
+import { existsSync, readFileSync, renameSync } from "fs";
+import { ReactionType, ReactionEmote } from "./entity/ReactionEmote";
+import { getRepository, Repository } from "typeorm";
+import { KnownUser, User, UserRole } from "./entity/KnownUser";
+import { Dictionary } from "lodash";
+import { Guide, GuideKeyword, GuideType } from "./entity/Guide";
+import { Quote } from "./entity/Quote";
+import { MessageReaction } from "./entity/MessageReaction";
+import { FaceCaptionMessage, FaceCaptionType } from "./entity/FaceCaptionMessage";
+import { PostedForumNewsItem } from "./entity/PostedForumsNewsItem";
+import { KnownChannel } from "./entity/KnownChannel";
+import { DeadChatReply } from "./entity/DeadChatReply";
+
+type EmoteTable = { [reactionType: string]: string[] };
+
+interface IGuide {
+    name: string;
+    displayName: string;
+    content: string;
+}
+
+interface IEditors {
+    roles: string[];
+    users: string[];
+}
+
+interface INewsItem {
+    hash: string;
+    messageId: string;
+}
+
+interface IQuote {
+    author: string;
+    message: string;
+}
+
+interface IFaceCaptionTable {
+    pre: string[];
+    post: string[];
+}
+
+type NewsTable = { [id: string] : INewsItem | boolean};
+type MessageReactionsTable = { [message: string] : string };
+type FaceEditProbabilityTable = { [channel: string] : number };
+
+interface IOldDatabase {
+    emotes: EmoteTable;
+    reactableMentionedUsers: string[];
+    specialUsers: string[];
+    bigUsers: string[];
+    dedUsers: string[];
+    editors: IEditors;
+    memes: IGuide[];
+    miscs: IGuide[];
+    guides: IGuide[];
+    quotes: IQuote[];
+    messageReactions: MessageReactionsTable;
+    faceCaptions: IFaceCaptionTable;
+    postedNewsGuids: NewsTable;
+    faceEditChannels: FaceEditProbabilityTable;
+    deadChatReplies: string[];
+    newsPostVerifyChannel: string;
+    feedOutputChannel: string;
+    aggregateChannel: string;
+    latestKissDiaryEntry: number;
+    latestCom3D2WorldDiaryEntry: number;
+    lastCOMJPVersion: number;
+}
+
+async function migrateEmotes(db: IOldDatabase) {
+    let repo = getRepository(ReactionEmote);
+
+    for (const emoteType in db.emotes) {
+        if(!Object.values(ReactionType).includes(emoteType)) {
+            console.log(`WARN: emote type ${emoteType} is not a predefined emote type!`)
+            continue;
+        }
+
+        await repo.save(db.emotes[emoteType].map(id => repo.create({
+            reactionId: id,
+            type: emoteType as ReactionType
+        })));
+    }
+}
+
+async function migrateUsers(db: IOldDatabase) {
+    let userRepo = getRepository(User);
+    let roleRepo = getRepository(UserRole);
+
+    let users : Dictionary<User> = {};
+    let roles : Dictionary<UserRole> = {};
+
+    let iterator = <T extends KnownUser>(targetDict : Dictionary<T>, repo: Repository<T>, ids: string[], action: (u: T) => void) => {
+        for(let userId of ids) {
+            let u : T;
+
+            if(userId in users)
+                u = targetDict[userId];
+            else{
+                u = targetDict[userId] = repo.create();
+                u.userID = userId;
+            }
+
+            action(u);
+        }
+    };
+
+    iterator(users, userRepo, db.reactableMentionedUsers, u => u.mentionReactionType = ReactionType.ANGERY);
+    iterator(users, userRepo, db.specialUsers, u => u.replyReactionType = ReactionType.HUG);
+    iterator(users, userRepo, db.bigUsers, u => u.replyReactionType = ReactionType.BIG);
+    iterator(users, userRepo, db.dedUsers, u => u.replyReactionType = ReactionType.DED);
+
+    iterator(users, userRepo, db.editors.users, u => u.canModerate = true);
+    iterator(roles, roleRepo, db.editors.roles, r => r.canModerate = true);
+
+    await userRepo.save(Object.values(users));
+    await roleRepo.save(Object.values(roles));
+}
+
+async function migrateGuides(db : IOldDatabase) {
+    let guideRepo = getRepository(Guide);
+    let keywordsRepo = getRepository(GuideKeyword);
+
+    let keywords : Dictionary<GuideKeyword> = {};
+    let dbGuides : Guide[] = [];
+
+    let process = async (guides: IGuide[], type: GuideType) => {
+        for(let guide of guides){
+            if(!guide.displayName)
+                continue;
+
+            let guideKeywords = guide.name.split(" ").map(s => s.trim().toLowerCase());
+            let keywordsObjects : GuideKeyword[] = [];
+            
+            for(let keyword of guideKeywords) {
+                let keywordObj : GuideKeyword;
+    
+                if(keyword in keywords)
+                    keywordObj = keywords[keyword];
+                else
+                    keywordObj = keywords[keyword] = await keywordsRepo.save(keywordsRepo.create({ keyword: keyword }));
+                keywordsObjects.push(keywordObj);
+            }
+    
+            let guideObj = guideRepo.create({
+                keywords: keywordsObjects,
+                type: type,
+                displayName: guide.displayName,
+                content: guide.content
+            });
+    
+            dbGuides.push(guideObj);
+        }
+    };
+
+    await process(db.memes, GuideType.MEME);
+    await process(db.guides, GuideType.GUIDE);
+    await process(db.miscs, GuideType.MISC);
+
+    // await keywordsRepo.save(Object.values(keywords));
+    await guideRepo.save(dbGuides);
+}
+
+async function migrateQuotes(db: IOldDatabase) {
+    let repo = getRepository(Quote);
+
+    await repo.save(db.quotes.map(q => repo.create({
+        author: q.author,
+        message: q.message
+    })));
+}
+
+async function migrateMessageReactions(db: IOldDatabase) {
+    let repo = getRepository(MessageReaction);
+
+    await repo.save(Object.entries(db.messageReactions).map(([message, emoteId]) => repo.create({
+        message: message,
+        reactionEmoteId: emoteId
+    })));
+}
+
+async function migrateFaceCaptions(db: IOldDatabase) {
+    let repo = getRepository(FaceCaptionMessage);
+
+    await repo.save([
+        ...db.faceCaptions.pre.map(s => repo.create( {
+            message: s,
+            type: FaceCaptionType.PREFIX
+        })),
+        ...db.faceCaptions.post.map(s => repo.create( {
+            message: s,
+            type: FaceCaptionType.POSTFIX
+        }))
+    ]);
+}
+
+async function migrateNews(db: IOldDatabase) {
+    let repo = getRepository(PostedForumNewsItem);
+
+    await repo.save(Object.entries(db.postedNewsGuids).filter(([id, item]) => typeof item != "boolean").map(([id, item]) => repo.create({
+        hash: (item as INewsItem).hash,
+        postedMessageId: (item as INewsItem).messageId,
+        id: id
+    })));
+}
+
+async function migrateChannels(db: IOldDatabase) {
+    let repo = getRepository(KnownChannel);
+
+    await repo.save([
+        repo.create({
+            channelId: db.newsPostVerifyChannel,
+            channelType: "newsPostVerify"            
+        }),
+        repo.create({
+            channelId: db.aggregateChannel,
+            channelType: "aggregatorManager"
+        }),
+        repo.create({
+            channelId: db.feedOutputChannel,
+            channelType: "newsFeed"
+        }),
+        ...Object.entries(db.faceEditChannels).map(([channelId, prob]) => repo.create({
+            channelId: channelId,
+            faceMorphProbability: prob
+        }))
+    ]);
+}
+
+async function migrateDeadMessages(db: IOldDatabase) {
+    let repo = getRepository(DeadChatReply);
+
+    await repo.save(db.deadChatReplies.map(r => repo.create({
+        message: r
+    })));
+}
+
+export async function migrate() {
+    if(!existsSync("./db.json"))
+        return;
+
+    let json = readFileSync("./db.json", { encoding: "utf-8" });
+
+    let db = JSON.parse(json) as IOldDatabase;
+
+    await migrateEmotes(db);
+    await migrateUsers(db);
+    await migrateGuides(db);
+    await migrateQuotes(db);
+    await migrateMessageReactions(db);
+    await migrateFaceCaptions(db);
+    await migrateNews(db);
+    await migrateChannels(db);
+    await migrateDeadMessages(db);
+
+    renameSync("./db.json", "./db_migrated.json");
 }

+ 154 - 143
src/main.ts

@@ -1,144 +1,155 @@
-import * as dotenv from "dotenv";
-dotenv.config();
-
-import * as fs from "fs";
-import * as path from "path";
-import { client } from "./client";
-import { ICommand, BotEvent, IBotCommand } from "./commands/command"
-import "reflect-metadata";
-import {createConnection} from "typeorm";
-import { migrate } from "./lowdb_migrator";
-import { documentation } from "./util";
-
-const REACT_PROBABILITY = 0.3;
-
-async function trigger(actions : BotEvent[], ...params: any[]) {
-    let actionDone = false;
-    for (let i = 0; i < actions.length; i++) {
-        const action = actions[i];
-        let actionResult = action(actionDone, ...params);
-        if(actionResult instanceof Promise)
-            actionDone = (await actionResult) || actionDone;
-        else
-            actionDone = actionResult || actionDone;
-    }
-    return actionDone;
-}
-
-let commands : IBotCommand[] = [];
-let msgActions : BotEvent[] = [];
-let indirectMentionActions : BotEvent[] = [];
-let startActions : Array<() => void | Promise<void>> = [];
-let directMessageActions : BotEvent[] = [];
-let postActions : BotEvent[] = [];
-
-client.on("ready", async () => {
-    console.log("Starting up NoctBot!");
-    client.user.setActivity(process.env.NODE_ENV == "dev" ? "Maintenance" : "@NoctBot help", {
-        type: "PLAYING"
-    });
-    for (let i = 0; i < startActions.length; i++) {
-        const action = startActions[i];
-        let val = action();
-        if(val instanceof Promise)
-            await val;
-    }
-    console.log("NoctBot is ready!");
-});
-
-client.on("message", async m => {
-    if (m.author.id == client.user.id) 
-        return;
-
-    let content = m.cleanContent.trim();
-
-    if (await trigger(msgActions, m, content))
-        return;
-
-    if (m.mentions.users.size > 0 && m.mentions.users.has(client.user.id)) {
-
-        if (m.content.trim().startsWith(client.user.toString())) {
-            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)) {
-                    c.action(m, content);
-                    return;
-                }
-                else if(c.pattern instanceof RegExp){
-                    let result = c.pattern.exec(content);
-                    if(result != null){
-                        c.action(m, content, result);
-                        return;
-                    }
-                }
-            }
-
-            if (await trigger(directMessageActions, m, lowerCaseContent))
-                return;
-        }
-
-        if (await trigger(indirectMentionActions, m))
-            return;
-    }
-
-    await trigger(postActions);
-});
-
-client.on("messageReactionAdd", (r, u) => {
-    if (Math.random() <= REACT_PROBABILITY && !u.bot) {
-        console.log(`Reacting to message ${r.message.id} because user ${u.tag} reacted to it`);
-        r.message.react(r.emoji);
-    }
-});
-
-async function main() {
-    await createConnection();
-    await migrate();
-
-    let commandsPath = path.resolve(path.dirname(module.filename), "commands");
-    let files = fs.readdirSync(commandsPath);
-
-    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)).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);
-    }
-
-    client.login(process.env.TOKEN);
-}
-
+import * as fs from "fs";
+import * as path from "path";
+import { client } from "./client";
+import { ICommand, BotEvent, IBotCommand } from "./commands/command"
+import "reflect-metadata";
+import {createConnection} from "typeorm";
+import { migrate } from "./lowdb_migrator";
+import { documentation } from "./util";
+import dotenv from "dotenv";
+
+if(process.env.NODE_ENV == "dev") {
+    dotenv.config({
+        path: "../.env"
+    });
+    dotenv.config({
+        path: "../db.env"
+    });
+    process.env.TYPEORM_HOST = "localhost";
+    process.env.TYPEORM_USERNAME = process.env.DB_USERNAME;
+    process.env.TYPEORM_PASSWORD = process.env.DB_PASSWORD;
+    process.env.TYPEORM_DATABASE = process.env.DB_NAME;
+}
+
+const REACT_PROBABILITY = 0.3;
+
+async function trigger(actions : BotEvent[], ...params: any[]) {
+    let actionDone = false;
+    for (let i = 0; i < actions.length; i++) {
+        const action = actions[i];
+        let actionResult = action(actionDone, ...params);
+        if(actionResult instanceof Promise)
+            actionDone = (await actionResult) || actionDone;
+        else
+            actionDone = actionResult || actionDone;
+    }
+    return actionDone;
+}
+
+let commands : IBotCommand[] = [];
+let msgActions : BotEvent[] = [];
+let indirectMentionActions : BotEvent[] = [];
+let startActions : Array<() => void | Promise<void>> = [];
+let directMessageActions : BotEvent[] = [];
+let postActions : BotEvent[] = [];
+
+client.on("ready", async () => {
+    console.log("Starting up NoctBot!");
+    client.user.setActivity(process.env.NODE_ENV == "dev" ? "Maintenance" : "@NoctBot help", {
+        type: "PLAYING"
+    });
+    for (let i = 0; i < startActions.length; i++) {
+        const action = startActions[i];
+        let val = action();
+        if(val instanceof Promise)
+            await val;
+    }
+    console.log("NoctBot is ready!");
+});
+
+client.on("message", async m => {
+    if (m.author.id == client.user.id) 
+        return;
+
+    let content = m.cleanContent.trim();
+
+    if (await trigger(msgActions, m, content))
+        return;
+
+    if (m.mentions.users.size > 0 && m.mentions.users.has(client.user.id)) {
+
+        if (m.content.trim().startsWith(client.user.toString())) {
+            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)) {
+                    c.action(m, content);
+                    return;
+                }
+                else if(c.pattern instanceof RegExp){
+                    let result = c.pattern.exec(content);
+                    if(result != null){
+                        c.action(m, content, result);
+                        return;
+                    }
+                }
+            }
+
+            if (await trigger(directMessageActions, m, lowerCaseContent))
+                return;
+        }
+
+        if (await trigger(indirectMentionActions, m))
+            return;
+    }
+
+    await trigger(postActions);
+});
+
+client.on("messageReactionAdd", (r, u) => {
+    if (Math.random() <= REACT_PROBABILITY && !u.bot) {
+        console.log(`Reacting to message ${r.message.id} because user ${u.tag} reacted to it`);
+        r.message.react(r.emoji);
+    }
+});
+
+async function main() {
+    await createConnection();
+    await migrate();
+
+    let commandsPath = path.resolve(path.dirname(module.filename), "commands");
+    let files = fs.readdirSync(commandsPath);
+
+    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)).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);
+    }
+
+    client.login(process.env.BOT_TOKEN);
+}
+
 main();

+ 2 - 0
bot/src/typedefs/bobb.d.ts

@@ -0,0 +1,2 @@
+declare module '@bbob/html';
+declare module '@bbob/preset-html5';

src/typedefs/rss_parser.d.ts → bot/src/typedefs/rss_parser.d.ts


+ 47 - 47
src/util.ts

@@ -1,48 +1,48 @@
-import { GuildMember } from "discord.js";
-import { DocumentationSet } from "./commands/command";
-import { getRepository } from "typeorm";
-import { KnownUser } from "./entity/KnownUser";
-
-const VALID_EXTENSIONS = new Set([
-    "png",
-    "jpg",
-    "jpeg",
-    "bmp",
-]);
-
-export let documentation : DocumentationSet = {};
-
-export function isDevelopment() {
-    return process.env.NODE_ENV == "dev";
-}
-
-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 async function isAuthorisedAsync(member : GuildMember) {
-    let repo = getRepository(KnownUser);
-
-    let user = await repo.findOne({
-        where: { userID: member.id },
-        select: [ "canModerate" ]
-    });
-
-    if (user && user.canModerate)
-        return true;
-
-    let role = await repo.createQueryBuilder()
-                .select(["userId"])
-                .where("userId in (:...ids)", {ids: member.roles.keyArray()})
-                .andWhere("canModerate = 1")
-                .getOne();
-    if (role)
-        return true;
-    return false;
-}
-
+import { GuildMember } from "discord.js";
+import { DocumentationSet } from "./commands/command";
+import { getRepository } from "typeorm";
+import { KnownUser } from "./entity/KnownUser";
+
+const VALID_EXTENSIONS = new Set([
+    "png",
+    "jpg",
+    "jpeg",
+    "bmp",
+]);
+
+export let documentation : DocumentationSet = {};
+
+export function isDevelopment() {
+    return process.env.NODE_ENV == "dev";
+}
+
+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 async function isAuthorisedAsync(member : GuildMember) {
+    let repo = getRepository(KnownUser);
+
+    let user = await repo.findOne({
+        where: { userID: member.id },
+        select: [ "canModerate" ]
+    });
+
+    if (user && user.canModerate)
+        return true;
+
+    let role = await repo.createQueryBuilder()
+                .select(["userId"])
+                .where("userId in (:...ids)", {ids: member.roles.keyArray()})
+                .andWhere("canModerate = 1")
+                .getOne();
+    if (role)
+        return true;
+    return false;
+}
+
 export type Dict<TVal> = { [key: string]: TVal };

+ 267 - 267
src/xenforo.ts

@@ -1,268 +1,268 @@
-import request from "request-promise-native";
-import { Response } from "request";
-import { Dict } from "./util";
-
-enum ReqMethod {
-    GET = "get",
-    POST = "post",
-    DELETE = "delete"
-}
-
-export interface RequestError {
-    code: string;
-    message: string;
-    params: { key: string; value: any; }[];
-}
-
-export type RequestErrorSet = { errors: RequestError[] };
-
-export class XenforoClient {
-
-    constructor(private endpoint: string, private userKey: string) {
-    }
-
-    private async makeRequest<T>(uri: string, method: ReqMethod, data?: any) {
-        let result = await request(`${this.endpoint}/${uri}`, {
-            method: method,
-            headers: {
-                "XF-Api-Key": this.userKey
-            },
-            form: data || undefined,
-            resolveWithFullResponse: true
-        }) as Response;
-
-        if (result.statusCode != 200) {
-            throw await JSON.parse(result.body) as RequestErrorSet;
-        } else {
-            return await JSON.parse(result.body) as T;
-        }
-    }
-
-    async getThread(id: number, opts?: GetThreadOptions) {
-        return await this.makeRequest<GetThreadResponse>(`threads/${id}`, ReqMethod.GET, opts);
-    }
-
-    async deleteThread(id: number, opts?: DeleteThreadOptions) {
-        return await this.makeRequest<SuccessResponse>(`threads/${id}`, ReqMethod.DELETE, opts);
-    }
-
-    async createThread(forumId: number, title: string, message: string, opts?: CreateThreadOptions) {
-        return await this.makeRequest<CreateThreadResponse>(`threads/`, ReqMethod.POST, {
-            node_id: forumId,
-            title: title,
-            message: message,
-            ...opts
-        });
-    }
-
-    async getPost(id: number) {
-        let post = await this.makeRequest<{post: Post}>(`posts/${id}`, ReqMethod.GET);
-        return post.post;
-    }
-
-    async getForumThreads(id: number) {
-        return await this.makeRequest<GetForumThreadsResponse>(`forums/${id}/threads`, ReqMethod.GET);
-    }
-}
-
-//#region Request types
-interface DeleteThreadOptions {
-    hard_delete?: boolean;
-    reason?: boolean;
-    starter_alert?: boolean;
-    starter_alert_reason?: boolean;
-}
-
-interface GetThreadOptions {
-    with_posts?: boolean;
-    page?: number;
-}
-
-interface CreateThreadOptions {
-    prefix_id?: number;
-    tags?: string[];
-    custom_fields?: Dict<string>;
-    discussion_open?: boolean;
-    sticky?: boolean;
-    attachment_key?: boolean;
-}
-//#endregion
-
-//#region Response types 
-type GetThreadResponse = {
-    thread: Thread;
-    messages: Post[];
-    pagination: any;
-};
-
-type SuccessResponse = {
-    success: boolean;
-}
-
-type CreateThreadResponse = SuccessResponse & { thread: Thread; };
-
-type GetForumThreadsResponse = {
-    threads: Thread[];
-    pagination: object;
-    sticky: Thread[];
-};
-//#endregion
-
-//#region Data types
-export interface Forum {
-    allow_posting: boolean;
-    allow_poll: boolean;
-    require_prefix: boolean;
-    min_tags: number;
-}
-
-export interface User {
-    about?: string;
-    activity_visible?: boolean;
-    age?: number;
-    alert_optout?: any[];
-    allow_post_profile?: string;
-    allow_receive_news_feed?: string;
-    allow_send_personal_conversation?: string;
-    allow_view_identities: string;
-    allow_view_profile?: string;
-    avatar_urls: object;
-    can_ban: boolean;
-    can_converse: boolean;
-    can_edit: boolean;
-    can_follow: boolean;
-    can_ignore: boolean;
-    can_post_profile: boolean;
-    can_view_profile: boolean;
-    can_view_profile_posts: boolean;
-    can_warn: boolean;
-    content_show_signature?: boolean;
-    creation_watch_state?: string;
-    custom_fields?: object;
-    custom_title?: string;
-    dob?: object;
-    email?: string;
-    email_on_conversation?: boolean;
-    gravatar?: string;
-    interaction_watch_state?: boolean;
-    is_admin?: boolean;
-    is_banned?: boolean;
-    is_discouraged?: boolean;
-    is_followed?: boolean;
-    is_ignored?: boolean;
-    is_moderator?: boolean;
-    is_super_admin?: boolean;
-    last_activity?: number;
-    location: string;
-    push_on_conversation?: boolean;
-    push_optout?: any[];
-    receive_admin_email?: boolean;
-    secondary_group_ids?: any[];
-    show_dob_date?: boolean;
-    show_dob_year?: boolean;
-    signature: string;
-    timezone?: string;
-    use_tfa?: any[];
-    user_group_id?: number;
-    user_state?: string;
-    user_title: string;
-    visible?: boolean;
-    warning_points?: number;
-    website?: string;
-    user_id: number;
-    username: string;
-    message_count: number;
-    register_date: number;
-    trophy_points: number;
-    is_staff: boolean;
-    reaction_score: number;
-}
-
-export interface Node {
-    breadcrumbs: any[];
-    type_data: object;
-    node_id: number;
-    title: string;
-    node_name: string;
-    description: string;
-    node_type_id: string;
-    parent_node_id: number;
-    display_order: number;
-    display_in_list: boolean;
-}
-
-export interface Thread {
-    username: string;
-    is_watching?: boolean;
-    visitor_post_count?: number;
-    custom_fields: object;
-    tags: any[];
-    prefix?: string;
-    can_edit: boolean;
-    can_edit_tags: boolean;
-    can_reply: boolean;
-    can_soft_delete: boolean;
-    can_hard_delete: boolean;
-    can_view_attachments: boolean;
-    Forum?: Node;
-    thread_id: number;
-    node_id: number;
-    title: string;
-    reply_count: number;
-    view_count: number;
-    user_id: number;
-    post_date: number;
-    sticky: boolean;
-    discussion_state: string;
-    discussion_open: boolean;
-    discussion_type: string;
-    first_post_id: number;
-    last_post_date: number;
-    last_post_id: number;
-    last_post_user_id: number;
-    last_post_username: string;
-    first_post_reaction_score: number;
-    prefix_id: number;
-}
-
-export interface Attachment {
-    filename: string;
-    file_size: number;
-    height: number;
-    width: number;
-    thumbnail_url: string;
-    video_url: string;
-    attachment_id: number;
-    content_type: string;
-    content_id: number;
-    attach_date: number;
-    view_count: number;
-}
-
-export interface Post {
-    username: string;
-    is_first_post: boolean;
-    is_last_post: boolean;
-    can_edit: boolean;
-    can_soft_delete: boolean;
-    can_hard_delete: boolean;
-    can_react: boolean;
-    can_view_attachments: boolean;
-    Thread?: Thread;
-    Attachments?: Attachment[];
-    is_reacted_to: boolean;
-    visitor_reaction_id: number;
-    post_id: number;
-    thread_id: number;
-    user_id: number;
-    post_date: number;
-    message: string;
-    message_state: string;
-    attach_count: number;
-    warning_message: string;
-    position: number;
-    last_edit_date: number;
-    reaction_score: number;
-    User: User;
-}
+import request from "request-promise-native";
+import { Response } from "request";
+import { Dict } from "./util";
+
+enum ReqMethod {
+    GET = "get",
+    POST = "post",
+    DELETE = "delete"
+}
+
+export interface RequestError {
+    code: string;
+    message: string;
+    params: { key: string; value: any; }[];
+}
+
+export type RequestErrorSet = { errors: RequestError[] };
+
+export class XenforoClient {
+
+    constructor(private endpoint: string, private userKey: string) {
+    }
+
+    private async makeRequest<T>(uri: string, method: ReqMethod, data?: any) {
+        let result = await request(`${this.endpoint}/${uri}`, {
+            method: method,
+            headers: {
+                "XF-Api-Key": this.userKey
+            },
+            form: data || undefined,
+            resolveWithFullResponse: true
+        }) as Response;
+
+        if (result.statusCode != 200) {
+            throw await JSON.parse(result.body) as RequestErrorSet;
+        } else {
+            return await JSON.parse(result.body) as T;
+        }
+    }
+
+    async getThread(id: number, opts?: GetThreadOptions) {
+        return await this.makeRequest<GetThreadResponse>(`threads/${id}`, ReqMethod.GET, opts);
+    }
+
+    async deleteThread(id: number, opts?: DeleteThreadOptions) {
+        return await this.makeRequest<SuccessResponse>(`threads/${id}`, ReqMethod.DELETE, opts);
+    }
+
+    async createThread(forumId: number, title: string, message: string, opts?: CreateThreadOptions) {
+        return await this.makeRequest<CreateThreadResponse>(`threads/`, ReqMethod.POST, {
+            node_id: forumId,
+            title: title,
+            message: message,
+            ...opts
+        });
+    }
+
+    async getPost(id: number) {
+        let post = await this.makeRequest<{post: Post}>(`posts/${id}`, ReqMethod.GET);
+        return post.post;
+    }
+
+    async getForumThreads(id: number) {
+        return await this.makeRequest<GetForumThreadsResponse>(`forums/${id}/threads`, ReqMethod.GET);
+    }
+}
+
+//#region Request types
+interface DeleteThreadOptions {
+    hard_delete?: boolean;
+    reason?: boolean;
+    starter_alert?: boolean;
+    starter_alert_reason?: boolean;
+}
+
+interface GetThreadOptions {
+    with_posts?: boolean;
+    page?: number;
+}
+
+interface CreateThreadOptions {
+    prefix_id?: number;
+    tags?: string[];
+    custom_fields?: Dict<string>;
+    discussion_open?: boolean;
+    sticky?: boolean;
+    attachment_key?: boolean;
+}
+//#endregion
+
+//#region Response types 
+type GetThreadResponse = {
+    thread: Thread;
+    messages: Post[];
+    pagination: any;
+};
+
+type SuccessResponse = {
+    success: boolean;
+}
+
+type CreateThreadResponse = SuccessResponse & { thread: Thread; };
+
+type GetForumThreadsResponse = {
+    threads: Thread[];
+    pagination: object;
+    sticky: Thread[];
+};
+//#endregion
+
+//#region Data types
+export interface Forum {
+    allow_posting: boolean;
+    allow_poll: boolean;
+    require_prefix: boolean;
+    min_tags: number;
+}
+
+export interface User {
+    about?: string;
+    activity_visible?: boolean;
+    age?: number;
+    alert_optout?: any[];
+    allow_post_profile?: string;
+    allow_receive_news_feed?: string;
+    allow_send_personal_conversation?: string;
+    allow_view_identities: string;
+    allow_view_profile?: string;
+    avatar_urls: object;
+    can_ban: boolean;
+    can_converse: boolean;
+    can_edit: boolean;
+    can_follow: boolean;
+    can_ignore: boolean;
+    can_post_profile: boolean;
+    can_view_profile: boolean;
+    can_view_profile_posts: boolean;
+    can_warn: boolean;
+    content_show_signature?: boolean;
+    creation_watch_state?: string;
+    custom_fields?: object;
+    custom_title?: string;
+    dob?: object;
+    email?: string;
+    email_on_conversation?: boolean;
+    gravatar?: string;
+    interaction_watch_state?: boolean;
+    is_admin?: boolean;
+    is_banned?: boolean;
+    is_discouraged?: boolean;
+    is_followed?: boolean;
+    is_ignored?: boolean;
+    is_moderator?: boolean;
+    is_super_admin?: boolean;
+    last_activity?: number;
+    location: string;
+    push_on_conversation?: boolean;
+    push_optout?: any[];
+    receive_admin_email?: boolean;
+    secondary_group_ids?: any[];
+    show_dob_date?: boolean;
+    show_dob_year?: boolean;
+    signature: string;
+    timezone?: string;
+    use_tfa?: any[];
+    user_group_id?: number;
+    user_state?: string;
+    user_title: string;
+    visible?: boolean;
+    warning_points?: number;
+    website?: string;
+    user_id: number;
+    username: string;
+    message_count: number;
+    register_date: number;
+    trophy_points: number;
+    is_staff: boolean;
+    reaction_score: number;
+}
+
+export interface Node {
+    breadcrumbs: any[];
+    type_data: object;
+    node_id: number;
+    title: string;
+    node_name: string;
+    description: string;
+    node_type_id: string;
+    parent_node_id: number;
+    display_order: number;
+    display_in_list: boolean;
+}
+
+export interface Thread {
+    username: string;
+    is_watching?: boolean;
+    visitor_post_count?: number;
+    custom_fields: object;
+    tags: any[];
+    prefix?: string;
+    can_edit: boolean;
+    can_edit_tags: boolean;
+    can_reply: boolean;
+    can_soft_delete: boolean;
+    can_hard_delete: boolean;
+    can_view_attachments: boolean;
+    Forum?: Node;
+    thread_id: number;
+    node_id: number;
+    title: string;
+    reply_count: number;
+    view_count: number;
+    user_id: number;
+    post_date: number;
+    sticky: boolean;
+    discussion_state: string;
+    discussion_open: boolean;
+    discussion_type: string;
+    first_post_id: number;
+    last_post_date: number;
+    last_post_id: number;
+    last_post_user_id: number;
+    last_post_username: string;
+    first_post_reaction_score: number;
+    prefix_id: number;
+}
+
+export interface Attachment {
+    filename: string;
+    file_size: number;
+    height: number;
+    width: number;
+    thumbnail_url: string;
+    video_url: string;
+    attachment_id: number;
+    content_type: string;
+    content_id: number;
+    attach_date: number;
+    view_count: number;
+}
+
+export interface Post {
+    username: string;
+    is_first_post: boolean;
+    is_last_post: boolean;
+    can_edit: boolean;
+    can_soft_delete: boolean;
+    can_hard_delete: boolean;
+    can_react: boolean;
+    can_view_attachments: boolean;
+    Thread?: Thread;
+    Attachments?: Attachment[];
+    is_reacted_to: boolean;
+    visitor_reaction_id: number;
+    post_id: number;
+    thread_id: number;
+    user_id: number;
+    post_date: number;
+    message: string;
+    message_state: string;
+    attach_count: number;
+    warning_message: string;
+    position: number;
+    last_edit_date: number;
+    reaction_score: number;
+    User: User;
+}
 //#endregion

+ 22 - 22
tsconfig.json

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

+ 8 - 0
db.env.template

@@ -0,0 +1,8 @@
+TYPEORM_CONNECTION=
+TYPEORM_HOST=
+TYPEORM_PORT=
+TYPEORM_SYNCHRONIZE=
+TYPEORM_LOGGING=
+TYPEORM_ENTITIES=
+TYPEORM_MIGRATIONS=
+TYPEORM_SUBSCRIBERS=

+ 46 - 0
docker-compose.yml

@@ -0,0 +1,46 @@
+version: '3.7'
+
+services:
+  noctbot:
+    image: noctbot
+    build:
+      context: ./bot/
+      args:
+        OPENCV_VERSION: 3.4
+    restart: on-failure
+    depends_on:
+      - db
+    env_file: 
+      - .env
+      - db.env
+    environment:
+      NODE_ENV: production
+      BOT_TOKEN: ${BOT_TOKEN}
+      FORUM_PASS: ${FORUM_PASS}
+      FORUM_API_KEY: ${FORUM_API_KEY}
+      IGNORE_CHANGED_NEWS: ${IGNORE_CHANGED_NEWS}
+      NPM_CONFIG_LOGLEVEL: info
+      TYPEORM_USERNAME: ${DB_USERNAME}
+      TYPEORM_PASSWORD: ${DB_PASSWORD}
+      TYPEORM_DATABASE: ${DB_NAME}
+    ports:
+      - 3000:3000
+
+  db:
+    image: postgres
+    restart: always
+    env_file:
+      - ./db.env
+    environment: 
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+      POSTGRES_USER: ${DB_USERNAME}
+    volumes:
+      - ./data/db:/var/lib/postgresql/data
+    ports:
+      - 5432:5432
+
+  adminer:
+    image: adminer
+    restart: always
+    ports:
+      - 8080:8080

+ 23 - 0
install-compose.sh

@@ -0,0 +1,23 @@
+#!/bin/sh
+
+# Installs docker-compose inside docker-machine
+
+DOCKER_COMPOSE_VERSION=1.24.1
+
+# Download docker-compose to the permanent storage
+echo 'Downloading docker-compose to the permanent VM storage...'
+sudo mkdir -p /var/lib/boot2docker/bin
+sudo curl -sL https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` -o /var/lib/boot2docker/bin/docker-compose
+sudo chmod +x /var/lib/boot2docker/bin/docker-compose
+sudo ln -sf /var/lib/boot2docker/bin/docker-compose /usr/local/bin/docker-compose
+
+# Making the symlink persistent via bootlocal.sh
+echo 'Writing to bootlocal.sh to make docker-compose available on every boot...'
+cat <<SCRIPT | sudo tee -a /var/lib/boot2docker/bootlocal.sh > /dev/null
+# docker-compose
+sudo ln -sf /var/lib/boot2docker/bin/docker-compose /usr/local/bin/docker-compose
+SCRIPT
+sudo chmod +x /var/lib/boot2docker/bootlocal.sh
+
+echo 'Launching docker-compose...'
+docker-compose --version

+ 0 - 2
src/typedefs/bobb.d.ts

@@ -1,2 +0,0 @@
-declare module '@bbob/html';
-declare module '@bbob/preset-html5';