From f186e5c61fcc23243a6cff725ebee0a01e80ce80 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 28 Jul 2019 18:24:32 +0300 Subject: [PATCH 01/58] Work on documentation --- dashboard/package-lock.json | 13 ++ dashboard/package.json | 1 + .../GuildConfigEditor.vue} | 2 +- .../GuildList.vue} | 0 .../{Dashboard.vue => Dashboard/Layout.vue} | 11 +- dashboard/src/components/Docs/CodeBlock.vue | 21 +++ .../components/Docs/ConfigurationFormat.vue | 42 ++++++ .../src/components/Docs/Introduction.vue | 20 +++ dashboard/src/components/Docs/Layout.vue | 121 ++++++++++++++++++ dashboard/src/components/Docs/Permissions.vue | 88 +++++++++++++ .../components/Docs/PluginConfiguration.vue | 83 ++++++++++++ dashboard/src/components/Splash.vue | 4 +- dashboard/src/directives/trim-code.ts | 11 ++ dashboard/src/main.ts | 1 - dashboard/src/routes.ts | 30 ++++- dashboard/src/style/dashboard.scss | 4 + dashboard/src/style/docs.scss | 9 ++ 17 files changed, 451 insertions(+), 10 deletions(-) rename dashboard/src/components/{DashboardGuildConfigEditor.vue => Dashboard/GuildConfigEditor.vue} (98%) rename dashboard/src/components/{DashboardGuildList.vue => Dashboard/GuildList.vue} (100%) rename dashboard/src/components/{Dashboard.vue => Dashboard/Layout.vue} (79%) create mode 100644 dashboard/src/components/Docs/CodeBlock.vue create mode 100644 dashboard/src/components/Docs/ConfigurationFormat.vue create mode 100644 dashboard/src/components/Docs/Introduction.vue create mode 100644 dashboard/src/components/Docs/Layout.vue create mode 100644 dashboard/src/components/Docs/Permissions.vue create mode 100644 dashboard/src/components/Docs/PluginConfiguration.vue create mode 100644 dashboard/src/directives/trim-code.ts create mode 100644 dashboard/src/style/docs.scss diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 9181fa9e..11233f5d 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -4253,6 +4253,11 @@ "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", "dev": true }, + "highlight.js": { + "version": "9.15.8", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.8.tgz", + "integrity": "sha512-RrapkKQWwE+wKdF73VsOa2RQdIoO3mxwJ4P8mhbI6KYJUraUHRKM5w5zQQKXNk0xNL4UVRdulV9SBJcmzJNzVA==" + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -7578,6 +7583,14 @@ "diff-match-patch": "^1.0.0" } }, + "vue-highlightjs": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/vue-highlightjs/-/vue-highlightjs-1.3.3.tgz", + "integrity": "sha1-KaDVcTL8HOFc+mHolpGPW3GMXVI=", + "requires": { + "highlight.js": "*" + } + }, "vue-hot-reload-api": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.3.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 01fa35cf..33f47026 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -22,6 +22,7 @@ "js-cookie": "^2.2.0", "vue": "^2.6.10", "vue-codemirror": "^4.0.6", + "vue-highlightjs": "^1.3.3", "vue-hot-reload-api": "^2.3.3", "vue-router": "^3.0.6" }, diff --git a/dashboard/src/components/DashboardGuildConfigEditor.vue b/dashboard/src/components/Dashboard/GuildConfigEditor.vue similarity index 98% rename from dashboard/src/components/DashboardGuildConfigEditor.vue rename to dashboard/src/components/Dashboard/GuildConfigEditor.vue index e3953268..61016cec 100644 --- a/dashboard/src/components/DashboardGuildConfigEditor.vue +++ b/dashboard/src/components/Dashboard/GuildConfigEditor.vue @@ -35,7 +35,7 @@ import "codemirror/lib/codemirror.css"; import "codemirror/theme/oceanic-next.css"; import "codemirror/mode/yaml/yaml.js"; - import {ApiError} from "../api"; + import {ApiError} from "../../api"; export default { components: { diff --git a/dashboard/src/components/DashboardGuildList.vue b/dashboard/src/components/Dashboard/GuildList.vue similarity index 100% rename from dashboard/src/components/DashboardGuildList.vue rename to dashboard/src/components/Dashboard/GuildList.vue diff --git a/dashboard/src/components/Dashboard.vue b/dashboard/src/components/Dashboard/Layout.vue similarity index 79% rename from dashboard/src/components/Dashboard.vue rename to dashboard/src/components/Dashboard/Layout.vue index 084c135b..9380ca65 100644 --- a/dashboard/src/components/Dashboard.vue +++ b/dashboard/src/components/Dashboard/Layout.vue @@ -1,10 +1,10 @@ + + diff --git a/dashboard/src/components/Docs/ConfigurationFormat.vue b/dashboard/src/components/Docs/ConfigurationFormat.vue new file mode 100644 index 00000000..5311d288 --- /dev/null +++ b/dashboard/src/components/Docs/ConfigurationFormat.vue @@ -0,0 +1,42 @@ + + + diff --git a/dashboard/src/components/Docs/Introduction.vue b/dashboard/src/components/Docs/Introduction.vue new file mode 100644 index 00000000..cd59805e --- /dev/null +++ b/dashboard/src/components/Docs/Introduction.vue @@ -0,0 +1,20 @@ + diff --git a/dashboard/src/components/Docs/Layout.vue b/dashboard/src/components/Docs/Layout.vue new file mode 100644 index 00000000..6e6ec93d --- /dev/null +++ b/dashboard/src/components/Docs/Layout.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/dashboard/src/components/Docs/Permissions.vue b/dashboard/src/components/Docs/Permissions.vue new file mode 100644 index 00000000..45058964 --- /dev/null +++ b/dashboard/src/components/Docs/Permissions.vue @@ -0,0 +1,88 @@ + + + diff --git a/dashboard/src/components/Docs/PluginConfiguration.vue b/dashboard/src/components/Docs/PluginConfiguration.vue new file mode 100644 index 00000000..2b2555a8 --- /dev/null +++ b/dashboard/src/components/Docs/PluginConfiguration.vue @@ -0,0 +1,83 @@ + + + diff --git a/dashboard/src/components/Splash.vue b/dashboard/src/components/Splash.vue index 165e2e27..15e1cc0c 100644 --- a/dashboard/src/components/Splash.vue +++ b/dashboard/src/components/Splash.vue @@ -10,8 +10,8 @@ Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind.
- Dashboard - Docs + Dashboard + Docs
Error diff --git a/dashboard/src/directives/trim-code.ts b/dashboard/src/directives/trim-code.ts new file mode 100644 index 00000000..c3ba5259 --- /dev/null +++ b/dashboard/src/directives/trim-code.ts @@ -0,0 +1,11 @@ +import Vue from "vue"; + +Vue.directive("trim-code", { + bind(el, binding) { + el.innerHTML = el.innerHTML + .replace(/(^\n+|\n+$)/g, "") + .split("\n") + .map(line => line.slice(binding.value)) + .join("\n"); + }, +}); diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index 493413a6..1c73d9a0 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -18,7 +18,6 @@ Vue.mixin({ }); import App from "./components/App.vue"; -import Login from "./components/Login.vue"; const app = new Vue({ router, diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index 79d58983..b1b2dacc 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -14,19 +14,43 @@ export const router = new VueRouter({ { path: "/login", beforeEnter: authRedirectGuard }, { path: "/login-callback", beforeEnter: loginCallbackGuard }, + // Docs + { + path: "/docs", + component: () => import("./components/Docs/Layout.vue"), + children: [ + { + path: "", + component: () => import("./components/Docs/Introduction.vue"), + }, + { + path: "configuration-format", + component: () => import("./components/Docs/ConfigurationFormat.vue"), + }, + { + path: "permissions", + component: () => import("./components/Docs/Permissions.vue"), + }, + { + path: "plugin-configuration", + component: () => import("./components/Docs/PluginConfiguration.vue"), + }, + ], + }, + // Dashboard { path: "/dashboard", - component: () => import("./components/Dashboard.vue"), + component: () => import("./components/Dashboard/Layout.vue"), beforeEnter: authGuard, children: [ { path: "", - component: () => import("./components/DashboardGuildList.vue"), + component: () => import("./components/Dashboard/GuildList.vue"), }, { path: "guilds/:guildId/config", - component: () => import("./components/DashboardGuildConfigEditor.vue"), + component: () => import("./components/Dashboard/GuildConfigEditor.vue"), }, ], }, diff --git a/dashboard/src/style/dashboard.scss b/dashboard/src/style/dashboard.scss index 30219c94..d992efc1 100644 --- a/dashboard/src/style/dashboard.scss +++ b/dashboard/src/style/dashboard.scss @@ -3,3 +3,7 @@ $family-primary: 'Open Sans', sans-serif; @import "~bulmaswatch/superhero/_variables"; @import "~bulma/bulma"; @import "~bulmaswatch/superhero/_overrides"; + +.dashboard-cloak { + visibility: visible !important; +} diff --git a/dashboard/src/style/docs.scss b/dashboard/src/style/docs.scss new file mode 100644 index 00000000..8fa5aeb9 --- /dev/null +++ b/dashboard/src/style/docs.scss @@ -0,0 +1,9 @@ +$family-primary: 'Open Sans', sans-serif; + +@import "~bulmaswatch/superhero/_variables"; +@import "~bulma/bulma"; +@import "~bulmaswatch/superhero/_overrides"; + +.docs-cloak { + visibility: visible !important; +} From b065b1b18eaffa6891531a39d0f74a3e154de039 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 28 Jul 2019 18:26:36 +0300 Subject: [PATCH 02/58] Dashboard: rename component folders to lowercase --- .../GuildConfigEditor.vue | 0 .../{Dashboard => dashboard}/GuildList.vue | 0 .../{Dashboard => dashboard}/Layout.vue | 0 .../src/components/{Docs => docs}/CodeBlock.vue | 0 .../{Docs => docs}/ConfigurationFormat.vue | 0 .../components/{Docs => docs}/Introduction.vue | 0 .../src/components/{Docs => docs}/Layout.vue | 0 .../components/{Docs => docs}/Permissions.vue | 0 .../{Docs => docs}/PluginConfiguration.vue | 0 dashboard/src/routes.ts | 16 ++++++++-------- 10 files changed, 8 insertions(+), 8 deletions(-) rename dashboard/src/components/{Dashboard => dashboard}/GuildConfigEditor.vue (100%) rename dashboard/src/components/{Dashboard => dashboard}/GuildList.vue (100%) rename dashboard/src/components/{Dashboard => dashboard}/Layout.vue (100%) rename dashboard/src/components/{Docs => docs}/CodeBlock.vue (100%) rename dashboard/src/components/{Docs => docs}/ConfigurationFormat.vue (100%) rename dashboard/src/components/{Docs => docs}/Introduction.vue (100%) rename dashboard/src/components/{Docs => docs}/Layout.vue (100%) rename dashboard/src/components/{Docs => docs}/Permissions.vue (100%) rename dashboard/src/components/{Docs => docs}/PluginConfiguration.vue (100%) diff --git a/dashboard/src/components/Dashboard/GuildConfigEditor.vue b/dashboard/src/components/dashboard/GuildConfigEditor.vue similarity index 100% rename from dashboard/src/components/Dashboard/GuildConfigEditor.vue rename to dashboard/src/components/dashboard/GuildConfigEditor.vue diff --git a/dashboard/src/components/Dashboard/GuildList.vue b/dashboard/src/components/dashboard/GuildList.vue similarity index 100% rename from dashboard/src/components/Dashboard/GuildList.vue rename to dashboard/src/components/dashboard/GuildList.vue diff --git a/dashboard/src/components/Dashboard/Layout.vue b/dashboard/src/components/dashboard/Layout.vue similarity index 100% rename from dashboard/src/components/Dashboard/Layout.vue rename to dashboard/src/components/dashboard/Layout.vue diff --git a/dashboard/src/components/Docs/CodeBlock.vue b/dashboard/src/components/docs/CodeBlock.vue similarity index 100% rename from dashboard/src/components/Docs/CodeBlock.vue rename to dashboard/src/components/docs/CodeBlock.vue diff --git a/dashboard/src/components/Docs/ConfigurationFormat.vue b/dashboard/src/components/docs/ConfigurationFormat.vue similarity index 100% rename from dashboard/src/components/Docs/ConfigurationFormat.vue rename to dashboard/src/components/docs/ConfigurationFormat.vue diff --git a/dashboard/src/components/Docs/Introduction.vue b/dashboard/src/components/docs/Introduction.vue similarity index 100% rename from dashboard/src/components/Docs/Introduction.vue rename to dashboard/src/components/docs/Introduction.vue diff --git a/dashboard/src/components/Docs/Layout.vue b/dashboard/src/components/docs/Layout.vue similarity index 100% rename from dashboard/src/components/Docs/Layout.vue rename to dashboard/src/components/docs/Layout.vue diff --git a/dashboard/src/components/Docs/Permissions.vue b/dashboard/src/components/docs/Permissions.vue similarity index 100% rename from dashboard/src/components/Docs/Permissions.vue rename to dashboard/src/components/docs/Permissions.vue diff --git a/dashboard/src/components/Docs/PluginConfiguration.vue b/dashboard/src/components/docs/PluginConfiguration.vue similarity index 100% rename from dashboard/src/components/Docs/PluginConfiguration.vue rename to dashboard/src/components/docs/PluginConfiguration.vue diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index b1b2dacc..7ec2ff22 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -17,23 +17,23 @@ export const router = new VueRouter({ // Docs { path: "/docs", - component: () => import("./components/Docs/Layout.vue"), + component: () => import("./components/docs/Layout.vue"), children: [ { path: "", - component: () => import("./components/Docs/Introduction.vue"), + component: () => import("./components/docs/Introduction.vue"), }, { path: "configuration-format", - component: () => import("./components/Docs/ConfigurationFormat.vue"), + component: () => import("./components/docs/ConfigurationFormat.vue"), }, { path: "permissions", - component: () => import("./components/Docs/Permissions.vue"), + component: () => import("./components/docs/Permissions.vue"), }, { path: "plugin-configuration", - component: () => import("./components/Docs/PluginConfiguration.vue"), + component: () => import("./components/docs/PluginConfiguration.vue"), }, ], }, @@ -41,16 +41,16 @@ export const router = new VueRouter({ // Dashboard { path: "/dashboard", - component: () => import("./components/Dashboard/Layout.vue"), + component: () => import("./components/dashboard/Layout.vue"), beforeEnter: authGuard, children: [ { path: "", - component: () => import("./components/Dashboard/GuildList.vue"), + component: () => import("./components/dashboard/GuildList.vue"), }, { path: "guilds/:guildId/config", - component: () => import("./components/Dashboard/GuildConfigEditor.vue"), + component: () => import("./components/dashboard/GuildConfigEditor.vue"), }, ], }, From 17f605dd6d371453ab6f8e3930e4120832e9e157 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 28 Jul 2019 20:13:01 +0300 Subject: [PATCH 03/58] More docs --- .../src/components/docs/Introduction.vue | 10 +++ dashboard/src/components/docs/Layout.vue | 3 +- .../src/components/docs/plugins/Layout.vue | 3 + .../components/docs/plugins/ModActions.vue | 67 +++++++++++++++++++ dashboard/src/routes.ts | 10 +++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/components/docs/plugins/Layout.vue create mode 100644 dashboard/src/components/docs/plugins/ModActions.vue diff --git a/dashboard/src/components/docs/Introduction.vue b/dashboard/src/components/docs/Introduction.vue index cd59805e..899466b8 100644 --- a/dashboard/src/components/docs/Introduction.vue +++ b/dashboard/src/components/docs/Introduction.vue @@ -16,5 +16,15 @@ All Zeppelin configuration is done through the dashboard by editing a YAML config file. By default, only the server owner has access to this, but they can give other users access as they see fit. See Configuration format for more details.

+ +

Plugins

+

+ Zeppelin is divided into plugins: grouped functionality that can be enabled/disabled as needed, and that have their own configurations. +

+ +

Commands

+

+ The commands for each plugin are listed on the plugin's page (see "Plugins" on the menu). On these pages, the command prefix is assumed to be ! but this can be changed on a per-server basis. +

diff --git a/dashboard/src/components/docs/Layout.vue b/dashboard/src/components/docs/Layout.vue index 6e6ec93d..bedea88e 100644 --- a/dashboard/src/components/docs/Layout.vue +++ b/dashboard/src/components/docs/Layout.vue @@ -31,8 +31,7 @@ diff --git a/dashboard/src/components/docs/plugins/Layout.vue b/dashboard/src/components/docs/plugins/Layout.vue new file mode 100644 index 00000000..a44ab87b --- /dev/null +++ b/dashboard/src/components/docs/plugins/Layout.vue @@ -0,0 +1,3 @@ + diff --git a/dashboard/src/components/docs/plugins/ModActions.vue b/dashboard/src/components/docs/plugins/ModActions.vue new file mode 100644 index 00000000..62ca2cfd --- /dev/null +++ b/dashboard/src/components/docs/plugins/ModActions.vue @@ -0,0 +1,67 @@ + + + diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index 7ec2ff22..2affdb0e 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -35,6 +35,16 @@ export const router = new VueRouter({ path: "plugin-configuration", component: () => import("./components/docs/PluginConfiguration.vue"), }, + { + path: "plugins", + component: () => import("./components/docs/plugins/Layout.vue"), + children: [ + { + path: "mod-actions", + component: () => import("./components/docs/plugins/ModActions.vue"), + }, + ], + }, ], }, From 66705678a6105b029120c57f131d344f0f168868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Bl=C3=B6meke?= Date: Mon, 29 Jul 2019 18:55:32 +0200 Subject: [PATCH 04/58] More docs work - Page for Document types and done LocateUser --- dashboard/package.json | 1 + dashboard/src/components/docs/Layout.vue | 6 ++ .../docs/descriptions/ArgumentTypes.vue | 52 ++++++++++ .../components/docs/descriptions/Layout.vue | 3 + .../components/docs/plugins/LocateUser.vue | 95 +++++++++++++++++++ dashboard/src/main.ts | 3 + dashboard/src/routes.ts | 22 +++++ dashboard/src/style/base.scss | 1 + 8 files changed, 183 insertions(+) create mode 100644 dashboard/src/components/docs/descriptions/ArgumentTypes.vue create mode 100644 dashboard/src/components/docs/descriptions/Layout.vue create mode 100644 dashboard/src/components/docs/plugins/LocateUser.vue diff --git a/dashboard/package.json b/dashboard/package.json index 33f47026..8e49c5cc 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -17,6 +17,7 @@ "vue-template-compiler": "^2.6.10" }, "dependencies": { + "buefy": "^0.7.10", "bulma": "^0.7.5", "bulmaswatch": "^0.7.2", "js-cookie": "^2.2.0", diff --git a/dashboard/src/components/docs/Layout.vue b/dashboard/src/components/docs/Layout.vue index bedea88e..4ba0653e 100644 --- a/dashboard/src/components/docs/Layout.vue +++ b/dashboard/src/components/docs/Layout.vue @@ -29,8 +29,14 @@
  • Permissions
  • + + + diff --git a/dashboard/src/components/docs/descriptions/ArgumentTypes.vue b/dashboard/src/components/docs/descriptions/ArgumentTypes.vue new file mode 100644 index 00000000..e371206d --- /dev/null +++ b/dashboard/src/components/docs/descriptions/ArgumentTypes.vue @@ -0,0 +1,52 @@ + + + diff --git a/dashboard/src/components/docs/descriptions/Layout.vue b/dashboard/src/components/docs/descriptions/Layout.vue new file mode 100644 index 00000000..a44ab87b --- /dev/null +++ b/dashboard/src/components/docs/descriptions/Layout.vue @@ -0,0 +1,3 @@ + diff --git a/dashboard/src/components/docs/plugins/LocateUser.vue b/dashboard/src/components/docs/plugins/LocateUser.vue new file mode 100644 index 00000000..1c174139 --- /dev/null +++ b/dashboard/src/components/docs/plugins/LocateUser.vue @@ -0,0 +1,95 @@ + + + diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index 1c73d9a0..ff700e91 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -1,6 +1,8 @@ import "./style/base.scss"; +import "buefy/dist/buefy.css"; import Vue from "vue"; +import Buefy from "buefy"; import { RootStore } from "./store"; import { router } from "./routes"; @@ -19,6 +21,7 @@ Vue.mixin({ import App from "./components/App.vue"; +Vue.use(Buefy); const app = new Vue({ router, store: RootStore, diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index 2affdb0e..23c0329d 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -35,6 +35,16 @@ export const router = new VueRouter({ path: "plugin-configuration", component: () => import("./components/docs/PluginConfiguration.vue"), }, + { + path: "descriptions", + component: () => import("./components/docs/descriptions/Layout.vue"), + children: [ + { + path: "argument-types", + component: () => import("./components/docs/descriptions/ArgumentTypes.vue"), + }, + ], + }, { path: "plugins", component: () => import("./components/docs/plugins/Layout.vue"), @@ -43,6 +53,10 @@ export const router = new VueRouter({ path: "mod-actions", component: () => import("./components/docs/plugins/ModActions.vue"), }, + { + path: "locate-user", + component: () => import("./components/docs/plugins/LocateUser.vue"), + }, ], }, ], @@ -65,4 +79,12 @@ export const router = new VueRouter({ ], }, ], + + scrollBehavior: function(to) { + if (to.hash) { + return { + selector: to.hash, + }; + } + }, }); diff --git a/dashboard/src/style/base.scss b/dashboard/src/style/base.scss index c40f12c3..cf7f1c3d 100644 --- a/dashboard/src/style/base.scss +++ b/dashboard/src/style/base.scss @@ -1,4 +1,5 @@ @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400,600&display=swap'); +@import url('https://cdn.materialdesignicons.com/2.5.94/css/materialdesignicons.min.css'); @import "~bulma/sass/base/minireset"; body { From e67cc27b1012daad359e1660268f63a91b657afe Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 21:30:36 +0300 Subject: [PATCH 05/58] Docs: consistent menu capitalization --- dashboard/src/components/docs/Layout.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/docs/Layout.vue b/dashboard/src/components/docs/Layout.vue index 4ba0653e..3e3265e6 100644 --- a/dashboard/src/components/docs/Layout.vue +++ b/dashboard/src/components/docs/Layout.vue @@ -31,7 +31,7 @@ From cb8392de2c314787d36dc1b1cd077ac431b5644f Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 21:34:19 +0300 Subject: [PATCH 06/58] Include buefy in package-lock.json --- dashboard/package-lock.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 11233f5d..4fb815f1 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1985,6 +1985,14 @@ "node-releases": "^1.1.23" } }, + "buefy": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/buefy/-/buefy-0.7.10.tgz", + "integrity": "sha512-jU9CTEQR1rozxagwEPB69qObBDwWl+4uCa6TjiPkqcqOb/uxq1uvyvCsVinADzjNjzOTFhUOuuSPQk8gsVEOzA==", + "requires": { + "bulma": "0.7.5" + } + }, "buffer": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", From d1c837e89f0ee2d0027f7fd021efe217d5290389 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 21:34:34 +0300 Subject: [PATCH 07/58] Move material icons import to docs.scss for lazy loading --- dashboard/src/style/base.scss | 1 - dashboard/src/style/docs.scss | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dashboard/src/style/base.scss b/dashboard/src/style/base.scss index cf7f1c3d..c40f12c3 100644 --- a/dashboard/src/style/base.scss +++ b/dashboard/src/style/base.scss @@ -1,5 +1,4 @@ @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400,600&display=swap'); -@import url('https://cdn.materialdesignicons.com/2.5.94/css/materialdesignicons.min.css'); @import "~bulma/sass/base/minireset"; body { diff --git a/dashboard/src/style/docs.scss b/dashboard/src/style/docs.scss index 8fa5aeb9..c4d48a3f 100644 --- a/dashboard/src/style/docs.scss +++ b/dashboard/src/style/docs.scss @@ -1,3 +1,5 @@ +@import url('https://cdn.materialdesignicons.com/2.5.94/css/materialdesignicons.min.css'); + $family-primary: 'Open Sans', sans-serif; @import "~bulmaswatch/superhero/_variables"; From c0739ba3268e4f7549b3bef1d815973e02258bfa Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 21:51:25 +0300 Subject: [PATCH 08/58] Docs: add 'work in progress' notification --- dashboard/src/components/docs/Layout.vue | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/dashboard/src/components/docs/Layout.vue b/dashboard/src/components/docs/Layout.vue index 3e3265e6..7e4659a8 100644 --- a/dashboard/src/components/docs/Layout.vue +++ b/dashboard/src/components/docs/Layout.vue @@ -17,6 +17,11 @@ +
    + + Note! This documentation is a work in progress. +
    +
    @@ -76,6 +81,20 @@ font-weight: 600; } + .wip-bar { + padding: 4px 10px; + margin-bottom: 24px; + background-color: #2B3E50; + border-radius: 4px; + } + + .wip-bar i { + color: #fdd7a5; + font-size: 24px; + vertical-align: -3px; + margin-right: 6px; + } + .wrapper { display: flex; } From ab87efac1f3ce252858119e46c48da7153a89398 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 21:51:38 +0300 Subject: [PATCH 09/58] Tweaks --- .../components/docs/plugins/LocateUser.vue | 89 +++++++++---------- 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/dashboard/src/components/docs/plugins/LocateUser.vue b/dashboard/src/components/docs/plugins/LocateUser.vue index 1c174139..14131705 100644 --- a/dashboard/src/components/docs/plugins/LocateUser.vue +++ b/dashboard/src/components/docs/plugins/LocateUser.vue @@ -8,11 +8,11 @@

    Description

    This plugin allows users with access to the commands the following: -

      -
    • Instantly receive an invite to the voice channel of a user
    • -
    • Be notified as soon as a user switches or joins a voice channel
    • -

    +
      +
    • Instantly receive an invite to the voice channel of a user
    • +
    • Be notified as soon as a user switches or joins a voice channel
    • +

    Default configuration

    @@ -30,11 +30,12 @@

    !where

    Permission: can_where
    - Arguments: -

      -
    • <User> The user we want to find
    • -
    - + Arguments: +

    +
      +
    • <User> The user we want to find
    • +
    +

    Sends an instant invite to the voice channel the user from the <User> argument is in.

    !vcalert

    @@ -46,43 +47,41 @@ Shortcut: !vca

    - Sends an instant invite along with a specified reminder once the user switches or joins a voice channel.

    - - -
    -

    - Additional Information -

    - - - - -
    -
    -
    - - Signatures: -
      -
    • !vcalert <user>
    • -
    • !vcalert <user> [delay] [reminderString]
    • -
    - - Arguments: -
      -
    • <user> The user we want to find
    • -
    • [delay] How long the alert should be active, following the default Delay format. Default: 10 minutes
    • -
    • [reminderString] Any text we want to receive once the alert triggers. Default: None
    • -
    -
    -
    -
    - + Sends an instant invite along with a specified reminder once the user switches or joins a voice channel.

    + +
    +
    + Additional Information +
    + + + + +
    +
    +
    + + Signatures: +
      +
    • !vcalert <user>
    • +
    • !vcalert <user> [delay] [reminderString]
    • +
    + + Arguments: +
      +
    • <user> The user we want to find
    • +
    • [delay] How long the alert should be active, following the default Delay format. Default: 10 minutes
    • +
    • [reminderString] Any text we want to receive once the alert triggers. Default: None
    • +
    +
    +
    +
    From 34f7d6f9376c6316e9ba63c962e858df046ca359 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 21:56:03 +0300 Subject: [PATCH 10/58] Docs: update user section of ArgumentTypes (from Dark) --- .../src/components/docs/descriptions/ArgumentTypes.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dashboard/src/components/docs/descriptions/ArgumentTypes.vue b/dashboard/src/components/docs/descriptions/ArgumentTypes.vue index e371206d..a29d8ca6 100644 --- a/dashboard/src/components/docs/descriptions/ArgumentTypes.vue +++ b/dashboard/src/components/docs/descriptions/ArgumentTypes.vue @@ -40,6 +40,14 @@ This page details the different argument types available for commands.

    String

    User

    +

    + Anything that uniquelly identifies a user. This includes: +

    +
      +
    • User ID 108552944961454080
    • +
    • User Mention @Dark#1010
    • +
    • Loose user mention Dark#1010
    • +
    From 3c02935cb41e7c9a56163479f15ec8593bdda403 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 22:31:47 +0300 Subject: [PATCH 11/58] Dashboard: don't collapse whitespace in code blocks (or anywhere) --- dashboard/.htmlnanorc.js | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 dashboard/.htmlnanorc.js diff --git a/dashboard/.htmlnanorc.js b/dashboard/.htmlnanorc.js new file mode 100644 index 00000000..7aeaf2ab --- /dev/null +++ b/dashboard/.htmlnanorc.js @@ -0,0 +1,3 @@ +module.exports = { + collapseWhitespace: false +}; From 735eddae179a2a4994b2c0bac0a45aff16661300 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 22:32:11 +0300 Subject: [PATCH 12/58] Splash: docs -> documentation --- dashboard/src/components/Splash.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/Splash.vue b/dashboard/src/components/Splash.vue index 15e1cc0c..2944f7fd 100644 --- a/dashboard/src/components/Splash.vue +++ b/dashboard/src/components/Splash.vue @@ -11,7 +11,7 @@
    Dashboard - Docs + Documentation
    Error From c5c0ed55d0f869599be4c984ca9ff09fc180283c Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 22:36:04 +0300 Subject: [PATCH 13/58] Dashboard: fix docs link after logging in, move logout button to the right --- dashboard/src/components/dashboard/Layout.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dashboard/src/components/dashboard/Layout.vue b/dashboard/src/components/dashboard/Layout.vue index 9380ca65..5ba8d727 100644 --- a/dashboard/src/components/dashboard/Layout.vue +++ b/dashboard/src/components/dashboard/Layout.vue @@ -12,7 +12,9 @@ From a45d2965e90ca26f19649946d6555164d69531ef Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 22:57:58 +0300 Subject: [PATCH 14/58] Dashboard: disable source maps in prod builds, add debug build with source maps and no minification --- dashboard/package-lock.json | 10 ++++++++++ dashboard/package.json | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 4fb815f1..50b8d39b 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -2416,6 +2416,16 @@ "sha.js": "^2.4.8" } }, + "cross-env": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", + "integrity": "sha512-jtdNFfFW1hB7sMhr/H6rW1Z45LFqyI431m3qU6bFXcQ3Eh7LtBuG3h74o7ohHZ3crrRkkqHlo4jYHFPcjroANg==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.5", + "is-windows": "^1.0.0" + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 8e49c5cc..d873418c 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -4,7 +4,8 @@ "description": "", "private": true, "scripts": { - "build": "rimraf dist && parcel build src/index.html --out-dir dist", + "build": "rimraf dist && parcel build src/index.html --no-source-maps --out-dir dist", + "build-debug": "rimraf dist && cross-env NODE_ENV=development parcel build src/index.html --no-minify --out-dir dist", "watch": "parcel src/index.html" }, "devDependencies": { @@ -12,6 +13,7 @@ "babel-core": "^6.26.3", "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-runtime": "^6.23.0", + "cross-env": "^5.2.0", "parcel-bundler": "^1.12.3", "sass": "^1.21.0", "vue-template-compiler": "^2.6.10" From b52a4e1225fb98022b0d2c3663284a576d6cb991 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 22:58:13 +0300 Subject: [PATCH 15/58] Code style tweak --- dashboard/src/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index 23c0329d..4463c075 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -80,7 +80,7 @@ export const router = new VueRouter({ }, ], - scrollBehavior: function(to) { + scrollBehavior(to) { if (to.hash) { return { selector: to.hash, From 08dec5d7034c4fec6e836d715ecc5e1778918f49 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 29 Jul 2019 22:58:38 +0300 Subject: [PATCH 16/58] Disable dynamic scss imports (weird errors in prod builds, FIXME) --- dashboard/src/components/dashboard/Layout.vue | 5 ++--- dashboard/src/components/docs/Layout.vue | 7 +------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/dashboard/src/components/dashboard/Layout.vue b/dashboard/src/components/dashboard/Layout.vue index 5ba8d727..f23c59d8 100644 --- a/dashboard/src/components/dashboard/Layout.vue +++ b/dashboard/src/components/dashboard/Layout.vue @@ -45,10 +45,9 @@ From 66d6642bc60519360e4639e8b054bf6803a4c5e3 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:14:23 +0300 Subject: [PATCH 17/58] Add forceFresh parameter to resolveMember. Clean up resolveUser/resolveMember code. --- src/plugins/ZeppelinPlugin.ts | 16 +++- src/utils.ts | 133 +++++++++++++++++----------------- 2 files changed, 80 insertions(+), 69 deletions(-) diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index 325a8f17..d91bcb8c 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -4,7 +4,7 @@ import * as t from "io-ts"; import { pipe } from "fp-ts/lib/pipeable"; import { fold } from "fp-ts/lib/Either"; import { PathReporter } from "io-ts/lib/PathReporter"; -import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; +import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, resolveUserId, UnknownUser } from "../utils"; import { Member, User } from "eris"; import { performance } from "perf_hooks"; import { validateStrict } from "../validatorUtils"; @@ -126,14 +126,24 @@ export class ZeppelinPlugin extends Plug * Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. * If the member is not found in the cache, it's fetched from the API. */ - async getMember(memberResolvable: string): Promise { + async getMember(memberResolvable: string, forceFresh = false): Promise { const start = performance.now(); - const member = await resolveMember(this.bot, this.guild, memberResolvable); + + let member; + if (forceFresh) { + const userId = await resolveUserId(this.bot, memberResolvable); + member = userId && (await this.bot.getRESTGuildMember(this.guild.id, userId)); + if (member) member.id = member.user.id; + } else { + member = await resolveMember(this.bot, this.guild, memberResolvable); + } + const time = performance.now() - start; if (time >= SLOW_RESOLVE_THRESHOLD) { const rounded = Math.round(time); logger.warn(`Slow member resolve (${rounded}ms): ${memberResolvable} in ${this.guild.name} (${this.guild.id})`); } + return member; } } diff --git a/src/utils.ts b/src/utils.ts index c41fa665..3ef6ee21 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -31,6 +31,12 @@ const delayStringMultipliers = { s: 1000, }; +export const MS = 1; +export const SECONDS = 1000 * MS; +export const MINUTES = 60 * SECONDS; +export const HOURS = 60 * MINUTES; +export const DAYS = 24 * HOURS; + export function tNullable(type: t.Mixed) { return t.union([type, t.undefined, t.null]); } @@ -571,91 +577,86 @@ export class UnknownUser { const unknownUsers = new Set(); const unknownMembers = new Set(); +export function resolveUserId(bot: Client, value: string) { + // A user mention? + const mentionMatch = value.match(/^<@!?(\d+)>$/); + if (mentionMatch) { + return mentionMatch[1]; + } + + // A non-mention, full username? + const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/); + if (usernameMatch) { + const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); + if (user) return user.id; + } + + // Just a user ID? + const idMatch = value.match(/^\d+$/); + if (idMatch) { + return value; + } + + return null; +} + export async function resolveUser(bot: Client, value: string): Promise { if (value == null || typeof value !== "string") { return new UnknownUser(); } - let userId; - - // A user mention? - const mentionMatch = value.match(/^<@!?(\d+)>$/); - if (mentionMatch) { - userId = mentionMatch[1]; + // If we have the user cached, return that directly + const userId = resolveUserId(bot, value); + if (bot.users.has(userId)) { + return bot.users.get(userId); } - // A non-mention, full username? - if (!userId) { - const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/); - if (usernameMatch) { - const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); - if (user) userId = user.id; - } + // We don't want to spam the API by trying to fetch unknown users again and again, + // so we cache the fact that they're "unknown" for a while + if (unknownUsers.has(userId)) { + return new UnknownUser({ id: userId }); } - // Just a user ID? - if (!userId) { - const idMatch = value.match(/^\d+$/); - if (!idMatch) { - return null; - } - - userId = value; + const freshUser = await bot.getRESTUser(userId); + if (freshUser) { + bot.users.add(freshUser, bot); + return freshUser; } - const cachedUser = bot.users.find(u => u.id === userId); - if (cachedUser) return cachedUser; - - // We only fetch the user from the API if we haven't tried it before: - // - If the user was found, the bot has them in its cache - // - If the user was not found, they'll be in unknownUsers - if (!unknownUsers.has(userId)) { - try { - const freshUser = await bot.getRESTUser(userId); - bot.users.add(freshUser, bot); - return freshUser; - } catch (e) {} // tslint:disable-line - - unknownUsers.add(userId); - } + unknownUsers.add(userId); + setTimeout(() => unknownUsers.delete(userId), 15 * MINUTES); return new UnknownUser({ id: userId }); } export async function resolveMember(bot: Client, guild: Guild, value: string): Promise { - // Start by resolving the user - const user = await resolveUser(bot, value); - if (!user || user instanceof UnknownUser) return null; + const userId = resolveUserId(bot, value); + if (!userId) return null; - // See if we have the member cached... - let member = guild.members.get(user.id); - - // We only fetch the member from the API if we haven't tried it before: - // - If the member was found, the bot has them in the guild's member cache - // - If the member was not found, they'll be in unknownMembers - const unknownKey = `${guild.id}-${user.id}`; - if (!unknownMembers.has(unknownKey)) { - // If not, fetch it from the API - if (!member) { - try { - logger.debug(`Fetching unknown member (${user.id} in ${guild.name} (${guild.id})) from the API`); - - member = await bot.getRESTGuildMember(guild.id, user.id); - member.id = user.id; - member.guild = guild; - } catch (e) {} // tslint:disable-line - } - - if (!member) unknownMembers.add(unknownKey); + // If we have the member cached, return that directly + if (guild.members.has(userId)) { + return guild.members.get(userId); } - return member; + // We don't want to spam the API by trying to fetch unknown members again and again, + // so we cache the fact that they're "unknown" for a while + const unknownKey = `${guild.id}-${userId}`; + if (unknownMembers.has(unknownKey)) { + return null; + } + + logger.debug(`Fetching unknown member (${userId} in ${guild.name} (${guild.id})) from the API`); + + const freshMember = await bot.getRESTGuildMember(guild.id, userId).catch(noop); + if (freshMember) { + freshMember.id = userId; + return freshMember; + } + + unknownMembers.add(unknownKey); + setTimeout(() => unknownMembers.delete(unknownKey), 15 * MINUTES); + + return null; } -export const MS = 1; -export const SECONDS = 1000 * MS; -export const MINUTES = 60 * SECONDS; -export const HOURS = 60 * MINUTES; -export const DAYS = 24 * HOURS; - export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions }; From 09464e56e30dc6ff0bc4f817e5919c48fbda7212 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:16:01 +0300 Subject: [PATCH 18/58] Mutes: fix mutes/unmutes sometimes not applying/removing the role --- src/plugins/Mutes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/Mutes.ts b/src/plugins/Mutes.ts index 926cc84d..de9c6e38 100644 --- a/src/plugins/Mutes.ts +++ b/src/plugins/Mutes.ts @@ -139,7 +139,7 @@ export class MutesPlugin extends ZeppelinPlugin { } const user = await this.resolveUser(userId); - const member = await this.getMember(user.id); + const member = await this.getMember(user.id, true); // Grab the fresh member so we don't have stale role info if (member) { // Apply mute role if it's missing @@ -264,7 +264,7 @@ export class MutesPlugin extends ZeppelinPlugin { if (!existingMute) return; const user = await this.resolveUser(userId); - const member = await this.getMember(userId); + const member = await this.getMember(userId, true); // Grab the fresh member so we don't have stale role info if (unmuteTime) { // Schedule timed unmute (= just set the mute's duration) From f0ba484f7995fd5571e459e81a5f4acc2e4f59a4 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:16:23 +0300 Subject: [PATCH 19/58] Utility: make sure the data in !info is always fresh --- src/plugins/Utility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/Utility.ts b/src/plugins/Utility.ts index de49a7f4..be5be4e4 100644 --- a/src/plugins/Utility.ts +++ b/src/plugins/Utility.ts @@ -607,7 +607,7 @@ export class UtilityPlugin extends ZeppelinPlugin { let member; if (!(user instanceof UnknownUser)) { - member = await this.getMember(user.id); + member = await this.getMember(user.id, true); } const embed: EmbedOptions = { From 5aa1f3cec9e93825004b53854351088322086c38 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:16:46 +0300 Subject: [PATCH 20/58] Code cleanup --- src/plugins/LocateUser.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/plugins/LocateUser.ts b/src/plugins/LocateUser.ts index 1fde5681..59d0d612 100644 --- a/src/plugins/LocateUser.ts +++ b/src/plugins/LocateUser.ts @@ -3,8 +3,8 @@ import { ZeppelinPlugin } from "./ZeppelinPlugin"; import humanizeDuration from "humanize-duration"; import { Message, Member, Guild, TextableChannel, VoiceChannel, Channel, User } from "eris"; import { GuildVCAlerts } from "../data/GuildVCAlerts"; -import moment = require("moment"); -import { resolveMember, sorter, createChunkedMessage, errorMessage, successMessage } from "../utils"; +import moment from "moment-timezone"; +import { resolveMember, sorter, createChunkedMessage, errorMessage, successMessage, MINUTES } from "../utils"; import * as t from "io-ts"; const ConfigSchema = t.type({ @@ -71,7 +71,7 @@ export class LocatePlugin extends ZeppelinPlugin { @d.command("where", "", {}) @d.permission("can_where") async whereCmd(msg: Message, args: { member: Member; time?: number; reminder?: string }) { - let member = await resolveMember(this.bot, this.guild, args.member.id); + const member = await resolveMember(this.bot, this.guild, args.member.id); sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`); } @@ -80,9 +80,9 @@ export class LocatePlugin extends ZeppelinPlugin { }) @d.permission("can_alert") async vcalertCmd(msg: Message, args: { member: Member; duration?: number; reminder?: string }) { - let time = args.duration || 600000; - let alertTime = moment().add(time, "millisecond"); - let body = args.reminder || "None"; + const time = args.duration || 10 * MINUTES; + const alertTime = moment().add(time, "millisecond"); + const body = args.reminder || "None"; this.alerts.add(msg.author.id, args.member.id, msg.channel.id, alertTime.format("YYYY-MM-DD HH:mm:ss"), body); if (!this.usersWithAlerts.includes(args.member.id)) { @@ -162,10 +162,10 @@ export class LocatePlugin extends ZeppelinPlugin { const member = await resolveMember(this.bot, this.guild, userid); triggeredAlerts.forEach(alert => { - let prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${ + const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${ alert.body }\`\n`; - sendWhere(this.guild, member, this.bot.getChannel(alert.channel_id), prepend); + sendWhere(this.guild, member, this.bot.getChannel(alert.channel_id) as TextableChannel, prepend); this.alerts.delete(alert.id); }); } @@ -179,12 +179,12 @@ export class LocatePlugin extends ZeppelinPlugin { } export async function sendWhere(guild: Guild, member: Member, channel: TextableChannel, prepend: string) { - let voice = await (guild.channels.get(member.voiceState.channelID)); + const voice = guild.channels.get(member.voiceState.channelID) as VoiceChannel; if (voice == null) { channel.createMessage(prepend + "That user is not in a channel"); } else { - let invite = await createInvite(voice); + const invite = await createInvite(voice); channel.createMessage( prepend + ` ${member.mention} is in the following channel: ${voice.name} https://${getInviteLink(invite)}`, ); @@ -192,7 +192,7 @@ export async function sendWhere(guild: Guild, member: Member, channel: TextableC } export async function createInvite(vc: VoiceChannel) { - let existingInvites = await vc.getInvites(); + const existingInvites = await vc.getInvites(); if (existingInvites.length !== 0) { return existingInvites[0]; From aab3a0cb1df5d404d46884a0762514eed97986eb Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:18:02 +0300 Subject: [PATCH 21/58] Post: don't confirm post commands if the target channel is the same as the command channel --- src/plugins/Post.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/plugins/Post.ts b/src/plugins/Post.ts index 5822b17a..4d8572bd 100644 --- a/src/plugins/Post.ts +++ b/src/plugins/Post.ts @@ -261,7 +261,9 @@ export class PostPlugin extends ZeppelinPlugin { } else { // Post the message immediately await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]); - this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`); + if (args.channel.id !== msg.channel.id) { + this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`); + } } } @@ -349,7 +351,9 @@ export class PostPlugin extends ZeppelinPlugin { const createdMsg = await args.channel.createMessage({ embed }); this.savedMessages.setPermanent(createdMsg.id); - await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`); + if (msg.channel.id !== args.channel.id) { + await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`); + } } if (args.content) { From 460562d485ba84fd1cfbf25454a4ffaa2f2c674b Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:18:26 +0300 Subject: [PATCH 22/58] tslint: enable no-floating-promises --- tslint.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tslint.json b/tslint.json index 5de5cf89..f1048f51 100644 --- a/tslint.json +++ b/tslint.json @@ -22,6 +22,7 @@ "no-bitwise": false, "interface-over-type-literal": false, "interface-name": false, - "no-submodule-imports": false + "no-submodule-imports": false, + "no-floating-promises": true } } From 4034c6a850b78682eba64b4b43510a2d640bc46f Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:18:41 +0300 Subject: [PATCH 23/58] Also run tslint on pre-commit --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index c29277a1..87cb7144 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ }, "lint-staged": { "*.ts": [ + "tslint", "prettier --write", "git add" ] From 68380c5ac3b21cabed254f4cd3e86a312da2ae35 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:41:35 +0300 Subject: [PATCH 24/58] Add support for safe regex type checking; make sure regexes passed to Censor are safe --- package-lock.json | 34 +++++++++++++++++++++++++++------- package.json | 1 + src/plugins/Censor.ts | 4 +++- src/validatorUtils.ts | 17 +++++++++++++++-- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1da7a64a..c0f72f58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9882,13 +9882,23 @@ "requires": { "extend-shallow": "^3.0.2", "safe-regex": "^1.1.0" + }, + "dependencies": { + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + } } }, "regexp-tree": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.11.tgz", - "integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg==", - "dev": true + "integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg==" }, "regexpu-core": { "version": "4.5.4", @@ -10122,12 +10132,11 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.0.2.tgz", + "integrity": "sha512-rRALJT0mh4qVFIJ9HvfjKDN77F9vp7kltOpFFI/8e6oKyHFmmxz4aSkY/YVauRDe7U0RrHdw9Lsxdel3E19s0A==", "requires": { - "ret": "~0.1.10" + "regexp-tree": "~0.1.1" } }, "safer-buffer": { @@ -10887,6 +10896,17 @@ "extend-shallow": "^3.0.2", "regex-not": "^1.0.2", "safe-regex": "^1.1.0" + }, + "dependencies": { + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + } } }, "to-regex-range": { diff --git a/package.json b/package.json index 87cb7144..117f548d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "passport-custom": "^1.0.5", "passport-oauth2": "^1.5.0", "reflect-metadata": "^0.1.12", + "safe-regex": "^2.0.2", "seedrandom": "^3.0.1", "tlds": "^1.203.1", "tmp": "0.0.33", diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts index 7ee32dca..5d67fa5a 100644 --- a/src/plugins/Censor.ts +++ b/src/plugins/Censor.ts @@ -17,6 +17,7 @@ import { SavedMessage } from "../data/entities/SavedMessage"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; import cloneDeep from "lodash.clonedeep"; import * as t from "io-ts"; +import { TSafeRegexString } from "../validatorUtils"; const ConfigSchema = t.type({ filter_zalgo: t.boolean, @@ -31,12 +32,13 @@ const ConfigSchema = t.type({ domain_blacklist: tNullable(t.array(t.string)), blocked_tokens: tNullable(t.array(t.string)), blocked_words: tNullable(t.array(t.string)), - blocked_regex: tNullable(t.array(t.string)), + blocked_regex: tNullable(t.array(TSafeRegexString)), }); type TConfigSchema = t.TypeOf; export class CensorPlugin extends ZeppelinPlugin { public static pluginName = "censor"; + protected static configSchema = ConfigSchema; protected serverLogs: GuildLogs; protected savedMessages: GuildSavedMessages; diff --git a/src/validatorUtils.ts b/src/validatorUtils.ts index a1ca1944..1ddd758e 100644 --- a/src/validatorUtils.ts +++ b/src/validatorUtils.ts @@ -1,8 +1,19 @@ import * as t from "io-ts"; import { pipe } from "fp-ts/lib/pipeable"; -import { fold } from "fp-ts/lib/Either"; +import { fold, either } from "fp-ts/lib/Either"; import { noop } from "./utils"; import deepDiff from "deep-diff"; +import safeRegex from "safe-regex"; + +export const TSafeRegexString = new t.Type( + "TSafeRegexString", + (s): s is string => typeof s === "string", + (from, to) => + either.chain(t.string.validate(from, to), s => { + return safeRegex(s) ? t.success(s) : t.failure(from, to, "Unsafe regex"); + }), + s => s, +); // From io-ts/lib/PathReporter function stringify(v) { @@ -35,10 +46,12 @@ const report = fold((errors: any) => { return errors.map(err => { if (err.message) return err.message; const context = err.context.map(c => c.key).filter(k => k && !k.startsWith("{")); + if (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1); + const value = stringify(err.value); return value === undefined ? `<${context.join("/")}> is required` - : `Invalid value <${stringify(err.value)}> supplied to <${context.join("/")}>`; + : `Invalid value supplied to <${context.join("/")}>`; }); }, noop); From 6282c13b7020f77831a1e48e91c67175061a00b8 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 13:42:39 +0300 Subject: [PATCH 25/58] Don't fetch all users on load (again...) --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index ae56d20f..57b45acd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,7 +78,7 @@ import { AllowedGuilds } from "./data/AllowedGuilds"; logger.info("Connecting to database"); connect().then(async conn => { const client = new Client(`Bot ${process.env.TOKEN}`, { - getAllUsers: true, + getAllUsers: false, restMode: true, }); client.setMaxListeners(100); From 6043fd5cd309fd5e393dc614e6d14dddac7c0e20 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 15:44:41 +0300 Subject: [PATCH 26/58] Configs are not decoded as well as validated by io-ts. Improvements to config validation, error messages, and TSafeRegex type. --- src/configValidator.ts | 6 +- src/plugins/GlobalZeppelinPlugin.ts | 98 +++++++++++++++++---- src/plugins/ZeppelinPlugin.ts | 127 +++++++++++++++++++--------- src/utils.ts | 15 +++- src/validatorUtils.ts | 51 ++++++++--- 5 files changed, 223 insertions(+), 74 deletions(-) diff --git a/src/configValidator.ts b/src/configValidator.ts index 0e60ad48..6411477b 100644 --- a/src/configValidator.ts +++ b/src/configValidator.ts @@ -5,7 +5,7 @@ import { fold } from "fp-ts/lib/Either"; import { PathReporter } from "io-ts/lib/PathReporter"; import { availablePlugins } from "./plugins/availablePlugins"; import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; -import { validateStrict } from "./validatorUtils"; +import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils"; const pluginNameToClass = new Map(); for (const pluginClass of availablePlugins) { @@ -29,8 +29,8 @@ const globalConfigRootSchema = t.type({ const partialMegaTest = t.partial({ name: t.string }); export function validateGuildConfig(config: any): string[] | null { - const rootErrors = validateStrict(partialGuildConfigRootSchema, config); - if (rootErrors) return rootErrors; + const validationResult = decodeAndValidateStrict(partialGuildConfigRootSchema, config); + if (validationResult instanceof StrictValidationError) return validationResult.getErrors(); if (config.plugins) { for (const [pluginName, pluginOptions] of Object.entries(config.plugins)) { diff --git a/src/plugins/GlobalZeppelinPlugin.ts b/src/plugins/GlobalZeppelinPlugin.ts index cc182be0..397e8c3f 100644 --- a/src/plugins/GlobalZeppelinPlugin.ts +++ b/src/plugins/GlobalZeppelinPlugin.ts @@ -4,10 +4,11 @@ import * as t from "io-ts"; import { pipe } from "fp-ts/lib/pipeable"; import { fold } from "fp-ts/lib/Either"; import { PathReporter } from "io-ts/lib/PathReporter"; -import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; +import { deepKeyIntersect, isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; import { Member, User } from "eris"; import { performance } from "perf_hooks"; -import { validateStrict } from "../validatorUtils"; +import { decodeAndValidateStrict, StrictValidationErrors } from "../validatorUtils"; +import { mergeConfig } from "knub/dist/configUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; @@ -15,25 +16,90 @@ export class GlobalZeppelinPlugin extend protected static configSchema: t.TypeC; public static dependencies = []; - public static validateOptions(options: IPluginOptions): string[] | null { + /** + * Since we want to do type checking without creating instances of every plugin, + * we need a static version of getDefaultOptions(). This static version is then, + * by turn, called from getDefaultOptions() so everything still works as expected. + */ + protected static getStaticDefaultOptions() { + // Implemented by plugin + return {}; + } + + /** + * Wrapper to fetch the real default options from getStaticDefaultOptions() + */ + protected getDefaultOptions(): IPluginOptions { + return (this.constructor as typeof GlobalZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions; + } + + /** + * Merges the given options and default options and decodes them according to the config schema of the plugin (if any). + * Throws on any decoding/validation errors. + * + * Intended as an augmented, static replacement for Plugin.getMergedConfig() which is why this is also called from + * getMergedConfig(). + * + * Like getStaticDefaultOptions(), we also want to use this function for type checking without creating an instance of + * the plugin, which is why this has to be a static function. + */ + protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions { + const defaultOptions: any = this.getStaticDefaultOptions(); + const mergedConfig = mergeConfig({}, defaultOptions.config || {}, options.config || {}); + const mergedOverrides = options["=overrides"] + ? options["=overrides"] + : (options.overrides || []).concat(defaultOptions.overrides || []); + + const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; + if (decodedConfig instanceof StrictValidationErrors) { + throw new Error(decodedConfig.getErrors().join("\n")); + } + + const decodedOverrides = []; + for (const override of mergedOverrides) { + const overrideConfigMergedWithBaseConfig = mergeConfig({}, mergedConfig, override.config); + const decodedOverrideConfig = this.configSchema + ? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig) + : overrideConfigMergedWithBaseConfig; + if (decodedOverrideConfig instanceof StrictValidationErrors) { + throw new Error(decodedConfig.getErrors().join("\n")); + } + decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config) }); + } + + return { + config: decodedConfig, + overrides: decodedOverrides, + }; + } + + /** + * Wrapper that calls mergeAndValidateStaticOptions() + */ + protected getMergedOptions(): IPluginOptions { + if (!this.mergedPluginOptions) { + this.mergedPluginOptions = ((this + .constructor as unknown) as typeof GlobalZeppelinPlugin).mergeAndDecodeStaticOptions(this.pluginOptions); + } + + return this.mergedPluginOptions as IPluginOptions; + } + + /** + * Run static type checks and other validations on the given options + */ + public static validateOptions(options: any): string[] | null { // Validate config values if (this.configSchema) { - if (options.config) { - const errors = validateStrict(this.configSchema, options.config); - if (errors) return errors; - } - - if (options.overrides) { - for (const override of options.overrides) { - if (override.config) { - const errors = validateStrict(this.configSchema, override.config); - if (errors) return errors; - } - } - } + this.mergeAndDecodeStaticOptions(options); } // No errors, return null return null; } + + public async runLoad(): Promise { + const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config + return super.runLoad(); + } } diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index d91bcb8c..c9c596e8 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -4,10 +4,19 @@ import * as t from "io-ts"; import { pipe } from "fp-ts/lib/pipeable"; import { fold } from "fp-ts/lib/Either"; import { PathReporter } from "io-ts/lib/PathReporter"; -import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, resolveUserId, UnknownUser } from "../utils"; +import { + deepKeyIntersect, + isSnowflake, + isUnicodeEmoji, + resolveMember, + resolveUser, + resolveUserId, + UnknownUser, +} from "../utils"; import { Member, User } from "eris"; import { performance } from "perf_hooks"; -import { validateStrict } from "../validatorUtils"; +import { decodeAndValidateStrict, StrictValidationErrors } from "../validatorUtils"; +import { mergeConfig } from "knub/dist/configUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; @@ -29,50 +38,83 @@ export class ZeppelinPlugin extends Plug return ourLevel > memberLevel; } + /** + * Since we want to do type checking without creating instances of every plugin, + * we need a static version of getDefaultOptions(). This static version is then, + * by turn, called from getDefaultOptions() so everything still works as expected. + */ protected static getStaticDefaultOptions() { // Implemented by plugin return {}; } + /** + * Wrapper to fetch the real default options from getStaticDefaultOptions() + */ protected getDefaultOptions(): IPluginOptions { return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions; } + /** + * Merges the given options and default options and decodes them according to the config schema of the plugin (if any). + * Throws on any decoding/validation errors. + * + * Intended as an augmented, static replacement for Plugin.getMergedConfig() which is why this is also called from + * getMergedConfig(). + * + * Like getStaticDefaultOptions(), we also want to use this function for type checking without creating an instance of + * the plugin, which is why this has to be a static function. + */ + protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions { + const defaultOptions: any = this.getStaticDefaultOptions(); + const mergedConfig = mergeConfig({}, defaultOptions.config || {}, options.config || {}); + const mergedOverrides = options["=overrides"] + ? options["=overrides"] + : (options.overrides || []).concat(defaultOptions.overrides || []); + + const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; + if (decodedConfig instanceof StrictValidationErrors) { + throw new Error(decodedConfig.getErrors().join("\n")); + } + + const decodedOverrides = []; + for (const override of mergedOverrides) { + const overrideConfigMergedWithBaseConfig = mergeConfig({}, mergedConfig, override.config || {}); + const decodedOverrideConfig = this.configSchema + ? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig) + : overrideConfigMergedWithBaseConfig; + if (decodedOverrideConfig instanceof StrictValidationErrors) { + throw new Error(decodedConfig.getErrors().join("\n")); + } + decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config || {}) }); + } + + return { + config: decodedConfig, + overrides: decodedOverrides, + }; + } + + /** + * Wrapper that calls mergeAndValidateStaticOptions() + */ + protected getMergedOptions(): IPluginOptions { + if (!this.mergedPluginOptions) { + this.mergedPluginOptions = ((this.constructor as unknown) as typeof ZeppelinPlugin).mergeAndDecodeStaticOptions( + this.pluginOptions, + ); + } + + return this.mergedPluginOptions as IPluginOptions; + } + + /** + * Run static type checks and other validations on the given options + */ public static validateOptions(options: any): string[] | null { // Validate config values if (this.configSchema) { - if (options.config) { - const merged = configUtils.mergeConfig( - {}, - (this.getStaticDefaultOptions() as any).config || {}, - options.config, - ); - const errors = validateStrict(this.configSchema, merged); - if (errors) { - return errors; - } - } - - if (options.overrides) { - for (const [i, override] of options.overrides.entries()) { - if (override.config) { - // For type checking overrides, apply default config + supplied config + any overrides preceding this override + finally this override - // Exhaustive type checking would require checking against all combinations of preceding overrides but that's... costy. This will do for now. - // TODO: Override default config retrieval functions and do some sort of memoized checking there? - const merged = configUtils.mergeConfig( - {}, - (this.getStaticDefaultOptions() as any).config || {}, - options.config || {}, - ...options.overrides.slice(0, i).map(o => o.config || {}), - override.config, - ); - const errors = validateStrict(this.configSchema, merged); - if (errors) { - return errors; - } - } - } - } + this.mergeAndDecodeStaticOptions(options); } // No errors, return null @@ -80,12 +122,7 @@ export class ZeppelinPlugin extends Plug } public async runLoad(): Promise { - const mergedOptions = this.getMergedOptions(); - const validationErrors = ((this.constructor as unknown) as typeof ZeppelinPlugin).validateOptions(mergedOptions); - if (validationErrors) { - throw new Error(validationErrors.join("\n")); - } - + const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config return super.runLoad(); } @@ -103,10 +140,20 @@ export class ZeppelinPlugin extends Plug } } + /** + * Intended for cross-plugin functionality + */ public getRegisteredCommands() { return this.commands.commands; } + /** + * Intended for cross-plugin functionality + */ + public getRuntimeOptions() { + return this.getMergedOptions(); + } + /** * Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. * If the user is not found in the cache, it's fetched from the API. diff --git a/src/utils.ts b/src/utils.ts index 3ef6ee21..029e8037 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -37,7 +37,7 @@ export const MINUTES = 60 * SECONDS; export const HOURS = 60 * MINUTES; export const DAYS = 24 * HOURS; -export function tNullable(type: t.Mixed) { +export function tNullable>(type: T) { return t.union([type, t.undefined, t.null]); } @@ -574,6 +574,19 @@ export class UnknownUser { } } +export function deepKeyIntersect(obj, keyReference) { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + if (!keyReference.hasOwnProperty(key)) continue; + if (value != null && typeof value === "object" && typeof keyReference[key] === "object") { + result[key] = deepKeyIntersect(value, keyReference[key]); + } else { + result[key] = value; + } + } + return result; +} + const unknownUsers = new Set(); const unknownMembers = new Set(); diff --git a/src/validatorUtils.ts b/src/validatorUtils.ts index 1ddd758e..544ae73e 100644 --- a/src/validatorUtils.ts +++ b/src/validatorUtils.ts @@ -5,14 +5,23 @@ import { noop } from "./utils"; import deepDiff from "deep-diff"; import safeRegex from "safe-regex"; -export const TSafeRegexString = new t.Type( - "TSafeRegexString", - (s): s is string => typeof s === "string", +const regexWithFlags = /^\/(.*?)\/([i]*)$/; + +/** + * The TSafeRegex type supports two syntaxes for regexes: // and just + * The value is then checked for "catastrophic exponential-time regular expressions" by + * https://www.npmjs.com/package/safe-regex + */ +export const TSafeRegex = new t.Type( + "TSafeRegex", + (s): s is RegExp => s instanceof RegExp, (from, to) => either.chain(t.string.validate(from, to), s => { - return safeRegex(s) ? t.success(s) : t.failure(from, to, "Unsafe regex"); + const advancedSyntaxMatch = s.match(regexWithFlags); + const [regexStr, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [s, ""]; + return safeRegex(regexStr) ? t.success(new RegExp(regexStr, flags)) : t.failure(from, to, "Unsafe regex"); }), - s => s, + s => `/${s.source}/${s.flags}`, ); // From io-ts/lib/PathReporter @@ -42,24 +51,38 @@ function getContextPath(context) { } // tslint:enable -const report = fold((errors: any) => { - return errors.map(err => { - if (err.message) return err.message; +export class StrictValidationError extends Error { + private errors; + + constructor(errors: string[]) { + errors = Array.from(new Set(errors)); + super(errors.join("\n")); + this.errors = errors; + } + getErrors() { + return this.errors; + } +} + +const report = fold((errors: any): StrictValidationError | void => { + const errorStrings = errors.map(err => { const context = err.context.map(c => c.key).filter(k => k && !k.startsWith("{")); - if (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1); + while (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1); const value = stringify(err.value); return value === undefined ? `<${context.join("/")}> is required` - : `Invalid value supplied to <${context.join("/")}>`; + : `Invalid value supplied to <${context.join("/")}>${err.message ? `: ${err.message}` : ""}`; }); + + return new StrictValidationError(errorStrings); }, noop); /** - * Validates the given value against the given schema while also disallowing extra properties + * Decodes and validates the given value against the given schema while also disallowing extra properties * See: https://github.com/gcanti/io-ts/issues/322 */ -export function validateStrict(schema: t.HasProps, value: any): string[] | null { +export function decodeAndValidateStrict(schema: t.HasProps, value: any): StrictValidationError | any { const validationResult = t.exact(schema).decode(value); return pipe( validationResult, @@ -70,10 +93,10 @@ export function validateStrict(schema: t.HasProps, value: any): string[] | null if (JSON.stringify(value) !== JSON.stringify(result)) { const diff = deepDiff(result, value); const errors = diff.filter(d => d.kind === "N").map(d => `Unknown property <${d.path.join(".")}>`); - return errors.length ? errors : ["Found unknown properties"]; + if (errors.length) return new StrictValidationError(errors); } - return null; + return result; }, ), ); From a188b0433c5344f9e22b048a6283b6b00139f3f3 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 15:45:35 +0300 Subject: [PATCH 27/58] api: crash on unhandled rejections --- src/api/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/api/index.ts b/src/api/index.ts index 113d8990..8daeda13 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,9 +7,19 @@ import { initArchives } from "./archives"; import { connect } from "../data/db"; import path from "path"; import { TokenError } from "passport-oauth2"; +import { PluginError } from "knub"; require("dotenv").config({ path: path.resolve(__dirname, "..", "..", "api.env") }); +function errorHandler(err) { + // tslint:disable:no-console + console.error(err.stack || err); + process.exit(1); + // tslint:enable:no-console +} + +process.on("unhandledRejection", errorHandler); + console.log("Connecting to database..."); connect().then(() => { const app = express(); From e0bef49a84abb914c471b3193968011c37bcd58c Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 15:46:09 +0300 Subject: [PATCH 28/58] BotControl: more robust owner checking, use GlobalZeppelinPlugin, add show_plugin_config command --- src/plugins/BotControl.ts | 92 +++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/src/plugins/BotControl.ts b/src/plugins/BotControl.ts index 6288600e..de60cd7d 100644 --- a/src/plugins/BotControl.ts +++ b/src/plugins/BotControl.ts @@ -1,32 +1,61 @@ -import { decorators as d, GlobalPlugin, IPluginOptions } from "knub"; +import { decorators as d, IPluginOptions } from "knub"; import child_process from "child_process"; import { GuildChannel, Message, TextChannel } from "eris"; -import { createChunkedMessage, errorMessage, noop, sorter, successMessage } from "../utils"; +import moment from "moment-timezone"; +import { createChunkedMessage, errorMessage, noop, sorter, successMessage, tNullable } from "../utils"; import { ReactionRolesPlugin } from "./ReactionRoles"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { GuildArchives } from "../data/GuildArchives"; +import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; +import * as t from "io-ts"; let activeReload: [string, string] = null; -interface IBotControlPluginConfig { - owners: string[]; - update_cmd: string; -} +const ConfigSchema = t.type({ + can_use: t.boolean, + owners: t.array(t.string), + update_cmd: tNullable(t.string), +}); +type TConfigSchema = t.TypeOf; /** * A global plugin that allows bot owners to control the bot */ -export class BotControlPlugin extends GlobalPlugin { +export class BotControlPlugin extends GlobalZeppelinPlugin { public static pluginName = "bot_control"; + protected static configSchema = ConfigSchema; - getDefaultOptions(): IPluginOptions { + protected archives: GuildArchives; + + protected static getStaticDefaultOptions() { return { config: { + can_use: false, owners: [], update_cmd: null, }, + overrides: [ + { + level: ">=100", + config: { + can_use: true, + }, + }, + ], }; } + protected getMemberLevel(member) { + if (this.getConfig().owners.includes(member.id)) { + return 100; + } + + return 0; + } + async onLoad() { + this.archives = new GuildArchives(0); + if (activeReload) { const [guildId, channelId] = activeReload; activeReload = null; @@ -46,9 +75,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("bot_full_update") + @d.permission("can_use") async fullUpdateCmd(msg: Message) { - if (!this.isOwner(msg.author.id)) return; - const updateCmd = this.getConfig().update_cmd; if (!updateCmd) { msg.channel.createMessage(errorMessage("Update command not specified!")); @@ -64,8 +92,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("bot_reload_global_plugins") + @d.permission("can_use") async reloadGlobalPluginsCmd(msg: Message) { - if (!this.isOwner(msg.author.id)) return; if (activeReload) return; if (msg.channel) { @@ -77,8 +105,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("perf") + @d.permission("can_use") async perfCmd(msg: Message) { - if (!this.isOwner(msg.author.id)) return; const perfItems = this.knub.getPerformanceDebugItems(); if (perfItems.length) { @@ -90,9 +118,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("refresh_reaction_roles_globally") + @d.permission("can_use") async refreshAllReactionRolesCmd(msg: Message) { - if (!this.isOwner(msg.author.id)) return; - const guilds = this.knub.getLoadedGuilds(); for (const guild of guilds) { if (guild.loadedPlugins.has("reaction_roles")) { @@ -103,9 +130,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("guilds") + @d.permission("can_use") async serversCmd(msg: Message) { - if (!this.isOwner(msg.author.id)) return; - const joinedGuilds = Array.from(this.bot.guilds.values()); const loadedGuilds = this.knub.getLoadedGuilds(); const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.id, guildData), new Map()); @@ -122,9 +148,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("leave_guild", "") + @d.permission("can_use") async leaveGuildCmd(msg: Message, args: { guildId: string }) { - if (!this.isOwner(msg.author.id)) return; - if (!this.bot.guilds.has(args.guildId)) { msg.channel.createMessage(errorMessage("I am not in that guild")); return; @@ -144,9 +169,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("reload_guild", "") + @d.permission("can_use") async reloadGuildCmd(msg: Message, args: { guildId: string }) { - if (!this.isOwner(msg.author.id)) return; - if (!this.bot.guilds.has(args.guildId)) { msg.channel.createMessage(errorMessage("I am not in that guild")); return; @@ -164,9 +188,8 @@ export class BotControlPlugin extends GlobalPlugin { } @d.command("reload_all_guilds") + @d.permission("can_use") async reloadAllGuilds(msg: Message) { - if (!this.isOwner(msg.author.id)) return; - const failedReloads: Map = new Map(); let reloadCount = 0; @@ -191,4 +214,29 @@ export class BotControlPlugin extends GlobalPlugin { msg.channel.createMessage(successMessage(`Reloaded ${reloadCount} guild(s)`)); } } + + @d.command("show_plugin_config", " ") + @d.permission("can_use") + async showPluginConfig(msg: Message, args: { guildId: string; pluginName: string }) { + const guildData = this.knub.getGuildData(args.guildId); + if (!guildData) { + msg.channel.createMessage(errorMessage(`Guild not loaded`)); + return; + } + + const pluginInstance = guildData.loadedPlugins.get(args.pluginName); + if (!pluginInstance) { + msg.channel.createMessage(errorMessage(`Plugin not loaded`)); + return; + } + + if (!(pluginInstance instanceof ZeppelinPlugin)) { + msg.channel.createMessage(errorMessage(`Plugin is not a Zeppelin plugin`)); + return; + } + + const opts = pluginInstance.getRuntimeOptions(); + const archiveId = await this.archives.create(JSON.stringify(opts, null, 2), moment().add(15, "minutes")); + msg.channel.createMessage(this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId)); + } } From a602164c13e848131e0e0a7d7adbc7b13f48ccf9 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 15:46:36 +0300 Subject: [PATCH 29/58] Forgot to commit these a couple commits ago --- src/plugins/GlobalZeppelinPlugin.ts | 20 ++++++++++++++------ src/plugins/ZeppelinPlugin.ts | 20 ++++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/plugins/GlobalZeppelinPlugin.ts b/src/plugins/GlobalZeppelinPlugin.ts index 397e8c3f..b1dba184 100644 --- a/src/plugins/GlobalZeppelinPlugin.ts +++ b/src/plugins/GlobalZeppelinPlugin.ts @@ -7,7 +7,7 @@ import { PathReporter } from "io-ts/lib/PathReporter"; import { deepKeyIntersect, isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; import { Member, User } from "eris"; import { performance } from "perf_hooks"; -import { decodeAndValidateStrict, StrictValidationErrors } from "../validatorUtils"; +import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils"; import { mergeConfig } from "knub/dist/configUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; @@ -51,8 +51,8 @@ export class GlobalZeppelinPlugin extend : (options.overrides || []).concat(defaultOptions.overrides || []); const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; - if (decodedConfig instanceof StrictValidationErrors) { - throw new Error(decodedConfig.getErrors().join("\n")); + if (decodedConfig instanceof StrictValidationError) { + throw decodedConfig; } const decodedOverrides = []; @@ -61,8 +61,8 @@ export class GlobalZeppelinPlugin extend const decodedOverrideConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig) : overrideConfigMergedWithBaseConfig; - if (decodedOverrideConfig instanceof StrictValidationErrors) { - throw new Error(decodedConfig.getErrors().join("\n")); + if (decodedOverrideConfig instanceof StrictValidationError) { + throw decodedOverrideConfig; } decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config) }); } @@ -91,7 +91,15 @@ export class GlobalZeppelinPlugin extend public static validateOptions(options: any): string[] | null { // Validate config values if (this.configSchema) { - this.mergeAndDecodeStaticOptions(options); + try { + this.mergeAndDecodeStaticOptions(options); + } catch (e) { + if (e instanceof StrictValidationError) { + return e.getErrors(); + } + + throw e; + } } // No errors, return null diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index c9c596e8..da252d9c 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -15,7 +15,7 @@ import { } from "../utils"; import { Member, User } from "eris"; import { performance } from "perf_hooks"; -import { decodeAndValidateStrict, StrictValidationErrors } from "../validatorUtils"; +import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils"; import { mergeConfig } from "knub/dist/configUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; @@ -73,8 +73,8 @@ export class ZeppelinPlugin extends Plug : (options.overrides || []).concat(defaultOptions.overrides || []); const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; - if (decodedConfig instanceof StrictValidationErrors) { - throw new Error(decodedConfig.getErrors().join("\n")); + if (decodedConfig instanceof StrictValidationError) { + throw decodedConfig; } const decodedOverrides = []; @@ -83,8 +83,8 @@ export class ZeppelinPlugin extends Plug const decodedOverrideConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig) : overrideConfigMergedWithBaseConfig; - if (decodedOverrideConfig instanceof StrictValidationErrors) { - throw new Error(decodedConfig.getErrors().join("\n")); + if (decodedOverrideConfig instanceof StrictValidationError) { + throw decodedOverrideConfig; } decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config || {}) }); } @@ -114,7 +114,15 @@ export class ZeppelinPlugin extends Plug public static validateOptions(options: any): string[] | null { // Validate config values if (this.configSchema) { - this.mergeAndDecodeStaticOptions(options); + try { + this.mergeAndDecodeStaticOptions(options); + } catch (e) { + if (e instanceof StrictValidationError) { + return e.getErrors(); + } + + throw e; + } } // No errors, return null From 82a0fa4b4304419e1b3460381a8e252aed5b8da7 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 15:47:09 +0300 Subject: [PATCH 30/58] Use GlobalZeppelinPlugin when applicable --- src/plugins/GuildConfigReloader.ts | 1 + src/plugins/UsernameSaver.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/GuildConfigReloader.ts b/src/plugins/GuildConfigReloader.ts index 2fd82e5c..ed180910 100644 --- a/src/plugins/GuildConfigReloader.ts +++ b/src/plugins/GuildConfigReloader.ts @@ -13,6 +13,7 @@ const CHECK_INTERVAL = 1000; */ export class GuildConfigReloader extends GlobalZeppelinPlugin { public static pluginName = "guild_config_reloader"; + protected guildConfigs: Configs; private unloaded = false; private highestConfigId; diff --git a/src/plugins/UsernameSaver.ts b/src/plugins/UsernameSaver.ts index 41095f1f..fe6388b0 100644 --- a/src/plugins/UsernameSaver.ts +++ b/src/plugins/UsernameSaver.ts @@ -1,8 +1,9 @@ import { decorators as d, GlobalPlugin } from "knub"; import { UsernameHistory } from "../data/UsernameHistory"; import { Member, User } from "eris"; +import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; -export class UsernameSaver extends GlobalPlugin { +export class UsernameSaver extends GlobalZeppelinPlugin { public static pluginName = "username_saver"; protected usernameHistory: UsernameHistory; From e4f1a6eb15f012beffa6d5e896dbdbf5e1d1b486 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 15:47:42 +0300 Subject: [PATCH 31/58] Censor: use decoded TSafeRegex for blocked_regex, disable default i flag(!) --- src/plugins/Censor.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts index 5d67fa5a..f1854f6b 100644 --- a/src/plugins/Censor.ts +++ b/src/plugins/Censor.ts @@ -17,7 +17,7 @@ import { SavedMessage } from "../data/entities/SavedMessage"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; import cloneDeep from "lodash.clonedeep"; import * as t from "io-ts"; -import { TSafeRegexString } from "../validatorUtils"; +import { TSafeRegex } from "../validatorUtils"; const ConfigSchema = t.type({ filter_zalgo: t.boolean, @@ -32,7 +32,7 @@ const ConfigSchema = t.type({ domain_blacklist: tNullable(t.array(t.string)), blocked_tokens: tNullable(t.array(t.string)), blocked_words: tNullable(t.array(t.string)), - blocked_regex: tNullable(t.array(TSafeRegexString)), + blocked_regex: tNullable(t.array(TSafeRegex)), }); type TConfigSchema = t.TypeOf; @@ -238,12 +238,12 @@ export class CensorPlugin extends ZeppelinPlugin { } // Filter regex - const blockedRegex = config.blocked_regex || []; - for (const regexStr of blockedRegex) { - const regex = new RegExp(regexStr, "i"); + const blockedRegex: RegExp[] = config.blocked_regex || []; + for (const regex of blockedRegex) { + // Support supplying your own regex flags with the // syntax // We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly if (regex.test(savedMessage.data.content) || regex.test(messageContent)) { - this.censorMessage(savedMessage, `blocked regex (\`${regexStr}\`) found`); + this.censorMessage(savedMessage, `blocked regex (\`${regex.source}\`) found`); return true; } } From a1aa995a7afa1babead4dad180755f79290dfb32 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 16:47:42 +0300 Subject: [PATCH 32/58] Fix for non-object-literals in deepKeyIntersect --- src/plugins/ZeppelinPlugin.ts | 5 ++++- src/utils.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index da252d9c..ed71efa4 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -86,7 +86,10 @@ export class ZeppelinPlugin extends Plug if (decodedOverrideConfig instanceof StrictValidationError) { throw decodedOverrideConfig; } - decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config || {}) }); + decodedOverrides.push({ + ...override, + config: deepKeyIntersect(decodedOverrideConfig, override.config || {}), + }); } return { diff --git a/src/utils.ts b/src/utils.ts index 029e8037..832484b9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -574,11 +574,19 @@ export class UnknownUser { } } +export function isObjectLiteral(obj) { + let deepestPrototype = obj; + while (Object.getPrototypeOf(deepestPrototype) != null) { + deepestPrototype = Object.getPrototypeOf(deepestPrototype); + } + return Object.getPrototypeOf(obj) === deepestPrototype; +} + export function deepKeyIntersect(obj, keyReference) { const result = {}; for (const [key, value] of Object.entries(obj)) { if (!keyReference.hasOwnProperty(key)) continue; - if (value != null && typeof value === "object" && typeof keyReference[key] === "object") { + if (value != null && typeof value === "object" && typeof keyReference[key] === "object" && isObjectLiteral(value)) { result[key] = deepKeyIntersect(value, keyReference[key]); } else { result[key] = value; From 08d49ad4778e3d89c02294a19af812116bf11414 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 16:51:42 +0300 Subject: [PATCH 33/58] Some improvements to 'unknown member' error reporting --- src/plugins/ModActions.ts | 9 +++++++-- src/plugins/ZeppelinPlugin.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index 21a57f44..dca53619 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -496,8 +496,13 @@ export class ModActionsPlugin extends ZeppelinPlugin { ppId: pp && pp.id, }); } catch (e) { - logger.error(`Failed to mute user ${user.id}: ${e.stack}`); - msg.channel.createMessage(errorMessage("Could not mute the user")); + if (e instanceof DiscordRESTError && e.code === 10007) { + msg.channel.createMessage(errorMessage("Could not mute the user: unknown member")); + } else { + logger.error(`Failed to mute user ${user.id}: ${e.stack}`); + msg.channel.createMessage(errorMessage("Could not mute the user")); + } + return; } diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index ed71efa4..7848855a 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -14,6 +14,7 @@ import { UnknownUser, } from "../utils"; import { Member, User } from "eris"; +import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line import { performance } from "perf_hooks"; import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils"; import { mergeConfig } from "knub/dist/configUtils"; @@ -190,7 +191,14 @@ export class ZeppelinPlugin extends Plug let member; if (forceFresh) { const userId = await resolveUserId(this.bot, memberResolvable); - member = userId && (await this.bot.getRESTGuildMember(this.guild.id, userId)); + try { + member = userId && (await this.bot.getRESTGuildMember(this.guild.id, userId)); + } catch (e) { + if (!(e instanceof DiscordRESTError)) { + throw e; + } + } + if (member) member.id = member.user.id; } else { member = await resolveMember(this.bot, this.guild, memberResolvable); From 72729fdc1494c870d4eb6d6d247ef77cfe7a4790 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 17:13:49 +0300 Subject: [PATCH 34/58] Update Knub to 20.3.2 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0f72f58..74c6992e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8299,9 +8299,9 @@ "dev": true }, "knub": { - "version": "20.3.1", - "resolved": "https://registry.npmjs.org/knub/-/knub-20.3.1.tgz", - "integrity": "sha512-aSLCvP6CM5aNxtXCABdctTwU0XylPqpP5g2RL1qccvHyDF36GCuahBy8fkGB6RfnSCTHN9sABeGvM4Qidgo/rw==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/knub/-/knub-20.3.2.tgz", + "integrity": "sha512-Dg9I0QT1cEEJuH53/lGM+2k7LyBefJBTYQMBdcU9hvLCPI4ow/NhnfP9wn1JbpRqshatbIKfLAV7nwBxcU51oA==", "requires": { "escape-string-regexp": "^1.0.5", "lodash.at": "^4.6.0", diff --git a/package.json b/package.json index 117f548d..45021639 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "humanize-duration": "^3.15.0", "io-ts": "^2.0.0", "js-yaml": "^3.13.1", - "knub": "^20.3.1", + "knub": "^20.3.2", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", From d679ab8b72fde3c537dfb31842034fe23ff9cef6 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 17:30:47 +0300 Subject: [PATCH 35/58] Temporary fixes to deepKeyIntersect while config modifiers are still a thing --- src/utils.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 832484b9..aca89a6e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -582,11 +582,33 @@ export function isObjectLiteral(obj) { return Object.getPrototypeOf(obj) === deepestPrototype; } +const keyMods = ["+", "-", "="]; export function deepKeyIntersect(obj, keyReference) { const result = {}; - for (const [key, value] of Object.entries(obj)) { - if (!keyReference.hasOwnProperty(key)) continue; - if (value != null && typeof value === "object" && typeof keyReference[key] === "object" && isObjectLiteral(value)) { + for (let [key, value] of Object.entries(obj)) { + if (!keyReference.hasOwnProperty(key)) { + // Temporary solution so we don't erase keys with modifiers + // Modifiers will be removed soon(tm) so we can remove this when that happens as well + let found = false; + for (const mod of keyMods) { + if (keyReference.hasOwnProperty(mod + key)) { + key = mod + key; + found = true; + break; + } + } + if (!found) continue; + } + + if (Array.isArray(value)) { + // Also temp (because modifier shenanigans) + result[key] = keyReference[key]; + } else if ( + value != null && + typeof value === "object" && + typeof keyReference[key] === "object" && + isObjectLiteral(value) + ) { result[key] = deepKeyIntersect(value, keyReference[key]); } else { result[key] = value; From 77ed889adcb2ae946c27474019cd5efb31bb715d Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 18:08:20 +0300 Subject: [PATCH 36/58] Debug --- src/plugins/Censor.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts index f1854f6b..d77deb2c 100644 --- a/src/plugins/Censor.ts +++ b/src/plugins/Censor.ts @@ -1,4 +1,4 @@ -import { IPluginOptions } from "knub"; +import { IPluginOptions, logger } from "knub"; import { Invite, Embed } from "eris"; import escapeStringRegexp from "escape-string-regexp"; import { GuildLogs } from "../data/GuildLogs"; @@ -239,8 +239,16 @@ export class CensorPlugin extends ZeppelinPlugin { // Filter regex const blockedRegex: RegExp[] = config.blocked_regex || []; - for (const regex of blockedRegex) { - // Support supplying your own regex flags with the // syntax + for (const [i, regex] of blockedRegex.entries()) { + if (typeof regex.test !== "function") { + logger.debug( + `Regex <${regex}> was not a regex; index ${i} of censor.blocked_regex for guild ${this.guild.name} (${ + this.guild.id + })`, + ); + continue; + } + // We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly if (regex.test(savedMessage.data.content) || regex.test(messageContent)) { this.censorMessage(savedMessage, `blocked regex (\`${regex.source}\`) found`); From a7507fa4efaa57d0d2320dd5fbdad2427f5eba02 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 4 Aug 2019 19:20:46 +0300 Subject: [PATCH 37/58] Debug2 --- src/plugins/Censor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts index d77deb2c..69a9ad86 100644 --- a/src/plugins/Censor.ts +++ b/src/plugins/Censor.ts @@ -241,8 +241,8 @@ export class CensorPlugin extends ZeppelinPlugin { const blockedRegex: RegExp[] = config.blocked_regex || []; for (const [i, regex] of blockedRegex.entries()) { if (typeof regex.test !== "function") { - logger.debug( - `Regex <${regex}> was not a regex; index ${i} of censor.blocked_regex for guild ${this.guild.name} (${ + logger.info( + `[DEBUG] Regex <${regex}> was not a regex; index ${i} of censor.blocked_regex for guild ${this.guild.name} (${ this.guild.id })`, ); From 4f8dc4f0ae3478610b74469cb5f9535e58a77c5b Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 5 Aug 2019 01:39:11 +0300 Subject: [PATCH 38/58] ModActions: ignore server errors when getting bans --- src/plugins/ModActions.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index dca53619..a3006bd7 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -1,6 +1,7 @@ import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub"; import { Attachment, Constants as ErisConstants, Guild, Member, Message, TextChannel, User } from "eris"; import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line +import DiscordHTTPError from "eris/lib/errors/DiscordHTTPError"; // tslint:disable-line import humanizeDuration from "humanize-duration"; import { GuildCases } from "../data/GuildCases"; import { @@ -161,8 +162,16 @@ export class ModActionsPlugin extends ZeppelinPlugin { } async isBanned(userId): Promise { - const bans = (await this.guild.getBans()) as any; - return bans.some(b => b.user.id === userId); + try { + const bans = (await this.guild.getBans()) as any; + return bans.some(b => b.user.id === userId); + } catch (e) { + if (e instanceof DiscordHTTPError && e.code === 500) { + return false; + } + + throw e; + } } async findRelevantAuditLogEntry(actionType: number, userId: string, attempts?: number, attemptDelay?: number) { From 5a1cf205a720caad84a1a91c8e99954856b7947e Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 5 Aug 2019 01:40:27 +0300 Subject: [PATCH 39/58] utils: safety checks to resolveUser; ignore a tslint error --- src/utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index aca89a6e..33fb6af3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -404,7 +404,7 @@ export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path if (retries === 0) { throw httpsErr; } else { - console.warn("File download failed, retrying. Error given:", httpsErr.message); + console.warn("File download failed, retrying. Error given:", httpsErr.message); // tslint:disable-line resolve(downloadFile(attachmentUrl, retries - 1)); } }); @@ -650,6 +650,10 @@ export async function resolveUser(bot: Client, value: string): Promise Date: Sat, 10 Aug 2019 00:11:02 +0300 Subject: [PATCH 40/58] Update Knub to 21.0.0 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74c6992e..9604236d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8299,9 +8299,9 @@ "dev": true }, "knub": { - "version": "20.3.2", - "resolved": "https://registry.npmjs.org/knub/-/knub-20.3.2.tgz", - "integrity": "sha512-Dg9I0QT1cEEJuH53/lGM+2k7LyBefJBTYQMBdcU9hvLCPI4ow/NhnfP9wn1JbpRqshatbIKfLAV7nwBxcU51oA==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/knub/-/knub-21.0.0.tgz", + "integrity": "sha512-hHANshytor8RO3Jd5EjAZ9sP0emcfuTvBtqe3S+2UmMFk3HxSXLXuGsVfKL151vfKC5P3VDIOiQdl52ankBT5A==", "requires": { "escape-string-regexp": "^1.0.5", "lodash.at": "^4.6.0", diff --git a/package.json b/package.json index 45021639..9f7077d2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "humanize-duration": "^3.15.0", "io-ts": "^2.0.0", "js-yaml": "^3.13.1", - "knub": "^20.3.2", + "knub": "^21.0.0", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", From 307f95e0f4df3d2aa27389814e70051eb6936f4c Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 10 Aug 2019 00:12:16 +0300 Subject: [PATCH 41/58] Add a standardized way of checking for bot owners in global plugins --- src/plugins/BotControl.ts | 10 +--------- src/plugins/GlobalZeppelinPlugin.ts | 5 +++++ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/plugins/BotControl.ts b/src/plugins/BotControl.ts index de60cd7d..2345438e 100644 --- a/src/plugins/BotControl.ts +++ b/src/plugins/BotControl.ts @@ -46,11 +46,7 @@ export class BotControlPlugin extends GlobalZeppelinPlugin { } protected getMemberLevel(member) { - if (this.getConfig().owners.includes(member.id)) { - return 100; - } - - return 0; + return this.isOwner(member.id) ? 100 : 0; } async onLoad() { @@ -70,10 +66,6 @@ export class BotControlPlugin extends GlobalZeppelinPlugin { } } - isOwner(userId) { - return this.getConfig().owners.includes(userId); - } - @d.command("bot_full_update") @d.permission("can_use") async fullUpdateCmd(msg: Message) { diff --git a/src/plugins/GlobalZeppelinPlugin.ts b/src/plugins/GlobalZeppelinPlugin.ts index b1dba184..c49da5aa 100644 --- a/src/plugins/GlobalZeppelinPlugin.ts +++ b/src/plugins/GlobalZeppelinPlugin.ts @@ -110,4 +110,9 @@ export class GlobalZeppelinPlugin extend const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config return super.runLoad(); } + + protected isOwner(userId) { + const owners = this.knub.getGlobalConfig().owners || []; + return owners.includes(userId); + } } From 733855ac7067d0c4d806cfceceedfaf90a27b58c Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 10 Aug 2019 00:13:35 +0300 Subject: [PATCH 42/58] Add standardized function for asking the user to confirm an action --- src/utils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 33fb6af3..1cfdfe8b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,6 +6,7 @@ import { GuildAuditLog, GuildAuditLogEntry, Member, + MessageContent, TextableChannel, TextChannel, User, @@ -21,7 +22,7 @@ const fsp = fs.promises; import https from "https"; import tmp from "tmp"; -import { logger } from "knub"; +import { logger, waitForReaction } from "knub"; const delayStringMultipliers = { w: 1000 * 60 * 60 * 24 * 7, @@ -707,3 +708,10 @@ export async function resolveMember(bot: Client, guild: Guild, value: string): P } export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions }; + +export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) { + const msg = await channel.createMessage(content); + const reply = await waitForReaction(bot, msg, ["✅", "❌"], userId); + msg.delete().catch(noop); + return reply && reply.name === "✅"; +} From 66ac85e3004157a14292511072bd87b7c9b85cad Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 10 Aug 2019 00:13:55 +0300 Subject: [PATCH 43/58] Add ChannelArchiver plugin --- src/plugins/ChannelArchiver.ts | 141 ++++++++++++++++++++++++++++++++ src/plugins/availablePlugins.ts | 2 + 2 files changed, 143 insertions(+) create mode 100644 src/plugins/ChannelArchiver.ts diff --git a/src/plugins/ChannelArchiver.ts b/src/plugins/ChannelArchiver.ts new file mode 100644 index 00000000..859e30e7 --- /dev/null +++ b/src/plugins/ChannelArchiver.ts @@ -0,0 +1,141 @@ +import { decorators as d, logger } from "knub"; +import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; +import { Attachment, GuildChannel, Message, TextChannel } from "eris"; +import { confirm, downloadFile, errorMessage, noop, SECONDS, trimLines } from "../utils"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import moment from "moment-timezone"; +import https from "https"; +import fs from "fs"; +const fsp = fs.promises; + +const MAX_ARCHIVED_MESSAGES = 5000; +const MAX_MESSAGES_PER_FETCH = 100; +const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS; +const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8; + +export class ChannelArchiverPlugin extends ZeppelinPlugin { + public static pluginName = "channel_archiver"; + + protected isOwner(userId) { + const owners = this.knub.getGlobalConfig().owners || []; + return owners.includes(userId); + } + + protected async rehostAttachment(attachment: Attachment, targetChannel: TextChannel): Promise { + if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) { + return "Attachment too big to rehost"; + } + + let downloaded; + try { + downloaded = await downloadFile(attachment.url, 3); + } catch (e) { + return "Failed to download attachment after 3 tries"; + } + + try { + const rehostMessage = await targetChannel.createMessage(`Rehost of attachment ${attachment.id}`, { + name: attachment.filename, + file: await fsp.readFile(downloaded.path), + }); + return rehostMessage.attachments[0].url; + } catch (e) { + return "Failed to rehost attachment"; + } + } + + @d.command("archive_channel", "", { + options: [ + { + name: "attachment-channel", + type: "textChannel", + }, + { + name: "messages", + type: "number", + }, + ], + preFilters: [ + (msg, command, plugin: ChannelArchiverPlugin) => { + return plugin.isOwner(msg.author.id); + }, + ], + }) + protected async archiveCmd( + msg: Message, + args: { channel: TextChannel; "attachment-channel"?: TextChannel; messages?: number }, + ) { + if (!this.isOwner(msg.author.id)) return; + + if (!args["attachment-channel"]) { + const confirmed = await confirm( + this.bot, + msg.channel, + msg.author.id, + "No `--attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", + ); + if (!confirmed) { + msg.channel.createMessage(errorMessage("Canceled")); + return; + } + } + + const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES; + if (maxMessagesToArchive <= 0) return; + + const archiveLines = []; + let archivedMessages = 0; + let previousId; + + const startTime = Date.now(); + const progressMsg = await msg.channel.createMessage("Creating archive..."); + const progressUpdateInterval = setInterval(() => { + const secondsSinceStart = Math.round((Date.now() - startTime) / 1000); + progressMsg + .edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`) + .catch(() => clearInterval(progressUpdateInterval)); + }, PROGRESS_UPDATE_INTERVAL); + + while (archivedMessages < maxMessagesToArchive) { + const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); + const messages = await msg.channel.getMessages(messagesToFetch, previousId); + + for (const message of messages) { + const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss"); + let content = `[${ts}] [${message.author.id}] [${message.author.username}#${ + message.author.discriminator + }]: ${message.content || ""}`; + if (message.attachments.length) { + if (args["attachment-channel"]) { + const rehostedAttachmentUrl = await this.rehostAttachment( + message.attachments[0], + args["attachment-channel"], + ); + content += `\n-- Attachment: ${rehostedAttachmentUrl}`; + } else { + content += `\n-- Attachment: ${message.attachments[0].url}`; + } + } + + archiveLines.push(content); + previousId = message.id; + archivedMessages++; + } + } + + clearInterval(progressUpdateInterval); + + archiveLines.reverse(); + + const nowTs = moment().format("YYYY-MM-DD HH:mm:ss"); + + let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`; + result += `\n\n${archiveLines.join("\n")}\n`; + + progressMsg.delete().catch(noop); + msg.channel.createMessage("Archive created!", { + file: Buffer.from(result), + name: `archive-${args.channel.name}-${moment().format("YYYY-MM-DD-HH-mm-ss")}.txt`, + }); + } +} diff --git a/src/plugins/availablePlugins.ts b/src/plugins/availablePlugins.ts index 1ee8f998..86172803 100644 --- a/src/plugins/availablePlugins.ts +++ b/src/plugins/availablePlugins.ts @@ -25,6 +25,7 @@ import { GuildInfoSaverPlugin } from "./GuildInfoSaver"; import { CompanionChannelPlugin } from "./CompanionChannels"; import { LocatePlugin } from "./LocateUser"; import { GuildConfigReloader } from "./GuildConfigReloader"; +import { ChannelArchiverPlugin } from "./ChannelArchiver"; /** * Plugins available to be loaded for individual guilds @@ -54,6 +55,7 @@ export const availablePlugins = [ GuildInfoSaverPlugin, CompanionChannelPlugin, LocatePlugin, + ChannelArchiverPlugin, ]; /** From 64f7b8cce4ff011a96dd053e8bcf04e5ff8112fc Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 10 Aug 2019 00:22:25 +0300 Subject: [PATCH 44/58] ChannelArchiver: fix archiving of the wrong channel --- src/plugins/ChannelArchiver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/ChannelArchiver.ts b/src/plugins/ChannelArchiver.ts index 859e30e7..a4bd2779 100644 --- a/src/plugins/ChannelArchiver.ts +++ b/src/plugins/ChannelArchiver.ts @@ -98,7 +98,7 @@ export class ChannelArchiverPlugin extends ZeppelinPlugin { while (archivedMessages < maxMessagesToArchive) { const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); - const messages = await msg.channel.getMessages(messagesToFetch, previousId); + const messages = await args.channel.getMessages(messagesToFetch, previousId); for (const message of messages) { const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss"); From cd07a3e32e1c6c2b769f623e8e053c59324235f7 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 10 Aug 2019 00:28:18 +0300 Subject: [PATCH 45/58] ChannelArchiver: break out of the loop if there are no more messages to archive --- src/plugins/ChannelArchiver.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/ChannelArchiver.ts b/src/plugins/ChannelArchiver.ts index a4bd2779..4704630b 100644 --- a/src/plugins/ChannelArchiver.ts +++ b/src/plugins/ChannelArchiver.ts @@ -99,6 +99,7 @@ export class ChannelArchiverPlugin extends ZeppelinPlugin { while (archivedMessages < maxMessagesToArchive) { const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); const messages = await args.channel.getMessages(messagesToFetch, previousId); + if (messages.length === 0) break; for (const message of messages) { const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss"); From afd677b270ee1a51ce930acf27d047f6ff6e0a21 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 10 Aug 2019 00:43:58 +0300 Subject: [PATCH 46/58] ChannelArchiver: include message reactions in archive --- src/plugins/ChannelArchiver.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/plugins/ChannelArchiver.ts b/src/plugins/ChannelArchiver.ts index 4704630b..3ce48be9 100644 --- a/src/plugins/ChannelArchiver.ts +++ b/src/plugins/ChannelArchiver.ts @@ -106,6 +106,7 @@ export class ChannelArchiverPlugin extends ZeppelinPlugin { let content = `[${ts}] [${message.author.id}] [${message.author.username}#${ message.author.discriminator }]: ${message.content || ""}`; + if (message.attachments.length) { if (args["attachment-channel"]) { const rehostedAttachmentUrl = await this.rehostAttachment( @@ -118,6 +119,14 @@ export class ChannelArchiverPlugin extends ZeppelinPlugin { } } + if (message.reactions && Object.keys(message.reactions).length > 0) { + const reactionCounts = []; + for (const [emoji, info] of Object.entries(message.reactions)) { + reactionCounts.push(`${info.count}x ${emoji}`); + } + content += `\n-- Reactions: ${reactionCounts.join(", ")}`; + } + archiveLines.push(content); previousId = message.id; archivedMessages++; From 8d8343543d82034c184ab4ac7ed5e65215020521 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 10 Aug 2019 01:47:45 +0300 Subject: [PATCH 47/58] Fix userid/channelid argument types; rename userid/userID to userId for consistency; misc code style fix --- src/plugins/AutoReactionsPlugin.ts | 4 ++-- src/plugins/LocateUser.ts | 16 ++++++++-------- src/plugins/NameHistory.ts | 4 ++-- src/plugins/PingableRolesPlugin.ts | 4 ++-- src/plugins/Starboard.ts | 4 ++-- src/plugins/Utility.ts | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/plugins/AutoReactionsPlugin.ts b/src/plugins/AutoReactionsPlugin.ts index e937dc7d..c8cfe67a 100644 --- a/src/plugins/AutoReactionsPlugin.ts +++ b/src/plugins/AutoReactionsPlugin.ts @@ -49,7 +49,7 @@ export class AutoReactionsPlugin extends ZeppelinPlugin { this.savedMessages.events.off("create", this.onMessageCreateFn); } - @d.command("auto_reactions", " ") + @d.command("auto_reactions", " ") @d.permission("can_manage") async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) { const finalReactions = []; @@ -83,7 +83,7 @@ export class AutoReactionsPlugin extends ZeppelinPlugin { msg.channel.createMessage(successMessage(`Auto-reactions set for <#${args.channelId}>`)); } - @d.command("auto_reactions disable", "") + @d.command("auto_reactions disable", "") @d.permission("can_manage") async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) { const autoReaction = await this.autoReactions.getForChannel(args.channelId); diff --git a/src/plugins/LocateUser.ts b/src/plugins/LocateUser.ts index 59d0d612..ada922aa 100644 --- a/src/plugins/LocateUser.ts +++ b/src/plugins/LocateUser.ts @@ -52,7 +52,7 @@ export class LocatePlugin extends ZeppelinPlugin { for (const alert of outdatedAlerts) { await this.alerts.delete(alert.id); - await this.removeUserIDFromActiveAlerts(alert.user_id); + await this.removeUserIdFromActiveAlerts(alert.user_id); } this.outdatedAlertsTimeout = setTimeout(() => this.outdatedAlertsLoop(), ALERT_LOOP_TIME); @@ -137,7 +137,7 @@ export class LocatePlugin extends ZeppelinPlugin { async userJoinedVC(member: Member, channel: Channel) { if (this.usersWithAlerts.includes(member.id)) { this.sendAlerts(member.id); - await this.removeUserIDFromActiveAlerts(member.id); + await this.removeUserIdFromActiveAlerts(member.id); } } @@ -145,7 +145,7 @@ export class LocatePlugin extends ZeppelinPlugin { async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) { if (this.usersWithAlerts.includes(member.id)) { this.sendAlerts(member.id); - await this.removeUserIDFromActiveAlerts(member.id); + await this.removeUserIdFromActiveAlerts(member.id); } } @@ -157,9 +157,9 @@ export class LocatePlugin extends ZeppelinPlugin { }); } - async sendAlerts(userid: string) { - const triggeredAlerts = await this.alerts.getAlertsByUserId(userid); - const member = await resolveMember(this.bot, this.guild, userid); + async sendAlerts(userId: string) { + const triggeredAlerts = await this.alerts.getAlertsByUserId(userId); + const member = await resolveMember(this.bot, this.guild, userId); triggeredAlerts.forEach(alert => { const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${ @@ -170,8 +170,8 @@ export class LocatePlugin extends ZeppelinPlugin { }); } - async removeUserIDFromActiveAlerts(userid: string) { - const index = this.usersWithAlerts.indexOf(userid); + async removeUserIdFromActiveAlerts(userId: string) { + const index = this.usersWithAlerts.indexOf(userId); if (index > -1) { this.usersWithAlerts.splice(index, 1); } diff --git a/src/plugins/NameHistory.ts b/src/plugins/NameHistory.ts index d869b04e..673372d5 100644 --- a/src/plugins/NameHistory.ts +++ b/src/plugins/NameHistory.ts @@ -40,7 +40,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin { this.usernameHistory = new UsernameHistory(); } - @d.command("names", "") + @d.command("names", "") @d.permission("can_view") async namesCmd(msg: Message, args: { userId: string }) { const nicknames = await this.nicknameHistory.getByUserId(args.userId); @@ -72,7 +72,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin { @d.event("guildMemberUpdate") async onGuildMemberUpdate(_, member: Member) { const latestEntry = await this.nicknameHistory.getLastEntry(member.id); - if (!latestEntry || latestEntry.nickname != member.nick) { + if (!latestEntry || latestEntry.nickname !== member.nick) { // tslint:disable-line await this.nicknameHistory.addEntry(member.id, member.nick); } diff --git a/src/plugins/PingableRolesPlugin.ts b/src/plugins/PingableRolesPlugin.ts index 7638c976..7c3fff54 100644 --- a/src/plugins/PingableRolesPlugin.ts +++ b/src/plugins/PingableRolesPlugin.ts @@ -53,7 +53,7 @@ export class PingableRolesPlugin extends ZeppelinPlugin { return this.cache.get(channelId); } - @d.command("pingable_role disable", " ") + @d.command("pingable_role disable", " ") @d.permission("can_manage") async disablePingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) { const pingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); @@ -70,7 +70,7 @@ export class PingableRolesPlugin extends ZeppelinPlugin { ); } - @d.command("pingable_role", " ") + @d.command("pingable_role", " ") @d.permission("can_manage") async setPingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) { const existingPingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); diff --git a/src/plugins/Starboard.ts b/src/plugins/Starboard.ts index 02af31e8..dcf637a6 100644 --- a/src/plugins/Starboard.ts +++ b/src/plugins/Starboard.ts @@ -177,7 +177,7 @@ export class StarboardPlugin extends ZeppelinPlugin { /** * Deletes the starboard from the specified channel. The already-posted starboard messages are retained. */ - @d.command("starboard delete", "") + @d.command("starboard delete", "") @d.permission("can_manage") async deleteCmd(msg: Message, args: { channelId: string }) { const starboard = await this.starboards.getStarboardByChannelId(args.channelId); @@ -336,7 +336,7 @@ export class StarboardPlugin extends ZeppelinPlugin { } } - @d.command("starboard migrate_pins", " ") + @d.command("starboard migrate_pins", " ") async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) { const starboard = await this.starboards.getStarboardByChannelId(args.starboardChannelId); if (!starboard) { diff --git a/src/plugins/Utility.ts b/src/plugins/Utility.ts index be5be4e4..1e66c80f 100644 --- a/src/plugins/Utility.ts +++ b/src/plugins/Utility.ts @@ -554,7 +554,7 @@ export class UtilityPlugin extends ZeppelinPlugin { }, CLEAN_COMMAND_DELETE_DELAY); } - @d.command("clean user", " ") + @d.command("clean user", " ") @d.permission("can_clean") async cleanUserCmd(msg: Message, args: { userId: string; count: number }) { if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { From 328ec379c597fc08a8590326e210b528ca6e0adf Mon Sep 17 00:00:00 2001 From: Miikka Virtanen <2606411+Dragory@users.noreply.github.com> Date: Wed, 14 Aug 2019 10:53:35 +0300 Subject: [PATCH 48/58] Fix issues with long reasons in !ban/softban/kick. Display an error if a ban/softban/kick command fails. --- src/plugins/ModActions.ts | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index a3006bd7..6dff1ba5 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -788,7 +788,12 @@ export class ModActionsPlugin extends ZeppelinPlugin { // Kick the user this.serverLogs.ignoreLog(LogType.MEMBER_KICK, memberToKick.id); this.ignoreEvent(IgnoredEventType.Kick, memberToKick.id); - memberToKick.kick(reason); + try { + await memberToKick.kick(); + } catch (e) { + msg.channel.create(errorMessage("Failed to kick the user")); + return; + } // Create a case for this action const casesPlugin = this.getPlugin("cases"); @@ -872,10 +877,15 @@ export class ModActionsPlugin extends ZeppelinPlugin { }); } - // Ban the user + // (Try to) ban the user this.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToBan.id); this.ignoreEvent(IgnoredEventType.Ban, memberToBan.id); - memberToBan.ban(1, reason); + try { + await memberToBan.ban(1); + } catch (e) { + msg.channel.create(errorMessage("Failed to ban the user")); + return; + } // Create a case for this action const casesPlugin = this.getPlugin("cases"); @@ -949,8 +959,19 @@ export class ModActionsPlugin extends ZeppelinPlugin { this.ignoreEvent(IgnoredEventType.Ban, memberToSoftban.id); this.ignoreEvent(IgnoredEventType.Unban, memberToSoftban.id); - await memberToSoftban.ban(1, reason); - await this.guild.unbanMember(memberToSoftban.id); + try { + await memberToSoftban.ban(1); + } catch (e) { + msg.channel.create(errorMessage("Failed to softban the user")); + return; + } + + try { + await this.guild.unbanMember(memberToSoftban.id); + } catch (e) { + msg.channel.create(errorMessage("Failed to unban the user after softbanning them")); + return; + } // Create a case for this action const casesPlugin = this.getPlugin("cases"); @@ -1068,7 +1089,7 @@ export class ModActionsPlugin extends ZeppelinPlugin { this.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); try { - await this.guild.banMember(user.id, 1, reason); + await this.guild.banMember(user.id, 1); } catch (e) { this.sendErrorMessage(msg.channel, "Failed to forceban member"); return; From a48a58bd3cac42e0a327c5c1c60db21682785097 Mon Sep 17 00:00:00 2001 From: Miikka Virtanen <2606411+Dragory@users.noreply.github.com> Date: Wed, 14 Aug 2019 10:55:34 +0300 Subject: [PATCH 49/58] Fix error when calling resolveUserId with null/missing user id --- src/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index 1cfdfe8b..643322c1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -622,6 +622,10 @@ const unknownUsers = new Set(); const unknownMembers = new Set(); export function resolveUserId(bot: Client, value: string) { + if (value == null) { + return null; + } + // A user mention? const mentionMatch = value.match(/^<@!?(\d+)>$/); if (mentionMatch) { From 1e549ef2a337b73ebb916d1a07d0d109404548d8 Mon Sep 17 00:00:00 2001 From: Miikka Virtanen <2606411+Dragory@users.noreply.github.com> Date: Wed, 14 Aug 2019 10:57:39 +0300 Subject: [PATCH 50/58] Fix error if the bot attempts to save a message that was already saved --- src/data/GuildSavedMessages.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/data/GuildSavedMessages.ts b/src/data/GuildSavedMessages.ts index 25ca4503..c1af28bf 100644 --- a/src/data/GuildSavedMessages.ts +++ b/src/data/GuildSavedMessages.ts @@ -147,6 +147,9 @@ export class GuildSavedMessages extends BaseGuildRepository { } async createFromMsg(msg: Message, overrides = {}) { + const existingSavedMsg = await this.find(msg.id); + if (existingSavedMsg) return; + const savedMessageData = this.msgToSavedMessageData(msg); const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS"); From ae43d890a1f57502d491ebe9bab5a65192534c60 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 18 Aug 2019 16:40:15 +0300 Subject: [PATCH 51/58] Initial work on new automod --- src/Queue.ts | 4 +- src/SimpleCache.ts | 60 +++ src/plugins/Automod.ts | 808 ++++++++++++++++++++++++++++++++++ src/plugins/ZeppelinPlugin.ts | 12 +- src/utils.ts | 6 +- tslint.json | 3 +- 6 files changed, 888 insertions(+), 5 deletions(-) create mode 100644 src/SimpleCache.ts create mode 100644 src/plugins/Automod.ts diff --git a/src/Queue.ts b/src/Queue.ts index a3e519bb..cb8179bd 100644 --- a/src/Queue.ts +++ b/src/Queue.ts @@ -1,6 +1,8 @@ +import { SECONDS } from "./utils"; + type QueueFn = (...args: any[]) => Promise; -const DEFAULT_TIMEOUT = 10 * 1000; +const DEFAULT_TIMEOUT = 10 * SECONDS; export class Queue { protected running: boolean = false; diff --git a/src/SimpleCache.ts b/src/SimpleCache.ts new file mode 100644 index 00000000..a06d8d12 --- /dev/null +++ b/src/SimpleCache.ts @@ -0,0 +1,60 @@ +import Timeout = NodeJS.Timeout; + +const CLEAN_INTERVAL = 1000; + +export class SimpleCache { + protected readonly retentionTime; + protected cleanTimeout: Timeout; + protected unloaded: boolean; + + protected store: Map; + + constructor(retentionTime) { + this.retentionTime = retentionTime; + this.store = new Map(); + } + + unload() { + this.unloaded = true; + clearTimeout(this.cleanTimeout); + } + + cleanLoop() { + const now = Date.now(); + for (const [key, info] of this.store.entries()) { + if (now >= info.remove_at) { + this.store.delete(key); + } + } + + if (!this.unloaded) { + this.cleanTimeout = setTimeout(() => this.cleanLoop(), CLEAN_INTERVAL); + } + } + + set(key: string, value) { + this.store.set(key, { + remove_at: Date.now() + this.retentionTime, + value, + }); + } + + get(key: string) { + const info = this.store.get(key); + if (!info) return null; + + return info.value; + } + + has(key: string) { + return this.store.has(key); + } + + delete(key: string) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } +} diff --git a/src/plugins/Automod.ts b/src/plugins/Automod.ts new file mode 100644 index 00000000..8fed54f2 --- /dev/null +++ b/src/plugins/Automod.ts @@ -0,0 +1,808 @@ +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import * as t from "io-ts"; +import { + convertDelayStringToMS, + getEmojiInString, + getInviteCodesInString, + getRoleMentions, + getUrlsInString, + getUserMentions, + MINUTES, + noop, + tNullable, +} from "../utils"; +import { decorators as d } from "knub"; +import { mergeConfig } from "knub/dist/configUtils"; +import { Invite, Member, Message } from "eris"; +import escapeStringRegexp from "escape-string-regexp"; +import { SimpleCache } from "../SimpleCache"; +import { Queue } from "../Queue"; +import Timeout = NodeJS.Timeout; +import { ModActionsPlugin } from "./ModActions"; +import { MutesPlugin } from "./Mutes"; + +type MessageInfo = { channelId: string; messageId: string }; + +type TextTriggerWithMultipleMatchTypes = { + match_messages: boolean; + match_embeds: boolean; + match_usernames: boolean; + match_nicknames: boolean; +}; + +interface TriggerMatchResult { + type: string; +} + +interface MessageTextTriggerMatchResult extends TriggerMatchResult { + type: "message" | "embed"; + str: string; + userId: string; + messageInfo: MessageInfo; +} + +interface OtherTextTriggerMatchResult extends TriggerMatchResult { + type: "username" | "nickname"; + str: string; + userId: string; +} + +type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult; + +interface TextSpamTriggerMatchResult extends TriggerMatchResult { + type: "textspam"; + actionType: RecentActionType; + channelId: string; + userId: string; + messageInfos: MessageInfo[]; +} + +interface RaidSpamTriggerMatchResult extends TriggerMatchResult { + type: "raidspam"; + actionType: RecentActionType; + channelId: string; + userIds: string[]; + messageInfos: MessageInfo[]; +} + +interface OtherSpamTriggerMatchResult extends TriggerMatchResult { + type: "otherspam"; + actionType: RecentActionType; + userIds: string[]; +} + +type AnyTriggerMatchResult = + | TextTriggerMatchResult + | TextSpamTriggerMatchResult + | RaidSpamTriggerMatchResult + | OtherSpamTriggerMatchResult; + +/** + * TRIGGERS + */ + +const MatchWordsTrigger = t.type({ + words: t.array(t.string), + case_sensitive: t.boolean, + only_full_words: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchWordsTrigger = t.TypeOf; +const defaultMatchWordsTrigger: TMatchWordsTrigger = { + words: [], + case_sensitive: false, + only_full_words: true, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const MatchRegexTrigger = t.type({ + patterns: t.array(t.string), + case_sensitive: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchRegexTrigger = t.TypeOf; +const defaultMatchRegexTrigger: Partial = { + case_sensitive: false, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const MatchInvitesTrigger = t.type({ + include_guilds: tNullable(t.array(t.string)), + exclude_guilds: tNullable(t.array(t.string)), + include_invite_codes: tNullable(t.array(t.string)), + exclude_invite_codes: tNullable(t.array(t.string)), + allow_group_dm_invites: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchInvitesTrigger = t.TypeOf; +const defaultMatchInvitesTrigger: Partial = { + allow_group_dm_invites: false, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const MatchLinksTrigger = t.type({ + include_domains: tNullable(t.array(t.string)), + exclude_domains: tNullable(t.array(t.string)), + include_subdomains: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchLinksTrigger = t.TypeOf; +const defaultMatchLinksTrigger: Partial = { + include_subdomains: true, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const BaseSpamTrigger = t.type({ + amount: t.number, + within: t.string, +}); +const BaseTextSpamTrigger = t.intersection([ + BaseSpamTrigger, + t.type({ + per_channel: t.boolean, + }), +]); +type TBaseTextSpamTrigger = t.TypeOf; +const defaultTextSpamTrigger: Partial> = { + per_channel: true, +}; + +const MaxMessagesTrigger = BaseTextSpamTrigger; +type TMaxMessagesTrigger = t.TypeOf; +const MaxMentionsTrigger = BaseTextSpamTrigger; +type TMaxMentionsTrigger = t.TypeOf; +const MaxLinksTrigger = BaseTextSpamTrigger; +type TMaxLinksTrigger = t.TypeOf; +const MaxAttachmentsTrigger = BaseTextSpamTrigger; +type TMaxAttachmentsTrigger = t.TypeOf; +const MaxEmojisTrigger = BaseTextSpamTrigger; +type TMaxEmojisTrigger = t.TypeOf; +const MaxLinesTrigger = BaseTextSpamTrigger; +type TMaxLinesTrigger = t.TypeOf; +const MaxCharactersTrigger = BaseTextSpamTrigger; +type TMaxCharactersTrigger = t.TypeOf; +const MaxVoiceMovesTrigger = BaseSpamTrigger; +type TMaxVoiceMovesTrigger = t.TypeOf; + +/** + * ACTIONS + */ + +const CleanAction = t.boolean; + +const WarnAction = t.type({ + reason: t.string, +}); + +const MuteAction = t.type({ + duration: t.string, + reason: tNullable(t.string), +}); + +const KickAction = t.type({ + reason: tNullable(t.string), +}); + +const BanAction = t.type({ + reason: tNullable(t.string), +}); + +/** + * FULL CONFIG SCHEMA + */ + +const Rule = t.type({ + enabled: t.boolean, + name: t.string, + presets: tNullable(t.array(t.string)), + triggers: t.array( + t.type({ + match_words: tNullable(MatchWordsTrigger), + match_regex: tNullable(MatchRegexTrigger), + match_invites: tNullable(MatchInvitesTrigger), + match_links: tNullable(MatchLinksTrigger), + max_messages: tNullable(MaxMessagesTrigger), + max_mentions: tNullable(MaxMentionsTrigger), + max_links: tNullable(MaxLinksTrigger), + max_attachments: tNullable(MaxAttachmentsTrigger), + max_emojis: tNullable(MaxEmojisTrigger), + max_lines: tNullable(MaxLinesTrigger), + max_characters: tNullable(MaxCharactersTrigger), + max_voice_moves: tNullable(MaxVoiceMovesTrigger), + // TODO: Duplicates trigger + }), + ), + actions: t.type({ + clean: tNullable(CleanAction), + warn: tNullable(WarnAction), + mute: tNullable(MuteAction), + kick: tNullable(KickAction), + ban: tNullable(BanAction), + // TODO: Alert action + }), +}); +type TRule = t.TypeOf; + +const ConfigSchema = t.type({ + rules: t.record(t.string, Rule), +}); +type TConfigSchema = t.TypeOf; + +/** + * DEFAULTS + */ + +const defaultTriggers = { + match_words: defaultMatchWordsTrigger, +}; + +/** + * MISC + */ + +enum RecentActionType { + Message = 1, + Mention, + Link, + Attachment, + Emoji, + Line, + Character, + VoiceChannelMove, +} + +interface BaseRecentAction { + identifier: string; + timestamp: number; + count: number; +} + +type TextRecentAction = BaseRecentAction & { + type: + | RecentActionType.Message + | RecentActionType.Mention + | RecentActionType.Link + | RecentActionType.Attachment + | RecentActionType.Emoji + | RecentActionType.Line + | RecentActionType.Character; + messageInfo: MessageInfo; +}; + +type OtherRecentAction = BaseRecentAction & { + type: RecentActionType.VoiceChannelMove; +}; + +type RecentAction = TextRecentAction | OtherRecentAction; + +const MAX_SPAM_CHECK_TIMESPAN = 5 * MINUTES; + +const inviteCache = new SimpleCache(10 * MINUTES); + +export class AutomodPlugin extends ZeppelinPlugin { + public static pluginName = "automod"; + protected static configSchema = ConfigSchema; + public static dependencies = ["mod_actions", "mutes"]; + + protected unloaded = false; + + // Handle automod checks/actions in a queue so we don't get overlap on the same user + protected automodQueue: Queue; + + // Recent actions are used to detect "max_*" type of triggers, i.e. spam triggers + protected recentActions: RecentAction[]; + protected recentActionClearInterval: Timeout; + + // After a spam trigger is tripped and the rule's action carried out, a short "grace period" will be placed on the user. + // During this grace period, if the user repeats the same type of recent action that tripped the rule, that message will + // be deleted and no further action will be carried out. This is mainly to account for the delay between the spam message + // being posted and the bot reacting to it, during which the user could keep posting more spam. + protected spamGracePeriods: Array<{ key: string; type: RecentActionType; expiresAt: number }>; + protected spamGracePriodClearInterval: Timeout; + + protected modActions: ModActionsPlugin; + protected mutes: MutesPlugin; + + protected static preprocessStaticConfig(config) { + if (config.rules && typeof config.rules === "object") { + // Loop through each rule + for (const [name, rule] of Object.entries(config.rules)) { + if (rule == null || typeof rule !== "object") continue; + + rule["name"] = name; + + // If the rule doesn't have an explicitly set "enabled" property, set it to true + if (rule["enabled"] == null) { + rule["enabled"] = true; + } + + // Loop through the rule's triggers + if (rule["triggers"] != null && Array.isArray(rule["triggers"])) { + for (const trigger of rule["triggers"]) { + if (trigger == null || typeof trigger !== "object") continue; + // Apply default triggers to the triggers used in this rule + for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) { + if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") { + trigger[defaultTriggerName] = mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]); + } + } + } + } + } + } + + return config; + } + + protected static getStaticDefaultOptions() { + return { + rules: [], + }; + } + + protected onLoad() { + this.automodQueue = new Queue(); + this.modActions = this.getPlugin("mod_actions"); + } + + protected onUnload() { + this.unloaded = true; + clearInterval(this.recentActionClearInterval); + clearInterval(this.spamGracePriodClearInterval); + } + + protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean { + for (const word of trigger.words) { + const pattern = trigger.only_full_words ? `\b${escapeStringRegexp(word)}\b` : escapeStringRegexp(word); + + const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); + return regex.test(str); + } + } + + protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean { + // TODO: Time limit regexes + for (const pattern of trigger.patterns) { + const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); + return regex.test(str); + } + } + + protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise { + const inviteCodes = getInviteCodesInString(str); + if (inviteCodes.length === 0) return false; + + const uniqueInviteCodes = Array.from(new Set(inviteCodes)); + + for (const code of uniqueInviteCodes) { + if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) { + return true; + } + if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) { + return true; + } + } + + const invites: Array = await Promise.all( + uniqueInviteCodes.map(async code => { + if (inviteCache.has(code)) { + return inviteCache.get(code); + } else { + const invite = await this.bot.getInvite(code).catch(noop); + inviteCache.set(code, invite); + return invite; + } + }), + ); + + for (const invite of invites) { + if (!invite) return true; + if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) { + return true; + } + if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) { + return true; + } + } + + return false; + } + + protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): boolean { + const links = getUrlsInString(str, true); + for (const link of links) { + const normalizedHostname = link.hostname.toLowerCase(); + + if (trigger.include_domains) { + for (const domain of trigger.include_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + return true; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + return true; + } + } + } + + if (trigger.exclude_domains) { + for (const domain of trigger.exclude_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + return false; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + return false; + } + } + + return true; + } + } + + return false; + } + + protected matchTextSpamTrigger( + recentActionType: RecentActionType, + trigger: TBaseTextSpamTrigger, + msg: Message, + ): TextSpamTriggerMatchResult { + const since = msg.timestamp - convertDelayStringToMS(trigger.within); + const recentActions = trigger.per_channel + ? this.getMatchingRecentActions(recentActionType, `${msg.channel.id}-${msg.author.id}`, since) + : this.getMatchingRecentActions(recentActionType, msg.author.id, since); + if (recentActions.length > trigger.amount) { + return { + type: "textspam", + actionType: recentActionType, + channelId: trigger.per_channel ? msg.channel.id : null, + messageInfos: recentActions.map(action => (action as TextRecentAction).messageInfo), + userId: msg.author.id, + }; + } + + return null; + } + + protected async matchMultipleTextTypesOnMessage( + trigger: TextTriggerWithMultipleMatchTypes, + msg: Message, + cb, + ): Promise { + const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id }; + + if (trigger.match_messages) { + const str = msg.content; + const match = await cb(str); + if (match) return { type: "message", str, userId: msg.author.id, messageInfo }; + } + + if (trigger.match_embeds && msg.embeds.length) { + const str = JSON.stringify(msg.embeds[0]); + const match = await cb(str); + if (match) return { type: "embed", str, userId: msg.author.id, messageInfo }; + } + + if (trigger.match_usernames) { + const str = `${msg.author.username}#${msg.author.discriminator}`; + const match = await cb(str); + if (match) return { type: "username", str, userId: msg.author.id }; + } + + if (trigger.match_nicknames && msg.member.nick) { + const str = msg.member.nick; + const match = await cb(str); + if (match) return { type: "nickname", str, userId: msg.author.id }; + } + + return null; + } + + protected async matchMultipleTextTypesOnMember( + trigger: TextTriggerWithMultipleMatchTypes, + member: Member, + cb, + ): Promise { + if (trigger.match_usernames) { + const str = `${member.user.username}#${member.user.discriminator}`; + const match = await cb(str); + if (match) return { type: "username", str, userId: member.id }; + } + + if (trigger.match_nicknames && member.nick) { + const str = member.nick; + const match = await cb(str); + if (match) return { type: "nickname", str, userId: member.id }; + } + + return null; + } + + /** + * Returns whether the triggers in the rule match the given message + */ + protected async matchRuleToMessage( + rule: TRule, + msg: Message, + ): Promise { + for (const trigger of rule.triggers) { + if (trigger.match_words) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => { + return this.evaluateMatchWordsTrigger(trigger.match_words, str); + }); + if (match) return match; + } + + if (trigger.match_regex) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => { + return this.evaluateMatchRegexTrigger(trigger.match_regex, str); + }); + if (match) return match; + } + + if (trigger.match_invites) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => { + return this.evaluateMatchInvitesTrigger(trigger.match_invites, str); + }); + if (match) return match; + } + + if (trigger.match_links) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => { + return this.evaluateMatchLinksTrigger(trigger.match_links, str); + }); + if (match) return match; + } + + if (trigger.max_messages) { + const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.max_messages, msg); + if (match) return match; + } + + if (trigger.max_mentions) { + const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.max_mentions, msg); + if (match) return match; + } + + if (trigger.max_links) { + const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.max_links, msg); + if (match) return match; + } + + if (trigger.max_attachments) { + const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.max_attachments, msg); + if (match) return match; + } + + if (trigger.max_emojis) { + const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.max_emojis, msg); + if (match) return match; + } + + if (trigger.max_lines) { + const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.max_lines, msg); + if (match) return match; + } + + if (trigger.max_characters) { + const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.max_characters, msg); + if (match) return match; + } + } + + return null; + } + + /** + * Logs recent actions for spam detection purposes + */ + protected async logRecentActionsForMessage(msg: Message) { + const timestamp = msg.timestamp; + const globalIdentifier = msg.author.id; + const perChannelIdentifier = `${msg.channel.id}-${msg.author.id}`; + const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id }; + + this.recentActions.push({ + type: RecentActionType.Message, + identifier: globalIdentifier, + timestamp, + count: 1, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Message, + identifier: perChannelIdentifier, + timestamp, + count: 1, + messageInfo, + }); + + const mentionCount = getUserMentions(msg.content || "").length + getRoleMentions(msg.content || "").length; + if (mentionCount) { + this.recentActions.push({ + type: RecentActionType.Mention, + identifier: globalIdentifier, + timestamp, + count: mentionCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Mention, + identifier: perChannelIdentifier, + timestamp, + count: mentionCount, + messageInfo, + }); + } + + const linkCount = getUrlsInString(msg.content || "").length; + if (linkCount) { + this.recentActions.push({ + type: RecentActionType.Link, + identifier: globalIdentifier, + timestamp, + count: linkCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Link, + identifier: perChannelIdentifier, + timestamp, + count: linkCount, + messageInfo, + }); + } + + const attachmentCount = msg.attachments.length; + if (attachmentCount) { + this.recentActions.push({ + type: RecentActionType.Attachment, + identifier: globalIdentifier, + timestamp, + count: attachmentCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Attachment, + identifier: perChannelIdentifier, + timestamp, + count: attachmentCount, + messageInfo, + }); + } + + const emojiCount = getEmojiInString(msg.content || "").length; + if (emojiCount) { + this.recentActions.push({ + type: RecentActionType.Emoji, + identifier: globalIdentifier, + timestamp, + count: emojiCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Emoji, + identifier: perChannelIdentifier, + timestamp, + count: emojiCount, + messageInfo, + }); + } + + // + 1 is for the first line of the message (which doesn't have a line break) + const lineCount = msg.content ? msg.content.match(/\n/g).length + 1 : 0; + if (lineCount) { + this.recentActions.push({ + type: RecentActionType.Line, + identifier: globalIdentifier, + timestamp, + count: lineCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Line, + identifier: perChannelIdentifier, + timestamp, + count: lineCount, + messageInfo, + }); + } + + const characterCount = [...(msg.content || "")].length; + if (characterCount) { + this.recentActions.push({ + type: RecentActionType.Character, + identifier: globalIdentifier, + timestamp, + count: characterCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Character, + identifier: perChannelIdentifier, + timestamp, + count: characterCount, + messageInfo, + }); + } + } + + protected getMatchingRecentActions(type: RecentActionType, identifier: string, since: number) { + return this.recentActions.filter(action => { + return action.type === type && action.identifier === identifier && action.timestamp >= since; + }); + } + + protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) { + if (rule.actions.clean) { + if (matchResult.type === "message" || matchResult.type === "embed") { + await this.bot.deleteMessage(matchResult.messageInfo.channelId, matchResult.messageInfo.messageId).catch(noop); + } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { + for (const { channelId, messageId } of matchResult.messageInfos) { + await this.bot.deleteMessage(channelId, messageId).catch(noop); + } + } + } + + if (rule.actions.mute) { + const duration = rule.actions.mute.duration ? convertDelayStringToMS(rule.actions.mute.duration) : null; + const reason = rule.actions.mute.reason || "Muted automatically"; + const caseArgs = { + modId: this.bot.user.id, + extraNotes: [`Matched automod rule "${rule.name}"`], + }; + + if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { + await this.mutes.muteUser(matchResult.userId, duration, reason, caseArgs); + } else if (matchResult.type === "raidspam") { + for (const userId of matchResult.userIds) { + await this.mutes.muteUser(userId, duration, reason, caseArgs); + } + } + } + + // TODO: Other actions + } + + @d.event("messageCreate") + protected onMessageCreate(msg: Message) { + this.automodQueue.add(async () => { + if (this.unloaded) return; + + await this.logRecentActionsForMessage(msg); + + const config = this.getMatchingConfig({ message: msg }); + for (const [name, rule] of Object.entries(config.rules)) { + const matchResult = await this.matchRuleToMessage(rule, msg); + if (matchResult) { + await this.applyActionsOnMatch(rule, matchResult); + } + } + }); + } +} diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index 7848855a..d44c7218 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -56,6 +56,14 @@ export class ZeppelinPlugin extends Plug return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions; } + /** + * Allows the plugin to preprocess the config before it's validated. + * Useful for e.g. adding default properties to dynamic objects. + */ + protected static preprocessStaticConfig(config: any) { + return config; + } + /** * Merges the given options and default options and decodes them according to the config schema of the plugin (if any). * Throws on any decoding/validation errors. @@ -68,11 +76,13 @@ export class ZeppelinPlugin extends Plug */ protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions { const defaultOptions: any = this.getStaticDefaultOptions(); - const mergedConfig = mergeConfig({}, defaultOptions.config || {}, options.config || {}); + let mergedConfig = mergeConfig({}, defaultOptions.config || {}, options.config || {}); const mergedOverrides = options["=overrides"] ? options["=overrides"] : (options.overrides || []).concat(defaultOptions.overrides || []); + mergedConfig = this.preprocessStaticConfig(mergedConfig); + const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; if (decodedConfig instanceof StrictValidationError) { throw decodedConfig; diff --git a/src/utils.ts b/src/utils.ts index 643322c1..eb85f2e4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -175,8 +175,10 @@ export async function findRelevantAuditLogEntry( const urlRegex = /(\S+\.\S+)/g; const protocolRegex = /^[a-z]+:\/\//; -export function getUrlsInString(str: string): url.URL[] { - const matches = str.match(urlRegex) || []; +export function getUrlsInString(str: string, unique = false): url.URL[] { + let matches = str.match(urlRegex).map(m => m[0]) || []; + if (unique) matches = Array.from(new Set(matches)); + return matches.reduce((urls, match) => { if (!protocolRegex.test(match)) { match = `https://${match}`; diff --git a/tslint.json b/tslint.json index f1048f51..22f3c3c9 100644 --- a/tslint.json +++ b/tslint.json @@ -23,6 +23,7 @@ "interface-over-type-literal": false, "interface-name": false, "no-submodule-imports": false, - "no-floating-promises": true + "no-floating-promises": true, + "no-string-literal": false } } From a05fcd7c06d8606b8b32df8a0abb573aad9b8da2 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Wed, 21 Aug 2019 22:05:27 +0300 Subject: [PATCH 52/58] Update Knub to v22.0.0 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9604236d..39f4e90f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8299,9 +8299,9 @@ "dev": true }, "knub": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/knub/-/knub-21.0.0.tgz", - "integrity": "sha512-hHANshytor8RO3Jd5EjAZ9sP0emcfuTvBtqe3S+2UmMFk3HxSXLXuGsVfKL151vfKC5P3VDIOiQdl52ankBT5A==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/knub/-/knub-22.0.0.tgz", + "integrity": "sha512-QHMqSS8eVBVX0vMff8lEkWhO7mOVXdobUrNOuAMI7ldto0Aakf0oNdDnwRXFj0yNb5Sp1fvzYFt35nsx/ORqkw==", "requires": { "escape-string-regexp": "^1.0.5", "lodash.at": "^4.6.0", diff --git a/package.json b/package.json index 9f7077d2..0f25d387 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "humanize-duration": "^3.15.0", "io-ts": "^2.0.0", "js-yaml": "^3.13.1", - "knub": "^21.0.0", + "knub": "^22.0.0", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", From 357687654a45519867af2f88a583c1e7d054a1a3 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Wed, 21 Aug 2019 22:06:31 +0300 Subject: [PATCH 53/58] Move vuex dependency from root package.json to dashboard folder --- dashboard/package-lock.json | 5 +++++ dashboard/package.json | 3 ++- package-lock.json | 5 ----- package.json | 3 +-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 50b8d39b..7cfcfdfb 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -7635,6 +7635,11 @@ "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", "dev": true }, + "vuex": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz", + "integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg==" + }, "w3c-hr-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index d873418c..8a477bcd 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -27,7 +27,8 @@ "vue-codemirror": "^4.0.6", "vue-highlightjs": "^1.3.3", "vue-hot-reload-api": "^2.3.3", - "vue-router": "^3.0.6" + "vue-router": "^3.0.6", + "vuex": "^3.1.1" }, "browserslist": [ "last 2 Chrome versions" diff --git a/package-lock.json b/package-lock.json index 39f4e90f..f728fad7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11393,11 +11393,6 @@ "extsprintf": "^1.2.0" } }, - "vuex": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz", - "integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg==" - }, "w3c-hr-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", diff --git a/package.json b/package.json index 0f25d387..9b9cc694 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,7 @@ "ts-node": "^3.3.0", "typeorm": "^0.2.14", "typescript": "^3.5.3", - "uuid": "^3.3.2", - "vuex": "^3.1.1" + "uuid": "^3.3.2" }, "devDependencies": { "@babel/core": "^7.5.5", From 404039e19688ffd4780e1cfa80287faa8d298148 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Wed, 21 Aug 2019 22:07:13 +0300 Subject: [PATCH 54/58] Add property/type for plugin info in ZeppelinPlugin --- src/plugins/ZeppelinPlugin.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index d44c7218..0b02bcde 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -21,7 +21,14 @@ import { mergeConfig } from "knub/dist/configUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; +export interface PluginInfo { + name: string; + description?: string; +} + export class ZeppelinPlugin extends Plugin { + public static pluginInfo: PluginInfo; + protected static configSchema: t.TypeC; public static dependencies = []; From 6bdb05e678d57d957951cceb06d8d749adb568c0 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 22 Aug 2019 01:18:36 +0300 Subject: [PATCH 55/58] Make warn/kick/ban actions in ModActions available to other plugins via public methods --- src/plugins/ModActions.ts | 254 ++++++++++++++++++++++++++++---------- 1 file changed, 186 insertions(+), 68 deletions(-) diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index 6dff1ba5..6545f62c 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -27,7 +27,7 @@ import { LogType } from "../data/LogType"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { Case } from "../data/entities/Case"; import { renderTemplate } from "../templateFormatter"; -import { CasesPlugin } from "./Cases"; +import { CaseArgs, CasesPlugin } from "./Cases"; import { MuteResult, MutesPlugin } from "./Mutes"; import * as t from "io-ts"; @@ -328,8 +328,115 @@ export class ModActionsPlugin extends ZeppelinPlugin { } /** - * Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it + * Kick the specified server member. Generates a case. */ + async kickMember(member: Member, reason: string = null, caseArgs: Partial = {}): Promise { + const config = this.getConfig(); + + // Attempt to message the user *before* kicking them, as doing it after may not be possible + let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; + if (reason) { + const kickMessage = await renderTemplate(config.kick_message, { + guildName: this.guild.name, + reason, + }); + + notifyResult = await notifyUser(this.bot, this.guild, member.user, kickMessage, { + useDM: config.dm_on_kick, + useChannel: config.message_on_kick, + channelId: config.message_channel, + }); + } + + // Kick the user + this.serverLogs.ignoreLog(LogType.MEMBER_KICK, member.id); + this.ignoreEvent(IgnoredEventType.Kick, member.id); + try { + await member.kick(); + } catch (e) { + return { + status: "failed", + error: e.getMessage(), + }; + } + + // Create a case for this action + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ + ...caseArgs, + userId: member.id, + modId: caseArgs.modId, + type: CaseTypes.Kick, + reason, + noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [], + }); + + // Log the action + const mod = await this.resolveUser(caseArgs.modId); + this.serverLogs.log(LogType.MEMBER_KICK, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(member.user), + }); + + return { + status: "success", + case: createdCase, + notifyResult, + }; + } + + /** + * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. + */ + async banUserId(userId: string, reason: string = null, caseArgs: Partial = {}): Promise { + const config = this.getConfig(); + const user = await this.resolveUser(userId); + + // Attempt to message the user *before* banning them, as doing it after may not be possible + let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; + if (reason && user instanceof User) { + const banMessage = await renderTemplate(config.ban_message, { + guildName: this.guild.name, + reason, + }); + notifyResult = await notifyUser(this.bot, this.guild, user, banMessage, { + useDM: config.dm_on_ban, + useChannel: config.message_on_ban, + channelId: config.message_channel, + }); + } + + // (Try to) ban the user + this.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId); + this.ignoreEvent(IgnoredEventType.Ban, userId); + try { + await this.guild.banMember(userId, 1); + } catch (e) { + return { + status: "failed", + error: e.getMessage(), + }; + } + + // Create a case for this action + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ + ...caseArgs, + userId, + modId: caseArgs.modId, + type: CaseTypes.Ban, + reason, + noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [], + }); + + // Log the action + const mod = await this.resolveUser(caseArgs.modId); + this.serverLogs.log(LogType.MEMBER_BAN, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(user), + }); + } + @d.command("update", " [note:string$]", { overloads: ["[note:string$]"], }) @@ -433,37 +540,27 @@ export class ModActionsPlugin extends ZeppelinPlugin { const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason); + const warnResult = await this.warnMember( + memberToWarn, + warnMessage, + { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }, + msg.channel as TextChannel, + ); - const userMessageResult = await notifyUser(this.bot, this.guild, memberToWarn.user, warnMessage, { - useDM: config.dm_on_warn, - useChannel: config.message_on_warn, - }); - - if (userMessageResult.status === NotifyUserStatus.Failed) { - const failedMsg = await msg.channel.createMessage("Failed to message the user. Log the warning anyway?"); - const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"], msg.author.id); - failedMsg.delete(); - if (!reply || reply.name === "❌") { - return; - } + if (warnResult.status === "failed") { + msg.channel.createMessage(errorMessage("Failed to warn user")); + return; } - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - userId: memberToWarn.id, - modId: mod.id, - type: CaseTypes.Warn, - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - noteDetails: userMessageResult.status !== NotifyUserStatus.Ignored ? [ucfirst(userMessageResult.text)] : [], - }); - - const messageResultText = userMessageResult.text ? ` (${userMessageResult.text})` : ""; + const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; msg.channel.createMessage( successMessage( `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${ - createdCase.case_number + warnResult.case.case_number })${messageResultText}`, ), ); @@ -474,6 +571,61 @@ export class ModActionsPlugin extends ZeppelinPlugin { }); } + async warnMember( + member: Member, + warnMessage: string, + caseArgs: Partial = {}, + retryPromptChannel: TextChannel = null, + ): Promise { + const config = this.getConfig(); + + const notifyResult = await notifyUser(this.bot, this.guild, member.user, warnMessage, { + useDM: config.dm_on_warn, + useChannel: config.message_on_warn, + }); + + if (notifyResult.status === NotifyUserStatus.Failed) { + if (retryPromptChannel && this.guild.channels.has(retryPromptChannel.id)) { + const failedMsg = await retryPromptChannel.createMessage("Failed to message the user. Log the warning anyway?"); + const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"]); + failedMsg.delete(); + if (!reply || reply.name === "❌") { + return { + status: "failed", + error: "Failed to message user", + }; + } + } else { + return { + status: "failed", + error: "Failed to message user", + }; + } + } + + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ + ...caseArgs, + userId: member.id, + modId: caseArgs.modId, + type: CaseTypes.Warn, + reason: warnMessage, + noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [], + }); + + const mod = await this.resolveUser(caseArgs.modId); + this.serverLogs.log(LogType.MEMBER_WARN, { + mod: stripObjectToScalars(mod), + member: stripObjectToScalars(member, ["user", "roles"]), + }); + + return { + status: "success", + case: createdCase, + notifyResult, + }; + } + /** * The actual function run by both !mute and !forcemute. * The only difference between the two commands is in target member validation. @@ -767,58 +919,24 @@ export class ModActionsPlugin extends ZeppelinPlugin { mod = args.mod; } - const config = this.getConfig(); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); + const kickResult = await this.kickMember(memberToKick, reason, { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }); - // Attempt to message the user *before* kicking them, as doing it after may not be possible - let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; - if (args.reason) { - const kickMessage = await renderTemplate(config.kick_message, { - guildName: this.guild.name, - reason, - }); - - userMessageResult = await notifyUser(this.bot, this.guild, memberToKick.user, kickMessage, { - useDM: config.dm_on_kick, - useChannel: config.message_on_kick, - channelId: config.message_channel, - }); - } - - // Kick the user - this.serverLogs.ignoreLog(LogType.MEMBER_KICK, memberToKick.id); - this.ignoreEvent(IgnoredEventType.Kick, memberToKick.id); - try { - await memberToKick.kick(); - } catch (e) { - msg.channel.create(errorMessage("Failed to kick the user")); + if (kickResult.status === "failed") { + msg.channel.createMessage(errorMessage(`Failed to kick user`)); return; } - // Create a case for this action - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - userId: memberToKick.id, - modId: mod.id, - type: CaseTypes.Kick, - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - noteDetails: userMessageResult.status !== NotifyUserStatus.Ignored ? [ucfirst(userMessageResult.text)] : [], - }); - // Confirm the action to the moderator let response = `Kicked **${memberToKick.user.username}#${memberToKick.user.discriminator}** (Case #${ - createdCase.case_number + kickResult.case.case_number })`; - if (userMessageResult.text) response += ` (${userMessageResult.text})`; + if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; msg.channel.createMessage(successMessage(response)); - - // Log the action - this.serverLogs.log(LogType.MEMBER_KICK, { - mod: stripObjectToScalars(mod.user), - user: stripObjectToScalars(memberToKick.user), - }); } @d.command("ban", " [reason:string$]", { From ee6d622941473d24c93f91b67a3e0428bb2ba87a Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 22 Aug 2019 01:22:26 +0300 Subject: [PATCH 56/58] Auto-generate plugin docs (WIP) --- dashboard/package-lock.json | 13 +- dashboard/package.json | 2 + .../src/components/docs/ArgumentTypes.vue | 64 +++++++ .../components/docs/ConfigurationFormat.vue | 6 +- .../src/components/docs/Introduction.vue | 20 +- dashboard/src/components/docs/Layout.vue | 22 ++- dashboard/src/components/docs/Permissions.vue | 26 +-- dashboard/src/components/docs/Plugin.vue | 77 ++++++++ .../components/docs/PluginConfiguration.vue | 12 +- dashboard/src/routes.ts | 30 +-- dashboard/src/store/docs.ts | 54 ++++++ dashboard/src/store/index.ts | 4 + dashboard/src/store/types.ts | 22 +++ dashboard/src/style/docs.scss | 33 ++++ src/api/docs.ts | 70 +++++++ src/api/index.ts | 11 +- src/plugins/AutoReactionsPlugin.ts | 2 +- src/plugins/Automod.ts | 2 +- src/plugins/BotControl.ts | 2 +- src/plugins/Cases.ts | 2 +- src/plugins/Censor.ts | 2 +- src/plugins/CompanionChannels.ts | 2 +- src/plugins/CustomEvents.ts | 5 +- src/plugins/GlobalZeppelinPlugin.ts | 2 +- src/plugins/GuildInfoSaver.ts | 1 + src/plugins/LocateUser.ts | 2 +- src/plugins/Logs.ts | 2 +- src/plugins/MessageSaver.ts | 3 +- src/plugins/ModActions.ts | 181 ++++++++++++------ src/plugins/Mutes.ts | 2 +- src/plugins/NameHistory.ts | 3 +- src/plugins/Persist.ts | 2 +- src/plugins/PingableRolesPlugin.ts | 2 +- src/plugins/Post.ts | 2 +- src/plugins/ReactionRoles.ts | 2 +- src/plugins/Reminders.ts | 2 +- src/plugins/SelfGrantableRolesPlugin.ts | 3 +- src/plugins/Slowmode.ts | 2 +- src/plugins/Spam.ts | 2 +- src/plugins/Starboard.ts | 3 +- src/plugins/Tags.ts | 4 +- src/plugins/Utility.ts | 2 +- src/plugins/ZeppelinPlugin.ts | 13 +- src/utils.ts | 31 +++ 44 files changed, 599 insertions(+), 150 deletions(-) create mode 100644 dashboard/src/components/docs/ArgumentTypes.vue create mode 100644 dashboard/src/components/docs/Plugin.vue create mode 100644 dashboard/src/store/docs.ts create mode 100644 src/api/docs.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 7cfcfdfb..5e5694bc 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1267,7 +1267,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -4778,7 +4777,6 @@ "version": "3.13.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -4787,8 +4785,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" } } }, @@ -4987,6 +4984,11 @@ "object-visit": "^1.0.0" } }, + "marked": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", + "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -6949,8 +6951,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "sshpk": { "version": "1.16.1", diff --git a/dashboard/package.json b/dashboard/package.json index 8a477bcd..cd8d203a 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -23,6 +23,8 @@ "bulma": "^0.7.5", "bulmaswatch": "^0.7.2", "js-cookie": "^2.2.0", + "js-yaml": "^3.13.1", + "marked": "^0.7.0", "vue": "^2.6.10", "vue-codemirror": "^4.0.6", "vue-highlightjs": "^1.3.3", diff --git a/dashboard/src/components/docs/ArgumentTypes.vue b/dashboard/src/components/docs/ArgumentTypes.vue new file mode 100644 index 00000000..08f26f01 --- /dev/null +++ b/dashboard/src/components/docs/ArgumentTypes.vue @@ -0,0 +1,64 @@ + + + diff --git a/dashboard/src/components/docs/ConfigurationFormat.vue b/dashboard/src/components/docs/ConfigurationFormat.vue index 5311d288..06e5df6f 100644 --- a/dashboard/src/components/docs/ConfigurationFormat.vue +++ b/dashboard/src/components/docs/ConfigurationFormat.vue @@ -1,10 +1,10 @@