mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-05-17 23:25:02 +00:00
Merge remote-tracking branch 'origin/master' into revampedStarboard
This commit is contained in:
commit
6eec6fddd7
72 changed files with 3465 additions and 425 deletions
3
dashboard/.htmlnanorc.js
Normal file
3
dashboard/.htmlnanorc.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
collapseWhitespace: false
|
||||
};
|
49
dashboard/package-lock.json
generated
49
dashboard/package-lock.json
generated
|
@ -1267,7 +1267,6 @@
|
|||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
|
@ -1985,6 +1984,14 @@
|
|||
"node-releases": "^1.1.23"
|
||||
}
|
||||
},
|
||||
"buefy": {
|
||||
"version": "0.7.10",
|
||||
"resolved": "https://registry.npmjs.org/buefy/-/buefy-0.7.10.tgz",
|
||||
"integrity": "sha512-jU9CTEQR1rozxagwEPB69qObBDwWl+4uCa6TjiPkqcqOb/uxq1uvyvCsVinADzjNjzOTFhUOuuSPQk8gsVEOzA==",
|
||||
"requires": {
|
||||
"bulma": "0.7.5"
|
||||
}
|
||||
},
|
||||
"buffer": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
|
||||
|
@ -2408,6 +2415,16 @@
|
|||
"sha.js": "^2.4.8"
|
||||
}
|
||||
},
|
||||
"cross-env": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz",
|
||||
"integrity": "sha512-jtdNFfFW1hB7sMhr/H6rW1Z45LFqyI431m3qU6bFXcQ3Eh7LtBuG3h74o7ohHZ3crrRkkqHlo4jYHFPcjroANg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cross-spawn": "^6.0.5",
|
||||
"is-windows": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
||||
|
@ -4253,6 +4270,11 @@
|
|||
"integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==",
|
||||
"dev": true
|
||||
},
|
||||
"highlight.js": {
|
||||
"version": "9.15.8",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.8.tgz",
|
||||
"integrity": "sha512-RrapkKQWwE+wKdF73VsOa2RQdIoO3mxwJ4P8mhbI6KYJUraUHRKM5w5zQQKXNk0xNL4UVRdulV9SBJcmzJNzVA=="
|
||||
},
|
||||
"hmac-drbg": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
||||
|
@ -4755,7 +4777,6 @@
|
|||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
|
||||
"integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
|
@ -4764,8 +4785,7 @@
|
|||
"esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -4964,6 +4984,11 @@
|
|||
"object-visit": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"marked": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz",
|
||||
"integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg=="
|
||||
},
|
||||
"md5.js": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
||||
|
@ -6926,8 +6951,7 @@
|
|||
"sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
|
||||
"dev": true
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
|
||||
},
|
||||
"sshpk": {
|
||||
"version": "1.16.1",
|
||||
|
@ -7578,6 +7602,14 @@
|
|||
"diff-match-patch": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"vue-highlightjs": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-highlightjs/-/vue-highlightjs-1.3.3.tgz",
|
||||
"integrity": "sha1-KaDVcTL8HOFc+mHolpGPW3GMXVI=",
|
||||
"requires": {
|
||||
"highlight.js": "*"
|
||||
}
|
||||
},
|
||||
"vue-hot-reload-api": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.3.tgz",
|
||||
|
@ -7604,6 +7636,11 @@
|
|||
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
|
||||
"dev": true
|
||||
},
|
||||
"vuex": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz",
|
||||
"integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg=="
|
||||
},
|
||||
"w3c-hr-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "rimraf dist && parcel build src/index.html --out-dir dist",
|
||||
"build": "rimraf dist && parcel build src/index.html --no-source-maps --out-dir dist",
|
||||
"build-debug": "rimraf dist && cross-env NODE_ENV=development parcel build src/index.html --no-minify --out-dir dist",
|
||||
"watch": "parcel src/index.html"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -12,18 +13,24 @@
|
|||
"babel-core": "^6.26.3",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"parcel-bundler": "^1.12.3",
|
||||
"sass": "^1.21.0",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"buefy": "^0.7.10",
|
||||
"bulma": "^0.7.5",
|
||||
"bulmaswatch": "^0.7.2",
|
||||
"js-cookie": "^2.2.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"marked": "^0.7.0",
|
||||
"vue": "^2.6.10",
|
||||
"vue-codemirror": "^4.0.6",
|
||||
"vue-highlightjs": "^1.3.3",
|
||||
"vue-hot-reload-api": "^2.3.3",
|
||||
"vue-router": "^3.0.6"
|
||||
"vue-router": "^3.0.6",
|
||||
"vuex": "^3.1.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Chrome versions"
|
||||
|
|
|
@ -10,8 +10,8 @@
|
|||
Zeppelin is a private moderation bot for Discord, designed with large servers and reliability in mind.
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="/login">Dashboard</a>
|
||||
<a class="btn disabled" href="#">Docs</a>
|
||||
<router-link class="btn" to="/login">Dashboard</router-link>
|
||||
<router-link class="btn" to="/docs">Documentation</router-link>
|
||||
</div>
|
||||
<div class="error" v-if="error">
|
||||
<strong>Error</strong>
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
import "codemirror/lib/codemirror.css";
|
||||
import "codemirror/theme/oceanic-next.css";
|
||||
import "codemirror/mode/yaml/yaml.js";
|
||||
import {ApiError} from "../api";
|
||||
import {ApiError} from "../../api";
|
||||
|
||||
export default {
|
||||
components: {
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard dashboard-cloak">
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -12,7 +12,9 @@
|
|||
<div class="navbar-menu is-active">
|
||||
<div class="navbar-start">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -28,6 +30,11 @@
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-cloak {
|
||||
/* Replaced by "visible" in dashboard.scss */
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dashboard-logo {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
@ -38,10 +45,9 @@
|
|||
</style>
|
||||
|
||||
<script>
|
||||
import "../../style/dashboard.scss";
|
||||
|
||||
export default {
|
||||
async mounted() {
|
||||
await import("../style/dashboard.scss");
|
||||
},
|
||||
methods: {
|
||||
async logout() {
|
||||
await this.$store.dispatch("auth/logout");
|
64
dashboard/src/components/docs/ArgumentTypes.vue
Normal file
64
dashboard/src/components/docs/ArgumentTypes.vue
Normal 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>
|
22
dashboard/src/components/docs/CodeBlock.vue
Normal file
22
dashboard/src/components/docs/CodeBlock.vue
Normal 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>
|
42
dashboard/src/components/docs/ConfigurationFormat.vue
Normal file
42
dashboard/src/components/docs/ConfigurationFormat.vue
Normal 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>
|
30
dashboard/src/components/docs/Introduction.vue
Normal file
30
dashboard/src/components/docs/Introduction.vue
Normal 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>
|
155
dashboard/src/components/docs/Layout.vue
Normal file
155
dashboard/src/components/docs/Layout.vue
Normal 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>
|
88
dashboard/src/components/docs/Permissions.vue
Normal file
88
dashboard/src/components/docs/Permissions.vue
Normal 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>
|
132
dashboard/src/components/docs/Plugin.vue
Normal file
132
dashboard/src/components/docs/Plugin.vue
Normal 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>
|
83
dashboard/src/components/docs/PluginConfiguration.vue
Normal file
83
dashboard/src/components/docs/PluginConfiguration.vue
Normal 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>
|
60
dashboard/src/components/docs/descriptions/ArgumentTypes.vue
Normal file
60
dashboard/src/components/docs/descriptions/ArgumentTypes.vue
Normal 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>
|
3
dashboard/src/components/docs/descriptions/Layout.vue
Normal file
3
dashboard/src/components/docs/descriptions/Layout.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
3
dashboard/src/components/docs/plugins/Layout.vue
Normal file
3
dashboard/src/components/docs/plugins/Layout.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
94
dashboard/src/components/docs/plugins/LocateUser.vue
Normal file
94
dashboard/src/components/docs/plugins/LocateUser.vue
Normal 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><User></code> The user we want to find</li>
|
||||
</ul>
|
||||
<p>
|
||||
Sends an instant invite to the voice channel the user from the <code><User></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 <user></code></li>
|
||||
<li><code>!vcalert <user> [delay] [reminderString]</code></li>
|
||||
</ul>
|
||||
|
||||
Arguments:
|
||||
<ul>
|
||||
<li><code><user></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>
|
67
dashboard/src/components/docs/plugins/ModActions.vue
Normal file
67
dashboard/src/components/docs/plugins/ModActions.vue
Normal 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>
|
11
dashboard/src/directives/trim-code.ts
Normal file
11
dashboard/src/directives/trim-code.ts
Normal 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");
|
||||
},
|
||||
});
|
|
@ -1,6 +1,8 @@
|
|||
import "./style/base.scss";
|
||||
import "buefy/dist/buefy.css";
|
||||
|
||||
import Vue from "vue";
|
||||
import Buefy from "buefy";
|
||||
import { RootStore } from "./store";
|
||||
import { router } from "./routes";
|
||||
|
||||
|
@ -18,8 +20,8 @@ Vue.mixin({
|
|||
});
|
||||
|
||||
import App from "./components/App.vue";
|
||||
import Login from "./components/Login.vue";
|
||||
|
||||
Vue.use(Buefy);
|
||||
const app = new Vue({
|
||||
router,
|
||||
store: RootStore,
|
||||
|
|
|
@ -14,21 +14,65 @@ export const router = new VueRouter({
|
|||
{ path: "/login", beforeEnter: authRedirectGuard },
|
||||
{ path: "/login-callback", beforeEnter: loginCallbackGuard },
|
||||
|
||||
// Docs
|
||||
{
|
||||
path: "/docs",
|
||||
component: () => import("./components/docs/Layout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./components/docs/Introduction.vue"),
|
||||
},
|
||||
{
|
||||
path: "configuration-format",
|
||||
component: () => import("./components/docs/ConfigurationFormat.vue"),
|
||||
},
|
||||
{
|
||||
path: "permissions",
|
||||
component: () => import("./components/docs/Permissions.vue"),
|
||||
},
|
||||
{
|
||||
path: "plugin-configuration",
|
||||
component: () => import("./components/docs/PluginConfiguration.vue"),
|
||||
},
|
||||
{
|
||||
path: "descriptions/argument-types",
|
||||
component: () => import("./components/docs/ArgumentTypes.vue"),
|
||||
},
|
||||
{
|
||||
path: "plugins/:pluginName",
|
||||
component: () => import("./components/docs/Plugin.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
{
|
||||
path: "/dashboard",
|
||||
component: () => import("./components/Dashboard.vue"),
|
||||
component: () => import("./components/dashboard/Layout.vue"),
|
||||
beforeEnter: authGuard,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./components/DashboardGuildList.vue"),
|
||||
component: () => import("./components/dashboard/GuildList.vue"),
|
||||
},
|
||||
{
|
||||
path: "guilds/:guildId/config",
|
||||
component: () => import("./components/DashboardGuildConfigEditor.vue"),
|
||||
component: () => import("./components/dashboard/GuildConfigEditor.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (to.hash) {
|
||||
return {
|
||||
selector: to.hash,
|
||||
};
|
||||
} else if (savedPosition) {
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
54
dashboard/src/store/docs.ts
Normal file
54
dashboard/src/store/docs.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
};
|
|
@ -6,23 +6,27 @@ Vue.use(Vuex);
|
|||
import { RootState } from "./types";
|
||||
import { AuthStore } from "./auth";
|
||||
import { GuildStore } from "./guilds";
|
||||
import { DocsStore } from "./docs";
|
||||
|
||||
export const RootStore = new Vuex.Store<RootState>({
|
||||
modules: {
|
||||
auth: AuthStore,
|
||||
guilds: GuildStore,
|
||||
docs: DocsStore,
|
||||
},
|
||||
});
|
||||
|
||||
// Set up typings so Vue/our components know about the state's types
|
||||
declare module "vue/types/options" {
|
||||
interface ComponentOptions<V extends Vue> {
|
||||
// @ts-ignore
|
||||
store?: Store<RootState>;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "vue/types/vue" {
|
||||
interface Vue {
|
||||
// @ts-ignore
|
||||
$store: Store<RootState>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,29 @@ export interface GuildState {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ThinDocsPlugin {
|
||||
name: string;
|
||||
info: {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DocsPlugin extends ThinDocsPlugin {
|
||||
commands: any[];
|
||||
}
|
||||
|
||||
export interface DocsState {
|
||||
allPlugins: ThinDocsPlugin[];
|
||||
loadingAllPlugins: boolean;
|
||||
|
||||
plugins: {
|
||||
[key: string]: DocsPlugin;
|
||||
};
|
||||
}
|
||||
|
||||
export type RootState = {
|
||||
auth: AuthState;
|
||||
guilds: GuildState;
|
||||
docs: DocsState;
|
||||
};
|
||||
|
|
|
@ -3,3 +3,7 @@ $family-primary: 'Open Sans', sans-serif;
|
|||
@import "~bulmaswatch/superhero/_variables";
|
||||
@import "~bulma/bulma";
|
||||
@import "~bulmaswatch/superhero/_overrides";
|
||||
|
||||
.dashboard-cloak {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
|
44
dashboard/src/style/docs.scss
Normal file
44
dashboard/src/style/docs.scss
Normal 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
45
package-lock.json
generated
|
@ -8299,9 +8299,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"knub": {
|
||||
"version": "20.3.1",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-20.3.1.tgz",
|
||||
"integrity": "sha512-aSLCvP6CM5aNxtXCABdctTwU0XylPqpP5g2RL1qccvHyDF36GCuahBy8fkGB6RfnSCTHN9sABeGvM4Qidgo/rw==",
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-22.0.0.tgz",
|
||||
"integrity": "sha512-QHMqSS8eVBVX0vMff8lEkWhO7mOVXdobUrNOuAMI7ldto0Aakf0oNdDnwRXFj0yNb5Sp1fvzYFt35nsx/ORqkw==",
|
||||
"requires": {
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"lodash.at": "^4.6.0",
|
||||
|
@ -9882,13 +9882,23 @@
|
|||
"requires": {
|
||||
"extend-shallow": "^3.0.2",
|
||||
"safe-regex": "^1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"safe-regex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
|
||||
"integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ret": "~0.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"regexp-tree": {
|
||||
"version": "0.1.11",
|
||||
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.11.tgz",
|
||||
"integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-7/l/DgapVVDzZobwMCCgMlqiqyLFJ0cduo/j+3BcDJIB+yJdsYCfKuI3l/04NV+H/rfNRdPIDbXNZHM9XvQatg=="
|
||||
},
|
||||
"regexpu-core": {
|
||||
"version": "4.5.4",
|
||||
|
@ -10122,12 +10132,11 @@
|
|||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"safe-regex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
|
||||
"integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
|
||||
"dev": true,
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.0.2.tgz",
|
||||
"integrity": "sha512-rRALJT0mh4qVFIJ9HvfjKDN77F9vp7kltOpFFI/8e6oKyHFmmxz4aSkY/YVauRDe7U0RrHdw9Lsxdel3E19s0A==",
|
||||
"requires": {
|
||||
"ret": "~0.1.10"
|
||||
"regexp-tree": "~0.1.1"
|
||||
}
|
||||
},
|
||||
"safer-buffer": {
|
||||
|
@ -10887,6 +10896,17 @@
|
|||
"extend-shallow": "^3.0.2",
|
||||
"regex-not": "^1.0.2",
|
||||
"safe-regex": "^1.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"safe-regex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
|
||||
"integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ret": "~0.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"to-regex-range": {
|
||||
|
@ -11373,11 +11393,6 @@
|
|||
"extsprintf": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"vuex": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz",
|
||||
"integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg=="
|
||||
},
|
||||
"w3c-hr-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
"humanize-duration": "^3.15.0",
|
||||
"io-ts": "^2.0.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"knub": "^20.3.1",
|
||||
"knub": "^22.0.0",
|
||||
"last-commit-log": "^2.1.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
|
@ -44,14 +44,14 @@
|
|||
"passport-custom": "^1.0.5",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"reflect-metadata": "^0.1.12",
|
||||
"safe-regex": "^2.0.2",
|
||||
"seedrandom": "^3.0.1",
|
||||
"tlds": "^1.203.1",
|
||||
"tmp": "0.0.33",
|
||||
"ts-node": "^3.3.0",
|
||||
"typeorm": "^0.2.14",
|
||||
"typescript": "^3.5.3",
|
||||
"uuid": "^3.3.2",
|
||||
"vuex": "^3.1.1"
|
||||
"uuid": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.5.5",
|
||||
|
@ -84,6 +84,7 @@
|
|||
},
|
||||
"lint-staged": {
|
||||
"*.ts": [
|
||||
"tslint",
|
||||
"prettier --write",
|
||||
"git add"
|
||||
]
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { SECONDS } from "./utils";
|
||||
|
||||
type QueueFn = (...args: any[]) => Promise<any>;
|
||||
|
||||
const DEFAULT_TIMEOUT = 10 * 1000;
|
||||
const DEFAULT_TIMEOUT = 10 * SECONDS;
|
||||
|
||||
export class Queue {
|
||||
protected running: boolean = false;
|
||||
|
|
60
src/SimpleCache.ts
Normal file
60
src/SimpleCache.ts
Normal 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
122
src/api/docs.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
|
@ -4,13 +4,22 @@ import cors from "cors";
|
|||
import { initAuth } from "./auth";
|
||||
import { initGuildsAPI } from "./guilds";
|
||||
import { initArchives } from "./archives";
|
||||
import { initDocs } from "./docs";
|
||||
import { connect } from "../data/db";
|
||||
import path from "path";
|
||||
import { TokenError } from "passport-oauth2";
|
||||
import { PluginError } from "knub";
|
||||
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "..", "..", "api.env") });
|
||||
|
||||
console.log("Connecting to database...");
|
||||
function errorHandler(err) {
|
||||
console.error(err.stack || err); // tslint:disable-line:no-console
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", errorHandler);
|
||||
|
||||
console.log("Connecting to database..."); // tslint:disable-line
|
||||
connect().then(() => {
|
||||
const app = express();
|
||||
|
||||
|
@ -24,6 +33,7 @@ connect().then(() => {
|
|||
initAuth(app);
|
||||
initGuildsAPI(app);
|
||||
initArchives(app);
|
||||
initDocs(app);
|
||||
|
||||
// Default route
|
||||
app.get("/", (req, res) => {
|
||||
|
@ -35,7 +45,7 @@ connect().then(() => {
|
|||
if (err instanceof TokenError) {
|
||||
clientError(res, "Invalid code");
|
||||
} else {
|
||||
console.error(err);
|
||||
console.error(err); // tslint:disable-line
|
||||
error(res, "Server error", err.status || 500);
|
||||
}
|
||||
});
|
||||
|
@ -46,5 +56,6 @@ connect().then(() => {
|
|||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
// tslint:disable-next-line
|
||||
app.listen(port, () => console.log(`API server listening on port ${port}`));
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ import { fold } from "fp-ts/lib/Either";
|
|||
import { PathReporter } from "io-ts/lib/PathReporter";
|
||||
import { availablePlugins } from "./plugins/availablePlugins";
|
||||
import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
|
||||
import { validateStrict } from "./validatorUtils";
|
||||
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
||||
|
||||
const pluginNameToClass = new Map<string, typeof ZeppelinPlugin>();
|
||||
for (const pluginClass of availablePlugins) {
|
||||
|
@ -29,8 +29,8 @@ const globalConfigRootSchema = t.type({
|
|||
const partialMegaTest = t.partial({ name: t.string });
|
||||
|
||||
export function validateGuildConfig(config: any): string[] | null {
|
||||
const rootErrors = validateStrict(partialGuildConfigRootSchema, config);
|
||||
if (rootErrors) return rootErrors;
|
||||
const validationResult = decodeAndValidateStrict(partialGuildConfigRootSchema, config);
|
||||
if (validationResult instanceof StrictValidationError) return validationResult.getErrors();
|
||||
|
||||
if (config.plugins) {
|
||||
for (const [pluginName, pluginOptions] of Object.entries(config.plugins)) {
|
||||
|
|
|
@ -147,6 +147,9 @@ export class GuildSavedMessages extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async createFromMsg(msg: Message, overrides = {}) {
|
||||
const existingSavedMsg = await this.find(msg.id);
|
||||
if (existingSavedMsg) return;
|
||||
|
||||
const savedMessageData = this.msgToSavedMessageData(msg);
|
||||
const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS");
|
||||
|
||||
|
|
|
@ -78,7 +78,7 @@ import { AllowedGuilds } from "./data/AllowedGuilds";
|
|||
logger.info("Connecting to database");
|
||||
connect().then(async conn => {
|
||||
const client = new Client(`Bot ${process.env.TOKEN}`, {
|
||||
getAllUsers: true,
|
||||
getAllUsers: false,
|
||||
restMode: true,
|
||||
});
|
||||
client.setMaxListeners(100);
|
||||
|
|
|
@ -4,7 +4,7 @@ import { SavedMessage } from "../data/entities/SavedMessage";
|
|||
import { GuildAutoReactions } from "../data/GuildAutoReactions";
|
||||
import { Message } from "eris";
|
||||
import { customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import * as t from "io-ts";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
|
@ -14,14 +14,21 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "auto_reactions";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Auto-reactions",
|
||||
description: trimPluginDescription(`
|
||||
Allows setting up automatic reactions to all new messages on a channel
|
||||
`),
|
||||
};
|
||||
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
protected autoReactions: GuildAutoReactions;
|
||||
|
||||
private onMessageCreateFn;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_manage: false,
|
||||
|
@ -49,7 +56,7 @@ export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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")
|
||||
async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) {
|
||||
const finalReactions = [];
|
||||
|
@ -83,7 +90,7 @@ export class AutoReactionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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")
|
||||
async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) {
|
||||
const autoReaction = await this.autoReactions.getForChannel(args.channelId);
|
||||
|
|
872
src/plugins/Automod.ts
Normal file
872
src/plugins/Automod.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,32 +1,57 @@
|
|||
import { decorators as d, GlobalPlugin, IPluginOptions } from "knub";
|
||||
import { decorators as d, IPluginOptions } from "knub";
|
||||
import child_process from "child_process";
|
||||
import { GuildChannel, Message, TextChannel } from "eris";
|
||||
import { createChunkedMessage, errorMessage, noop, sorter, successMessage } from "../utils";
|
||||
import moment from "moment-timezone";
|
||||
import { createChunkedMessage, errorMessage, noop, sorter, successMessage, tNullable } from "../utils";
|
||||
import { ReactionRolesPlugin } from "./ReactionRoles";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { GuildArchives } from "../data/GuildArchives";
|
||||
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
|
||||
import * as t from "io-ts";
|
||||
|
||||
let activeReload: [string, string] = null;
|
||||
|
||||
interface IBotControlPluginConfig {
|
||||
owners: string[];
|
||||
update_cmd: string;
|
||||
}
|
||||
const ConfigSchema = t.type({
|
||||
can_use: t.boolean,
|
||||
owners: t.array(t.string),
|
||||
update_cmd: tNullable(t.string),
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
/**
|
||||
* 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 configSchema = ConfigSchema;
|
||||
|
||||
getDefaultOptions(): IPluginOptions<IBotControlPluginConfig> {
|
||||
protected archives: GuildArchives;
|
||||
|
||||
public static getStaticDefaultOptions() {
|
||||
return {
|
||||
config: {
|
||||
can_use: false,
|
||||
owners: [],
|
||||
update_cmd: null,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
level: ">=100",
|
||||
config: {
|
||||
can_use: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
protected getMemberLevel(member) {
|
||||
return this.isOwner(member.id) ? 100 : 0;
|
||||
}
|
||||
|
||||
async onLoad() {
|
||||
this.archives = new GuildArchives(0);
|
||||
|
||||
if (activeReload) {
|
||||
const [guildId, channelId] = activeReload;
|
||||
activeReload = null;
|
||||
|
@ -41,14 +66,9 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
}
|
||||
|
||||
isOwner(userId) {
|
||||
return this.getConfig().owners.includes(userId);
|
||||
}
|
||||
|
||||
@d.command("bot_full_update")
|
||||
@d.permission("can_use")
|
||||
async fullUpdateCmd(msg: Message) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
|
||||
const updateCmd = this.getConfig().update_cmd;
|
||||
if (!updateCmd) {
|
||||
msg.channel.createMessage(errorMessage("Update command not specified!"));
|
||||
|
@ -64,8 +84,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
|
||||
@d.command("bot_reload_global_plugins")
|
||||
@d.permission("can_use")
|
||||
async reloadGlobalPluginsCmd(msg: Message) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
if (activeReload) return;
|
||||
|
||||
if (msg.channel) {
|
||||
|
@ -77,8 +97,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
|
||||
@d.command("perf")
|
||||
@d.permission("can_use")
|
||||
async perfCmd(msg: Message) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
const perfItems = this.knub.getPerformanceDebugItems();
|
||||
|
||||
if (perfItems.length) {
|
||||
|
@ -90,9 +110,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
|
||||
@d.command("refresh_reaction_roles_globally")
|
||||
@d.permission("can_use")
|
||||
async refreshAllReactionRolesCmd(msg: Message) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
|
||||
const guilds = this.knub.getLoadedGuilds();
|
||||
for (const guild of guilds) {
|
||||
if (guild.loadedPlugins.has("reaction_roles")) {
|
||||
|
@ -103,9 +122,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
|
||||
@d.command("guilds")
|
||||
@d.permission("can_use")
|
||||
async serversCmd(msg: Message) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
|
||||
const joinedGuilds = Array.from(this.bot.guilds.values());
|
||||
const loadedGuilds = this.knub.getLoadedGuilds();
|
||||
const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.id, guildData), new Map());
|
||||
|
@ -122,9 +140,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
|
||||
@d.command("leave_guild", "<guildId:string>")
|
||||
@d.permission("can_use")
|
||||
async leaveGuildCmd(msg: Message, args: { guildId: string }) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
|
||||
if (!this.bot.guilds.has(args.guildId)) {
|
||||
msg.channel.createMessage(errorMessage("I am not in that guild"));
|
||||
return;
|
||||
|
@ -144,9 +161,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
|
||||
@d.command("reload_guild", "<guildId:string>")
|
||||
@d.permission("can_use")
|
||||
async reloadGuildCmd(msg: Message, args: { guildId: string }) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
|
||||
if (!this.bot.guilds.has(args.guildId)) {
|
||||
msg.channel.createMessage(errorMessage("I am not in that guild"));
|
||||
return;
|
||||
|
@ -164,9 +180,8 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
}
|
||||
|
||||
@d.command("reload_all_guilds")
|
||||
@d.permission("can_use")
|
||||
async reloadAllGuilds(msg: Message) {
|
||||
if (!this.isOwner(msg.author.id)) return;
|
||||
|
||||
const failedReloads: Map<string, string> = new Map();
|
||||
let reloadCount = 0;
|
||||
|
||||
|
@ -191,4 +206,29 @@ export class BotControlPlugin extends GlobalPlugin<IBotControlPluginConfig> {
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { CaseTypes } from "../data/CaseTypes";
|
|||
import { Case } from "../data/entities/Case";
|
||||
import moment from "moment-timezone";
|
||||
import { CaseTypeColors } from "../data/CaseTypeColors";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { GuildArchives } from "../data/GuildArchives";
|
||||
import { IPluginOptions } from "knub";
|
||||
import { GuildLogs } from "../data/GuildLogs";
|
||||
|
@ -45,13 +45,20 @@ export type CaseNoteArgs = {
|
|||
|
||||
export class CasesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "cases";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Cases",
|
||||
description: trimPluginDescription(`
|
||||
This plugin contains basic configuration for cases created by other plugins
|
||||
`),
|
||||
};
|
||||
|
||||
protected cases: GuildCases;
|
||||
protected archives: GuildArchives;
|
||||
protected logs: GuildLogs;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
log_automatic_actions: true,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IPluginOptions } from "knub";
|
||||
import { IPluginOptions, logger } from "knub";
|
||||
import { Invite, Embed } from "eris";
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
import { GuildLogs } from "../data/GuildLogs";
|
||||
|
@ -14,9 +14,10 @@ import {
|
|||
import { ZalgoRegex } from "../data/Zalgo";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
import * as t from "io-ts";
|
||||
import { TSafeRegex } from "../validatorUtils";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
filter_zalgo: t.boolean,
|
||||
|
@ -31,12 +32,20 @@ const ConfigSchema = t.type({
|
|||
domain_blacklist: tNullable(t.array(t.string)),
|
||||
blocked_tokens: tNullable(t.array(t.string)),
|
||||
blocked_words: tNullable(t.array(t.string)),
|
||||
blocked_regex: tNullable(t.array(t.string)),
|
||||
blocked_regex: tNullable(t.array(TSafeRegex)),
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
export class CensorPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "censor";
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Censor",
|
||||
description: trimPluginDescription(`
|
||||
Censor words, tokens, links, regex, etc.
|
||||
`),
|
||||
};
|
||||
|
||||
protected serverLogs: GuildLogs;
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
|
@ -44,7 +53,7 @@ export class CensorPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
private onMessageCreateFn;
|
||||
private onMessageUpdateFn;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
filter_zalgo: false,
|
||||
|
@ -236,12 +245,20 @@ export class CensorPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
// Filter regex
|
||||
const blockedRegex = config.blocked_regex || [];
|
||||
for (const regexStr of blockedRegex) {
|
||||
const regex = new RegExp(regexStr, "i");
|
||||
const blockedRegex: RegExp[] = config.blocked_regex || [];
|
||||
for (const [i, regex] of blockedRegex.entries()) {
|
||||
if (typeof regex.test !== "function") {
|
||||
logger.info(
|
||||
`[DEBUG] Regex <${regex}> was not a regex; index ${i} of censor.blocked_regex for guild ${this.guild.name} (${
|
||||
this.guild.id
|
||||
})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly
|
||||
if (regex.test(savedMessage.data.content) || regex.test(messageContent)) {
|
||||
this.censorMessage(savedMessage, `blocked regex (\`${regexStr}\`) found`);
|
||||
this.censorMessage(savedMessage, `blocked regex (\`${regex.source}\`) found`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
152
src/plugins/ChannelArchiver.ts
Normal file
152
src/plugins/ChannelArchiver.ts
Normal 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`,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { decorators as d, IPluginOptions, logger } from "knub";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { Member, Channel, GuildChannel, PermissionOverwrite, Permission, Message, TextChannel } from "eris";
|
||||
import * as t from "io-ts";
|
||||
import { tNullable } from "../utils";
|
||||
|
@ -28,9 +28,18 @@ const defaultCompanionChannelOpts: Partial<TCompanionChannelOpts> = {
|
|||
|
||||
export class CompanionChannelPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
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 {
|
||||
config: {
|
||||
entries: {},
|
||||
|
|
|
@ -70,12 +70,13 @@ class ActionError extends Error {}
|
|||
|
||||
export class CustomEventsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "custom_events";
|
||||
public static showInDocs = false;
|
||||
public static dependencies = ["cases"];
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
private clearTriggers: () => void;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
events: {},
|
||||
|
@ -162,7 +163,7 @@ export class CustomEventsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
||||
await casesPlugin.createCase({
|
||||
userId: targetId,
|
||||
modId: modId,
|
||||
modId,
|
||||
type: CaseTypes[action.case_type],
|
||||
reason: `__[${event.name}]__ ${reason}`,
|
||||
});
|
||||
|
|
|
@ -4,36 +4,115 @@ import * as t from "io-ts";
|
|||
import { pipe } from "fp-ts/lib/pipeable";
|
||||
import { fold } from "fp-ts/lib/Either";
|
||||
import { PathReporter } from "io-ts/lib/PathReporter";
|
||||
import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils";
|
||||
import { deepKeyIntersect, isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils";
|
||||
import { Member, User } from "eris";
|
||||
import { performance } from "perf_hooks";
|
||||
import { validateStrict } from "../validatorUtils";
|
||||
import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils";
|
||||
import { mergeConfig } from "knub/dist/configUtils";
|
||||
|
||||
const SLOW_RESOLVE_THRESHOLD = 1500;
|
||||
|
||||
export class GlobalZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends GlobalPlugin<TConfig> {
|
||||
protected static configSchema: t.TypeC<any>;
|
||||
public static configSchema: t.TypeC<any>;
|
||||
public static dependencies = [];
|
||||
|
||||
public static validateOptions(options: IPluginOptions): string[] | null {
|
||||
// Validate config values
|
||||
if (this.configSchema) {
|
||||
if (options.config) {
|
||||
const errors = validateStrict(this.configSchema, options.config);
|
||||
if (errors) return errors;
|
||||
/**
|
||||
* Since we want to do type checking without creating instances of every plugin,
|
||||
* we need a static version of getDefaultOptions(). This static version is then,
|
||||
* by turn, called from getDefaultOptions() so everything still works as expected.
|
||||
*/
|
||||
public static getStaticDefaultOptions() {
|
||||
// Implemented by plugin
|
||||
return {};
|
||||
}
|
||||
|
||||
if (options.overrides) {
|
||||
for (const override of options.overrides) {
|
||||
if (override.config) {
|
||||
const errors = validateStrict(this.configSchema, override.config);
|
||||
if (errors) return errors;
|
||||
/**
|
||||
* Wrapper to fetch the real default options from getStaticDefaultOptions()
|
||||
*/
|
||||
protected getDefaultOptions(): IPluginOptions<TConfig> {
|
||||
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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ const CHECK_INTERVAL = 1000;
|
|||
*/
|
||||
export class GuildConfigReloader extends GlobalZeppelinPlugin {
|
||||
public static pluginName = "guild_config_reloader";
|
||||
|
||||
protected guildConfigs: Configs;
|
||||
private unloaded = false;
|
||||
private highestConfigId;
|
||||
|
|
|
@ -4,6 +4,7 @@ import { MINUTES } from "../utils";
|
|||
|
||||
export class GuildInfoSaverPlugin extends ZeppelinPlugin {
|
||||
public static pluginName = "guild_info_saver";
|
||||
public static showInDocs = false;
|
||||
protected allowedGuilds: AllowedGuilds;
|
||||
private updateInterval;
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
import { Message, Member, Guild, TextableChannel, VoiceChannel, Channel, User } from "eris";
|
||||
import { GuildVCAlerts } from "../data/GuildVCAlerts";
|
||||
import moment = require("moment");
|
||||
import { resolveMember, sorter, createChunkedMessage, errorMessage, successMessage } from "../utils";
|
||||
import moment from "moment-timezone";
|
||||
import { resolveMember, sorter, createChunkedMessage, errorMessage, successMessage, MINUTES } from "../utils";
|
||||
import * as t from "io-ts";
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
|
@ -17,13 +17,22 @@ const ALERT_LOOP_TIME = 30 * 1000;
|
|||
|
||||
export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "locate_user";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Locate user",
|
||||
description: trimPluginDescription(`
|
||||
This plugin allows users with access to the commands the following:
|
||||
* Instantly receive an invite to the voice channel of a user
|
||||
* Be notified as soon as a user switches or joins a voice channel
|
||||
`),
|
||||
};
|
||||
|
||||
private alerts: GuildVCAlerts;
|
||||
private outdatedAlertsTimeout;
|
||||
private usersWithAlerts: string[] = [];
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_where: false,
|
||||
|
@ -52,7 +61,7 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
for (const alert of outdatedAlerts) {
|
||||
await this.alerts.delete(alert.id);
|
||||
await this.removeUserIDFromActiveAlerts(alert.user_id);
|
||||
await this.removeUserIdFromActiveAlerts(alert.user_id);
|
||||
}
|
||||
|
||||
this.outdatedAlertsTimeout = setTimeout(() => this.outdatedAlertsLoop(), ALERT_LOOP_TIME);
|
||||
|
@ -68,21 +77,28 @@ export class LocatePlugin extends ZeppelinPlugin<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")
|
||||
async whereCmd(msg: Message, args: { member: Member; time?: number; reminder?: string }) {
|
||||
let member = await resolveMember(this.bot, this.guild, args.member.id);
|
||||
const member = await resolveMember(this.bot, this.guild, args.member.id);
|
||||
sendWhere(this.guild, member, msg.channel, `${msg.member.mention} |`);
|
||||
}
|
||||
|
||||
@d.command("vcalert", "<member:resolvedMember> [duration:delay] [reminder:string$]", {
|
||||
aliases: ["vca"],
|
||||
info: {
|
||||
description: "Sets up an alert that notifies you any time `<member>` switches or joins voice channels",
|
||||
},
|
||||
})
|
||||
@d.permission("can_alert")
|
||||
async vcalertCmd(msg: Message, args: { member: Member; duration?: number; reminder?: string }) {
|
||||
let time = args.duration || 600000;
|
||||
let alertTime = moment().add(time, "millisecond");
|
||||
let body = args.reminder || "None";
|
||||
const time = args.duration || 10 * MINUTES;
|
||||
const alertTime = moment().add(time, "millisecond");
|
||||
const body = args.reminder || "None";
|
||||
|
||||
this.alerts.add(msg.author.id, args.member.id, msg.channel.id, alertTime.format("YYYY-MM-DD HH:mm:ss"), body);
|
||||
if (!this.usersWithAlerts.includes(args.member.id)) {
|
||||
|
@ -137,7 +153,7 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
async userJoinedVC(member: Member, channel: Channel) {
|
||||
if (this.usersWithAlerts.includes(member.id)) {
|
||||
this.sendAlerts(member.id);
|
||||
await this.removeUserIDFromActiveAlerts(member.id);
|
||||
await this.removeUserIdFromActiveAlerts(member.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -145,7 +161,7 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) {
|
||||
if (this.usersWithAlerts.includes(member.id)) {
|
||||
this.sendAlerts(member.id);
|
||||
await this.removeUserIDFromActiveAlerts(member.id);
|
||||
await this.removeUserIdFromActiveAlerts(member.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,21 +173,21 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
});
|
||||
}
|
||||
|
||||
async sendAlerts(userid: string) {
|
||||
const triggeredAlerts = await this.alerts.getAlertsByUserId(userid);
|
||||
const member = await resolveMember(this.bot, this.guild, userid);
|
||||
async sendAlerts(userId: string) {
|
||||
const triggeredAlerts = await this.alerts.getAlertsByUserId(userId);
|
||||
const member = await resolveMember(this.bot, this.guild, userId);
|
||||
|
||||
triggeredAlerts.forEach(alert => {
|
||||
let prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${
|
||||
const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${
|
||||
alert.body
|
||||
}\`\n`;
|
||||
sendWhere(this.guild, member, <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);
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserIDFromActiveAlerts(userid: string) {
|
||||
const index = this.usersWithAlerts.indexOf(userid);
|
||||
async removeUserIdFromActiveAlerts(userId: string) {
|
||||
const index = this.usersWithAlerts.indexOf(userId);
|
||||
if (index > -1) {
|
||||
this.usersWithAlerts.splice(index, 1);
|
||||
}
|
||||
|
@ -179,12 +195,12 @@ export class LocatePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
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) {
|
||||
channel.createMessage(prepend + "That user is not in a channel");
|
||||
} else {
|
||||
let invite = await createInvite(voice);
|
||||
const invite = await createInvite(voice);
|
||||
channel.createMessage(
|
||||
prepend + ` ${member.mention} is in the following channel: ${voice.name} https://${getInviteLink(invite)}`,
|
||||
);
|
||||
|
@ -192,7 +208,7 @@ export async function sendWhere(guild: Guild, member: Member, channel: TextableC
|
|||
}
|
||||
|
||||
export async function createInvite(vc: VoiceChannel) {
|
||||
let existingInvites = await vc.getInvites();
|
||||
const existingInvites = await vc.getInvites();
|
||||
|
||||
if (existingInvites.length !== 0) {
|
||||
return existingInvites[0];
|
||||
|
|
|
@ -22,7 +22,7 @@ import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import { GuildArchives } from "../data/GuildArchives";
|
||||
import { GuildCases } from "../data/GuildCases";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { renderTemplate, TemplateParseError } from "../templateFormatter";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
import * as t from "io-ts";
|
||||
|
@ -53,7 +53,11 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "logs";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Logs",
|
||||
};
|
||||
|
||||
protected guildLogs: GuildLogs;
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
|
@ -70,7 +74,7 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
private excludedUserProps = ["user", "member", "mod"];
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
channels: {},
|
||||
|
|
|
@ -12,11 +12,12 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class MessageSaverPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "message_saver";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static showInDocs = false;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_manage: false,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub";
|
||||
import { Attachment, Constants as ErisConstants, Guild, Member, Message, TextChannel, User } from "eris";
|
||||
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
|
||||
import DiscordHTTPError from "eris/lib/errors/DiscordHTTPError"; // tslint:disable-line
|
||||
import humanizeDuration from "humanize-duration";
|
||||
import { GuildCases } from "../data/GuildCases";
|
||||
import {
|
||||
|
@ -15,6 +16,8 @@ import {
|
|||
stripObjectToScalars,
|
||||
successMessage,
|
||||
tNullable,
|
||||
trimEmptyStartEndLines,
|
||||
trimIndents,
|
||||
trimLines,
|
||||
ucfirst,
|
||||
UnknownUser,
|
||||
|
@ -23,10 +26,10 @@ import { GuildMutes } from "../data/GuildMutes";
|
|||
import { CaseTypes } from "../data/CaseTypes";
|
||||
import { GuildLogs } from "../data/GuildLogs";
|
||||
import { LogType } from "../data/LogType";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { Case } from "../data/entities/Case";
|
||||
import { renderTemplate } from "../templateFormatter";
|
||||
import { CasesPlugin } from "./Cases";
|
||||
import { CaseArgs, CasesPlugin } from "./Cases";
|
||||
import { MuteResult, MutesPlugin } from "./Mutes";
|
||||
import * as t from "io-ts";
|
||||
|
||||
|
@ -67,10 +70,52 @@ interface IIgnoredEvent {
|
|||
userId: string;
|
||||
}
|
||||
|
||||
export type WarnResult =
|
||||
| {
|
||||
status: "failed";
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
case: Case;
|
||||
notifyResult: INotifyUserResult;
|
||||
};
|
||||
|
||||
export type KickResult =
|
||||
| {
|
||||
status: "failed";
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
case: Case;
|
||||
notifyResult: INotifyUserResult;
|
||||
};
|
||||
|
||||
export type BanResult =
|
||||
| {
|
||||
status: "failed";
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
status: "success";
|
||||
case: Case;
|
||||
notifyResult: INotifyUserResult;
|
||||
};
|
||||
|
||||
type WarnMemberNotifyRetryCallback = () => boolean | Promise<boolean>;
|
||||
|
||||
export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "mod_actions";
|
||||
public static dependencies = ["cases", "mutes"];
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Mod actions",
|
||||
description: trimPluginDescription(`
|
||||
This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc.
|
||||
`),
|
||||
};
|
||||
|
||||
protected mutes: GuildMutes;
|
||||
protected cases: GuildCases;
|
||||
|
@ -86,7 +131,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.ignoredEvents = [];
|
||||
}
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
dm_on_warn: true,
|
||||
|
@ -161,8 +206,16 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
async isBanned(userId): Promise<boolean> {
|
||||
try {
|
||||
const bans = (await this.guild.getBans()) as any;
|
||||
return bans.some(b => b.user.id === userId);
|
||||
} catch (e) {
|
||||
if (e instanceof DiscordHTTPError && e.code === 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async findRelevantAuditLogEntry(actionType: number, userId: string, attempts?: number, attemptDelay?: number) {
|
||||
|
@ -319,10 +372,121 @@ export class ModActionsPlugin extends ZeppelinPlugin<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$]", {
|
||||
overloads: ["[note:string$]"],
|
||||
info: {
|
||||
description:
|
||||
"Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it",
|
||||
},
|
||||
})
|
||||
@d.permission("can_note")
|
||||
async updateCmd(msg: Message, args: { caseNumber?: number; note?: string }) {
|
||||
|
@ -362,7 +526,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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")
|
||||
async noteCmd(msg: Message, args: { user: string; note: string }) {
|
||||
const user = await this.resolveUser(args.user);
|
||||
|
@ -384,6 +552,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
@d.command("warn", "<user:string> <reason:string$>", {
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Send a warning to the specified user",
|
||||
},
|
||||
})
|
||||
@d.permission("can_warn")
|
||||
async warnCmd(msg: Message, args: { user: string; reason: string; mod?: Member }) {
|
||||
|
@ -424,37 +595,27 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
|
||||
|
||||
const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason);
|
||||
const warnResult = await this.warnMember(
|
||||
memberToWarn,
|
||||
warnMessage,
|
||||
{
|
||||
modId: mod.id,
|
||||
ppId: mod.id !== msg.author.id ? msg.author.id : null,
|
||||
},
|
||||
msg.channel as TextChannel,
|
||||
);
|
||||
|
||||
const userMessageResult = await notifyUser(this.bot, this.guild, memberToWarn.user, warnMessage, {
|
||||
useDM: config.dm_on_warn,
|
||||
useChannel: config.message_on_warn,
|
||||
});
|
||||
|
||||
if (userMessageResult.status === NotifyUserStatus.Failed) {
|
||||
const failedMsg = await msg.channel.createMessage("Failed to message the user. Log the warning anyway?");
|
||||
const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"], msg.author.id);
|
||||
failedMsg.delete();
|
||||
if (!reply || reply.name === "❌") {
|
||||
if (warnResult.status === "failed") {
|
||||
msg.channel.createMessage(errorMessage("Failed to warn user"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
||||
const createdCase = await casesPlugin.createCase({
|
||||
userId: memberToWarn.id,
|
||||
modId: mod.id,
|
||||
type: CaseTypes.Warn,
|
||||
reason,
|
||||
ppId: mod.id !== msg.author.id ? msg.author.id : null,
|
||||
noteDetails: userMessageResult.status !== NotifyUserStatus.Ignored ? [ucfirst(userMessageResult.text)] : [],
|
||||
});
|
||||
|
||||
const messageResultText = userMessageResult.text ? ` (${userMessageResult.text})` : "";
|
||||
const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : "";
|
||||
|
||||
msg.channel.createMessage(
|
||||
successMessage(
|
||||
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${
|
||||
createdCase.case_number
|
||||
warnResult.case.case_number
|
||||
})${messageResultText}`,
|
||||
),
|
||||
);
|
||||
|
@ -465,6 +626,61 @@ export class ModActionsPlugin extends ZeppelinPlugin<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 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,
|
||||
});
|
||||
} 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}`);
|
||||
msg.channel.createMessage(errorMessage("Could not mute the user"));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -536,6 +757,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
@d.command("mute", "<user:string> <time:delay> <reason:string$>", {
|
||||
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Mute the specified member",
|
||||
},
|
||||
})
|
||||
@d.permission("can_mute")
|
||||
async muteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) {
|
||||
|
@ -574,6 +798,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
@d.command("forcemute", "<user:string> <time:delay> <reason:string$>", {
|
||||
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Force-mute the specified user, even if they're not on the server",
|
||||
},
|
||||
})
|
||||
@d.permission("can_mute")
|
||||
async forcemuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) {
|
||||
|
@ -649,6 +876,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
@d.command("unmute", "<user:string> <time:delay> <reason:string$>", {
|
||||
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Unmute the specified member",
|
||||
},
|
||||
})
|
||||
@d.permission("can_mute")
|
||||
async unmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) {
|
||||
|
@ -691,6 +921,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
@d.command("forceunmute", "<user:string> <time:delay> <reason:string$>", {
|
||||
overloads: ["<user:string> <time:delay>", "<user:string> [reason:string$]"],
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Force-unmute the specified user, even if they're not on the server",
|
||||
},
|
||||
})
|
||||
@d.permission("can_mute")
|
||||
async forceunmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) {
|
||||
|
@ -717,6 +950,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
@d.command("kick", "<user:string> [reason:string$]", {
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Kick the specified member",
|
||||
},
|
||||
})
|
||||
@d.permission("can_kick")
|
||||
async kickCmd(msg, args: { user: string; reason: string; mod: Member }) {
|
||||
|
@ -753,57 +989,31 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
mod = args.mod;
|
||||
}
|
||||
|
||||
const config = this.getConfig();
|
||||
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
|
||||
|
||||
// Attempt to message the user *before* kicking them, as doing it after may not be possible
|
||||
let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
|
||||
if (args.reason) {
|
||||
const kickMessage = await renderTemplate(config.kick_message, {
|
||||
guildName: this.guild.name,
|
||||
reason,
|
||||
});
|
||||
|
||||
userMessageResult = await notifyUser(this.bot, this.guild, memberToKick.user, kickMessage, {
|
||||
useDM: config.dm_on_kick,
|
||||
useChannel: config.message_on_kick,
|
||||
channelId: config.message_channel,
|
||||
});
|
||||
}
|
||||
|
||||
// Kick the user
|
||||
this.serverLogs.ignoreLog(LogType.MEMBER_KICK, memberToKick.id);
|
||||
this.ignoreEvent(IgnoredEventType.Kick, memberToKick.id);
|
||||
memberToKick.kick(reason);
|
||||
|
||||
// Create a case for this action
|
||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
||||
const createdCase = await casesPlugin.createCase({
|
||||
userId: memberToKick.id,
|
||||
const kickResult = await this.kickMember(memberToKick, reason, {
|
||||
modId: mod.id,
|
||||
type: CaseTypes.Kick,
|
||||
reason,
|
||||
ppId: mod.id !== msg.author.id ? msg.author.id : null,
|
||||
noteDetails: userMessageResult.status !== NotifyUserStatus.Ignored ? [ucfirst(userMessageResult.text)] : [],
|
||||
});
|
||||
|
||||
if (kickResult.status === "failed") {
|
||||
msg.channel.createMessage(errorMessage(`Failed to kick user`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm the action to the moderator
|
||||
let response = `Kicked **${memberToKick.user.username}#${memberToKick.user.discriminator}** (Case #${
|
||||
createdCase.case_number
|
||||
kickResult.case.case_number
|
||||
})`;
|
||||
|
||||
if (userMessageResult.text) response += ` (${userMessageResult.text})`;
|
||||
if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`;
|
||||
msg.channel.createMessage(successMessage(response));
|
||||
|
||||
// Log the action
|
||||
this.serverLogs.log(LogType.MEMBER_KICK, {
|
||||
mod: stripObjectToScalars(mod.user),
|
||||
user: stripObjectToScalars(memberToKick.user),
|
||||
});
|
||||
}
|
||||
|
||||
@d.command("ban", "<user:string> [reason:string$]", {
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Ban the specified member",
|
||||
},
|
||||
})
|
||||
@d.permission("can_ban")
|
||||
async banCmd(msg, args: { user: string; reason?: string; mod?: Member }) {
|
||||
|
@ -840,57 +1050,32 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
mod = args.mod;
|
||||
}
|
||||
|
||||
const config = this.getConfig();
|
||||
const reason = this.formatReasonWithAttachments(args.reason, msg.attachments);
|
||||
|
||||
// Attempt to message the user *before* banning them, as doing it after may not be possible
|
||||
let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored };
|
||||
if (reason) {
|
||||
const banMessage = await renderTemplate(config.ban_message, {
|
||||
guildName: this.guild.name,
|
||||
reason,
|
||||
});
|
||||
|
||||
userMessageResult = await notifyUser(this.bot, this.guild, memberToBan.user, banMessage, {
|
||||
useDM: config.dm_on_ban,
|
||||
useChannel: config.message_on_ban,
|
||||
channelId: config.message_channel,
|
||||
});
|
||||
}
|
||||
|
||||
// Ban the user
|
||||
this.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToBan.id);
|
||||
this.ignoreEvent(IgnoredEventType.Ban, memberToBan.id);
|
||||
memberToBan.ban(1, reason);
|
||||
|
||||
// Create a case for this action
|
||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
||||
const createdCase = await casesPlugin.createCase({
|
||||
userId: memberToBan.id,
|
||||
const banResult = await this.banUserId(memberToBan.id, reason, {
|
||||
modId: mod.id,
|
||||
type: CaseTypes.Ban,
|
||||
reason,
|
||||
ppId: mod.id !== msg.author.id ? msg.author.id : null,
|
||||
noteDetails: userMessageResult.status !== NotifyUserStatus.Ignored ? [ucfirst(userMessageResult.text)] : [],
|
||||
});
|
||||
|
||||
if (banResult.status === "failed") {
|
||||
msg.channel.createMessage(errorMessage(`Failed to ban member`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm the action to the moderator
|
||||
let response = `Banned **${memberToBan.user.username}#${memberToBan.user.discriminator}** (Case #${
|
||||
createdCase.case_number
|
||||
banResult.case.case_number
|
||||
})`;
|
||||
|
||||
if (userMessageResult.text) response += ` (${userMessageResult.text})`;
|
||||
if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`;
|
||||
msg.channel.createMessage(successMessage(response));
|
||||
|
||||
// Log the action
|
||||
this.serverLogs.log(LogType.MEMBER_BAN, {
|
||||
mod: stripObjectToScalars(mod.user),
|
||||
user: stripObjectToScalars(memberToBan.user),
|
||||
});
|
||||
}
|
||||
|
||||
@d.command("softban", "<user:string> [reason:string$]", {
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description:
|
||||
'"Softban" the specified user by banning and immediately unbanning them. Effectively a kick with message deletions.',
|
||||
},
|
||||
})
|
||||
@d.permission("can_ban")
|
||||
async softbanCmd(msg, args: { user: string; reason: string; mod?: Member }) {
|
||||
|
@ -935,8 +1120,19 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.ignoreEvent(IgnoredEventType.Ban, 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);
|
||||
} catch (e) {
|
||||
msg.channel.create(errorMessage("Failed to unban the user after softbanning them"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a case for this action
|
||||
const casesPlugin = this.getPlugin<CasesPlugin>("cases");
|
||||
|
@ -966,6 +1162,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
@d.command("unban", "<user:string> [reason:string$]", {
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Unban the specified member",
|
||||
},
|
||||
})
|
||||
@d.permission("can_ban")
|
||||
async unbanCmd(msg: Message, args: { user: string; reason: string; mod: Member }) {
|
||||
|
@ -1017,6 +1216,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
@d.command("forceban", "<user:string> [reason:string$]", {
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Force-ban the specified user, even if they aren't on the server",
|
||||
},
|
||||
})
|
||||
@d.permission("can_ban")
|
||||
async forcebanCmd(msg: Message, args: { user: string; reason?: string; mod?: Member }) {
|
||||
|
@ -1054,7 +1256,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id);
|
||||
|
||||
try {
|
||||
await this.guild.banMember(user.id, 1, reason);
|
||||
await this.guild.banMember(user.id, 1);
|
||||
} catch (e) {
|
||||
this.sendErrorMessage(msg.channel, "Failed to forceban member");
|
||||
return;
|
||||
|
@ -1080,7 +1282,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
});
|
||||
}
|
||||
|
||||
@d.command("massban", "<userIds:string...>")
|
||||
@d.command("massban", "<userIds:string...>", {
|
||||
info: {
|
||||
description: "Mass-ban a list of user IDs",
|
||||
},
|
||||
})
|
||||
@d.permission("can_massban")
|
||||
async massbanCmd(msg: Message, args: { userIds: string[] }) {
|
||||
// Limit to 100 users at once (arbitrary?)
|
||||
|
@ -1164,6 +1370,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
@d.command("addcase", "<type:string> <user:string> [reason:string$]", {
|
||||
options: [{ name: "mod", type: "member" }],
|
||||
info: {
|
||||
description: "Add an arbitrary case to the specified user without taking any action",
|
||||
},
|
||||
})
|
||||
@d.permission("can_addcase")
|
||||
async addcaseCmd(msg: Message, args: { type: string; user: string; reason?: string; mod?: Member }) {
|
||||
|
@ -1224,15 +1433,13 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a case or list of cases
|
||||
* If the argument passed is a case id, display that case
|
||||
* If the argument passed is a user id, show all cases on that user
|
||||
*/
|
||||
@d.command("case", "<caseNumber:number>")
|
||||
@d.command("case", "<caseNumber:number>", {
|
||||
info: {
|
||||
description: "Show information about a specific case",
|
||||
},
|
||||
})
|
||||
@d.permission("can_view")
|
||||
async showCaseCmd(msg: Message, args: { caseNumber: number }) {
|
||||
// Assume case id
|
||||
const theCase = await this.cases.findByCaseNumber(args.caseNumber);
|
||||
|
||||
if (!theCase) {
|
||||
|
@ -1258,6 +1465,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
shortcut: "h",
|
||||
},
|
||||
],
|
||||
info: {
|
||||
description: "Show a list of cases the specified user has",
|
||||
},
|
||||
})
|
||||
@d.permission("can_view")
|
||||
async userCasesCmd(msg: Message, args: { user: string; expand?: boolean; hidden?: boolean }) {
|
||||
|
@ -1322,6 +1532,9 @@ export class ModActionsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
@d.command("cases", null, {
|
||||
options: [{ name: "mod", type: "Member" }],
|
||||
info: {
|
||||
description: "Show the most recent 5 cases by the specified --mod",
|
||||
},
|
||||
})
|
||||
@d.permission("can_view")
|
||||
async recentCasesCmd(msg: Message, args: { mod?: Member }) {
|
||||
|
@ -1347,7 +1560,11 @@ export class ModActionsPlugin extends ZeppelinPlugin<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")
|
||||
async hideCaseCmd(msg: Message, args: { caseNum: number }) {
|
||||
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")
|
||||
async unhideCaseCmd(msg: Message, args: { caseNum: number }) {
|
||||
const theCase = await this.cases.findByCaseNumber(args.caseNum);
|
||||
|
|
|
@ -64,14 +64,18 @@ const FIRST_CHECK_INCREMENT = 5 * 1000;
|
|||
|
||||
export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "mutes";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Mutes",
|
||||
};
|
||||
|
||||
protected mutes: GuildMutes;
|
||||
protected cases: GuildCases;
|
||||
protected serverLogs: GuildLogs;
|
||||
private muteClearIntervalId: NodeJS.Timer;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
mute_role: null,
|
||||
|
@ -139,7 +143,7 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
const user = await this.resolveUser(userId);
|
||||
const member = await this.getMember(user.id);
|
||||
const member = await this.getMember(user.id, true); // Grab the fresh member so we don't have stale role info
|
||||
|
||||
if (member) {
|
||||
// Apply mute role if it's missing
|
||||
|
@ -264,7 +268,7 @@ export class MutesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
if (!existingMute) return;
|
||||
|
||||
const user = await this.resolveUser(userId);
|
||||
const member = await this.getMember(userId);
|
||||
const member = await this.getMember(userId, true); // Grab the fresh member so we don't have stale role info
|
||||
|
||||
if (unmuteTime) {
|
||||
// Schedule timed unmute (= just set the mute's duration)
|
||||
|
|
|
@ -13,12 +13,13 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "name_history";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static showInDocs = false;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
protected nicknameHistory: GuildNicknameHistory;
|
||||
protected usernameHistory: UsernameHistory;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_view: false,
|
||||
|
@ -40,7 +41,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
this.usernameHistory = new UsernameHistory();
|
||||
}
|
||||
|
||||
@d.command("names", "<userId:userid>")
|
||||
@d.command("names", "<userId:userId>")
|
||||
@d.permission("can_view")
|
||||
async namesCmd(msg: Message, args: { userId: string }) {
|
||||
const nicknames = await this.nicknameHistory.getByUserId(args.userId);
|
||||
|
@ -72,7 +73,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
@d.event("guildMemberUpdate")
|
||||
async onGuildMemberUpdate(_, member: Member) {
|
||||
const latestEntry = await this.nicknameHistory.getLastEntry(member.id);
|
||||
if (!latestEntry || latestEntry.nickname != member.nick) {
|
||||
if (!latestEntry || latestEntry.nickname !== member.nick) {
|
||||
// tslint:disable-line
|
||||
await this.nicknameHistory.addEntry(member.id, member.nick);
|
||||
}
|
||||
|
|
|
@ -17,12 +17,16 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class PersistPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "persist";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Persist",
|
||||
};
|
||||
|
||||
protected persistedData: GuildPersistedData;
|
||||
protected logs: GuildLogs;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
persisted_roles: [],
|
||||
|
|
|
@ -15,13 +15,17 @@ const TIMEOUT = 10 * 1000;
|
|||
|
||||
export class PingableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "pingable_roles";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Pingable roles",
|
||||
};
|
||||
|
||||
protected pingableRoles: GuildPingableRoles;
|
||||
protected cache: Map<string, PingableRole[]>;
|
||||
protected timeouts: Map<string, any>;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_manage: false,
|
||||
|
@ -53,7 +57,7 @@ export class PingableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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")
|
||||
async disablePingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) {
|
||||
const pingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id);
|
||||
|
@ -70,7 +74,7 @@ export class PingableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
);
|
||||
}
|
||||
|
||||
@d.command("pingable_role", "<channelId:channelid> <role:role>")
|
||||
@d.command("pingable_role", "<channelId:channelId> <role:role>")
|
||||
@d.permission("can_manage")
|
||||
async setPingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) {
|
||||
const existingPingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id);
|
||||
|
|
|
@ -38,7 +38,11 @@ const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50;
|
|||
|
||||
export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "post";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Post",
|
||||
};
|
||||
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
protected scheduledPosts: GuildScheduledPosts;
|
||||
|
@ -58,7 +62,7 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
clearTimeout(this.scheduledPostLoopTimeout);
|
||||
}
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_post: false,
|
||||
|
@ -261,9 +265,11 @@ export class PostPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
} else {
|
||||
// Post the message immediately
|
||||
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}>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
this.savedMessages.setPermanent(createdMsg.id);
|
||||
|
||||
if (msg.channel.id !== args.channel.id) {
|
||||
await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.content) {
|
||||
const prefix = this.guildConfig.prefix || "!";
|
||||
|
|
|
@ -44,7 +44,11 @@ type PendingMemberRoleChanges = {
|
|||
|
||||
export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "reaction_roles";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Reaction roles",
|
||||
};
|
||||
|
||||
protected reactionRoles: GuildReactionRoles;
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
|
@ -55,7 +59,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
private autoRefreshTimeout;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
auto_refresh_interval: MIN_AUTO_REFRESH,
|
||||
|
|
|
@ -24,7 +24,11 @@ const MAX_TRIES = 3;
|
|||
|
||||
export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "reminders";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Reminders",
|
||||
};
|
||||
|
||||
protected reminders: GuildReminders;
|
||||
protected tries: Map<number, number>;
|
||||
|
@ -32,7 +36,7 @@ export class RemindersPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
private postRemindersTimeout;
|
||||
private unloaded = false;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_use: false,
|
||||
|
|
|
@ -14,11 +14,12 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class SelfGrantableRolesPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "self_grantable_roles";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static showInDocs = false;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
protected selfGrantableRoles: GuildSelfGrantableRoles;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_manage: false,
|
||||
|
|
|
@ -33,7 +33,11 @@ const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * 1000;
|
|||
|
||||
export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "slowmode";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Slowmode",
|
||||
};
|
||||
|
||||
protected slowmodes: GuildSlowmodes;
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
|
@ -42,7 +46,7 @@ export class SlowmodePlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
private onMessageCreateFn;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
use_native_slowmode: true,
|
||||
|
|
|
@ -74,7 +74,11 @@ const SPAM_ARCHIVE_EXPIRY_DAYS = 90;
|
|||
|
||||
export class SpamPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "spam";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Spam protection",
|
||||
};
|
||||
|
||||
protected logs: GuildLogs;
|
||||
protected archives: GuildArchives;
|
||||
|
@ -96,7 +100,7 @@ export class SpamPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
private expiryInterval;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
max_censor: null,
|
||||
|
|
|
@ -8,7 +8,7 @@ import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
|||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import * as t from "io-ts";
|
||||
import { GuildStarboardMessages } from "../data/GuildStarboardMessages";
|
||||
import { StarboardMessage } from "src/data/entities/StarboardMessage";
|
||||
import { StarboardMessage } from "../data/entities/StarboardMessage";
|
||||
|
||||
const StarboardOpts = t.type({
|
||||
source_channel_ids: t.array(t.string),
|
||||
|
@ -38,14 +38,15 @@ const defaultStarboardOpts: Partial<TStarboardOpts> = {
|
|||
|
||||
export class StarboardPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "starboard";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static showInDocs = false;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
protected savedMessages: GuildSavedMessages;
|
||||
protected starboardMessages: GuildStarboardMessages;
|
||||
|
||||
private onMessageDeleteFn;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
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 }) {
|
||||
try {
|
||||
const starboards = await this.getStarboardOptsForStarboardChannelId(this.bot.getChannel(args.starboardChannelId));
|
||||
|
|
|
@ -23,7 +23,11 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "tags";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Tags",
|
||||
};
|
||||
|
||||
protected archives: GuildArchives;
|
||||
protected tags: GuildTags;
|
||||
|
@ -34,7 +38,7 @@ export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
protected tagFunctions;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
prefix: "!!",
|
||||
|
@ -117,7 +121,7 @@ export class TagsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}
|
||||
|
||||
const prefix = this.getConfigForMsg(msg).prefix;
|
||||
const tagNames = tags.map(t => t.tag).sort();
|
||||
const tagNames = tags.map(tag => tag.tag).sort();
|
||||
msg.channel.createMessage(`
|
||||
Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\`
|
||||
`);
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { decorators as d, GlobalPlugin } from "knub";
|
||||
import { UsernameHistory } from "../data/UsernameHistory";
|
||||
import { Member, User } from "eris";
|
||||
import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin";
|
||||
|
||||
export class UsernameSaver extends GlobalPlugin {
|
||||
export class UsernameSaver extends GlobalZeppelinPlugin {
|
||||
public static pluginName = "username_saver";
|
||||
|
||||
protected usernameHistory: UsernameHistory;
|
||||
|
|
|
@ -82,7 +82,11 @@ type MemberSearchParams = {
|
|||
|
||||
export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "utility";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Utility",
|
||||
};
|
||||
|
||||
protected logs: GuildLogs;
|
||||
protected cases: GuildCases;
|
||||
|
@ -92,7 +96,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
protected lastFullMemberRefresh = 0;
|
||||
protected lastReload;
|
||||
|
||||
protected static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
can_roles: false,
|
||||
|
@ -554,7 +558,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
}, CLEAN_COMMAND_DELETE_DELAY);
|
||||
}
|
||||
|
||||
@d.command("clean user", "<userId:userid> <count:number>")
|
||||
@d.command("clean user", "<userId:userId> <count:number>")
|
||||
@d.permission("can_clean")
|
||||
async cleanUserCmd(msg: Message, args: { userId: string; count: number }) {
|
||||
if (args.count > MAX_CLEAN_COUNT || args.count <= 0) {
|
||||
|
@ -607,7 +611,7 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
|
||||
let member;
|
||||
if (!(user instanceof UnknownUser)) {
|
||||
member = await this.getMember(user.id);
|
||||
member = await this.getMember(user.id, true);
|
||||
}
|
||||
|
||||
const embed: EmbedOptions = {
|
||||
|
@ -698,7 +702,9 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
msg.channel.createMessage({ embed });
|
||||
}
|
||||
|
||||
@d.command(/(?:nickname|nick) reset/, "<member:resolvedMember>")
|
||||
@d.command("nickname reset", "<member:resolvedMember>", {
|
||||
aliases: ["nick reset"],
|
||||
})
|
||||
@d.permission("can_nickname")
|
||||
async nicknameResetCmd(msg: Message, args: { member: Member }) {
|
||||
if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) {
|
||||
|
@ -718,7 +724,9 @@ export class UtilityPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
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")
|
||||
async nicknameCmd(msg: Message, args: { member: Member; nickname: string }) {
|
||||
if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) {
|
||||
|
|
|
@ -16,11 +16,15 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
|||
|
||||
export class WelcomeMessagePlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "welcome_message";
|
||||
protected static configSchema = ConfigSchema;
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Welcome message",
|
||||
};
|
||||
|
||||
protected logs: GuildLogs;
|
||||
|
||||
protected getDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
send_dm: false,
|
||||
|
|
|
@ -4,15 +4,47 @@ import * as t from "io-ts";
|
|||
import { pipe } from "fp-ts/lib/pipeable";
|
||||
import { fold } from "fp-ts/lib/Either";
|
||||
import { PathReporter } from "io-ts/lib/PathReporter";
|
||||
import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils";
|
||||
import {
|
||||
deepKeyIntersect,
|
||||
isSnowflake,
|
||||
isUnicodeEmoji,
|
||||
resolveMember,
|
||||
resolveUser,
|
||||
resolveUserId,
|
||||
trimEmptyStartEndLines,
|
||||
trimIndents,
|
||||
UnknownUser,
|
||||
} from "../utils";
|
||||
import { Member, User } from "eris";
|
||||
import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line
|
||||
import { performance } from "perf_hooks";
|
||||
import { validateStrict } from "../validatorUtils";
|
||||
import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils";
|
||||
import { mergeConfig } from "knub/dist/configUtils";
|
||||
|
||||
const SLOW_RESOLVE_THRESHOLD = 1500;
|
||||
|
||||
export interface PluginInfo {
|
||||
prettyName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CommandInfo {
|
||||
description?: string;
|
||||
basicUsage?: string;
|
||||
parameterDescriptions?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function trimPluginDescription(str) {
|
||||
return trimIndents(trimEmptyStartEndLines(str), 6);
|
||||
}
|
||||
|
||||
export class ZeppelinPlugin<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 = [];
|
||||
|
||||
protected throwPluginRuntimeError(message: string) {
|
||||
|
@ -29,49 +61,103 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
return ourLevel > memberLevel;
|
||||
}
|
||||
|
||||
protected static getStaticDefaultOptions() {
|
||||
/**
|
||||
* Since we want to do type checking without creating instances of every plugin,
|
||||
* we need a static version of getDefaultOptions(). This static version is then,
|
||||
* by turn, called from getDefaultOptions() so everything still works as expected.
|
||||
*/
|
||||
public static getStaticDefaultOptions() {
|
||||
// Implemented by plugin
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper to fetch the real default options from getStaticDefaultOptions()
|
||||
*/
|
||||
protected getDefaultOptions(): IPluginOptions<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 {
|
||||
// Validate config values
|
||||
if (this.configSchema) {
|
||||
if (options.config) {
|
||||
const merged = configUtils.mergeConfig(
|
||||
{},
|
||||
(this.getStaticDefaultOptions() as any).config || {},
|
||||
options.config,
|
||||
);
|
||||
const errors = validateStrict(this.configSchema, merged);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
try {
|
||||
this.mergeAndDecodeStaticOptions(options);
|
||||
} catch (e) {
|
||||
if (e instanceof StrictValidationError) {
|
||||
return e.getErrors();
|
||||
}
|
||||
|
||||
if (options.overrides) {
|
||||
for (const [i, override] of options.overrides.entries()) {
|
||||
if (override.config) {
|
||||
// For type checking overrides, apply default config + supplied config + any overrides preceding this override + finally this override
|
||||
// Exhaustive type checking would require checking against all combinations of preceding overrides but that's... costy. This will do for now.
|
||||
// TODO: Override default config retrieval functions and do some sort of memoized checking there?
|
||||
const merged = configUtils.mergeConfig(
|
||||
{},
|
||||
(this.getStaticDefaultOptions() as any).config || {},
|
||||
options.config || {},
|
||||
...options.overrides.slice(0, i).map(o => o.config || {}),
|
||||
override.config,
|
||||
);
|
||||
const errors = validateStrict(this.configSchema, merged);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -80,12 +166,7 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
}
|
||||
|
||||
public async runLoad(): Promise<any> {
|
||||
const mergedOptions = this.getMergedOptions();
|
||||
const validationErrors = ((this.constructor as unknown) as typeof ZeppelinPlugin).validateOptions(mergedOptions);
|
||||
if (validationErrors) {
|
||||
throw new Error(validationErrors.join("\n"));
|
||||
}
|
||||
|
||||
const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config
|
||||
return super.runLoad();
|
||||
}
|
||||
|
||||
|
@ -103,10 +184,20 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intended for cross-plugin functionality
|
||||
*/
|
||||
public getRegisteredCommands() {
|
||||
return this.commands.commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intended for cross-plugin functionality
|
||||
*/
|
||||
public getRuntimeOptions() {
|
||||
return this.getMergedOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
|
||||
* If the user is not found in the cache, it's fetched from the API.
|
||||
|
@ -126,14 +217,31 @@ export class ZeppelinPlugin<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.
|
||||
* 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 member = await resolveMember(this.bot, this.guild, memberResolvable);
|
||||
|
||||
let member;
|
||||
if (forceFresh) {
|
||||
const userId = await resolveUserId(this.bot, memberResolvable);
|
||||
try {
|
||||
member = userId && (await this.bot.getRESTGuildMember(this.guild.id, userId));
|
||||
} catch (e) {
|
||||
if (!(e instanceof DiscordRESTError)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (member) member.id = member.user.id;
|
||||
} else {
|
||||
member = await resolveMember(this.bot, this.guild, memberResolvable);
|
||||
}
|
||||
|
||||
const time = performance.now() - start;
|
||||
if (time >= SLOW_RESOLVE_THRESHOLD) {
|
||||
const rounded = Math.round(time);
|
||||
logger.warn(`Slow member resolve (${rounded}ms): ${memberResolvable} in ${this.guild.name} (${this.guild.id})`);
|
||||
}
|
||||
|
||||
return member;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ import { GuildInfoSaverPlugin } from "./GuildInfoSaver";
|
|||
import { CompanionChannelPlugin } from "./CompanionChannels";
|
||||
import { LocatePlugin } from "./LocateUser";
|
||||
import { GuildConfigReloader } from "./GuildConfigReloader";
|
||||
import { ChannelArchiverPlugin } from "./ChannelArchiver";
|
||||
|
||||
/**
|
||||
* Plugins available to be loaded for individual guilds
|
||||
|
@ -54,6 +55,7 @@ export const availablePlugins = [
|
|||
GuildInfoSaverPlugin,
|
||||
CompanionChannelPlugin,
|
||||
LocatePlugin,
|
||||
ChannelArchiverPlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
241
src/utils.ts
241
src/utils.ts
|
@ -6,6 +6,7 @@ import {
|
|||
GuildAuditLog,
|
||||
GuildAuditLogEntry,
|
||||
Member,
|
||||
MessageContent,
|
||||
TextableChannel,
|
||||
TextChannel,
|
||||
User,
|
||||
|
@ -21,7 +22,7 @@ const fsp = fs.promises;
|
|||
|
||||
import https from "https";
|
||||
import tmp from "tmp";
|
||||
import { logger } from "knub";
|
||||
import { logger, waitForReaction } from "knub";
|
||||
|
||||
const delayStringMultipliers = {
|
||||
w: 1000 * 60 * 60 * 24 * 7,
|
||||
|
@ -31,8 +32,23 @@ const delayStringMultipliers = {
|
|||
s: 1000,
|
||||
};
|
||||
|
||||
export function tNullable(type: t.Mixed) {
|
||||
return t.union([type, t.undefined, t.null]);
|
||||
export const MS = 1;
|
||||
export const SECONDS = 1000 * MS;
|
||||
export const MINUTES = 60 * SECONDS;
|
||||
export const HOURS = 60 * MINUTES;
|
||||
export const DAYS = 24 * HOURS;
|
||||
|
||||
export function tNullable<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 protocolRegex = /^[a-z]+:\/\//;
|
||||
|
||||
export function getUrlsInString(str: string): url.URL[] {
|
||||
const matches = str.match(urlRegex) || [];
|
||||
export function getUrlsInString(str: string, unique = false): url.URL[] {
|
||||
let matches = str.match(urlRegex).map(m => m[0]) || [];
|
||||
if (unique) matches = Array.from(new Set(matches));
|
||||
|
||||
return matches.reduce((urls, match) => {
|
||||
if (!protocolRegex.test(match)) {
|
||||
match = `https://${match}`;
|
||||
|
@ -235,6 +253,48 @@ export function asSingleLine(str: string) {
|
|||
return trimLines(str).replace(/\n/g, " ");
|
||||
}
|
||||
|
||||
export function trimEmptyStartEndLines(str: string) {
|
||||
const lines = str.split("\n");
|
||||
let emptyLinesAtStart = 0;
|
||||
let emptyLinesAtEnd = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.match(/^\s*$/)) {
|
||||
emptyLinesAtStart++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = lines.length - 1; i > 0; i--) {
|
||||
if (lines[i].match(/^\s*$/)) {
|
||||
emptyLinesAtEnd++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.slice(emptyLinesAtStart, emptyLinesAtEnd ? -1 * emptyLinesAtEnd : null).join("\n");
|
||||
}
|
||||
|
||||
export function trimIndents(str: string, indentLength: number) {
|
||||
return str
|
||||
.split("\n")
|
||||
.map(line => line.slice(indentLength))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function indentLine(str: string, indentLength: number) {
|
||||
return " ".repeat(indentLength) + str;
|
||||
}
|
||||
|
||||
export function indentLines(str: string, indentLength: number) {
|
||||
return str
|
||||
.split("\n")
|
||||
.map(line => indentLine(line, indentLength))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export const emptyEmbedValue = "\u200b";
|
||||
export const embedPadding = "\n" + emptyEmbedValue;
|
||||
|
||||
|
@ -398,7 +458,7 @@ export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path
|
|||
if (retries === 0) {
|
||||
throw httpsErr;
|
||||
} else {
|
||||
console.warn("File download failed, retrying. Error given:", httpsErr.message);
|
||||
console.warn("File download failed, retrying. Error given:", httpsErr.message); // tslint:disable-line
|
||||
resolve(downloadFile(attachmentUrl, retries - 1));
|
||||
}
|
||||
});
|
||||
|
@ -568,94 +628,147 @@ export class UnknownUser {
|
|||
}
|
||||
}
|
||||
|
||||
export function isObjectLiteral(obj) {
|
||||
let deepestPrototype = obj;
|
||||
while (Object.getPrototypeOf(deepestPrototype) != null) {
|
||||
deepestPrototype = Object.getPrototypeOf(deepestPrototype);
|
||||
}
|
||||
return Object.getPrototypeOf(obj) === deepestPrototype;
|
||||
}
|
||||
|
||||
const keyMods = ["+", "-", "="];
|
||||
export function deepKeyIntersect(obj, keyReference) {
|
||||
const result = {};
|
||||
for (let [key, value] of Object.entries(obj)) {
|
||||
if (!keyReference.hasOwnProperty(key)) {
|
||||
// Temporary solution so we don't erase keys with modifiers
|
||||
// Modifiers will be removed soon(tm) so we can remove this when that happens as well
|
||||
let found = false;
|
||||
for (const mod of keyMods) {
|
||||
if (keyReference.hasOwnProperty(mod + key)) {
|
||||
key = mod + key;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Also temp (because modifier shenanigans)
|
||||
result[key] = keyReference[key];
|
||||
} else if (
|
||||
value != null &&
|
||||
typeof value === "object" &&
|
||||
typeof keyReference[key] === "object" &&
|
||||
isObjectLiteral(value)
|
||||
) {
|
||||
result[key] = deepKeyIntersect(value, keyReference[key]);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const unknownUsers = new Set();
|
||||
const unknownMembers = new Set();
|
||||
|
||||
export function resolveUserId(bot: Client, value: string) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A user mention?
|
||||
const mentionMatch = value.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
return mentionMatch[1];
|
||||
}
|
||||
|
||||
// A non-mention, full username?
|
||||
const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/);
|
||||
if (usernameMatch) {
|
||||
const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
|
||||
if (user) return user.id;
|
||||
}
|
||||
|
||||
// Just a user ID?
|
||||
const idMatch = value.match(/^\d+$/);
|
||||
if (idMatch) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveUser(bot: Client, value: string): Promise<User | UnknownUser> {
|
||||
if (value == null || typeof value !== "string") {
|
||||
return new UnknownUser();
|
||||
}
|
||||
|
||||
let userId;
|
||||
|
||||
// A user mention?
|
||||
const mentionMatch = value.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) {
|
||||
userId = mentionMatch[1];
|
||||
}
|
||||
|
||||
// A non-mention, full username?
|
||||
// If we have the user cached, return that directly
|
||||
const userId = resolveUserId(bot, value);
|
||||
if (!userId) {
|
||||
const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/);
|
||||
if (usernameMatch) {
|
||||
const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
|
||||
if (user) userId = user.id;
|
||||
}
|
||||
return new UnknownUser({ id: userId });
|
||||
}
|
||||
|
||||
// Just a user ID?
|
||||
if (!userId) {
|
||||
const idMatch = value.match(/^\d+$/);
|
||||
if (!idMatch) {
|
||||
return null;
|
||||
if (bot.users.has(userId)) {
|
||||
return bot.users.get(userId);
|
||||
}
|
||||
|
||||
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);
|
||||
if (cachedUser) return cachedUser;
|
||||
|
||||
// We only fetch the user from the API if we haven't tried it before:
|
||||
// - If the user was found, the bot has them in its cache
|
||||
// - If the user was not found, they'll be in unknownUsers
|
||||
if (!unknownUsers.has(userId)) {
|
||||
try {
|
||||
const freshUser = await bot.getRESTUser(userId);
|
||||
const freshUser = await bot.getRESTUser(userId).catch(noop);
|
||||
if (freshUser) {
|
||||
bot.users.add(freshUser, bot);
|
||||
return freshUser;
|
||||
} catch (e) {} // tslint:disable-line
|
||||
}
|
||||
|
||||
unknownUsers.add(userId);
|
||||
}
|
||||
setTimeout(() => unknownUsers.delete(userId), 15 * MINUTES);
|
||||
|
||||
return new UnknownUser({ id: userId });
|
||||
}
|
||||
|
||||
export async function resolveMember(bot: Client, guild: Guild, value: string): Promise<Member> {
|
||||
// Start by resolving the user
|
||||
const user = await resolveUser(bot, value);
|
||||
if (!user || user instanceof UnknownUser) return null;
|
||||
const userId = resolveUserId(bot, value);
|
||||
if (!userId) return null;
|
||||
|
||||
// See if we have the member cached...
|
||||
let member = guild.members.get(user.id);
|
||||
|
||||
// We only fetch the member from the API if we haven't tried it before:
|
||||
// - If the member was found, the bot has them in the guild's member cache
|
||||
// - If the member was not found, they'll be in unknownMembers
|
||||
const unknownKey = `${guild.id}-${user.id}`;
|
||||
if (!unknownMembers.has(unknownKey)) {
|
||||
// If not, fetch it from the API
|
||||
if (!member) {
|
||||
try {
|
||||
logger.debug(`Fetching unknown member (${user.id} in ${guild.name} (${guild.id})) from the API`);
|
||||
|
||||
member = await bot.getRESTGuildMember(guild.id, user.id);
|
||||
member.id = user.id;
|
||||
member.guild = guild;
|
||||
} catch (e) {} // tslint:disable-line
|
||||
// If we have the member cached, return that directly
|
||||
if (guild.members.has(userId)) {
|
||||
return guild.members.get(userId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const MS = 1;
|
||||
export const SECONDS = 1000 * MS;
|
||||
export const MINUTES = 60 * SECONDS;
|
||||
export const HOURS = 60 * MINUTES;
|
||||
export const DAYS = 24 * HOURS;
|
||||
unknownMembers.add(unknownKey);
|
||||
setTimeout(() => unknownMembers.delete(unknownKey), 15 * MINUTES);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 === "✅";
|
||||
}
|
||||
|
|
|
@ -1,8 +1,28 @@
|
|||
import * as t from "io-ts";
|
||||
import { pipe } from "fp-ts/lib/pipeable";
|
||||
import { fold } from "fp-ts/lib/Either";
|
||||
import { fold, either } from "fp-ts/lib/Either";
|
||||
import { noop } from "./utils";
|
||||
import deepDiff from "deep-diff";
|
||||
import safeRegex from "safe-regex";
|
||||
|
||||
const regexWithFlags = /^\/(.*?)\/([i]*)$/;
|
||||
|
||||
/**
|
||||
* The TSafeRegex type supports two syntaxes for regexes: /<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
|
||||
function stringify(v) {
|
||||
|
@ -31,22 +51,38 @@ function getContextPath(context) {
|
|||
}
|
||||
// tslint:enable
|
||||
|
||||
const report = fold((errors: any) => {
|
||||
return errors.map(err => {
|
||||
if (err.message) return err.message;
|
||||
export class StrictValidationError extends Error {
|
||||
private errors;
|
||||
|
||||
constructor(errors: string[]) {
|
||||
errors = Array.from(new Set(errors));
|
||||
super(errors.join("\n"));
|
||||
this.errors = errors;
|
||||
}
|
||||
getErrors() {
|
||||
return this.errors;
|
||||
}
|
||||
}
|
||||
|
||||
const report = fold((errors: any): StrictValidationError | void => {
|
||||
const errorStrings = errors.map(err => {
|
||||
const context = err.context.map(c => c.key).filter(k => k && !k.startsWith("{"));
|
||||
while (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1);
|
||||
|
||||
const value = stringify(err.value);
|
||||
return value === undefined
|
||||
? `<${context.join("/")}> is required`
|
||||
: `Invalid value <${stringify(err.value)}> supplied to <${context.join("/")}>`;
|
||||
: `Invalid value supplied to <${context.join("/")}>${err.message ? `: ${err.message}` : ""}`;
|
||||
});
|
||||
|
||||
return new StrictValidationError(errorStrings);
|
||||
}, noop);
|
||||
|
||||
/**
|
||||
* Validates the given value against the given schema while also disallowing extra properties
|
||||
* Decodes and validates the given value against the given schema while also disallowing extra properties
|
||||
* See: https://github.com/gcanti/io-ts/issues/322
|
||||
*/
|
||||
export function validateStrict(schema: t.HasProps, value: any): string[] | null {
|
||||
export function decodeAndValidateStrict(schema: t.HasProps, value: any): StrictValidationError | any {
|
||||
const validationResult = t.exact(schema).decode(value);
|
||||
return pipe(
|
||||
validationResult,
|
||||
|
@ -57,10 +93,10 @@ export function validateStrict(schema: t.HasProps, value: any): string[] | null
|
|||
if (JSON.stringify(value) !== JSON.stringify(result)) {
|
||||
const diff = deepDiff(result, value);
|
||||
const errors = diff.filter(d => d.kind === "N").map(d => `Unknown property <${d.path.join(".")}>`);
|
||||
return errors.length ? errors : ["Found unknown properties"];
|
||||
if (errors.length) return new StrictValidationError(errors);
|
||||
}
|
||||
|
||||
return null;
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
@ -22,6 +22,8 @@
|
|||
"no-bitwise": false,
|
||||
"interface-over-type-literal": false,
|
||||
"interface-name": false,
|
||||
"no-submodule-imports": false
|
||||
"no-submodule-imports": false,
|
||||
"no-floating-promises": true,
|
||||
"no-string-literal": false
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue