3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-18 07:35:02 +00:00

Merge remote-tracking branch 'origin/master' into revampedStarboard

This commit is contained in:
Nils Blömeke 2019-08-26 01:06:58 +02:00
commit 6eec6fddd7
72 changed files with 3465 additions and 425 deletions

3
dashboard/.htmlnanorc.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
collapseWhitespace: false
};

View file

@ -1267,7 +1267,6 @@
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"requires": { "requires": {
"sprintf-js": "~1.0.2" "sprintf-js": "~1.0.2"
} }
@ -1985,6 +1984,14 @@
"node-releases": "^1.1.23" "node-releases": "^1.1.23"
} }
}, },
"buefy": {
"version": "0.7.10",
"resolved": "https://registry.npmjs.org/buefy/-/buefy-0.7.10.tgz",
"integrity": "sha512-jU9CTEQR1rozxagwEPB69qObBDwWl+4uCa6TjiPkqcqOb/uxq1uvyvCsVinADzjNjzOTFhUOuuSPQk8gsVEOzA==",
"requires": {
"bulma": "0.7.5"
}
},
"buffer": { "buffer": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
@ -2408,6 +2415,16 @@
"sha.js": "^2.4.8" "sha.js": "^2.4.8"
} }
}, },
"cross-env": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz",
"integrity": "sha512-jtdNFfFW1hB7sMhr/H6rW1Z45LFqyI431m3qU6bFXcQ3Eh7LtBuG3h74o7ohHZ3crrRkkqHlo4jYHFPcjroANg==",
"dev": true,
"requires": {
"cross-spawn": "^6.0.5",
"is-windows": "^1.0.0"
}
},
"cross-spawn": { "cross-spawn": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@ -4253,6 +4270,11 @@
"integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==",
"dev": true "dev": true
}, },
"highlight.js": {
"version": "9.15.8",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.8.tgz",
"integrity": "sha512-RrapkKQWwE+wKdF73VsOa2RQdIoO3mxwJ4P8mhbI6KYJUraUHRKM5w5zQQKXNk0xNL4UVRdulV9SBJcmzJNzVA=="
},
"hmac-drbg": { "hmac-drbg": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@ -4755,7 +4777,6 @@
"version": "3.13.1", "version": "3.13.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
"integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
"dev": true,
"requires": { "requires": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"
@ -4764,8 +4785,7 @@
"esprima": { "esprima": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
"dev": true
} }
} }
}, },
@ -4964,6 +4984,11 @@
"object-visit": "^1.0.0" "object-visit": "^1.0.0"
} }
}, },
"marked": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz",
"integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg=="
},
"md5.js": { "md5.js": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -6926,8 +6951,7 @@
"sprintf-js": { "sprintf-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
"dev": true
}, },
"sshpk": { "sshpk": {
"version": "1.16.1", "version": "1.16.1",
@ -7578,6 +7602,14 @@
"diff-match-patch": "^1.0.0" "diff-match-patch": "^1.0.0"
} }
}, },
"vue-highlightjs": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/vue-highlightjs/-/vue-highlightjs-1.3.3.tgz",
"integrity": "sha1-KaDVcTL8HOFc+mHolpGPW3GMXVI=",
"requires": {
"highlight.js": "*"
}
},
"vue-hot-reload-api": { "vue-hot-reload-api": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.3.tgz", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.3.tgz",
@ -7604,6 +7636,11 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true "dev": true
}, },
"vuex": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz",
"integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg=="
},
"w3c-hr-time": { "w3c-hr-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",

View file

@ -4,7 +4,8 @@
"description": "", "description": "",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "rimraf dist && parcel build src/index.html --out-dir dist", "build": "rimraf dist && parcel build src/index.html --no-source-maps --out-dir dist",
"build-debug": "rimraf dist && cross-env NODE_ENV=development parcel build src/index.html --no-minify --out-dir dist",
"watch": "parcel src/index.html" "watch": "parcel src/index.html"
}, },
"devDependencies": { "devDependencies": {
@ -12,18 +13,24 @@
"babel-core": "^6.26.3", "babel-core": "^6.26.3",
"babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0", "babel-plugin-transform-runtime": "^6.23.0",
"cross-env": "^5.2.0",
"parcel-bundler": "^1.12.3", "parcel-bundler": "^1.12.3",
"sass": "^1.21.0", "sass": "^1.21.0",
"vue-template-compiler": "^2.6.10" "vue-template-compiler": "^2.6.10"
}, },
"dependencies": { "dependencies": {
"buefy": "^0.7.10",
"bulma": "^0.7.5", "bulma": "^0.7.5",
"bulmaswatch": "^0.7.2", "bulmaswatch": "^0.7.2",
"js-cookie": "^2.2.0", "js-cookie": "^2.2.0",
"js-yaml": "^3.13.1",
"marked": "^0.7.0",
"vue": "^2.6.10", "vue": "^2.6.10",
"vue-codemirror": "^4.0.6", "vue-codemirror": "^4.0.6",
"vue-highlightjs": "^1.3.3",
"vue-hot-reload-api": "^2.3.3", "vue-hot-reload-api": "^2.3.3",
"vue-router": "^3.0.6" "vue-router": "^3.0.6",
"vuex": "^3.1.1"
}, },
"browserslist": [ "browserslist": [
"last 2 Chrome versions" "last 2 Chrome versions"

View file

@ -10,8 +10,8 @@
Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind. Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind.
</div> </div>
<div class="actions"> <div class="actions">
<a class="btn" href="/login">Dashboard</a> <router-link class="btn" to="/login">Dashboard</router-link>
<a class="btn disabled" href="#">Docs</a> <router-link class="btn" to="/docs">Documentation</router-link>
</div> </div>
<div class="error" v-if="error"> <div class="error" v-if="error">
<strong>Error</strong> <strong>Error</strong>

View file

@ -35,7 +35,7 @@
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
import "codemirror/theme/oceanic-next.css"; import "codemirror/theme/oceanic-next.css";
import "codemirror/mode/yaml/yaml.js"; import "codemirror/mode/yaml/yaml.js";
import {ApiError} from "../api"; import {ApiError} from "../../api";
export default { export default {
components: { components: {

View file

@ -1,10 +1,10 @@
<template> <template>
<div class="dashboard"> <div class="dashboard dashboard-cloak">
<nav class="navbar" role="navigation" aria-label="main navigation"> <nav class="navbar" role="navigation" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"> <div class="navbar-brand">
<div class="navbar-item"> <div class="navbar-item">
<img class="dashboard-logo" src="../img/logo.png" aria-hidden="true"> <img class="dashboard-logo" src="../../img/logo.png" alt="" aria-hidden="true">
<h1 class="dashboard-title">Zeppelin Dashboard</h1> <h1 class="dashboard-title">Zeppelin Dashboard</h1>
</div> </div>
</div> </div>
@ -12,7 +12,9 @@
<div class="navbar-menu is-active"> <div class="navbar-menu is-active">
<div class="navbar-start"> <div class="navbar-start">
<router-link to="/dashboard" class="navbar-item">Guilds</router-link> <router-link to="/dashboard" class="navbar-item">Guilds</router-link>
<a href="#" class="navbar-item">Docs</a> <a href="/docs" class="navbar-item">Documentation</a>
</div>
<div class="navbar-end">
<a href="javascript:void(0)" class="navbar-item" v-on:click="logout()">Log out</a> <a href="javascript:void(0)" class="navbar-item" v-on:click="logout()">Log out</a>
</div> </div>
</div> </div>
@ -28,6 +30,11 @@
</template> </template>
<style scoped> <style scoped>
.dashboard-cloak {
/* Replaced by "visible" in dashboard.scss */
visibility: hidden;
}
.dashboard-logo { .dashboard-logo {
margin-right: 12px; margin-right: 12px;
} }
@ -38,10 +45,9 @@
</style> </style>
<script> <script>
import "../../style/dashboard.scss";
export default { export default {
async mounted() {
await import("../style/dashboard.scss");
},
methods: { methods: {
async logout() { async logout() {
await this.$store.dispatch("auth/logout"); await this.$store.dispatch("auth/logout");

View file

@ -0,0 +1,64 @@
<template>
<div>
<h1 class="z-title is-1 mb-1">Argument Types</h1>
<p class="mb-1">
This page details the different argument types available for commands.
</p>
<h2 id="delay" class="z-title is-2 mt-2 mb-1">Delay</h2>
<p class="mb-1">
A delay is used to specify an amount of time. It uses simple letters to specify time durations.<br>
For example, <code>2d15h27m3s</code> would be 2 days, 15 hours, 27 minutes and 3 seconds.
</p>
<p class="mb-1">
Note that the delay should always be written as 1 word, without spaces!
</p>
<b-collapse :open="false" class="card mb-1">
<div slot="trigger" slot-scope="props" class="card-header" role="button">
<p class="card-header-title">Additional Information</p>
<a class="card-header-icon">
<b-icon :icon="props.open ? 'menu-down' : 'menu-up'"></b-icon>
</a>
</div>
<div class="card-content">
<div class="content">
Durations:
<ul>
<li>
<code>d</code> Day
</li>
<li>
<code>h</code> Hour
</li>
<li>
<code>m</code> Minute
</li>
<li>
<code>s</code> Seconds
</li>
</ul>
</div>
</div>
</b-collapse>
<h2 id="string" class="z-title is-2 mb-1">String</h2>
<h2 id="user" class="z-title is-2 mt-2 mb-1">User</h2>
<p class="mb-1">
Anything that uniquely identifies a user. This includes:
</p>
<ul class="z-list z-ul mb-1">
<li>User ID <code>108552944961454080</code></li>
<li>User Mention <code>@Dark#1010</code></li>
<li>Loose user mention <code>Dark#1010</code></li>
</ul>
</div>
</template>
<script>
import CodeBlock from "./CodeBlock";
export default {
components: { CodeBlock },
};
</script>

View file

@ -0,0 +1,22 @@
<template>
<pre class="codeblock" v-highlightjs><code :class="lang" v-trim-code="trim"><slot></slot></code></pre>
</template>
<style scoped>
.codeblock {
border-radius: 3px;
padding: 16px;
max-width: 970px; /* FIXME: temp fix for overflowing code blocks, look into properly later */
}
.hljs {
background: transparent;
padding: 0;
}
</style>
<script>
export default {
props: ["lang", "trim"],
};
</script>

View file

@ -0,0 +1,42 @@
<template>
<div>
<h1 class="z-title is-1 mb-1">Configuration format</h1>
<p class="mb-1">
This is the basic format of the bot configuration for a guild. The basic breakdown is:
</p>
<ol class="z-list mb-1">
<li>Prefix (i.e. what character is preceding each command)</li>
<li>Permission levels (see <router-link to="/docs/permissions">Permissions</router-link> for more info)</li>
<li>Plugin-specific configuration (see <router-link to="/docs/plugin-configuration">Plugin configuration</router-link> for more info)</li>
</ol>
<CodeBlock lang="yaml" trim="4">
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'
</CodeBlock>
</div>
</template>
<script>
import CodeBlock from "./CodeBlock";
export default {
components: { CodeBlock },
};
</script>

View file

@ -0,0 +1,30 @@
<template>
<div>
<h1 class="z-title is-1 mb-1">Introduction</h1>
<p class="mb-1">
Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind.
</p>
<h2 class="z-title is-2 mt-2 mb-1">Getting the bot</h2>
<p class="mb-1">
Since the bot is currently private, access to the bot is granted on a case by case basis.<br>
There are plans to streamline this process in the future.
</p>
<h2 class="z-title is-2 mt-2 mb-1">Configuration</h2>
<p class="mb-1">
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 <router-link to="/docs/configuration-format">Configuration format</router-link> for more details.
</p>
<h2 class="z-title is-2 mt-2 mb-1">Plugins</h2>
<p class="mb-1">
Zeppelin is divided into plugins: grouped functionality that can be enabled/disabled as needed, and that have their own configurations.
</p>
<h2 class="z-title is-2 mt-2 mb-1">Commands</h2>
<p class="mb-1">
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 <code>!</code> but this can be changed on a per-server basis.
</p>
</div>
</template>

View file

@ -0,0 +1,155 @@
<template>
<div class="docs docs-cloak">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<div class="navbar-item">
<img class="docs-logo" src="../../img/logo.png" alt="" aria-hidden="true">
<h1 class="docs-title">Zeppelin Documentation</h1>
</div>
</div>
<div class="navbar-menu is-active">
<div class="navbar-end">
<router-link to="/dashboard" class="navbar-item">Go to dashboard</router-link>
</div>
</div>
</div>
</nav>
<div class="wip-bar">
<i class="mdi mdi-alert"></i>
<strong>Note!</strong> This documentation is a work in progress.
</div>
<div class="wrapper">
<div class="docs-sidebar">
<div class="docs-sidebar-content">
<aside class="menu">
<p class="menu-label">General</p>
<ul class="menu-list">
<li><router-link to="/docs">Introduction</router-link></li>
<li><router-link to="/docs/configuration-format">Configuration format</router-link></li>
<li><router-link to="/docs/plugin-configuration">Plugin configuration</router-link></li>
<li><router-link to="/docs/permissions">Permissions</router-link></li>
</ul>
<p class="menu-label">Descriptions</p>
<ul class="menu-list">
<li><router-link to="/docs/descriptions/argument-types">Argument types</router-link></li>
</ul>
<p class="menu-label">Plugins</p>
<ul class="menu-list">
<li v-for="plugin in plugins">
<router-link :to="'/docs/plugins/' + plugin.name">{{ plugin.info.prettyName || plugin.name }}</router-link>
</li>
</ul>
</aside>
</div>
</div>
<div class="docs-main">
<router-view :key="$route.fullPath"></router-view>
</div>
</div>
</div>
</template>
<style scoped>
.docs-cloak {
/* Replaced by "visible" in docs.scss */
visibility: hidden;
}
.docs {
width: 100%;
max-width: 1280px;
margin: 20px auto;
}
.navbar {
border: 1px solid #4e5d6c;
border-radius: 3px;
margin-bottom: 24px;
padding: 0 16px;
}
.docs-logo {
margin-right: 12px;
}
.docs-title {
font-weight: 600;
}
.wip-bar {
padding: 4px 10px;
margin-bottom: 24px;
background-color: #2B3E50;
border-radius: 4px;
}
.wip-bar i {
color: #fdd7a5;
font-size: 24px;
vertical-align: -3px;
margin-right: 6px;
}
.wrapper {
display: flex;
}
.docs-sidebar {
flex: 0 0 280px;
}
.docs-sidebar-content {
/* can't scroll with a long list before reaching the end of the page, figure out */
/*position: sticky;*/
/*top: 20px;*/
}
.docs-sidebar .menu {
padding: 12px 16px;
border: 1px solid #4e5d6c;
background-color: #2b3e50;
border-radius: 3px;
}
.docs-sidebar .menu-label {
font-weight: 600;
}
.docs-main {
flex: 1 1 100%;
padding: 0 24px 24px;
}
.docs-main >>> h4 {
margin-top: 1.25em; /* ? */
}
</style>
<script>
import Vue from "vue";
import VueHighlightJS from "vue-highlightjs";
import {mapState} from "vuex";
import "../../directives/trim-code";
import "highlight.js/styles/ocean.css";
import "../../style/docs.scss";
Vue.use(VueHighlightJS);
export default {
async mounted() {
await this.$store.dispatch("docs/loadAllPlugins");
},
computed: {
...mapState('docs', {
plugins: 'allPlugins',
}),
},
};
</script>

View file

@ -0,0 +1,88 @@
<template>
<div>
<h1 class="z-title is-1 mb-1">Permissions</h1>
<p class="mb-1">
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 <router-link to="/docs/plugin-configuration">Plugin configuration</router-link> for more info)
and can depend on e.g. user id, role id, channel id, category id, or <strong>permission level</strong>.
</p>
<h2 class="z-title is-2 mt-2 mb-1">Permission levels</h2>
<p class="mb-1">
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).
</p>
<p class="mb-1">
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).
</p>
<p class="mb-1">
Permission levels are defined in the config in the <strong>levels</strong> section. For example:
</p>
<CodeBlock lang="yaml" trim="4">
# "role/user id": level
levels:
"172949857164722176": 100 # Example admin
"172950000412655616": 50 # Example mod
</CodeBlock>
<h2 class="z-title is-2 mt-2 mb-1">Examples</h2>
<h3 class="z-title is-3 mb-1">Basic overrides</h3>
<p class="mb-1">
For this example, let's assume we have a plugin called <code>cats</code> which has a command <code>!cat</code> locked behind the permission <code>can_cat</code>.
Let's say that by default, the plugin allows anyone to use <code>!cat</code>, but we want to restrict it to moderators only.
</p>
<p class="mb-1">
Here's what the configuration for this would look like:
</p>
<CodeBlock lang="yaml" trim="4">
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
</CodeBlock>
<h3 class="z-title is-3 mt-2 mb-1">Replacing defaults</h3>
<p class="mb-1">
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...<br>
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 <strong>replace</strong> the default overrides that enable moderator commands at level 50.
</p>
<p class="mb-1">
Here's what the configuration for this would look like:
</p>
<CodeBlock lang="yaml" trim="4">
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
</CodeBlock>
</div>
</template>
<script>
import CodeBlock from "./CodeBlock";
export default {
components: { CodeBlock },
};
</script>

View file

@ -0,0 +1,132 @@
<template>
<div v-if="loading">
Loading...
</div>
<div v-else>
<h1 class="z-title is-1 mb-1">{{ data.info.prettyName || data.name }}</h1>
<p class="mb-1">
Name in config: <code>{{ data.name }}</code>
</p>
<div v-if="data.info.description" class="content" v-html="renderMarkdown(data.info.description)"></div>
<p class="mt-1 mb-1">
To enable this plugin with default configuration, add <code>{{ data.name }}: {}</code> to the <code>plugins</code> list in config
</p>
<h2 id="default-configuration" class="z-title is-2 mt-2 mb-1">Default configuration</h2>
<CodeBlock lang="yaml">{{ renderConfiguration(data.options) }}</CodeBlock>
<b-collapse :open="false" class="card mt-1 mb-1">
<div slot="trigger" slot-scope="props" class="card-header" role="button">
<p class="card-header-title">Config schema</p>
<a class="card-header-icon">
<b-icon :icon="props.open ? 'menu-down' : 'menu-up'"></b-icon>
</a>
</div>
<div class="card-content">
<CodeBlock lang="plain">{{ data.configSchema }}</CodeBlock>
</div>
</b-collapse>
<div v-if="data.commands.length">
<h2 id="commands" class="z-title is-2 mt-2 mb-1">Commands</h2>
<div v-for="command in data.commands">
<h3 class="z-title is-3 mt-2 mb-1">!{{ command.trigger }}</h3>
<div v-if="command.config.requiredPermission">
Permission: <code>{{ command.config.requiredPermission }}</code>
</div>
<div v-if="command.config.info && command.config.info.basicUsage">
Basic usage: <code>{{ command.config.info.basicUsage }}</code>
</div>
<div v-if="command.config.aliases && command.config.aliases.length">
Shortcut:
<code style="margin-right: 4px" v-for="alias in command.config.aliases">!{{ alias }}</code>
</div>
<div v-if="command.config.info && command.config.info.description" class="content mt-1 mb-1" v-html="renderMarkdown(command.config.info.description)"></div>
<b-collapse :open="false" class="card mt-1 mb-1">
<div slot="trigger" slot-scope="props" class="card-header" role="button">
<p class="card-header-title">Additional information</p>
<a class="card-header-icon">
<b-icon :icon="props.open ? 'menu-down' : 'menu-up'"></b-icon>
</a>
</div>
<div class="card-content">
Signatures:
<ul class="z-list z-ul">
<li>
<code>
!{{ command.trigger }}
<span v-for="param in command.parameters">{{ renderParameter(param) }} </span>
</code>
</li>
</ul>
<div class="mt-2" v-if="command.parameters.length">
Command arguments:
<ul class="z-list z-ul">
<li v-for="param in command.parameters">
<code>{{ renderParameter(param) }}</code>
<router-link :to="'/docs/descriptions/argument-types#' + (param.type || 'string')">{{ param.type || 'string' }}</router-link>
<div v-if="command.config.info && command.config.info.parameterDescriptions && command.config.info.parameterDescriptions[param.name]" class="content">
{{ renderMarkdown(command.config.info.parameterDescriptions[param.name]) }}
</div>
</li>
</ul>
</div>
</div>
</b-collapse>
</div>
</div>
</div>
</template>
<script>
import Vue from "vue";
import {mapState} from "vuex";
import marked from "marked";
import yaml from "js-yaml";
import CodeBlock from "./CodeBlock";
export default {
components: { CodeBlock },
async mounted() {
this.loading = true;
await this.$store.dispatch("docs/loadPluginData", this.pluginName);
this.loading = false;
},
methods: {
renderMarkdown(str) {
return marked(str);
},
renderConfiguration(options) {
return yaml.safeDump({
[this.pluginName]: options,
});
},
renderParameter(param) {
let str = `${param.name}`;
if (param.rest) str += '...';
if (param.required) {
return `<${str}>`;
} else {
return `[${str}]`;
}
},
},
data() {
return {
loading: true,
pluginName: this.$route.params.pluginName,
};
},
computed: {
...mapState("docs", {
data(state) {
return state.plugins[this.pluginName];
},
}),
},
}
</script>

View file

@ -0,0 +1,83 @@
<template>
<div>
<h1 class="z-title is-1 mb-1">Plugin configuration</h1>
<p class="mb-1">
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 <router-link to="/docs/permissions">Permissions</router-link> for more info).
</p>
<p class="mb-1">
Information about each plugin's options can be found on the plugin's page on the sidebar. See <router-link to="/docs/configuration-format">Configuration format</router-link> for an example of a full config.
</p>
<h2 class="z-title is-2 mt-2 mb-1">Overrides</h2>
<p class="mb-1">
Overrides are the primary mechanism of changing options and permissions based on permission levels, roles, channels, user ids, etc.
</p>
<p class="mb-1">
Here's an example demonstrating different types of overrides:
</p>
<CodeBlock lang="yaml" trim="4">
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
</CodeBlock>
</div>
</template>
<script>
import CodeBlock from "./CodeBlock";
export default {
components: { CodeBlock },
};
</script>

View file

@ -0,0 +1,60 @@
<template>
<div>
<h1>Argument Types</h1>
This page details the different argument types available for commands.
<h2 id="delay">Delay</h2>A delay is used to specify an amount of time. It uses simple letters to specify time durations:
<br />
<br />Example:
<code>2d15h27m3s</code> Would be 2 days, 15 hours, 27 minutes and 3 seconds.
<br />
<br />It is important to note that spaces are not supported!
<br />
<br />
<b-collapse :open="false" class="card">
<div slot="trigger" slot-scope="props" class="card-header" role="button">
<p class="card-header-title">Additional Information</p>
<a class="card-header-icon">
<b-icon :icon="props.open ? 'menu-down' : 'menu-up'"></b-icon>
</a>
</div>
<div class="card-content">
<div class="content">
Durations:
<ul>
<li>
<code>d</code> Day
</li>
<li>
<code>h</code> Hour
</li>
<li>
<code>m</code> Minute
</li>
<li>
<code>s</code> Seconds
</li>
</ul>
</div>
</div>
</b-collapse>
<h2 id="string">String</h2>
<h2 id="user">User</h2>
<p>
Anything that uniquelly identifies a user. This includes:
</p>
<ul>
<li>User ID <code>108552944961454080</code></li>
<li>User Mention <code>@Dark#1010</code></li>
<li>Loose user mention <code>Dark#1010</code></li>
</ul>
</div>
</template>
<script>
import CodeBlock from "../CodeBlock";
export default {
components: { CodeBlock },
};
</script>

View file

@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

View file

@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

View file

@ -0,0 +1,94 @@
<template>
<div>
<h1>Locate user</h1>
<p>
Name in config: <code>locate_user</code>
</p>
<h2>Description</h2>
<p>
This plugin allows users with access to the commands the following:
</p>
<ul>
<li>Instantly receive an invite to the voice channel of a user</li>
<li>Be notified as soon as a user switches or joins a voice channel</li>
</ul>
<h2>Default configuration</h2>
<CodeBlock lang="yaml" trim="4">
config:
can_where: false
can_alert: false
overrides:
- level: ">=50"
config:
can_where: true
can_alert: true
</CodeBlock>
<h2>Commands</h2>
<h3>!where</h3>
<p>
Permission: <code>can_where</code><br>
Arguments:
</p>
<ul>
<li><code>&lt;User&gt;</code> The user we want to find</li>
</ul>
<p>
Sends an instant invite to the voice channel the user from the <code>&lt;User&gt;</code> argument is in.
</p>
<h3>!vcalert</h3>
<p>
Permission: <code>can_alert</code><br>
Basic usage: <code>!vcalert 108552944961454080</code><br>
Shortcut: <code>!vca</code><br><br>
Sends an instant invite along with a specified reminder once the user switches or joins a voice channel.
</p>
<b-collapse :open="false" class="card">
<div
slot="trigger"
slot-scope="props"
class="card-header"
role="button">
<div class="card-header-title">
Additional Information
</div>
<a class="card-header-icon">
<b-icon
:icon="props.open ? 'menu-down' : 'menu-up'">
</b-icon>
</a>
</div>
<div class="card-content">
<div class="content">
Signatures:
<ul>
<li><code>!vcalert &lt;user&gt;</code></li>
<li><code>!vcalert &lt;user&gt; [delay] [reminderString]</code></li>
</ul>
Arguments:
<ul>
<li><code>&lt;user&gt;</code> The user we want to find</li>
<li><code>[delay]</code> How long the alert should be active, following the default <router-link to="/docs/descriptions/argument-types#Delay">Delay format</router-link>. Default: <code>10 minutes</code></li>
<li><code>[reminderString]</code> Any text we want to receive once the alert triggers. Default: <code>None</code></li>
</ul>
</div>
</div>
</b-collapse>
</div>
</template>
<script>
import CodeBlock from "../CodeBlock";
export default {
components: { CodeBlock },
};
</script>

View file

@ -0,0 +1,67 @@
<template>
<div>
<h1>Mod actions</h1>
<p>
Name in config: <code>mod_actions</code>
</p>
<h2>Default configuration</h2>
<CodeBlock lang="yaml" trim="4">
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
</CodeBlock>
<h2>Commands</h2>
<h3>!note</h3>
<p>
Permission: <code>can_note</code>
</p>
<p>
</p>
</div>
</template>
<script>
import CodeBlock from "../CodeBlock";
export default {
components: { CodeBlock },
};
</script>

View file

@ -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");
},
});

View file

@ -1,6 +1,8 @@
import "./style/base.scss"; import "./style/base.scss";
import "buefy/dist/buefy.css";
import Vue from "vue"; import Vue from "vue";
import Buefy from "buefy";
import { RootStore } from "./store"; import { RootStore } from "./store";
import { router } from "./routes"; import { router } from "./routes";
@ -18,8 +20,8 @@ Vue.mixin({
}); });
import App from "./components/App.vue"; import App from "./components/App.vue";
import Login from "./components/Login.vue";
Vue.use(Buefy);
const app = new Vue({ const app = new Vue({
router, router,
store: RootStore, store: RootStore,

View file

@ -14,21 +14,65 @@ export const router = new VueRouter({
{ path: "/login", beforeEnter: authRedirectGuard }, { path: "/login", beforeEnter: authRedirectGuard },
{ path: "/login-callback", beforeEnter: loginCallbackGuard }, { 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 // Dashboard
{ {
path: "/dashboard", path: "/dashboard",
component: () => import("./components/Dashboard.vue"), component: () => import("./components/dashboard/Layout.vue"),
beforeEnter: authGuard, beforeEnter: authGuard,
children: [ children: [
{ {
path: "", path: "",
component: () => import("./components/DashboardGuildList.vue"), component: () => import("./components/dashboard/GuildList.vue"),
}, },
{ {
path: "guilds/:guildId/config", 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 };
}
},
}); });

View file

@ -0,0 +1,54 @@
import { get } from "../api";
import { Module } from "vuex";
import { DocsState, RootState } from "./types";
export const DocsStore: Module<DocsState, RootState> = {
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;
},
},
};

View file

@ -6,23 +6,27 @@ Vue.use(Vuex);
import { RootState } from "./types"; import { RootState } from "./types";
import { AuthStore } from "./auth"; import { AuthStore } from "./auth";
import { GuildStore } from "./guilds"; import { GuildStore } from "./guilds";
import { DocsStore } from "./docs";
export const RootStore = new Vuex.Store<RootState>({ export const RootStore = new Vuex.Store<RootState>({
modules: { modules: {
auth: AuthStore, auth: AuthStore,
guilds: GuildStore, guilds: GuildStore,
docs: DocsStore,
}, },
}); });
// Set up typings so Vue/our components know about the state's types // Set up typings so Vue/our components know about the state's types
declare module "vue/types/options" { declare module "vue/types/options" {
interface ComponentOptions<V extends Vue> { interface ComponentOptions<V extends Vue> {
// @ts-ignore
store?: Store<RootState>; store?: Store<RootState>;
} }
} }
declare module "vue/types/vue" { declare module "vue/types/vue" {
interface Vue { interface Vue {
// @ts-ignore
$store: Store<RootState>; $store: Store<RootState>;
} }
} }

View file

@ -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 = { export type RootState = {
auth: AuthState; auth: AuthState;
guilds: GuildState; guilds: GuildState;
docs: DocsState;
}; };

View file

@ -3,3 +3,7 @@ $family-primary: 'Open Sans', sans-serif;
@import "~bulmaswatch/superhero/_variables"; @import "~bulmaswatch/superhero/_variables";
@import "~bulma/bulma"; @import "~bulma/bulma";
@import "~bulmaswatch/superhero/_overrides"; @import "~bulmaswatch/superhero/_overrides";
.dashboard-cloak {
visibility: visible !important;
}

View file

@ -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; }

45
package-lock.json generated
View file

@ -8299,9 +8299,9 @@
"dev": true "dev": true
}, },
"knub": { "knub": {
"version": "20.3.1", "version": "22.0.0",
"resolved": "https://registry.npmjs.org/knub/-/knub-20.3.1.tgz", "resolved": "https://registry.npmjs.org/knub/-/knub-22.0.0.tgz",
"integrity": "sha512-aSLCvP6CM5aNxtXCABdctTwU0XylPqpP5g2RL1qccvHyDF36GCuahBy8fkGB6RfnSCTHN9sABeGvM4Qidgo/rw==", "integrity": "sha512-QHMqSS8eVBVX0vMff8lEkWhO7mOVXdobUrNOuAMI7ldto0Aakf0oNdDnwRXFj0yNb5Sp1fvzYFt35nsx/ORqkw==",
"requires": { "requires": {
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
"lodash.at": "^4.6.0", "lodash.at": "^4.6.0",
@ -9882,13 +9882,23 @@
"requires": { "requires": {
"extend-shallow": "^3.0.2", "extend-shallow": "^3.0.2",
"safe-regex": "^1.1.0" "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": { "regexp-tree": {
"version": "0.1.11", "version": "0.1.11",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.11.tgz", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.11.tgz",
"integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg==", "integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg=="
"dev": true
}, },
"regexpu-core": { "regexpu-core": {
"version": "4.5.4", "version": "4.5.4",
@ -10122,12 +10132,11 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}, },
"safe-regex": { "safe-regex": {
"version": "1.1.0", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.0.2.tgz",
"integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "integrity": "sha512-rRALJT0mh4qVFIJ9HvfjKDN77F9vp7kltOpFFI/8e6oKyHFmmxz4aSkY/YVauRDe7U0RrHdw9Lsxdel3E19s0A==",
"dev": true,
"requires": { "requires": {
"ret": "~0.1.10" "regexp-tree": "~0.1.1"
} }
}, },
"safer-buffer": { "safer-buffer": {
@ -10887,6 +10896,17 @@
"extend-shallow": "^3.0.2", "extend-shallow": "^3.0.2",
"regex-not": "^1.0.2", "regex-not": "^1.0.2",
"safe-regex": "^1.1.0" "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": { "to-regex-range": {
@ -11373,11 +11393,6 @@
"extsprintf": "^1.2.0" "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": { "w3c-hr-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",

View file

@ -30,7 +30,7 @@
"humanize-duration": "^3.15.0", "humanize-duration": "^3.15.0",
"io-ts": "^2.0.0", "io-ts": "^2.0.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"knub": "^20.3.1", "knub": "^22.0.0",
"last-commit-log": "^2.1.0", "last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
@ -44,14 +44,14 @@
"passport-custom": "^1.0.5", "passport-custom": "^1.0.5",
"passport-oauth2": "^1.5.0", "passport-oauth2": "^1.5.0",
"reflect-metadata": "^0.1.12", "reflect-metadata": "^0.1.12",
"safe-regex": "^2.0.2",
"seedrandom": "^3.0.1", "seedrandom": "^3.0.1",
"tlds": "^1.203.1", "tlds": "^1.203.1",
"tmp": "0.0.33", "tmp": "0.0.33",
"ts-node": "^3.3.0", "ts-node": "^3.3.0",
"typeorm": "^0.2.14", "typeorm": "^0.2.14",
"typescript": "^3.5.3", "typescript": "^3.5.3",
"uuid": "^3.3.2", "uuid": "^3.3.2"
"vuex": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.5.5", "@babel/core": "^7.5.5",
@ -84,6 +84,7 @@
}, },
"lint-staged": { "lint-staged": {
"*.ts": [ "*.ts": [
"tslint",
"prettier --write", "prettier --write",
"git add" "git add"
] ]

View file

@ -1,6 +1,8 @@
import { SECONDS } from "./utils";
type QueueFn = (...args: any[]) => Promise<any>; type QueueFn = (...args: any[]) => Promise<any>;
const DEFAULT_TIMEOUT = 10 * 1000; const DEFAULT_TIMEOUT = 10 * SECONDS;
export class Queue { export class Queue {
protected running: boolean = false; protected running: boolean = false;

60
src/SimpleCache.ts Normal file
View file

@ -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<string, { remove_at: number; value: any }>;
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();
}
}

122
src/api/docs.ts Normal file
View file

@ -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,
});
});
}

View file

@ -4,13 +4,22 @@ import cors from "cors";
import { initAuth } from "./auth"; import { initAuth } from "./auth";
import { initGuildsAPI } from "./guilds"; import { initGuildsAPI } from "./guilds";
import { initArchives } from "./archives"; import { initArchives } from "./archives";
import { initDocs } from "./docs";
import { connect } from "../data/db"; import { connect } from "../data/db";
import path from "path"; import path from "path";
import { TokenError } from "passport-oauth2"; import { TokenError } from "passport-oauth2";
import { PluginError } from "knub";
require("dotenv").config({ path: path.resolve(__dirname, "..", "..", "api.env") }); 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(() => { connect().then(() => {
const app = express(); const app = express();
@ -24,6 +33,7 @@ connect().then(() => {
initAuth(app); initAuth(app);
initGuildsAPI(app); initGuildsAPI(app);
initArchives(app); initArchives(app);
initDocs(app);
// Default route // Default route
app.get("/", (req, res) => { app.get("/", (req, res) => {
@ -35,7 +45,7 @@ connect().then(() => {
if (err instanceof TokenError) { if (err instanceof TokenError) {
clientError(res, "Invalid code"); clientError(res, "Invalid code");
} else { } else {
console.error(err); console.error(err); // tslint:disable-line
error(res, "Server error", err.status || 500); error(res, "Server error", err.status || 500);
} }
}); });
@ -46,5 +56,6 @@ connect().then(() => {
}); });
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
// tslint:disable-next-line
app.listen(port, () => console.log(`API server listening on port ${port}`)); app.listen(port, () => console.log(`API server listening on port ${port}`));
}); });

View file

@ -5,7 +5,7 @@ import { fold } from "fp-ts/lib/Either";
import { PathReporter } from "io-ts/lib/PathReporter"; import { PathReporter } from "io-ts/lib/PathReporter";
import { availablePlugins } from "./plugins/availablePlugins"; import { availablePlugins } from "./plugins/availablePlugins";
import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
import { validateStrict } from "./validatorUtils"; import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
const pluginNameToClass = new Map<string, typeof ZeppelinPlugin>(); const pluginNameToClass = new Map<string, typeof ZeppelinPlugin>();
for (const pluginClass of availablePlugins) { for (const pluginClass of availablePlugins) {
@ -29,8 +29,8 @@ const globalConfigRootSchema = t.type({
const partialMegaTest = t.partial({ name: t.string }); const partialMegaTest = t.partial({ name: t.string });
export function validateGuildConfig(config: any): string[] | null { export function validateGuildConfig(config: any): string[] | null {
const rootErrors = validateStrict(partialGuildConfigRootSchema, config); const validationResult = decodeAndValidateStrict(partialGuildConfigRootSchema, config);
if (rootErrors) return rootErrors; if (validationResult instanceof StrictValidationError) return validationResult.getErrors();
if (config.plugins) { if (config.plugins) {
for (const [pluginName, pluginOptions] of Object.entries(config.plugins)) { for (const [pluginName, pluginOptions] of Object.entries(config.plugins)) {

View file

@ -147,6 +147,9 @@ export class GuildSavedMessages extends BaseGuildRepository {
} }
async createFromMsg(msg: Message, overrides = {}) { async createFromMsg(msg: Message, overrides = {}) {
const existingSavedMsg = await this.find(msg.id);
if (existingSavedMsg) return;
const savedMessageData = this.msgToSavedMessageData(msg); const savedMessageData = this.msgToSavedMessageData(msg);
const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS"); const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS");

View file

@ -78,7 +78,7 @@ import { AllowedGuilds } from "./data/AllowedGuilds";
logger.info("Connecting to database"); logger.info("Connecting to database");
connect().then(async conn => { connect().then(async conn => {
const client = new Client(`Bot ${process.env.TOKEN}`, { const client = new Client(`Bot ${process.env.TOKEN}`, {
getAllUsers: true, getAllUsers: false,
restMode: true, restMode: true,
}); });
client.setMaxListeners(100); client.setMaxListeners(100);

View file

@ -4,7 +4,7 @@ import { SavedMessage } from "../data/entities/SavedMessage";
import { GuildAutoReactions } from "../data/GuildAutoReactions"; import { GuildAutoReactions } from "../data/GuildAutoReactions";
import { Message } from "eris"; import { Message } from "eris";
import { customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils"; import { customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils";
import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import * as t from "io-ts"; import * as t from "io-ts";
const ConfigSchema = t.type({ const ConfigSchema = t.type({
@ -14,14 +14,21 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> { export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "auto_reactions"; 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 savedMessages: GuildSavedMessages;
protected autoReactions: GuildAutoReactions; protected autoReactions: GuildAutoReactions;
private onMessageCreateFn; private onMessageCreateFn;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_manage: false, can_manage: false,
@ -49,7 +56,7 @@ export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.savedMessages.events.off("create", this.onMessageCreateFn); this.savedMessages.events.off("create", this.onMessageCreateFn);
} }
@d.command("auto_reactions", "<channelId:channelid> <reactions...>") @d.command("auto_reactions", "<channelId:channelId> <reactions...>")
@d.permission("can_manage") @d.permission("can_manage")
async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) { async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) {
const finalReactions = []; const finalReactions = [];
@ -83,7 +90,7 @@ export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> {
msg.channel.createMessage(successMessage(`Auto-reactions set for <#${args.channelId}>`)); msg.channel.createMessage(successMessage(`Auto-reactions set for <#${args.channelId}>`));
} }
@d.command("auto_reactions disable", "<channelId:channelid>") @d.command("auto_reactions disable", "<channelId:channelId>")
@d.permission("can_manage") @d.permission("can_manage")
async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) { async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) {
const autoReaction = await this.autoReactions.getForChannel(args.channelId); const autoReaction = await this.autoReactions.getForChannel(args.channelId);

872
src/plugins/Automod.ts Normal file
View file

@ -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<typeof MatchWordsTrigger>;
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<typeof MatchRegexTrigger>;
const defaultMatchRegexTrigger: Partial<TMatchRegexTrigger> = {
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<typeof MatchInvitesTrigger>;
const defaultMatchInvitesTrigger: Partial<TMatchInvitesTrigger> = {
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<typeof MatchLinksTrigger>;
const defaultMatchLinksTrigger: Partial<TMatchLinksTrigger> = {
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<typeof BaseTextSpamTrigger>;
const defaultTextSpamTrigger: Partial<t.TypeOf<typeof BaseTextSpamTrigger>> = {
per_channel: true,
};
const MaxMessagesTrigger = BaseTextSpamTrigger;
type TMaxMessagesTrigger = t.TypeOf<typeof MaxMessagesTrigger>;
const MaxMentionsTrigger = BaseTextSpamTrigger;
type TMaxMentionsTrigger = t.TypeOf<typeof MaxMentionsTrigger>;
const MaxLinksTrigger = BaseTextSpamTrigger;
type TMaxLinksTrigger = t.TypeOf<typeof MaxLinksTrigger>;
const MaxAttachmentsTrigger = BaseTextSpamTrigger;
type TMaxAttachmentsTrigger = t.TypeOf<typeof MaxAttachmentsTrigger>;
const MaxEmojisTrigger = BaseTextSpamTrigger;
type TMaxEmojisTrigger = t.TypeOf<typeof MaxEmojisTrigger>;
const MaxLinesTrigger = BaseTextSpamTrigger;
type TMaxLinesTrigger = t.TypeOf<typeof MaxLinesTrigger>;
const MaxCharactersTrigger = BaseTextSpamTrigger;
type TMaxCharactersTrigger = t.TypeOf<typeof MaxCharactersTrigger>;
const MaxVoiceMovesTrigger = BaseSpamTrigger;
type TMaxVoiceMovesTrigger = t.TypeOf<typeof MaxVoiceMovesTrigger>;
/**
* 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<typeof Rule>;
const ConfigSchema = t.type({
rules: t.record(t.string, Rule),
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
/**
* 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<TConfigSchema> {
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<boolean> {
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<Invite | void> = 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<TextTriggerMatchResult> {
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<TextTriggerMatchResult> {
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<TextTriggerMatchResult | TextSpamTriggerMatchResult> {
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);
}
}
});
}
}

View file

@ -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 child_process from "child_process";
import { GuildChannel, Message, TextChannel } from "eris"; 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 { 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; let activeReload: [string, string] = null;
interface IBotControlPluginConfig { const ConfigSchema = t.type({
owners: string[]; can_use: t.boolean,
update_cmd: string; owners: t.array(t.string),
} update_cmd: tNullable(t.string),
});
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
/** /**
* A global plugin that allows bot owners to control the bot * A global plugin that allows bot owners to control the bot
*/ */
export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> { export class BotControlPlugin extends GlobalZeppelinPlugin<TConfigSchema> {
public static pluginName = "bot_control"; public static pluginName = "bot_control";
public static configSchema = ConfigSchema;
getDefaultOptions(): IPluginOptions<IBotControlPluginConfig> { protected archives: GuildArchives;
public static getStaticDefaultOptions() {
return { return {
config: { config: {
can_use: false,
owners: [], owners: [],
update_cmd: null, update_cmd: null,
}, },
overrides: [
{
level: ">=100",
config: {
can_use: true,
},
},
],
}; };
} }
protected getMemberLevel(member) {
return this.isOwner(member.id) ? 100 : 0;
}
async onLoad() { async onLoad() {
this.archives = new GuildArchives(0);
if (activeReload) { if (activeReload) {
const [guildId, channelId] = activeReload; const [guildId, channelId] = activeReload;
activeReload = null; activeReload = null;
@ -41,14 +66,9 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
} }
isOwner(userId) {
return this.getConfig().owners.includes(userId);
}
@d.command("bot_full_update") @d.command("bot_full_update")
@d.permission("can_use")
async fullUpdateCmd(msg: Message) { async fullUpdateCmd(msg: Message) {
if (!this.isOwner(msg.author.id)) return;
const updateCmd = this.getConfig().update_cmd; const updateCmd = this.getConfig().update_cmd;
if (!updateCmd) { if (!updateCmd) {
msg.channel.createMessage(errorMessage("Update command not specified!")); msg.channel.createMessage(errorMessage("Update command not specified!"));
@ -64,8 +84,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
@d.command("bot_reload_global_plugins") @d.command("bot_reload_global_plugins")
@d.permission("can_use")
async reloadGlobalPluginsCmd(msg: Message) { async reloadGlobalPluginsCmd(msg: Message) {
if (!this.isOwner(msg.author.id)) return;
if (activeReload) return; if (activeReload) return;
if (msg.channel) { if (msg.channel) {
@ -77,8 +97,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
@d.command("perf") @d.command("perf")
@d.permission("can_use")
async perfCmd(msg: Message) { async perfCmd(msg: Message) {
if (!this.isOwner(msg.author.id)) return;
const perfItems = this.knub.getPerformanceDebugItems(); const perfItems = this.knub.getPerformanceDebugItems();
if (perfItems.length) { if (perfItems.length) {
@ -90,9 +110,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
@d.command("refresh_reaction_roles_globally") @d.command("refresh_reaction_roles_globally")
@d.permission("can_use")
async refreshAllReactionRolesCmd(msg: Message) { async refreshAllReactionRolesCmd(msg: Message) {
if (!this.isOwner(msg.author.id)) return;
const guilds = this.knub.getLoadedGuilds(); const guilds = this.knub.getLoadedGuilds();
for (const guild of guilds) { for (const guild of guilds) {
if (guild.loadedPlugins.has("reaction_roles")) { if (guild.loadedPlugins.has("reaction_roles")) {
@ -103,9 +122,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
@d.command("guilds") @d.command("guilds")
@d.permission("can_use")
async serversCmd(msg: Message) { async serversCmd(msg: Message) {
if (!this.isOwner(msg.author.id)) return;
const joinedGuilds = Array.from(this.bot.guilds.values()); const joinedGuilds = Array.from(this.bot.guilds.values());
const loadedGuilds = this.knub.getLoadedGuilds(); const loadedGuilds = this.knub.getLoadedGuilds();
const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.id, guildData), new Map()); const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.id, guildData), new Map());
@ -122,9 +140,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
@d.command("leave_guild", "<guildId:string>") @d.command("leave_guild", "<guildId:string>")
@d.permission("can_use")
async leaveGuildCmd(msg: Message, args: { guildId: string }) { async leaveGuildCmd(msg: Message, args: { guildId: string }) {
if (!this.isOwner(msg.author.id)) return;
if (!this.bot.guilds.has(args.guildId)) { if (!this.bot.guilds.has(args.guildId)) {
msg.channel.createMessage(errorMessage("I am not in that guild")); msg.channel.createMessage(errorMessage("I am not in that guild"));
return; return;
@ -144,9 +161,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
@d.command("reload_guild", "<guildId:string>") @d.command("reload_guild", "<guildId:string>")
@d.permission("can_use")
async reloadGuildCmd(msg: Message, args: { guildId: string }) { async reloadGuildCmd(msg: Message, args: { guildId: string }) {
if (!this.isOwner(msg.author.id)) return;
if (!this.bot.guilds.has(args.guildId)) { if (!this.bot.guilds.has(args.guildId)) {
msg.channel.createMessage(errorMessage("I am not in that guild")); msg.channel.createMessage(errorMessage("I am not in that guild"));
return; return;
@ -164,9 +180,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
} }
@d.command("reload_all_guilds") @d.command("reload_all_guilds")
@d.permission("can_use")
async reloadAllGuilds(msg: Message) { async reloadAllGuilds(msg: Message) {
if (!this.isOwner(msg.author.id)) return;
const failedReloads: Map<string, string> = new Map(); const failedReloads: Map<string, string> = new Map();
let reloadCount = 0; let reloadCount = 0;
@ -191,4 +206,29 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
msg.channel.createMessage(successMessage(`Reloaded ${reloadCount} guild(s)`)); msg.channel.createMessage(successMessage(`Reloaded ${reloadCount} guild(s)`));
} }
} }
@d.command("show_plugin_config", "<guildId:string> <pluginName:string>")
@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));
}
} }

View file

@ -4,7 +4,7 @@ import { CaseTypes } from "../data/CaseTypes";
import { Case } from "../data/entities/Case"; import { Case } from "../data/entities/Case";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { CaseTypeColors } from "../data/CaseTypeColors"; import { CaseTypeColors } from "../data/CaseTypeColors";
import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildArchives } from "../data/GuildArchives"; import { GuildArchives } from "../data/GuildArchives";
import { IPluginOptions } from "knub"; import { IPluginOptions } from "knub";
import { GuildLogs } from "../data/GuildLogs"; import { GuildLogs } from "../data/GuildLogs";
@ -45,13 +45,20 @@ export type CaseNoteArgs = {
export class CasesPlugin extends ZeppelinPlugin<TConfigSchema> { export class CasesPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "cases"; 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 cases: GuildCases;
protected archives: GuildArchives; protected archives: GuildArchives;
protected logs: GuildLogs; protected logs: GuildLogs;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
log_automatic_actions: true, log_automatic_actions: true,

View file

@ -1,4 +1,4 @@
import { IPluginOptions } from "knub"; import { IPluginOptions, logger } from "knub";
import { Invite, Embed } from "eris"; import { Invite, Embed } from "eris";
import escapeStringRegexp from "escape-string-regexp"; import escapeStringRegexp from "escape-string-regexp";
import { GuildLogs } from "../data/GuildLogs"; import { GuildLogs } from "../data/GuildLogs";
@ -14,9 +14,10 @@ import {
import { ZalgoRegex } from "../data/Zalgo"; import { ZalgoRegex } from "../data/Zalgo";
import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { SavedMessage } from "../data/entities/SavedMessage"; import { SavedMessage } from "../data/entities/SavedMessage";
import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import * as t from "io-ts"; import * as t from "io-ts";
import { TSafeRegex } from "../validatorUtils";
const ConfigSchema = t.type({ const ConfigSchema = t.type({
filter_zalgo: t.boolean, filter_zalgo: t.boolean,
@ -31,12 +32,20 @@ const ConfigSchema = t.type({
domain_blacklist: tNullable(t.array(t.string)), domain_blacklist: tNullable(t.array(t.string)),
blocked_tokens: tNullable(t.array(t.string)), blocked_tokens: tNullable(t.array(t.string)),
blocked_words: 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<typeof ConfigSchema>; type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class CensorPlugin extends ZeppelinPlugin<TConfigSchema> { export class CensorPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "censor"; 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 serverLogs: GuildLogs;
protected savedMessages: GuildSavedMessages; protected savedMessages: GuildSavedMessages;
@ -44,7 +53,7 @@ export class CensorPlugin extends ZeppelinPlugin<TConfigSchema> {
private onMessageCreateFn; private onMessageCreateFn;
private onMessageUpdateFn; private onMessageUpdateFn;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
filter_zalgo: false, filter_zalgo: false,
@ -236,12 +245,20 @@ export class CensorPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
// Filter regex // Filter regex
const blockedRegex = config.blocked_regex || []; const blockedRegex: RegExp[] = config.blocked_regex || [];
for (const regexStr of blockedRegex) { for (const [i, regex] of blockedRegex.entries()) {
const regex = new RegExp(regexStr, "i"); 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 // 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)) { 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; return true;
} }
} }

View file

@ -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<string> {
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", "<channel:textChannel>", {
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 || "<no text 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`,
});
}
}

View file

@ -1,5 +1,5 @@
import { decorators as d, IPluginOptions, logger } from "knub"; 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 { Member, Channel, GuildChannel, PermissionOverwrite, Permission, Message, TextChannel } from "eris";
import * as t from "io-ts"; import * as t from "io-ts";
import { tNullable } from "../utils"; import { tNullable } from "../utils";
@ -28,9 +28,18 @@ const defaultCompanionChannelOpts: Partial<TCompanionChannelOpts> = {
export class CompanionChannelPlugin extends ZeppelinPlugin<TConfigSchema> { export class CompanionChannelPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "companion_channels"; public static pluginName = "companion_channels";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { 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<TConfigSchema> {
return { return {
config: { config: {
entries: {}, entries: {},

View file

@ -70,12 +70,13 @@ class ActionError extends Error {}
export class CustomEventsPlugin extends ZeppelinPlugin<TConfigSchema> { export class CustomEventsPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "custom_events"; public static pluginName = "custom_events";
public static showInDocs = false;
public static dependencies = ["cases"]; public static dependencies = ["cases"];
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
private clearTriggers: () => void; private clearTriggers: () => void;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
events: {}, events: {},
@ -162,7 +163,7 @@ export class CustomEventsPlugin extends ZeppelinPlugin<TConfigSchema> {
const casesPlugin = this.getPlugin<CasesPlugin>("cases"); const casesPlugin = this.getPlugin<CasesPlugin>("cases");
await casesPlugin.createCase({ await casesPlugin.createCase({
userId: targetId, userId: targetId,
modId: modId, modId,
type: CaseTypes[action.case_type], type: CaseTypes[action.case_type],
reason: `__[${event.name}]__ ${reason}`, reason: `__[${event.name}]__ ${reason}`,
}); });

View file

@ -4,36 +4,115 @@ import * as t from "io-ts";
import { pipe } from "fp-ts/lib/pipeable"; import { pipe } from "fp-ts/lib/pipeable";
import { fold } from "fp-ts/lib/Either"; import { fold } from "fp-ts/lib/Either";
import { PathReporter } from "io-ts/lib/PathReporter"; 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 { Member, User } from "eris";
import { performance } from "perf_hooks"; 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; const SLOW_RESOLVE_THRESHOLD = 1500;
export class GlobalZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends GlobalPlugin<TConfig> { export class GlobalZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends GlobalPlugin<TConfig> {
protected static configSchema: t.TypeC<any>; public static configSchema: t.TypeC<any>;
public static dependencies = []; public static dependencies = [];
public static validateOptions(options: IPluginOptions): string[] | null { /**
// Validate config values * Since we want to do type checking without creating instances of every plugin,
if (this.configSchema) { * we need a static version of getDefaultOptions(). This static version is then,
if (options.config) { * by turn, called from getDefaultOptions() so everything still works as expected.
const errors = validateStrict(this.configSchema, options.config); */
if (errors) return errors; public static getStaticDefaultOptions() {
// Implemented by plugin
return {};
} }
if (options.overrides) { /**
for (const override of options.overrides) { * Wrapper to fetch the real default options from getStaticDefaultOptions()
if (override.config) { */
const errors = validateStrict(this.configSchema, override.config); protected getDefaultOptions(): IPluginOptions<TConfig> {
if (errors) return errors; return (this.constructor as typeof GlobalZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions<TConfig>;
} }
/**
* 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<TConfig> {
if (!this.mergedPluginOptions) {
this.mergedPluginOptions = ((this
.constructor as unknown) as typeof GlobalZeppelinPlugin).mergeAndDecodeStaticOptions(this.pluginOptions);
}
return this.mergedPluginOptions as IPluginOptions<TConfig>;
}
/**
* Run static type checks and other validations on the given options
*/
public static validateOptions(options: any): string[] | null {
// Validate config values
if (this.configSchema) {
try {
this.mergeAndDecodeStaticOptions(options);
} catch (e) {
if (e instanceof StrictValidationError) {
return e.getErrors();
}
throw e;
} }
} }
// No errors, return null // No errors, return null
return null; return null;
} }
public async runLoad(): Promise<any> {
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);
}
} }

View file

@ -13,6 +13,7 @@ const CHECK_INTERVAL = 1000;
*/ */
export class GuildConfigReloader extends GlobalZeppelinPlugin { export class GuildConfigReloader extends GlobalZeppelinPlugin {
public static pluginName = "guild_config_reloader"; public static pluginName = "guild_config_reloader";
protected guildConfigs: Configs; protected guildConfigs: Configs;
private unloaded = false; private unloaded = false;
private highestConfigId; private highestConfigId;

View file

@ -4,6 +4,7 @@ import { MINUTES } from "../utils";
export class GuildInfoSaverPlugin extends ZeppelinPlugin { export class GuildInfoSaverPlugin extends ZeppelinPlugin {
public static pluginName = "guild_info_saver"; public static pluginName = "guild_info_saver";
public static showInDocs = false;
protected allowedGuilds: AllowedGuilds; protected allowedGuilds: AllowedGuilds;
private updateInterval; private updateInterval;

View file

@ -1,10 +1,10 @@
import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub"; import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub";
import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import humanizeDuration from "humanize-duration"; import humanizeDuration from "humanize-duration";
import { Message, Member, Guild, TextableChannel, VoiceChannel, Channel, User } from "eris"; import { Message, Member, Guild, TextableChannel, VoiceChannel, Channel, User } from "eris";
import { GuildVCAlerts } from "../data/GuildVCAlerts"; import { GuildVCAlerts } from "../data/GuildVCAlerts";
import moment = require("moment"); import moment from "moment-timezone";
import { resolveMember, sorter, createChunkedMessage, errorMessage, successMessage } from "../utils"; import { resolveMember, sorter, createChunkedMessage, errorMessage, successMessage, MINUTES } from "../utils";
import * as t from "io-ts"; import * as t from "io-ts";
const ConfigSchema = t.type({ const ConfigSchema = t.type({
@ -17,13 +17,22 @@ const ALERT_LOOP_TIME = 30 * 1000;
export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> { export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "locate_user"; 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 alerts: GuildVCAlerts;
private outdatedAlertsTimeout; private outdatedAlertsTimeout;
private usersWithAlerts: string[] = []; private usersWithAlerts: string[] = [];
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_where: false, can_where: false,
@ -52,7 +61,7 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
for (const alert of outdatedAlerts) { for (const alert of outdatedAlerts) {
await this.alerts.delete(alert.id); 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); this.outdatedAlertsTimeout = setTimeout(() => this.outdatedAlertsLoop(), ALERT_LOOP_TIME);
@ -68,21 +77,28 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
@d.command("where", "<member:resolvedMember>", {}) @d.command("where", "<member:resolvedMember>", {
info: {
description: "Posts an instant invite to the voice channel that `<member>` is in",
},
})
@d.permission("can_where") @d.permission("can_where")
async whereCmd(msg: Message, args: { member: Member; time?: number; reminder?: string }) { 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} |`); sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`);
} }
@d.command("vcalert", "<member:resolvedMember> [duration:delay] [reminder:string$]", { @d.command("vcalert", "<member:resolvedMember> [duration:delay] [reminder:string$]", {
aliases: ["vca"], aliases: ["vca"],
info: {
description: "Sets up an alert that notifies you any time `<member>` switches or joins voice channels",
},
}) })
@d.permission("can_alert") @d.permission("can_alert")
async vcalertCmd(msg: Message, args: { member: Member; duration?: number; reminder?: string }) { async vcalertCmd(msg: Message, args: { member: Member; duration?: number; reminder?: string }) {
let time = args.duration || 600000; const time = args.duration || 10 * MINUTES;
let alertTime = moment().add(time, "millisecond"); const alertTime = moment().add(time, "millisecond");
let body = args.reminder || "None"; 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); 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)) { if (!this.usersWithAlerts.includes(args.member.id)) {
@ -137,7 +153,7 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
async userJoinedVC(member: Member, channel: Channel) { async userJoinedVC(member: Member, channel: Channel) {
if (this.usersWithAlerts.includes(member.id)) { if (this.usersWithAlerts.includes(member.id)) {
this.sendAlerts(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<TConfigSchema> {
async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) { async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) {
if (this.usersWithAlerts.includes(member.id)) { if (this.usersWithAlerts.includes(member.id)) {
this.sendAlerts(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<TConfigSchema> {
}); });
} }
async sendAlerts(userid: string) { async sendAlerts(userId: string) {
const triggeredAlerts = await this.alerts.getAlertsByUserId(userid); const triggeredAlerts = await this.alerts.getAlertsByUserId(userId);
const member = await resolveMember(this.bot, this.guild, userid); const member = await resolveMember(this.bot, this.guild, userId);
triggeredAlerts.forEach(alert => { 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 alert.body
}\`\n`; }\`\n`;
sendWhere(this.guild, member, <TextableChannel>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); this.alerts.delete(alert.id);
}); });
} }
async removeUserIDFromActiveAlerts(userid: string) { async removeUserIdFromActiveAlerts(userId: string) {
const index = this.usersWithAlerts.indexOf(userid); const index = this.usersWithAlerts.indexOf(userId);
if (index > -1) { if (index > -1) {
this.usersWithAlerts.splice(index, 1); this.usersWithAlerts.splice(index, 1);
} }
@ -179,12 +195,12 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
} }
export async function sendWhere(guild: Guild, member: Member, channel: TextableChannel, prepend: string) { export async function sendWhere(guild: Guild, member: Member, channel: TextableChannel, prepend: string) {
let voice = await (<VoiceChannel>guild.channels.get(member.voiceState.channelID)); const voice = guild.channels.get(member.voiceState.channelID) as VoiceChannel;
if (voice == null) { if (voice == null) {
channel.createMessage(prepend + "That user is not in a channel"); channel.createMessage(prepend + "That user is not in a channel");
} else { } else {
let invite = await createInvite(voice); const invite = await createInvite(voice);
channel.createMessage( channel.createMessage(
prepend + ` ${member.mention} is in the following channel: ${voice.name} https://${getInviteLink(invite)}`, 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) { export async function createInvite(vc: VoiceChannel) {
let existingInvites = await vc.getInvites(); const existingInvites = await vc.getInvites();
if (existingInvites.length !== 0) { if (existingInvites.length !== 0) {
return existingInvites[0]; return existingInvites[0];

View file

@ -22,7 +22,7 @@ import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { SavedMessage } from "../data/entities/SavedMessage"; import { SavedMessage } from "../data/entities/SavedMessage";
import { GuildArchives } from "../data/GuildArchives"; import { GuildArchives } from "../data/GuildArchives";
import { GuildCases } from "../data/GuildCases"; import { GuildCases } from "../data/GuildCases";
import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import { renderTemplate, TemplateParseError } from "../templateFormatter"; import { renderTemplate, TemplateParseError } from "../templateFormatter";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import * as t from "io-ts"; import * as t from "io-ts";
@ -53,7 +53,11 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> { export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "logs"; public static pluginName = "logs";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Logs",
};
protected guildLogs: GuildLogs; protected guildLogs: GuildLogs;
protected savedMessages: GuildSavedMessages; protected savedMessages: GuildSavedMessages;
@ -70,7 +74,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
private excludedUserProps = ["user", "member", "mod"]; private excludedUserProps = ["user", "member", "mod"];
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
channels: {}, channels: {},

View file

@ -12,11 +12,12 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class MessageSaverPlugin extends ZeppelinPlugin<TConfigSchema> { export class MessageSaverPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "message_saver"; public static pluginName = "message_saver";
protected static configSchema = ConfigSchema; public static showInDocs = false;
public static configSchema = ConfigSchema;
protected savedMessages: GuildSavedMessages; protected savedMessages: GuildSavedMessages;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_manage: false, can_manage: false,

View file

@ -1,6 +1,7 @@
import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub"; import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub";
import { Attachment, Constants as ErisConstants, Guild, Member, Message, TextChannel, User } from "eris"; import { Attachment, Constants as ErisConstants, Guild, Member, Message, TextChannel, User } from "eris";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line 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 humanizeDuration from "humanize-duration";
import { GuildCases } from "../data/GuildCases"; import { GuildCases } from "../data/GuildCases";
import { import {
@ -15,6 +16,8 @@ import {
stripObjectToScalars, stripObjectToScalars,
successMessage, successMessage,
tNullable, tNullable,
trimEmptyStartEndLines,
trimIndents,
trimLines, trimLines,
ucfirst, ucfirst,
UnknownUser, UnknownUser,
@ -23,10 +26,10 @@ import { GuildMutes } from "../data/GuildMutes";
import { CaseTypes } from "../data/CaseTypes"; import { CaseTypes } from "../data/CaseTypes";
import { GuildLogs } from "../data/GuildLogs"; import { GuildLogs } from "../data/GuildLogs";
import { LogType } from "../data/LogType"; import { LogType } from "../data/LogType";
import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import { Case } from "../data/entities/Case"; import { Case } from "../data/entities/Case";
import { renderTemplate } from "../templateFormatter"; import { renderTemplate } from "../templateFormatter";
import { CasesPlugin } from "./Cases"; import { CaseArgs, CasesPlugin } from "./Cases";
import { MuteResult, MutesPlugin } from "./Mutes"; import { MuteResult, MutesPlugin } from "./Mutes";
import * as t from "io-ts"; import * as t from "io-ts";
@ -67,10 +70,52 @@ interface IIgnoredEvent {
userId: string; 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<boolean>;
export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> { export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "mod_actions"; public static pluginName = "mod_actions";
public static dependencies = ["cases", "mutes"]; 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 mutes: GuildMutes;
protected cases: GuildCases; protected cases: GuildCases;
@ -86,7 +131,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.ignoredEvents = []; this.ignoredEvents = [];
} }
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
dm_on_warn: true, dm_on_warn: true,
@ -161,8 +206,16 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
async isBanned(userId): Promise<boolean> { async isBanned(userId): Promise<boolean> {
try {
const bans = (await this.guild.getBans()) as any; const bans = (await this.guild.getBans()) as any;
return bans.some(b => b.user.id === userId); 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) { async findRelevantAuditLogEntry(actionType: number, userId: string, attempts?: number, attemptDelay?: number) {
@ -319,10 +372,121 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
/** /**
* 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<CaseArgs> = {}): Promise<KickResult> {
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<CasesPlugin>("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<CaseArgs> = {}): Promise<BanResult> {
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<CasesPlugin>("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", "<caseNumber:number> [note:string$]", { @d.command("update", "<caseNumber:number> [note:string$]", {
overloads: ["[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") @d.permission("can_note")
async updateCmd(msg: Message, args: { caseNumber?: number; note?: string }) { async updateCmd(msg: Message, args: { caseNumber?: number; note?: string }) {
@ -362,7 +526,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`)); msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`));
} }
@d.command("note", "<user:string> <note:string$>") @d.command("note", "<user:string> <note:string$>", {
info: {
description: "Add a note to the specified user",
},
})
@d.permission("can_note") @d.permission("can_note")
async noteCmd(msg: Message, args: { user: string; note: string }) { async noteCmd(msg: Message, args: { user: string; note: string }) {
const user = await this.resolveUser(args.user); const user = await this.resolveUser(args.user);
@ -384,6 +552,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("warn", "<user:string> <reason:string$>", { @d.command("warn", "<user:string> <reason:string$>", {
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Send a warning to the specified user",
},
}) })
@d.permission("can_warn") @d.permission("can_warn")
async warnCmd(msg: Message, args: { user: string; reason: string; mod?: Member }) { async warnCmd(msg: Message, args: { user: string; reason: string; mod?: Member }) {
@ -424,37 +595,27 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason); 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, { if (warnResult.status === "failed") {
useDM: config.dm_on_warn, msg.channel.createMessage(errorMessage("Failed to warn user"));
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; return;
} }
}
const casesPlugin = this.getPlugin<CasesPlugin>("cases"); const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : "";
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})` : "";
msg.channel.createMessage( msg.channel.createMessage(
successMessage( successMessage(
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${ `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${
createdCase.case_number warnResult.case.case_number
})${messageResultText}`, })${messageResultText}`,
), ),
); );
@ -465,6 +626,61 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
async warnMember(
member: Member,
warnMessage: string,
caseArgs: Partial<CaseArgs> = {},
retryPromptChannel: TextChannel = null,
): Promise<WarnResult | null> {
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<CasesPlugin>("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 actual function run by both !mute and !forcemute.
* The only difference between the two commands is in target member validation. * The only difference between the two commands is in target member validation.
@ -496,8 +712,13 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
ppId: pp && pp.id, ppId: pp && pp.id,
}); });
} catch (e) { } catch (e) {
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}`); logger.error(`Failed to mute user ${user.id}: ${e.stack}`);
msg.channel.createMessage(errorMessage("Could not mute the user")); msg.channel.createMessage(errorMessage("Could not mute the user"));
}
return; return;
} }
@ -536,6 +757,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("mute", "<user:string> <time:delay> <reason:string$>", { @d.command("mute", "<user:string> <time:delay> <reason:string$>", {
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"], overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Mute the specified member",
},
}) })
@d.permission("can_mute") @d.permission("can_mute")
async muteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) { async muteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) {
@ -574,6 +798,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("forcemute", "<user:string> <time:delay> <reason:string$>", { @d.command("forcemute", "<user:string> <time:delay> <reason:string$>", {
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"], overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Force-mute the specified user, even if they're not on the server",
},
}) })
@d.permission("can_mute") @d.permission("can_mute")
async forcemuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) { async forcemuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) {
@ -649,6 +876,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("unmute", "<user:string> <time:delay> <reason:string$>", { @d.command("unmute", "<user:string> <time:delay> <reason:string$>", {
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"], overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Unmute the specified member",
},
}) })
@d.permission("can_mute") @d.permission("can_mute")
async unmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) { async unmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) {
@ -691,6 +921,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("forceunmute", "<user:string> <time:delay> <reason:string$>", { @d.command("forceunmute", "<user:string> <time:delay> <reason:string$>", {
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"], overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Force-unmute the specified user, even if they're not on the server",
},
}) })
@d.permission("can_mute") @d.permission("can_mute")
async forceunmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) { async forceunmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) {
@ -717,6 +950,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("kick", "<user:string> [reason:string$]", { @d.command("kick", "<user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Kick the specified member",
},
}) })
@d.permission("can_kick") @d.permission("can_kick")
async kickCmd(msg, args: { user: string; reason: string; mod: Member }) { async kickCmd(msg, args: { user: string; reason: string; mod: Member }) {
@ -753,57 +989,31 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
mod = args.mod; mod = args.mod;
} }
const config = this.getConfig();
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
const kickResult = await this.kickMember(memberToKick, reason, {
// 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<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: memberToKick.id,
modId: mod.id, modId: mod.id,
type: CaseTypes.Kick,
reason,
ppId: mod.id !== msg.author.id ? msg.author.id : null, 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 // Confirm the action to the moderator
let response = `Kicked **${memberToKick.user.username}#${memberToKick.user.discriminator}** (Case #${ 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)); 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", "<user:string> [reason:string$]", { @d.command("ban", "<user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Ban the specified member",
},
}) })
@d.permission("can_ban") @d.permission("can_ban")
async banCmd(msg, args: { user: string; reason?: string; mod?: Member }) { async banCmd(msg, args: { user: string; reason?: string; mod?: Member }) {
@ -840,57 +1050,32 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
mod = args.mod; mod = args.mod;
} }
const config = this.getConfig();
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
const banResult = await this.banUserId(memberToBan.id, reason, {
// 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<CasesPlugin>("cases");
const createdCase = await casesPlugin.createCase({
userId: memberToBan.id,
modId: mod.id, modId: mod.id,
type: CaseTypes.Ban,
reason,
ppId: mod.id !== msg.author.id ? msg.author.id : null, 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 // Confirm the action to the moderator
let response = `Banned **${memberToBan.user.username}#${memberToBan.user.discriminator}** (Case #${ 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)); 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", "<user:string> [reason:string$]", { @d.command("softban", "<user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }], 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") @d.permission("can_ban")
async softbanCmd(msg, args: { user: string; reason: string; mod?: Member }) { async softbanCmd(msg, args: { user: string; reason: string; mod?: Member }) {
@ -935,8 +1120,19 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.ignoreEvent(IgnoredEventType.Ban, memberToSoftban.id); this.ignoreEvent(IgnoredEventType.Ban, memberToSoftban.id);
this.ignoreEvent(IgnoredEventType.Unban, memberToSoftban.id); this.ignoreEvent(IgnoredEventType.Unban, memberToSoftban.id);
await memberToSoftban.ban(1, reason); try {
await memberToSoftban.ban(1);
} catch (e) {
msg.channel.create(errorMessage("Failed to softban the user"));
return;
}
try {
await this.guild.unbanMember(memberToSoftban.id); 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 // Create a case for this action
const casesPlugin = this.getPlugin<CasesPlugin>("cases"); const casesPlugin = this.getPlugin<CasesPlugin>("cases");
@ -966,6 +1162,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("unban", "<user:string> [reason:string$]", { @d.command("unban", "<user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Unban the specified member",
},
}) })
@d.permission("can_ban") @d.permission("can_ban")
async unbanCmd(msg: Message, args: { user: string; reason: string; mod: Member }) { async unbanCmd(msg: Message, args: { user: string; reason: string; mod: Member }) {
@ -1017,6 +1216,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("forceban", "<user:string> [reason:string$]", { @d.command("forceban", "<user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Force-ban the specified user, even if they aren't on the server",
},
}) })
@d.permission("can_ban") @d.permission("can_ban")
async forcebanCmd(msg: Message, args: { user: string; reason?: string; mod?: Member }) { async forcebanCmd(msg: Message, args: { user: string; reason?: string; mod?: Member }) {
@ -1054,7 +1256,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
this.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); this.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id);
try { try {
await this.guild.banMember(user.id, 1, reason); await this.guild.banMember(user.id, 1);
} catch (e) { } catch (e) {
this.sendErrorMessage(msg.channel, "Failed to forceban member"); this.sendErrorMessage(msg.channel, "Failed to forceban member");
return; return;
@ -1080,7 +1282,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
@d.command("massban", "<userIds:string...>") @d.command("massban", "<userIds:string...>", {
info: {
description: "Mass-ban a list of user IDs",
},
})
@d.permission("can_massban") @d.permission("can_massban")
async massbanCmd(msg: Message, args: { userIds: string[] }) { async massbanCmd(msg: Message, args: { userIds: string[] }) {
// Limit to 100 users at once (arbitrary?) // Limit to 100 users at once (arbitrary?)
@ -1164,6 +1370,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("addcase", "<type:string> <user:string> [reason:string$]", { @d.command("addcase", "<type:string> <user:string> [reason:string$]", {
options: [{ name: "mod", type: "member" }], options: [{ name: "mod", type: "member" }],
info: {
description: "Add an arbitrary case to the specified user without taking any action",
},
}) })
@d.permission("can_addcase") @d.permission("can_addcase")
async addcaseCmd(msg: Message, args: { type: string; user: string; reason?: string; mod?: Member }) { async addcaseCmd(msg: Message, args: { type: string; user: string; reason?: string; mod?: Member }) {
@ -1224,15 +1433,13 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
/** @d.command("case", "<caseNumber:number>", {
* Display a case or list of cases info: {
* If the argument passed is a case id, display that case description: "Show information about a specific case",
* If the argument passed is a user id, show all cases on that user },
*/ })
@d.command("case", "<caseNumber:number>")
@d.permission("can_view") @d.permission("can_view")
async showCaseCmd(msg: Message, args: { caseNumber: number }) { async showCaseCmd(msg: Message, args: { caseNumber: number }) {
// Assume case id
const theCase = await this.cases.findByCaseNumber(args.caseNumber); const theCase = await this.cases.findByCaseNumber(args.caseNumber);
if (!theCase) { if (!theCase) {
@ -1258,6 +1465,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
shortcut: "h", shortcut: "h",
}, },
], ],
info: {
description: "Show a list of cases the specified user has",
},
}) })
@d.permission("can_view") @d.permission("can_view")
async userCasesCmd(msg: Message, args: { user: string; expand?: boolean; hidden?: boolean }) { async userCasesCmd(msg: Message, args: { user: string; expand?: boolean; hidden?: boolean }) {
@ -1322,6 +1532,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.command("cases", null, { @d.command("cases", null, {
options: [{ name: "mod", type: "Member" }], options: [{ name: "mod", type: "Member" }],
info: {
description: "Show the most recent 5 cases by the specified --mod",
},
}) })
@d.permission("can_view") @d.permission("can_view")
async recentCasesCmd(msg: Message, args: { mod?: Member }) { async recentCasesCmd(msg: Message, args: { mod?: Member }) {
@ -1347,7 +1560,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
} }
@d.command("hidecase", "<caseNum:number>") @d.command("hidecase", "<caseNum:number>", {
info: {
description: "Hide the specified case so it doesn't appear in !cases or !info",
},
})
@d.permission("can_hidecase") @d.permission("can_hidecase")
async hideCaseCmd(msg: Message, args: { caseNum: number }) { async hideCaseCmd(msg: Message, args: { caseNum: number }) {
const theCase = await this.cases.findByCaseNumber(args.caseNum); const theCase = await this.cases.findByCaseNumber(args.caseNum);
@ -1362,7 +1579,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
); );
} }
@d.command("unhidecase", "<caseNum:number>") @d.command("unhidecase", "<caseNum:number>", {
info: {
description: "Un-hide the specified case, making it appear in !cases and !info again",
},
})
@d.permission("can_hidecase") @d.permission("can_hidecase")
async unhideCaseCmd(msg: Message, args: { caseNum: number }) { async unhideCaseCmd(msg: Message, args: { caseNum: number }) {
const theCase = await this.cases.findByCaseNumber(args.caseNum); const theCase = await this.cases.findByCaseNumber(args.caseNum);

View file

@ -64,14 +64,18 @@ const FIRST_CHECK_INCREMENT = 5 * 1000;
export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> { export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "mutes"; public static pluginName = "mutes";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Mutes",
};
protected mutes: GuildMutes; protected mutes: GuildMutes;
protected cases: GuildCases; protected cases: GuildCases;
protected serverLogs: GuildLogs; protected serverLogs: GuildLogs;
private muteClearIntervalId: NodeJS.Timer; private muteClearIntervalId: NodeJS.Timer;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
mute_role: null, mute_role: null,
@ -139,7 +143,7 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
const user = await this.resolveUser(userId); 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) { if (member) {
// Apply mute role if it's missing // Apply mute role if it's missing
@ -264,7 +268,7 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
if (!existingMute) return; if (!existingMute) return;
const user = await this.resolveUser(userId); 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) { if (unmuteTime) {
// Schedule timed unmute (= just set the mute's duration) // Schedule timed unmute (= just set the mute's duration)

View file

@ -13,12 +13,13 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> { export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "name_history"; public static pluginName = "name_history";
protected static configSchema = ConfigSchema; public static showInDocs = false;
public static configSchema = ConfigSchema;
protected nicknameHistory: GuildNicknameHistory; protected nicknameHistory: GuildNicknameHistory;
protected usernameHistory: UsernameHistory; protected usernameHistory: UsernameHistory;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_view: false, can_view: false,
@ -40,7 +41,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> {
this.usernameHistory = new UsernameHistory(); this.usernameHistory = new UsernameHistory();
} }
@d.command("names", "<userId:userid>") @d.command("names", "<userId:userId>")
@d.permission("can_view") @d.permission("can_view")
async namesCmd(msg: Message, args: { userId: string }) { async namesCmd(msg: Message, args: { userId: string }) {
const nicknames = await this.nicknameHistory.getByUserId(args.userId); const nicknames = await this.nicknameHistory.getByUserId(args.userId);
@ -72,7 +73,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> {
@d.event("guildMemberUpdate") @d.event("guildMemberUpdate")
async onGuildMemberUpdate(_, member: Member) { async onGuildMemberUpdate(_, member: Member) {
const latestEntry = await this.nicknameHistory.getLastEntry(member.id); const latestEntry = await this.nicknameHistory.getLastEntry(member.id);
if (!latestEntry || latestEntry.nickname != member.nick) { if (!latestEntry || latestEntry.nickname !== member.nick) {
// tslint:disable-line // tslint:disable-line
await this.nicknameHistory.addEntry(member.id, member.nick); await this.nicknameHistory.addEntry(member.id, member.nick);
} }

View file

@ -17,12 +17,16 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class PersistPlugin extends ZeppelinPlugin<TConfigSchema> { export class PersistPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "persist"; public static pluginName = "persist";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Persist",
};
protected persistedData: GuildPersistedData; protected persistedData: GuildPersistedData;
protected logs: GuildLogs; protected logs: GuildLogs;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
persisted_roles: [], persisted_roles: [],

View file

@ -15,13 +15,17 @@ const TIMEOUT = 10 * 1000;
export class PingableRolesPlugin extends ZeppelinPlugin<TConfigSchema> { export class PingableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "pingable_roles"; public static pluginName = "pingable_roles";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Pingable roles",
};
protected pingableRoles: GuildPingableRoles; protected pingableRoles: GuildPingableRoles;
protected cache: Map<string, PingableRole[]>; protected cache: Map<string, PingableRole[]>;
protected timeouts: Map<string, any>; protected timeouts: Map<string, any>;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_manage: false, can_manage: false,
@ -53,7 +57,7 @@ export class PingableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
return this.cache.get(channelId); return this.cache.get(channelId);
} }
@d.command("pingable_role disable", "<channelId:channelid> <role:role>") @d.command("pingable_role disable", "<channelId:channelId> <role:role>")
@d.permission("can_manage") @d.permission("can_manage")
async disablePingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) { async disablePingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) {
const pingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); const pingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id);
@ -70,7 +74,7 @@ export class PingableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
); );
} }
@d.command("pingable_role", "<channelId:channelid> <role:role>") @d.command("pingable_role", "<channelId:channelId> <role:role>")
@d.permission("can_manage") @d.permission("can_manage")
async setPingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) { async setPingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) {
const existingPingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); const existingPingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id);

View file

@ -38,7 +38,11 @@ const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
export class PostPlugin extends ZeppelinPlugin<TConfigSchema> { export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "post"; public static pluginName = "post";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Post",
};
protected savedMessages: GuildSavedMessages; protected savedMessages: GuildSavedMessages;
protected scheduledPosts: GuildScheduledPosts; protected scheduledPosts: GuildScheduledPosts;
@ -58,7 +62,7 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
clearTimeout(this.scheduledPostLoopTimeout); clearTimeout(this.scheduledPostLoopTimeout);
} }
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_post: false, can_post: false,
@ -261,9 +265,11 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
} else { } else {
// Post the message immediately // Post the message immediately
await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]); await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]);
if (args.channel.id !== msg.channel.id) {
this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`); this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`);
} }
} }
}
/** /**
* COMMAND: Post a message with an embed as the bot to the specified channel * COMMAND: Post a message with an embed as the bot to the specified channel
@ -349,8 +355,10 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
const createdMsg = await args.channel.createMessage({ embed }); const createdMsg = await args.channel.createMessage({ embed });
this.savedMessages.setPermanent(createdMsg.id); this.savedMessages.setPermanent(createdMsg.id);
if (msg.channel.id !== args.channel.id) {
await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`); await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`);
} }
}
if (args.content) { if (args.content) {
const prefix = this.guildConfig.prefix || "!"; const prefix = this.guildConfig.prefix || "!";

View file

@ -44,7 +44,11 @@ type PendingMemberRoleChanges = {
export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> { export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "reaction_roles"; public static pluginName = "reaction_roles";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Reaction roles",
};
protected reactionRoles: GuildReactionRoles; protected reactionRoles: GuildReactionRoles;
protected savedMessages: GuildSavedMessages; protected savedMessages: GuildSavedMessages;
@ -55,7 +59,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
private autoRefreshTimeout; private autoRefreshTimeout;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
auto_refresh_interval: MIN_AUTO_REFRESH, auto_refresh_interval: MIN_AUTO_REFRESH,

View file

@ -24,7 +24,11 @@ const MAX_TRIES = 3;
export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> { export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "reminders"; public static pluginName = "reminders";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Reminders",
};
protected reminders: GuildReminders; protected reminders: GuildReminders;
protected tries: Map<number, number>; protected tries: Map<number, number>;
@ -32,7 +36,7 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
private postRemindersTimeout; private postRemindersTimeout;
private unloaded = false; private unloaded = false;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_use: false, can_use: false,

View file

@ -14,11 +14,12 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class SelfGrantableRolesPlugin extends ZeppelinPlugin<TConfigSchema> { export class SelfGrantableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "self_grantable_roles"; public static pluginName = "self_grantable_roles";
protected static configSchema = ConfigSchema; public static showInDocs = false;
public static configSchema = ConfigSchema;
protected selfGrantableRoles: GuildSelfGrantableRoles; protected selfGrantableRoles: GuildSelfGrantableRoles;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_manage: false, can_manage: false,

View file

@ -33,7 +33,11 @@ const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * 1000;
export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> { export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "slowmode"; public static pluginName = "slowmode";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Slowmode",
};
protected slowmodes: GuildSlowmodes; protected slowmodes: GuildSlowmodes;
protected savedMessages: GuildSavedMessages; protected savedMessages: GuildSavedMessages;
@ -42,7 +46,7 @@ export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> {
private onMessageCreateFn; private onMessageCreateFn;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
use_native_slowmode: true, use_native_slowmode: true,

View file

@ -74,7 +74,11 @@ const SPAM_ARCHIVE_EXPIRY_DAYS = 90;
export class SpamPlugin extends ZeppelinPlugin<TConfigSchema> { export class SpamPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "spam"; public static pluginName = "spam";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Spam protection",
};
protected logs: GuildLogs; protected logs: GuildLogs;
protected archives: GuildArchives; protected archives: GuildArchives;
@ -96,7 +100,7 @@ export class SpamPlugin extends ZeppelinPlugin<TConfigSchema> {
private expiryInterval; private expiryInterval;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
max_censor: null, max_censor: null,

View file

@ -8,7 +8,7 @@ import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { SavedMessage } from "../data/entities/SavedMessage"; import { SavedMessage } from "../data/entities/SavedMessage";
import * as t from "io-ts"; import * as t from "io-ts";
import { GuildStarboardMessages } from "../data/GuildStarboardMessages"; import { GuildStarboardMessages } from "../data/GuildStarboardMessages";
import { StarboardMessage } from "src/data/entities/StarboardMessage"; import { StarboardMessage } from "../data/entities/StarboardMessage";
const StarboardOpts = t.type({ const StarboardOpts = t.type({
source_channel_ids: t.array(t.string), source_channel_ids: t.array(t.string),
@ -38,14 +38,15 @@ const defaultStarboardOpts: Partial<TStarboardOpts> = {
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> { export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "starboard"; public static pluginName = "starboard";
protected static configSchema = ConfigSchema; public static showInDocs = false;
public static configSchema = ConfigSchema;
protected savedMessages: GuildSavedMessages; protected savedMessages: GuildSavedMessages;
protected starboardMessages: GuildStarboardMessages; protected starboardMessages: GuildStarboardMessages;
private onMessageDeleteFn; private onMessageDeleteFn;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_manage: false, can_manage: false,
@ -258,7 +259,7 @@ export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
} }
@d.command("starboard migrate_pins", "<pinChannelId:channelid> <starboardChannelId:channelid>") @d.command("starboard migrate_pins", "<pinChannelId:channelId> <starboardChannelId:channelId>")
async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) { async migratePinsCmd(msg: Message, args: { pinChannelId: string; starboardChannelId }) {
try { try {
const starboards = await this.getStarboardOptsForStarboardChannelId(this.bot.getChannel(args.starboardChannelId)); const starboards = await this.getStarboardOptsForStarboardChannelId(this.bot.getChannel(args.starboardChannelId));

View file

@ -23,7 +23,11 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> { export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "tags"; public static pluginName = "tags";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Tags",
};
protected archives: GuildArchives; protected archives: GuildArchives;
protected tags: GuildTags; protected tags: GuildTags;
@ -34,7 +38,7 @@ export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
protected tagFunctions; protected tagFunctions;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
prefix: "!!", prefix: "!!",
@ -117,7 +121,7 @@ export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
const prefix = this.getConfigForMsg(msg).prefix; const prefix = this.getConfigForMsg(msg).prefix;
const tagNames = tags.map(t => t.tag).sort(); const tagNames = tags.map(tag => tag.tag).sort();
msg.channel.createMessage(` msg.channel.createMessage(`
Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\` Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\`
`); `);

View file

@ -1,8 +1,9 @@
import { decorators as d, GlobalPlugin } from "knub"; import { decorators as d, GlobalPlugin } from "knub";
import { UsernameHistory } from "../data/UsernameHistory"; import { UsernameHistory } from "../data/UsernameHistory";
import { Member, User } from "eris"; import { Member, User } from "eris";
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
export class UsernameSaver extends GlobalPlugin { export class UsernameSaver extends GlobalZeppelinPlugin {
public static pluginName = "username_saver"; public static pluginName = "username_saver";
protected usernameHistory: UsernameHistory; protected usernameHistory: UsernameHistory;

View file

@ -82,7 +82,11 @@ type MemberSearchParams = {
export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> { export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "utility"; public static pluginName = "utility";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Utility",
};
protected logs: GuildLogs; protected logs: GuildLogs;
protected cases: GuildCases; protected cases: GuildCases;
@ -92,7 +96,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
protected lastFullMemberRefresh = 0; protected lastFullMemberRefresh = 0;
protected lastReload; protected lastReload;
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
can_roles: false, can_roles: false,
@ -554,7 +558,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
}, CLEAN_COMMAND_DELETE_DELAY); }, CLEAN_COMMAND_DELETE_DELAY);
} }
@d.command("clean user", "<userId:userid> <count:number>") @d.command("clean user", "<userId:userId> <count:number>")
@d.permission("can_clean") @d.permission("can_clean")
async cleanUserCmd(msg: Message, args: { userId: string; count: number }) { async cleanUserCmd(msg: Message, args: { userId: string; count: number }) {
if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
@ -607,7 +611,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
let member; let member;
if (!(user instanceof UnknownUser)) { if (!(user instanceof UnknownUser)) {
member = await this.getMember(user.id); member = await this.getMember(user.id, true);
} }
const embed: EmbedOptions = { const embed: EmbedOptions = {
@ -698,7 +702,9 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
msg.channel.createMessage({ embed }); msg.channel.createMessage({ embed });
} }
@d.command(/(?:nickname|nick) reset/, "<member:resolvedMember>") @d.command("nickname reset", "<member:resolvedMember>", {
aliases: ["nick reset"],
})
@d.permission("can_nickname") @d.permission("can_nickname")
async nicknameResetCmd(msg: Message, args: { member: Member }) { async nicknameResetCmd(msg: Message, args: { member: Member }) {
if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) {
@ -718,7 +724,9 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
msg.channel.createMessage(successMessage(`The nickname of <@!${args.member.id}> has been reset`)); msg.channel.createMessage(successMessage(`The nickname of <@!${args.member.id}> has been reset`));
} }
@d.command(/nickname|nick/, "<member:resolvedMember> <nickname:string$>") @d.command("nickname", "<member:resolvedMember> <nickname:string$>", {
aliases: ["nick"],
})
@d.permission("can_nickname") @d.permission("can_nickname")
async nicknameCmd(msg: Message, args: { member: Member; nickname: string }) { async nicknameCmd(msg: Message, args: { member: Member; nickname: string }) {
if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) {

View file

@ -16,11 +16,15 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export class WelcomeMessagePlugin extends ZeppelinPlugin<TConfigSchema> { export class WelcomeMessagePlugin extends ZeppelinPlugin<TConfigSchema> {
public static pluginName = "welcome_message"; public static pluginName = "welcome_message";
protected static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static pluginInfo = {
prettyName: "Welcome message",
};
protected logs: GuildLogs; protected logs: GuildLogs;
protected getDefaultOptions(): IPluginOptions<TConfigSchema> { public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
return { return {
config: { config: {
send_dm: false, send_dm: false,

View file

@ -4,15 +4,47 @@ import * as t from "io-ts";
import { pipe } from "fp-ts/lib/pipeable"; import { pipe } from "fp-ts/lib/pipeable";
import { fold } from "fp-ts/lib/Either"; import { fold } from "fp-ts/lib/Either";
import { PathReporter } from "io-ts/lib/PathReporter"; import { PathReporter } from "io-ts/lib/PathReporter";
import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; import {
deepKeyIntersect,
isSnowflake,
isUnicodeEmoji,
resolveMember,
resolveUser,
resolveUserId,
trimEmptyStartEndLines,
trimIndents,
UnknownUser,
} from "../utils";
import { Member, User } from "eris"; import { Member, User } from "eris";
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
import { performance } from "perf_hooks"; 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; const SLOW_RESOLVE_THRESHOLD = 1500;
export interface PluginInfo {
prettyName: string;
description?: string;
}
export interface CommandInfo {
description?: string;
basicUsage?: string;
parameterDescriptions?: {
[key: string]: string;
};
}
export function trimPluginDescription(str) {
return trimIndents(trimEmptyStartEndLines(str), 6);
}
export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plugin<TConfig> { export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plugin<TConfig> {
protected static configSchema: t.TypeC<any>; public static pluginInfo: PluginInfo;
public static showInDocs: boolean = true;
public static configSchema: t.TypeC<any>;
public static dependencies = []; public static dependencies = [];
protected throwPluginRuntimeError(message: string) { protected throwPluginRuntimeError(message: string) {
@ -29,49 +61,103 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
return ourLevel > memberLevel; return ourLevel > memberLevel;
} }
protected static getStaticDefaultOptions() { /**
* Since we want to do type checking without creating instances of every plugin,
* we need a static version of getDefaultOptions(). This static version is then,
* by turn, called from getDefaultOptions() so everything still works as expected.
*/
public static getStaticDefaultOptions() {
// Implemented by plugin // Implemented by plugin
return {}; return {};
} }
/**
* Wrapper to fetch the real default options from getStaticDefaultOptions()
*/
protected getDefaultOptions(): IPluginOptions<TConfig> { protected getDefaultOptions(): IPluginOptions<TConfig> {
return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions<TConfig>; return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions<TConfig>;
} }
/**
* Allows the plugin to preprocess the config before it's validated.
* Useful for e.g. adding default properties to dynamic objects.
*/
protected static preprocessStaticConfig(config: any) {
return config;
}
/**
* Merges the given options and default options and decodes them according to the config schema of the plugin (if any).
* Throws on any decoding/validation errors.
*
* Intended as an augmented, static replacement for Plugin.getMergedConfig() which is why this is also called from
* getMergedConfig().
*
* Like getStaticDefaultOptions(), we also want to use this function for type checking without creating an instance of
* the plugin, which is why this has to be a static function.
*/
protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions {
const defaultOptions: any = this.getStaticDefaultOptions();
let mergedConfig = mergeConfig({}, defaultOptions.config || {}, options.config || {});
const mergedOverrides = options["=overrides"]
? options["=overrides"]
: (options.overrides || []).concat(defaultOptions.overrides || []);
mergedConfig = this.preprocessStaticConfig(mergedConfig);
const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig;
if (decodedConfig instanceof StrictValidationError) {
throw decodedConfig;
}
const decodedOverrides = [];
for (const override of mergedOverrides) {
const overrideConfigMergedWithBaseConfig = mergeConfig({}, mergedConfig, override.config || {});
const decodedOverrideConfig = this.configSchema
? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig)
: overrideConfigMergedWithBaseConfig;
if (decodedOverrideConfig instanceof StrictValidationError) {
throw decodedOverrideConfig;
}
decodedOverrides.push({
...override,
config: deepKeyIntersect(decodedOverrideConfig, override.config || {}),
});
}
return {
config: decodedConfig,
overrides: decodedOverrides,
};
}
/**
* Wrapper that calls mergeAndValidateStaticOptions()
*/
protected getMergedOptions(): IPluginOptions<TConfig> {
if (!this.mergedPluginOptions) {
this.mergedPluginOptions = ((this.constructor as unknown) as typeof ZeppelinPlugin).mergeAndDecodeStaticOptions(
this.pluginOptions,
);
}
return this.mergedPluginOptions as IPluginOptions<TConfig>;
}
/**
* Run static type checks and other validations on the given options
*/
public static validateOptions(options: any): string[] | null { public static validateOptions(options: any): string[] | null {
// Validate config values // Validate config values
if (this.configSchema) { if (this.configSchema) {
if (options.config) { try {
const merged = configUtils.mergeConfig( this.mergeAndDecodeStaticOptions(options);
{}, } catch (e) {
(this.getStaticDefaultOptions() as any).config || {}, if (e instanceof StrictValidationError) {
options.config, return e.getErrors();
);
const errors = validateStrict(this.configSchema, merged);
if (errors) {
return errors;
}
} }
if (options.overrides) { throw e;
for (const [i, override] of options.overrides.entries()) {
if (override.config) {
// For type checking overrides, apply default config + supplied config + any overrides preceding this override + finally this override
// Exhaustive type checking would require checking against all combinations of preceding overrides but that's... costy. This will do for now.
// TODO: Override default config retrieval functions and do some sort of memoized checking there?
const merged = configUtils.mergeConfig(
{},
(this.getStaticDefaultOptions() as any).config || {},
options.config || {},
...options.overrides.slice(0, i).map(o => o.config || {}),
override.config,
);
const errors = validateStrict(this.configSchema, merged);
if (errors) {
return errors;
}
}
}
} }
} }
@ -80,12 +166,7 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
} }
public async runLoad(): Promise<any> { public async runLoad(): Promise<any> {
const mergedOptions = this.getMergedOptions(); const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config
const validationErrors = ((this.constructor as unknown) as typeof ZeppelinPlugin).validateOptions(mergedOptions);
if (validationErrors) {
throw new Error(validationErrors.join("\n"));
}
return super.runLoad(); return super.runLoad();
} }
@ -103,10 +184,20 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
} }
} }
/**
* Intended for cross-plugin functionality
*/
public getRegisteredCommands() { public getRegisteredCommands() {
return this.commands.commands; return this.commands.commands;
} }
/**
* Intended for cross-plugin functionality
*/
public getRuntimeOptions() {
return this.getMergedOptions();
}
/** /**
* Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. * Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
* If the user is not found in the cache, it's fetched from the API. * If the user is not found in the cache, it's fetched from the API.
@ -126,14 +217,31 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
* Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. * Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
* If the member is not found in the cache, it's fetched from the API. * If the member is not found in the cache, it's fetched from the API.
*/ */
async getMember(memberResolvable: string): Promise<Member> { async getMember(memberResolvable: string, forceFresh = false): Promise<Member> {
const start = performance.now(); const start = performance.now();
const member = await resolveMember(this.bot, this.guild, memberResolvable);
let member;
if (forceFresh) {
const userId = await resolveUserId(this.bot, memberResolvable);
try {
member = userId && (await this.bot.getRESTGuildMember(this.guild.id, userId));
} catch (e) {
if (!(e instanceof DiscordRESTError)) {
throw e;
}
}
if (member) member.id = member.user.id;
} else {
member = await resolveMember(this.bot, this.guild, memberResolvable);
}
const time = performance.now() - start; const time = performance.now() - start;
if (time >= SLOW_RESOLVE_THRESHOLD) { if (time >= SLOW_RESOLVE_THRESHOLD) {
const rounded = Math.round(time); const rounded = Math.round(time);
logger.warn(`Slow member resolve (${rounded}ms): ${memberResolvable} in ${this.guild.name} (${this.guild.id})`); logger.warn(`Slow member resolve (${rounded}ms): ${memberResolvable} in ${this.guild.name} (${this.guild.id})`);
} }
return member; return member;
} }
} }

View file

@ -25,6 +25,7 @@ import { GuildInfoSaverPlugin } from "./GuildInfoSaver";
import { CompanionChannelPlugin } from "./CompanionChannels"; import { CompanionChannelPlugin } from "./CompanionChannels";
import { LocatePlugin } from "./LocateUser"; import { LocatePlugin } from "./LocateUser";
import { GuildConfigReloader } from "./GuildConfigReloader"; import { GuildConfigReloader } from "./GuildConfigReloader";
import { ChannelArchiverPlugin } from "./ChannelArchiver";
/** /**
* Plugins available to be loaded for individual guilds * Plugins available to be loaded for individual guilds
@ -54,6 +55,7 @@ export const availablePlugins = [
GuildInfoSaverPlugin, GuildInfoSaverPlugin,
CompanionChannelPlugin, CompanionChannelPlugin,
LocatePlugin, LocatePlugin,
ChannelArchiverPlugin,
]; ];
/** /**

View file

@ -6,6 +6,7 @@ import {
GuildAuditLog, GuildAuditLog,
GuildAuditLogEntry, GuildAuditLogEntry,
Member, Member,
MessageContent,
TextableChannel, TextableChannel,
TextChannel, TextChannel,
User, User,
@ -21,7 +22,7 @@ const fsp = fs.promises;
import https from "https"; import https from "https";
import tmp from "tmp"; import tmp from "tmp";
import { logger } from "knub"; import { logger, waitForReaction } from "knub";
const delayStringMultipliers = { const delayStringMultipliers = {
w: 1000 * 60 * 60 * 24 * 7, w: 1000 * 60 * 60 * 24 * 7,
@ -31,8 +32,23 @@ const delayStringMultipliers = {
s: 1000, s: 1000,
}; };
export function tNullable(type: t.Mixed) { export const MS = 1;
return t.union([type, t.undefined, t.null]); export const SECONDS = 1000 * MS;
export const MINUTES = 60 * SECONDS;
export const HOURS = 60 * MINUTES;
export const DAYS = 24 * HOURS;
export function tNullable<T extends t.Type<any, any, unknown>>(type: T) {
return t.union([type, t.undefined, t.null], type.name);
}
export function dropPropertiesByName(obj, propName) {
if (obj.hasOwnProperty(propName)) delete obj[propName];
for (const value of Object.values(obj)) {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
dropPropertiesByName(value, propName);
}
}
} }
/** /**
@ -168,8 +184,10 @@ export async function findRelevantAuditLogEntry(
const urlRegex = /(\S+\.\S+)/g; const urlRegex = /(\S+\.\S+)/g;
const protocolRegex = /^[a-z]+:\/\//; const protocolRegex = /^[a-z]+:\/\//;
export function getUrlsInString(str: string): url.URL[] { export function getUrlsInString(str: string, unique = false): url.URL[] {
const matches = str.match(urlRegex) || []; let matches = str.match(urlRegex).map(m => m[0]) || [];
if (unique) matches = Array.from(new Set(matches));
return matches.reduce((urls, match) => { return matches.reduce((urls, match) => {
if (!protocolRegex.test(match)) { if (!protocolRegex.test(match)) {
match = `https://${match}`; match = `https://${match}`;
@ -235,6 +253,48 @@ export function asSingleLine(str: string) {
return trimLines(str).replace(/\n/g, " "); return trimLines(str).replace(/\n/g, " ");
} }
export function trimEmptyStartEndLines(str: string) {
const lines = str.split("\n");
let emptyLinesAtStart = 0;
let emptyLinesAtEnd = 0;
for (const line of lines) {
if (line.match(/^\s*$/)) {
emptyLinesAtStart++;
} else {
break;
}
}
for (let i = lines.length - 1; i > 0; i--) {
if (lines[i].match(/^\s*$/)) {
emptyLinesAtEnd++;
} else {
break;
}
}
return lines.slice(emptyLinesAtStart, emptyLinesAtEnd ? -1 * emptyLinesAtEnd : null).join("\n");
}
export function trimIndents(str: string, indentLength: number) {
return str
.split("\n")
.map(line => line.slice(indentLength))
.join("\n");
}
export function indentLine(str: string, indentLength: number) {
return " ".repeat(indentLength) + str;
}
export function indentLines(str: string, indentLength: number) {
return str
.split("\n")
.map(line => indentLine(line, indentLength))
.join("\n");
}
export const emptyEmbedValue = "\u200b"; export const emptyEmbedValue = "\u200b";
export const embedPadding = "\n" + emptyEmbedValue; export const embedPadding = "\n" + emptyEmbedValue;
@ -398,7 +458,7 @@ export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path
if (retries === 0) { if (retries === 0) {
throw httpsErr; throw httpsErr;
} else { } else {
console.warn("File download failed, retrying. Error given:", httpsErr.message); console.warn("File download failed, retrying. Error given:", httpsErr.message); // tslint:disable-line
resolve(downloadFile(attachmentUrl, retries - 1)); resolve(downloadFile(attachmentUrl, retries - 1));
} }
}); });
@ -568,94 +628,147 @@ export class UnknownUser {
} }
} }
export function isObjectLiteral(obj) {
let deepestPrototype = obj;
while (Object.getPrototypeOf(deepestPrototype) != null) {
deepestPrototype = Object.getPrototypeOf(deepestPrototype);
}
return Object.getPrototypeOf(obj) === deepestPrototype;
}
const keyMods = ["+", "-", "="];
export function deepKeyIntersect(obj, keyReference) {
const result = {};
for (let [key, value] of Object.entries(obj)) {
if (!keyReference.hasOwnProperty(key)) {
// Temporary solution so we don't erase keys with modifiers
// Modifiers will be removed soon(tm) so we can remove this when that happens as well
let found = false;
for (const mod of keyMods) {
if (keyReference.hasOwnProperty(mod + key)) {
key = mod + key;
found = true;
break;
}
}
if (!found) continue;
}
if (Array.isArray(value)) {
// Also temp (because modifier shenanigans)
result[key] = keyReference[key];
} else if (
value != null &&
typeof value === "object" &&
typeof keyReference[key] === "object" &&
isObjectLiteral(value)
) {
result[key] = deepKeyIntersect(value, keyReference[key]);
} else {
result[key] = value;
}
}
return result;
}
const unknownUsers = new Set(); const unknownUsers = new Set();
const unknownMembers = new Set(); const unknownMembers = new Set();
export function resolveUserId(bot: Client, value: string) {
if (value == null) {
return null;
}
// A user mention?
const mentionMatch = value.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
return mentionMatch[1];
}
// A non-mention, full username?
const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/);
if (usernameMatch) {
const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
if (user) return user.id;
}
// Just a user ID?
const idMatch = value.match(/^\d+$/);
if (idMatch) {
return value;
}
return null;
}
export async function resolveUser(bot: Client, value: string): Promise<User | UnknownUser> { export async function resolveUser(bot: Client, value: string): Promise<User | UnknownUser> {
if (value == null || typeof value !== "string") { if (value == null || typeof value !== "string") {
return new UnknownUser(); return new UnknownUser();
} }
let userId; // If we have the user cached, return that directly
const userId = resolveUserId(bot, value);
// A user mention?
const mentionMatch = value.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
userId = mentionMatch[1];
}
// A non-mention, full username?
if (!userId) { if (!userId) {
const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/); return new UnknownUser({ id: userId });
if (usernameMatch) {
const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
if (user) userId = user.id;
}
} }
// Just a user ID? if (bot.users.has(userId)) {
if (!userId) { return bot.users.get(userId);
const idMatch = value.match(/^\d+$/);
if (!idMatch) {
return null;
} }
userId = value; // We don't want to spam the API by trying to fetch unknown users again and again,
// so we cache the fact that they're "unknown" for a while
if (unknownUsers.has(userId)) {
return new UnknownUser({ id: userId });
} }
const cachedUser = bot.users.find(u => u.id === userId); const freshUser = await bot.getRESTUser(userId).catch(noop);
if (cachedUser) return cachedUser; if (freshUser) {
// We only fetch the user from the API if we haven't tried it before:
// - If the user was found, the bot has them in its cache
// - If the user was not found, they'll be in unknownUsers
if (!unknownUsers.has(userId)) {
try {
const freshUser = await bot.getRESTUser(userId);
bot.users.add(freshUser, bot); bot.users.add(freshUser, bot);
return freshUser; return freshUser;
} catch (e) {} // tslint:disable-line }
unknownUsers.add(userId); unknownUsers.add(userId);
} setTimeout(() => unknownUsers.delete(userId), 15 * MINUTES);
return new UnknownUser({ id: userId }); return new UnknownUser({ id: userId });
} }
export async function resolveMember(bot: Client, guild: Guild, value: string): Promise<Member> { export async function resolveMember(bot: Client, guild: Guild, value: string): Promise<Member> {
// Start by resolving the user const userId = resolveUserId(bot, value);
const user = await resolveUser(bot, value); if (!userId) return null;
if (!user || user instanceof UnknownUser) return null;
// See if we have the member cached... // If we have the member cached, return that directly
let member = guild.members.get(user.id); if (guild.members.has(userId)) {
return guild.members.get(userId);
// We only fetch the member from the API if we haven't tried it before:
// - If the member was found, the bot has them in the guild's member cache
// - If the member was not found, they'll be in unknownMembers
const unknownKey = `${guild.id}-${user.id}`;
if (!unknownMembers.has(unknownKey)) {
// If not, fetch it from the API
if (!member) {
try {
logger.debug(`Fetching unknown member (${user.id} in ${guild.name} (${guild.id})) from the API`);
member = await bot.getRESTGuildMember(guild.id, user.id);
member.id = user.id;
member.guild = guild;
} catch (e) {} // tslint:disable-line
} }
if (!member) unknownMembers.add(unknownKey); // We don't want to spam the API by trying to fetch unknown members again and again,
// so we cache the fact that they're "unknown" for a while
const unknownKey = `${guild.id}-${userId}`;
if (unknownMembers.has(unknownKey)) {
return null;
} }
return member; logger.debug(`Fetching unknown member (${userId} in ${guild.name} (${guild.id})) from the API`);
const freshMember = await bot.getRESTGuildMember(guild.id, userId).catch(noop);
if (freshMember) {
freshMember.id = userId;
return freshMember;
}
unknownMembers.add(unknownKey);
setTimeout(() => unknownMembers.delete(unknownKey), 15 * MINUTES);
return null;
} }
export const MS = 1;
export const SECONDS = 1000 * MS;
export const MINUTES = 60 * SECONDS;
export const HOURS = 60 * MINUTES;
export const DAYS = 24 * HOURS;
export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions }; export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions };
export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) {
const msg = await channel.createMessage(content);
const reply = await waitForReaction(bot, msg, ["✅", "❌"], userId);
msg.delete().catch(noop);
return reply && reply.name === "✅";
}

View file

@ -1,8 +1,28 @@
import * as t from "io-ts"; import * as t from "io-ts";
import { pipe } from "fp-ts/lib/pipeable"; import { pipe } from "fp-ts/lib/pipeable";
import { fold } from "fp-ts/lib/Either"; import { fold, either } from "fp-ts/lib/Either";
import { noop } from "./utils"; import { noop } from "./utils";
import deepDiff from "deep-diff"; import deepDiff from "deep-diff";
import safeRegex from "safe-regex";
const regexWithFlags = /^\/(.*?)\/([i]*)$/;
/**
* The TSafeRegex type supports two syntaxes for regexes: /<regex>/<flags> and just <regex>
* The value is then checked for "catastrophic exponential-time regular expressions" by
* https://www.npmjs.com/package/safe-regex
*/
export const TSafeRegex = new t.Type<RegExp, string>(
"TSafeRegex",
(s): s is RegExp => s instanceof RegExp,
(from, to) =>
either.chain(t.string.validate(from, to), s => {
const advancedSyntaxMatch = s.match(regexWithFlags);
const [regexStr, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [s, ""];
return safeRegex(regexStr) ? t.success(new RegExp(regexStr, flags)) : t.failure(from, to, "Unsafe regex");
}),
s => `/${s.source}/${s.flags}`,
);
// From io-ts/lib/PathReporter // From io-ts/lib/PathReporter
function stringify(v) { function stringify(v) {
@ -31,22 +51,38 @@ function getContextPath(context) {
} }
// tslint:enable // tslint:enable
const report = fold((errors: any) => { export class StrictValidationError extends Error {
return errors.map(err => { private errors;
if (err.message) return err.message;
constructor(errors: string[]) {
errors = Array.from(new Set(errors));
super(errors.join("\n"));
this.errors = errors;
}
getErrors() {
return this.errors;
}
}
const report = fold((errors: any): StrictValidationError | void => {
const errorStrings = errors.map(err => {
const context = err.context.map(c => c.key).filter(k => k && !k.startsWith("{")); const context = err.context.map(c => c.key).filter(k => k && !k.startsWith("{"));
while (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1);
const value = stringify(err.value); const value = stringify(err.value);
return value === undefined return value === undefined
? `<${context.join("/")}> is required` ? `<${context.join("/")}> is required`
: `Invalid value <${stringify(err.value)}> supplied to <${context.join("/")}>`; : `Invalid value supplied to <${context.join("/")}>${err.message ? `: ${err.message}` : ""}`;
}); });
return new StrictValidationError(errorStrings);
}, noop); }, noop);
/** /**
* Validates the given value against the given schema while also disallowing extra properties * Decodes and validates the given value against the given schema while also disallowing extra properties
* See: https://github.com/gcanti/io-ts/issues/322 * See: https://github.com/gcanti/io-ts/issues/322
*/ */
export function validateStrict(schema: t.HasProps, value: any): string[] | null { export function decodeAndValidateStrict(schema: t.HasProps, value: any): StrictValidationError | any {
const validationResult = t.exact(schema).decode(value); const validationResult = t.exact(schema).decode(value);
return pipe( return pipe(
validationResult, validationResult,
@ -57,10 +93,10 @@ export function validateStrict(schema: t.HasProps, value: any): string[] | null
if (JSON.stringify(value) !== JSON.stringify(result)) { if (JSON.stringify(value) !== JSON.stringify(result)) {
const diff = deepDiff(result, value); const diff = deepDiff(result, value);
const errors = diff.filter(d => d.kind === "N").map(d => `Unknown property <${d.path.join(".")}>`); const errors = diff.filter(d => d.kind === "N").map(d => `Unknown property <${d.path.join(".")}>`);
return errors.length ? errors : ["Found unknown properties"]; if (errors.length) return new StrictValidationError(errors);
} }
return null; return result;
}, },
), ),
); );

View file

@ -22,6 +22,8 @@
"no-bitwise": false, "no-bitwise": false,
"interface-over-type-literal": false, "interface-over-type-literal": false,
"interface-name": false, "interface-name": false,
"no-submodule-imports": false "no-submodule-imports": false,
"no-floating-promises": true,
"no-string-literal": false
} }
} }