mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-05-23 17:45:03 +00:00
Merge branch 'master' into setup-documentation
This commit is contained in:
commit
6a3007562e
429 changed files with 8120 additions and 2717 deletions
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.github
|
||||||
|
.idea
|
||||||
|
node_modules
|
||||||
|
/assets
|
||||||
|
/debug
|
1
backend/.prettierignore
Normal file
1
backend/.prettierignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/dist
|
|
@ -1,24 +1,24 @@
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.accessSync(path.resolve(__dirname, 'bot.env'));
|
fs.accessSync(path.resolve(__dirname, "bot.env"));
|
||||||
require('dotenv').config({ path: path.resolve(__dirname, 'bot.env') });
|
require("dotenv").config({ path: path.resolve(__dirname, "bot.env") });
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
fs.accessSync(path.resolve(__dirname, 'api.env'));
|
fs.accessSync(path.resolve(__dirname, "api.env"));
|
||||||
require('dotenv').config({ path: path.resolve(__dirname, 'api.env') });
|
require("dotenv").config({ path: path.resolve(__dirname, "api.env") });
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("bot.env or api.env required");
|
throw new Error("bot.env or api.env required");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const moment = require('moment-timezone');
|
const moment = require("moment-timezone");
|
||||||
moment.tz.setDefault('UTC');
|
moment.tz.setDefault("UTC");
|
||||||
|
|
||||||
const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js'));
|
const entities = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/data/entities/*.js"));
|
||||||
const migrations = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js'));
|
const migrations = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/migrations/*.js"));
|
||||||
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations'));
|
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, "src/migrations"));
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
type: "mysql",
|
type: "mysql",
|
||||||
|
@ -26,26 +26,29 @@ module.exports = {
|
||||||
username: process.env.DB_USER,
|
username: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_DATABASE,
|
database: process.env.DB_DATABASE,
|
||||||
charset: 'utf8mb4',
|
charset: "utf8mb4",
|
||||||
supportBigNumbers: true,
|
supportBigNumbers: true,
|
||||||
bigNumberStrings: true,
|
bigNumberStrings: true,
|
||||||
dateStrings: true,
|
dateStrings: true,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
connectTimeout: 2000,
|
connectTimeout: 2000,
|
||||||
|
|
||||||
|
logging: ["error", "warn"],
|
||||||
|
maxQueryExecutionTime: 250,
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
entities: [entities],
|
entities: [entities],
|
||||||
|
|
||||||
// Pool options
|
// Pool options
|
||||||
extra: {
|
extra: {
|
||||||
typeCast(field, next) {
|
typeCast(field, next) {
|
||||||
if (field.type === 'DATETIME') {
|
if (field.type === "DATETIME") {
|
||||||
const val = field.string();
|
const val = field.string();
|
||||||
return val != null ? moment.utc(val).format('YYYY-MM-DD HH:mm:ss') : null;
|
return val != null ? moment.utc(val).format("YYYY-MM-DD HH:mm:ss") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Migrations
|
// Migrations
|
||||||
|
|
30
backend/package-lock.json
generated
30
backend/package-lock.json
generated
|
@ -24,7 +24,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": "^30.0.0-beta.39",
|
"knub": "^30.0.0-beta.45",
|
||||||
"knub-command-manager": "^9.1.0",
|
"knub-command-manager": "^9.1.0",
|
||||||
"last-commit-log": "^2.1.0",
|
"last-commit-log": "^2.1.0",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
|
@ -53,7 +53,8 @@
|
||||||
"utf-8-validate": "^5.0.5",
|
"utf-8-validate": "^5.0.5",
|
||||||
"uuid": "^3.3.2",
|
"uuid": "^3.3.2",
|
||||||
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
|
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
|
||||||
"zlib-sync": "^0.1.7"
|
"zlib-sync": "^0.1.7",
|
||||||
|
"zod": "^3.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.5",
|
"@types/cors": "^2.8.5",
|
||||||
|
@ -3042,9 +3043,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/knub": {
|
"node_modules/knub": {
|
||||||
"version": "30.0.0-beta.39",
|
"version": "30.0.0-beta.45",
|
||||||
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz",
|
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
|
||||||
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==",
|
"integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord-api-types": "^0.22.0",
|
"discord-api-types": "^0.22.0",
|
||||||
"discord.js": "^13.0.1",
|
"discord.js": "^13.0.1",
|
||||||
|
@ -5965,6 +5966,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nan": "^2.14.0"
|
"nan": "^2.14.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -8281,9 +8290,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"knub": {
|
"knub": {
|
||||||
"version": "30.0.0-beta.39",
|
"version": "30.0.0-beta.45",
|
||||||
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz",
|
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
|
||||||
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==",
|
"integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"discord-api-types": "^0.22.0",
|
"discord-api-types": "^0.22.0",
|
||||||
"discord.js": "^13.0.1",
|
"discord.js": "^13.0.1",
|
||||||
|
@ -10527,6 +10536,11 @@
|
||||||
"requires": {
|
"requires": {
|
||||||
"nan": "^2.14.0"
|
"nan": "^2.14.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,11 +7,11 @@
|
||||||
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
|
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
|
||||||
"watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"",
|
"watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"",
|
||||||
"build": "rimraf dist && tsc",
|
"build": "rimraf dist && tsc",
|
||||||
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9229 dist/backend/src/index.js init",
|
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9229 dist/backend/src/index.js init",
|
||||||
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js",
|
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/index.js",
|
||||||
"watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"",
|
"watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"",
|
||||||
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init",
|
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init",
|
||||||
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js",
|
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/api/index.js",
|
||||||
"watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"",
|
"watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"",
|
||||||
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
|
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
|
||||||
"migrate-prod": "npm run typeorm -- migration:run",
|
"migrate-prod": "npm run typeorm -- migration:run",
|
||||||
|
@ -39,7 +39,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": "^30.0.0-beta.39",
|
"knub": "^30.0.0-beta.45",
|
||||||
"knub-command-manager": "^9.1.0",
|
"knub-command-manager": "^9.1.0",
|
||||||
"last-commit-log": "^2.1.0",
|
"last-commit-log": "^2.1.0",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
|
@ -68,7 +68,8 @@
|
||||||
"utf-8-validate": "^5.0.5",
|
"utf-8-validate": "^5.0.5",
|
||||||
"uuid": "^3.3.2",
|
"uuid": "^3.3.2",
|
||||||
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
|
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
|
||||||
"zlib-sync": "^0.1.7"
|
"zlib-sync": "^0.1.7",
|
||||||
|
"zod": "^3.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.5",
|
"@types/cors": "^2.8.5",
|
||||||
|
|
|
@ -6,9 +6,9 @@
|
||||||
* https://github.com/TypeStrong/ts-node/pull/254
|
* https://github.com/TypeStrong/ts-node/pull/254
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const tsconfig = require('./tsconfig.json');
|
const tsconfig = require("./tsconfig.json");
|
||||||
const tsconfigPaths = require('tsconfig-paths');
|
const tsconfigPaths = require("tsconfig-paths");
|
||||||
|
|
||||||
// E.g. ./dist/backend
|
// E.g. ./dist/backend
|
||||||
const baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname));
|
const baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname));
|
||||||
|
|
|
@ -28,11 +28,11 @@ export class Queue<TQueueFunction extends AnyFn = AnyFn> {
|
||||||
return this.queue.length + (this.running ? 1 : 0);
|
return this.queue.length + (this.running ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(fn: TQueueFunction): Promise<void> {
|
public add(fn: TQueueFunction): Promise<any> {
|
||||||
const promise = new Promise<void>(resolve => {
|
const promise = new Promise<any>((resolve) => {
|
||||||
this.queue.push(async () => {
|
this.queue.push(async () => {
|
||||||
await fn();
|
const result = await fn();
|
||||||
resolve();
|
resolve(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.running) this.next();
|
if (!this.running) this.next();
|
||||||
|
@ -50,7 +50,7 @@ export class Queue<TQueueFunction extends AnyFn = AnyFn> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn = this.queue.shift()!;
|
const fn = this.queue.shift()!;
|
||||||
new Promise(resolve => {
|
new Promise((resolve) => {
|
||||||
// Either fn() completes or the timeout is reached
|
// Either fn() completes or the timeout is reached
|
||||||
void fn().then(resolve);
|
void fn().then(resolve);
|
||||||
setTimeout(resolve, this._timeout);
|
setTimeout(resolve, this._timeout);
|
||||||
|
|
|
@ -42,7 +42,7 @@ export class QueuedEventEmitter {
|
||||||
const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])];
|
const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])];
|
||||||
|
|
||||||
let promise: Promise<any> = Promise.resolve();
|
let promise: Promise<any> = Promise.resolve();
|
||||||
listeners.forEach(listener => {
|
listeners.forEach((listener) => {
|
||||||
promise = this.queue.add(listener.bind(null, ...args));
|
promise = this.queue.add(listener.bind(null, ...args));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ const INITIAL_REGEX_TIMEOUT = 5 * SECONDS;
|
||||||
const INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS;
|
const INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS;
|
||||||
const FINAL_REGEX_TIMEOUT = 5 * SECONDS;
|
const FINAL_REGEX_TIMEOUT = 5 * SECONDS;
|
||||||
|
|
||||||
const regexTimeoutUpgradePromise = new Promise(resolve => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION));
|
const regexTimeoutUpgradePromise = new Promise((resolve) => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION));
|
||||||
|
|
||||||
let newWorkerTimeout = INITIAL_REGEX_TIMEOUT;
|
let newWorkerTimeout = INITIAL_REGEX_TIMEOUT;
|
||||||
regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT));
|
regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT));
|
||||||
|
|
|
@ -33,21 +33,21 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
|
||||||
Authorization: `Bearer ${bearerToken}`,
|
Authorization: `Bearer ${bearerToken}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
res => {
|
(res) => {
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
reject(new Error(`Discord API error ${res.statusCode}`));
|
reject(new Error(`Discord API error ${res.statusCode}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawData = "";
|
let rawData = "";
|
||||||
res.on("data", data => (rawData += data));
|
res.on("data", (data) => (rawData += data));
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
resolve(JSON.parse(rawData));
|
resolve(JSON.parse(rawData));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
request.on("error", err => reject(err));
|
request.on("error", (err) => reject(err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,7 +149,7 @@ export function initAuth(app: express.Express) {
|
||||||
return res.json({ valid: false });
|
return res.json({ valid: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ valid: true });
|
res.json({ valid: true, userId });
|
||||||
});
|
});
|
||||||
app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {
|
app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {
|
||||||
await apiLogins.expireApiKey(req.user!.apiKey);
|
await apiLogins.expireApiKey(req.user!.apiKey);
|
||||||
|
|
|
@ -22,21 +22,21 @@ function formatConfigSchema(schema) {
|
||||||
} else if (schema.name.startsWith("Optional<")) {
|
} else if (schema.name.startsWith("Optional<")) {
|
||||||
return `Optional<${formatConfigSchema(schema.types[0])}>`;
|
return `Optional<${formatConfigSchema(schema.types[0])}>`;
|
||||||
} else {
|
} else {
|
||||||
return schema.types.map(t => formatConfigSchema(t)).join(" | ");
|
return schema.types.map((t) => formatConfigSchema(t)).join(" | ");
|
||||||
}
|
}
|
||||||
} else if (schema._tag === "IntersectionType") {
|
} else if (schema._tag === "IntersectionType") {
|
||||||
return schema.types.map(t => formatConfigSchema(t)).join(" & ");
|
return schema.types.map((t) => formatConfigSchema(t)).join(" & ");
|
||||||
} else {
|
} else {
|
||||||
return schema.name;
|
return schema.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initDocs(app: express.Express) {
|
export function initDocs(app: express.Express) {
|
||||||
const docsPlugins = guildPlugins.filter(plugin => plugin.showInDocs);
|
const docsPlugins = guildPlugins.filter((plugin) => plugin.showInDocs);
|
||||||
|
|
||||||
app.get("/docs/plugins", (req: express.Request, res: express.Response) => {
|
app.get("/docs/plugins", (req: express.Request, res: express.Response) => {
|
||||||
res.json(
|
res.json(
|
||||||
docsPlugins.map(plugin => {
|
docsPlugins.map((plugin) => {
|
||||||
const thinInfo = plugin.info ? { prettyName: plugin.info.prettyName, legacy: plugin.info.legacy ?? false } : {};
|
const thinInfo = plugin.info ? { prettyName: plugin.info.prettyName, legacy: plugin.info.legacy ?? false } : {};
|
||||||
return {
|
return {
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
|
@ -56,7 +56,7 @@ export function initDocs(app: express.Express) {
|
||||||
const name = plugin.name;
|
const name = plugin.name;
|
||||||
const info = plugin.info || {};
|
const info = plugin.info || {};
|
||||||
|
|
||||||
const commands = (plugin.commands || []).map(cmd => ({
|
const commands = (plugin.commands || []).map((cmd) => ({
|
||||||
trigger: cmd.trigger,
|
trigger: cmd.trigger,
|
||||||
permission: cmd.permission,
|
permission: cmd.permission,
|
||||||
signature: cmd.signature,
|
signature: cmd.signature,
|
||||||
|
|
|
@ -3,15 +3,21 @@ import express, { Request, Response } from "express";
|
||||||
import { YAMLException } from "js-yaml";
|
import { YAMLException } from "js-yaml";
|
||||||
import { validateGuildConfig } from "../configValidator";
|
import { validateGuildConfig } from "../configValidator";
|
||||||
import { AllowedGuilds } from "../data/AllowedGuilds";
|
import { AllowedGuilds } from "../data/AllowedGuilds";
|
||||||
import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments";
|
import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments";
|
||||||
import { Configs } from "../data/Configs";
|
import { Configs } from "../data/Configs";
|
||||||
import { apiTokenAuthHandlers } from "./auth";
|
import { apiTokenAuthHandlers } from "./auth";
|
||||||
import { hasGuildPermission, requireGuildPermission } from "./permissions";
|
import { hasGuildPermission, requireGuildPermission } from "./permissions";
|
||||||
import { clientError, ok, serverError, unauthorized } from "./responses";
|
import { clientError, ok, serverError, unauthorized } from "./responses";
|
||||||
import { loadYamlSafely } from "../utils/loadYamlSafely";
|
import { loadYamlSafely } from "../utils/loadYamlSafely";
|
||||||
import { ObjectAliasError } from "../utils/validateNoObjectAliases";
|
import { ObjectAliasError } from "../utils/validateNoObjectAliases";
|
||||||
|
import { isSnowflake } from "../utils";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { ApiAuditLog } from "../data/ApiAuditLog";
|
||||||
|
import { AuditLogEventTypes } from "../data/apiAuditLogTypes";
|
||||||
|
import { Queue } from "../Queue";
|
||||||
|
|
||||||
const apiPermissionAssignments = new ApiPermissionAssignments();
|
const apiPermissionAssignments = new ApiPermissionAssignments();
|
||||||
|
const auditLog = new ApiAuditLog();
|
||||||
|
|
||||||
export function initGuildsAPI(app: express.Express) {
|
export function initGuildsAPI(app: express.Express) {
|
||||||
const allowedGuilds = new AllowedGuilds();
|
const allowedGuilds = new AllowedGuilds();
|
||||||
|
@ -25,6 +31,14 @@ export function initGuildsAPI(app: express.Express) {
|
||||||
res.json(guilds);
|
res.json(guilds);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
guildRouter.get(
|
||||||
|
"/my-permissions", // a
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId);
|
||||||
|
res.json(permissions);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
guildRouter.get("/:guildId", async (req: Request, res: Response) => {
|
guildRouter.get("/:guildId", async (req: Request, res: Response) => {
|
||||||
if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {
|
if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {
|
||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
|
@ -101,5 +115,65 @@ export function initGuildsAPI(app: express.Express) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const permissionManagementQueue = new Queue();
|
||||||
|
guildRouter.post(
|
||||||
|
"/:guildId/set-target-permissions",
|
||||||
|
requireGuildPermission(ApiPermissions.ManageAccess),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
await permissionManagementQueue.add(async () => {
|
||||||
|
const { type, targetId, permissions, expiresAt } = req.body;
|
||||||
|
|
||||||
|
if (type !== ApiPermissionTypes.User) {
|
||||||
|
return clientError(res, "Invalid type");
|
||||||
|
}
|
||||||
|
if (!isSnowflake(targetId)) {
|
||||||
|
return clientError(res, "Invalid targetId");
|
||||||
|
}
|
||||||
|
const validPermissions = new Set(Object.values(ApiPermissions));
|
||||||
|
validPermissions.delete(ApiPermissions.Owner);
|
||||||
|
if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) {
|
||||||
|
return clientError(res, "Invalid permissions");
|
||||||
|
}
|
||||||
|
if (expiresAt != null && !moment.utc(expiresAt).isValid()) {
|
||||||
|
return clientError(res, "Invalid expiresAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
|
||||||
|
if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) {
|
||||||
|
return clientError(res, "Can't change owner permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.length === 0) {
|
||||||
|
await apiPermissionAssignments.removeUser(req.params.guildId, targetId);
|
||||||
|
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, {
|
||||||
|
type: ApiPermissionTypes.User,
|
||||||
|
target_id: targetId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
|
||||||
|
if (existing) {
|
||||||
|
await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions);
|
||||||
|
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, {
|
||||||
|
type: ApiPermissionTypes.User,
|
||||||
|
target_id: targetId,
|
||||||
|
permissions,
|
||||||
|
expires_at: existing.expires_at,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt);
|
||||||
|
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, {
|
||||||
|
type: ApiPermissionTypes.User,
|
||||||
|
target_id: targetId,
|
||||||
|
permissions,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.use("/guilds", guildRouter);
|
app.use("/guilds", guildRouter);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { initAuth } from "./auth";
|
||||||
import { initDocs } from "./docs";
|
import { initDocs } from "./docs";
|
||||||
import { initGuildsAPI } from "./guilds";
|
import { initGuildsAPI } from "./guilds";
|
||||||
import { clientError, error, notFound } from "./responses";
|
import { clientError, error, notFound } from "./responses";
|
||||||
|
import { startBackgroundTasks } from "./tasks";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
@ -14,7 +15,11 @@ app.use(
|
||||||
origin: process.env.DASHBOARD_URL,
|
origin: process.env.DASHBOARD_URL,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(express.json());
|
app.use(
|
||||||
|
express.json({
|
||||||
|
limit: "10mb",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
initAuth(app);
|
initAuth(app);
|
||||||
initGuildsAPI(app);
|
initGuildsAPI(app);
|
||||||
|
@ -43,3 +48,5 @@ app.use((req, res, next) => {
|
||||||
|
|
||||||
const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000;
|
const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000;
|
||||||
app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line
|
app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line
|
||||||
|
|
||||||
|
startBackgroundTasks();
|
||||||
|
|
10
backend/src/api/tasks.ts
Normal file
10
backend/src/api/tasks.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments";
|
||||||
|
import { MINUTES } from "../utils";
|
||||||
|
|
||||||
|
export function startBackgroundTasks() {
|
||||||
|
// Clear expired API permissions every minute
|
||||||
|
const apiPermissions = new ApiPermissionAssignments();
|
||||||
|
setInterval(() => {
|
||||||
|
apiPermissions.clearExpiredPermissions();
|
||||||
|
}, 1 * MINUTES);
|
||||||
|
}
|
|
@ -36,7 +36,7 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
|
||||||
const plugin = pluginNameToPlugin.get(pluginName)!;
|
const plugin = pluginNameToPlugin.get(pluginName)!;
|
||||||
try {
|
try {
|
||||||
const mergedOptions = configUtils.mergeConfig(plugin.defaultOptions || {}, pluginOptions);
|
const mergedOptions = configUtils.mergeConfig(plugin.defaultOptions || {}, pluginOptions);
|
||||||
await plugin.configPreprocessor?.((mergedOptions as unknown) as PluginOptions<any>, true);
|
await plugin.configPreprocessor?.(mergedOptions as unknown as PluginOptions<any>, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
|
if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
|
||||||
return `${pluginName}: ${err.message}`;
|
return `${pluginName}: ${err.message}`;
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { getRepository, Repository } from "typeorm";
|
||||||
import { ApiPermissionTypes } from "./ApiPermissionAssignments";
|
import { ApiPermissionTypes } from "./ApiPermissionAssignments";
|
||||||
import { BaseRepository } from "./BaseRepository";
|
import { BaseRepository } from "./BaseRepository";
|
||||||
import { AllowedGuild } from "./entities/AllowedGuild";
|
import { AllowedGuild } from "./entities/AllowedGuild";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { DBDateFormat } from "../utils";
|
||||||
|
|
||||||
export class AllowedGuilds extends BaseRepository {
|
export class AllowedGuilds extends BaseRepository {
|
||||||
private allowedGuilds: Repository<AllowedGuild>;
|
private allowedGuilds: Repository<AllowedGuild>;
|
||||||
|
@ -37,7 +39,10 @@ export class AllowedGuilds extends BaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateInfo(id, name, icon, ownerId) {
|
updateInfo(id, name, icon, ownerId) {
|
||||||
return this.allowedGuilds.update({ id }, { name, icon, owner_id: ownerId });
|
return this.allowedGuilds.update(
|
||||||
|
{ id },
|
||||||
|
{ name, icon, owner_id: ownerId, updated_at: moment.utc().format(DBDateFormat) },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(id, data: Partial<Omit<AllowedGuild, "id">> = {}) {
|
add(id, data: Partial<Omit<AllowedGuild, "id">> = {}) {
|
||||||
|
|
28
backend/src/data/ApiAuditLog.ts
Normal file
28
backend/src/data/ApiAuditLog.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { BaseRepository } from "./BaseRepository";
|
||||||
|
import { getRepository, Repository } from "typeorm/index";
|
||||||
|
import { ApiAuditLogEntry } from "./entities/ApiAuditLogEntry";
|
||||||
|
import { ApiLogin } from "./entities/ApiLogin";
|
||||||
|
import { AuditLogEventData, AuditLogEventType } from "./apiAuditLogTypes";
|
||||||
|
|
||||||
|
export class ApiAuditLog extends BaseRepository {
|
||||||
|
private auditLog: Repository<ApiAuditLogEntry<any>>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.auditLog = getRepository(ApiAuditLogEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEntry<TEventType extends AuditLogEventType>(
|
||||||
|
guildId: string,
|
||||||
|
authorId: string,
|
||||||
|
eventType: TEventType,
|
||||||
|
eventData: AuditLogEventData[TEventType],
|
||||||
|
) {
|
||||||
|
this.auditLog.insert({
|
||||||
|
guild_id: guildId,
|
||||||
|
author_id: authorId,
|
||||||
|
event_type: eventType as any,
|
||||||
|
event_data: eventData as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -68,10 +68,7 @@ export class ApiLogins extends BaseRepository {
|
||||||
token: hashedToken,
|
token: hashedToken,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
logged_in_at: moment.utc().format(DBDateFormat),
|
logged_in_at: moment.utc().format(DBDateFormat),
|
||||||
expires_at: moment
|
expires_at: moment.utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
|
||||||
.utc()
|
|
||||||
.add(LOGIN_EXPIRY_TIME, "ms")
|
|
||||||
.format(DBDateFormat),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${loginId}.${token}`;
|
return `${loginId}.${token}`;
|
||||||
|
@ -96,10 +93,7 @@ export class ApiLogins extends BaseRepository {
|
||||||
await this.apiLogins.update(
|
await this.apiLogins.update(
|
||||||
{ id: loginId },
|
{ id: loginId },
|
||||||
{
|
{
|
||||||
expires_at: moment()
|
expires_at: moment().utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
|
||||||
.utc()
|
|
||||||
.add(LOGIN_EXPIRY_TIME, "ms")
|
|
||||||
.format(DBDateFormat),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,9 @@ import { ApiPermissions } from "@shared/apiPermissions";
|
||||||
import { getRepository, Repository } from "typeorm";
|
import { getRepository, Repository } from "typeorm";
|
||||||
import { BaseRepository } from "./BaseRepository";
|
import { BaseRepository } from "./BaseRepository";
|
||||||
import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment";
|
import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment";
|
||||||
|
import { Permissions } from "discord.js";
|
||||||
|
import { ApiAuditLog } from "./ApiAuditLog";
|
||||||
|
import { AuditLogEventTypes } from "./apiAuditLogTypes";
|
||||||
|
|
||||||
export enum ApiPermissionTypes {
|
export enum ApiPermissionTypes {
|
||||||
User = "USER",
|
User = "USER",
|
||||||
|
@ -10,10 +13,12 @@ export enum ApiPermissionTypes {
|
||||||
|
|
||||||
export class ApiPermissionAssignments extends BaseRepository {
|
export class ApiPermissionAssignments extends BaseRepository {
|
||||||
private apiPermissions: Repository<ApiPermissionAssignment>;
|
private apiPermissions: Repository<ApiPermissionAssignment>;
|
||||||
|
private auditLogs: ApiAuditLog;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.apiPermissions = getRepository(ApiPermissionAssignment);
|
this.apiPermissions = getRepository(ApiPermissionAssignment);
|
||||||
|
this.auditLogs = new ApiAuditLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
getByGuildId(guildId) {
|
getByGuildId(guildId) {
|
||||||
|
@ -43,16 +48,100 @@ export class ApiPermissionAssignments extends BaseRepository {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addUser(guildId, userId, permissions: ApiPermissions[]) {
|
addUser(guildId, userId, permissions: ApiPermissions[], expiresAt: string | null = null) {
|
||||||
return this.apiPermissions.insert({
|
return this.apiPermissions.insert({
|
||||||
guild_id: guildId,
|
guild_id: guildId,
|
||||||
type: ApiPermissionTypes.User,
|
type: ApiPermissionTypes.User,
|
||||||
target_id: userId,
|
target_id: userId,
|
||||||
permissions,
|
permissions,
|
||||||
|
expires_at: expiresAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
removeUser(guildId, userId) {
|
removeUser(guildId, userId) {
|
||||||
return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId });
|
return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateUserPermissions(guildId: string, userId: string, permissions: ApiPermissions[]): Promise<void> {
|
||||||
|
await this.apiPermissions.update(
|
||||||
|
{
|
||||||
|
guild_id: guildId,
|
||||||
|
type: ApiPermissionTypes.User,
|
||||||
|
target_id: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearExpiredPermissions() {
|
||||||
|
await this.apiPermissions
|
||||||
|
.createQueryBuilder()
|
||||||
|
.where("expires_at IS NOT NULL")
|
||||||
|
.andWhere("expires_at <= NOW()")
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyOwnerChange(guildId: string, newOwnerId: string) {
|
||||||
|
const existingPermissions = await this.getByGuildId(guildId);
|
||||||
|
let updatedOwner = false;
|
||||||
|
for (const perm of existingPermissions) {
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
// Remove owner permission from anyone who currently has it
|
||||||
|
if (perm.permissions.includes(ApiPermissions.Owner)) {
|
||||||
|
perm.permissions.splice(perm.permissions.indexOf(ApiPermissions.Owner), 1);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add owner permission if we encounter the new owner
|
||||||
|
if (perm.type === ApiPermissionTypes.User && perm.target_id === newOwnerId) {
|
||||||
|
perm.permissions.push(ApiPermissions.Owner);
|
||||||
|
updatedOwner = true;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
const criteria = {
|
||||||
|
guild_id: perm.guild_id,
|
||||||
|
type: perm.type,
|
||||||
|
target_id: perm.target_id,
|
||||||
|
};
|
||||||
|
if (perm.permissions.length === 0) {
|
||||||
|
// No remaining permissions -> remove entry
|
||||||
|
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.REMOVE_API_PERMISSION, {
|
||||||
|
type: perm.type,
|
||||||
|
target_id: perm.target_id,
|
||||||
|
});
|
||||||
|
await this.apiPermissions.delete(criteria);
|
||||||
|
} else {
|
||||||
|
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.EDIT_API_PERMISSION, {
|
||||||
|
type: perm.type,
|
||||||
|
target_id: perm.target_id,
|
||||||
|
permissions: perm.permissions,
|
||||||
|
expires_at: perm.expires_at,
|
||||||
|
});
|
||||||
|
await this.apiPermissions.update(criteria, {
|
||||||
|
permissions: perm.permissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedOwner) {
|
||||||
|
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.ADD_API_PERMISSION, {
|
||||||
|
type: ApiPermissionTypes.User,
|
||||||
|
target_id: newOwnerId,
|
||||||
|
permissions: [ApiPermissions.Owner],
|
||||||
|
expires_at: null,
|
||||||
|
});
|
||||||
|
await this.apiPermissions.insert({
|
||||||
|
guild_id: guildId,
|
||||||
|
type: ApiPermissionTypes.User,
|
||||||
|
target_id: newOwnerId,
|
||||||
|
permissions: [ApiPermissions.Owner],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,7 @@ export class ApiUserInfo extends BaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id, data: ApiUserInfoData) {
|
update(id, data: ApiUserInfoData) {
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const repo = entityManager.getRepository(ApiUserInfoEntity);
|
const repo = entityManager.getRepository(ApiUserInfoEntity);
|
||||||
|
|
||||||
const existingInfo = await repo.findOne({ where: { id } });
|
const existingInfo = await repo.findOne({ where: { id } });
|
||||||
|
|
|
@ -41,11 +41,7 @@ export class Configs extends BaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
getActiveLargerThanId(id) {
|
getActiveLargerThanId(id) {
|
||||||
return this.configs
|
return this.configs.createQueryBuilder().where("id > :id", { id }).andWhere("is_active = 1").getMany();
|
||||||
.createQueryBuilder()
|
|
||||||
.where("id > :id", { id })
|
|
||||||
.andWhere("is_active = 1")
|
|
||||||
.getMany();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasConfig(key) {
|
async hasConfig(key) {
|
||||||
|
@ -65,7 +61,7 @@ export class Configs extends BaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveNewRevision(key, config, editedBy) {
|
async saveNewRevision(key, config, editedBy) {
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const repo = entityManager.getRepository(Config);
|
const repo = entityManager.getRepository(Config);
|
||||||
// Mark all old revisions inactive
|
// Mark all old revisions inactive
|
||||||
await repo.update({ key }, { is_active: false });
|
await repo.update({ key }, { is_active: false });
|
||||||
|
|
|
@ -13,9 +13,9 @@
|
||||||
"MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}",
|
"MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}",
|
||||||
"MEMBER_JOIN": "📥 {new} {userMention(member)} joined (created {account_age} ago)",
|
"MEMBER_JOIN": "📥 {new} {userMention(member)} joined (created {account_age} ago)",
|
||||||
"MEMBER_LEAVE": "📤 {userMention(member)} left the server",
|
"MEMBER_LEAVE": "📤 {userMention(member)} left the server",
|
||||||
"MEMBER_ROLE_ADD": "🔑 {userMention(member)}: role(s) **{roles}** added by {userMention(mod)}",
|
"MEMBER_ROLE_ADD": "🔑 {userMention(member)} received roles: **{roles}**",
|
||||||
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)}: role(s) **{roles}** removed by {userMention(mod)}",
|
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)} lost roles: **{roles}**",
|
||||||
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)}: roles changed: added **{addedRoles}**, removed **{removedRoles}** by {userMention(mod)}",
|
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**",
|
||||||
"MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
|
"MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
|
||||||
"MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",
|
"MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",
|
||||||
"MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin",
|
"MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin",
|
||||||
|
@ -50,9 +50,9 @@
|
||||||
"STAGE_INSTANCE_DELETE": "📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>",
|
"STAGE_INSTANCE_DELETE": "📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>",
|
||||||
"STAGE_INSTANCE_UPDATE": "📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}",
|
"STAGE_INSTANCE_UPDATE": "📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}",
|
||||||
|
|
||||||
"EMOJI_CREATE": "<{emoji.identifier}> Emoji `{emoji.name} ({emoji.id})` was created",
|
"EMOJI_CREATE": "{emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created",
|
||||||
"EMOJI_DELETE": "👋 Emoji `{emoji.name} ({emoji.id})` was deleted",
|
"EMOJI_DELETE": "👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted",
|
||||||
"EMOJI_UPDATE": "<{newEmoji.identifier}> Emoji `{newEmoji.name} ({newEmoji.id})` was updated. Changes:\n{differenceString}",
|
"EMOJI_UPDATE": "{newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}",
|
||||||
|
|
||||||
"STICKER_CREATE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}",
|
"STICKER_CREATE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}",
|
||||||
"STICKER_DELETE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.",
|
"STICKER_DELETE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.",
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import { Guild, Snowflake } from "discord.js";
|
import { Guild, Snowflake, User } from "discord.js";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { isDefaultSticker } from "src/utils/isDefaultSticker";
|
import { isDefaultSticker } from "src/utils/isDefaultSticker";
|
||||||
import { getRepository, Repository } from "typeorm";
|
import { getRepository, Repository } from "typeorm";
|
||||||
import { renderTemplate } from "../templateFormatter";
|
import { renderTemplate, TemplateSafeValueContainer } from "../templateFormatter";
|
||||||
import { trimLines } from "../utils";
|
import { trimLines } from "../utils";
|
||||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||||
import { ArchiveEntry } from "./entities/ArchiveEntry";
|
import { ArchiveEntry } from "./entities/ArchiveEntry";
|
||||||
import { SavedMessage } from "./entities/SavedMessage";
|
import { SavedMessage } from "./entities/SavedMessage";
|
||||||
|
import {
|
||||||
|
channelToTemplateSafeChannel,
|
||||||
|
guildToTemplateSafeGuild,
|
||||||
|
userToTemplateSafeUser,
|
||||||
|
} from "../utils/templateSafeObjects";
|
||||||
|
|
||||||
const DEFAULT_EXPIRY_DAYS = 30;
|
const DEFAULT_EXPIRY_DAYS = 30;
|
||||||
|
|
||||||
|
@ -75,21 +80,25 @@ export class GuildArchives extends BaseGuildRepository {
|
||||||
const msgLines: string[] = [];
|
const msgLines: string[] = [];
|
||||||
for (const msg of savedMessages) {
|
for (const msg of savedMessages) {
|
||||||
const channel = guild.channels.cache.get(msg.channel_id as Snowflake);
|
const channel = guild.channels.cache.get(msg.channel_id as Snowflake);
|
||||||
const user = { ...msg.data.author, id: msg.user_id };
|
const partialUser = new TemplateSafeValueContainer({ ...msg.data.author, id: msg.user_id });
|
||||||
|
|
||||||
const line = await renderTemplate(MESSAGE_ARCHIVE_MESSAGE_FORMAT, {
|
const line = await renderTemplate(
|
||||||
id: msg.id,
|
MESSAGE_ARCHIVE_MESSAGE_FORMAT,
|
||||||
timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
|
new TemplateSafeValueContainer({
|
||||||
content: msg.data.content,
|
id: msg.id,
|
||||||
attachments: msg.data.attachments?.map(att => {
|
timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
|
||||||
return JSON.stringify({ name: att.name, url: att.url, type: att.contentType });
|
content: msg.data.content,
|
||||||
|
attachments: msg.data.attachments?.map((att) => {
|
||||||
|
return JSON.stringify({ name: att.name, url: att.url, type: att.contentType });
|
||||||
|
}),
|
||||||
|
stickers: msg.data.stickers?.map((sti) => {
|
||||||
|
return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) });
|
||||||
|
}),
|
||||||
|
user: partialUser,
|
||||||
|
channel: channel ? channelToTemplateSafeChannel(channel) : null,
|
||||||
}),
|
}),
|
||||||
stickers: msg.data.stickers?.map(sti => {
|
);
|
||||||
return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) });
|
|
||||||
}),
|
|
||||||
user,
|
|
||||||
channel,
|
|
||||||
});
|
|
||||||
msgLines.push(line);
|
msgLines.push(line);
|
||||||
}
|
}
|
||||||
return msgLines;
|
return msgLines;
|
||||||
|
@ -100,7 +109,12 @@ export class GuildArchives extends BaseGuildRepository {
|
||||||
expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days");
|
expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days");
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild });
|
const headerStr = await renderTemplate(
|
||||||
|
MESSAGE_ARCHIVE_HEADER_FORMAT,
|
||||||
|
new TemplateSafeValueContainer({
|
||||||
|
guild: guildToTemplateSafeGuild(guild),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);
|
const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);
|
||||||
const messagesStr = msgLines.join("\n");
|
const messagesStr = msgLines.join("\n");
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { getRepository, In, Repository } from "typeorm";
|
import { getRepository, In, InsertResult, Repository } from "typeorm";
|
||||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||||
import { CaseTypes } from "./CaseTypes";
|
import { CaseTypes } from "./CaseTypes";
|
||||||
import { connection } from "./db";
|
import { connection } from "./db";
|
||||||
|
@ -116,13 +116,30 @@ export class GuildCases extends BaseGuildRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data): Promise<Case> {
|
async createInternal(data): Promise<InsertResult> {
|
||||||
const result = await this.cases.insert({
|
return this.cases
|
||||||
...data,
|
.insert({
|
||||||
guild_id: this.guildId,
|
...data,
|
||||||
case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`,
|
guild_id: this.guildId,
|
||||||
});
|
case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err?.code === "ER_DUP_ENTRY") {
|
||||||
|
if (data.audit_log_id) {
|
||||||
|
console.trace(`Tried to insert case with duplicate audit_log_id`);
|
||||||
|
return this.createInternal({
|
||||||
|
...data,
|
||||||
|
audit_log_id: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data): Promise<Case> {
|
||||||
|
const result = await this.createInternal(data);
|
||||||
return (await this.find(result.identifiers[0].id))!;
|
return (await this.find(result.identifiers[0].id))!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,7 +148,7 @@ export class GuildCases extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) {
|
async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) {
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const cases = entityManager.getRepository(Case);
|
const cases = entityManager.getRepository(Case);
|
||||||
const caseNotes = entityManager.getRepository(CaseNote);
|
const caseNotes = entityManager.getRepository(CaseNote);
|
||||||
|
|
||||||
|
|
|
@ -17,19 +17,11 @@ const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
|
||||||
const decayQueue = new Queue();
|
const decayQueue = new Queue();
|
||||||
|
|
||||||
async function deleteCountersMarkedToBeDeleted(): Promise<void> {
|
async function deleteCountersMarkedToBeDeleted(): Promise<void> {
|
||||||
await getRepository(Counter)
|
await getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||||
.createQueryBuilder()
|
|
||||||
.where("delete_at <= NOW()")
|
|
||||||
.delete()
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTriggersMarkedToBeDeleted(): Promise<void> {
|
async function deleteTriggersMarkedToBeDeleted(): Promise<void> {
|
||||||
await getRepository(CounterTrigger)
|
await getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||||
.createQueryBuilder()
|
|
||||||
.where("delete_at <= NOW()")
|
|
||||||
.delete()
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);
|
setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);
|
||||||
|
@ -97,10 +89,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
criteria.id = Not(In(idsToKeep));
|
criteria.id = Not(In(idsToKeep));
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAt = moment
|
const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, "ms").format(DBDateFormat);
|
||||||
.utc()
|
|
||||||
.add(DELETE_UNUSED_COUNTERS_AFTER, "ms")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
|
|
||||||
await this.counters.update(criteria, {
|
await this.counters.update(criteria, {
|
||||||
delete_at: deleteAt,
|
delete_at: deleteAt,
|
||||||
|
@ -108,11 +97,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCountersMarkedToBeDeleted(): Promise<void> {
|
async deleteCountersMarkedToBeDeleted(): Promise<void> {
|
||||||
await this.counters
|
await this.counters.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||||
.createQueryBuilder()
|
|
||||||
.where("delete_at <= NOW()")
|
|
||||||
.delete()
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeCounterValue(
|
async changeCounterValue(
|
||||||
|
@ -230,14 +215,11 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
const triggersToMark = await triggersToMarkQuery.getMany();
|
const triggersToMark = await triggersToMarkQuery.getMany();
|
||||||
|
|
||||||
if (triggersToMark.length) {
|
if (triggersToMark.length) {
|
||||||
const deleteAt = moment
|
const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms").format(DBDateFormat);
|
||||||
.utc()
|
|
||||||
.add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
|
|
||||||
await this.counterTriggers.update(
|
await this.counterTriggers.update(
|
||||||
{
|
{
|
||||||
id: In(triggersToMark.map(t => t.id)),
|
id: In(triggersToMark.map((t) => t.id)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
delete_at: deleteAt,
|
delete_at: deleteAt,
|
||||||
|
@ -247,11 +229,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTriggersMarkedToBeDeleted(): Promise<void> {
|
async deleteTriggersMarkedToBeDeleted(): Promise<void> {
|
||||||
await this.counterTriggers
|
await this.counterTriggers.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||||
.createQueryBuilder()
|
|
||||||
.where("delete_at <= NOW()")
|
|
||||||
.delete()
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initCounterTrigger(
|
async initCounterTrigger(
|
||||||
|
@ -278,7 +256,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);
|
throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const existing = await entityManager.findOne(CounterTrigger, {
|
const existing = await entityManager.findOne(CounterTrigger, {
|
||||||
counter_id: counterId,
|
counter_id: counterId,
|
||||||
name: triggerName,
|
name: triggerName,
|
||||||
|
@ -330,7 +308,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
channelId = channelId || "0";
|
channelId = channelId || "0";
|
||||||
userId = userId || "0";
|
userId = userId || "0";
|
||||||
|
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {
|
const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {
|
||||||
trigger_id: counterTrigger.id,
|
trigger_id: counterTrigger.id,
|
||||||
user_id: userId!,
|
user_id: userId!,
|
||||||
|
@ -378,7 +356,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
async checkAllValuesForTrigger(
|
async checkAllValuesForTrigger(
|
||||||
counterTrigger: CounterTrigger,
|
counterTrigger: CounterTrigger,
|
||||||
): Promise<Array<{ channelId: string; userId: string }>> {
|
): Promise<Array<{ channelId: string; userId: string }>> {
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const matchingValues = await entityManager
|
const matchingValues = await entityManager
|
||||||
.createQueryBuilder(CounterValue, "cv")
|
.createQueryBuilder(CounterValue, "cv")
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
|
@ -395,7 +373,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
if (matchingValues.length) {
|
if (matchingValues.length) {
|
||||||
await entityManager.insert(
|
await entityManager.insert(
|
||||||
CounterTriggerState,
|
CounterTriggerState,
|
||||||
matchingValues.map(row => ({
|
matchingValues.map((row) => ({
|
||||||
trigger_id: counterTrigger.id,
|
trigger_id: counterTrigger.id,
|
||||||
channel_id: row.channel_id,
|
channel_id: row.channel_id,
|
||||||
user_id: row.user_id,
|
user_id: row.user_id,
|
||||||
|
@ -403,7 +381,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchingValues.map(row => ({
|
return matchingValues.map((row) => ({
|
||||||
channelId: row.channel_id,
|
channelId: row.channel_id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
}));
|
}));
|
||||||
|
@ -429,7 +407,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
channelId = channelId || "0";
|
channelId = channelId || "0";
|
||||||
userId = userId || "0";
|
userId = userId || "0";
|
||||||
|
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const matchingValue = await entityManager
|
const matchingValue = await entityManager
|
||||||
.createQueryBuilder(CounterValue, "cv")
|
.createQueryBuilder(CounterValue, "cv")
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
|
@ -468,7 +446,7 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
async checkAllValuesForReverseTrigger(
|
async checkAllValuesForReverseTrigger(
|
||||||
counterTrigger: CounterTrigger,
|
counterTrigger: CounterTrigger,
|
||||||
): Promise<Array<{ channelId: string; userId: string }>> {
|
): Promise<Array<{ channelId: string; userId: string }>> {
|
||||||
return connection.transaction(async entityManager => {
|
return connection.transaction(async (entityManager) => {
|
||||||
const matchingValues: Array<{
|
const matchingValues: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
triggerStateId: string;
|
triggerStateId: string;
|
||||||
|
@ -496,11 +474,11 @@ export class GuildCounters extends BaseGuildRepository {
|
||||||
|
|
||||||
if (matchingValues.length) {
|
if (matchingValues.length) {
|
||||||
await entityManager.delete(CounterTriggerState, {
|
await entityManager.delete(CounterTriggerState, {
|
||||||
id: In(matchingValues.map(v => v.triggerStateId)),
|
id: In(matchingValues.map((v) => v.triggerStateId)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchingValues.map(row => ({
|
return matchingValues.map((row) => ({
|
||||||
channelId: row.channel_id,
|
channelId: row.channel_id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -1,42 +1,54 @@
|
||||||
import { QueuedEventEmitter } from "../QueuedEventEmitter";
|
import { Mute } from "./entities/Mute";
|
||||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
import { ScheduledPost } from "./entities/ScheduledPost";
|
||||||
|
import { Reminder } from "./entities/Reminder";
|
||||||
|
|
||||||
export class GuildEvents extends BaseGuildRepository {
|
interface GuildEventArgs extends Record<string, unknown[]> {
|
||||||
private queuedEventEmitter: QueuedEventEmitter;
|
expiredMutes: [Mute[]];
|
||||||
private pluginListeners: Map<string, Map<string, any[]>>;
|
scheduledPosts: [ScheduledPost[]];
|
||||||
|
reminders: [Reminder[]];
|
||||||
|
}
|
||||||
|
|
||||||
constructor(guildId) {
|
type GuildEvent = keyof GuildEventArgs;
|
||||||
super(guildId);
|
|
||||||
this.queuedEventEmitter = new QueuedEventEmitter();
|
type GuildEventListener<K extends GuildEvent> = (...args: GuildEventArgs[K]) => void;
|
||||||
|
|
||||||
|
type ListenerMap = {
|
||||||
|
[K in GuildEvent]?: Array<GuildEventListener<K>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const guildListeners: Map<string, ListenerMap> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return - Function to unregister the listener
|
||||||
|
*/
|
||||||
|
export function onGuildEvent<K extends GuildEvent>(
|
||||||
|
guildId: string,
|
||||||
|
eventName: K,
|
||||||
|
listener: GuildEventListener<K>,
|
||||||
|
): () => void {
|
||||||
|
if (!guildListeners.has(guildId)) {
|
||||||
|
guildListeners.set(guildId, {});
|
||||||
}
|
}
|
||||||
|
const listenerMap = guildListeners.get(guildId)!;
|
||||||
public on(pluginName: string, eventName: string, fn) {
|
if (listenerMap[eventName] == null) {
|
||||||
this.queuedEventEmitter.on(eventName, fn);
|
listenerMap[eventName] = [];
|
||||||
|
|
||||||
if (!this.pluginListeners.has(pluginName)) {
|
|
||||||
this.pluginListeners.set(pluginName, new Map());
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginListeners = this.pluginListeners.get(pluginName)!;
|
|
||||||
if (!pluginListeners.has(eventName)) {
|
|
||||||
pluginListeners.set(eventName, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginEventListeners = pluginListeners.get(eventName)!;
|
|
||||||
pluginEventListeners.push(fn);
|
|
||||||
}
|
}
|
||||||
|
listenerMap[eventName]!.push(listener);
|
||||||
|
|
||||||
public offPlugin(pluginName: string) {
|
return () => {
|
||||||
const pluginListeners = this.pluginListeners.get(pluginName) || new Map();
|
listenerMap[eventName]!.splice(listenerMap[eventName]!.indexOf(listener), 1);
|
||||||
for (const [eventName, listeners] of Array.from(pluginListeners.entries())) {
|
};
|
||||||
for (const listener of listeners) {
|
}
|
||||||
this.queuedEventEmitter.off(eventName, listener);
|
|
||||||
}
|
export function emitGuildEvent<K extends GuildEvent>(guildId: string, eventName: K, args: GuildEventArgs[K]): void {
|
||||||
}
|
if (!guildListeners.has(guildId)) {
|
||||||
this.pluginListeners.delete(pluginName);
|
return;
|
||||||
}
|
}
|
||||||
|
const listenerMap = guildListeners.get(guildId)!;
|
||||||
public emit(eventName: string, args: any[] = []) {
|
if (listenerMap[eventName] == null) {
|
||||||
return this.queuedEventEmitter.emit(eventName, args);
|
return;
|
||||||
|
}
|
||||||
|
for (const listener of listenerMap[eventName]!) {
|
||||||
|
listener(...args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,12 +46,12 @@ export class GuildLogs extends events.EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
isLogIgnored(type: LogType, ignoreId: any) {
|
isLogIgnored(type: LogType, ignoreId: any) {
|
||||||
return this.ignoredLogs.some(info => type === info.type && ignoreId === info.ignoreId);
|
return this.ignoredLogs.some((info) => type === info.type && ignoreId === info.ignoreId);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearIgnoredLog(type: LogType, ignoreId: any) {
|
clearIgnoredLog(type: LogType, ignoreId: any) {
|
||||||
this.ignoredLogs.splice(
|
this.ignoredLogs.splice(
|
||||||
this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId),
|
this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ export class GuildMemberTimezones extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(memberId, timezone: string) {
|
async set(memberId, timezone: string) {
|
||||||
await connection.transaction(async entityManager => {
|
await connection.transaction(async (entityManager) => {
|
||||||
const repo = entityManager.getRepository(MemberTimezone);
|
const repo = entityManager.getRepository(MemberTimezone);
|
||||||
const existingRow = await repo.findOne({
|
const existingRow = await repo.findOne({
|
||||||
guild_id: this.guildId,
|
guild_id: this.guildId,
|
||||||
|
|
|
@ -35,12 +35,7 @@ export class GuildMutes extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
|
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
|
||||||
const expiresAt = expiryTime
|
const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
|
||||||
? moment
|
|
||||||
.utc()
|
|
||||||
.add(expiryTime, "ms")
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const result = await this.mutes.insert({
|
const result = await this.mutes.insert({
|
||||||
guild_id: this.guildId,
|
guild_id: this.guildId,
|
||||||
|
@ -53,12 +48,7 @@ export class GuildMutes extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) {
|
async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) {
|
||||||
const expiresAt = newExpiryTime
|
const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
|
||||||
? moment
|
|
||||||
.utc()
|
|
||||||
.add(newExpiryTime, "ms")
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (rolesToRestore && rolesToRestore.length) {
|
if (rolesToRestore && rolesToRestore.length) {
|
||||||
return this.mutes.update(
|
return this.mutes.update(
|
||||||
|
@ -89,7 +79,7 @@ export class GuildMutes extends BaseGuildRepository {
|
||||||
.createQueryBuilder("mutes")
|
.createQueryBuilder("mutes")
|
||||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
new Brackets(qb => {
|
new Brackets((qb) => {
|
||||||
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
|
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
@ -70,7 +70,7 @@ export class GuildNicknameHistory extends BaseGuildRepository {
|
||||||
|
|
||||||
if (toDelete.length > 0) {
|
if (toDelete.length > 0) {
|
||||||
await this.nicknameHistory.delete({
|
await this.nicknameHistory.delete({
|
||||||
id: In(toDelete.map(v => v.id)),
|
id: In(toDelete.map((v) => v.id)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,9 +45,90 @@ export class GuildSavedMessages extends BaseGuildRepository {
|
||||||
timestamp: msg.createdTimestamp,
|
timestamp: msg.createdTimestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (msg.attachments.size) data.attachments = [...msg.attachments.values()];
|
if (msg.attachments.size) {
|
||||||
if (msg.embeds.length) data.embeds = msg.embeds;
|
data.attachments = Array.from(msg.attachments.values()).map((att) => ({
|
||||||
if (msg.stickers?.size) data.stickers = [...msg.stickers.values()];
|
id: att.id,
|
||||||
|
contentType: att.contentType,
|
||||||
|
name: att.name,
|
||||||
|
proxyURL: att.proxyURL,
|
||||||
|
size: att.size,
|
||||||
|
spoiler: att.spoiler,
|
||||||
|
url: att.url,
|
||||||
|
width: att.width,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.embeds.length) {
|
||||||
|
data.embeds = msg.embeds.map((embed) => ({
|
||||||
|
title: embed.title,
|
||||||
|
description: embed.description,
|
||||||
|
url: embed.url,
|
||||||
|
timestamp: embed.timestamp,
|
||||||
|
color: embed.color,
|
||||||
|
|
||||||
|
fields: embed.fields.map((field) => ({
|
||||||
|
name: field.name,
|
||||||
|
value: field.value,
|
||||||
|
inline: field.inline,
|
||||||
|
})),
|
||||||
|
|
||||||
|
author: embed.author
|
||||||
|
? {
|
||||||
|
name: embed.author.name,
|
||||||
|
url: embed.author.url,
|
||||||
|
iconURL: embed.author.iconURL,
|
||||||
|
proxyIconURL: embed.author.proxyIconURL,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
thumbnail: embed.thumbnail
|
||||||
|
? {
|
||||||
|
url: embed.thumbnail.url,
|
||||||
|
proxyURL: embed.thumbnail.proxyURL,
|
||||||
|
height: embed.thumbnail.height,
|
||||||
|
width: embed.thumbnail.width,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
image: embed.image
|
||||||
|
? {
|
||||||
|
url: embed.image.url,
|
||||||
|
proxyURL: embed.image.proxyURL,
|
||||||
|
height: embed.image.height,
|
||||||
|
width: embed.image.width,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
video: embed.video
|
||||||
|
? {
|
||||||
|
url: embed.video.url,
|
||||||
|
proxyURL: embed.video.proxyURL,
|
||||||
|
height: embed.video.height,
|
||||||
|
width: embed.video.width,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
footer: embed.footer
|
||||||
|
? {
|
||||||
|
text: embed.footer.text,
|
||||||
|
iconURL: embed.footer.iconURL,
|
||||||
|
proxyIconURL: embed.footer.proxyIconURL,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.stickers?.size) {
|
||||||
|
data.stickers = Array.from(msg.stickers.values()).map((sticker) => ({
|
||||||
|
format: sticker.format,
|
||||||
|
guildId: sticker.guildId,
|
||||||
|
id: sticker.id,
|
||||||
|
name: sticker.name,
|
||||||
|
description: sticker.description,
|
||||||
|
available: sticker.available,
|
||||||
|
type: sticker.type,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
@ -131,8 +212,12 @@ export class GuildSavedMessages extends BaseGuildRepository {
|
||||||
try {
|
try {
|
||||||
await this.messages.insert(data);
|
await this.messages.insert(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e); // tslint:disable-line
|
if (e?.code === "ER_DUP_ENTRY") {
|
||||||
return;
|
console.trace(`Tried to insert duplicate message ID: ${data.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await this.messages.findOne(data.id);
|
const inserted = await this.messages.findOne(data.id);
|
||||||
|
@ -141,8 +226,10 @@ export class GuildSavedMessages extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFromMsg(msg: Message, overrides = {}) {
|
async createFromMsg(msg: Message, overrides = {}) {
|
||||||
const existingSavedMsg = await this.find(msg.id);
|
// FIXME: Hotfix
|
||||||
if (existingSavedMsg) return;
|
if (!msg.channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const savedMessageData = this.msgToSavedMessageData(msg);
|
const savedMessageData = this.msgToSavedMessageData(msg);
|
||||||
const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss");
|
const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
|
|
@ -11,10 +11,7 @@ export class GuildScheduledPosts extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
all(): Promise<ScheduledPost[]> {
|
all(): Promise<ScheduledPost[]> {
|
||||||
return this.scheduledPosts
|
return this.scheduledPosts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
|
||||||
.createQueryBuilder()
|
|
||||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
|
||||||
.getMany();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getDueScheduledPosts(): Promise<ScheduledPost[]> {
|
getDueScheduledPosts(): Promise<ScheduledPost[]> {
|
||||||
|
|
|
@ -67,10 +67,7 @@ export class GuildSlowmodes extends BaseGuildRepository {
|
||||||
const slowmode = await this.getChannelSlowmode(channelId);
|
const slowmode = await this.getChannelSlowmode(channelId);
|
||||||
if (!slowmode) return;
|
if (!slowmode) return;
|
||||||
|
|
||||||
const expiresAt = moment
|
const expiresAt = moment.utc().add(slowmode.slowmode_seconds, "seconds").format("YYYY-MM-DD HH:mm:ss");
|
||||||
.utc()
|
|
||||||
.add(slowmode.slowmode_seconds, "seconds")
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
|
|
||||||
if (await this.userHasSlowmode(channelId, userId)) {
|
if (await this.userHasSlowmode(channelId, userId)) {
|
||||||
// Update existing
|
// Update existing
|
||||||
|
|
|
@ -30,10 +30,7 @@ export class GuildTempbans extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTempban(userId, expiryTime, modId): Promise<Tempban> {
|
async addTempban(userId, expiryTime, modId): Promise<Tempban> {
|
||||||
const expiresAt = moment
|
const expiresAt = moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
|
||||||
.utc()
|
|
||||||
.add(expiryTime, "ms")
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
|
|
||||||
const result = await this.tempbans.insert({
|
const result = await this.tempbans.insert({
|
||||||
guild_id: this.guildId,
|
guild_id: this.guildId,
|
||||||
|
@ -47,10 +44,7 @@ export class GuildTempbans extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateExpiryTime(userId, newExpiryTime, modId) {
|
async updateExpiryTime(userId, newExpiryTime, modId) {
|
||||||
const expiresAt = moment
|
const expiresAt = moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
|
||||||
.utc()
|
|
||||||
.add(newExpiryTime, "ms")
|
|
||||||
.format("YYYY-MM-DD HH:mm:ss");
|
|
||||||
|
|
||||||
return this.tempbans.update(
|
return this.tempbans.update(
|
||||||
{
|
{
|
||||||
|
|
|
@ -19,10 +19,7 @@ export class GuildVCAlerts extends BaseGuildRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllGuildAlerts(): Promise<VCAlert[]> {
|
async getAllGuildAlerts(): Promise<VCAlert[]> {
|
||||||
return this.allAlerts
|
return this.allAlerts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
|
||||||
.createQueryBuilder()
|
|
||||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
|
||||||
.getMany();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAlertsByUserId(userId: string): Promise<VCAlert[]> {
|
async getAlertsByUserId(userId: string): Promise<VCAlert[]> {
|
||||||
|
|
|
@ -67,7 +67,7 @@ export class UsernameHistory extends BaseRepository {
|
||||||
|
|
||||||
if (toDelete.length > 0) {
|
if (toDelete.length > 0) {
|
||||||
await this.usernameHistory.delete({
|
await this.usernameHistory.delete({
|
||||||
id: In(toDelete.map(v => v.id)),
|
id: In(toDelete.map((v) => v.id)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
47
backend/src/data/apiAuditLogTypes.ts
Normal file
47
backend/src/data/apiAuditLogTypes.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { ApiPermissionTypes } from "./ApiPermissionAssignments";
|
||||||
|
|
||||||
|
export const AuditLogEventTypes = {
|
||||||
|
ADD_API_PERMISSION: "ADD_API_PERMISSION" as const,
|
||||||
|
EDIT_API_PERMISSION: "EDIT_API_PERMISSION" as const,
|
||||||
|
REMOVE_API_PERMISSION: "REMOVE_API_PERMISSION" as const,
|
||||||
|
EDIT_CONFIG: "EDIT_CONFIG" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogEventType = keyof typeof AuditLogEventTypes;
|
||||||
|
|
||||||
|
export type AddApiPermissionEventData = {
|
||||||
|
target_id: string;
|
||||||
|
permissions: string[];
|
||||||
|
expires_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveApiPermissionEventData = {
|
||||||
|
target_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditConfigEventData = {};
|
||||||
|
|
||||||
|
export interface AuditLogEventData extends Record<AuditLogEventType, unknown> {
|
||||||
|
ADD_API_PERMISSION: {
|
||||||
|
type: ApiPermissionTypes;
|
||||||
|
target_id: string;
|
||||||
|
permissions: string[];
|
||||||
|
expires_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
EDIT_API_PERMISSION: {
|
||||||
|
type: ApiPermissionTypes;
|
||||||
|
target_id: string;
|
||||||
|
permissions: string[];
|
||||||
|
expires_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
REMOVE_API_PERMISSION: {
|
||||||
|
type: ApiPermissionTypes;
|
||||||
|
target_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
EDIT_CONFIG: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyAuditLogEventData = AuditLogEventData[AuditLogEventType];
|
|
@ -13,10 +13,7 @@ export async function cleanupConfigs() {
|
||||||
let rows;
|
let rows;
|
||||||
|
|
||||||
// >1 month old: 1 config retained per month
|
// >1 month old: 1 config retained per month
|
||||||
const oneMonthCutoff = moment
|
const oneMonthCutoff = moment.utc().subtract(30, "days").format(DBDateFormat);
|
||||||
.utc()
|
|
||||||
.subtract(30, "days")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
do {
|
do {
|
||||||
rows = await connection.query(
|
rows = await connection.query(
|
||||||
`
|
`
|
||||||
|
@ -46,7 +43,7 @@ export async function cleanupConfigs() {
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await configRepository.delete({
|
await configRepository.delete({
|
||||||
id: In(rows.map(r => r.id)),
|
id: In(rows.map((r) => r.id)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,10 +51,7 @@ export async function cleanupConfigs() {
|
||||||
} while (rows.length === CLEAN_PER_LOOP);
|
} while (rows.length === CLEAN_PER_LOOP);
|
||||||
|
|
||||||
// >2 weeks old: 1 config retained per day
|
// >2 weeks old: 1 config retained per day
|
||||||
const twoWeekCutoff = moment
|
const twoWeekCutoff = moment.utc().subtract(2, "weeks").format(DBDateFormat);
|
||||||
.utc()
|
|
||||||
.subtract(2, "weeks")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
do {
|
do {
|
||||||
rows = await connection.query(
|
rows = await connection.query(
|
||||||
`
|
`
|
||||||
|
@ -87,7 +81,7 @@ export async function cleanupConfigs() {
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await configRepository.delete({
|
await configRepository.delete({
|
||||||
id: In(rows.map(r => r.id)),
|
id: In(rows.map((r) => r.id)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,61 +11,78 @@ import { SavedMessage } from "../entities/SavedMessage";
|
||||||
const RETENTION_PERIOD = 1 * DAYS;
|
const RETENTION_PERIOD = 1 * DAYS;
|
||||||
const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES;
|
const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES;
|
||||||
const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES;
|
const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES;
|
||||||
const CLEAN_PER_LOOP = 500;
|
const CLEAN_PER_LOOP = 200;
|
||||||
|
|
||||||
export async function cleanupMessages(): Promise<number> {
|
export async function cleanupMessages(): Promise<number> {
|
||||||
let cleaned = 0;
|
let cleaned = 0;
|
||||||
|
|
||||||
const messagesRepository = getRepository(SavedMessage);
|
const messagesRepository = getRepository(SavedMessage);
|
||||||
|
|
||||||
const deletedAtThreshold = moment
|
const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat);
|
||||||
.utc()
|
const postedAtThreshold = moment.utc().subtract(RETENTION_PERIOD, "ms").format(DBDateFormat);
|
||||||
.subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms")
|
const botPostedAtThreshold = moment.utc().subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat);
|
||||||
.format(DBDateFormat);
|
|
||||||
const postedAtThreshold = moment
|
|
||||||
.utc()
|
|
||||||
.subtract(RETENTION_PERIOD, "ms")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
const botPostedAtThreshold = moment
|
|
||||||
.utc()
|
|
||||||
.subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
|
|
||||||
// SELECT + DELETE messages in batches
|
// SELECT + DELETE messages in batches
|
||||||
// This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below
|
// This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below
|
||||||
// when a message was being inserted at the same time
|
// when a message was being inserted at the same time
|
||||||
let rows;
|
let ids: string[];
|
||||||
do {
|
do {
|
||||||
rows = await connection.query(
|
const deletedMessageRows = await connection.query(
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE (
|
WHERE (
|
||||||
deleted_at IS NOT NULL
|
deleted_at IS NOT NULL
|
||||||
AND deleted_at <= ?
|
AND deleted_at <= ?
|
||||||
)
|
)
|
||||||
OR (
|
|
||||||
posted_at <= ?
|
|
||||||
AND is_permanent = 0
|
|
||||||
)
|
|
||||||
OR (
|
|
||||||
is_bot = 1
|
|
||||||
AND posted_at <= ?
|
|
||||||
AND is_permanent = 0
|
|
||||||
)
|
|
||||||
LIMIT ${CLEAN_PER_LOOP}
|
LIMIT ${CLEAN_PER_LOOP}
|
||||||
`,
|
`,
|
||||||
[deletedAtThreshold, postedAtThreshold, botPostedAtThreshold],
|
[deletedAtThreshold],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.length > 0) {
|
const oldPostedRows = await connection.query(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM messages
|
||||||
|
WHERE (
|
||||||
|
posted_at <= ?
|
||||||
|
AND is_permanent = 0
|
||||||
|
)
|
||||||
|
LIMIT ${CLEAN_PER_LOOP}
|
||||||
|
`,
|
||||||
|
[postedAtThreshold],
|
||||||
|
);
|
||||||
|
|
||||||
|
const oldBotPostedRows = await connection.query(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM messages
|
||||||
|
WHERE (
|
||||||
|
is_bot = 1
|
||||||
|
AND posted_at <= ?
|
||||||
|
AND is_permanent = 0
|
||||||
|
)
|
||||||
|
LIMIT ${CLEAN_PER_LOOP}
|
||||||
|
`,
|
||||||
|
[botPostedAtThreshold],
|
||||||
|
);
|
||||||
|
|
||||||
|
ids = Array.from(
|
||||||
|
new Set([
|
||||||
|
...deletedMessageRows.map((r) => r.id),
|
||||||
|
...oldPostedRows.map((r) => r.id),
|
||||||
|
...oldBotPostedRows.map((r) => r.id),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ids.length > 0) {
|
||||||
await messagesRepository.delete({
|
await messagesRepository.delete({
|
||||||
id: In(rows.map(r => r.id)),
|
id: In(ids),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cleaned += rows.length;
|
cleaned += ids.length;
|
||||||
} while (rows.length === CLEAN_PER_LOOP);
|
} while (ids.length > 0);
|
||||||
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,10 +11,7 @@ export async function cleanupNicknames(): Promise<number> {
|
||||||
let cleaned = 0;
|
let cleaned = 0;
|
||||||
|
|
||||||
const nicknameHistoryRepository = getRepository(NicknameHistoryEntry);
|
const nicknameHistoryRepository = getRepository(NicknameHistoryEntry);
|
||||||
const dateThreshold = moment
|
const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
|
||||||
.utc()
|
|
||||||
.subtract(NICKNAME_RETENTION_PERIOD, "ms")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
|
|
||||||
// Clean old nicknames (NICKNAME_RETENTION_PERIOD)
|
// Clean old nicknames (NICKNAME_RETENTION_PERIOD)
|
||||||
let rows;
|
let rows;
|
||||||
|
@ -31,7 +28,7 @@ export async function cleanupNicknames(): Promise<number> {
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await nicknameHistoryRepository.delete({
|
await nicknameHistoryRepository.delete({
|
||||||
id: In(rows.map(r => r.id)),
|
id: In(rows.map((r) => r.id)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,10 +11,7 @@ export async function cleanupUsernames(): Promise<number> {
|
||||||
let cleaned = 0;
|
let cleaned = 0;
|
||||||
|
|
||||||
const usernameHistoryRepository = getRepository(UsernameHistoryEntry);
|
const usernameHistoryRepository = getRepository(UsernameHistoryEntry);
|
||||||
const dateThreshold = moment
|
const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
|
||||||
.utc()
|
|
||||||
.subtract(USERNAME_RETENTION_PERIOD, "ms")
|
|
||||||
.format(DBDateFormat);
|
|
||||||
|
|
||||||
// Clean old usernames (USERNAME_RETENTION_PERIOD)
|
// Clean old usernames (USERNAME_RETENTION_PERIOD)
|
||||||
let rows;
|
let rows;
|
||||||
|
@ -31,7 +28,7 @@ export async function cleanupUsernames(): Promise<number> {
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await usernameHistoryRepository.delete({
|
await usernameHistoryRepository.delete({
|
||||||
id: In(rows.map(r => r.id)),
|
id: In(rows.map((r) => r.id)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,4 +14,10 @@ export class AllowedGuild {
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
created_at: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
25
backend/src/data/entities/ApiAuditLogEntry.ts
Normal file
25
backend/src/data/entities/ApiAuditLogEntry.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
|
||||||
|
import { ApiUserInfo } from "./ApiUserInfo";
|
||||||
|
import { AuditLogEventData, AuditLogEventType } from "../apiAuditLogTypes";
|
||||||
|
|
||||||
|
@Entity("api_audit_log")
|
||||||
|
export class ApiAuditLogEntry<TEventType extends AuditLogEventType> {
|
||||||
|
@Column()
|
||||||
|
@PrimaryColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
guild_id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
author_id: string;
|
||||||
|
|
||||||
|
@Column({ type: String })
|
||||||
|
event_type: TEventType;
|
||||||
|
|
||||||
|
@Column("simple-json")
|
||||||
|
event_data: AuditLogEventData[TEventType];
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
created_at: string;
|
||||||
|
}
|
|
@ -19,10 +19,7 @@ export class ApiLogin {
|
||||||
@Column()
|
@Column()
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
|
||||||
@ManyToOne(
|
@ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.logins)
|
||||||
type => ApiUserInfo,
|
|
||||||
userInfo => userInfo.logins,
|
|
||||||
)
|
|
||||||
@JoinColumn({ name: "user_id" })
|
@JoinColumn({ name: "user_id" })
|
||||||
userInfo: ApiUserInfo;
|
userInfo: ApiUserInfo;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
|
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
|
||||||
import { ApiUserInfo } from "./ApiUserInfo";
|
import { ApiUserInfo } from "./ApiUserInfo";
|
||||||
|
import { ApiPermissionTypes } from "../ApiPermissionAssignments";
|
||||||
|
|
||||||
@Entity("api_permissions")
|
@Entity("api_permissions")
|
||||||
export class ApiPermissionAssignment {
|
export class ApiPermissionAssignment {
|
||||||
|
@ -7,9 +8,9 @@ export class ApiPermissionAssignment {
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ type: String })
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
type: string;
|
type: ApiPermissionTypes;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
|
@ -18,10 +19,10 @@ export class ApiPermissionAssignment {
|
||||||
@Column("simple-array")
|
@Column("simple-array")
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
|
||||||
@ManyToOne(
|
@Column({ type: String, nullable: true })
|
||||||
type => ApiUserInfo,
|
expires_at: string | null;
|
||||||
userInfo => userInfo.permissionAssignments,
|
|
||||||
)
|
@ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.permissionAssignments)
|
||||||
@JoinColumn({ name: "target_id" })
|
@JoinColumn({ name: "target_id" })
|
||||||
userInfo: ApiUserInfo;
|
userInfo: ApiUserInfo;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,15 +20,9 @@ export class ApiUserInfo {
|
||||||
@Column()
|
@Column()
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
||||||
@OneToMany(
|
@OneToMany((type) => ApiLogin, (login) => login.userInfo)
|
||||||
type => ApiLogin,
|
|
||||||
login => login.userInfo,
|
|
||||||
)
|
|
||||||
logins: ApiLogin[];
|
logins: ApiLogin[];
|
||||||
|
|
||||||
@OneToMany(
|
@OneToMany((type) => ApiPermissionAssignment, (p) => p.userInfo)
|
||||||
type => ApiPermissionAssignment,
|
|
||||||
p => p.userInfo,
|
|
||||||
)
|
|
||||||
permissionAssignments: ApiPermissionAssignment[];
|
permissionAssignments: ApiPermissionAssignment[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,9 +35,6 @@ export class Case {
|
||||||
*/
|
*/
|
||||||
@Column({ type: String, nullable: true }) log_message_id: string | null;
|
@Column({ type: String, nullable: true }) log_message_id: string | null;
|
||||||
|
|
||||||
@OneToMany(
|
@OneToMany((type) => CaseNote, (note) => note.case)
|
||||||
type => CaseNote,
|
|
||||||
note => note.case,
|
|
||||||
)
|
|
||||||
notes: CaseNote[];
|
notes: CaseNote[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,10 +15,7 @@ export class CaseNote {
|
||||||
|
|
||||||
@Column() created_at: string;
|
@Column() created_at: string;
|
||||||
|
|
||||||
@ManyToOne(
|
@ManyToOne((type) => Case, (theCase) => theCase.notes)
|
||||||
type => Case,
|
|
||||||
theCase => theCase.notes,
|
|
||||||
)
|
|
||||||
@JoinColumn({ name: "case_id" })
|
@JoinColumn({ name: "case_id" })
|
||||||
case: Case;
|
case: Case;
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,7 @@ export class Config {
|
||||||
@Column()
|
@Column()
|
||||||
edited_at: string;
|
edited_at: string;
|
||||||
|
|
||||||
@ManyToOne(type => ApiUserInfo)
|
@ManyToOne((type) => ApiUserInfo)
|
||||||
@JoinColumn({ name: "edited_by" })
|
@JoinColumn({ name: "edited_by" })
|
||||||
userInfo: ApiUserInfo;
|
userInfo: ApiUserInfo;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,79 @@
|
||||||
import { MessageAttachment, Sticker } from "discord.js";
|
import { Snowflake } from "discord.js";
|
||||||
import { Column, Entity, PrimaryColumn } from "typeorm";
|
import { Column, Entity, PrimaryColumn } from "typeorm";
|
||||||
import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer";
|
import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer";
|
||||||
|
|
||||||
|
export interface ISavedMessageAttachmentData {
|
||||||
|
id: Snowflake;
|
||||||
|
contentType: string | null;
|
||||||
|
name: string | null;
|
||||||
|
proxyURL: string;
|
||||||
|
size: number;
|
||||||
|
spoiler: boolean;
|
||||||
|
url: string;
|
||||||
|
width: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISavedMessageEmbedData {
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
url: string | null;
|
||||||
|
timestamp: number | null;
|
||||||
|
color: number | null;
|
||||||
|
fields: Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
inline: boolean;
|
||||||
|
}>;
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
iconURL?: string;
|
||||||
|
proxyIconURL?: string;
|
||||||
|
};
|
||||||
|
thumbnail?: {
|
||||||
|
url: string;
|
||||||
|
proxyURL?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
};
|
||||||
|
image?: {
|
||||||
|
url: string;
|
||||||
|
proxyURL?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
};
|
||||||
|
video?: {
|
||||||
|
url?: string;
|
||||||
|
proxyURL?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
};
|
||||||
|
footer?: {
|
||||||
|
text?: string;
|
||||||
|
iconURL?: string;
|
||||||
|
proxyIconURL?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISavedMessageStickerData {
|
||||||
|
format: string;
|
||||||
|
guildId: Snowflake | null;
|
||||||
|
id: Snowflake;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
available: boolean | null;
|
||||||
|
type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ISavedMessageData {
|
export interface ISavedMessageData {
|
||||||
attachments?: MessageAttachment[];
|
attachments?: ISavedMessageAttachmentData[];
|
||||||
author: {
|
author: {
|
||||||
username: string;
|
username: string;
|
||||||
discriminator: string;
|
discriminator: string;
|
||||||
};
|
};
|
||||||
content: string;
|
content: string;
|
||||||
embeds?: object[];
|
embeds?: ISavedMessageEmbedData[];
|
||||||
stickers?: Sticker[];
|
stickers?: ISavedMessageStickerData[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ export class StarboardMessage {
|
||||||
@Column()
|
@Column()
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
|
|
||||||
@OneToOne(type => SavedMessage)
|
@OneToOne((type) => SavedMessage)
|
||||||
@JoinColumn({ name: "message_id" })
|
@JoinColumn({ name: "message_id" })
|
||||||
message: SavedMessage;
|
message: SavedMessage;
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ export class StarboardReaction {
|
||||||
@Column()
|
@Column()
|
||||||
reactor_id: string;
|
reactor_id: string;
|
||||||
|
|
||||||
@OneToOne(type => SavedMessage)
|
@OneToOne((type) => SavedMessage)
|
||||||
@JoinColumn({ name: "message_id" })
|
@JoinColumn({ name: "message_id" })
|
||||||
message: SavedMessage;
|
message: SavedMessage;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Client, Intents, TextChannel } from "discord.js";
|
import { Client, Constants, Intents, TextChannel, ThreadChannel } from "discord.js";
|
||||||
import yaml from "js-yaml";
|
|
||||||
import { Knub, PluginError } from "knub";
|
import { Knub, PluginError } from "knub";
|
||||||
import { PluginLoadError } from "knub/dist/plugins/PluginLoadError";
|
import { PluginLoadError } from "knub/dist/plugins/PluginLoadError";
|
||||||
// Always use UTC internally
|
// Always use UTC internally
|
||||||
|
@ -18,8 +17,12 @@ import { RecoverablePluginError } from "./RecoverablePluginError";
|
||||||
import { SimpleError } from "./SimpleError";
|
import { SimpleError } from "./SimpleError";
|
||||||
import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types";
|
import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types";
|
||||||
import { startUptimeCounter } from "./uptime";
|
import { startUptimeCounter } from "./uptime";
|
||||||
import { errorMessage, isDiscordAPIError, isDiscordHTTPError, successMessage } from "./utils";
|
import { errorMessage, isDiscordAPIError, isDiscordHTTPError, SECONDS, successMessage } from "./utils";
|
||||||
import { loadYamlSafely } from "./utils/loadYamlSafely";
|
import { loadYamlSafely } from "./utils/loadYamlSafely";
|
||||||
|
import { DecayingCounter } from "./utils/DecayingCounter";
|
||||||
|
import { PluginNotLoadedError } from "knub/dist/plugins/PluginNotLoadedError";
|
||||||
|
import { logRestCall } from "./restCallStats";
|
||||||
|
import { logRateLimit } from "./rateLimitStats";
|
||||||
|
|
||||||
if (!process.env.KEY) {
|
if (!process.env.KEY) {
|
||||||
// tslint:disable-next-line:no-console
|
// tslint:disable-next-line:no-console
|
||||||
|
@ -94,6 +97,25 @@ function errorHandler(err) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: Hotfix
|
||||||
|
if (err.message && err.message.startsWith("Unknown custom override criteria")) {
|
||||||
|
// console.warn(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: Hotfix
|
||||||
|
if (err.message && err.message.startsWith("Unknown override criteria")) {
|
||||||
|
// console.warn(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof PluginNotLoadedError) {
|
||||||
|
// We don't want to crash the bot here, although this *should not happen*
|
||||||
|
// TODO: Proper system for preventing plugin load/unload race conditions
|
||||||
|
console.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// tslint:disable:no-console
|
// tslint:disable:no-console
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
@ -124,8 +146,8 @@ if (process.env.NODE_ENV === "production") {
|
||||||
|
|
||||||
// Verify required Node.js version
|
// Verify required Node.js version
|
||||||
const REQUIRED_NODE_VERSION = "14.0.0";
|
const REQUIRED_NODE_VERSION = "14.0.0";
|
||||||
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10));
|
const requiredParts = REQUIRED_NODE_VERSION.split(".").map((v) => parseInt(v, 10));
|
||||||
const actualVersionParts = process.versions.node.split(".").map(v => parseInt(v, 10));
|
const actualVersionParts = process.versions.node.split(".").map((v) => parseInt(v, 10));
|
||||||
for (const [i, part] of actualVersionParts.entries()) {
|
for (const [i, part] of actualVersionParts.entries()) {
|
||||||
if (part > requiredParts[i]) break;
|
if (part > requiredParts[i]) break;
|
||||||
if (part === requiredParts[i]) continue;
|
if (part === requiredParts[i]) continue;
|
||||||
|
@ -136,10 +158,21 @@ moment.tz.setDefault("UTC");
|
||||||
|
|
||||||
logger.info("Connecting to database");
|
logger.info("Connecting to database");
|
||||||
connect().then(async () => {
|
connect().then(async () => {
|
||||||
|
const RequestHandler = require("discord.js/src/rest/RequestHandler.js");
|
||||||
|
const originalPush = RequestHandler.prototype.push;
|
||||||
|
// tslint:disable-next-line:only-arrow-functions
|
||||||
|
RequestHandler.prototype.push = function (...args) {
|
||||||
|
const request = args[0];
|
||||||
|
logRestCall(request.method, request.path);
|
||||||
|
return originalPush.call(this, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"],
|
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"],
|
||||||
restTimeOffset: 150,
|
|
||||||
restGlobalRateLimit: 50,
|
restGlobalRateLimit: 50,
|
||||||
|
// restTimeOffset: 1000,
|
||||||
|
|
||||||
// Disable mentions by default
|
// Disable mentions by default
|
||||||
allowedMentions: {
|
allowedMentions: {
|
||||||
parse: [],
|
parse: [],
|
||||||
|
@ -166,11 +199,31 @@ connect().then(async () => {
|
||||||
});
|
});
|
||||||
client.setMaxListeners(200);
|
client.setMaxListeners(200);
|
||||||
|
|
||||||
client.on("rateLimit", rateLimitData => {
|
client.on(Constants.Events.RATE_LIMIT, (data) => {
|
||||||
logger.info(`[429] ${JSON.stringify(rateLimitData)}`);
|
// tslint:disable-next-line:no-console
|
||||||
|
// console.log(`[DEBUG] [RATE_LIMIT] ${JSON.stringify(data)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", err => {
|
const safe429DecayInterval = 5 * SECONDS;
|
||||||
|
const safe429MaxCount = 5;
|
||||||
|
const safe429Counter = new DecayingCounter(safe429DecayInterval);
|
||||||
|
client.on(Constants.Events.DEBUG, (errorText) => {
|
||||||
|
if (!errorText.includes("429")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tslint:disable-next-line:no-console
|
||||||
|
console.warn(`[DEBUG] [WARN] [429] ${errorText}`);
|
||||||
|
|
||||||
|
const value = safe429Counter.add(1);
|
||||||
|
if (value > safe429MaxCount) {
|
||||||
|
// tslint:disable-next-line:no-console
|
||||||
|
console.error(`Too many 429s (over ${safe429MaxCount} in ${safe429MaxCount * safe429DecayInterval}ms), exiting`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
errorHandler(new DiscordJSError(err.message, (err as any).code, 0));
|
errorHandler(new DiscordJSError(err.message, (err as any).code, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -198,9 +251,9 @@ connect().then(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredPlugins = ctx.config.plugins;
|
const configuredPlugins = ctx.config.plugins;
|
||||||
const basePluginNames = baseGuildPlugins.map(p => p.name);
|
const basePluginNames = baseGuildPlugins.map((p) => p.name);
|
||||||
|
|
||||||
return Array.from(plugins.keys()).filter(pluginName => {
|
return Array.from(plugins.keys()).filter((pluginName) => {
|
||||||
if (basePluginNames.includes(pluginName)) return true;
|
if (basePluginNames.includes(pluginName)) return true;
|
||||||
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
|
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
|
||||||
});
|
});
|
||||||
|
@ -208,6 +261,13 @@ connect().then(async () => {
|
||||||
|
|
||||||
async getConfig(id) {
|
async getConfig(id) {
|
||||||
const key = id === "global" ? "global" : `guild-${id}`;
|
const key = id === "global" ? "global" : `guild-${id}`;
|
||||||
|
if (id !== "global") {
|
||||||
|
const allowedGuild = await allowedGuilds.find(id);
|
||||||
|
if (!allowedGuild) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const row = await guildConfigs.getActiveByKey(key);
|
const row = await guildConfigs.getActiveByKey(key);
|
||||||
if (row) {
|
if (row) {
|
||||||
try {
|
try {
|
||||||
|
@ -239,13 +299,15 @@ connect().then(async () => {
|
||||||
},
|
},
|
||||||
|
|
||||||
sendSuccessMessageFn(channel, body) {
|
sendSuccessMessageFn(channel, body) {
|
||||||
const guildId = channel instanceof TextChannel ? channel.guild.id : undefined;
|
const guildId =
|
||||||
|
channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;
|
||||||
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined;
|
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined;
|
||||||
channel.send(successMessage(body, emoji));
|
channel.send(successMessage(body, emoji));
|
||||||
},
|
},
|
||||||
|
|
||||||
sendErrorMessageFn(channel, body) {
|
sendErrorMessageFn(channel, body) {
|
||||||
const guildId = channel instanceof TextChannel ? channel.guild.id : undefined;
|
const guildId =
|
||||||
|
channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;
|
||||||
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined;
|
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined;
|
||||||
channel.send(errorMessage(body, emoji));
|
channel.send(errorMessage(body, emoji));
|
||||||
},
|
},
|
||||||
|
@ -256,6 +318,10 @@ connect().then(async () => {
|
||||||
startUptimeCounter();
|
startUptimeCounter();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
client.on(Constants.Events.RATE_LIMIT, (data) => {
|
||||||
|
logRateLimit(data);
|
||||||
|
});
|
||||||
|
|
||||||
bot.initialize();
|
bot.initialize();
|
||||||
logger.info("Bot Initialized");
|
logger.info("Bot Initialized");
|
||||||
logger.info("Logging in...");
|
logger.info("Logging in...");
|
||||||
|
|
|
@ -9,7 +9,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
|
||||||
|
|
||||||
const migratedUsernames = new Set();
|
const migratedUsernames = new Set();
|
||||||
|
|
||||||
await new Promise(async resolve => {
|
await new Promise(async (resolve) => {
|
||||||
const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history");
|
const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history");
|
||||||
stream.on("data", (row: any) => {
|
stream.on("data", (row: any) => {
|
||||||
migratedUsernames.add(row.key);
|
migratedUsernames.add(row.key);
|
||||||
|
@ -18,7 +18,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
|
||||||
});
|
});
|
||||||
|
|
||||||
const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => {
|
const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => {
|
||||||
return new Promise(async resolve => {
|
return new Promise(async (resolve) => {
|
||||||
const toInsert: any[][] = [];
|
const toInsert: any[][] = [];
|
||||||
const toDelete: number[] = [];
|
const toDelete: number[] = [];
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||||
|
|
||||||
|
export class AddExpiresAtToApiPermissions1630837386329 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumns("api_permissions", [
|
||||||
|
new TableColumn({
|
||||||
|
name: "expires_at",
|
||||||
|
type: "datetime",
|
||||||
|
isNullable: true,
|
||||||
|
default: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumn("api_permissions", "expires_at");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateApiAuditLogTable1630837718830 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: "api_audit_log",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
type: "int",
|
||||||
|
unsigned: true,
|
||||||
|
isPrimary: true,
|
||||||
|
isGenerated: true,
|
||||||
|
generationStrategy: "increment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guild_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "author_id",
|
||||||
|
type: "bigint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "event_type",
|
||||||
|
type: "varchar",
|
||||||
|
length: "255",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "event_data",
|
||||||
|
type: "longtext",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "created_at",
|
||||||
|
type: "datetime",
|
||||||
|
default: "(NOW())",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
new TableIndex({
|
||||||
|
columnNames: ["guild_id", "author_id"],
|
||||||
|
}),
|
||||||
|
new TableIndex({
|
||||||
|
columnNames: ["guild_id", "event_type"],
|
||||||
|
}),
|
||||||
|
new TableIndex({
|
||||||
|
columnNames: ["created_at"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable("api_audit_log");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
|
||||||
|
|
||||||
|
export class AddTimestampsToAllowedGuilds1630840428694 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumns("allowed_guilds", [
|
||||||
|
new TableColumn({
|
||||||
|
name: "created_at",
|
||||||
|
type: "datetime",
|
||||||
|
default: "(NOW())",
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: "updated_at",
|
||||||
|
type: "datetime",
|
||||||
|
default: "(NOW())",
|
||||||
|
onUpdate: "CURRENT_TIMESTAMP",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumn("allowed_guilds", "updated_at");
|
||||||
|
await queryRunner.dropColumn("allowed_guilds", "created_at");
|
||||||
|
}
|
||||||
|
}
|
21
backend/src/migrations/1631474131804-AddIndexToIsBot.ts
Normal file
21
backend/src/migrations/1631474131804-AddIndexToIsBot.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
|
||||||
|
|
||||||
|
export class AddIndexToIsBot1631474131804 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
"messages",
|
||||||
|
new TableIndex({
|
||||||
|
columnNames: ["is_bot"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropIndex(
|
||||||
|
"messages",
|
||||||
|
new TableIndex({
|
||||||
|
columnNames: ["is_bot"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -62,6 +62,8 @@ const PluginOverrideCriteriaType: t.Type<PluginOverrideCriteria<unknown>> = t.re
|
||||||
const validTopLevelOverrideKeys = [
|
const validTopLevelOverrideKeys = [
|
||||||
"channel",
|
"channel",
|
||||||
"category",
|
"category",
|
||||||
|
"thread",
|
||||||
|
"is_thread",
|
||||||
"level",
|
"level",
|
||||||
"user",
|
"user",
|
||||||
"role",
|
"role",
|
||||||
|
@ -83,7 +85,7 @@ export function strictValidationErrorToConfigValidationError(err: StrictValidati
|
||||||
return new ConfigValidationError(
|
return new ConfigValidationError(
|
||||||
err
|
err
|
||||||
.getErrors()
|
.getErrors()
|
||||||
.map(e => e.toString())
|
.map((e) => e.toString())
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -197,7 +199,7 @@ export async function sendSuccessMessage(
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
.send({ ...content }) // Force line break
|
.send({ ...content }) // Force line break
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
|
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
|
||||||
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
|
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -218,7 +220,7 @@ export async function sendErrorMessage(
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
.send({ ...content }) // Force line break
|
.send({ ...content }) // Force line break
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
|
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
|
||||||
logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`);
|
logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -232,7 +234,7 @@ export function getBaseUrl(pluginData: AnyPluginData<any>) {
|
||||||
|
|
||||||
export function isOwner(pluginData: AnyPluginData<any>, userId: string) {
|
export function isOwner(pluginData: AnyPluginData<any>, userId: string) {
|
||||||
const knub = pluginData.getKnubInstance() as TZeppelinKnub;
|
const knub = pluginData.getKnubInstance() as TZeppelinKnub;
|
||||||
const owners = knub.getGlobalConfig().owners;
|
const owners = knub.getGlobalConfig()?.owners;
|
||||||
if (!owners) {
|
if (!owners) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -250,7 +252,7 @@ type AnyFn = (...args: any[]) => any;
|
||||||
* Creates a public plugin function out of a function with pluginData as the first parameter
|
* Creates a public plugin function out of a function with pluginData as the first parameter
|
||||||
*/
|
*/
|
||||||
export function mapToPublicFn<T extends AnyFn>(inputFn: T) {
|
export function mapToPublicFn<T extends AnyFn>(inputFn: T) {
|
||||||
return pluginData => {
|
return (pluginData) => {
|
||||||
return (...args: Tail<Parameters<typeof inputFn>>): ReturnType<typeof inputFn> => {
|
return (...args: Tail<Parameters<typeof inputFn>>): ReturnType<typeof inputFn> => {
|
||||||
return inputFn(pluginData, ...args);
|
return inputFn(pluginData, ...args);
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,7 +25,7 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
|
||||||
configurationGuide: "Maximum deletion delay is currently 5 minutes",
|
configurationGuide: "Maximum deletion delay is currently 5 minutes",
|
||||||
},
|
},
|
||||||
|
|
||||||
dependencies: [TimeAndDatePlugin, LogsPlugin],
|
dependencies: () => [TimeAndDatePlugin, LogsPlugin],
|
||||||
configSchema: ConfigSchema,
|
configSchema: ConfigSchema,
|
||||||
defaultOptions,
|
defaultOptions,
|
||||||
|
|
||||||
|
@ -45,13 +45,13 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
|
||||||
afterLoad(pluginData) {
|
afterLoad(pluginData) {
|
||||||
const { state, guild } = pluginData;
|
const { state, guild } = pluginData;
|
||||||
|
|
||||||
state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg);
|
state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);
|
||||||
state.guildSavedMessages.events.on("create", state.onMessageCreateFn);
|
state.guildSavedMessages.events.on("create", state.onMessageCreateFn);
|
||||||
|
|
||||||
state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg);
|
state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg);
|
||||||
state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn);
|
state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn);
|
||||||
|
|
||||||
state.onMessageDeleteBulkFn = msgs => onMessageDeleteBulk(pluginData, msgs);
|
state.onMessageDeleteBulkFn = (msgs) => onMessageDeleteBulk(pluginData, msgs);
|
||||||
state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn);
|
state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Permissions, Snowflake, TextChannel } from "discord.js";
|
import { Permissions, Snowflake, TextChannel } from "discord.js";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
|
import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { LogType } from "../../../data/LogType";
|
||||||
import { logger } from "../../../logger";
|
import { logger } from "../../../logger";
|
||||||
import { resolveUser, verboseChannelMention } from "../../../utils";
|
import { resolveUser, verboseChannelMention } from "../../../utils";
|
||||||
|
@ -27,7 +27,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
||||||
const perms = channel.permissionsFor(pluginData.client.user!.id);
|
const perms = channel.permissionsFor(pluginData.client.user!.id);
|
||||||
|
|
||||||
if (!hasDiscordPermissions(perms, Permissions.FLAGS.VIEW_CHANNEL | Permissions.FLAGS.READ_MESSAGE_HISTORY)) {
|
if (!hasDiscordPermissions(perms, Permissions.FLAGS.VIEW_CHANNEL | Permissions.FLAGS.READ_MESSAGE_HISTORY)) {
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention(
|
body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention(
|
||||||
channel,
|
channel,
|
||||||
)}`,
|
)}`,
|
||||||
|
@ -36,7 +36,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasDiscordPermissions(perms, Permissions.FLAGS.MANAGE_MESSAGES)) {
|
if (!hasDiscordPermissions(perms, Permissions.FLAGS.MANAGE_MESSAGES)) {
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`,
|
body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -45,7 +45,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
||||||
const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
|
const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
|
||||||
|
|
||||||
pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);
|
pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);
|
||||||
(channel as TextChannel).messages.delete(itemToDelete.message.id as Snowflake).catch(err => {
|
(channel as TextChannel).messages.delete(itemToDelete.message.id as Snowflake).catch((err) => {
|
||||||
if (err.code === 10008) {
|
if (err.code === 10008) {
|
||||||
// "Unknown Message", probably already deleted by automod or another bot, ignore
|
// "Unknown Message", probably already deleted by automod or another bot, ignore
|
||||||
return;
|
return;
|
||||||
|
@ -59,10 +59,10 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
||||||
.inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x"))
|
.inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x"))
|
||||||
.format(timeAndDate.getDateFormat("pretty_datetime"));
|
.format(timeAndDate.getDateFormat("pretty_datetime"));
|
||||||
|
|
||||||
pluginData.state.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, {
|
pluginData.getPlugin(LogsPlugin).logMessageDeleteAuto({
|
||||||
message: itemToDelete.message,
|
message: itemToDelete.message,
|
||||||
user: userToConfigAccessibleUser(user),
|
user,
|
||||||
channel: channelToConfigAccessibleChannel(channel),
|
channel,
|
||||||
messageDate,
|
messageDate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType";
|
||||||
import { convertDelayStringToMS, resolveMember } from "../../../utils";
|
import { convertDelayStringToMS, resolveMember } from "../../../utils";
|
||||||
import { AutoDeletePluginType, MAX_DELAY } from "../types";
|
import { AutoDeletePluginType, MAX_DELAY } from "../types";
|
||||||
import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue";
|
import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue";
|
||||||
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
|
||||||
export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
|
export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
|
||||||
const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
|
const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
|
||||||
|
@ -14,7 +15,7 @@ export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePlug
|
||||||
if (delay > MAX_DELAY) {
|
if (delay > MAX_DELAY) {
|
||||||
delay = MAX_DELAY;
|
delay = MAX_DELAY;
|
||||||
if (!pluginData.state.maxDelayWarningSent) {
|
if (!pluginData.state.maxDelayWarningSent) {
|
||||||
pluginData.state.guildLogs.log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,
|
body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,
|
||||||
});
|
});
|
||||||
pluginData.state.maxDelayWarningSent = true;
|
pluginData.state.maxDelayWarningSent = true;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { AutoDeletePluginType } from "../types";
|
||||||
import { scheduleNextDeletion } from "./scheduleNextDeletion";
|
import { scheduleNextDeletion } from "./scheduleNextDeletion";
|
||||||
|
|
||||||
export function onMessageDelete(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
|
export function onMessageDelete(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
|
||||||
const indexToDelete = pluginData.state.deletionQueue.findIndex(item => item.message.id === msg.id);
|
const indexToDelete = pluginData.state.deletionQueue.findIndex((item) => item.message.id === msg.id);
|
||||||
if (indexToDelete > -1) {
|
if (indexToDelete > -1) {
|
||||||
pluginData.state.deletionQueue.splice(indexToDelete, 1);
|
pluginData.state.deletionQueue.splice(indexToDelete, 1);
|
||||||
scheduleNextDeletion(pluginData);
|
scheduleNextDeletion(pluginData);
|
||||||
|
|
|
@ -33,7 +33,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
|
||||||
`),
|
`),
|
||||||
},
|
},
|
||||||
|
|
||||||
dependencies: [LogsPlugin],
|
dependencies: () => [LogsPlugin],
|
||||||
configSchema: ConfigSchema,
|
configSchema: ConfigSchema,
|
||||||
defaultOptions,
|
defaultOptions,
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ export const AddReactionsEvt = autoReactionsEvt({
|
||||||
);
|
);
|
||||||
if (missingPermissions) {
|
if (missingPermissions) {
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Cannot apply auto-reactions in <#${message.channel.id}>. ${missingPermissionError(missingPermissions)}`,
|
body: `Cannot apply auto-reactions in <#${message.channel.id}>. ${missingPermissionError(missingPermissions)}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -39,11 +39,11 @@ export const AddReactionsEvt = autoReactionsEvt({
|
||||||
if (isDiscordAPIError(e)) {
|
if (isDiscordAPIError(e)) {
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
if (e.code === 10008) {
|
if (e.code === 10008) {
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Make sure nothing is deleting the message before the reactions are applied.`,
|
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Make sure nothing is deleting the message before the reactions are applied.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Error code ${e.code}.`,
|
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Error code ${e.code}.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,7 +57,7 @@ const defaultOptions = {
|
||||||
/**
|
/**
|
||||||
* Config preprocessor to set default values for triggers and perform extra validation
|
* Config preprocessor to set default values for triggers and perform extra validation
|
||||||
*/
|
*/
|
||||||
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
|
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = (options) => {
|
||||||
if (options.config?.rules) {
|
if (options.config?.rules) {
|
||||||
// Loop through each rule
|
// Loop through each rule
|
||||||
for (const [name, rule] of Object.entries(options.config.rules)) {
|
for (const [name, rule] of Object.entries(options.config.rules)) {
|
||||||
|
@ -114,6 +114,21 @@ const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (triggerObj[triggerName].match_mime_type) {
|
||||||
|
const white = triggerObj[triggerName].match_mime_type.whitelist_enabled;
|
||||||
|
const black = triggerObj[triggerName].match_mime_type.blacklist_enabled;
|
||||||
|
|
||||||
|
if (white && black) {
|
||||||
|
throw new StrictValidationError([
|
||||||
|
`Cannot have both blacklist and whitelist enabled at rule <${rule.name}/match_mime_type>`,
|
||||||
|
]);
|
||||||
|
} else if (!white && !black) {
|
||||||
|
throw new StrictValidationError([
|
||||||
|
`Must have either blacklist or whitelist enabled at rule <${rule.name}/match_mime_type>`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -159,7 +174,7 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
|
||||||
info: pluginInfo,
|
info: pluginInfo,
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
dependencies: [
|
dependencies: () => [
|
||||||
LogsPlugin,
|
LogsPlugin,
|
||||||
ModActionsPlugin,
|
ModActionsPlugin,
|
||||||
MutesPlugin,
|
MutesPlugin,
|
||||||
|
@ -217,10 +232,10 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
|
||||||
30 * SECONDS,
|
30 * SECONDS,
|
||||||
);
|
);
|
||||||
|
|
||||||
pluginData.state.onMessageCreateFn = message => runAutomodOnMessage(pluginData, message, false);
|
pluginData.state.onMessageCreateFn = (message) => runAutomodOnMessage(pluginData, message, false);
|
||||||
pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn);
|
pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn);
|
||||||
|
|
||||||
pluginData.state.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true);
|
pluginData.state.onMessageUpdateFn = (message) => runAutomodOnMessage(pluginData, message, true);
|
||||||
pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn);
|
pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn);
|
||||||
|
|
||||||
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
||||||
|
|
|
@ -17,13 +17,13 @@ export const AddRolesAction = automodAction({
|
||||||
defaultConfig: [],
|
defaultConfig: [],
|
||||||
|
|
||||||
async apply({ pluginData, contexts, actionConfig, ruleName }) {
|
async apply({ pluginData, contexts, actionConfig, ruleName }) {
|
||||||
const members = unique(contexts.map(c => c.member).filter(nonNullish));
|
const members = unique(contexts.map((c) => c.member).filter(nonNullish));
|
||||||
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
|
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
|
||||||
|
|
||||||
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
|
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
|
||||||
if (missingPermissions) {
|
if (missingPermissions) {
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
|
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -41,10 +41,10 @@ export const AddRolesAction = automodAction({
|
||||||
|
|
||||||
if (rolesWeCannotAssign.length) {
|
if (rolesWeCannotAssign.length) {
|
||||||
const roleNamesWeCannotAssign = rolesWeCannotAssign.map(
|
const roleNamesWeCannotAssign = rolesWeCannotAssign.map(
|
||||||
roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
|
(roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
|
||||||
);
|
);
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join(
|
body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join(
|
||||||
"**, **",
|
"**, **",
|
||||||
)}**`,
|
)}**`,
|
||||||
|
@ -52,7 +52,7 @@ export const AddRolesAction = automodAction({
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
members.map(async member => {
|
members.map(async (member) => {
|
||||||
const memberRoles = new Set(member.roles.cache.keys());
|
const memberRoles = new Set(member.roles.cache.keys());
|
||||||
for (const roleId of rolesToAssign) {
|
for (const roleId of rolesToAssign) {
|
||||||
memberRoles.add(roleId as Snowflake);
|
memberRoles.add(roleId as Snowflake);
|
||||||
|
|
|
@ -2,6 +2,7 @@ import * as t from "io-ts";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { LogType } from "../../../data/LogType";
|
||||||
import { CountersPlugin } from "../../Counters/CountersPlugin";
|
import { CountersPlugin } from "../../Counters/CountersPlugin";
|
||||||
import { automodAction } from "../helpers";
|
import { automodAction } from "../helpers";
|
||||||
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
|
||||||
export const AddToCounterAction = automodAction({
|
export const AddToCounterAction = automodAction({
|
||||||
configType: t.type({
|
configType: t.type({
|
||||||
|
@ -14,7 +15,7 @@ export const AddToCounterAction = automodAction({
|
||||||
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
|
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
|
||||||
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
||||||
if (!countersPlugin.counterExists(actionConfig.counter)) {
|
if (!countersPlugin.counterExists(actionConfig.counter)) {
|
||||||
pluginData.state.logs.log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
|
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -1,18 +1,27 @@
|
||||||
import { Snowflake, TextChannel } from "discord.js";
|
import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
|
import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { LogType } from "../../../data/LogType";
|
||||||
import { renderTemplate, TemplateParseError } from "../../../templateFormatter";
|
import {
|
||||||
|
createTypedTemplateSafeValueContainer,
|
||||||
|
renderTemplate,
|
||||||
|
TemplateParseError,
|
||||||
|
TemplateSafeValueContainer,
|
||||||
|
} from "../../../templateFormatter";
|
||||||
import {
|
import {
|
||||||
createChunkedMessage,
|
createChunkedMessage,
|
||||||
messageLink,
|
messageLink,
|
||||||
stripObjectToScalars,
|
stripObjectToScalars,
|
||||||
tAllowedMentions,
|
tAllowedMentions,
|
||||||
tNormalizedNullOptional,
|
tNormalizedNullOptional,
|
||||||
|
isTruthy,
|
||||||
verboseChannelMention,
|
verboseChannelMention,
|
||||||
|
validateAndParseMessageContent,
|
||||||
} from "../../../utils";
|
} from "../../../utils";
|
||||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
import { automodAction } from "../helpers";
|
import { automodAction } from "../helpers";
|
||||||
|
import { TemplateSafeUser, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||||
|
import { messageIsEmpty } from "../../../utils/messageIsEmpty";
|
||||||
|
|
||||||
export const AlertAction = automodAction({
|
export const AlertAction = automodAction({
|
||||||
configType: t.type({
|
configType: t.type({
|
||||||
|
@ -27,38 +36,44 @@ export const AlertAction = automodAction({
|
||||||
const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);
|
const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
|
|
||||||
if (channel && channel instanceof TextChannel) {
|
if (channel && (channel instanceof TextChannel || channel instanceof ThreadChannel)) {
|
||||||
const text = actionConfig.text;
|
const text = actionConfig.text;
|
||||||
const theMessageLink =
|
const theMessageLink =
|
||||||
contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id);
|
contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id);
|
||||||
|
|
||||||
const safeUsers = contexts.map(c => c.user && stripObjectToScalars(c.user)).filter(Boolean);
|
const safeUsers = contexts.map((c) => (c.user ? userToTemplateSafeUser(c.user) : null)).filter(isTruthy);
|
||||||
const safeUser = safeUsers[0];
|
const safeUser = safeUsers[0];
|
||||||
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
|
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
|
||||||
|
|
||||||
const logMessage = await logs.getLogMessage(LogType.AUTOMOD_ACTION, {
|
const logMessage = await logs.getLogMessage(
|
||||||
rule: ruleName,
|
LogType.AUTOMOD_ACTION,
|
||||||
user: safeUser,
|
createTypedTemplateSafeValueContainer({
|
||||||
users: safeUsers,
|
|
||||||
actionsTaken,
|
|
||||||
matchSummary: matchResult.summary,
|
|
||||||
});
|
|
||||||
|
|
||||||
let rendered;
|
|
||||||
try {
|
|
||||||
rendered = await renderTemplate(actionConfig.text, {
|
|
||||||
rule: ruleName,
|
rule: ruleName,
|
||||||
user: safeUser,
|
user: safeUser,
|
||||||
users: safeUsers,
|
users: safeUsers,
|
||||||
text,
|
|
||||||
actionsTaken,
|
actionsTaken,
|
||||||
matchSummary: matchResult.summary,
|
matchSummary: matchResult.summary ?? "",
|
||||||
messageLink: theMessageLink,
|
}),
|
||||||
logMessage,
|
);
|
||||||
});
|
|
||||||
|
let rendered;
|
||||||
|
try {
|
||||||
|
rendered = await renderTemplate(
|
||||||
|
actionConfig.text,
|
||||||
|
new TemplateSafeValueContainer({
|
||||||
|
rule: ruleName,
|
||||||
|
user: safeUser,
|
||||||
|
users: safeUsers,
|
||||||
|
text,
|
||||||
|
actionsTaken,
|
||||||
|
matchSummary: matchResult.summary,
|
||||||
|
messageLink: theMessageLink,
|
||||||
|
logMessage: validateAndParseMessageContent(logMessage)?.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TemplateParseError) {
|
if (err instanceof TemplateParseError) {
|
||||||
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Error in alert format of automod rule ${ruleName}: ${err.message}`,
|
body: `Error in alert format of automod rule ${ruleName}: ${err.message}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -67,6 +82,13 @@ export const AlertAction = automodAction({
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (messageIsEmpty(rendered)) {
|
||||||
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
|
body: `Tried to send alert with an empty message for automod rule ${ruleName}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createChunkedMessage(
|
await createChunkedMessage(
|
||||||
channel,
|
channel,
|
||||||
|
@ -75,13 +97,13 @@ export const AlertAction = automodAction({
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 50001) {
|
if (err.code === 50001) {
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Missing access to send alert to channel ${verboseChannelMention(
|
body: `Missing access to send alert to channel ${verboseChannelMention(
|
||||||
channel,
|
channel,
|
||||||
)} in automod rule **${ruleName}**`,
|
)} in automod rule **${ruleName}**`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention(
|
body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention(
|
||||||
channel,
|
channel,
|
||||||
)} in automod rule **${ruleName}**`,
|
)} in automod rule **${ruleName}**`,
|
||||||
|
@ -89,7 +111,7 @@ export const AlertAction = automodAction({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`,
|
body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
20
backend/src/plugins/Automod/actions/archiveThread.ts
Normal file
20
backend/src/plugins/Automod/actions/archiveThread.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { ThreadChannel } from "discord.js";
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { noop } from "../../../utils";
|
||||||
|
import { automodAction } from "../helpers";
|
||||||
|
|
||||||
|
export const ArchiveThreadAction = automodAction({
|
||||||
|
configType: t.type({}),
|
||||||
|
defaultConfig: {},
|
||||||
|
|
||||||
|
async apply({ pluginData, contexts }) {
|
||||||
|
const threads = contexts
|
||||||
|
.filter((c) => c.message?.channel_id)
|
||||||
|
.map((c) => pluginData.guild.channels.cache.get(c.message!.channel_id))
|
||||||
|
.filter((c): c is ThreadChannel => c?.isThread() ?? false);
|
||||||
|
|
||||||
|
for (const thread of threads) {
|
||||||
|
await thread.setArchived().catch(noop);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -3,6 +3,7 @@ import { AutomodActionBlueprint } from "../helpers";
|
||||||
import { AddRolesAction } from "./addRoles";
|
import { AddRolesAction } from "./addRoles";
|
||||||
import { AddToCounterAction } from "./addToCounter";
|
import { AddToCounterAction } from "./addToCounter";
|
||||||
import { AlertAction } from "./alert";
|
import { AlertAction } from "./alert";
|
||||||
|
import { ArchiveThreadAction } from "./archiveThread";
|
||||||
import { BanAction } from "./ban";
|
import { BanAction } from "./ban";
|
||||||
import { ChangeNicknameAction } from "./changeNickname";
|
import { ChangeNicknameAction } from "./changeNickname";
|
||||||
import { CleanAction } from "./clean";
|
import { CleanAction } from "./clean";
|
||||||
|
@ -32,6 +33,7 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
|
||||||
add_to_counter: AddToCounterAction,
|
add_to_counter: AddToCounterAction,
|
||||||
set_counter: SetCounterAction,
|
set_counter: SetCounterAction,
|
||||||
set_slowmode: SetSlowmodeAction,
|
set_slowmode: SetSlowmodeAction,
|
||||||
|
archive_thread: ArchiveThreadAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvailableActions = t.type({
|
export const AvailableActions = t.type({
|
||||||
|
@ -50,4 +52,5 @@ export const AvailableActions = t.type({
|
||||||
add_to_counter: AddToCounterAction.configType,
|
add_to_counter: AddToCounterAction.configType,
|
||||||
set_counter: SetCounterAction.configType,
|
set_counter: SetCounterAction.configType,
|
||||||
set_slowmode: SetSlowmodeAction.configType,
|
set_slowmode: SetSlowmodeAction.configType,
|
||||||
|
archive_thread: ArchiveThreadAction.configType,
|
||||||
});
|
});
|
||||||
|
|
|
@ -35,7 +35,7 @@ export const BanAction = automodAction({
|
||||||
hide: Boolean(actionConfig.hide_case),
|
hide: Boolean(actionConfig.hide_case),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userIdsToBan = unique(contexts.map(c => c.user?.id).filter(nonNullish));
|
const userIdsToBan = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
|
||||||
|
|
||||||
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
||||||
for (const userId of userIdsToBan) {
|
for (const userId of userIdsToBan) {
|
||||||
|
|
|
@ -15,14 +15,14 @@ export const ChangeNicknameAction = automodAction({
|
||||||
defaultConfig: {},
|
defaultConfig: {},
|
||||||
|
|
||||||
async apply({ pluginData, contexts, actionConfig }) {
|
async apply({ pluginData, contexts, actionConfig }) {
|
||||||
const members = unique(contexts.map(c => c.member).filter(nonNullish));
|
const members = unique(contexts.map((c) => c.member).filter(nonNullish));
|
||||||
|
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
if (pluginData.state.recentNicknameChanges.has(member.id)) continue;
|
if (pluginData.state.recentNicknameChanges.has(member.id)) continue;
|
||||||
const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name;
|
const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name;
|
||||||
|
|
||||||
member.edit({ nick: newName }).catch(err => {
|
member.edit({ nick: newName }).catch((err) => {
|
||||||
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Failed to change the nickname of \`${member.id}\``,
|
body: `Failed to change the nickname of \`${member.id}\``,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,8 +31,8 @@ export const KickAction = automodAction({
|
||||||
hide: Boolean(actionConfig.hide_case),
|
hide: Boolean(actionConfig.hide_case),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish));
|
const userIdsToKick = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
|
||||||
const membersToKick = await asyncMap(userIdsToKick, id => resolveMember(pluginData.client, pluginData.guild, id));
|
const membersToKick = await asyncMap(userIdsToKick, (id) => resolveMember(pluginData.client, pluginData.guild, id));
|
||||||
|
|
||||||
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
||||||
for (const member of membersToKick) {
|
for (const member of membersToKick) {
|
||||||
|
|
|
@ -1,26 +1,25 @@
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { LogType } from "../../../data/LogType";
|
||||||
import { stripObjectToScalars, unique } from "../../../utils";
|
import { isTruthy, stripObjectToScalars, unique } from "../../../utils";
|
||||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
import { automodAction } from "../helpers";
|
import { automodAction } from "../helpers";
|
||||||
|
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||||
|
|
||||||
export const LogAction = automodAction({
|
export const LogAction = automodAction({
|
||||||
configType: t.boolean,
|
configType: t.boolean,
|
||||||
defaultConfig: true,
|
defaultConfig: true,
|
||||||
|
|
||||||
async apply({ pluginData, contexts, ruleName, matchResult }) {
|
async apply({ pluginData, contexts, ruleName, matchResult }) {
|
||||||
const safeUsers = unique(contexts.map(c => c.user))
|
const users = unique(contexts.map((c) => c.user)).filter(isTruthy);
|
||||||
.filter(Boolean)
|
const user = users[0];
|
||||||
.map(user => stripObjectToScalars(user));
|
|
||||||
const safeUser = safeUsers[0];
|
|
||||||
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
|
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
|
||||||
|
|
||||||
pluginData.getPlugin(LogsPlugin).log(LogType.AUTOMOD_ACTION, {
|
pluginData.getPlugin(LogsPlugin).logAutomodAction({
|
||||||
rule: ruleName,
|
rule: ruleName,
|
||||||
user: safeUser,
|
user,
|
||||||
users: safeUsers,
|
users,
|
||||||
actionsTaken,
|
actionsTaken,
|
||||||
matchSummary: matchResult.summary,
|
matchSummary: matchResult.summary ?? "",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -40,7 +40,7 @@ export const MuteAction = automodAction({
|
||||||
hide: Boolean(actionConfig.hide_case),
|
hide: Boolean(actionConfig.hide_case),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userIdsToMute = unique(contexts.map(c => c.user?.id).filter(nonNullish));
|
const userIdsToMute = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
|
||||||
|
|
||||||
const mutes = pluginData.getPlugin(MutesPlugin);
|
const mutes = pluginData.getPlugin(MutesPlugin);
|
||||||
for (const userId of userIdsToMute) {
|
for (const userId of userIdsToMute) {
|
||||||
|
@ -55,7 +55,7 @@ export const MuteAction = automodAction({
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
|
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
|
||||||
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Failed to mute <@!${userId}> in Automod rule \`${ruleName}\` because a mute role has not been specified in server config`,
|
body: `Failed to mute <@!${userId}> in Automod rule \`${ruleName}\` because a mute role has not been specified in server config`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -18,13 +18,13 @@ export const RemoveRolesAction = automodAction({
|
||||||
defaultConfig: [],
|
defaultConfig: [],
|
||||||
|
|
||||||
async apply({ pluginData, contexts, actionConfig, ruleName }) {
|
async apply({ pluginData, contexts, actionConfig, ruleName }) {
|
||||||
const members = unique(contexts.map(c => c.member).filter(nonNullish));
|
const members = unique(contexts.map((c) => c.member).filter(nonNullish));
|
||||||
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
|
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
|
||||||
|
|
||||||
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
|
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
|
||||||
if (missingPermissions) {
|
if (missingPermissions) {
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
|
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -42,10 +42,10 @@ export const RemoveRolesAction = automodAction({
|
||||||
|
|
||||||
if (rolesWeCannotRemove.length) {
|
if (rolesWeCannotRemove.length) {
|
||||||
const roleNamesWeCannotRemove = rolesWeCannotRemove.map(
|
const roleNamesWeCannotRemove = rolesWeCannotRemove.map(
|
||||||
roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
|
(roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
|
||||||
);
|
);
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
logs.log(LogType.BOT_ALERT, {
|
logs.logBotAlert({
|
||||||
body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join(
|
body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join(
|
||||||
"**, **",
|
"**, **",
|
||||||
)}**`,
|
)}**`,
|
||||||
|
@ -53,7 +53,7 @@ export const RemoveRolesAction = automodAction({
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
members.map(async member => {
|
members.map(async (member) => {
|
||||||
const memberRoles = new Set(member.roles.cache.keys());
|
const memberRoles = new Set(member.roles.cache.keys());
|
||||||
for (const roleId of rolesToRemove) {
|
for (const roleId of rolesToRemove) {
|
||||||
memberRoles.delete(roleId as Snowflake);
|
memberRoles.delete(roleId as Snowflake);
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { MessageOptions, Permissions, Snowflake, TextChannel, User } from "discord.js";
|
import { MessageOptions, Permissions, Snowflake, TextChannel, ThreadChannel, User } from "discord.js";
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
|
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
|
||||||
import { renderTemplate } from "../../../templateFormatter";
|
|
||||||
import {
|
import {
|
||||||
convertDelayStringToMS,
|
convertDelayStringToMS,
|
||||||
noop,
|
noop,
|
||||||
|
@ -11,11 +10,13 @@ import {
|
||||||
tMessageContent,
|
tMessageContent,
|
||||||
tNullable,
|
tNullable,
|
||||||
unique,
|
unique,
|
||||||
|
validateAndParseMessageContent,
|
||||||
verboseChannelMention,
|
verboseChannelMention,
|
||||||
} from "../../../utils";
|
} from "../../../utils";
|
||||||
import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
|
import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
|
||||||
import { automodAction } from "../helpers";
|
import { automodAction } from "../helpers";
|
||||||
import { AutomodContext } from "../types";
|
import { AutomodContext } from "../types";
|
||||||
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
|
||||||
export const ReplyAction = automodAction({
|
export const ReplyAction = automodAction({
|
||||||
configType: t.union([
|
configType: t.union([
|
||||||
|
@ -23,6 +24,7 @@ export const ReplyAction = automodAction({
|
||||||
t.type({
|
t.type({
|
||||||
text: tMessageContent,
|
text: tMessageContent,
|
||||||
auto_delete: tNullable(t.union([tDelayString, t.number])),
|
auto_delete: tNullable(t.union([tDelayString, t.number])),
|
||||||
|
inline: tNullable(t.boolean),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
@ -30,8 +32,11 @@ export const ReplyAction = automodAction({
|
||||||
|
|
||||||
async apply({ pluginData, contexts, actionConfig, ruleName }) {
|
async apply({ pluginData, contexts, actionConfig, ruleName }) {
|
||||||
const contextsWithTextChannels = contexts
|
const contextsWithTextChannels = contexts
|
||||||
.filter(c => c.message?.channel_id)
|
.filter((c) => c.message?.channel_id)
|
||||||
.filter(c => pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake) instanceof TextChannel);
|
.filter((c) => {
|
||||||
|
const channel = pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake);
|
||||||
|
return channel instanceof TextChannel || channel instanceof ThreadChannel;
|
||||||
|
});
|
||||||
|
|
||||||
const contextsByChannelId = contextsWithTextChannels.reduce((map: Map<string, AutomodContext[]>, context) => {
|
const contextsByChannelId = contextsWithTextChannels.reduce((map: Map<string, AutomodContext[]>, context) => {
|
||||||
if (!map.has(context.message!.channel_id)) {
|
if (!map.has(context.message!.channel_id)) {
|
||||||
|
@ -43,13 +48,17 @@ export const ReplyAction = automodAction({
|
||||||
}, new Map());
|
}, new Map());
|
||||||
|
|
||||||
for (const [channelId, _contexts] of contextsByChannelId.entries()) {
|
for (const [channelId, _contexts] of contextsByChannelId.entries()) {
|
||||||
const users = unique(Array.from(new Set(_contexts.map(c => c.user).filter(Boolean)))) as User[];
|
const users = unique(Array.from(new Set(_contexts.map((c) => c.user).filter(Boolean)))) as User[];
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
|
|
||||||
const renderReplyText = async str =>
|
const renderReplyText = async (str: string) =>
|
||||||
renderTemplate(str, {
|
renderTemplate(
|
||||||
user: userToConfigAccessibleUser(user),
|
str,
|
||||||
});
|
new TemplateSafeValueContainer({
|
||||||
|
user: userToTemplateSafeUser(user),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const formatted =
|
const formatted =
|
||||||
typeof actionConfig === "string"
|
typeof actionConfig === "string"
|
||||||
? await renderReplyText(actionConfig)
|
? await renderReplyText(actionConfig)
|
||||||
|
@ -65,7 +74,7 @@ export const ReplyAction = automodAction({
|
||||||
Permissions.FLAGS.SEND_MESSAGES | Permissions.FLAGS.VIEW_CHANNEL,
|
Permissions.FLAGS.SEND_MESSAGES | Permissions.FLAGS.VIEW_CHANNEL,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
pluginData.state.logs.log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``,
|
body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
|
@ -76,7 +85,7 @@ export const ReplyAction = automodAction({
|
||||||
typeof formatted !== "string" &&
|
typeof formatted !== "string" &&
|
||||||
!hasDiscordPermissions(channel.permissionsFor(pluginData.client.user!.id), Permissions.FLAGS.EMBED_LINKS)
|
!hasDiscordPermissions(channel.permissionsFor(pluginData.client.user!.id), Permissions.FLAGS.EMBED_LINKS)
|
||||||
) {
|
) {
|
||||||
pluginData.state.logs.log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Missing permissions to reply **with an embed** in ${verboseChannelMention(
|
body: `Missing permissions to reply **with an embed** in ${verboseChannelMention(
|
||||||
channel,
|
channel,
|
||||||
)} in Automod rule \`${ruleName}\``,
|
)} in Automod rule \`${ruleName}\``,
|
||||||
|
@ -84,17 +93,27 @@ export const ReplyAction = automodAction({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageContent: MessageOptions = typeof formatted === "string" ? { content: formatted } : formatted;
|
const messageContent = validateAndParseMessageContent(formatted);
|
||||||
const replyMsg = await channel.send({
|
|
||||||
|
const messageOpts: MessageOptions = {
|
||||||
...messageContent,
|
...messageContent,
|
||||||
allowedMentions: {
|
allowedMentions: {
|
||||||
users: [user.id],
|
users: [user.id],
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (typeof actionConfig !== "string" && actionConfig.inline) {
|
||||||
|
messageOpts.reply = {
|
||||||
|
failIfNotExists: false,
|
||||||
|
messageReference: _contexts[0].message!.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyMsg = await channel.send(messageOpts);
|
||||||
|
|
||||||
if (typeof actionConfig === "object" && actionConfig.auto_delete) {
|
if (typeof actionConfig === "object" && actionConfig.auto_delete) {
|
||||||
const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!;
|
const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!;
|
||||||
setTimeout(() => replyMsg.delete().catch(noop), delay);
|
setTimeout(() => !replyMsg.deleted && replyMsg.delete().catch(noop), delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import * as t from "io-ts";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { LogType } from "../../../data/LogType";
|
||||||
import { CountersPlugin } from "../../Counters/CountersPlugin";
|
import { CountersPlugin } from "../../Counters/CountersPlugin";
|
||||||
import { automodAction } from "../helpers";
|
import { automodAction } from "../helpers";
|
||||||
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
|
||||||
export const SetCounterAction = automodAction({
|
export const SetCounterAction = automodAction({
|
||||||
configType: t.type({
|
configType: t.type({
|
||||||
|
@ -14,7 +15,7 @@ export const SetCounterAction = automodAction({
|
||||||
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
|
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
|
||||||
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
||||||
if (!countersPlugin.counterExists(actionConfig.counter)) {
|
if (!countersPlugin.counterExists(actionConfig.counter)) {
|
||||||
pluginData.state.logs.log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
|
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { ChannelTypeStrings } from "src/types";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { LogType } from "../../../data/LogType";
|
||||||
import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils";
|
import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils";
|
||||||
import { automodAction } from "../helpers";
|
import { automodAction } from "../helpers";
|
||||||
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
|
||||||
export const SetSlowmodeAction = automodAction({
|
export const SetSlowmodeAction = automodAction({
|
||||||
configType: t.type({
|
configType: t.type({
|
||||||
|
@ -53,7 +54,7 @@ export const SetSlowmodeAction = automodAction({
|
||||||
? `Duration is greater than maximum native slowmode duration`
|
? `Duration is greater than maximum native slowmode duration`
|
||||||
: e.message;
|
: e.message;
|
||||||
|
|
||||||
pluginData.state.logs.log(LogType.BOT_ALERT, {
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`,
|
body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,8 +31,8 @@ export const WarnAction = automodAction({
|
||||||
hide: Boolean(actionConfig.hide_case),
|
hide: Boolean(actionConfig.hide_case),
|
||||||
};
|
};
|
||||||
|
|
||||||
const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish));
|
const userIdsToWarn = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
|
||||||
const membersToWarn = await asyncMap(userIdsToWarn, id => resolveMember(pluginData.client, pluginData.guild, id));
|
const membersToWarn = await asyncMap(userIdsToWarn, (id) => resolveMember(pluginData.client, pluginData.guild, id));
|
||||||
|
|
||||||
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
||||||
for (const member of membersToWarn) {
|
for (const member of membersToWarn) {
|
||||||
|
|
|
@ -8,11 +8,15 @@ export const RunAutomodOnMemberUpdate = typedGuildEventListener<AutomodPluginTyp
|
||||||
event: "guildMemberUpdate",
|
event: "guildMemberUpdate",
|
||||||
listener({ pluginData, args: { oldMember, newMember } }) {
|
listener({ pluginData, args: { oldMember, newMember } }) {
|
||||||
if (!oldMember) return;
|
if (!oldMember) return;
|
||||||
|
if (oldMember.partial) return;
|
||||||
|
|
||||||
if (isEqual(oldMember.roles, newMember.roles)) return;
|
const oldRoles = [...oldMember.roles.cache.keys()];
|
||||||
|
const newRoles = [...newMember.roles.cache.keys()];
|
||||||
|
|
||||||
const addedRoles = diff(newMember.roles, oldMember.roles);
|
if (isEqual(oldRoles, newRoles)) return;
|
||||||
const removedRoles = diff(oldMember.roles, newMember.roles);
|
|
||||||
|
const addedRoles = diff(newRoles, oldRoles);
|
||||||
|
const removedRoles = diff(oldRoles, newRoles);
|
||||||
|
|
||||||
if (addedRoles.length || removedRoles.length) {
|
if (addedRoles.length || removedRoles.length) {
|
||||||
const context: AutomodContext = {
|
const context: AutomodContext = {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
|
||||||
|
|
||||||
export function clearOldRecentActions(pluginData: GuildPluginData<AutomodPluginType>) {
|
export function clearOldRecentActions(pluginData: GuildPluginData<AutomodPluginType>) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
pluginData.state.recentActions = pluginData.state.recentActions.filter(info => {
|
pluginData.state.recentActions = pluginData.state.recentActions.filter((info) => {
|
||||||
return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;
|
return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
|
||||||
|
|
||||||
export function clearOldRecentSpam(pluginData: GuildPluginData<AutomodPluginType>) {
|
export function clearOldRecentSpam(pluginData: GuildPluginData<AutomodPluginType>) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
pluginData.state.recentSpam = pluginData.state.recentSpam.filter(spam => {
|
pluginData.state.recentSpam = pluginData.state.recentSpam.filter((spam) => {
|
||||||
return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;
|
return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ export function clearRecentActionsForMessage(pluginData: GuildPluginData<Automod
|
||||||
const globalIdentifier = message.user_id;
|
const globalIdentifier = message.user_id;
|
||||||
const perChannelIdentifier = `${message.channel_id}-${message.user_id}`;
|
const perChannelIdentifier = `${message.channel_id}-${message.user_id}`;
|
||||||
|
|
||||||
pluginData.state.recentActions = pluginData.state.recentActions.filter(act => {
|
pluginData.state.recentActions = pluginData.state.recentActions.filter((act) => {
|
||||||
return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier;
|
return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,7 +60,7 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
|
||||||
|
|
||||||
if (matchedSpam) {
|
if (matchedSpam) {
|
||||||
const messages = matchedSpam.recentActions
|
const messages = matchedSpam.recentActions
|
||||||
.map(action => action.context.message)
|
.map((action) => action.context.message)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort(sorter("posted_at")) as SavedMessage[];
|
.sort(sorter("posted_at")) as SavedMessage[];
|
||||||
|
|
||||||
|
@ -75,8 +75,8 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
extraContexts: matchedSpam.recentActions
|
extraContexts: matchedSpam.recentActions
|
||||||
.map(action => action.context)
|
.map((action) => action.context)
|
||||||
.filter(_context => _context !== context),
|
.filter((_context) => _context !== context),
|
||||||
|
|
||||||
extra: {
|
extra: {
|
||||||
archiveId,
|
archiveId,
|
||||||
|
|
|
@ -7,7 +7,7 @@ export function findRecentSpam(
|
||||||
type: RecentActionType,
|
type: RecentActionType,
|
||||||
identifier?: string,
|
identifier?: string,
|
||||||
) {
|
) {
|
||||||
return pluginData.state.recentSpam.find(spam => {
|
return pluginData.state.recentSpam.find((spam) => {
|
||||||
return spam.type === type && (!identifier || spam.identifiers.includes(identifier));
|
return spam.type === type && (!identifier || spam.identifiers.includes(identifier));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ export function getMatchingRecentActions(
|
||||||
) {
|
) {
|
||||||
to = to || Date.now();
|
to = to || Date.now();
|
||||||
|
|
||||||
return pluginData.state.recentActions.filter(action => {
|
return pluginData.state.recentActions.filter((action) => {
|
||||||
return (
|
return (
|
||||||
action.type === type &&
|
action.type === type &&
|
||||||
(!identifier || action.identifier === identifier) &&
|
(!identifier || action.identifier === identifier) &&
|
||||||
|
|
|
@ -29,6 +29,6 @@ export function getTextMatchPartialSummary(
|
||||||
const visibleName = context.member?.nickname || context.user!.username;
|
const visibleName = context.member?.nickname || context.user!.username;
|
||||||
return `visible name: ${visibleName}`;
|
return `visible name: ${visibleName}`;
|
||||||
} else if (type === "customstatus") {
|
} else if (type === "customstatus") {
|
||||||
return `custom status: ${context.member!.presence?.activities.find(a => a.type === "CUSTOM")?.name}`;
|
return `custom status: ${context.member!.presence?.activities.find((a) => a.type === "CUSTOM")?.name}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Constants } from "discord.js";
|
import { Constants, MessageEmbed } from "discord.js";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||||
import { resolveMember } from "../../../utils";
|
import { resolveMember } from "../../../utils";
|
||||||
|
@ -32,9 +32,9 @@ export async function* matchMultipleTextTypesOnMessage(
|
||||||
yield ["message", msg.data.content];
|
yield ["message", msg.data.content];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
if (trigger.match_embeds && msg.data.embeds?.length) {
|
||||||
const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
|
const copiedEmbed: MessageEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
|
||||||
if (copiedEmbed.type === "video") {
|
if (copiedEmbed.video) {
|
||||||
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
|
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
|
||||||
}
|
}
|
||||||
yield ["embed", JSON.stringify(copiedEmbed)];
|
yield ["embed", JSON.stringify(copiedEmbed)];
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Snowflake, TextChannel } from "discord.js";
|
import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
|
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
|
||||||
import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
|
import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
|
||||||
|
@ -19,7 +19,7 @@ export function resolveActionContactMethods(
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake);
|
const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake);
|
||||||
if (!(channel instanceof TextChannel)) {
|
if (!(channel instanceof TextChannel || channel instanceof ThreadChannel)) {
|
||||||
throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);
|
throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Snowflake, TextChannel } from "discord.js";
|
import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { availableActions } from "../actions/availableActions";
|
import { availableActions } from "../actions/availableActions";
|
||||||
import { CleanAction } from "../actions/clean";
|
import { CleanAction } from "../actions/clean";
|
||||||
|
@ -11,13 +11,25 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
|
||||||
const userId = context.user?.id || context.member?.id || context.message?.user_id;
|
const userId = context.user?.id || context.member?.id || context.message?.user_id;
|
||||||
const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake));
|
const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake));
|
||||||
const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null;
|
const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null;
|
||||||
const channelId = context.message?.channel_id;
|
|
||||||
const channel = channelId ? (pluginData.guild.channels.cache.get(channelId as Snowflake) as TextChannel) : null;
|
const channelIdOrThreadId = context.message?.channel_id;
|
||||||
|
const channelOrThread = channelIdOrThreadId
|
||||||
|
? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as TextChannel | ThreadChannel)
|
||||||
|
: null;
|
||||||
|
const channelId = channelOrThread?.isThread() ? channelOrThread.parent?.id : channelIdOrThreadId;
|
||||||
|
const threadId = channelOrThread?.isThread() ? channelOrThread.id : null;
|
||||||
|
const channel = channelOrThread?.isThread() ? channelOrThread.parent : channelOrThread;
|
||||||
const categoryId = channel?.parentId;
|
const categoryId = channel?.parentId;
|
||||||
|
|
||||||
|
// Don't apply Automod on Zeppelin itself
|
||||||
|
if (userId && userId === pluginData.client.user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const config = await pluginData.config.getMatchingConfig({
|
const config = await pluginData.config.getMatchingConfig({
|
||||||
channelId,
|
channelId,
|
||||||
categoryId,
|
categoryId,
|
||||||
|
threadId,
|
||||||
userId,
|
userId,
|
||||||
member,
|
member,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { User } from "discord.js";
|
import { User } from "discord.js";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
|
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||||
import { LogType } from "../../../data/LogType";
|
import { LogType } from "../../../data/LogType";
|
||||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel";
|
import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel";
|
||||||
|
@ -19,12 +19,12 @@ export async function setAntiraidLevel(
|
||||||
const logs = pluginData.getPlugin(LogsPlugin);
|
const logs = pluginData.getPlugin(LogsPlugin);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
logs.log(LogType.SET_ANTIRAID_USER, {
|
logs.logSetAntiraidUser({
|
||||||
level: newLevel ?? "off",
|
level: newLevel ?? "off",
|
||||||
user: userToConfigAccessibleUser(user),
|
user,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logs.log(LogType.SET_ANTIRAID_AUTO, {
|
logs.logSetAntiraidAuto({
|
||||||
level: newLevel ?? "off",
|
level: newLevel ?? "off",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { KickTrigger } from "./kick";
|
||||||
import { LineSpamTrigger } from "./lineSpam";
|
import { LineSpamTrigger } from "./lineSpam";
|
||||||
import { LinkSpamTrigger } from "./linkSpam";
|
import { LinkSpamTrigger } from "./linkSpam";
|
||||||
import { MatchAttachmentTypeTrigger } from "./matchAttachmentType";
|
import { MatchAttachmentTypeTrigger } from "./matchAttachmentType";
|
||||||
|
import { MatchMimeTypeTrigger } from "./matchMimeType";
|
||||||
import { MatchInvitesTrigger } from "./matchInvites";
|
import { MatchInvitesTrigger } from "./matchInvites";
|
||||||
import { MatchLinksTrigger } from "./matchLinks";
|
import { MatchLinksTrigger } from "./matchLinks";
|
||||||
import { MatchRegexTrigger } from "./matchRegex";
|
import { MatchRegexTrigger } from "./matchRegex";
|
||||||
|
@ -37,6 +38,7 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
|
||||||
match_invites: MatchInvitesTrigger,
|
match_invites: MatchInvitesTrigger,
|
||||||
match_links: MatchLinksTrigger,
|
match_links: MatchLinksTrigger,
|
||||||
match_attachment_type: MatchAttachmentTypeTrigger,
|
match_attachment_type: MatchAttachmentTypeTrigger,
|
||||||
|
match_mime_type: MatchMimeTypeTrigger,
|
||||||
member_join: MemberJoinTrigger,
|
member_join: MemberJoinTrigger,
|
||||||
role_added: RoleAddedTrigger,
|
role_added: RoleAddedTrigger,
|
||||||
role_removed: RoleRemovedTrigger,
|
role_removed: RoleRemovedTrigger,
|
||||||
|
@ -72,6 +74,7 @@ export const AvailableTriggers = t.type({
|
||||||
match_invites: MatchInvitesTrigger.configType,
|
match_invites: MatchInvitesTrigger.configType,
|
||||||
match_links: MatchLinksTrigger.configType,
|
match_links: MatchLinksTrigger.configType,
|
||||||
match_attachment_type: MatchAttachmentTypeTrigger.configType,
|
match_attachment_type: MatchAttachmentTypeTrigger.configType,
|
||||||
|
match_mime_type: MatchMimeTypeTrigger.configType,
|
||||||
member_join: MemberJoinTrigger.configType,
|
member_join: MemberJoinTrigger.configType,
|
||||||
member_leave: MemberLeaveTrigger.configType,
|
member_leave: MemberLeaveTrigger.configType,
|
||||||
role_added: RoleAddedTrigger.configType,
|
role_added: RoleAddedTrigger.configType,
|
||||||
|
|
|
@ -15,7 +15,7 @@ export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
|
||||||
},
|
},
|
||||||
|
|
||||||
async match({ triggerConfig, context }) {
|
async match({ triggerConfig, context }) {
|
||||||
const foundFruit = triggerConfig.allowedFruits.find(fruit => context.message?.data.content === fruit);
|
const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit);
|
||||||
if (foundFruit) {
|
if (foundFruit) {
|
||||||
return {
|
return {
|
||||||
extra: {
|
extra: {
|
||||||
|
|
|
@ -28,17 +28,15 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.message.data.attachments) return null;
|
if (!context.message.data.attachments) {
|
||||||
const attachments: any[] = context.message.data.attachments;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of context.message.data.attachments) {
|
||||||
const attachmentType = attachment.filename
|
const attachmentType = attachment.url.split(".").pop()!.toLowerCase();
|
||||||
.split(".")
|
|
||||||
.pop()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
const blacklist = trigger.blacklist_enabled
|
const blacklist = trigger.blacklist_enabled
|
||||||
? (trigger.filetype_blacklist || []).map(_t => _t.toLowerCase())
|
? (trigger.filetype_blacklist || []).map((_t) => _t.toLowerCase())
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (blacklist && blacklist.includes(attachmentType)) {
|
if (blacklist && blacklist.includes(attachmentType)) {
|
||||||
|
@ -51,7 +49,7 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
|
||||||
}
|
}
|
||||||
|
|
||||||
const whitelist = trigger.whitelist_enabled
|
const whitelist = trigger.whitelist_enabled
|
||||||
? (trigger.filetype_whitelist || []).map(_t => _t.toLowerCase())
|
? (trigger.filetype_whitelist || []).map((_t) => _t.toLowerCase())
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (whitelist && !whitelist.includes(attachmentType)) {
|
if (whitelist && !whitelist.includes(attachmentType)) {
|
||||||
|
|
80
backend/src/plugins/Automod/triggers/matchMimeType.ts
Normal file
80
backend/src/plugins/Automod/triggers/matchMimeType.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { automodTrigger } from "../helpers";
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils";
|
||||||
|
import { GuildChannel, Util } from "discord.js";
|
||||||
|
|
||||||
|
interface MatchResultType {
|
||||||
|
matchedType: string;
|
||||||
|
mode: "blacklist" | "whitelist";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
|
||||||
|
configType: t.type({
|
||||||
|
mime_type_blacklist: t.array(t.string),
|
||||||
|
blacklist_enabled: t.boolean,
|
||||||
|
mime_type_whitelist: t.array(t.string),
|
||||||
|
whitelist_enabled: t.boolean,
|
||||||
|
}),
|
||||||
|
|
||||||
|
defaultConfig: {
|
||||||
|
mime_type_blacklist: [],
|
||||||
|
blacklist_enabled: false,
|
||||||
|
mime_type_whitelist: [],
|
||||||
|
whitelist_enabled: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
async match({ context, triggerConfig: trigger }) {
|
||||||
|
if (!context.message) return;
|
||||||
|
|
||||||
|
const { attachments } = context.message.data;
|
||||||
|
if (!attachments) return null;
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const { contentType: rawContentType } = attachment;
|
||||||
|
const contentType = (rawContentType || "").split(";")[0]; // Remove "; charset=utf8" and similar from the end
|
||||||
|
|
||||||
|
const blacklist = trigger.blacklist_enabled
|
||||||
|
? (trigger.mime_type_blacklist ?? []).map((_t) => _t.toLowerCase())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (contentType && blacklist?.includes(contentType)) {
|
||||||
|
return {
|
||||||
|
extra: {
|
||||||
|
matchedType: contentType,
|
||||||
|
mode: "blacklist",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const whitelist = trigger.whitelist_enabled
|
||||||
|
? (trigger.mime_type_whitelist ?? []).map((_t) => _t.toLowerCase())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (whitelist && (!contentType || !whitelist.includes(contentType))) {
|
||||||
|
return {
|
||||||
|
extra: {
|
||||||
|
matchedType: contentType || "<unknown>",
|
||||||
|
mode: "whitelist",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMatchInformation({ pluginData, contexts, matchResult }) {
|
||||||
|
const { message } = contexts[0];
|
||||||
|
const channel = pluginData.guild.channels.resolve(message!.channel_id);
|
||||||
|
const prettyChannel = verboseChannelMention(channel as GuildChannel);
|
||||||
|
const { matchedType, mode } = matchResult.extra;
|
||||||
|
|
||||||
|
return (
|
||||||
|
asSingleLine(`
|
||||||
|
Matched MIME type \`${Util.escapeInlineCode(matchedType)}\`
|
||||||
|
(${mode === "blacklist" ? "blacklisted" : "not in whitelist"})
|
||||||
|
in message (\`${message!.id}\`) in ${prettyChannel}
|
||||||
|
`) + messageSummary(message!)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -64,7 +64,7 @@ export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
|
||||||
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
|
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
|
||||||
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
|
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
|
||||||
let pattern = trigger.loose_matching
|
let pattern = trigger.loose_matching
|
||||||
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
|
? [...word].map((c) => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
|
||||||
: escapeStringRegexp(word);
|
: escapeStringRegexp(word);
|
||||||
|
|
||||||
if (trigger.only_full_words) {
|
if (trigger.only_full_words) {
|
||||||
|
|
|
@ -30,7 +30,7 @@ export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
|
||||||
const totalCount = sumRecentActionCounts(matchingActions);
|
const totalCount = sumRecentActionCounts(matchingActions);
|
||||||
|
|
||||||
if (totalCount >= triggerConfig.amount) {
|
if (totalCount >= triggerConfig.amount) {
|
||||||
const extraContexts = matchingActions.map(a => a.context).filter(c => c !== context);
|
const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context);
|
||||||
|
|
||||||
pluginData.state.recentSpam.push({
|
pluginData.state.recentSpam.push({
|
||||||
type: RecentActionType.MemberJoin,
|
type: RecentActionType.MemberJoin,
|
||||||
|
|
|
@ -18,11 +18,19 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd";
|
||||||
import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
|
import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
|
||||||
import { ServersCmd } from "./commands/ServersCmd";
|
import { ServersCmd } from "./commands/ServersCmd";
|
||||||
import { BotControlPluginType, ConfigSchema } from "./types";
|
import { BotControlPluginType, ConfigSchema } from "./types";
|
||||||
|
import { PluginPerformanceCmd } from "./commands/PluginPerformanceCmd";
|
||||||
|
import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd";
|
||||||
|
import { ChannelToServerCmd } from "./commands/ChannelToServerCmd";
|
||||||
|
import { RestPerformanceCmd } from "./commands/RestPerformanceCmd";
|
||||||
|
import { RateLimitPerformanceCmd } from "./commands/RateLimitPerformanceCmd";
|
||||||
|
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
config: {
|
config: {
|
||||||
can_use: false,
|
can_use: false,
|
||||||
can_eligible: false,
|
can_eligible: false,
|
||||||
|
can_performance: false,
|
||||||
|
can_add_server_from_invite: false,
|
||||||
|
can_list_dashboard_perms: false,
|
||||||
update_cmd: null,
|
update_cmd: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -45,6 +53,11 @@ export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()({
|
||||||
ListDashboardUsersCmd,
|
ListDashboardUsersCmd,
|
||||||
ListDashboardPermsCmd,
|
ListDashboardPermsCmd,
|
||||||
EligibleCmd,
|
EligibleCmd,
|
||||||
|
PluginPerformanceCmd,
|
||||||
|
RestPerformanceCmd,
|
||||||
|
RateLimitPerformanceCmd,
|
||||||
|
AddServerFromInviteCmd,
|
||||||
|
ChannelToServerCmd,
|
||||||
],
|
],
|
||||||
|
|
||||||
async afterLoad(pluginData) {
|
async afterLoad(pluginData) {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue