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 +}; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 9181fa9e..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" } @@ -1985,6 +1984,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", @@ -2408,6 +2415,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", @@ -4253,6 +4270,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", @@ -4755,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" @@ -4764,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==" } } }, @@ -4964,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", @@ -6926,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", @@ -7578,6 +7602,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", @@ -7604,6 +7636,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 01fa35cf..cd8d203a 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,18 +13,24 @@ "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" }, "dependencies": { + "buefy": "^0.7.10", "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", "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/dashboard/src/components/Splash.vue b/dashboard/src/components/Splash.vue index 165e2e27..2944f7fd 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 + Documentation
Error 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 72% rename from dashboard/src/components/Dashboard.vue rename to dashboard/src/components/dashboard/Layout.vue index 084c135b..f23c59d8 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/CodeBlock.vue b/dashboard/src/components/docs/CodeBlock.vue new file mode 100644 index 00000000..b716e7a0 --- /dev/null +++ b/dashboard/src/components/docs/CodeBlock.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/dashboard/src/components/docs/ConfigurationFormat.vue b/dashboard/src/components/docs/ConfigurationFormat.vue new file mode 100644 index 00000000..06e5df6f --- /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..abc8e716 --- /dev/null +++ b/dashboard/src/components/docs/Introduction.vue @@ -0,0 +1,30 @@ + diff --git a/dashboard/src/components/docs/Layout.vue b/dashboard/src/components/docs/Layout.vue new file mode 100644 index 00000000..5b9de5f1 --- /dev/null +++ b/dashboard/src/components/docs/Layout.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/dashboard/src/components/docs/Permissions.vue b/dashboard/src/components/docs/Permissions.vue new file mode 100644 index 00000000..faec12a9 --- /dev/null +++ b/dashboard/src/components/docs/Permissions.vue @@ -0,0 +1,88 @@ + + + diff --git a/dashboard/src/components/docs/Plugin.vue b/dashboard/src/components/docs/Plugin.vue new file mode 100644 index 00000000..6f2aee6e --- /dev/null +++ b/dashboard/src/components/docs/Plugin.vue @@ -0,0 +1,132 @@ + + + diff --git a/dashboard/src/components/docs/PluginConfiguration.vue b/dashboard/src/components/docs/PluginConfiguration.vue new file mode 100644 index 00000000..2caa40e5 --- /dev/null +++ b/dashboard/src/components/docs/PluginConfiguration.vue @@ -0,0 +1,83 @@ + + + diff --git a/dashboard/src/components/docs/descriptions/ArgumentTypes.vue b/dashboard/src/components/docs/descriptions/ArgumentTypes.vue new file mode 100644 index 00000000..a29d8ca6 --- /dev/null +++ b/dashboard/src/components/docs/descriptions/ArgumentTypes.vue @@ -0,0 +1,60 @@ + + + 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/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/LocateUser.vue b/dashboard/src/components/docs/plugins/LocateUser.vue new file mode 100644 index 00000000..14131705 --- /dev/null +++ b/dashboard/src/components/docs/plugins/LocateUser.vue @@ -0,0 +1,94 @@ + + + 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/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..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"; @@ -18,8 +20,8 @@ Vue.mixin({ }); import App from "./components/App.vue"; -import Login from "./components/Login.vue"; +Vue.use(Buefy); const app = new Vue({ router, store: RootStore, diff --git a/dashboard/src/routes.ts b/dashboard/src/routes.ts index 79d58983..f90b6171 100644 --- a/dashboard/src/routes.ts +++ b/dashboard/src/routes.ts @@ -14,21 +14,65 @@ 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"), + }, + { + path: "descriptions/argument-types", + component: () => import("./components/docs/ArgumentTypes.vue"), + }, + { + path: "plugins/:pluginName", + component: () => import("./components/docs/Plugin.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"), }, ], }, ], + + scrollBehavior(to, from, savedPosition) { + if (to.hash) { + return { + selector: to.hash, + }; + } else if (savedPosition) { + return savedPosition; + } else { + return { x: 0, y: 0 }; + } + }, }); diff --git a/dashboard/src/store/docs.ts b/dashboard/src/store/docs.ts new file mode 100644 index 00000000..44203c8e --- /dev/null +++ b/dashboard/src/store/docs.ts @@ -0,0 +1,54 @@ +import { get } from "../api"; +import { Module } from "vuex"; +import { DocsState, RootState } from "./types"; + +export const DocsStore: Module = { + namespaced: true, + + state: { + allPlugins: [], + loadingAllPlugins: false, + + plugins: {}, + }, + + actions: { + async loadAllPlugins({ state, commit }) { + if (state.loadingAllPlugins) return; + commit("setAllPluginLoadStatus", true); + + const plugins = await get("docs/plugins"); + plugins.sort((a, b) => { + const aName = (a.info.prettyName || a.name).toLowerCase(); + const bName = (b.info.prettyName || b.name).toLowerCase(); + if (aName > bName) return 1; + if (aName < bName) return -1; + return 0; + }); + commit("setAllPlugins", plugins); + + commit("setAllPluginLoadStatus", false); + }, + + async loadPluginData({ state, commit }, name) { + if (state.plugins[name]) return; + + const data = await get(`docs/plugins/${name}`); + commit("setPluginData", { name, data }); + }, + }, + + mutations: { + setAllPluginLoadStatus(state: DocsState, status: boolean) { + state.loadingAllPlugins = status; + }, + + setAllPlugins(state: DocsState, plugins) { + state.allPlugins = plugins; + }, + + setPluginData(state: DocsState, { name, data }) { + state.plugins[name] = data; + }, + }, +}; diff --git a/dashboard/src/store/index.ts b/dashboard/src/store/index.ts index 4d53ae51..c4aadd83 100644 --- a/dashboard/src/store/index.ts +++ b/dashboard/src/store/index.ts @@ -6,23 +6,27 @@ Vue.use(Vuex); import { RootState } from "./types"; import { AuthStore } from "./auth"; import { GuildStore } from "./guilds"; +import { DocsStore } from "./docs"; export const RootStore = new Vuex.Store({ modules: { auth: AuthStore, guilds: GuildStore, + docs: DocsStore, }, }); // Set up typings so Vue/our components know about the state's types declare module "vue/types/options" { interface ComponentOptions { + // @ts-ignore store?: Store; } } declare module "vue/types/vue" { interface Vue { + // @ts-ignore $store: Store; } } diff --git a/dashboard/src/store/types.ts b/dashboard/src/store/types.ts index b4ee3af4..82fae8b0 100644 --- a/dashboard/src/store/types.ts +++ b/dashboard/src/store/types.ts @@ -21,7 +21,29 @@ export interface GuildState { }; } +export interface ThinDocsPlugin { + name: string; + info: { + name: string; + description?: string; + }; +} + +export interface DocsPlugin extends ThinDocsPlugin { + commands: any[]; +} + +export interface DocsState { + allPlugins: ThinDocsPlugin[]; + loadingAllPlugins: boolean; + + plugins: { + [key: string]: DocsPlugin; + }; +} + export type RootState = { auth: AuthState; guilds: GuildState; + docs: DocsState; }; 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..33f7c9a3 --- /dev/null +++ b/dashboard/src/style/docs.scss @@ -0,0 +1,44 @@ +@import url('https://cdn.materialdesignicons.com/2.5.94/css/materialdesignicons.min.css'); + +$family-primary: 'Open Sans', sans-serif; +$list-background-color: transparent; + +$size-1: 2.5rem; +$size-2: 2rem; +$size-3: 1.5rem; +$size-4: 1.25rem; + +@import "~bulmaswatch/superhero/_variables"; +@import "~bulma/bulma"; +@import "~bulmaswatch/superhero/_overrides"; + +.docs-cloak { + visibility: visible !important; +} + +.z-title { + line-height: 1.125; + + &.is-1 { font-size: $size-1; } + &.is-2 { font-size: $size-2; } + &.is-3 { font-size: $size-3; } + &.is-4 { font-size: $size-4; } + &.is-5 { font-size: $size-5; } + &.is-6 { font-size: $size-6; } +} + +.z-list { + margin-left: 1.5rem; +} + +.z-ul { + list-style: disc; +} + +.mt-1 { margin-top: 1rem; } +.mt-2 { margin-top: 1.5rem; } +.mt-3 { margin-top: 2rem; } + +.mb-1 { margin-bottom: 1rem; } +.mb-2 { margin-bottom: 1.5rem; } +.mb-3 { margin-bottom: 2rem; } diff --git a/package-lock.json b/package-lock.json index 1da7a64a..f728fad7 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": "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", @@ -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": { @@ -11373,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 c29277a1..9b9cc694 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": "^22.0.0", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", @@ -44,14 +44,14 @@ "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", "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", @@ -84,6 +84,7 @@ }, "lint-staged": { "*.ts": [ + "tslint", "prettier --write", "git add" ] 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/api/docs.ts b/src/api/docs.ts new file mode 100644 index 00000000..324a1079 --- /dev/null +++ b/src/api/docs.ts @@ -0,0 +1,122 @@ +import express from "express"; +import { availablePlugins } from "../plugins/availablePlugins"; +import { ZeppelinPlugin } from "../plugins/ZeppelinPlugin"; +import { notFound } from "./responses"; +import { CommandManager, ICommandConfig } from "knub/dist/CommandManager"; +import { dropPropertiesByName, indentLines } from "../utils"; + +const commandManager = new CommandManager(); + +function formatConfigSchema(schema) { + if (schema._tag === "InterfaceType" || schema._tag === "PartialType") { + return ( + `{\n` + + Object.entries(schema.props) + .map(([k, value]) => indentLines(`${k}: ${formatConfigSchema(value)}`, 2)) + .join("\n") + + "\n}" + ); + } else if (schema._tag === "DictionaryType") { + return "{\n" + indentLines(`[string]: ${formatConfigSchema(schema.codomain)}`, 2) + "\n}"; + } else { + return schema.name; + } +} + +function formatTypeName(typeName) { + let result = ""; + let indent = 0; + let skip = false; + for (const char of [...typeName]) { + if (skip) { + skip = false; + continue; + } + + if (char === "}") { + result += "\n"; + indent--; + skip = true; + } + + result += char; + + if (char === "{") { + result += "\n"; + indent++; + skip = true; + } + + if (char === ",") { + result += "\n"; + skip = true; + } + } + return result; +} + +export function initDocs(app: express.Express) { + const docsPlugins = availablePlugins.filter(pluginClass => pluginClass.showInDocs); + + app.get("/docs/plugins", (req: express.Request, res: express.Response) => { + res.json( + docsPlugins.map(pluginClass => { + const thinInfo = pluginClass.pluginInfo ? { prettyName: pluginClass.pluginInfo.prettyName } : {}; + return { + name: pluginClass.pluginName, + info: thinInfo, + }; + }), + ); + }); + + app.get("/docs/plugins/:pluginName", (req: express.Request, res: express.Response) => { + const pluginClass = docsPlugins.find(obj => obj.pluginName === req.params.pluginName); + if (!pluginClass) { + return notFound(res); + } + + const props = Reflect.ownKeys(pluginClass.prototype); + const commands = props.reduce((arr, prop) => { + if (typeof prop !== "string") return arr; + const propCommands = Reflect.getMetadata("commands", pluginClass.prototype, prop); + if (propCommands) { + arr.push( + ...propCommands.map(cmd => { + const trigger = typeof cmd.command === "string" ? cmd.command : cmd.command.source; + const parameters = cmd.parameters + ? typeof cmd.parameters === "string" + ? commandManager.parseParameterString(cmd.parameters) + : cmd.parameters + : []; + const config: ICommandConfig = cmd.options || {}; + if (config.overloads) { + config.overloads = config.overloads.map(overload => { + return typeof overload === "string" ? commandManager.parseParameterString(overload) : overload; + }); + } + + return { + trigger, + parameters, + config, + }; + }), + ); + } + return arr; + }, []); + + const options = (pluginClass as typeof ZeppelinPlugin).getStaticDefaultOptions(); + + const configSchema = pluginClass.configSchema && formatConfigSchema(pluginClass.configSchema); + + res.json({ + name: pluginClass.pluginName, + info: pluginClass.pluginInfo || {}, + configSchema, + options, + commands, + }); + }); +} diff --git a/src/api/index.ts b/src/api/index.ts index 113d8990..4dc9c942 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,13 +4,22 @@ import cors from "cors"; import { initAuth } from "./auth"; import { initGuildsAPI } from "./guilds"; import { initArchives } from "./archives"; +import { initDocs } from "./docs"; 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") }); -console.log("Connecting to database..."); +function errorHandler(err) { + console.error(err.stack || err); // tslint:disable-line:no-console + process.exit(1); +} + +process.on("unhandledRejection", errorHandler); + +console.log("Connecting to database..."); // tslint:disable-line connect().then(() => { const app = express(); @@ -24,6 +33,7 @@ connect().then(() => { initAuth(app); initGuildsAPI(app); initArchives(app); + initDocs(app); // Default route app.get("/", (req, res) => { @@ -35,7 +45,7 @@ connect().then(() => { if (err instanceof TokenError) { clientError(res, "Invalid code"); } else { - console.error(err); + console.error(err); // tslint:disable-line error(res, "Server error", err.status || 500); } }); @@ -46,5 +56,6 @@ connect().then(() => { }); const port = process.env.PORT || 3000; + // tslint:disable-next-line app.listen(port, () => console.log(`API server listening on port ${port}`)); }); 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/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"); 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); diff --git a/src/plugins/AutoReactionsPlugin.ts b/src/plugins/AutoReactionsPlugin.ts index e937dc7d..45e1d653 100644 --- a/src/plugins/AutoReactionsPlugin.ts +++ b/src/plugins/AutoReactionsPlugin.ts @@ -4,7 +4,7 @@ import { SavedMessage } from "../data/entities/SavedMessage"; import { GuildAutoReactions } from "../data/GuildAutoReactions"; import { Message } from "eris"; import { customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import * as t from "io-ts"; const ConfigSchema = t.type({ @@ -14,14 +14,21 @@ type TConfigSchema = t.TypeOf; export class AutoReactionsPlugin extends ZeppelinPlugin { public static pluginName = "auto_reactions"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Auto-reactions", + description: trimPluginDescription(` + Allows setting up automatic reactions to all new messages on a channel + `), + }; protected savedMessages: GuildSavedMessages; protected autoReactions: GuildAutoReactions; private onMessageCreateFn; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_manage: false, @@ -49,7 +56,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 +90,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/Automod.ts b/src/plugins/Automod.ts new file mode 100644 index 00000000..5b90959f --- /dev/null +++ b/src/plugins/Automod.ts @@ -0,0 +1,872 @@ +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), +}); + +const AlertAction = t.type({ + text: 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), + alert: tNullable(AlertAction), + }), +}); +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"; + public 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; + } + + public 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.warn) { + const reason = rule.actions.mute.reason || "Warned automatically"; + const caseArgs = { + modId: this.bot.user.id, + extraNotes: [`Matched automod rule "${rule.name}"`], + }; + + if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { + const member = await this.getMember(matchResult.userId); + if (member) { + await this.modActions.warnMember(member, reason, caseArgs); + } + } else if (matchResult.type === "raidspam") { + for (const userId of matchResult.userIds) { + const member = await this.getMember(userId); + if (member) { + await this.modActions.warnMember(member, reason, caseArgs); + } + } + } + } + + 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); + } + } + } + + if (rule.actions.kick) { + const reason = rule.actions.kick.reason || "Kicked automatically"; + const caseArgs = { + modId: this.bot.user.id, + extraNotes: [`Matched automod rule "${rule.name}"`], + }; + + if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { + const member = await this.getMember(matchResult.userId); + if (member) { + await this.modActions.kickMember(member, reason, caseArgs); + } + } else if (matchResult.type === "raidspam") { + for (const userId of matchResult.userIds) { + const member = await this.getMember(userId); + if (member) { + await this.modActions.kickMember(member, reason, caseArgs); + } + } + } + } + + if (rule.actions.ban) { + const reason = rule.actions.ban.reason || "Banned 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.modActions.banUserId(matchResult.userId, reason, caseArgs); + } else if (matchResult.type === "raidspam") { + for (const userId of matchResult.userIds) { + await this.modActions.banUserId(userId, reason, caseArgs); + } + } + } + + // TODO: Alert action (and AUTOMOD_ALERT log type) + } + + @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/BotControl.ts b/src/plugins/BotControl.ts index 6288600e..8ab6af4f 100644 --- a/src/plugins/BotControl.ts +++ b/src/plugins/BotControl.ts @@ -1,32 +1,57 @@ -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"; + public static configSchema = ConfigSchema; - getDefaultOptions(): IPluginOptions { + protected archives: GuildArchives; + + public static getStaticDefaultOptions() { return { config: { + can_use: false, owners: [], update_cmd: null, }, + overrides: [ + { + level: ">=100", + config: { + can_use: true, + }, + }, + ], }; } + protected getMemberLevel(member) { + return this.isOwner(member.id) ? 100 : 0; + } + async onLoad() { + this.archives = new GuildArchives(0); + if (activeReload) { const [guildId, channelId] = activeReload; activeReload = null; @@ -41,14 +66,9 @@ export class BotControlPlugin extends GlobalPlugin { } } - isOwner(userId) { - return this.getConfig().owners.includes(userId); - } - @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 +84,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 +97,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 +110,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 +122,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 +140,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 +161,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 +180,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 +206,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)); + } } diff --git a/src/plugins/Cases.ts b/src/plugins/Cases.ts index 5d333435..a08db070 100644 --- a/src/plugins/Cases.ts +++ b/src/plugins/Cases.ts @@ -4,7 +4,7 @@ import { CaseTypes } from "../data/CaseTypes"; import { Case } from "../data/entities/Case"; import moment from "moment-timezone"; import { CaseTypeColors } from "../data/CaseTypeColors"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import { GuildArchives } from "../data/GuildArchives"; import { IPluginOptions } from "knub"; import { GuildLogs } from "../data/GuildLogs"; @@ -45,13 +45,20 @@ export type CaseNoteArgs = { export class CasesPlugin extends ZeppelinPlugin { public static pluginName = "cases"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Cases", + description: trimPluginDescription(` + This plugin contains basic configuration for cases created by other plugins + `), + }; protected cases: GuildCases; protected archives: GuildArchives; protected logs: GuildLogs; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { log_automatic_actions: true, diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts index 7ee32dca..4647bb77 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"; @@ -14,9 +14,10 @@ import { import { ZalgoRegex } from "../data/Zalgo"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { SavedMessage } from "../data/entities/SavedMessage"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import cloneDeep from "lodash.clonedeep"; import * as t from "io-ts"; +import { TSafeRegex } from "../validatorUtils"; const ConfigSchema = t.type({ filter_zalgo: t.boolean, @@ -31,12 +32,20 @@ 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(TSafeRegex)), }); type TConfigSchema = t.TypeOf; export class CensorPlugin extends ZeppelinPlugin { public static pluginName = "censor"; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Censor", + description: trimPluginDescription(` + Censor words, tokens, links, regex, etc. + `), + }; protected serverLogs: GuildLogs; protected savedMessages: GuildSavedMessages; @@ -44,7 +53,7 @@ export class CensorPlugin extends ZeppelinPlugin { private onMessageCreateFn; private onMessageUpdateFn; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { filter_zalgo: false, @@ -236,12 +245,20 @@ 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 [i, regex] of blockedRegex.entries()) { + if (typeof regex.test !== "function") { + logger.info( + `[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 (\`${regexStr}\`) found`); + this.censorMessage(savedMessage, `blocked regex (\`${regex.source}\`) found`); return true; } } diff --git a/src/plugins/ChannelArchiver.ts b/src/plugins/ChannelArchiver.ts new file mode 100644 index 00000000..93af4cc5 --- /dev/null +++ b/src/plugins/ChannelArchiver.ts @@ -0,0 +1,152 @@ +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"; + public static showInDocs = false; + + 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 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"); + 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}`; + } + } + + 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++; + } + } + + 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/CompanionChannels.ts b/src/plugins/CompanionChannels.ts index a8771601..fc371d54 100644 --- a/src/plugins/CompanionChannels.ts +++ b/src/plugins/CompanionChannels.ts @@ -1,5 +1,5 @@ import { decorators as d, IPluginOptions, logger } from "knub"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import { Member, Channel, GuildChannel, PermissionOverwrite, Permission, Message, TextChannel } from "eris"; import * as t from "io-ts"; import { tNullable } from "../utils"; @@ -28,9 +28,18 @@ const defaultCompanionChannelOpts: Partial = { export class CompanionChannelPlugin extends ZeppelinPlugin { public static pluginName = "companion_channels"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; - protected static getStaticDefaultOptions(): IPluginOptions { + public static pluginInfo = { + prettyName: "Companion channels", + description: trimPluginDescription(` + Set up 'companion channels' between text and voice channels. + Once set up, any time a user joins one of the specified voice channels, + they'll get channel permissions applied to them for the text channels. + `), + }; + + public static getStaticDefaultOptions(): IPluginOptions { return { config: { entries: {}, diff --git a/src/plugins/CustomEvents.ts b/src/plugins/CustomEvents.ts index aaeb9926..d2e8281d 100644 --- a/src/plugins/CustomEvents.ts +++ b/src/plugins/CustomEvents.ts @@ -70,12 +70,13 @@ class ActionError extends Error {} export class CustomEventsPlugin extends ZeppelinPlugin { public static pluginName = "custom_events"; + public static showInDocs = false; public static dependencies = ["cases"]; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; private clearTriggers: () => void; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { events: {}, @@ -162,7 +163,7 @@ export class CustomEventsPlugin extends ZeppelinPlugin { const casesPlugin = this.getPlugin("cases"); await casesPlugin.createCase({ userId: targetId, - modId: modId, + modId, type: CaseTypes[action.case_type], reason: `__[${event.name}]__ ${reason}`, }); diff --git a/src/plugins/GlobalZeppelinPlugin.ts b/src/plugins/GlobalZeppelinPlugin.ts index cc182be0..73f21cef 100644 --- a/src/plugins/GlobalZeppelinPlugin.ts +++ b/src/plugins/GlobalZeppelinPlugin.ts @@ -4,36 +4,115 @@ 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, StrictValidationError } from "../validatorUtils"; +import { mergeConfig } from "knub/dist/configUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; export class GlobalZeppelinPlugin extends GlobalPlugin { - protected static configSchema: t.TypeC; + public 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. + */ + public 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 StrictValidationError) { + throw decodedConfig; + } + + 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 StrictValidationError) { + throw decodedOverrideConfig; + } + 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; - } + try { + this.mergeAndDecodeStaticOptions(options); + } catch (e) { + if (e instanceof StrictValidationError) { + return e.getErrors(); } + + throw e; } } // No errors, return null return null; } + + public async runLoad(): Promise { + 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); + } } 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/GuildInfoSaver.ts b/src/plugins/GuildInfoSaver.ts index 9c1108c4..2dfd591b 100644 --- a/src/plugins/GuildInfoSaver.ts +++ b/src/plugins/GuildInfoSaver.ts @@ -4,6 +4,7 @@ import { MINUTES } from "../utils"; export class GuildInfoSaverPlugin extends ZeppelinPlugin { public static pluginName = "guild_info_saver"; + public static showInDocs = false; protected allowedGuilds: AllowedGuilds; private updateInterval; diff --git a/src/plugins/LocateUser.ts b/src/plugins/LocateUser.ts index 1fde5681..d859f17c 100644 --- a/src/plugins/LocateUser.ts +++ b/src/plugins/LocateUser.ts @@ -1,10 +1,10 @@ import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, 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({ @@ -17,13 +17,22 @@ const ALERT_LOOP_TIME = 30 * 1000; export class LocatePlugin extends ZeppelinPlugin { public static pluginName = "locate_user"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Locate user", + description: trimPluginDescription(` + 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 + `), + }; private alerts: GuildVCAlerts; private outdatedAlertsTimeout; private usersWithAlerts: string[] = []; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_where: false, @@ -52,7 +61,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); @@ -68,21 +77,28 @@ export class LocatePlugin extends ZeppelinPlugin { }); } - @d.command("where", "", {}) + @d.command("where", "", { + info: { + description: "Posts an instant invite to the voice channel that `` is in", + }, + }) @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} |`); } @d.command("vcalert", " [duration:delay] [reminder:string$]", { aliases: ["vca"], + info: { + description: "Sets up an alert that notifies you any time `` switches or joins voice channels", + }, }) @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)) { @@ -137,7 +153,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 +161,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,21 +173,21 @@ 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 => { - 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); }); } - 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); } @@ -179,12 +195,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 +208,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]; diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts index 5e23d308..d2ab4380 100644 --- a/src/plugins/Logs.ts +++ b/src/plugins/Logs.ts @@ -22,7 +22,7 @@ import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { SavedMessage } from "../data/entities/SavedMessage"; import { GuildArchives } from "../data/GuildArchives"; import { GuildCases } from "../data/GuildCases"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import { renderTemplate, TemplateParseError } from "../templateFormatter"; import cloneDeep from "lodash.clonedeep"; import * as t from "io-ts"; @@ -53,7 +53,11 @@ type TConfigSchema = t.TypeOf; export class LogsPlugin extends ZeppelinPlugin { public static pluginName = "logs"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Logs", + }; protected guildLogs: GuildLogs; protected savedMessages: GuildSavedMessages; @@ -70,7 +74,7 @@ export class LogsPlugin extends ZeppelinPlugin { private excludedUserProps = ["user", "member", "mod"]; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { channels: {}, diff --git a/src/plugins/MessageSaver.ts b/src/plugins/MessageSaver.ts index ca4d138d..d8567b05 100644 --- a/src/plugins/MessageSaver.ts +++ b/src/plugins/MessageSaver.ts @@ -12,11 +12,12 @@ type TConfigSchema = t.TypeOf; export class MessageSaverPlugin extends ZeppelinPlugin { public static pluginName = "message_saver"; - protected static configSchema = ConfigSchema; + public static showInDocs = false; + public static configSchema = ConfigSchema; protected savedMessages: GuildSavedMessages; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_manage: false, diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index 21a57f44..093e9629 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 { @@ -15,6 +16,8 @@ import { stripObjectToScalars, successMessage, tNullable, + trimEmptyStartEndLines, + trimIndents, trimLines, ucfirst, UnknownUser, @@ -23,10 +26,10 @@ import { GuildMutes } from "../data/GuildMutes"; import { CaseTypes } from "../data/CaseTypes"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { trimPluginDescription, 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"; @@ -67,10 +70,52 @@ interface IIgnoredEvent { userId: string; } +export type WarnResult = + | { + status: "failed"; + error: string; + } + | { + status: "success"; + case: Case; + notifyResult: INotifyUserResult; + }; + +export type KickResult = + | { + status: "failed"; + error: string; + } + | { + status: "success"; + case: Case; + notifyResult: INotifyUserResult; + }; + +export type BanResult = + | { + status: "failed"; + error: string; + } + | { + status: "success"; + case: Case; + notifyResult: INotifyUserResult; + }; + +type WarnMemberNotifyRetryCallback = () => boolean | Promise; + export class ModActionsPlugin extends ZeppelinPlugin { public static pluginName = "mod_actions"; public static dependencies = ["cases", "mutes"]; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Mod actions", + description: trimPluginDescription(` + This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc. + `), + }; protected mutes: GuildMutes; protected cases: GuildCases; @@ -86,7 +131,7 @@ export class ModActionsPlugin extends ZeppelinPlugin { this.ignoredEvents = []; } - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { dm_on_warn: true, @@ -161,8 +206,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) { @@ -319,10 +372,121 @@ 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$]"], + info: { + description: + "Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it", + }, }) @d.permission("can_note") async updateCmd(msg: Message, args: { caseNumber?: number; note?: string }) { @@ -362,7 +526,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`)); } - @d.command("note", " ") + @d.command("note", " ", { + info: { + description: "Add a note to the specified user", + }, + }) @d.permission("can_note") async noteCmd(msg: Message, args: { user: string; note: string }) { const user = await this.resolveUser(args.user); @@ -384,6 +552,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("warn", " ", { options: [{ name: "mod", type: "member" }], + info: { + description: "Send a warning to the specified user", + }, }) @d.permission("can_warn") async warnCmd(msg: Message, args: { user: string; reason: string; mod?: Member }) { @@ -424,37 +595,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}`, ), ); @@ -465,6 +626,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. @@ -496,8 +712,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; } @@ -536,6 +757,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("mute", " ", { overloads: [" ", " [reason:string$]"], options: [{ name: "mod", type: "member" }], + info: { + description: "Mute the specified member", + }, }) @d.permission("can_mute") async muteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) { @@ -574,6 +798,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("forcemute", " ", { overloads: [" ", " [reason:string$]"], options: [{ name: "mod", type: "member" }], + info: { + description: "Force-mute the specified user, even if they're not on the server", + }, }) @d.permission("can_mute") async forcemuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) { @@ -649,6 +876,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("unmute", " ", { overloads: [" ", " [reason:string$]"], options: [{ name: "mod", type: "member" }], + info: { + description: "Unmute the specified member", + }, }) @d.permission("can_mute") async unmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) { @@ -691,6 +921,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("forceunmute", " ", { overloads: [" ", " [reason:string$]"], options: [{ name: "mod", type: "member" }], + info: { + description: "Force-unmute the specified user, even if they're not on the server", + }, }) @d.permission("can_mute") async forceunmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) { @@ -717,6 +950,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("kick", " [reason:string$]", { options: [{ name: "mod", type: "member" }], + info: { + description: "Kick the specified member", + }, }) @d.permission("can_kick") async kickCmd(msg, args: { user: string; reason: string; mod: Member }) { @@ -753,57 +989,31 @@ export class ModActionsPlugin extends ZeppelinPlugin { mod = args.mod; } - const config = this.getConfig(); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - // 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); - memberToKick.kick(reason); - - // Create a case for this action - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - userId: memberToKick.id, + const kickResult = await this.kickMember(memberToKick, reason, { 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)] : [], }); + if (kickResult.status === "failed") { + msg.channel.createMessage(errorMessage(`Failed to kick user`)); + return; + } + // 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$]", { options: [{ name: "mod", type: "member" }], + info: { + description: "Ban the specified member", + }, }) @d.permission("can_ban") async banCmd(msg, args: { user: string; reason?: string; mod?: Member }) { @@ -840,57 +1050,32 @@ export class ModActionsPlugin extends ZeppelinPlugin { mod = args.mod; } - const config = this.getConfig(); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - // Attempt to message the user *before* banning them, as doing it after may not be possible - let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; - if (reason) { - const banMessage = await renderTemplate(config.ban_message, { - guildName: this.guild.name, - reason, - }); - - userMessageResult = await notifyUser(this.bot, this.guild, memberToBan.user, banMessage, { - useDM: config.dm_on_ban, - useChannel: config.message_on_ban, - channelId: config.message_channel, - }); - } - - // Ban the user - this.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToBan.id); - this.ignoreEvent(IgnoredEventType.Ban, memberToBan.id); - memberToBan.ban(1, reason); - - // Create a case for this action - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - userId: memberToBan.id, + const banResult = await this.banUserId(memberToBan.id, reason, { modId: mod.id, - type: CaseTypes.Ban, - reason, ppId: mod.id !== msg.author.id ? msg.author.id : null, - noteDetails: userMessageResult.status !== NotifyUserStatus.Ignored ? [ucfirst(userMessageResult.text)] : [], }); + if (banResult.status === "failed") { + msg.channel.createMessage(errorMessage(`Failed to ban member`)); + return; + } + // Confirm the action to the moderator let response = `Banned **${memberToBan.user.username}#${memberToBan.user.discriminator}** (Case #${ - createdCase.case_number + banResult.case.case_number })`; - if (userMessageResult.text) response += ` (${userMessageResult.text})`; + if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; msg.channel.createMessage(successMessage(response)); - - // Log the action - this.serverLogs.log(LogType.MEMBER_BAN, { - mod: stripObjectToScalars(mod.user), - user: stripObjectToScalars(memberToBan.user), - }); } @d.command("softban", " [reason:string$]", { options: [{ name: "mod", type: "member" }], + info: { + description: + '"Softban" the specified user by banning and immediately unbanning them. Effectively a kick with message deletions.', + }, }) @d.permission("can_ban") async softbanCmd(msg, args: { user: string; reason: string; mod?: Member }) { @@ -935,8 +1120,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"); @@ -966,6 +1162,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("unban", " [reason:string$]", { options: [{ name: "mod", type: "member" }], + info: { + description: "Unban the specified member", + }, }) @d.permission("can_ban") async unbanCmd(msg: Message, args: { user: string; reason: string; mod: Member }) { @@ -1017,6 +1216,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("forceban", " [reason:string$]", { options: [{ name: "mod", type: "member" }], + info: { + description: "Force-ban the specified user, even if they aren't on the server", + }, }) @d.permission("can_ban") async forcebanCmd(msg: Message, args: { user: string; reason?: string; mod?: Member }) { @@ -1054,7 +1256,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; @@ -1080,7 +1282,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { }); } - @d.command("massban", "") + @d.command("massban", "", { + info: { + description: "Mass-ban a list of user IDs", + }, + }) @d.permission("can_massban") async massbanCmd(msg: Message, args: { userIds: string[] }) { // Limit to 100 users at once (arbitrary?) @@ -1164,6 +1370,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("addcase", " [reason:string$]", { options: [{ name: "mod", type: "member" }], + info: { + description: "Add an arbitrary case to the specified user without taking any action", + }, }) @d.permission("can_addcase") async addcaseCmd(msg: Message, args: { type: string; user: string; reason?: string; mod?: Member }) { @@ -1224,15 +1433,13 @@ export class ModActionsPlugin extends ZeppelinPlugin { }); } - /** - * Display a case or list of cases - * If the argument passed is a case id, display that case - * If the argument passed is a user id, show all cases on that user - */ - @d.command("case", "") + @d.command("case", "", { + info: { + description: "Show information about a specific case", + }, + }) @d.permission("can_view") async showCaseCmd(msg: Message, args: { caseNumber: number }) { - // Assume case id const theCase = await this.cases.findByCaseNumber(args.caseNumber); if (!theCase) { @@ -1258,6 +1465,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { shortcut: "h", }, ], + info: { + description: "Show a list of cases the specified user has", + }, }) @d.permission("can_view") async userCasesCmd(msg: Message, args: { user: string; expand?: boolean; hidden?: boolean }) { @@ -1322,6 +1532,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("cases", null, { options: [{ name: "mod", type: "Member" }], + info: { + description: "Show the most recent 5 cases by the specified --mod", + }, }) @d.permission("can_view") async recentCasesCmd(msg: Message, args: { mod?: Member }) { @@ -1347,7 +1560,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { } } - @d.command("hidecase", "") + @d.command("hidecase", "", { + info: { + description: "Hide the specified case so it doesn't appear in !cases or !info", + }, + }) @d.permission("can_hidecase") async hideCaseCmd(msg: Message, args: { caseNum: number }) { const theCase = await this.cases.findByCaseNumber(args.caseNum); @@ -1362,7 +1579,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { ); } - @d.command("unhidecase", "") + @d.command("unhidecase", "", { + info: { + description: "Un-hide the specified case, making it appear in !cases and !info again", + }, + }) @d.permission("can_hidecase") async unhideCaseCmd(msg: Message, args: { caseNum: number }) { const theCase = await this.cases.findByCaseNumber(args.caseNum); diff --git a/src/plugins/Mutes.ts b/src/plugins/Mutes.ts index 926cc84d..f6ca8b98 100644 --- a/src/plugins/Mutes.ts +++ b/src/plugins/Mutes.ts @@ -64,14 +64,18 @@ const FIRST_CHECK_INCREMENT = 5 * 1000; export class MutesPlugin extends ZeppelinPlugin { public static pluginName = "mutes"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Mutes", + }; protected mutes: GuildMutes; protected cases: GuildCases; protected serverLogs: GuildLogs; private muteClearIntervalId: NodeJS.Timer; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { mute_role: null, @@ -139,7 +143,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 +268,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) diff --git a/src/plugins/NameHistory.ts b/src/plugins/NameHistory.ts index d869b04e..19ce73f2 100644 --- a/src/plugins/NameHistory.ts +++ b/src/plugins/NameHistory.ts @@ -13,12 +13,13 @@ type TConfigSchema = t.TypeOf; export class NameHistoryPlugin extends ZeppelinPlugin { public static pluginName = "name_history"; - protected static configSchema = ConfigSchema; + public static showInDocs = false; + public static configSchema = ConfigSchema; protected nicknameHistory: GuildNicknameHistory; protected usernameHistory: UsernameHistory; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_view: false, @@ -40,7 +41,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 +73,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/Persist.ts b/src/plugins/Persist.ts index 1e3b8d74..240f5518 100644 --- a/src/plugins/Persist.ts +++ b/src/plugins/Persist.ts @@ -17,12 +17,16 @@ type TConfigSchema = t.TypeOf; export class PersistPlugin extends ZeppelinPlugin { public static pluginName = "persist"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Persist", + }; protected persistedData: GuildPersistedData; protected logs: GuildLogs; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { persisted_roles: [], diff --git a/src/plugins/PingableRolesPlugin.ts b/src/plugins/PingableRolesPlugin.ts index 7638c976..895f042f 100644 --- a/src/plugins/PingableRolesPlugin.ts +++ b/src/plugins/PingableRolesPlugin.ts @@ -15,13 +15,17 @@ const TIMEOUT = 10 * 1000; export class PingableRolesPlugin extends ZeppelinPlugin { public static pluginName = "pingable_roles"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Pingable roles", + }; protected pingableRoles: GuildPingableRoles; protected cache: Map; protected timeouts: Map; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_manage: false, @@ -53,7 +57,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 +74,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/Post.ts b/src/plugins/Post.ts index 5822b17a..b949bb62 100644 --- a/src/plugins/Post.ts +++ b/src/plugins/Post.ts @@ -38,7 +38,11 @@ const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50; export class PostPlugin extends ZeppelinPlugin { public static pluginName = "post"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Post", + }; protected savedMessages: GuildSavedMessages; protected scheduledPosts: GuildScheduledPosts; @@ -58,7 +62,7 @@ export class PostPlugin extends ZeppelinPlugin { clearTimeout(this.scheduledPostLoopTimeout); } - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_post: false, @@ -261,7 +265,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 +355,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) { diff --git a/src/plugins/ReactionRoles.ts b/src/plugins/ReactionRoles.ts index 1520a9ac..c2218544 100644 --- a/src/plugins/ReactionRoles.ts +++ b/src/plugins/ReactionRoles.ts @@ -44,7 +44,11 @@ type PendingMemberRoleChanges = { export class ReactionRolesPlugin extends ZeppelinPlugin { public static pluginName = "reaction_roles"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Reaction roles", + }; protected reactionRoles: GuildReactionRoles; protected savedMessages: GuildSavedMessages; @@ -55,7 +59,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { private autoRefreshTimeout; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { auto_refresh_interval: MIN_AUTO_REFRESH, diff --git a/src/plugins/Reminders.ts b/src/plugins/Reminders.ts index 6f2c214d..b97dc72d 100644 --- a/src/plugins/Reminders.ts +++ b/src/plugins/Reminders.ts @@ -24,7 +24,11 @@ const MAX_TRIES = 3; export class RemindersPlugin extends ZeppelinPlugin { public static pluginName = "reminders"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Reminders", + }; protected reminders: GuildReminders; protected tries: Map; @@ -32,7 +36,7 @@ export class RemindersPlugin extends ZeppelinPlugin { private postRemindersTimeout; private unloaded = false; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_use: false, diff --git a/src/plugins/SelfGrantableRolesPlugin.ts b/src/plugins/SelfGrantableRolesPlugin.ts index bf9cb936..232898be 100644 --- a/src/plugins/SelfGrantableRolesPlugin.ts +++ b/src/plugins/SelfGrantableRolesPlugin.ts @@ -14,11 +14,12 @@ type TConfigSchema = t.TypeOf; export class SelfGrantableRolesPlugin extends ZeppelinPlugin { public static pluginName = "self_grantable_roles"; - protected static configSchema = ConfigSchema; + public static showInDocs = false; + public static configSchema = ConfigSchema; protected selfGrantableRoles: GuildSelfGrantableRoles; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_manage: false, diff --git a/src/plugins/Slowmode.ts b/src/plugins/Slowmode.ts index df4801d0..0fc58751 100644 --- a/src/plugins/Slowmode.ts +++ b/src/plugins/Slowmode.ts @@ -33,7 +33,11 @@ const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * 1000; export class SlowmodePlugin extends ZeppelinPlugin { public static pluginName = "slowmode"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Slowmode", + }; protected slowmodes: GuildSlowmodes; protected savedMessages: GuildSavedMessages; @@ -42,7 +46,7 @@ export class SlowmodePlugin extends ZeppelinPlugin { private onMessageCreateFn; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { use_native_slowmode: true, diff --git a/src/plugins/Spam.ts b/src/plugins/Spam.ts index d398fb3f..6447d791 100644 --- a/src/plugins/Spam.ts +++ b/src/plugins/Spam.ts @@ -74,7 +74,11 @@ const SPAM_ARCHIVE_EXPIRY_DAYS = 90; export class SpamPlugin extends ZeppelinPlugin { public static pluginName = "spam"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Spam protection", + }; protected logs: GuildLogs; protected archives: GuildArchives; @@ -96,7 +100,7 @@ export class SpamPlugin extends ZeppelinPlugin { private expiryInterval; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { max_censor: null, diff --git a/src/plugins/Starboard.ts b/src/plugins/Starboard.ts index b4e82b5c..06f3b69b 100644 --- a/src/plugins/Starboard.ts +++ b/src/plugins/Starboard.ts @@ -8,7 +8,7 @@ import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { SavedMessage } from "../data/entities/SavedMessage"; import * as t from "io-ts"; import { GuildStarboardMessages } from "../data/GuildStarboardMessages"; -import { StarboardMessage } from "src/data/entities/StarboardMessage"; +import { StarboardMessage } from "../data/entities/StarboardMessage"; const StarboardOpts = t.type({ source_channel_ids: t.array(t.string), @@ -38,14 +38,15 @@ const defaultStarboardOpts: Partial = { export class StarboardPlugin extends ZeppelinPlugin { public static pluginName = "starboard"; - protected static configSchema = ConfigSchema; + public static showInDocs = false; + public static configSchema = ConfigSchema; protected savedMessages: GuildSavedMessages; protected starboardMessages: GuildStarboardMessages; private onMessageDeleteFn; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_manage: false, @@ -258,7 +259,7 @@ export class StarboardPlugin extends ZeppelinPlugin { } } - @d.command("starboard migrate_pins", " ") + @d.command("starboard migrate_pins", " ") async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) { try { const starboards = await this.getStarboardOptsForStarboardChannelId(this.bot.getChannel(args.starboardChannelId)); diff --git a/src/plugins/Tags.ts b/src/plugins/Tags.ts index 2937f026..a6570600 100644 --- a/src/plugins/Tags.ts +++ b/src/plugins/Tags.ts @@ -23,7 +23,11 @@ type TConfigSchema = t.TypeOf; export class TagsPlugin extends ZeppelinPlugin { public static pluginName = "tags"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Tags", + }; protected archives: GuildArchives; protected tags: GuildTags; @@ -34,7 +38,7 @@ export class TagsPlugin extends ZeppelinPlugin { protected tagFunctions; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { prefix: "!!", @@ -117,7 +121,7 @@ export class TagsPlugin extends ZeppelinPlugin { } const prefix = this.getConfigForMsg(msg).prefix; - const tagNames = tags.map(t => t.tag).sort(); + const tagNames = tags.map(tag => tag.tag).sort(); msg.channel.createMessage(` Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\` `); 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; diff --git a/src/plugins/Utility.ts b/src/plugins/Utility.ts index de49a7f4..ae4037e4 100644 --- a/src/plugins/Utility.ts +++ b/src/plugins/Utility.ts @@ -82,7 +82,11 @@ type MemberSearchParams = { export class UtilityPlugin extends ZeppelinPlugin { public static pluginName = "utility"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Utility", + }; protected logs: GuildLogs; protected cases: GuildCases; @@ -92,7 +96,7 @@ export class UtilityPlugin extends ZeppelinPlugin { protected lastFullMemberRefresh = 0; protected lastReload; - protected static getStaticDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { can_roles: false, @@ -554,7 +558,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) { @@ -607,7 +611,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 = { @@ -698,7 +702,9 @@ export class UtilityPlugin extends ZeppelinPlugin { msg.channel.createMessage({ embed }); } - @d.command(/(?:nickname|nick) reset/, "") + @d.command("nickname reset", "", { + aliases: ["nick reset"], + }) @d.permission("can_nickname") async nicknameResetCmd(msg: Message, args: { member: Member }) { if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { @@ -718,7 +724,9 @@ export class UtilityPlugin extends ZeppelinPlugin { msg.channel.createMessage(successMessage(`The nickname of <@!${args.member.id}> has been reset`)); } - @d.command(/nickname|nick/, " ") + @d.command("nickname", " ", { + aliases: ["nick"], + }) @d.permission("can_nickname") async nicknameCmd(msg: Message, args: { member: Member; nickname: string }) { if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { diff --git a/src/plugins/WelcomeMessage.ts b/src/plugins/WelcomeMessage.ts index 9a27ff14..330858c6 100644 --- a/src/plugins/WelcomeMessage.ts +++ b/src/plugins/WelcomeMessage.ts @@ -16,11 +16,15 @@ type TConfigSchema = t.TypeOf; export class WelcomeMessagePlugin extends ZeppelinPlugin { public static pluginName = "welcome_message"; - protected static configSchema = ConfigSchema; + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Welcome message", + }; protected logs: GuildLogs; - protected getDefaultOptions(): IPluginOptions { + public static getStaticDefaultOptions(): IPluginOptions { return { config: { send_dm: false, diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index 325a8f17..db16e904 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -4,15 +4,47 @@ 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, + resolveUserId, + trimEmptyStartEndLines, + trimIndents, + UnknownUser, +} from "../utils"; import { Member, User } from "eris"; +import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line import { performance } from "perf_hooks"; -import { validateStrict } from "../validatorUtils"; +import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils"; +import { mergeConfig } from "knub/dist/configUtils"; const SLOW_RESOLVE_THRESHOLD = 1500; +export interface PluginInfo { + prettyName: string; + description?: string; +} + +export interface CommandInfo { + description?: string; + basicUsage?: string; + parameterDescriptions?: { + [key: string]: string; + }; +} + +export function trimPluginDescription(str) { + return trimIndents(trimEmptyStartEndLines(str), 6); +} + export class ZeppelinPlugin extends Plugin { - protected static configSchema: t.TypeC; + public static pluginInfo: PluginInfo; + public static showInDocs: boolean = true; + + public static configSchema: t.TypeC; public static dependencies = []; protected throwPluginRuntimeError(message: string) { @@ -29,49 +61,103 @@ export class ZeppelinPlugin extends Plug return ourLevel > memberLevel; } - protected static getStaticDefaultOptions() { + /** + * 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. + */ + public 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; } + /** + * 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. + * + * 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(); + 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; + } + + 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 StrictValidationError) { + throw decodedOverrideConfig; + } + 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; + try { + this.mergeAndDecodeStaticOptions(options); + } catch (e) { + if (e instanceof StrictValidationError) { + return e.getErrors(); } - } - 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; - } - } - } + throw e; } } @@ -80,12 +166,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 +184,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. @@ -126,14 +217,31 @@ 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); + 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); + } + 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/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, ]; /** diff --git a/src/utils.ts b/src/utils.ts index c41fa665..f57367f8 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, @@ -31,8 +32,23 @@ const delayStringMultipliers = { s: 1000, }; -export function tNullable(type: t.Mixed) { - return t.union([type, t.undefined, t.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 function tNullable>(type: T) { + return t.union([type, t.undefined, t.null], type.name); +} + +export function dropPropertiesByName(obj, propName) { + if (obj.hasOwnProperty(propName)) delete obj[propName]; + for (const value of Object.values(obj)) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + dropPropertiesByName(value, propName); + } + } } /** @@ -168,8 +184,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}`; @@ -235,6 +253,48 @@ export function asSingleLine(str: string) { return trimLines(str).replace(/\n/g, " "); } +export function trimEmptyStartEndLines(str: string) { + const lines = str.split("\n"); + let emptyLinesAtStart = 0; + let emptyLinesAtEnd = 0; + + for (const line of lines) { + if (line.match(/^\s*$/)) { + emptyLinesAtStart++; + } else { + break; + } + } + + for (let i = lines.length - 1; i > 0; i--) { + if (lines[i].match(/^\s*$/)) { + emptyLinesAtEnd++; + } else { + break; + } + } + + return lines.slice(emptyLinesAtStart, emptyLinesAtEnd ? -1 * emptyLinesAtEnd : null).join("\n"); +} + +export function trimIndents(str: string, indentLength: number) { + return str + .split("\n") + .map(line => line.slice(indentLength)) + .join("\n"); +} + +export function indentLine(str: string, indentLength: number) { + return " ".repeat(indentLength) + str; +} + +export function indentLines(str: string, indentLength: number) { + return str + .split("\n") + .map(line => indentLine(line, indentLength)) + .join("\n"); +} + export const emptyEmbedValue = "\u200b"; export const embedPadding = "\n" + emptyEmbedValue; @@ -398,7 +458,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)); } }); @@ -568,94 +628,147 @@ export class UnknownUser { } } +export function isObjectLiteral(obj) { + let deepestPrototype = obj; + while (Object.getPrototypeOf(deepestPrototype) != null) { + deepestPrototype = Object.getPrototypeOf(deepestPrototype); + } + return Object.getPrototypeOf(obj) === deepestPrototype; +} + +const keyMods = ["+", "-", "="]; +export function deepKeyIntersect(obj, keyReference) { + const result = {}; + 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; + } + } + return result; +} + 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) { + 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]; - } - - // A non-mention, full username? + // If we have the user cached, return that directly + const userId = resolveUserId(bot, value); 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; - } + return new UnknownUser({ id: userId }); } - // Just a user ID? - if (!userId) { - const idMatch = value.match(/^\d+$/); - if (!idMatch) { - return null; - } - - userId = value; + if (bot.users.has(userId)) { + return bot.users.get(userId); } - 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); + // 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 }); } + const freshUser = await bot.getRESTUser(userId).catch(noop); + if (freshUser) { + bot.users.add(freshUser, bot); + return freshUser; + } + + 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 }; + +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 === "✅"; +} diff --git a/src/validatorUtils.ts b/src/validatorUtils.ts index a1ca1944..544ae73e 100644 --- a/src/validatorUtils.ts +++ b/src/validatorUtils.ts @@ -1,8 +1,28 @@ 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"; + +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 => { + 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.source}/${s.flags}`, +); // From io-ts/lib/PathReporter function stringify(v) { @@ -31,22 +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("{")); + 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 <${stringify(err.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, @@ -57,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; }, ), ); diff --git a/tslint.json b/tslint.json index 5de5cf89..22f3c3c9 100644 --- a/tslint.json +++ b/tslint.json @@ -22,6 +22,8 @@ "no-bitwise": false, "interface-over-type-literal": false, "interface-name": false, - "no-submodule-imports": false + "no-submodule-imports": false, + "no-floating-promises": true, + "no-string-literal": false } }