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 path = require('path');
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
try {
|
||||
fs.accessSync(path.resolve(__dirname, 'bot.env'));
|
||||
require('dotenv').config({ path: path.resolve(__dirname, 'bot.env') });
|
||||
fs.accessSync(path.resolve(__dirname, "bot.env"));
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "bot.env") });
|
||||
} catch {
|
||||
try {
|
||||
fs.accessSync(path.resolve(__dirname, 'api.env'));
|
||||
require('dotenv').config({ path: path.resolve(__dirname, 'api.env') });
|
||||
fs.accessSync(path.resolve(__dirname, "api.env"));
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "api.env") });
|
||||
} catch {
|
||||
throw new Error("bot.env or api.env required");
|
||||
}
|
||||
}
|
||||
|
||||
const moment = require('moment-timezone');
|
||||
moment.tz.setDefault('UTC');
|
||||
const moment = require("moment-timezone");
|
||||
moment.tz.setDefault("UTC");
|
||||
|
||||
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 migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations'));
|
||||
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 migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, "src/migrations"));
|
||||
|
||||
module.exports = {
|
||||
type: "mysql",
|
||||
|
@ -26,26 +26,29 @@ module.exports = {
|
|||
username: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE,
|
||||
charset: 'utf8mb4',
|
||||
charset: "utf8mb4",
|
||||
supportBigNumbers: true,
|
||||
bigNumberStrings: true,
|
||||
dateStrings: true,
|
||||
synchronize: false,
|
||||
connectTimeout: 2000,
|
||||
|
||||
logging: ["error", "warn"],
|
||||
maxQueryExecutionTime: 250,
|
||||
|
||||
// Entities
|
||||
entities: [entities],
|
||||
|
||||
// Pool options
|
||||
extra: {
|
||||
typeCast(field, next) {
|
||||
if (field.type === 'DATETIME') {
|
||||
if (field.type === "DATETIME") {
|
||||
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();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Migrations
|
||||
|
|
30
backend/package-lock.json
generated
30
backend/package-lock.json
generated
|
@ -24,7 +24,7 @@
|
|||
"humanize-duration": "^3.15.0",
|
||||
"io-ts": "^2.0.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"knub": "^30.0.0-beta.39",
|
||||
"knub": "^30.0.0-beta.45",
|
||||
"knub-command-manager": "^9.1.0",
|
||||
"last-commit-log": "^2.1.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
|
@ -53,7 +53,8 @@
|
|||
"utf-8-validate": "^5.0.5",
|
||||
"uuid": "^3.3.2",
|
||||
"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": {
|
||||
"@types/cors": "^2.8.5",
|
||||
|
@ -3042,9 +3043,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/knub": {
|
||||
"version": "30.0.0-beta.39",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz",
|
||||
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==",
|
||||
"version": "30.0.0-beta.45",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
|
||||
"integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.22.0",
|
||||
"discord.js": "^13.0.1",
|
||||
|
@ -5965,6 +5966,14 @@
|
|||
"dependencies": {
|
||||
"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": {
|
||||
|
@ -8281,9 +8290,9 @@
|
|||
}
|
||||
},
|
||||
"knub": {
|
||||
"version": "30.0.0-beta.39",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz",
|
||||
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==",
|
||||
"version": "30.0.0-beta.45",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
|
||||
"integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
|
||||
"requires": {
|
||||
"discord-api-types": "^0.22.0",
|
||||
"discord.js": "^13.0.1",
|
||||
|
@ -10527,6 +10536,11 @@
|
|||
"requires": {
|
||||
"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-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"",
|
||||
"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-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js",
|
||||
"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 --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\"",
|
||||
"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-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js",
|
||||
"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 --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\"",
|
||||
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
|
||||
"migrate-prod": "npm run typeorm -- migration:run",
|
||||
|
@ -39,7 +39,7 @@
|
|||
"humanize-duration": "^3.15.0",
|
||||
"io-ts": "^2.0.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"knub": "^30.0.0-beta.39",
|
||||
"knub": "^30.0.0-beta.45",
|
||||
"knub-command-manager": "^9.1.0",
|
||||
"last-commit-log": "^2.1.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
|
@ -68,7 +68,8 @@
|
|||
"utf-8-validate": "^5.0.5",
|
||||
"uuid": "^3.3.2",
|
||||
"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": {
|
||||
"@types/cors": "^2.8.5",
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
* https://github.com/TypeStrong/ts-node/pull/254
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const tsconfig = require('./tsconfig.json');
|
||||
const tsconfigPaths = require('tsconfig-paths');
|
||||
const path = require("path");
|
||||
const tsconfig = require("./tsconfig.json");
|
||||
const tsconfigPaths = require("tsconfig-paths");
|
||||
|
||||
// E.g. ./dist/backend
|
||||
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);
|
||||
}
|
||||
|
||||
public add(fn: TQueueFunction): Promise<void> {
|
||||
const promise = new Promise<void>(resolve => {
|
||||
public add(fn: TQueueFunction): Promise<any> {
|
||||
const promise = new Promise<any>((resolve) => {
|
||||
this.queue.push(async () => {
|
||||
await fn();
|
||||
resolve();
|
||||
const result = await fn();
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
if (!this.running) this.next();
|
||||
|
@ -50,7 +50,7 @@ export class Queue<TQueueFunction extends AnyFn = AnyFn> {
|
|||
}
|
||||
|
||||
const fn = this.queue.shift()!;
|
||||
new Promise(resolve => {
|
||||
new Promise((resolve) => {
|
||||
// Either fn() completes or the timeout is reached
|
||||
void fn().then(resolve);
|
||||
setTimeout(resolve, this._timeout);
|
||||
|
|
|
@ -42,7 +42,7 @@ export class QueuedEventEmitter {
|
|||
const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])];
|
||||
|
||||
let promise: Promise<any> = Promise.resolve();
|
||||
listeners.forEach(listener => {
|
||||
listeners.forEach((listener) => {
|
||||
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 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;
|
||||
regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT));
|
||||
|
|
|
@ -33,21 +33,21 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
|
|||
Authorization: `Bearer ${bearerToken}`,
|
||||
},
|
||||
},
|
||||
res => {
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Discord API error ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let rawData = "";
|
||||
res.on("data", data => (rawData += data));
|
||||
res.on("data", (data) => (rawData += data));
|
||||
res.on("end", () => {
|
||||
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 });
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
res.json({ valid: true, userId });
|
||||
});
|
||||
app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {
|
||||
await apiLogins.expireApiKey(req.user!.apiKey);
|
||||
|
|
|
@ -22,21 +22,21 @@ function formatConfigSchema(schema) {
|
|||
} else if (schema.name.startsWith("Optional<")) {
|
||||
return `Optional<${formatConfigSchema(schema.types[0])}>`;
|
||||
} else {
|
||||
return schema.types.map(t => formatConfigSchema(t)).join(" | ");
|
||||
return schema.types.map((t) => formatConfigSchema(t)).join(" | ");
|
||||
}
|
||||
} else if (schema._tag === "IntersectionType") {
|
||||
return schema.types.map(t => formatConfigSchema(t)).join(" & ");
|
||||
return schema.types.map((t) => formatConfigSchema(t)).join(" & ");
|
||||
} else {
|
||||
return schema.name;
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
res.json(
|
||||
docsPlugins.map(plugin => {
|
||||
docsPlugins.map((plugin) => {
|
||||
const thinInfo = plugin.info ? { prettyName: plugin.info.prettyName, legacy: plugin.info.legacy ?? false } : {};
|
||||
return {
|
||||
name: plugin.name,
|
||||
|
@ -56,7 +56,7 @@ export function initDocs(app: express.Express) {
|
|||
const name = plugin.name;
|
||||
const info = plugin.info || {};
|
||||
|
||||
const commands = (plugin.commands || []).map(cmd => ({
|
||||
const commands = (plugin.commands || []).map((cmd) => ({
|
||||
trigger: cmd.trigger,
|
||||
permission: cmd.permission,
|
||||
signature: cmd.signature,
|
||||
|
|
|
@ -3,15 +3,21 @@ import express, { Request, Response } from "express";
|
|||
import { YAMLException } from "js-yaml";
|
||||
import { validateGuildConfig } from "../configValidator";
|
||||
import { AllowedGuilds } from "../data/AllowedGuilds";
|
||||
import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments";
|
||||
import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments";
|
||||
import { Configs } from "../data/Configs";
|
||||
import { apiTokenAuthHandlers } from "./auth";
|
||||
import { hasGuildPermission, requireGuildPermission } from "./permissions";
|
||||
import { clientError, ok, serverError, unauthorized } from "./responses";
|
||||
import { loadYamlSafely } from "../utils/loadYamlSafely";
|
||||
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 auditLog = new ApiAuditLog();
|
||||
|
||||
export function initGuildsAPI(app: express.Express) {
|
||||
const allowedGuilds = new AllowedGuilds();
|
||||
|
@ -25,6 +31,14 @@ export function initGuildsAPI(app: express.Express) {
|
|||
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) => {
|
||||
if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { initAuth } from "./auth";
|
|||
import { initDocs } from "./docs";
|
||||
import { initGuildsAPI } from "./guilds";
|
||||
import { clientError, error, notFound } from "./responses";
|
||||
import { startBackgroundTasks } from "./tasks";
|
||||
|
||||
const app = express();
|
||||
|
||||
|
@ -14,7 +15,11 @@ app.use(
|
|||
origin: process.env.DASHBOARD_URL,
|
||||
}),
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
express.json({
|
||||
limit: "10mb",
|
||||
}),
|
||||
);
|
||||
|
||||
initAuth(app);
|
||||
initGuildsAPI(app);
|
||||
|
@ -43,3 +48,5 @@ app.use((req, res, next) => {
|
|||
|
||||
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
|
||||
|
||||
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)!;
|
||||
try {
|
||||
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) {
|
||||
if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
|
||||
return `${pluginName}: ${err.message}`;
|
||||
|
|
|
@ -2,6 +2,8 @@ import { getRepository, Repository } from "typeorm";
|
|||
import { ApiPermissionTypes } from "./ApiPermissionAssignments";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
import { AllowedGuild } from "./entities/AllowedGuild";
|
||||
import moment from "moment-timezone";
|
||||
import { DBDateFormat } from "../utils";
|
||||
|
||||
export class AllowedGuilds extends BaseRepository {
|
||||
private allowedGuilds: Repository<AllowedGuild>;
|
||||
|
@ -37,7 +39,10 @@ export class AllowedGuilds extends BaseRepository {
|
|||
}
|
||||
|
||||
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">> = {}) {
|
||||
|
|
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,
|
||||
user_id: userId,
|
||||
logged_in_at: moment.utc().format(DBDateFormat),
|
||||
expires_at: moment
|
||||
.utc()
|
||||
.add(LOGIN_EXPIRY_TIME, "ms")
|
||||
.format(DBDateFormat),
|
||||
expires_at: moment.utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
|
||||
});
|
||||
|
||||
return `${loginId}.${token}`;
|
||||
|
@ -96,10 +93,7 @@ export class ApiLogins extends BaseRepository {
|
|||
await this.apiLogins.update(
|
||||
{ id: loginId },
|
||||
{
|
||||
expires_at: moment()
|
||||
.utc()
|
||||
.add(LOGIN_EXPIRY_TIME, "ms")
|
||||
.format(DBDateFormat),
|
||||
expires_at: moment().utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,9 @@ import { ApiPermissions } from "@shared/apiPermissions";
|
|||
import { getRepository, Repository } from "typeorm";
|
||||
import { BaseRepository } from "./BaseRepository";
|
||||
import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment";
|
||||
import { Permissions } from "discord.js";
|
||||
import { ApiAuditLog } from "./ApiAuditLog";
|
||||
import { AuditLogEventTypes } from "./apiAuditLogTypes";
|
||||
|
||||
export enum ApiPermissionTypes {
|
||||
User = "USER",
|
||||
|
@ -10,10 +13,12 @@ export enum ApiPermissionTypes {
|
|||
|
||||
export class ApiPermissionAssignments extends BaseRepository {
|
||||
private apiPermissions: Repository<ApiPermissionAssignment>;
|
||||
private auditLogs: ApiAuditLog;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.apiPermissions = getRepository(ApiPermissionAssignment);
|
||||
this.auditLogs = new ApiAuditLog();
|
||||
}
|
||||
|
||||
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({
|
||||
guild_id: guildId,
|
||||
type: ApiPermissionTypes.User,
|
||||
target_id: userId,
|
||||
permissions,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
removeUser(guildId, 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) {
|
||||
return connection.transaction(async entityManager => {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const repo = entityManager.getRepository(ApiUserInfoEntity);
|
||||
|
||||
const existingInfo = await repo.findOne({ where: { id } });
|
||||
|
|
|
@ -41,11 +41,7 @@ export class Configs extends BaseRepository {
|
|||
}
|
||||
|
||||
getActiveLargerThanId(id) {
|
||||
return this.configs
|
||||
.createQueryBuilder()
|
||||
.where("id > :id", { id })
|
||||
.andWhere("is_active = 1")
|
||||
.getMany();
|
||||
return this.configs.createQueryBuilder().where("id > :id", { id }).andWhere("is_active = 1").getMany();
|
||||
}
|
||||
|
||||
async hasConfig(key) {
|
||||
|
@ -65,7 +61,7 @@ export class Configs extends BaseRepository {
|
|||
}
|
||||
|
||||
async saveNewRevision(key, config, editedBy) {
|
||||
return connection.transaction(async entityManager => {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const repo = entityManager.getRepository(Config);
|
||||
// Mark all old revisions inactive
|
||||
await repo.update({ key }, { is_active: false });
|
||||
|
|
|
@ -13,9 +13,9 @@
|
|||
"MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}",
|
||||
"MEMBER_JOIN": "📥 {new} {userMention(member)} joined (created {account_age} ago)",
|
||||
"MEMBER_LEAVE": "📤 {userMention(member)} left the server",
|
||||
"MEMBER_ROLE_ADD": "🔑 {userMention(member)}: role(s) **{roles}** added by {userMention(mod)}",
|
||||
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)}: role(s) **{roles}** removed by {userMention(mod)}",
|
||||
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)}: roles changed: added **{addedRoles}**, removed **{removedRoles}** by {userMention(mod)}",
|
||||
"MEMBER_ROLE_ADD": "🔑 {userMention(member)} received roles: **{roles}**",
|
||||
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)} lost roles: **{roles}**",
|
||||
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**",
|
||||
"MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
|
||||
"MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",
|
||||
"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_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_DELETE": "👋 Emoji `{emoji.name} ({emoji.id})` was deleted",
|
||||
"EMOJI_UPDATE": "<{newEmoji.identifier}> Emoji `{newEmoji.name} ({newEmoji.id})` was updated. Changes:\n{differenceString}",
|
||||
"EMOJI_CREATE": "{emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created",
|
||||
"EMOJI_DELETE": "👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted",
|
||||
"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_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 { isDefaultSticker } from "src/utils/isDefaultSticker";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { renderTemplate } from "../templateFormatter";
|
||||
import { renderTemplate, TemplateSafeValueContainer } from "../templateFormatter";
|
||||
import { trimLines } from "../utils";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { ArchiveEntry } from "./entities/ArchiveEntry";
|
||||
import { SavedMessage } from "./entities/SavedMessage";
|
||||
import {
|
||||
channelToTemplateSafeChannel,
|
||||
guildToTemplateSafeGuild,
|
||||
userToTemplateSafeUser,
|
||||
} from "../utils/templateSafeObjects";
|
||||
|
||||
const DEFAULT_EXPIRY_DAYS = 30;
|
||||
|
||||
|
@ -75,21 +80,25 @@ export class GuildArchives extends BaseGuildRepository {
|
|||
const msgLines: string[] = [];
|
||||
for (const msg of savedMessages) {
|
||||
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, {
|
||||
id: msg.id,
|
||||
timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
|
||||
content: msg.data.content,
|
||||
attachments: msg.data.attachments?.map(att => {
|
||||
return JSON.stringify({ name: att.name, url: att.url, type: att.contentType });
|
||||
const line = await renderTemplate(
|
||||
MESSAGE_ARCHIVE_MESSAGE_FORMAT,
|
||||
new TemplateSafeValueContainer({
|
||||
id: msg.id,
|
||||
timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
|
||||
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);
|
||||
}
|
||||
return msgLines;
|
||||
|
@ -100,7 +109,12 @@ export class GuildArchives extends BaseGuildRepository {
|
|||
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 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 { CaseTypes } from "./CaseTypes";
|
||||
import { connection } from "./db";
|
||||
|
@ -116,13 +116,30 @@ export class GuildCases extends BaseGuildRepository {
|
|||
);
|
||||
}
|
||||
|
||||
async create(data): Promise<Case> {
|
||||
const result = await this.cases.insert({
|
||||
...data,
|
||||
guild_id: this.guildId,
|
||||
case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`,
|
||||
});
|
||||
async createInternal(data): Promise<InsertResult> {
|
||||
return this.cases
|
||||
.insert({
|
||||
...data,
|
||||
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))!;
|
||||
}
|
||||
|
||||
|
@ -131,7 +148,7 @@ export class GuildCases extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
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 caseNotes = entityManager.getRepository(CaseNote);
|
||||
|
||||
|
|
|
@ -17,19 +17,11 @@ const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
|
|||
const decayQueue = new Queue();
|
||||
|
||||
async function deleteCountersMarkedToBeDeleted(): Promise<void> {
|
||||
await getRepository(Counter)
|
||||
.createQueryBuilder()
|
||||
.where("delete_at <= NOW()")
|
||||
.delete()
|
||||
.execute();
|
||||
await getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||
}
|
||||
|
||||
async function deleteTriggersMarkedToBeDeleted(): Promise<void> {
|
||||
await getRepository(CounterTrigger)
|
||||
.createQueryBuilder()
|
||||
.where("delete_at <= NOW()")
|
||||
.delete()
|
||||
.execute();
|
||||
await getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||
}
|
||||
|
||||
setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);
|
||||
|
@ -97,10 +89,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
criteria.id = Not(In(idsToKeep));
|
||||
}
|
||||
|
||||
const deleteAt = moment
|
||||
.utc()
|
||||
.add(DELETE_UNUSED_COUNTERS_AFTER, "ms")
|
||||
.format(DBDateFormat);
|
||||
const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, "ms").format(DBDateFormat);
|
||||
|
||||
await this.counters.update(criteria, {
|
||||
delete_at: deleteAt,
|
||||
|
@ -108,11 +97,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async deleteCountersMarkedToBeDeleted(): Promise<void> {
|
||||
await this.counters
|
||||
.createQueryBuilder()
|
||||
.where("delete_at <= NOW()")
|
||||
.delete()
|
||||
.execute();
|
||||
await this.counters.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||
}
|
||||
|
||||
async changeCounterValue(
|
||||
|
@ -230,14 +215,11 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
const triggersToMark = await triggersToMarkQuery.getMany();
|
||||
|
||||
if (triggersToMark.length) {
|
||||
const deleteAt = moment
|
||||
.utc()
|
||||
.add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms")
|
||||
.format(DBDateFormat);
|
||||
const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms").format(DBDateFormat);
|
||||
|
||||
await this.counterTriggers.update(
|
||||
{
|
||||
id: In(triggersToMark.map(t => t.id)),
|
||||
id: In(triggersToMark.map((t) => t.id)),
|
||||
},
|
||||
{
|
||||
delete_at: deleteAt,
|
||||
|
@ -247,11 +229,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async deleteTriggersMarkedToBeDeleted(): Promise<void> {
|
||||
await this.counterTriggers
|
||||
.createQueryBuilder()
|
||||
.where("delete_at <= NOW()")
|
||||
.delete()
|
||||
.execute();
|
||||
await this.counterTriggers.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
|
||||
}
|
||||
|
||||
async initCounterTrigger(
|
||||
|
@ -278,7 +256,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);
|
||||
}
|
||||
|
||||
return connection.transaction(async entityManager => {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const existing = await entityManager.findOne(CounterTrigger, {
|
||||
counter_id: counterId,
|
||||
name: triggerName,
|
||||
|
@ -330,7 +308,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
channelId = channelId || "0";
|
||||
userId = userId || "0";
|
||||
|
||||
return connection.transaction(async entityManager => {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {
|
||||
trigger_id: counterTrigger.id,
|
||||
user_id: userId!,
|
||||
|
@ -378,7 +356,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
async checkAllValuesForTrigger(
|
||||
counterTrigger: CounterTrigger,
|
||||
): Promise<Array<{ channelId: string; userId: string }>> {
|
||||
return connection.transaction(async entityManager => {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const matchingValues = await entityManager
|
||||
.createQueryBuilder(CounterValue, "cv")
|
||||
.leftJoin(
|
||||
|
@ -395,7 +373,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
if (matchingValues.length) {
|
||||
await entityManager.insert(
|
||||
CounterTriggerState,
|
||||
matchingValues.map(row => ({
|
||||
matchingValues.map((row) => ({
|
||||
trigger_id: counterTrigger.id,
|
||||
channel_id: row.channel_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,
|
||||
userId: row.user_id,
|
||||
}));
|
||||
|
@ -429,7 +407,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
channelId = channelId || "0";
|
||||
userId = userId || "0";
|
||||
|
||||
return connection.transaction(async entityManager => {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const matchingValue = await entityManager
|
||||
.createQueryBuilder(CounterValue, "cv")
|
||||
.innerJoin(
|
||||
|
@ -468,7 +446,7 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
async checkAllValuesForReverseTrigger(
|
||||
counterTrigger: CounterTrigger,
|
||||
): Promise<Array<{ channelId: string; userId: string }>> {
|
||||
return connection.transaction(async entityManager => {
|
||||
return connection.transaction(async (entityManager) => {
|
||||
const matchingValues: Array<{
|
||||
id: string;
|
||||
triggerStateId: string;
|
||||
|
@ -496,11 +474,11 @@ export class GuildCounters extends BaseGuildRepository {
|
|||
|
||||
if (matchingValues.length) {
|
||||
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,
|
||||
userId: row.user_id,
|
||||
}));
|
||||
|
|
|
@ -1,42 +1,54 @@
|
|||
import { QueuedEventEmitter } from "../QueuedEventEmitter";
|
||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { Mute } from "./entities/Mute";
|
||||
import { ScheduledPost } from "./entities/ScheduledPost";
|
||||
import { Reminder } from "./entities/Reminder";
|
||||
|
||||
export class GuildEvents extends BaseGuildRepository {
|
||||
private queuedEventEmitter: QueuedEventEmitter;
|
||||
private pluginListeners: Map<string, Map<string, any[]>>;
|
||||
interface GuildEventArgs extends Record<string, unknown[]> {
|
||||
expiredMutes: [Mute[]];
|
||||
scheduledPosts: [ScheduledPost[]];
|
||||
reminders: [Reminder[]];
|
||||
}
|
||||
|
||||
constructor(guildId) {
|
||||
super(guildId);
|
||||
this.queuedEventEmitter = new QueuedEventEmitter();
|
||||
type GuildEvent = keyof GuildEventArgs;
|
||||
|
||||
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, {});
|
||||
}
|
||||
|
||||
public on(pluginName: string, eventName: string, fn) {
|
||||
this.queuedEventEmitter.on(eventName, fn);
|
||||
|
||||
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);
|
||||
const listenerMap = guildListeners.get(guildId)!;
|
||||
if (listenerMap[eventName] == null) {
|
||||
listenerMap[eventName] = [];
|
||||
}
|
||||
listenerMap[eventName]!.push(listener);
|
||||
|
||||
public offPlugin(pluginName: string) {
|
||||
const pluginListeners = this.pluginListeners.get(pluginName) || new Map();
|
||||
for (const [eventName, listeners] of Array.from(pluginListeners.entries())) {
|
||||
for (const listener of listeners) {
|
||||
this.queuedEventEmitter.off(eventName, listener);
|
||||
}
|
||||
}
|
||||
this.pluginListeners.delete(pluginName);
|
||||
return () => {
|
||||
listenerMap[eventName]!.splice(listenerMap[eventName]!.indexOf(listener), 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitGuildEvent<K extends GuildEvent>(guildId: string, eventName: K, args: GuildEventArgs[K]): void {
|
||||
if (!guildListeners.has(guildId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
public emit(eventName: string, args: any[] = []) {
|
||||
return this.queuedEventEmitter.emit(eventName, args);
|
||||
const listenerMap = guildListeners.get(guildId)!;
|
||||
if (listenerMap[eventName] == null) {
|
||||
return;
|
||||
}
|
||||
for (const listener of listenerMap[eventName]!) {
|
||||
listener(...args);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,12 +46,12 @@ export class GuildLogs extends events.EventEmitter {
|
|||
}
|
||||
|
||||
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) {
|
||||
this.ignoredLogs.splice(
|
||||
this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId),
|
||||
this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ export class GuildMemberTimezones extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async set(memberId, timezone: string) {
|
||||
await connection.transaction(async entityManager => {
|
||||
await connection.transaction(async (entityManager) => {
|
||||
const repo = entityManager.getRepository(MemberTimezone);
|
||||
const existingRow = await repo.findOne({
|
||||
guild_id: this.guildId,
|
||||
|
|
|
@ -35,12 +35,7 @@ export class GuildMutes extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
|
||||
const expiresAt = expiryTime
|
||||
? moment
|
||||
.utc()
|
||||
.add(expiryTime, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss")
|
||||
: null;
|
||||
const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
|
||||
|
||||
const result = await this.mutes.insert({
|
||||
guild_id: this.guildId,
|
||||
|
@ -53,12 +48,7 @@ export class GuildMutes extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) {
|
||||
const expiresAt = newExpiryTime
|
||||
? moment
|
||||
.utc()
|
||||
.add(newExpiryTime, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss")
|
||||
: null;
|
||||
const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
|
||||
|
||||
if (rolesToRestore && rolesToRestore.length) {
|
||||
return this.mutes.update(
|
||||
|
@ -89,7 +79,7 @@ export class GuildMutes extends BaseGuildRepository {
|
|||
.createQueryBuilder("mutes")
|
||||
.where("guild_id = :guild_id", { guild_id: this.guildId })
|
||||
.andWhere(
|
||||
new Brackets(qb => {
|
||||
new Brackets((qb) => {
|
||||
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -70,7 +70,7 @@ export class GuildNicknameHistory extends BaseGuildRepository {
|
|||
|
||||
if (toDelete.length > 0) {
|
||||
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,
|
||||
};
|
||||
|
||||
if (msg.attachments.size) data.attachments = [...msg.attachments.values()];
|
||||
if (msg.embeds.length) data.embeds = msg.embeds;
|
||||
if (msg.stickers?.size) data.stickers = [...msg.stickers.values()];
|
||||
if (msg.attachments.size) {
|
||||
data.attachments = Array.from(msg.attachments.values()).map((att) => ({
|
||||
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;
|
||||
}
|
||||
|
@ -131,8 +212,12 @@ export class GuildSavedMessages extends BaseGuildRepository {
|
|||
try {
|
||||
await this.messages.insert(data);
|
||||
} catch (e) {
|
||||
console.warn(e); // tslint:disable-line
|
||||
return;
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
console.trace(`Tried to insert duplicate message ID: ${data.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
const inserted = await this.messages.findOne(data.id);
|
||||
|
@ -141,8 +226,10 @@ export class GuildSavedMessages extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async createFromMsg(msg: Message, overrides = {}) {
|
||||
const existingSavedMsg = await this.find(msg.id);
|
||||
if (existingSavedMsg) return;
|
||||
// FIXME: Hotfix
|
||||
if (!msg.channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedMessageData = this.msgToSavedMessageData(msg);
|
||||
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[]> {
|
||||
return this.scheduledPosts
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.getMany();
|
||||
return this.scheduledPosts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
|
||||
}
|
||||
|
||||
getDueScheduledPosts(): Promise<ScheduledPost[]> {
|
||||
|
|
|
@ -67,10 +67,7 @@ export class GuildSlowmodes extends BaseGuildRepository {
|
|||
const slowmode = await this.getChannelSlowmode(channelId);
|
||||
if (!slowmode) return;
|
||||
|
||||
const expiresAt = moment
|
||||
.utc()
|
||||
.add(slowmode.slowmode_seconds, "seconds")
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
const expiresAt = moment.utc().add(slowmode.slowmode_seconds, "seconds").format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
if (await this.userHasSlowmode(channelId, userId)) {
|
||||
// Update existing
|
||||
|
|
|
@ -30,10 +30,7 @@ export class GuildTempbans extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async addTempban(userId, expiryTime, modId): Promise<Tempban> {
|
||||
const expiresAt = moment
|
||||
.utc()
|
||||
.add(expiryTime, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
const expiresAt = moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
const result = await this.tempbans.insert({
|
||||
guild_id: this.guildId,
|
||||
|
@ -47,10 +44,7 @@ export class GuildTempbans extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async updateExpiryTime(userId, newExpiryTime, modId) {
|
||||
const expiresAt = moment
|
||||
.utc()
|
||||
.add(newExpiryTime, "ms")
|
||||
.format("YYYY-MM-DD HH:mm:ss");
|
||||
const expiresAt = moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
return this.tempbans.update(
|
||||
{
|
||||
|
|
|
@ -19,10 +19,7 @@ export class GuildVCAlerts extends BaseGuildRepository {
|
|||
}
|
||||
|
||||
async getAllGuildAlerts(): Promise<VCAlert[]> {
|
||||
return this.allAlerts
|
||||
.createQueryBuilder()
|
||||
.where("guild_id = :guildId", { guildId: this.guildId })
|
||||
.getMany();
|
||||
return this.allAlerts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
|
||||
}
|
||||
|
||||
async getAlertsByUserId(userId: string): Promise<VCAlert[]> {
|
||||
|
|
|
@ -67,7 +67,7 @@ export class UsernameHistory extends BaseRepository {
|
|||
|
||||
if (toDelete.length > 0) {
|
||||
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;
|
||||
|
||||
// >1 month old: 1 config retained per month
|
||||
const oneMonthCutoff = moment
|
||||
.utc()
|
||||
.subtract(30, "days")
|
||||
.format(DBDateFormat);
|
||||
const oneMonthCutoff = moment.utc().subtract(30, "days").format(DBDateFormat);
|
||||
do {
|
||||
rows = await connection.query(
|
||||
`
|
||||
|
@ -46,7 +43,7 @@ export async function cleanupConfigs() {
|
|||
|
||||
if (rows.length > 0) {
|
||||
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);
|
||||
|
||||
// >2 weeks old: 1 config retained per day
|
||||
const twoWeekCutoff = moment
|
||||
.utc()
|
||||
.subtract(2, "weeks")
|
||||
.format(DBDateFormat);
|
||||
const twoWeekCutoff = moment.utc().subtract(2, "weeks").format(DBDateFormat);
|
||||
do {
|
||||
rows = await connection.query(
|
||||
`
|
||||
|
@ -87,7 +81,7 @@ export async function cleanupConfigs() {
|
|||
|
||||
if (rows.length > 0) {
|
||||
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 BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES;
|
||||
const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES;
|
||||
const CLEAN_PER_LOOP = 500;
|
||||
const CLEAN_PER_LOOP = 200;
|
||||
|
||||
export async function cleanupMessages(): Promise<number> {
|
||||
let cleaned = 0;
|
||||
|
||||
const messagesRepository = getRepository(SavedMessage);
|
||||
|
||||
const deletedAtThreshold = moment
|
||||
.utc()
|
||||
.subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms")
|
||||
.format(DBDateFormat);
|
||||
const postedAtThreshold = moment
|
||||
.utc()
|
||||
.subtract(RETENTION_PERIOD, "ms")
|
||||
.format(DBDateFormat);
|
||||
const botPostedAtThreshold = moment
|
||||
.utc()
|
||||
.subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms")
|
||||
.format(DBDateFormat);
|
||||
const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms").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
|
||||
// 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
|
||||
let rows;
|
||||
let ids: string[];
|
||||
do {
|
||||
rows = await connection.query(
|
||||
const deletedMessageRows = await connection.query(
|
||||
`
|
||||
SELECT id
|
||||
FROM messages
|
||||
WHERE (
|
||||
deleted_at IS NOT NULL
|
||||
AND deleted_at <= ?
|
||||
)
|
||||
OR (
|
||||
posted_at <= ?
|
||||
AND is_permanent = 0
|
||||
)
|
||||
OR (
|
||||
is_bot = 1
|
||||
AND posted_at <= ?
|
||||
AND is_permanent = 0
|
||||
)
|
||||
deleted_at IS NOT NULL
|
||||
AND deleted_at <= ?
|
||||
)
|
||||
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({
|
||||
id: In(rows.map(r => r.id)),
|
||||
id: In(ids),
|
||||
});
|
||||
}
|
||||
|
||||
cleaned += rows.length;
|
||||
} while (rows.length === CLEAN_PER_LOOP);
|
||||
cleaned += ids.length;
|
||||
} while (ids.length > 0);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
|
|
@ -11,10 +11,7 @@ export async function cleanupNicknames(): Promise<number> {
|
|||
let cleaned = 0;
|
||||
|
||||
const nicknameHistoryRepository = getRepository(NicknameHistoryEntry);
|
||||
const dateThreshold = moment
|
||||
.utc()
|
||||
.subtract(NICKNAME_RETENTION_PERIOD, "ms")
|
||||
.format(DBDateFormat);
|
||||
const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
|
||||
|
||||
// Clean old nicknames (NICKNAME_RETENTION_PERIOD)
|
||||
let rows;
|
||||
|
@ -31,7 +28,7 @@ export async function cleanupNicknames(): Promise<number> {
|
|||
|
||||
if (rows.length > 0) {
|
||||
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;
|
||||
|
||||
const usernameHistoryRepository = getRepository(UsernameHistoryEntry);
|
||||
const dateThreshold = moment
|
||||
.utc()
|
||||
.subtract(USERNAME_RETENTION_PERIOD, "ms")
|
||||
.format(DBDateFormat);
|
||||
const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
|
||||
|
||||
// Clean old usernames (USERNAME_RETENTION_PERIOD)
|
||||
let rows;
|
||||
|
@ -31,7 +28,7 @@ export async function cleanupUsernames(): Promise<number> {
|
|||
|
||||
if (rows.length > 0) {
|
||||
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()
|
||||
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()
|
||||
expires_at: string;
|
||||
|
||||
@ManyToOne(
|
||||
type => ApiUserInfo,
|
||||
userInfo => userInfo.logins,
|
||||
)
|
||||
@ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.logins)
|
||||
@JoinColumn({ name: "user_id" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
|
||||
import { ApiUserInfo } from "./ApiUserInfo";
|
||||
import { ApiPermissionTypes } from "../ApiPermissionAssignments";
|
||||
|
||||
@Entity("api_permissions")
|
||||
export class ApiPermissionAssignment {
|
||||
|
@ -7,9 +8,9 @@ export class ApiPermissionAssignment {
|
|||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
@Column({ type: String })
|
||||
@PrimaryColumn()
|
||||
type: string;
|
||||
type: ApiPermissionTypes;
|
||||
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
|
@ -18,10 +19,10 @@ export class ApiPermissionAssignment {
|
|||
@Column("simple-array")
|
||||
permissions: string[];
|
||||
|
||||
@ManyToOne(
|
||||
type => ApiUserInfo,
|
||||
userInfo => userInfo.permissionAssignments,
|
||||
)
|
||||
@Column({ type: String, nullable: true })
|
||||
expires_at: string | null;
|
||||
|
||||
@ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.permissionAssignments)
|
||||
@JoinColumn({ name: "target_id" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
||||
|
|
|
@ -20,15 +20,9 @@ export class ApiUserInfo {
|
|||
@Column()
|
||||
updated_at: string;
|
||||
|
||||
@OneToMany(
|
||||
type => ApiLogin,
|
||||
login => login.userInfo,
|
||||
)
|
||||
@OneToMany((type) => ApiLogin, (login) => login.userInfo)
|
||||
logins: ApiLogin[];
|
||||
|
||||
@OneToMany(
|
||||
type => ApiPermissionAssignment,
|
||||
p => p.userInfo,
|
||||
)
|
||||
@OneToMany((type) => ApiPermissionAssignment, (p) => p.userInfo)
|
||||
permissionAssignments: ApiPermissionAssignment[];
|
||||
}
|
||||
|
|
|
@ -35,9 +35,6 @@ export class Case {
|
|||
*/
|
||||
@Column({ type: String, nullable: true }) log_message_id: string | null;
|
||||
|
||||
@OneToMany(
|
||||
type => CaseNote,
|
||||
note => note.case,
|
||||
)
|
||||
@OneToMany((type) => CaseNote, (note) => note.case)
|
||||
notes: CaseNote[];
|
||||
}
|
||||
|
|
|
@ -15,10 +15,7 @@ export class CaseNote {
|
|||
|
||||
@Column() created_at: string;
|
||||
|
||||
@ManyToOne(
|
||||
type => Case,
|
||||
theCase => theCase.notes,
|
||||
)
|
||||
@ManyToOne((type) => Case, (theCase) => theCase.notes)
|
||||
@JoinColumn({ name: "case_id" })
|
||||
case: Case;
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ export class Config {
|
|||
@Column()
|
||||
edited_at: string;
|
||||
|
||||
@ManyToOne(type => ApiUserInfo)
|
||||
@ManyToOne((type) => ApiUserInfo)
|
||||
@JoinColumn({ name: "edited_by" })
|
||||
userInfo: ApiUserInfo;
|
||||
}
|
||||
|
|
|
@ -1,16 +1,79 @@
|
|||
import { MessageAttachment, Sticker } from "discord.js";
|
||||
import { Snowflake } from "discord.js";
|
||||
import { Column, Entity, PrimaryColumn } from "typeorm";
|
||||
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 {
|
||||
attachments?: MessageAttachment[];
|
||||
attachments?: ISavedMessageAttachmentData[];
|
||||
author: {
|
||||
username: string;
|
||||
discriminator: string;
|
||||
};
|
||||
content: string;
|
||||
embeds?: object[];
|
||||
stickers?: Sticker[];
|
||||
embeds?: ISavedMessageEmbedData[];
|
||||
stickers?: ISavedMessageStickerData[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ export class StarboardMessage {
|
|||
@Column()
|
||||
guild_id: string;
|
||||
|
||||
@OneToOne(type => SavedMessage)
|
||||
@OneToOne((type) => SavedMessage)
|
||||
@JoinColumn({ name: "message_id" })
|
||||
message: SavedMessage;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ export class StarboardReaction {
|
|||
@Column()
|
||||
reactor_id: string;
|
||||
|
||||
@OneToOne(type => SavedMessage)
|
||||
@OneToOne((type) => SavedMessage)
|
||||
@JoinColumn({ name: "message_id" })
|
||||
message: SavedMessage;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Client, Intents, TextChannel } from "discord.js";
|
||||
import yaml from "js-yaml";
|
||||
import { Client, Constants, Intents, TextChannel, ThreadChannel } from "discord.js";
|
||||
import { Knub, PluginError } from "knub";
|
||||
import { PluginLoadError } from "knub/dist/plugins/PluginLoadError";
|
||||
// Always use UTC internally
|
||||
|
@ -18,8 +17,12 @@ import { RecoverablePluginError } from "./RecoverablePluginError";
|
|||
import { SimpleError } from "./SimpleError";
|
||||
import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types";
|
||||
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 { DecayingCounter } from "./utils/DecayingCounter";
|
||||
import { PluginNotLoadedError } from "knub/dist/plugins/PluginNotLoadedError";
|
||||
import { logRestCall } from "./restCallStats";
|
||||
import { logRateLimit } from "./rateLimitStats";
|
||||
|
||||
if (!process.env.KEY) {
|
||||
// tslint:disable-next-line:no-console
|
||||
|
@ -94,6 +97,25 @@ function errorHandler(err) {
|
|||
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
|
||||
console.error(err);
|
||||
|
||||
|
@ -124,8 +146,8 @@ if (process.env.NODE_ENV === "production") {
|
|||
|
||||
// Verify required Node.js version
|
||||
const REQUIRED_NODE_VERSION = "14.0.0";
|
||||
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10));
|
||||
const actualVersionParts = process.versions.node.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));
|
||||
for (const [i, part] of actualVersionParts.entries()) {
|
||||
if (part > requiredParts[i]) break;
|
||||
if (part === requiredParts[i]) continue;
|
||||
|
@ -136,10 +158,21 @@ moment.tz.setDefault("UTC");
|
|||
|
||||
logger.info("Connecting to database");
|
||||
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({
|
||||
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"],
|
||||
restTimeOffset: 150,
|
||||
|
||||
restGlobalRateLimit: 50,
|
||||
// restTimeOffset: 1000,
|
||||
|
||||
// Disable mentions by default
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
|
@ -166,11 +199,31 @@ connect().then(async () => {
|
|||
});
|
||||
client.setMaxListeners(200);
|
||||
|
||||
client.on("rateLimit", rateLimitData => {
|
||||
logger.info(`[429] ${JSON.stringify(rateLimitData)}`);
|
||||
client.on(Constants.Events.RATE_LIMIT, (data) => {
|
||||
// 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));
|
||||
});
|
||||
|
||||
|
@ -198,9 +251,9 @@ connect().then(async () => {
|
|||
}
|
||||
|
||||
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;
|
||||
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
|
||||
});
|
||||
|
@ -208,6 +261,13 @@ connect().then(async () => {
|
|||
|
||||
async getConfig(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);
|
||||
if (row) {
|
||||
try {
|
||||
|
@ -239,13 +299,15 @@ connect().then(async () => {
|
|||
},
|
||||
|
||||
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;
|
||||
channel.send(successMessage(body, emoji));
|
||||
},
|
||||
|
||||
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;
|
||||
channel.send(errorMessage(body, emoji));
|
||||
},
|
||||
|
@ -256,6 +318,10 @@ connect().then(async () => {
|
|||
startUptimeCounter();
|
||||
});
|
||||
|
||||
client.on(Constants.Events.RATE_LIMIT, (data) => {
|
||||
logRateLimit(data);
|
||||
});
|
||||
|
||||
bot.initialize();
|
||||
logger.info("Bot Initialized");
|
||||
logger.info("Logging in...");
|
||||
|
|
|
@ -9,7 +9,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
|
|||
|
||||
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");
|
||||
stream.on("data", (row: any) => {
|
||||
migratedUsernames.add(row.key);
|
||||
|
@ -18,7 +18,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
|
|||
});
|
||||
|
||||
const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => {
|
||||
return new Promise(async resolve => {
|
||||
return new Promise(async (resolve) => {
|
||||
const toInsert: any[][] = [];
|
||||
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 = [
|
||||
"channel",
|
||||
"category",
|
||||
"thread",
|
||||
"is_thread",
|
||||
"level",
|
||||
"user",
|
||||
"role",
|
||||
|
@ -83,7 +85,7 @@ export function strictValidationErrorToConfigValidationError(err: StrictValidati
|
|||
return new ConfigValidationError(
|
||||
err
|
||||
.getErrors()
|
||||
.map(e => e.toString())
|
||||
.map((e) => e.toString())
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
@ -197,7 +199,7 @@ export async function sendSuccessMessage(
|
|||
|
||||
return channel
|
||||
.send({ ...content }) // Force line break
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
|
||||
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
|
||||
return undefined;
|
||||
|
@ -218,7 +220,7 @@ export async function sendErrorMessage(
|
|||
|
||||
return channel
|
||||
.send({ ...content }) // Force line break
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
|
||||
logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`);
|
||||
return undefined;
|
||||
|
@ -232,7 +234,7 @@ export function getBaseUrl(pluginData: AnyPluginData<any>) {
|
|||
|
||||
export function isOwner(pluginData: AnyPluginData<any>, userId: string) {
|
||||
const knub = pluginData.getKnubInstance() as TZeppelinKnub;
|
||||
const owners = knub.getGlobalConfig().owners;
|
||||
const owners = knub.getGlobalConfig()?.owners;
|
||||
if (!owners) {
|
||||
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
|
||||
*/
|
||||
export function mapToPublicFn<T extends AnyFn>(inputFn: T) {
|
||||
return pluginData => {
|
||||
return (pluginData) => {
|
||||
return (...args: Tail<Parameters<typeof inputFn>>): ReturnType<typeof inputFn> => {
|
||||
return inputFn(pluginData, ...args);
|
||||
};
|
||||
|
|
|
@ -25,7 +25,7 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
|
|||
configurationGuide: "Maximum deletion delay is currently 5 minutes",
|
||||
},
|
||||
|
||||
dependencies: [TimeAndDatePlugin, LogsPlugin],
|
||||
dependencies: () => [TimeAndDatePlugin, LogsPlugin],
|
||||
configSchema: ConfigSchema,
|
||||
defaultOptions,
|
||||
|
||||
|
@ -45,13 +45,13 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
|
|||
afterLoad(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.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg);
|
||||
state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg);
|
||||
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);
|
||||
},
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Permissions, Snowflake, TextChannel } from "discord.js";
|
||||
import { GuildPluginData } from "knub";
|
||||
import moment from "moment-timezone";
|
||||
import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
|
||||
import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||
import { LogType } from "../../../data/LogType";
|
||||
import { logger } from "../../../logger";
|
||||
import { resolveUser, verboseChannelMention } from "../../../utils";
|
||||
|
@ -27,7 +27,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
|||
const perms = channel.permissionsFor(pluginData.client.user!.id);
|
||||
|
||||
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(
|
||||
channel,
|
||||
)}`,
|
||||
|
@ -36,7 +36,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
|||
}
|
||||
|
||||
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)}`,
|
||||
});
|
||||
return;
|
||||
|
@ -45,7 +45,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
|||
const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
|
||||
|
||||
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) {
|
||||
// "Unknown Message", probably already deleted by automod or another bot, ignore
|
||||
return;
|
||||
|
@ -59,10 +59,10 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
|
|||
.inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x"))
|
||||
.format(timeAndDate.getDateFormat("pretty_datetime"));
|
||||
|
||||
pluginData.state.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, {
|
||||
pluginData.getPlugin(LogsPlugin).logMessageDeleteAuto({
|
||||
message: itemToDelete.message,
|
||||
user: userToConfigAccessibleUser(user),
|
||||
channel: channelToConfigAccessibleChannel(channel),
|
||||
user,
|
||||
channel,
|
||||
messageDate,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType";
|
|||
import { convertDelayStringToMS, resolveMember } from "../../../utils";
|
||||
import { AutoDeletePluginType, MAX_DELAY } from "../types";
|
||||
import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
|
||||
export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
|
||||
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) {
|
||||
delay = MAX_DELAY;
|
||||
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`,
|
||||
});
|
||||
pluginData.state.maxDelayWarningSent = true;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { AutoDeletePluginType } from "../types";
|
|||
import { scheduleNextDeletion } from "./scheduleNextDeletion";
|
||||
|
||||
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) {
|
||||
pluginData.state.deletionQueue.splice(indexToDelete, 1);
|
||||
scheduleNextDeletion(pluginData);
|
||||
|
|
|
@ -33,7 +33,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
|
|||
`),
|
||||
},
|
||||
|
||||
dependencies: [LogsPlugin],
|
||||
dependencies: () => [LogsPlugin],
|
||||
configSchema: ConfigSchema,
|
||||
defaultOptions,
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ export const AddReactionsEvt = autoReactionsEvt({
|
|||
);
|
||||
if (missingPermissions) {
|
||||
const logs = pluginData.getPlugin(LogsPlugin);
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
body: `Cannot apply auto-reactions in <#${message.channel.id}>. ${missingPermissionError(missingPermissions)}`,
|
||||
});
|
||||
return;
|
||||
|
@ -39,11 +39,11 @@ export const AddReactionsEvt = autoReactionsEvt({
|
|||
if (isDiscordAPIError(e)) {
|
||||
const logs = pluginData.getPlugin(LogsPlugin);
|
||||
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.`,
|
||||
});
|
||||
} 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}.`,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -57,7 +57,7 @@ const defaultOptions = {
|
|||
/**
|
||||
* 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) {
|
||||
// Loop through each rule
|
||||
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,
|
||||
|
||||
// prettier-ignore
|
||||
dependencies: [
|
||||
dependencies: () => [
|
||||
LogsPlugin,
|
||||
ModActionsPlugin,
|
||||
MutesPlugin,
|
||||
|
@ -217,10 +232,10 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
|
|||
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.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true);
|
||||
pluginData.state.onMessageUpdateFn = (message) => runAutomodOnMessage(pluginData, message, true);
|
||||
pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn);
|
||||
|
||||
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
||||
|
|
|
@ -17,13 +17,13 @@ export const AddRolesAction = automodAction({
|
|||
defaultConfig: [],
|
||||
|
||||
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 missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
|
||||
if (missingPermissions) {
|
||||
const logs = pluginData.getPlugin(LogsPlugin);
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
|
||||
});
|
||||
return;
|
||||
|
@ -41,10 +41,10 @@ export const AddRolesAction = automodAction({
|
|||
|
||||
if (rolesWeCannotAssign.length) {
|
||||
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);
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join(
|
||||
"**, **",
|
||||
)}**`,
|
||||
|
@ -52,7 +52,7 @@ export const AddRolesAction = automodAction({
|
|||
}
|
||||
|
||||
await Promise.all(
|
||||
members.map(async member => {
|
||||
members.map(async (member) => {
|
||||
const memberRoles = new Set(member.roles.cache.keys());
|
||||
for (const roleId of rolesToAssign) {
|
||||
memberRoles.add(roleId as Snowflake);
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as t from "io-ts";
|
|||
import { LogType } from "../../../data/LogType";
|
||||
import { CountersPlugin } from "../../Counters/CountersPlugin";
|
||||
import { automodAction } from "../helpers";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
|
||||
export const AddToCounterAction = automodAction({
|
||||
configType: t.type({
|
||||
|
@ -14,7 +15,7 @@ export const AddToCounterAction = automodAction({
|
|||
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
|
||||
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
||||
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}\``,
|
||||
});
|
||||
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 { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
|
||||
import { LogType } from "../../../data/LogType";
|
||||
import { renderTemplate, TemplateParseError } from "../../../templateFormatter";
|
||||
import {
|
||||
createTypedTemplateSafeValueContainer,
|
||||
renderTemplate,
|
||||
TemplateParseError,
|
||||
TemplateSafeValueContainer,
|
||||
} from "../../../templateFormatter";
|
||||
import {
|
||||
createChunkedMessage,
|
||||
messageLink,
|
||||
stripObjectToScalars,
|
||||
tAllowedMentions,
|
||||
tNormalizedNullOptional,
|
||||
isTruthy,
|
||||
verboseChannelMention,
|
||||
validateAndParseMessageContent,
|
||||
} from "../../../utils";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
import { automodAction } from "../helpers";
|
||||
import { TemplateSafeUser, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||
import { messageIsEmpty } from "../../../utils/messageIsEmpty";
|
||||
|
||||
export const AlertAction = automodAction({
|
||||
configType: t.type({
|
||||
|
@ -27,38 +36,44 @@ export const AlertAction = automodAction({
|
|||
const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);
|
||||
const logs = pluginData.getPlugin(LogsPlugin);
|
||||
|
||||
if (channel && channel instanceof TextChannel) {
|
||||
if (channel && (channel instanceof TextChannel || channel instanceof ThreadChannel)) {
|
||||
const text = actionConfig.text;
|
||||
const theMessageLink =
|
||||
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 actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
|
||||
|
||||
const logMessage = await logs.getLogMessage(LogType.AUTOMOD_ACTION, {
|
||||
rule: ruleName,
|
||||
user: safeUser,
|
||||
users: safeUsers,
|
||||
actionsTaken,
|
||||
matchSummary: matchResult.summary,
|
||||
});
|
||||
|
||||
let rendered;
|
||||
try {
|
||||
rendered = await renderTemplate(actionConfig.text, {
|
||||
const logMessage = await logs.getLogMessage(
|
||||
LogType.AUTOMOD_ACTION,
|
||||
createTypedTemplateSafeValueContainer({
|
||||
rule: ruleName,
|
||||
user: safeUser,
|
||||
users: safeUsers,
|
||||
text,
|
||||
actionsTaken,
|
||||
matchSummary: matchResult.summary,
|
||||
messageLink: theMessageLink,
|
||||
logMessage,
|
||||
});
|
||||
matchSummary: matchResult.summary ?? "",
|
||||
}),
|
||||
);
|
||||
|
||||
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) {
|
||||
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}`,
|
||||
});
|
||||
return;
|
||||
|
@ -67,6 +82,13 @@ export const AlertAction = automodAction({
|
|||
throw err;
|
||||
}
|
||||
|
||||
if (messageIsEmpty(rendered)) {
|
||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||
body: `Tried to send alert with an empty message for automod rule ${ruleName}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createChunkedMessage(
|
||||
channel,
|
||||
|
@ -75,13 +97,13 @@ export const AlertAction = automodAction({
|
|||
);
|
||||
} catch (err) {
|
||||
if (err.code === 50001) {
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
body: `Missing access to send alert to channel ${verboseChannelMention(
|
||||
channel,
|
||||
)} in automod rule **${ruleName}**`,
|
||||
});
|
||||
} else {
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention(
|
||||
channel,
|
||||
)} in automod rule **${ruleName}**`,
|
||||
|
@ -89,7 +111,7 @@ export const AlertAction = automodAction({
|
|||
}
|
||||
}
|
||||
} else {
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
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 { AddToCounterAction } from "./addToCounter";
|
||||
import { AlertAction } from "./alert";
|
||||
import { ArchiveThreadAction } from "./archiveThread";
|
||||
import { BanAction } from "./ban";
|
||||
import { ChangeNicknameAction } from "./changeNickname";
|
||||
import { CleanAction } from "./clean";
|
||||
|
@ -32,6 +33,7 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
|
|||
add_to_counter: AddToCounterAction,
|
||||
set_counter: SetCounterAction,
|
||||
set_slowmode: SetSlowmodeAction,
|
||||
archive_thread: ArchiveThreadAction,
|
||||
};
|
||||
|
||||
export const AvailableActions = t.type({
|
||||
|
@ -50,4 +52,5 @@ export const AvailableActions = t.type({
|
|||
add_to_counter: AddToCounterAction.configType,
|
||||
set_counter: SetCounterAction.configType,
|
||||
set_slowmode: SetSlowmodeAction.configType,
|
||||
archive_thread: ArchiveThreadAction.configType,
|
||||
});
|
||||
|
|
|
@ -35,7 +35,7 @@ export const BanAction = automodAction({
|
|||
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);
|
||||
for (const userId of userIdsToBan) {
|
||||
|
|
|
@ -15,14 +15,14 @@ export const ChangeNicknameAction = automodAction({
|
|||
defaultConfig: {},
|
||||
|
||||
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) {
|
||||
if (pluginData.state.recentNicknameChanges.has(member.id)) continue;
|
||||
const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name;
|
||||
|
||||
member.edit({ nick: newName }).catch(err => {
|
||||
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {
|
||||
member.edit({ nick: newName }).catch((err) => {
|
||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||
body: `Failed to change the nickname of \`${member.id}\``,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -31,8 +31,8 @@ export const KickAction = automodAction({
|
|||
hide: Boolean(actionConfig.hide_case),
|
||||
};
|
||||
|
||||
const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish));
|
||||
const membersToKick = await asyncMap(userIdsToKick, id => resolveMember(pluginData.client, pluginData.guild, id));
|
||||
const userIdsToKick = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
|
||||
const membersToKick = await asyncMap(userIdsToKick, (id) => resolveMember(pluginData.client, pluginData.guild, id));
|
||||
|
||||
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
||||
for (const member of membersToKick) {
|
||||
|
|
|
@ -1,26 +1,25 @@
|
|||
import * as t from "io-ts";
|
||||
import { LogType } from "../../../data/LogType";
|
||||
import { stripObjectToScalars, unique } from "../../../utils";
|
||||
import { isTruthy, stripObjectToScalars, unique } from "../../../utils";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
import { automodAction } from "../helpers";
|
||||
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||
|
||||
export const LogAction = automodAction({
|
||||
configType: t.boolean,
|
||||
defaultConfig: true,
|
||||
|
||||
async apply({ pluginData, contexts, ruleName, matchResult }) {
|
||||
const safeUsers = unique(contexts.map(c => c.user))
|
||||
.filter(Boolean)
|
||||
.map(user => stripObjectToScalars(user));
|
||||
const safeUser = safeUsers[0];
|
||||
const users = unique(contexts.map((c) => c.user)).filter(isTruthy);
|
||||
const user = users[0];
|
||||
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
|
||||
|
||||
pluginData.getPlugin(LogsPlugin).log(LogType.AUTOMOD_ACTION, {
|
||||
pluginData.getPlugin(LogsPlugin).logAutomodAction({
|
||||
rule: ruleName,
|
||||
user: safeUser,
|
||||
users: safeUsers,
|
||||
user,
|
||||
users,
|
||||
actionsTaken,
|
||||
matchSummary: matchResult.summary,
|
||||
matchSummary: matchResult.summary ?? "",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -40,7 +40,7 @@ export const MuteAction = automodAction({
|
|||
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);
|
||||
for (const userId of userIdsToMute) {
|
||||
|
@ -55,7 +55,7 @@ export const MuteAction = automodAction({
|
|||
);
|
||||
} catch (e) {
|
||||
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`,
|
||||
});
|
||||
} else {
|
||||
|
|
|
@ -18,13 +18,13 @@ export const RemoveRolesAction = automodAction({
|
|||
defaultConfig: [],
|
||||
|
||||
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 missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
|
||||
if (missingPermissions) {
|
||||
const logs = pluginData.getPlugin(LogsPlugin);
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
|
||||
});
|
||||
return;
|
||||
|
@ -42,10 +42,10 @@ export const RemoveRolesAction = automodAction({
|
|||
|
||||
if (rolesWeCannotRemove.length) {
|
||||
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);
|
||||
logs.log(LogType.BOT_ALERT, {
|
||||
logs.logBotAlert({
|
||||
body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join(
|
||||
"**, **",
|
||||
)}**`,
|
||||
|
@ -53,7 +53,7 @@ export const RemoveRolesAction = automodAction({
|
|||
}
|
||||
|
||||
await Promise.all(
|
||||
members.map(async member => {
|
||||
members.map(async (member) => {
|
||||
const memberRoles = new Set(member.roles.cache.keys());
|
||||
for (const roleId of rolesToRemove) {
|
||||
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 { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
|
||||
import { LogType } from "../../../data/LogType";
|
||||
import { renderTemplate } from "../../../templateFormatter";
|
||||
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||
import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
|
||||
import {
|
||||
convertDelayStringToMS,
|
||||
noop,
|
||||
|
@ -11,11 +10,13 @@ import {
|
|||
tMessageContent,
|
||||
tNullable,
|
||||
unique,
|
||||
validateAndParseMessageContent,
|
||||
verboseChannelMention,
|
||||
} from "../../../utils";
|
||||
import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
|
||||
import { automodAction } from "../helpers";
|
||||
import { AutomodContext } from "../types";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
|
||||
export const ReplyAction = automodAction({
|
||||
configType: t.union([
|
||||
|
@ -23,6 +24,7 @@ export const ReplyAction = automodAction({
|
|||
t.type({
|
||||
text: tMessageContent,
|
||||
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 }) {
|
||||
const contextsWithTextChannels = contexts
|
||||
.filter(c => c.message?.channel_id)
|
||||
.filter(c => pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake) instanceof TextChannel);
|
||||
.filter((c) => c.message?.channel_id)
|
||||
.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) => {
|
||||
if (!map.has(context.message!.channel_id)) {
|
||||
|
@ -43,13 +48,17 @@ export const ReplyAction = automodAction({
|
|||
}, new Map());
|
||||
|
||||
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 renderReplyText = async str =>
|
||||
renderTemplate(str, {
|
||||
user: userToConfigAccessibleUser(user),
|
||||
});
|
||||
const renderReplyText = async (str: string) =>
|
||||
renderTemplate(
|
||||
str,
|
||||
new TemplateSafeValueContainer({
|
||||
user: userToTemplateSafeUser(user),
|
||||
}),
|
||||
);
|
||||
|
||||
const formatted =
|
||||
typeof actionConfig === "string"
|
||||
? await renderReplyText(actionConfig)
|
||||
|
@ -65,7 +74,7 @@ export const ReplyAction = automodAction({
|
|||
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}\``,
|
||||
});
|
||||
continue;
|
||||
|
@ -76,7 +85,7 @@ export const ReplyAction = automodAction({
|
|||
typeof formatted !== "string" &&
|
||||
!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(
|
||||
channel,
|
||||
)} in Automod rule \`${ruleName}\``,
|
||||
|
@ -84,17 +93,27 @@ export const ReplyAction = automodAction({
|
|||
continue;
|
||||
}
|
||||
|
||||
const messageContent: MessageOptions = typeof formatted === "string" ? { content: formatted } : formatted;
|
||||
const replyMsg = await channel.send({
|
||||
const messageContent = validateAndParseMessageContent(formatted);
|
||||
|
||||
const messageOpts: MessageOptions = {
|
||||
...messageContent,
|
||||
allowedMentions: {
|
||||
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) {
|
||||
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 { CountersPlugin } from "../../Counters/CountersPlugin";
|
||||
import { automodAction } from "../helpers";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
|
||||
export const SetCounterAction = automodAction({
|
||||
configType: t.type({
|
||||
|
@ -14,7 +15,7 @@ export const SetCounterAction = automodAction({
|
|||
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
|
||||
const countersPlugin = pluginData.getPlugin(CountersPlugin);
|
||||
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}\``,
|
||||
});
|
||||
return;
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ChannelTypeStrings } from "src/types";
|
|||
import { LogType } from "../../../data/LogType";
|
||||
import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils";
|
||||
import { automodAction } from "../helpers";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
|
||||
export const SetSlowmodeAction = automodAction({
|
||||
configType: t.type({
|
||||
|
@ -53,7 +54,7 @@ export const SetSlowmodeAction = automodAction({
|
|||
? `Duration is greater than maximum native slowmode duration`
|
||||
: 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}`,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -31,8 +31,8 @@ export const WarnAction = automodAction({
|
|||
hide: Boolean(actionConfig.hide_case),
|
||||
};
|
||||
|
||||
const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish));
|
||||
const membersToWarn = await asyncMap(userIdsToWarn, id => resolveMember(pluginData.client, pluginData.guild, id));
|
||||
const userIdsToWarn = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
|
||||
const membersToWarn = await asyncMap(userIdsToWarn, (id) => resolveMember(pluginData.client, pluginData.guild, id));
|
||||
|
||||
const modActions = pluginData.getPlugin(ModActionsPlugin);
|
||||
for (const member of membersToWarn) {
|
||||
|
|
|
@ -8,11 +8,15 @@ export const RunAutomodOnMemberUpdate = typedGuildEventListener<AutomodPluginTyp
|
|||
event: "guildMemberUpdate",
|
||||
listener({ pluginData, args: { oldMember, newMember } }) {
|
||||
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);
|
||||
const removedRoles = diff(oldMember.roles, newMember.roles);
|
||||
if (isEqual(oldRoles, newRoles)) return;
|
||||
|
||||
const addedRoles = diff(newRoles, oldRoles);
|
||||
const removedRoles = diff(oldRoles, newRoles);
|
||||
|
||||
if (addedRoles.length || removedRoles.length) {
|
||||
const context: AutomodContext = {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
|
|||
|
||||
export function clearOldRecentActions(pluginData: GuildPluginData<AutomodPluginType>) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
|
|||
|
||||
export function clearOldRecentSpam(pluginData: GuildPluginData<AutomodPluginType>) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ export function clearRecentActionsForMessage(pluginData: GuildPluginData<Automod
|
|||
const globalIdentifier = 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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
|
|||
|
||||
if (matchedSpam) {
|
||||
const messages = matchedSpam.recentActions
|
||||
.map(action => action.context.message)
|
||||
.map((action) => action.context.message)
|
||||
.filter(Boolean)
|
||||
.sort(sorter("posted_at")) as SavedMessage[];
|
||||
|
||||
|
@ -75,8 +75,8 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
|
|||
|
||||
return {
|
||||
extraContexts: matchedSpam.recentActions
|
||||
.map(action => action.context)
|
||||
.filter(_context => _context !== context),
|
||||
.map((action) => action.context)
|
||||
.filter((_context) => _context !== context),
|
||||
|
||||
extra: {
|
||||
archiveId,
|
||||
|
|
|
@ -7,7 +7,7 @@ export function findRecentSpam(
|
|||
type: RecentActionType,
|
||||
identifier?: string,
|
||||
) {
|
||||
return pluginData.state.recentSpam.find(spam => {
|
||||
return pluginData.state.recentSpam.find((spam) => {
|
||||
return spam.type === type && (!identifier || spam.identifiers.includes(identifier));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ export function getMatchingRecentActions(
|
|||
) {
|
||||
to = to || Date.now();
|
||||
|
||||
return pluginData.state.recentActions.filter(action => {
|
||||
return pluginData.state.recentActions.filter((action) => {
|
||||
return (
|
||||
action.type === type &&
|
||||
(!identifier || action.identifier === identifier) &&
|
||||
|
|
|
@ -29,6 +29,6 @@ export function getTextMatchPartialSummary(
|
|||
const visibleName = context.member?.nickname || context.user!.username;
|
||||
return `visible name: ${visibleName}`;
|
||||
} 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 { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||
import { resolveMember } from "../../../utils";
|
||||
|
@ -32,9 +32,9 @@ export async function* matchMultipleTextTypesOnMessage(
|
|||
yield ["message", msg.data.content];
|
||||
}
|
||||
|
||||
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
||||
const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
|
||||
if (copiedEmbed.type === "video") {
|
||||
if (trigger.match_embeds && msg.data.embeds?.length) {
|
||||
const copiedEmbed: MessageEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
|
||||
if (copiedEmbed.video) {
|
||||
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
|
||||
}
|
||||
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 { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
|
||||
import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
|
||||
|
@ -19,7 +19,7 @@ export function resolveActionContactMethods(
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Snowflake, TextChannel } from "discord.js";
|
||||
import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
|
||||
import { GuildPluginData } from "knub";
|
||||
import { availableActions } from "../actions/availableActions";
|
||||
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 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 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;
|
||||
|
||||
// Don't apply Automod on Zeppelin itself
|
||||
if (userId && userId === pluginData.client.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await pluginData.config.getMatchingConfig({
|
||||
channelId,
|
||||
categoryId,
|
||||
threadId,
|
||||
userId,
|
||||
member,
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { User } from "discord.js";
|
||||
import { GuildPluginData } from "knub";
|
||||
import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects";
|
||||
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||
import { LogType } from "../../../data/LogType";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel";
|
||||
|
@ -19,12 +19,12 @@ export async function setAntiraidLevel(
|
|||
const logs = pluginData.getPlugin(LogsPlugin);
|
||||
|
||||
if (user) {
|
||||
logs.log(LogType.SET_ANTIRAID_USER, {
|
||||
logs.logSetAntiraidUser({
|
||||
level: newLevel ?? "off",
|
||||
user: userToConfigAccessibleUser(user),
|
||||
user,
|
||||
});
|
||||
} else {
|
||||
logs.log(LogType.SET_ANTIRAID_AUTO, {
|
||||
logs.logSetAntiraidAuto({
|
||||
level: newLevel ?? "off",
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { KickTrigger } from "./kick";
|
|||
import { LineSpamTrigger } from "./lineSpam";
|
||||
import { LinkSpamTrigger } from "./linkSpam";
|
||||
import { MatchAttachmentTypeTrigger } from "./matchAttachmentType";
|
||||
import { MatchMimeTypeTrigger } from "./matchMimeType";
|
||||
import { MatchInvitesTrigger } from "./matchInvites";
|
||||
import { MatchLinksTrigger } from "./matchLinks";
|
||||
import { MatchRegexTrigger } from "./matchRegex";
|
||||
|
@ -37,6 +38,7 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
|
|||
match_invites: MatchInvitesTrigger,
|
||||
match_links: MatchLinksTrigger,
|
||||
match_attachment_type: MatchAttachmentTypeTrigger,
|
||||
match_mime_type: MatchMimeTypeTrigger,
|
||||
member_join: MemberJoinTrigger,
|
||||
role_added: RoleAddedTrigger,
|
||||
role_removed: RoleRemovedTrigger,
|
||||
|
@ -72,6 +74,7 @@ export const AvailableTriggers = t.type({
|
|||
match_invites: MatchInvitesTrigger.configType,
|
||||
match_links: MatchLinksTrigger.configType,
|
||||
match_attachment_type: MatchAttachmentTypeTrigger.configType,
|
||||
match_mime_type: MatchMimeTypeTrigger.configType,
|
||||
member_join: MemberJoinTrigger.configType,
|
||||
member_leave: MemberLeaveTrigger.configType,
|
||||
role_added: RoleAddedTrigger.configType,
|
||||
|
|
|
@ -15,7 +15,7 @@ export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
|
|||
},
|
||||
|
||||
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) {
|
||||
return {
|
||||
extra: {
|
||||
|
|
|
@ -28,17 +28,15 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!context.message.data.attachments) return null;
|
||||
const attachments: any[] = context.message.data.attachments;
|
||||
if (!context.message.data.attachments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const attachmentType = attachment.filename
|
||||
.split(".")
|
||||
.pop()
|
||||
.toLowerCase();
|
||||
for (const attachment of context.message.data.attachments) {
|
||||
const attachmentType = attachment.url.split(".").pop()!.toLowerCase();
|
||||
|
||||
const blacklist = trigger.blacklist_enabled
|
||||
? (trigger.filetype_blacklist || []).map(_t => _t.toLowerCase())
|
||||
? (trigger.filetype_blacklist || []).map((_t) => _t.toLowerCase())
|
||||
: null;
|
||||
|
||||
if (blacklist && blacklist.includes(attachmentType)) {
|
||||
|
@ -51,7 +49,7 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
|
|||
}
|
||||
|
||||
const whitelist = trigger.whitelist_enabled
|
||||
? (trigger.filetype_whitelist || []).map(_t => _t.toLowerCase())
|
||||
? (trigger.filetype_whitelist || []).map((_t) => _t.toLowerCase())
|
||||
: null;
|
||||
|
||||
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
|
||||
// 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
|
||||
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
|
||||
? [...word].map((c) => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
|
||||
: escapeStringRegexp(word);
|
||||
|
||||
if (trigger.only_full_words) {
|
||||
|
|
|
@ -30,7 +30,7 @@ export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
|
|||
const totalCount = sumRecentActionCounts(matchingActions);
|
||||
|
||||
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({
|
||||
type: RecentActionType.MemberJoin,
|
||||
|
|
|
@ -18,11 +18,19 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd";
|
|||
import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
|
||||
import { ServersCmd } from "./commands/ServersCmd";
|
||||
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 = {
|
||||
config: {
|
||||
can_use: false,
|
||||
can_eligible: false,
|
||||
can_performance: false,
|
||||
can_add_server_from_invite: false,
|
||||
can_list_dashboard_perms: false,
|
||||
update_cmd: null,
|
||||
},
|
||||
};
|
||||
|
@ -45,6 +53,11 @@ export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()({
|
|||
ListDashboardUsersCmd,
|
||||
ListDashboardPermsCmd,
|
||||
EligibleCmd,
|
||||
PluginPerformanceCmd,
|
||||
RestPerformanceCmd,
|
||||
RateLimitPerformanceCmd,
|
||||
AddServerFromInviteCmd,
|
||||
ChannelToServerCmd,
|
||||
],
|
||||
|
||||
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