-

+
Zeppelin Dashboard
@@ -12,7 +12,9 @@
@@ -28,6 +30,11 @@
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 @@
+
+
+
Configuration format
+
+ This is the basic format of the bot configuration for a guild. The basic breakdown is:
+
+
+ - Prefix (i.e. what character is preceding each command)
+ - Permission levels (see Permissions for more info)
+ - Plugin-specific configuration (see Plugin configuration for more info)
+
+
+ prefix: "!"
+
+ # role id: level
+ levels:
+ "172949857164722176": 100 # Example admin
+ "172950000412655616": 50 # Example mod
+
+ plugins:
+ mod_plugin:
+ config:
+ kick_message: 'You have been kicked'
+ can_kick: false
+ overrides:
+ - level: '>=50'
+ config:
+ can_kick: true
+ - level: '>=100'
+ config:
+ kick_message: 'You have been kicked by an admin'
+
+
+
+
+
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 @@
+
+
+
Introduction
+
+ Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind.
+
+
+
Getting the bot
+
+ Since the bot is currently private, access to the bot is granted on a case by case basis.
+ There are plans to streamline this process in the future.
+
+
+
Configuration
+
+ All Zeppelin configuration is done through the dashboard by editing a YAML config file. By default, only the server
+ owner has access to this, but they can give other users access as they see fit. See Configuration format for more details.
+
+
+
Plugins
+
+ Zeppelin is divided into plugins: grouped functionality that can be enabled/disabled as needed, and that have their own configurations.
+
+
+
Commands
+
+ The commands for each plugin are listed on the plugin's page (see "Plugins" on the menu). On these pages, the command prefix is assumed to be !
but this can be changed on a per-server basis.
+
+
+
diff --git a/dashboard/src/components/docs/Layout.vue b/dashboard/src/components/docs/Layout.vue
new file mode 100644
index 00000000..5b9de5f1
--- /dev/null
+++ b/dashboard/src/components/docs/Layout.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+ Note! This documentation is a work in progress.
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
Permissions
+
+ Permissions in Zeppelin are simply values in plugin configuration that are checked when the command is used.
+ These values can be changed with overrides (see Plugin configuration for more info)
+ and can depend on e.g. user id, role id, channel id, category id, or permission level.
+
+
+
Permission levels
+
+ The simplest way to control access to bot commands and features is via permission levels.
+ These levels are simply a number (usually between 0 and 100), based on the user's roles or user id, that can then
+ be used in permission overrides. By default, several commands are "moderator only" (level 50 and up) or "admin only" (level 100 and up).
+
+
+ Additionally, having a higher permission level means that certain commands (such as !ban) can't be used against
+ you by users with a lower or equal permission level (so e.g. moderators can't ban each other or admins above them).
+
+
+ Permission levels are defined in the config in the levels section. For example:
+
+
+
+ # "role/user id": level
+ levels:
+ "172949857164722176": 100 # Example admin
+ "172950000412655616": 50 # Example mod
+
+
+
Examples
+
+
Basic overrides
+
+ For this example, let's assume we have a plugin called cats
which has a command !cat
locked behind the permission can_cat
.
+ Let's say that by default, the plugin allows anyone to use !cat
, but we want to restrict it to moderators only.
+
+
+ Here's what the configuration for this would look like:
+
+
+
+ plugins:
+ cats:
+ config:
+ can_cat: false # Here we set the permission false by default
+ overrides:
+ # In this override, can_cat is changed to "true" for anyone with a permission level of 50 or higher
+ - level: ">=50"
+ config:
+ can_cat: true
+
+
+
Replacing defaults
+
+ In this example, let's assume you don't want to use the default permission levels of 50 and 100 for mods and admins respectively.
+ Let's say you're using various incremental levels instead: 10, 20, 30, 40, 50...
+ We want to make it so moderator commands are available starting at level 70.
+ Additionally, we'd like to reserve banning for levels 90+ only.
+ To do this, we need to replace the default overrides that enable moderator commands at level 50.
+
+
+ Here's what the configuration for this would look like:
+
+
+
+ plugins:
+ mod_actions:
+ =overrides: # The "=" here means "replace any defaults"
+ - level: ">=70"
+ config:
+ can_warn: true
+ can_mute: true
+ can_kick: true
+ - level: ">=90"
+ config:
+ can_ban: true
+
+
+
+
+
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 @@
+
+
+ Loading...
+
+
+
{{ data.info.prettyName || data.name }}
+
+ Name in config: {{ data.name }}
+
+
+
+
+
+ To enable this plugin with default configuration, add {{ data.name }}: {}
to the plugins
list in config
+
+
+
Default configuration
+
{{ renderConfiguration(data.options) }}
+
+
+
+ {{ data.configSchema }}
+
+
+
+
+
Commands
+
+
!{{ command.trigger }}
+
+ Permission: {{ command.config.requiredPermission }}
+
+
+ Basic usage: {{ command.config.info.basicUsage }}
+
+
+ Shortcut:
+ !{{ alias }}
+
+
+
+
+
+
+ Signatures:
+
+ -
+
+ !{{ command.trigger }}
+ {{ renderParameter(param) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
Plugin configuration
+
+ Each plugin in Zeppelin has its own configuration options. In the config editor, you can both set the default config
+ and overrides based on specific conditions. Permissions are also just values in the plugin's config, and thus follow
+ the same rules with overrides etc. as other options (see Permissions for more info).
+
+
+ Information about each plugin's options can be found on the plugin's page on the sidebar. See Configuration format for an example of a full config.
+
+
+
Overrides
+
+ Overrides are the primary mechanism of changing options and permissions based on permission levels, roles, channels, user ids, etc.
+
+
+ Here's an example demonstrating different types of overrides:
+
+
+
+ plugins:
+ example_plugin:
+ config:
+ can_kick: false
+ kick_message: "You have been kicked"
+ nested:
+ value: "Hello"
+ other_value: "Foo"
+ overrides:
+ # Simple permission level based override to allow kicking only for levels 50 and up
+ - level: '>=50'
+ config:
+ can_kick: true
+ nested:
+ # This only affects nested.other_value; nested.value is still "Hello"
+ other_value: "Bar"
+ # Channel override - don't allow kicking on the specified channel
+ - channel: "109672661671505920"
+ config:
+ can_kick: false
+ # Same as above, but for a full category
+ - category: "360735466737369109"
+ config:
+ can_kick: false
+ # Multiple channels. If any of them match, this override applies.
+ - channel: ["109672661671505920", "570714864285253677"]
+ config:
+ can_kick: false
+ # Match based on a role
+ - role: "172950000412655616"
+ config:
+ can_kick: false
+ # Match based on multiple roles. The user must have ALL roles mentioned here for this override to apply.
+ - role: ["172950000412655616", "172949857164722176"]
+ config:
+ can_kick: false
+ # Match on user id
+ - user: "106391128718245888"
+ config:
+ kick_message: "You have been kicked by Dragory"
+ # Match on multiple conditions
+ - channel: "109672661671505920"
+ role: "172950000412655616"
+ config:
+ can_kick: false
+ # Match on ANY of multiple conditions
+ - channel: "109672661671505920"
+ role: "172950000412655616"
+ type: "any"
+ config:
+ can_kick: false
+
+
+
+
+
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 @@
+
+
+
Argument Types
+This page details the different argument types available for commands.
+
Delay
A delay is used to specify an amount of time. It uses simple letters to specify time durations:
+
+
Example:
+
2d15h27m3s
Would be 2 days, 15 hours, 27 minutes and 3 seconds.
+
+
It is important to note that spaces are not supported!
+
+
+
+
+
+
+ Durations:
+
+ -
+
d
Day
+
+ -
+
h
Hour
+
+ -
+
m
Minute
+
+ -
+
s
Seconds
+
+
+
+
+
+
String
+
+
User
+
+ Anything that uniquelly identifies a user. This includes:
+
+
+ - User ID
108552944961454080
+ - User Mention
@Dark#1010
+ - Loose user mention
Dark#1010
+
+
+
+
+
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 @@
+
+
+
Locate user
+
+ Name in config: locate_user
+
+
+
Description
+
+ This plugin allows users with access to the commands the following:
+
+
+ - Instantly receive an invite to the voice channel of a user
+ - Be notified as soon as a user switches or joins a voice channel
+
+
+
Default configuration
+
+ config:
+ can_where: false
+ can_alert: false
+ overrides:
+ - level: ">=50"
+ config:
+ can_where: true
+ can_alert: true
+
+
+
Commands
+
!where
+
+ Permission: can_where
+ Arguments:
+
+
+ <User>
The user we want to find
+
+
+ Sends an instant invite to the voice channel the user from the <User>
argument is in.
+
+
!vcalert
+
+ Permission: can_alert
+
+ Basic usage: !vcalert 108552944961454080
+
+
+ Shortcut: !vca
+
+ Sends an instant invite along with a specified reminder once the user switches or joins a voice channel.
+
+
+
+
+
+
+ Signatures:
+
+ !vcalert <user>
+ !vcalert <user> [delay] [reminderString]
+
+
+ Arguments:
+
+ <user>
The user we want to find
+ [delay]
How long the alert should be active, following the default Delay format. Default: 10 minutes
+ [reminderString]
Any text we want to receive once the alert triggers. Default: None
+
+
+
+
+
+
+
+
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 @@
+
+
+
Mod actions
+
+ Name in config: mod_actions
+
+
+
Default configuration
+
+ config:
+ dm_on_warn: true
+ dm_on_kick: false
+ dm_on_ban: false
+ message_on_warn: false,
+ message_on_kick: false
+ message_on_ban: false
+ message_channel: null
+ warn_message: "You have received a warning on {guildName}: {reason}"
+ kick_message: "You have been kicked from {guildName}. Reason given: {reason}"
+ ban_message: "You have been banned from {guildName}. Reason given: {reason}"
+ alert_on_rejoin: false
+ alert_channel: null
+ can_note: false
+ can_warn: false
+ can_mute: false
+ can_kick: false
+ can_ban: false
+ can_view: false
+ can_addcase: false
+ can_massban: false
+ can_hidecase: false
+ can_act_as_other: false
+ overrides:
+ - level: ">=50"
+ config:
+ can_note: true
+ can_warn: true
+ can_mute: true
+ can_kick: true
+ can_ban: true
+ can_view: true
+ can_addcase: true
+ - level: ">=100"
+ config:
+ can_massban: true
+ can_hidecase: true
+ can_act_as_other: true
+
+
+
Commands
+
!note
+
+ Permission: can_note
+
+
+
+
+
+
+
+
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