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