3
0
Fork 0
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:
Bluenix 2021-09-24 20:01:55 +02:00 committed by GitHub
commit 6a3007562e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
429 changed files with 8120 additions and 2717 deletions

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
.github
.idea
node_modules
/assets
/debug

1
backend/.prettierignore Normal file
View file

@ -0,0 +1 @@
/dist

View file

@ -1,24 +1,24 @@
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
try { try {
fs.accessSync(path.resolve(__dirname, 'bot.env')); fs.accessSync(path.resolve(__dirname, "bot.env"));
require('dotenv').config({ path: path.resolve(__dirname, 'bot.env') }); require("dotenv").config({ path: path.resolve(__dirname, "bot.env") });
} catch { } catch {
try { try {
fs.accessSync(path.resolve(__dirname, 'api.env')); fs.accessSync(path.resolve(__dirname, "api.env"));
require('dotenv').config({ path: path.resolve(__dirname, 'api.env') }); require("dotenv").config({ path: path.resolve(__dirname, "api.env") });
} catch { } catch {
throw new Error("bot.env or api.env required"); throw new Error("bot.env or api.env required");
} }
} }
const moment = require('moment-timezone'); const moment = require("moment-timezone");
moment.tz.setDefault('UTC'); moment.tz.setDefault("UTC");
const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js')); const entities = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/data/entities/*.js"));
const migrations = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js')); const migrations = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/migrations/*.js"));
const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations')); const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, "src/migrations"));
module.exports = { module.exports = {
type: "mysql", type: "mysql",
@ -26,26 +26,29 @@ module.exports = {
username: process.env.DB_USER, username: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE, database: process.env.DB_DATABASE,
charset: 'utf8mb4', charset: "utf8mb4",
supportBigNumbers: true, supportBigNumbers: true,
bigNumberStrings: true, bigNumberStrings: true,
dateStrings: true, dateStrings: true,
synchronize: false, synchronize: false,
connectTimeout: 2000, connectTimeout: 2000,
logging: ["error", "warn"],
maxQueryExecutionTime: 250,
// Entities // Entities
entities: [entities], entities: [entities],
// Pool options // Pool options
extra: { extra: {
typeCast(field, next) { typeCast(field, next) {
if (field.type === 'DATETIME') { if (field.type === "DATETIME") {
const val = field.string(); const val = field.string();
return val != null ? moment.utc(val).format('YYYY-MM-DD HH:mm:ss') : null; return val != null ? moment.utc(val).format("YYYY-MM-DD HH:mm:ss") : null;
} }
return next(); return next();
} },
}, },
// Migrations // Migrations

View file

@ -24,7 +24,7 @@
"humanize-duration": "^3.15.0", "humanize-duration": "^3.15.0",
"io-ts": "^2.0.0", "io-ts": "^2.0.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"knub": "^30.0.0-beta.39", "knub": "^30.0.0-beta.45",
"knub-command-manager": "^9.1.0", "knub-command-manager": "^9.1.0",
"last-commit-log": "^2.1.0", "last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
@ -53,7 +53,8 @@
"utf-8-validate": "^5.0.5", "utf-8-validate": "^5.0.5",
"uuid": "^3.3.2", "uuid": "^3.3.2",
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
"zlib-sync": "^0.1.7" "zlib-sync": "^0.1.7",
"zod": "^3.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.5", "@types/cors": "^2.8.5",
@ -3042,9 +3043,9 @@
} }
}, },
"node_modules/knub": { "node_modules/knub": {
"version": "30.0.0-beta.39", "version": "30.0.0-beta.45",
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz", "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==", "integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
"dependencies": { "dependencies": {
"discord-api-types": "^0.22.0", "discord-api-types": "^0.22.0",
"discord.js": "^13.0.1", "discord.js": "^13.0.1",
@ -5965,6 +5966,14 @@
"dependencies": { "dependencies": {
"nan": "^2.14.0" "nan": "^2.14.0"
} }
},
"node_modules/zod": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz",
"integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
}, },
"dependencies": { "dependencies": {
@ -8281,9 +8290,9 @@
} }
}, },
"knub": { "knub": {
"version": "30.0.0-beta.39", "version": "30.0.0-beta.45",
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz", "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz",
"integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==", "integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==",
"requires": { "requires": {
"discord-api-types": "^0.22.0", "discord-api-types": "^0.22.0",
"discord.js": "^13.0.1", "discord.js": "^13.0.1",
@ -10527,6 +10536,11 @@
"requires": { "requires": {
"nan": "^2.14.0" "nan": "^2.14.0"
} }
},
"zod": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz",
"integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw=="
} }
} }
} }

View file

@ -7,11 +7,11 @@
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"", "watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
"watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"", "watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"",
"build": "rimraf dist && tsc", "build": "rimraf dist && tsc",
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9229 dist/backend/src/index.js init", "start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9229 dist/backend/src/index.js init",
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js", "start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/index.js",
"watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"", "watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"",
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init", "start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init",
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js", "start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/api/index.js",
"watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"", "watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"",
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js", "typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
"migrate-prod": "npm run typeorm -- migration:run", "migrate-prod": "npm run typeorm -- migration:run",
@ -39,7 +39,7 @@
"humanize-duration": "^3.15.0", "humanize-duration": "^3.15.0",
"io-ts": "^2.0.0", "io-ts": "^2.0.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"knub": "^30.0.0-beta.39", "knub": "^30.0.0-beta.45",
"knub-command-manager": "^9.1.0", "knub-command-manager": "^9.1.0",
"last-commit-log": "^2.1.0", "last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
@ -68,7 +68,8 @@
"utf-8-validate": "^5.0.5", "utf-8-validate": "^5.0.5",
"uuid": "^3.3.2", "uuid": "^3.3.2",
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
"zlib-sync": "^0.1.7" "zlib-sync": "^0.1.7",
"zod": "^3.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.5", "@types/cors": "^2.8.5",

View file

@ -6,9 +6,9 @@
* https://github.com/TypeStrong/ts-node/pull/254 * https://github.com/TypeStrong/ts-node/pull/254
*/ */
const path = require('path'); const path = require("path");
const tsconfig = require('./tsconfig.json'); const tsconfig = require("./tsconfig.json");
const tsconfigPaths = require('tsconfig-paths'); const tsconfigPaths = require("tsconfig-paths");
// E.g. ./dist/backend // E.g. ./dist/backend
const baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname)); const baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname));

View file

@ -28,11 +28,11 @@ export class Queue<TQueueFunction extends AnyFn = AnyFn> {
return this.queue.length + (this.running ? 1 : 0); return this.queue.length + (this.running ? 1 : 0);
} }
public add(fn: TQueueFunction): Promise<void> { public add(fn: TQueueFunction): Promise<any> {
const promise = new Promise<void>(resolve => { const promise = new Promise<any>((resolve) => {
this.queue.push(async () => { this.queue.push(async () => {
await fn(); const result = await fn();
resolve(); resolve(result);
}); });
if (!this.running) this.next(); if (!this.running) this.next();
@ -50,7 +50,7 @@ export class Queue<TQueueFunction extends AnyFn = AnyFn> {
} }
const fn = this.queue.shift()!; const fn = this.queue.shift()!;
new Promise(resolve => { new Promise((resolve) => {
// Either fn() completes or the timeout is reached // Either fn() completes or the timeout is reached
void fn().then(resolve); void fn().then(resolve);
setTimeout(resolve, this._timeout); setTimeout(resolve, this._timeout);

View file

@ -42,7 +42,7 @@ export class QueuedEventEmitter {
const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])]; const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])];
let promise: Promise<any> = Promise.resolve(); let promise: Promise<any> = Promise.resolve();
listeners.forEach(listener => { listeners.forEach((listener) => {
promise = this.queue.add(listener.bind(null, ...args)); promise = this.queue.add(listener.bind(null, ...args));
}); });

View file

@ -27,7 +27,7 @@ const INITIAL_REGEX_TIMEOUT = 5 * SECONDS;
const INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS; const INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS;
const FINAL_REGEX_TIMEOUT = 5 * SECONDS; const FINAL_REGEX_TIMEOUT = 5 * SECONDS;
const regexTimeoutUpgradePromise = new Promise(resolve => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION)); const regexTimeoutUpgradePromise = new Promise((resolve) => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION));
let newWorkerTimeout = INITIAL_REGEX_TIMEOUT; let newWorkerTimeout = INITIAL_REGEX_TIMEOUT;
regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT)); regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT));

View file

@ -33,21 +33,21 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise<any> {
Authorization: `Bearer ${bearerToken}`, Authorization: `Bearer ${bearerToken}`,
}, },
}, },
res => { (res) => {
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
reject(new Error(`Discord API error ${res.statusCode}`)); reject(new Error(`Discord API error ${res.statusCode}`));
return; return;
} }
let rawData = ""; let rawData = "";
res.on("data", data => (rawData += data)); res.on("data", (data) => (rawData += data));
res.on("end", () => { res.on("end", () => {
resolve(JSON.parse(rawData)); resolve(JSON.parse(rawData));
}); });
}, },
); );
request.on("error", err => reject(err)); request.on("error", (err) => reject(err));
}); });
} }
@ -149,7 +149,7 @@ export function initAuth(app: express.Express) {
return res.json({ valid: false }); return res.json({ valid: false });
} }
res.json({ valid: true }); res.json({ valid: true, userId });
}); });
app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => {
await apiLogins.expireApiKey(req.user!.apiKey); await apiLogins.expireApiKey(req.user!.apiKey);

View file

@ -22,21 +22,21 @@ function formatConfigSchema(schema) {
} else if (schema.name.startsWith("Optional<")) { } else if (schema.name.startsWith("Optional<")) {
return `Optional<${formatConfigSchema(schema.types[0])}>`; return `Optional<${formatConfigSchema(schema.types[0])}>`;
} else { } else {
return schema.types.map(t => formatConfigSchema(t)).join(" | "); return schema.types.map((t) => formatConfigSchema(t)).join(" | ");
} }
} else if (schema._tag === "IntersectionType") { } else if (schema._tag === "IntersectionType") {
return schema.types.map(t => formatConfigSchema(t)).join(" & "); return schema.types.map((t) => formatConfigSchema(t)).join(" & ");
} else { } else {
return schema.name; return schema.name;
} }
} }
export function initDocs(app: express.Express) { export function initDocs(app: express.Express) {
const docsPlugins = guildPlugins.filter(plugin => plugin.showInDocs); const docsPlugins = guildPlugins.filter((plugin) => plugin.showInDocs);
app.get("/docs/plugins", (req: express.Request, res: express.Response) => { app.get("/docs/plugins", (req: express.Request, res: express.Response) => {
res.json( res.json(
docsPlugins.map(plugin => { docsPlugins.map((plugin) => {
const thinInfo = plugin.info ? { prettyName: plugin.info.prettyName, legacy: plugin.info.legacy ?? false } : {}; const thinInfo = plugin.info ? { prettyName: plugin.info.prettyName, legacy: plugin.info.legacy ?? false } : {};
return { return {
name: plugin.name, name: plugin.name,
@ -56,7 +56,7 @@ export function initDocs(app: express.Express) {
const name = plugin.name; const name = plugin.name;
const info = plugin.info || {}; const info = plugin.info || {};
const commands = (plugin.commands || []).map(cmd => ({ const commands = (plugin.commands || []).map((cmd) => ({
trigger: cmd.trigger, trigger: cmd.trigger,
permission: cmd.permission, permission: cmd.permission,
signature: cmd.signature, signature: cmd.signature,

View file

@ -3,15 +3,21 @@ import express, { Request, Response } from "express";
import { YAMLException } from "js-yaml"; import { YAMLException } from "js-yaml";
import { validateGuildConfig } from "../configValidator"; import { validateGuildConfig } from "../configValidator";
import { AllowedGuilds } from "../data/AllowedGuilds"; import { AllowedGuilds } from "../data/AllowedGuilds";
import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments"; import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments";
import { Configs } from "../data/Configs"; import { Configs } from "../data/Configs";
import { apiTokenAuthHandlers } from "./auth"; import { apiTokenAuthHandlers } from "./auth";
import { hasGuildPermission, requireGuildPermission } from "./permissions"; import { hasGuildPermission, requireGuildPermission } from "./permissions";
import { clientError, ok, serverError, unauthorized } from "./responses"; import { clientError, ok, serverError, unauthorized } from "./responses";
import { loadYamlSafely } from "../utils/loadYamlSafely"; import { loadYamlSafely } from "../utils/loadYamlSafely";
import { ObjectAliasError } from "../utils/validateNoObjectAliases"; import { ObjectAliasError } from "../utils/validateNoObjectAliases";
import { isSnowflake } from "../utils";
import moment from "moment-timezone";
import { ApiAuditLog } from "../data/ApiAuditLog";
import { AuditLogEventTypes } from "../data/apiAuditLogTypes";
import { Queue } from "../Queue";
const apiPermissionAssignments = new ApiPermissionAssignments(); const apiPermissionAssignments = new ApiPermissionAssignments();
const auditLog = new ApiAuditLog();
export function initGuildsAPI(app: express.Express) { export function initGuildsAPI(app: express.Express) {
const allowedGuilds = new AllowedGuilds(); const allowedGuilds = new AllowedGuilds();
@ -25,6 +31,14 @@ export function initGuildsAPI(app: express.Express) {
res.json(guilds); res.json(guilds);
}); });
guildRouter.get(
"/my-permissions", // a
async (req: Request, res: Response) => {
const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId);
res.json(permissions);
},
);
guildRouter.get("/:guildId", async (req: Request, res: Response) => { guildRouter.get("/:guildId", async (req: Request, res: Response) => {
if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) { if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) {
return unauthorized(res); return unauthorized(res);
@ -101,5 +115,65 @@ export function initGuildsAPI(app: express.Express) {
}, },
); );
const permissionManagementQueue = new Queue();
guildRouter.post(
"/:guildId/set-target-permissions",
requireGuildPermission(ApiPermissions.ManageAccess),
async (req: Request, res: Response) => {
await permissionManagementQueue.add(async () => {
const { type, targetId, permissions, expiresAt } = req.body;
if (type !== ApiPermissionTypes.User) {
return clientError(res, "Invalid type");
}
if (!isSnowflake(targetId)) {
return clientError(res, "Invalid targetId");
}
const validPermissions = new Set(Object.values(ApiPermissions));
validPermissions.delete(ApiPermissions.Owner);
if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) {
return clientError(res, "Invalid permissions");
}
if (expiresAt != null && !moment.utc(expiresAt).isValid()) {
return clientError(res, "Invalid expiresAt");
}
const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) {
return clientError(res, "Can't change owner permissions");
}
if (permissions.length === 0) {
await apiPermissionAssignments.removeUser(req.params.guildId, targetId);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
});
} else {
const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId);
if (existing) {
await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
permissions,
expires_at: existing.expires_at,
});
} else {
await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt);
await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: targetId,
permissions,
expires_at: expiresAt,
});
}
}
ok(res);
});
},
);
app.use("/guilds", guildRouter); app.use("/guilds", guildRouter);
} }

View file

@ -6,6 +6,7 @@ import { initAuth } from "./auth";
import { initDocs } from "./docs"; import { initDocs } from "./docs";
import { initGuildsAPI } from "./guilds"; import { initGuildsAPI } from "./guilds";
import { clientError, error, notFound } from "./responses"; import { clientError, error, notFound } from "./responses";
import { startBackgroundTasks } from "./tasks";
const app = express(); const app = express();
@ -14,7 +15,11 @@ app.use(
origin: process.env.DASHBOARD_URL, origin: process.env.DASHBOARD_URL,
}), }),
); );
app.use(express.json()); app.use(
express.json({
limit: "10mb",
}),
);
initAuth(app); initAuth(app);
initGuildsAPI(app); initGuildsAPI(app);
@ -43,3 +48,5 @@ app.use((req, res, next) => {
const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000; const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000;
app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line
startBackgroundTasks();

10
backend/src/api/tasks.ts Normal file
View 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);
}

View file

@ -36,7 +36,7 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
const plugin = pluginNameToPlugin.get(pluginName)!; const plugin = pluginNameToPlugin.get(pluginName)!;
try { try {
const mergedOptions = configUtils.mergeConfig(plugin.defaultOptions || {}, pluginOptions); const mergedOptions = configUtils.mergeConfig(plugin.defaultOptions || {}, pluginOptions);
await plugin.configPreprocessor?.((mergedOptions as unknown) as PluginOptions<any>, true); await plugin.configPreprocessor?.(mergedOptions as unknown as PluginOptions<any>, true);
} catch (err) { } catch (err) {
if (err instanceof ConfigValidationError || err instanceof StrictValidationError) { if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
return `${pluginName}: ${err.message}`; return `${pluginName}: ${err.message}`;

View file

@ -2,6 +2,8 @@ import { getRepository, Repository } from "typeorm";
import { ApiPermissionTypes } from "./ApiPermissionAssignments"; import { ApiPermissionTypes } from "./ApiPermissionAssignments";
import { BaseRepository } from "./BaseRepository"; import { BaseRepository } from "./BaseRepository";
import { AllowedGuild } from "./entities/AllowedGuild"; import { AllowedGuild } from "./entities/AllowedGuild";
import moment from "moment-timezone";
import { DBDateFormat } from "../utils";
export class AllowedGuilds extends BaseRepository { export class AllowedGuilds extends BaseRepository {
private allowedGuilds: Repository<AllowedGuild>; private allowedGuilds: Repository<AllowedGuild>;
@ -37,7 +39,10 @@ export class AllowedGuilds extends BaseRepository {
} }
updateInfo(id, name, icon, ownerId) { updateInfo(id, name, icon, ownerId) {
return this.allowedGuilds.update({ id }, { name, icon, owner_id: ownerId }); return this.allowedGuilds.update(
{ id },
{ name, icon, owner_id: ownerId, updated_at: moment.utc().format(DBDateFormat) },
);
} }
add(id, data: Partial<Omit<AllowedGuild, "id">> = {}) { add(id, data: Partial<Omit<AllowedGuild, "id">> = {}) {

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

View file

@ -68,10 +68,7 @@ export class ApiLogins extends BaseRepository {
token: hashedToken, token: hashedToken,
user_id: userId, user_id: userId,
logged_in_at: moment.utc().format(DBDateFormat), logged_in_at: moment.utc().format(DBDateFormat),
expires_at: moment expires_at: moment.utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
.utc()
.add(LOGIN_EXPIRY_TIME, "ms")
.format(DBDateFormat),
}); });
return `${loginId}.${token}`; return `${loginId}.${token}`;
@ -96,10 +93,7 @@ export class ApiLogins extends BaseRepository {
await this.apiLogins.update( await this.apiLogins.update(
{ id: loginId }, { id: loginId },
{ {
expires_at: moment() expires_at: moment().utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat),
.utc()
.add(LOGIN_EXPIRY_TIME, "ms")
.format(DBDateFormat),
}, },
); );
} }

View file

@ -2,6 +2,9 @@ import { ApiPermissions } from "@shared/apiPermissions";
import { getRepository, Repository } from "typeorm"; import { getRepository, Repository } from "typeorm";
import { BaseRepository } from "./BaseRepository"; import { BaseRepository } from "./BaseRepository";
import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment"; import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment";
import { Permissions } from "discord.js";
import { ApiAuditLog } from "./ApiAuditLog";
import { AuditLogEventTypes } from "./apiAuditLogTypes";
export enum ApiPermissionTypes { export enum ApiPermissionTypes {
User = "USER", User = "USER",
@ -10,10 +13,12 @@ export enum ApiPermissionTypes {
export class ApiPermissionAssignments extends BaseRepository { export class ApiPermissionAssignments extends BaseRepository {
private apiPermissions: Repository<ApiPermissionAssignment>; private apiPermissions: Repository<ApiPermissionAssignment>;
private auditLogs: ApiAuditLog;
constructor() { constructor() {
super(); super();
this.apiPermissions = getRepository(ApiPermissionAssignment); this.apiPermissions = getRepository(ApiPermissionAssignment);
this.auditLogs = new ApiAuditLog();
} }
getByGuildId(guildId) { getByGuildId(guildId) {
@ -43,16 +48,100 @@ export class ApiPermissionAssignments extends BaseRepository {
}); });
} }
addUser(guildId, userId, permissions: ApiPermissions[]) { addUser(guildId, userId, permissions: ApiPermissions[], expiresAt: string | null = null) {
return this.apiPermissions.insert({ return this.apiPermissions.insert({
guild_id: guildId, guild_id: guildId,
type: ApiPermissionTypes.User, type: ApiPermissionTypes.User,
target_id: userId, target_id: userId,
permissions, permissions,
expires_at: expiresAt,
}); });
} }
removeUser(guildId, userId) { removeUser(guildId, userId) {
return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId }); return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId });
} }
async updateUserPermissions(guildId: string, userId: string, permissions: ApiPermissions[]): Promise<void> {
await this.apiPermissions.update(
{
guild_id: guildId,
type: ApiPermissionTypes.User,
target_id: userId,
},
{
permissions,
},
);
}
async clearExpiredPermissions() {
await this.apiPermissions
.createQueryBuilder()
.where("expires_at IS NOT NULL")
.andWhere("expires_at <= NOW()")
.delete();
}
async applyOwnerChange(guildId: string, newOwnerId: string) {
const existingPermissions = await this.getByGuildId(guildId);
let updatedOwner = false;
for (const perm of existingPermissions) {
let hasChanges = false;
// Remove owner permission from anyone who currently has it
if (perm.permissions.includes(ApiPermissions.Owner)) {
perm.permissions.splice(perm.permissions.indexOf(ApiPermissions.Owner), 1);
hasChanges = true;
}
// Add owner permission if we encounter the new owner
if (perm.type === ApiPermissionTypes.User && perm.target_id === newOwnerId) {
perm.permissions.push(ApiPermissions.Owner);
updatedOwner = true;
hasChanges = true;
}
if (hasChanges) {
const criteria = {
guild_id: perm.guild_id,
type: perm.type,
target_id: perm.target_id,
};
if (perm.permissions.length === 0) {
// No remaining permissions -> remove entry
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.REMOVE_API_PERMISSION, {
type: perm.type,
target_id: perm.target_id,
});
await this.apiPermissions.delete(criteria);
} else {
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.EDIT_API_PERMISSION, {
type: perm.type,
target_id: perm.target_id,
permissions: perm.permissions,
expires_at: perm.expires_at,
});
await this.apiPermissions.update(criteria, {
permissions: perm.permissions,
});
}
}
}
if (!updatedOwner) {
this.auditLogs.addEntry(guildId, "0", AuditLogEventTypes.ADD_API_PERMISSION, {
type: ApiPermissionTypes.User,
target_id: newOwnerId,
permissions: [ApiPermissions.Owner],
expires_at: null,
});
await this.apiPermissions.insert({
guild_id: guildId,
type: ApiPermissionTypes.User,
target_id: newOwnerId,
permissions: [ApiPermissions.Owner],
});
}
}
} }

View file

@ -22,7 +22,7 @@ export class ApiUserInfo extends BaseRepository {
} }
update(id, data: ApiUserInfoData) { update(id, data: ApiUserInfoData) {
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const repo = entityManager.getRepository(ApiUserInfoEntity); const repo = entityManager.getRepository(ApiUserInfoEntity);
const existingInfo = await repo.findOne({ where: { id } }); const existingInfo = await repo.findOne({ where: { id } });

View file

@ -41,11 +41,7 @@ export class Configs extends BaseRepository {
} }
getActiveLargerThanId(id) { getActiveLargerThanId(id) {
return this.configs return this.configs.createQueryBuilder().where("id > :id", { id }).andWhere("is_active = 1").getMany();
.createQueryBuilder()
.where("id > :id", { id })
.andWhere("is_active = 1")
.getMany();
} }
async hasConfig(key) { async hasConfig(key) {
@ -65,7 +61,7 @@ export class Configs extends BaseRepository {
} }
async saveNewRevision(key, config, editedBy) { async saveNewRevision(key, config, editedBy) {
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const repo = entityManager.getRepository(Config); const repo = entityManager.getRepository(Config);
// Mark all old revisions inactive // Mark all old revisions inactive
await repo.update({ key }, { is_active: false }); await repo.update({ key }, { is_active: false });

View file

@ -13,9 +13,9 @@
"MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}", "MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}",
"MEMBER_JOIN": "📥 {new} {userMention(member)} joined (created {account_age} ago)", "MEMBER_JOIN": "📥 {new} {userMention(member)} joined (created {account_age} ago)",
"MEMBER_LEAVE": "📤 {userMention(member)} left the server", "MEMBER_LEAVE": "📤 {userMention(member)} left the server",
"MEMBER_ROLE_ADD": "🔑 {userMention(member)}: role(s) **{roles}** added by {userMention(mod)}", "MEMBER_ROLE_ADD": "🔑 {userMention(member)} received roles: **{roles}**",
"MEMBER_ROLE_REMOVE": "🔑 {userMention(member)}: role(s) **{roles}** removed by {userMention(mod)}", "MEMBER_ROLE_REMOVE": "🔑 {userMention(member)} lost roles: **{roles}**",
"MEMBER_ROLE_CHANGES": "🔑 {userMention(member)}: roles changed: added **{addedRoles}**, removed **{removedRoles}** by {userMention(mod)}", "MEMBER_ROLE_CHANGES": "🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**",
"MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
"MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",
"MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin", "MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin",
@ -50,9 +50,9 @@
"STAGE_INSTANCE_DELETE": "📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", "STAGE_INSTANCE_DELETE": "📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>",
"STAGE_INSTANCE_UPDATE": "📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", "STAGE_INSTANCE_UPDATE": "📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}",
"EMOJI_CREATE": "<{emoji.identifier}> Emoji `{emoji.name} ({emoji.id})` was created", "EMOJI_CREATE": "{emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created",
"EMOJI_DELETE": "👋 Emoji `{emoji.name} ({emoji.id})` was deleted", "EMOJI_DELETE": "👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted",
"EMOJI_UPDATE": "<{newEmoji.identifier}> Emoji `{newEmoji.name} ({newEmoji.id})` was updated. Changes:\n{differenceString}", "EMOJI_UPDATE": "{newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}",
"STICKER_CREATE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", "STICKER_CREATE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}",
"STICKER_DELETE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", "STICKER_DELETE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.",

View file

@ -1,12 +1,17 @@
import { Guild, Snowflake } from "discord.js"; import { Guild, Snowflake, User } from "discord.js";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { isDefaultSticker } from "src/utils/isDefaultSticker"; import { isDefaultSticker } from "src/utils/isDefaultSticker";
import { getRepository, Repository } from "typeorm"; import { getRepository, Repository } from "typeorm";
import { renderTemplate } from "../templateFormatter"; import { renderTemplate, TemplateSafeValueContainer } from "../templateFormatter";
import { trimLines } from "../utils"; import { trimLines } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository"; import { BaseGuildRepository } from "./BaseGuildRepository";
import { ArchiveEntry } from "./entities/ArchiveEntry"; import { ArchiveEntry } from "./entities/ArchiveEntry";
import { SavedMessage } from "./entities/SavedMessage"; import { SavedMessage } from "./entities/SavedMessage";
import {
channelToTemplateSafeChannel,
guildToTemplateSafeGuild,
userToTemplateSafeUser,
} from "../utils/templateSafeObjects";
const DEFAULT_EXPIRY_DAYS = 30; const DEFAULT_EXPIRY_DAYS = 30;
@ -75,21 +80,25 @@ export class GuildArchives extends BaseGuildRepository {
const msgLines: string[] = []; const msgLines: string[] = [];
for (const msg of savedMessages) { for (const msg of savedMessages) {
const channel = guild.channels.cache.get(msg.channel_id as Snowflake); const channel = guild.channels.cache.get(msg.channel_id as Snowflake);
const user = { ...msg.data.author, id: msg.user_id }; const partialUser = new TemplateSafeValueContainer({ ...msg.data.author, id: msg.user_id });
const line = await renderTemplate(MESSAGE_ARCHIVE_MESSAGE_FORMAT, { const line = await renderTemplate(
id: msg.id, MESSAGE_ARCHIVE_MESSAGE_FORMAT,
timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"), new TemplateSafeValueContainer({
content: msg.data.content, id: msg.id,
attachments: msg.data.attachments?.map(att => { timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"),
return JSON.stringify({ name: att.name, url: att.url, type: att.contentType }); content: msg.data.content,
attachments: msg.data.attachments?.map((att) => {
return JSON.stringify({ name: att.name, url: att.url, type: att.contentType });
}),
stickers: msg.data.stickers?.map((sti) => {
return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) });
}),
user: partialUser,
channel: channel ? channelToTemplateSafeChannel(channel) : null,
}), }),
stickers: msg.data.stickers?.map(sti => { );
return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) });
}),
user,
channel,
});
msgLines.push(line); msgLines.push(line);
} }
return msgLines; return msgLines;
@ -100,7 +109,12 @@ export class GuildArchives extends BaseGuildRepository {
expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days"); expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days");
} }
const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild }); const headerStr = await renderTemplate(
MESSAGE_ARCHIVE_HEADER_FORMAT,
new TemplateSafeValueContainer({
guild: guildToTemplateSafeGuild(guild),
}),
);
const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild); const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);
const messagesStr = msgLines.join("\n"); const messagesStr = msgLines.join("\n");

View file

@ -1,4 +1,4 @@
import { getRepository, In, Repository } from "typeorm"; import { getRepository, In, InsertResult, Repository } from "typeorm";
import { BaseGuildRepository } from "./BaseGuildRepository"; import { BaseGuildRepository } from "./BaseGuildRepository";
import { CaseTypes } from "./CaseTypes"; import { CaseTypes } from "./CaseTypes";
import { connection } from "./db"; import { connection } from "./db";
@ -116,13 +116,30 @@ export class GuildCases extends BaseGuildRepository {
); );
} }
async create(data): Promise<Case> { async createInternal(data): Promise<InsertResult> {
const result = await this.cases.insert({ return this.cases
...data, .insert({
guild_id: this.guildId, ...data,
case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`, guild_id: this.guildId,
}); case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`,
})
.catch((err) => {
if (err?.code === "ER_DUP_ENTRY") {
if (data.audit_log_id) {
console.trace(`Tried to insert case with duplicate audit_log_id`);
return this.createInternal({
...data,
audit_log_id: undefined,
});
}
}
throw err;
});
}
async create(data): Promise<Case> {
const result = await this.createInternal(data);
return (await this.find(result.identifiers[0].id))!; return (await this.find(result.identifiers[0].id))!;
} }
@ -131,7 +148,7 @@ export class GuildCases extends BaseGuildRepository {
} }
async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) { async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) {
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const cases = entityManager.getRepository(Case); const cases = entityManager.getRepository(Case);
const caseNotes = entityManager.getRepository(CaseNote); const caseNotes = entityManager.getRepository(CaseNote);

View file

@ -17,19 +17,11 @@ const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
const decayQueue = new Queue(); const decayQueue = new Queue();
async function deleteCountersMarkedToBeDeleted(): Promise<void> { async function deleteCountersMarkedToBeDeleted(): Promise<void> {
await getRepository(Counter) await getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
} }
async function deleteTriggersMarkedToBeDeleted(): Promise<void> { async function deleteTriggersMarkedToBeDeleted(): Promise<void> {
await getRepository(CounterTrigger) await getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute();
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
} }
setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS); setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);
@ -97,10 +89,7 @@ export class GuildCounters extends BaseGuildRepository {
criteria.id = Not(In(idsToKeep)); criteria.id = Not(In(idsToKeep));
} }
const deleteAt = moment const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, "ms").format(DBDateFormat);
.utc()
.add(DELETE_UNUSED_COUNTERS_AFTER, "ms")
.format(DBDateFormat);
await this.counters.update(criteria, { await this.counters.update(criteria, {
delete_at: deleteAt, delete_at: deleteAt,
@ -108,11 +97,7 @@ export class GuildCounters extends BaseGuildRepository {
} }
async deleteCountersMarkedToBeDeleted(): Promise<void> { async deleteCountersMarkedToBeDeleted(): Promise<void> {
await this.counters await this.counters.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
} }
async changeCounterValue( async changeCounterValue(
@ -230,14 +215,11 @@ export class GuildCounters extends BaseGuildRepository {
const triggersToMark = await triggersToMarkQuery.getMany(); const triggersToMark = await triggersToMarkQuery.getMany();
if (triggersToMark.length) { if (triggersToMark.length) {
const deleteAt = moment const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms").format(DBDateFormat);
.utc()
.add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms")
.format(DBDateFormat);
await this.counterTriggers.update( await this.counterTriggers.update(
{ {
id: In(triggersToMark.map(t => t.id)), id: In(triggersToMark.map((t) => t.id)),
}, },
{ {
delete_at: deleteAt, delete_at: deleteAt,
@ -247,11 +229,7 @@ export class GuildCounters extends BaseGuildRepository {
} }
async deleteTriggersMarkedToBeDeleted(): Promise<void> { async deleteTriggersMarkedToBeDeleted(): Promise<void> {
await this.counterTriggers await this.counterTriggers.createQueryBuilder().where("delete_at <= NOW()").delete().execute();
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
} }
async initCounterTrigger( async initCounterTrigger(
@ -278,7 +256,7 @@ export class GuildCounters extends BaseGuildRepository {
throw new Error(`Invalid comparison value: ${reverseComparisonValue}`); throw new Error(`Invalid comparison value: ${reverseComparisonValue}`);
} }
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const existing = await entityManager.findOne(CounterTrigger, { const existing = await entityManager.findOne(CounterTrigger, {
counter_id: counterId, counter_id: counterId,
name: triggerName, name: triggerName,
@ -330,7 +308,7 @@ export class GuildCounters extends BaseGuildRepository {
channelId = channelId || "0"; channelId = channelId || "0";
userId = userId || "0"; userId = userId || "0";
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const previouslyTriggered = await entityManager.findOne(CounterTriggerState, { const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {
trigger_id: counterTrigger.id, trigger_id: counterTrigger.id,
user_id: userId!, user_id: userId!,
@ -378,7 +356,7 @@ export class GuildCounters extends BaseGuildRepository {
async checkAllValuesForTrigger( async checkAllValuesForTrigger(
counterTrigger: CounterTrigger, counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> { ): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const matchingValues = await entityManager const matchingValues = await entityManager
.createQueryBuilder(CounterValue, "cv") .createQueryBuilder(CounterValue, "cv")
.leftJoin( .leftJoin(
@ -395,7 +373,7 @@ export class GuildCounters extends BaseGuildRepository {
if (matchingValues.length) { if (matchingValues.length) {
await entityManager.insert( await entityManager.insert(
CounterTriggerState, CounterTriggerState,
matchingValues.map(row => ({ matchingValues.map((row) => ({
trigger_id: counterTrigger.id, trigger_id: counterTrigger.id,
channel_id: row.channel_id, channel_id: row.channel_id,
user_id: row.user_id, user_id: row.user_id,
@ -403,7 +381,7 @@ export class GuildCounters extends BaseGuildRepository {
); );
} }
return matchingValues.map(row => ({ return matchingValues.map((row) => ({
channelId: row.channel_id, channelId: row.channel_id,
userId: row.user_id, userId: row.user_id,
})); }));
@ -429,7 +407,7 @@ export class GuildCounters extends BaseGuildRepository {
channelId = channelId || "0"; channelId = channelId || "0";
userId = userId || "0"; userId = userId || "0";
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const matchingValue = await entityManager const matchingValue = await entityManager
.createQueryBuilder(CounterValue, "cv") .createQueryBuilder(CounterValue, "cv")
.innerJoin( .innerJoin(
@ -468,7 +446,7 @@ export class GuildCounters extends BaseGuildRepository {
async checkAllValuesForReverseTrigger( async checkAllValuesForReverseTrigger(
counterTrigger: CounterTrigger, counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> { ): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async entityManager => { return connection.transaction(async (entityManager) => {
const matchingValues: Array<{ const matchingValues: Array<{
id: string; id: string;
triggerStateId: string; triggerStateId: string;
@ -496,11 +474,11 @@ export class GuildCounters extends BaseGuildRepository {
if (matchingValues.length) { if (matchingValues.length) {
await entityManager.delete(CounterTriggerState, { await entityManager.delete(CounterTriggerState, {
id: In(matchingValues.map(v => v.triggerStateId)), id: In(matchingValues.map((v) => v.triggerStateId)),
}); });
} }
return matchingValues.map(row => ({ return matchingValues.map((row) => ({
channelId: row.channel_id, channelId: row.channel_id,
userId: row.user_id, userId: row.user_id,
})); }));

View file

@ -1,42 +1,54 @@
import { QueuedEventEmitter } from "../QueuedEventEmitter"; import { Mute } from "./entities/Mute";
import { BaseGuildRepository } from "./BaseGuildRepository"; import { ScheduledPost } from "./entities/ScheduledPost";
import { Reminder } from "./entities/Reminder";
export class GuildEvents extends BaseGuildRepository { interface GuildEventArgs extends Record<string, unknown[]> {
private queuedEventEmitter: QueuedEventEmitter; expiredMutes: [Mute[]];
private pluginListeners: Map<string, Map<string, any[]>>; scheduledPosts: [ScheduledPost[]];
reminders: [Reminder[]];
}
constructor(guildId) { type GuildEvent = keyof GuildEventArgs;
super(guildId);
this.queuedEventEmitter = new QueuedEventEmitter(); type GuildEventListener<K extends GuildEvent> = (...args: GuildEventArgs[K]) => void;
type ListenerMap = {
[K in GuildEvent]?: Array<GuildEventListener<K>>;
};
const guildListeners: Map<string, ListenerMap> = new Map();
/**
* @return - Function to unregister the listener
*/
export function onGuildEvent<K extends GuildEvent>(
guildId: string,
eventName: K,
listener: GuildEventListener<K>,
): () => void {
if (!guildListeners.has(guildId)) {
guildListeners.set(guildId, {});
} }
const listenerMap = guildListeners.get(guildId)!;
public on(pluginName: string, eventName: string, fn) { if (listenerMap[eventName] == null) {
this.queuedEventEmitter.on(eventName, fn); listenerMap[eventName] = [];
if (!this.pluginListeners.has(pluginName)) {
this.pluginListeners.set(pluginName, new Map());
}
const pluginListeners = this.pluginListeners.get(pluginName)!;
if (!pluginListeners.has(eventName)) {
pluginListeners.set(eventName, []);
}
const pluginEventListeners = pluginListeners.get(eventName)!;
pluginEventListeners.push(fn);
} }
listenerMap[eventName]!.push(listener);
public offPlugin(pluginName: string) { return () => {
const pluginListeners = this.pluginListeners.get(pluginName) || new Map(); listenerMap[eventName]!.splice(listenerMap[eventName]!.indexOf(listener), 1);
for (const [eventName, listeners] of Array.from(pluginListeners.entries())) { };
for (const listener of listeners) { }
this.queuedEventEmitter.off(eventName, listener);
} export function emitGuildEvent<K extends GuildEvent>(guildId: string, eventName: K, args: GuildEventArgs[K]): void {
} if (!guildListeners.has(guildId)) {
this.pluginListeners.delete(pluginName); return;
} }
const listenerMap = guildListeners.get(guildId)!;
public emit(eventName: string, args: any[] = []) { if (listenerMap[eventName] == null) {
return this.queuedEventEmitter.emit(eventName, args); return;
}
for (const listener of listenerMap[eventName]!) {
listener(...args);
} }
} }

View file

@ -46,12 +46,12 @@ export class GuildLogs extends events.EventEmitter {
} }
isLogIgnored(type: LogType, ignoreId: any) { isLogIgnored(type: LogType, ignoreId: any) {
return this.ignoredLogs.some(info => type === info.type && ignoreId === info.ignoreId); return this.ignoredLogs.some((info) => type === info.type && ignoreId === info.ignoreId);
} }
clearIgnoredLog(type: LogType, ignoreId: any) { clearIgnoredLog(type: LogType, ignoreId: any) {
this.ignoredLogs.splice( this.ignoredLogs.splice(
this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId),
1, 1,
); );
} }

View file

@ -19,7 +19,7 @@ export class GuildMemberTimezones extends BaseGuildRepository {
} }
async set(memberId, timezone: string) { async set(memberId, timezone: string) {
await connection.transaction(async entityManager => { await connection.transaction(async (entityManager) => {
const repo = entityManager.getRepository(MemberTimezone); const repo = entityManager.getRepository(MemberTimezone);
const existingRow = await repo.findOne({ const existingRow = await repo.findOne({
guild_id: this.guildId, guild_id: this.guildId,

View file

@ -35,12 +35,7 @@ export class GuildMutes extends BaseGuildRepository {
} }
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> { async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
const expiresAt = expiryTime const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
? moment
.utc()
.add(expiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss")
: null;
const result = await this.mutes.insert({ const result = await this.mutes.insert({
guild_id: this.guildId, guild_id: this.guildId,
@ -53,12 +48,7 @@ export class GuildMutes extends BaseGuildRepository {
} }
async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) { async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) {
const expiresAt = newExpiryTime const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
? moment
.utc()
.add(newExpiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss")
: null;
if (rolesToRestore && rolesToRestore.length) { if (rolesToRestore && rolesToRestore.length) {
return this.mutes.update( return this.mutes.update(
@ -89,7 +79,7 @@ export class GuildMutes extends BaseGuildRepository {
.createQueryBuilder("mutes") .createQueryBuilder("mutes")
.where("guild_id = :guild_id", { guild_id: this.guildId }) .where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere( .andWhere(
new Brackets(qb => { new Brackets((qb) => {
qb.where("expires_at > NOW()").orWhere("expires_at IS NULL"); qb.where("expires_at > NOW()").orWhere("expires_at IS NULL");
}), }),
) )

View file

@ -70,7 +70,7 @@ export class GuildNicknameHistory extends BaseGuildRepository {
if (toDelete.length > 0) { if (toDelete.length > 0) {
await this.nicknameHistory.delete({ await this.nicknameHistory.delete({
id: In(toDelete.map(v => v.id)), id: In(toDelete.map((v) => v.id)),
}); });
} }
} }

View file

@ -45,9 +45,90 @@ export class GuildSavedMessages extends BaseGuildRepository {
timestamp: msg.createdTimestamp, timestamp: msg.createdTimestamp,
}; };
if (msg.attachments.size) data.attachments = [...msg.attachments.values()]; if (msg.attachments.size) {
if (msg.embeds.length) data.embeds = msg.embeds; data.attachments = Array.from(msg.attachments.values()).map((att) => ({
if (msg.stickers?.size) data.stickers = [...msg.stickers.values()]; id: att.id,
contentType: att.contentType,
name: att.name,
proxyURL: att.proxyURL,
size: att.size,
spoiler: att.spoiler,
url: att.url,
width: att.width,
}));
}
if (msg.embeds.length) {
data.embeds = msg.embeds.map((embed) => ({
title: embed.title,
description: embed.description,
url: embed.url,
timestamp: embed.timestamp,
color: embed.color,
fields: embed.fields.map((field) => ({
name: field.name,
value: field.value,
inline: field.inline,
})),
author: embed.author
? {
name: embed.author.name,
url: embed.author.url,
iconURL: embed.author.iconURL,
proxyIconURL: embed.author.proxyIconURL,
}
: undefined,
thumbnail: embed.thumbnail
? {
url: embed.thumbnail.url,
proxyURL: embed.thumbnail.proxyURL,
height: embed.thumbnail.height,
width: embed.thumbnail.width,
}
: undefined,
image: embed.image
? {
url: embed.image.url,
proxyURL: embed.image.proxyURL,
height: embed.image.height,
width: embed.image.width,
}
: undefined,
video: embed.video
? {
url: embed.video.url,
proxyURL: embed.video.proxyURL,
height: embed.video.height,
width: embed.video.width,
}
: undefined,
footer: embed.footer
? {
text: embed.footer.text,
iconURL: embed.footer.iconURL,
proxyIconURL: embed.footer.proxyIconURL,
}
: undefined,
}));
}
if (msg.stickers?.size) {
data.stickers = Array.from(msg.stickers.values()).map((sticker) => ({
format: sticker.format,
guildId: sticker.guildId,
id: sticker.id,
name: sticker.name,
description: sticker.description,
available: sticker.available,
type: sticker.type,
}));
}
return data; return data;
} }
@ -131,8 +212,12 @@ export class GuildSavedMessages extends BaseGuildRepository {
try { try {
await this.messages.insert(data); await this.messages.insert(data);
} catch (e) { } catch (e) {
console.warn(e); // tslint:disable-line if (e?.code === "ER_DUP_ENTRY") {
return; console.trace(`Tried to insert duplicate message ID: ${data.id}`);
return;
}
throw e;
} }
const inserted = await this.messages.findOne(data.id); const inserted = await this.messages.findOne(data.id);
@ -141,8 +226,10 @@ export class GuildSavedMessages extends BaseGuildRepository {
} }
async createFromMsg(msg: Message, overrides = {}) { async createFromMsg(msg: Message, overrides = {}) {
const existingSavedMsg = await this.find(msg.id); // FIXME: Hotfix
if (existingSavedMsg) return; if (!msg.channel) {
return;
}
const savedMessageData = this.msgToSavedMessageData(msg); const savedMessageData = this.msgToSavedMessageData(msg);
const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss"); const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss");

View file

@ -11,10 +11,7 @@ export class GuildScheduledPosts extends BaseGuildRepository {
} }
all(): Promise<ScheduledPost[]> { all(): Promise<ScheduledPost[]> {
return this.scheduledPosts return this.scheduledPosts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.getMany();
} }
getDueScheduledPosts(): Promise<ScheduledPost[]> { getDueScheduledPosts(): Promise<ScheduledPost[]> {

View file

@ -67,10 +67,7 @@ export class GuildSlowmodes extends BaseGuildRepository {
const slowmode = await this.getChannelSlowmode(channelId); const slowmode = await this.getChannelSlowmode(channelId);
if (!slowmode) return; if (!slowmode) return;
const expiresAt = moment const expiresAt = moment.utc().add(slowmode.slowmode_seconds, "seconds").format("YYYY-MM-DD HH:mm:ss");
.utc()
.add(slowmode.slowmode_seconds, "seconds")
.format("YYYY-MM-DD HH:mm:ss");
if (await this.userHasSlowmode(channelId, userId)) { if (await this.userHasSlowmode(channelId, userId)) {
// Update existing // Update existing

View file

@ -30,10 +30,7 @@ export class GuildTempbans extends BaseGuildRepository {
} }
async addTempban(userId, expiryTime, modId): Promise<Tempban> { async addTempban(userId, expiryTime, modId): Promise<Tempban> {
const expiresAt = moment const expiresAt = moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
.utc()
.add(expiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss");
const result = await this.tempbans.insert({ const result = await this.tempbans.insert({
guild_id: this.guildId, guild_id: this.guildId,
@ -47,10 +44,7 @@ export class GuildTempbans extends BaseGuildRepository {
} }
async updateExpiryTime(userId, newExpiryTime, modId) { async updateExpiryTime(userId, newExpiryTime, modId) {
const expiresAt = moment const expiresAt = moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss");
.utc()
.add(newExpiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss");
return this.tempbans.update( return this.tempbans.update(
{ {

View file

@ -19,10 +19,7 @@ export class GuildVCAlerts extends BaseGuildRepository {
} }
async getAllGuildAlerts(): Promise<VCAlert[]> { async getAllGuildAlerts(): Promise<VCAlert[]> {
return this.allAlerts return this.allAlerts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany();
.createQueryBuilder()
.where("guild_id = :guildId", { guildId: this.guildId })
.getMany();
} }
async getAlertsByUserId(userId: string): Promise<VCAlert[]> { async getAlertsByUserId(userId: string): Promise<VCAlert[]> {

View file

@ -67,7 +67,7 @@ export class UsernameHistory extends BaseRepository {
if (toDelete.length > 0) { if (toDelete.length > 0) {
await this.usernameHistory.delete({ await this.usernameHistory.delete({
id: In(toDelete.map(v => v.id)), id: In(toDelete.map((v) => v.id)),
}); });
} }
} }

View 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];

View file

@ -13,10 +13,7 @@ export async function cleanupConfigs() {
let rows; let rows;
// >1 month old: 1 config retained per month // >1 month old: 1 config retained per month
const oneMonthCutoff = moment const oneMonthCutoff = moment.utc().subtract(30, "days").format(DBDateFormat);
.utc()
.subtract(30, "days")
.format(DBDateFormat);
do { do {
rows = await connection.query( rows = await connection.query(
` `
@ -46,7 +43,7 @@ export async function cleanupConfigs() {
if (rows.length > 0) { if (rows.length > 0) {
await configRepository.delete({ await configRepository.delete({
id: In(rows.map(r => r.id)), id: In(rows.map((r) => r.id)),
}); });
} }
@ -54,10 +51,7 @@ export async function cleanupConfigs() {
} while (rows.length === CLEAN_PER_LOOP); } while (rows.length === CLEAN_PER_LOOP);
// >2 weeks old: 1 config retained per day // >2 weeks old: 1 config retained per day
const twoWeekCutoff = moment const twoWeekCutoff = moment.utc().subtract(2, "weeks").format(DBDateFormat);
.utc()
.subtract(2, "weeks")
.format(DBDateFormat);
do { do {
rows = await connection.query( rows = await connection.query(
` `
@ -87,7 +81,7 @@ export async function cleanupConfigs() {
if (rows.length > 0) { if (rows.length > 0) {
await configRepository.delete({ await configRepository.delete({
id: In(rows.map(r => r.id)), id: In(rows.map((r) => r.id)),
}); });
} }

View file

@ -11,61 +11,78 @@ import { SavedMessage } from "../entities/SavedMessage";
const RETENTION_PERIOD = 1 * DAYS; const RETENTION_PERIOD = 1 * DAYS;
const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES;
const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES; const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES;
const CLEAN_PER_LOOP = 500; const CLEAN_PER_LOOP = 200;
export async function cleanupMessages(): Promise<number> { export async function cleanupMessages(): Promise<number> {
let cleaned = 0; let cleaned = 0;
const messagesRepository = getRepository(SavedMessage); const messagesRepository = getRepository(SavedMessage);
const deletedAtThreshold = moment const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat);
.utc() const postedAtThreshold = moment.utc().subtract(RETENTION_PERIOD, "ms").format(DBDateFormat);
.subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms") const botPostedAtThreshold = moment.utc().subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat);
.format(DBDateFormat);
const postedAtThreshold = moment
.utc()
.subtract(RETENTION_PERIOD, "ms")
.format(DBDateFormat);
const botPostedAtThreshold = moment
.utc()
.subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms")
.format(DBDateFormat);
// SELECT + DELETE messages in batches // SELECT + DELETE messages in batches
// This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below // This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below
// when a message was being inserted at the same time // when a message was being inserted at the same time
let rows; let ids: string[];
do { do {
rows = await connection.query( const deletedMessageRows = await connection.query(
` `
SELECT id SELECT id
FROM messages FROM messages
WHERE ( WHERE (
deleted_at IS NOT NULL deleted_at IS NOT NULL
AND deleted_at <= ? AND deleted_at <= ?
) )
OR (
posted_at <= ?
AND is_permanent = 0
)
OR (
is_bot = 1
AND posted_at <= ?
AND is_permanent = 0
)
LIMIT ${CLEAN_PER_LOOP} LIMIT ${CLEAN_PER_LOOP}
`, `,
[deletedAtThreshold, postedAtThreshold, botPostedAtThreshold], [deletedAtThreshold],
); );
if (rows.length > 0) { const oldPostedRows = await connection.query(
`
SELECT id
FROM messages
WHERE (
posted_at <= ?
AND is_permanent = 0
)
LIMIT ${CLEAN_PER_LOOP}
`,
[postedAtThreshold],
);
const oldBotPostedRows = await connection.query(
`
SELECT id
FROM messages
WHERE (
is_bot = 1
AND posted_at <= ?
AND is_permanent = 0
)
LIMIT ${CLEAN_PER_LOOP}
`,
[botPostedAtThreshold],
);
ids = Array.from(
new Set([
...deletedMessageRows.map((r) => r.id),
...oldPostedRows.map((r) => r.id),
...oldBotPostedRows.map((r) => r.id),
]),
);
if (ids.length > 0) {
await messagesRepository.delete({ await messagesRepository.delete({
id: In(rows.map(r => r.id)), id: In(ids),
}); });
} }
cleaned += rows.length; cleaned += ids.length;
} while (rows.length === CLEAN_PER_LOOP); } while (ids.length > 0);
return cleaned; return cleaned;
} }

View file

@ -11,10 +11,7 @@ export async function cleanupNicknames(): Promise<number> {
let cleaned = 0; let cleaned = 0;
const nicknameHistoryRepository = getRepository(NicknameHistoryEntry); const nicknameHistoryRepository = getRepository(NicknameHistoryEntry);
const dateThreshold = moment const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
.utc()
.subtract(NICKNAME_RETENTION_PERIOD, "ms")
.format(DBDateFormat);
// Clean old nicknames (NICKNAME_RETENTION_PERIOD) // Clean old nicknames (NICKNAME_RETENTION_PERIOD)
let rows; let rows;
@ -31,7 +28,7 @@ export async function cleanupNicknames(): Promise<number> {
if (rows.length > 0) { if (rows.length > 0) {
await nicknameHistoryRepository.delete({ await nicknameHistoryRepository.delete({
id: In(rows.map(r => r.id)), id: In(rows.map((r) => r.id)),
}); });
} }

View file

@ -11,10 +11,7 @@ export async function cleanupUsernames(): Promise<number> {
let cleaned = 0; let cleaned = 0;
const usernameHistoryRepository = getRepository(UsernameHistoryEntry); const usernameHistoryRepository = getRepository(UsernameHistoryEntry);
const dateThreshold = moment const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, "ms").format(DBDateFormat);
.utc()
.subtract(USERNAME_RETENTION_PERIOD, "ms")
.format(DBDateFormat);
// Clean old usernames (USERNAME_RETENTION_PERIOD) // Clean old usernames (USERNAME_RETENTION_PERIOD)
let rows; let rows;
@ -31,7 +28,7 @@ export async function cleanupUsernames(): Promise<number> {
if (rows.length > 0) { if (rows.length > 0) {
await usernameHistoryRepository.delete({ await usernameHistoryRepository.delete({
id: In(rows.map(r => r.id)), id: In(rows.map((r) => r.id)),
}); });
} }

View file

@ -14,4 +14,10 @@ export class AllowedGuild {
@Column() @Column()
owner_id: string; owner_id: string;
@Column()
created_at: string;
@Column()
updated_at: string;
} }

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

View file

@ -19,10 +19,7 @@ export class ApiLogin {
@Column() @Column()
expires_at: string; expires_at: string;
@ManyToOne( @ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.logins)
type => ApiUserInfo,
userInfo => userInfo.logins,
)
@JoinColumn({ name: "user_id" }) @JoinColumn({ name: "user_id" })
userInfo: ApiUserInfo; userInfo: ApiUserInfo;
} }

View file

@ -1,5 +1,6 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm"; import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm";
import { ApiUserInfo } from "./ApiUserInfo"; import { ApiUserInfo } from "./ApiUserInfo";
import { ApiPermissionTypes } from "../ApiPermissionAssignments";
@Entity("api_permissions") @Entity("api_permissions")
export class ApiPermissionAssignment { export class ApiPermissionAssignment {
@ -7,9 +8,9 @@ export class ApiPermissionAssignment {
@PrimaryColumn() @PrimaryColumn()
guild_id: string; guild_id: string;
@Column() @Column({ type: String })
@PrimaryColumn() @PrimaryColumn()
type: string; type: ApiPermissionTypes;
@Column() @Column()
@PrimaryColumn() @PrimaryColumn()
@ -18,10 +19,10 @@ export class ApiPermissionAssignment {
@Column("simple-array") @Column("simple-array")
permissions: string[]; permissions: string[];
@ManyToOne( @Column({ type: String, nullable: true })
type => ApiUserInfo, expires_at: string | null;
userInfo => userInfo.permissionAssignments,
) @ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.permissionAssignments)
@JoinColumn({ name: "target_id" }) @JoinColumn({ name: "target_id" })
userInfo: ApiUserInfo; userInfo: ApiUserInfo;
} }

View file

@ -20,15 +20,9 @@ export class ApiUserInfo {
@Column() @Column()
updated_at: string; updated_at: string;
@OneToMany( @OneToMany((type) => ApiLogin, (login) => login.userInfo)
type => ApiLogin,
login => login.userInfo,
)
logins: ApiLogin[]; logins: ApiLogin[];
@OneToMany( @OneToMany((type) => ApiPermissionAssignment, (p) => p.userInfo)
type => ApiPermissionAssignment,
p => p.userInfo,
)
permissionAssignments: ApiPermissionAssignment[]; permissionAssignments: ApiPermissionAssignment[];
} }

View file

@ -35,9 +35,6 @@ export class Case {
*/ */
@Column({ type: String, nullable: true }) log_message_id: string | null; @Column({ type: String, nullable: true }) log_message_id: string | null;
@OneToMany( @OneToMany((type) => CaseNote, (note) => note.case)
type => CaseNote,
note => note.case,
)
notes: CaseNote[]; notes: CaseNote[];
} }

View file

@ -15,10 +15,7 @@ export class CaseNote {
@Column() created_at: string; @Column() created_at: string;
@ManyToOne( @ManyToOne((type) => Case, (theCase) => theCase.notes)
type => Case,
theCase => theCase.notes,
)
@JoinColumn({ name: "case_id" }) @JoinColumn({ name: "case_id" })
case: Case; case: Case;
} }

View file

@ -22,7 +22,7 @@ export class Config {
@Column() @Column()
edited_at: string; edited_at: string;
@ManyToOne(type => ApiUserInfo) @ManyToOne((type) => ApiUserInfo)
@JoinColumn({ name: "edited_by" }) @JoinColumn({ name: "edited_by" })
userInfo: ApiUserInfo; userInfo: ApiUserInfo;
} }

View file

@ -1,16 +1,79 @@
import { MessageAttachment, Sticker } from "discord.js"; import { Snowflake } from "discord.js";
import { Column, Entity, PrimaryColumn } from "typeorm"; import { Column, Entity, PrimaryColumn } from "typeorm";
import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer"; import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer";
export interface ISavedMessageAttachmentData {
id: Snowflake;
contentType: string | null;
name: string | null;
proxyURL: string;
size: number;
spoiler: boolean;
url: string;
width: number | null;
}
export interface ISavedMessageEmbedData {
title: string | null;
description: string | null;
url: string | null;
timestamp: number | null;
color: number | null;
fields: Array<{
name: string;
value: string;
inline: boolean;
}>;
author?: {
name?: string;
url?: string;
iconURL?: string;
proxyIconURL?: string;
};
thumbnail?: {
url: string;
proxyURL?: string;
height?: number;
width?: number;
};
image?: {
url: string;
proxyURL?: string;
height?: number;
width?: number;
};
video?: {
url?: string;
proxyURL?: string;
height?: number;
width?: number;
};
footer?: {
text?: string;
iconURL?: string;
proxyIconURL?: string;
};
}
export interface ISavedMessageStickerData {
format: string;
guildId: Snowflake | null;
id: Snowflake;
name: string;
description: string | null;
available: boolean | null;
type: string | null;
}
export interface ISavedMessageData { export interface ISavedMessageData {
attachments?: MessageAttachment[]; attachments?: ISavedMessageAttachmentData[];
author: { author: {
username: string; username: string;
discriminator: string; discriminator: string;
}; };
content: string; content: string;
embeds?: object[]; embeds?: ISavedMessageEmbedData[];
stickers?: Sticker[]; stickers?: ISavedMessageStickerData[];
timestamp: number; timestamp: number;
} }

View file

@ -16,7 +16,7 @@ export class StarboardMessage {
@Column() @Column()
guild_id: string; guild_id: string;
@OneToOne(type => SavedMessage) @OneToOne((type) => SavedMessage)
@JoinColumn({ name: "message_id" }) @JoinColumn({ name: "message_id" })
message: SavedMessage; message: SavedMessage;
} }

View file

@ -16,7 +16,7 @@ export class StarboardReaction {
@Column() @Column()
reactor_id: string; reactor_id: string;
@OneToOne(type => SavedMessage) @OneToOne((type) => SavedMessage)
@JoinColumn({ name: "message_id" }) @JoinColumn({ name: "message_id" })
message: SavedMessage; message: SavedMessage;
} }

View file

@ -1,5 +1,4 @@
import { Client, Intents, TextChannel } from "discord.js"; import { Client, Constants, Intents, TextChannel, ThreadChannel } from "discord.js";
import yaml from "js-yaml";
import { Knub, PluginError } from "knub"; import { Knub, PluginError } from "knub";
import { PluginLoadError } from "knub/dist/plugins/PluginLoadError"; import { PluginLoadError } from "knub/dist/plugins/PluginLoadError";
// Always use UTC internally // Always use UTC internally
@ -18,8 +17,12 @@ import { RecoverablePluginError } from "./RecoverablePluginError";
import { SimpleError } from "./SimpleError"; import { SimpleError } from "./SimpleError";
import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types"; import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types";
import { startUptimeCounter } from "./uptime"; import { startUptimeCounter } from "./uptime";
import { errorMessage, isDiscordAPIError, isDiscordHTTPError, successMessage } from "./utils"; import { errorMessage, isDiscordAPIError, isDiscordHTTPError, SECONDS, successMessage } from "./utils";
import { loadYamlSafely } from "./utils/loadYamlSafely"; import { loadYamlSafely } from "./utils/loadYamlSafely";
import { DecayingCounter } from "./utils/DecayingCounter";
import { PluginNotLoadedError } from "knub/dist/plugins/PluginNotLoadedError";
import { logRestCall } from "./restCallStats";
import { logRateLimit } from "./rateLimitStats";
if (!process.env.KEY) { if (!process.env.KEY) {
// tslint:disable-next-line:no-console // tslint:disable-next-line:no-console
@ -94,6 +97,25 @@ function errorHandler(err) {
return; return;
} }
// FIXME: Hotfix
if (err.message && err.message.startsWith("Unknown custom override criteria")) {
// console.warn(err.message);
return;
}
// FIXME: Hotfix
if (err.message && err.message.startsWith("Unknown override criteria")) {
// console.warn(err.message);
return;
}
if (err instanceof PluginNotLoadedError) {
// We don't want to crash the bot here, although this *should not happen*
// TODO: Proper system for preventing plugin load/unload race conditions
console.error(err);
return;
}
// tslint:disable:no-console // tslint:disable:no-console
console.error(err); console.error(err);
@ -124,8 +146,8 @@ if (process.env.NODE_ENV === "production") {
// Verify required Node.js version // Verify required Node.js version
const REQUIRED_NODE_VERSION = "14.0.0"; const REQUIRED_NODE_VERSION = "14.0.0";
const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10)); const requiredParts = REQUIRED_NODE_VERSION.split(".").map((v) => parseInt(v, 10));
const actualVersionParts = process.versions.node.split(".").map(v => parseInt(v, 10)); const actualVersionParts = process.versions.node.split(".").map((v) => parseInt(v, 10));
for (const [i, part] of actualVersionParts.entries()) { for (const [i, part] of actualVersionParts.entries()) {
if (part > requiredParts[i]) break; if (part > requiredParts[i]) break;
if (part === requiredParts[i]) continue; if (part === requiredParts[i]) continue;
@ -136,10 +158,21 @@ moment.tz.setDefault("UTC");
logger.info("Connecting to database"); logger.info("Connecting to database");
connect().then(async () => { connect().then(async () => {
const RequestHandler = require("discord.js/src/rest/RequestHandler.js");
const originalPush = RequestHandler.prototype.push;
// tslint:disable-next-line:only-arrow-functions
RequestHandler.prototype.push = function (...args) {
const request = args[0];
logRestCall(request.method, request.path);
return originalPush.call(this, ...args);
};
const client = new Client({ const client = new Client({
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"], partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"],
restTimeOffset: 150,
restGlobalRateLimit: 50, restGlobalRateLimit: 50,
// restTimeOffset: 1000,
// Disable mentions by default // Disable mentions by default
allowedMentions: { allowedMentions: {
parse: [], parse: [],
@ -166,11 +199,31 @@ connect().then(async () => {
}); });
client.setMaxListeners(200); client.setMaxListeners(200);
client.on("rateLimit", rateLimitData => { client.on(Constants.Events.RATE_LIMIT, (data) => {
logger.info(`[429] ${JSON.stringify(rateLimitData)}`); // tslint:disable-next-line:no-console
// console.log(`[DEBUG] [RATE_LIMIT] ${JSON.stringify(data)}`);
}); });
client.on("error", err => { const safe429DecayInterval = 5 * SECONDS;
const safe429MaxCount = 5;
const safe429Counter = new DecayingCounter(safe429DecayInterval);
client.on(Constants.Events.DEBUG, (errorText) => {
if (!errorText.includes("429")) {
return;
}
// tslint:disable-next-line:no-console
console.warn(`[DEBUG] [WARN] [429] ${errorText}`);
const value = safe429Counter.add(1);
if (value > safe429MaxCount) {
// tslint:disable-next-line:no-console
console.error(`Too many 429s (over ${safe429MaxCount} in ${safe429MaxCount * safe429DecayInterval}ms), exiting`);
process.exit(1);
}
});
client.on("error", (err) => {
errorHandler(new DiscordJSError(err.message, (err as any).code, 0)); errorHandler(new DiscordJSError(err.message, (err as any).code, 0));
}); });
@ -198,9 +251,9 @@ connect().then(async () => {
} }
const configuredPlugins = ctx.config.plugins; const configuredPlugins = ctx.config.plugins;
const basePluginNames = baseGuildPlugins.map(p => p.name); const basePluginNames = baseGuildPlugins.map((p) => p.name);
return Array.from(plugins.keys()).filter(pluginName => { return Array.from(plugins.keys()).filter((pluginName) => {
if (basePluginNames.includes(pluginName)) return true; if (basePluginNames.includes(pluginName)) return true;
return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false; return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false;
}); });
@ -208,6 +261,13 @@ connect().then(async () => {
async getConfig(id) { async getConfig(id) {
const key = id === "global" ? "global" : `guild-${id}`; const key = id === "global" ? "global" : `guild-${id}`;
if (id !== "global") {
const allowedGuild = await allowedGuilds.find(id);
if (!allowedGuild) {
return {};
}
}
const row = await guildConfigs.getActiveByKey(key); const row = await guildConfigs.getActiveByKey(key);
if (row) { if (row) {
try { try {
@ -239,13 +299,15 @@ connect().then(async () => {
}, },
sendSuccessMessageFn(channel, body) { sendSuccessMessageFn(channel, body) {
const guildId = channel instanceof TextChannel ? channel.guild.id : undefined; const guildId =
channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined; const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined;
channel.send(successMessage(body, emoji)); channel.send(successMessage(body, emoji));
}, },
sendErrorMessageFn(channel, body) { sendErrorMessageFn(channel, body) {
const guildId = channel instanceof TextChannel ? channel.guild.id : undefined; const guildId =
channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined;
const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined; const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined;
channel.send(errorMessage(body, emoji)); channel.send(errorMessage(body, emoji));
}, },
@ -256,6 +318,10 @@ connect().then(async () => {
startUptimeCounter(); startUptimeCounter();
}); });
client.on(Constants.Events.RATE_LIMIT, (data) => {
logRateLimit(data);
});
bot.initialize(); bot.initialize();
logger.info("Bot Initialized"); logger.info("Bot Initialized");
logger.info("Logging in..."); logger.info("Logging in...");

View file

@ -9,7 +9,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
const migratedUsernames = new Set(); const migratedUsernames = new Set();
await new Promise(async resolve => { await new Promise(async (resolve) => {
const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history"); const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history");
stream.on("data", (row: any) => { stream.on("data", (row: any) => {
migratedUsernames.add(row.key); migratedUsernames.add(row.key);
@ -18,7 +18,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
}); });
const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => { const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => {
return new Promise(async resolve => { return new Promise(async (resolve) => {
const toInsert: any[][] = []; const toInsert: any[][] = [];
const toDelete: number[] = []; const toDelete: number[] = [];

View file

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

View file

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

View file

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

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

View file

@ -62,6 +62,8 @@ const PluginOverrideCriteriaType: t.Type<PluginOverrideCriteria<unknown>> = t.re
const validTopLevelOverrideKeys = [ const validTopLevelOverrideKeys = [
"channel", "channel",
"category", "category",
"thread",
"is_thread",
"level", "level",
"user", "user",
"role", "role",
@ -83,7 +85,7 @@ export function strictValidationErrorToConfigValidationError(err: StrictValidati
return new ConfigValidationError( return new ConfigValidationError(
err err
.getErrors() .getErrors()
.map(e => e.toString()) .map((e) => e.toString())
.join("\n"), .join("\n"),
); );
} }
@ -197,7 +199,7 @@ export async function sendSuccessMessage(
return channel return channel
.send({ ...content }) // Force line break .send({ ...content }) // Force line break
.catch(err => { .catch((err) => {
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id; const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined; return undefined;
@ -218,7 +220,7 @@ export async function sendErrorMessage(
return channel return channel
.send({ ...content }) // Force line break .send({ ...content }) // Force line break
.catch(err => { .catch((err) => {
const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id; const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id;
logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined; return undefined;
@ -232,7 +234,7 @@ export function getBaseUrl(pluginData: AnyPluginData<any>) {
export function isOwner(pluginData: AnyPluginData<any>, userId: string) { export function isOwner(pluginData: AnyPluginData<any>, userId: string) {
const knub = pluginData.getKnubInstance() as TZeppelinKnub; const knub = pluginData.getKnubInstance() as TZeppelinKnub;
const owners = knub.getGlobalConfig().owners; const owners = knub.getGlobalConfig()?.owners;
if (!owners) { if (!owners) {
return false; return false;
} }
@ -250,7 +252,7 @@ type AnyFn = (...args: any[]) => any;
* Creates a public plugin function out of a function with pluginData as the first parameter * Creates a public plugin function out of a function with pluginData as the first parameter
*/ */
export function mapToPublicFn<T extends AnyFn>(inputFn: T) { export function mapToPublicFn<T extends AnyFn>(inputFn: T) {
return pluginData => { return (pluginData) => {
return (...args: Tail<Parameters<typeof inputFn>>): ReturnType<typeof inputFn> => { return (...args: Tail<Parameters<typeof inputFn>>): ReturnType<typeof inputFn> => {
return inputFn(pluginData, ...args); return inputFn(pluginData, ...args);
}; };

View file

@ -25,7 +25,7 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
configurationGuide: "Maximum deletion delay is currently 5 minutes", configurationGuide: "Maximum deletion delay is currently 5 minutes",
}, },
dependencies: [TimeAndDatePlugin, LogsPlugin], dependencies: () => [TimeAndDatePlugin, LogsPlugin],
configSchema: ConfigSchema, configSchema: ConfigSchema,
defaultOptions, defaultOptions,
@ -45,13 +45,13 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
afterLoad(pluginData) { afterLoad(pluginData) {
const { state, guild } = pluginData; const { state, guild } = pluginData;
state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg);
state.guildSavedMessages.events.on("create", state.onMessageCreateFn); state.guildSavedMessages.events.on("create", state.onMessageCreateFn);
state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg);
state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn); state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn);
state.onMessageDeleteBulkFn = msgs => onMessageDeleteBulk(pluginData, msgs); state.onMessageDeleteBulkFn = (msgs) => onMessageDeleteBulk(pluginData, msgs);
state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn); state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn);
}, },

View file

@ -1,7 +1,7 @@
import { Permissions, Snowflake, TextChannel } from "discord.js"; import { Permissions, Snowflake, TextChannel } from "discord.js";
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { logger } from "../../../logger"; import { logger } from "../../../logger";
import { resolveUser, verboseChannelMention } from "../../../utils"; import { resolveUser, verboseChannelMention } from "../../../utils";
@ -27,7 +27,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
const perms = channel.permissionsFor(pluginData.client.user!.id); const perms = channel.permissionsFor(pluginData.client.user!.id);
if (!hasDiscordPermissions(perms, Permissions.FLAGS.VIEW_CHANNEL | Permissions.FLAGS.READ_MESSAGE_HISTORY)) { if (!hasDiscordPermissions(perms, Permissions.FLAGS.VIEW_CHANNEL | Permissions.FLAGS.READ_MESSAGE_HISTORY)) {
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention( body: `Missing permissions to read messages or message history in auto-delete channel ${verboseChannelMention(
channel, channel,
)}`, )}`,
@ -36,7 +36,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
} }
if (!hasDiscordPermissions(perms, Permissions.FLAGS.MANAGE_MESSAGES)) { if (!hasDiscordPermissions(perms, Permissions.FLAGS.MANAGE_MESSAGES)) {
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`, body: `Missing permissions to delete messages in auto-delete channel ${verboseChannelMention(channel)}`,
}); });
return; return;
@ -45,7 +45,7 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); pluginData.state.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);
(channel as TextChannel).messages.delete(itemToDelete.message.id as Snowflake).catch(err => { (channel as TextChannel).messages.delete(itemToDelete.message.id as Snowflake).catch((err) => {
if (err.code === 10008) { if (err.code === 10008) {
// "Unknown Message", probably already deleted by automod or another bot, ignore // "Unknown Message", probably already deleted by automod or another bot, ignore
return; return;
@ -59,10 +59,10 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
.inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x")) .inGuildTz(moment.utc(itemToDelete.message.data.timestamp, "x"))
.format(timeAndDate.getDateFormat("pretty_datetime")); .format(timeAndDate.getDateFormat("pretty_datetime"));
pluginData.state.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, { pluginData.getPlugin(LogsPlugin).logMessageDeleteAuto({
message: itemToDelete.message, message: itemToDelete.message,
user: userToConfigAccessibleUser(user), user,
channel: channelToConfigAccessibleChannel(channel), channel,
messageDate, messageDate,
}); });
} }

View file

@ -4,6 +4,7 @@ import { LogType } from "../../../data/LogType";
import { convertDelayStringToMS, resolveMember } from "../../../utils"; import { convertDelayStringToMS, resolveMember } from "../../../utils";
import { AutoDeletePluginType, MAX_DELAY } from "../types"; import { AutoDeletePluginType, MAX_DELAY } from "../types";
import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue"; import { addMessageToDeletionQueue } from "./addMessageToDeletionQueue";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) { export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
@ -14,7 +15,7 @@ export async function onMessageCreate(pluginData: GuildPluginData<AutoDeletePlug
if (delay > MAX_DELAY) { if (delay > MAX_DELAY) {
delay = MAX_DELAY; delay = MAX_DELAY;
if (!pluginData.state.maxDelayWarningSent) { if (!pluginData.state.maxDelayWarningSent) {
pluginData.state.guildLogs.log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`, body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,
}); });
pluginData.state.maxDelayWarningSent = true; pluginData.state.maxDelayWarningSent = true;

View file

@ -4,7 +4,7 @@ import { AutoDeletePluginType } from "../types";
import { scheduleNextDeletion } from "./scheduleNextDeletion"; import { scheduleNextDeletion } from "./scheduleNextDeletion";
export function onMessageDelete(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) { export function onMessageDelete(pluginData: GuildPluginData<AutoDeletePluginType>, msg: SavedMessage) {
const indexToDelete = pluginData.state.deletionQueue.findIndex(item => item.message.id === msg.id); const indexToDelete = pluginData.state.deletionQueue.findIndex((item) => item.message.id === msg.id);
if (indexToDelete > -1) { if (indexToDelete > -1) {
pluginData.state.deletionQueue.splice(indexToDelete, 1); pluginData.state.deletionQueue.splice(indexToDelete, 1);
scheduleNextDeletion(pluginData); scheduleNextDeletion(pluginData);

View file

@ -33,7 +33,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
`), `),
}, },
dependencies: [LogsPlugin], dependencies: () => [LogsPlugin],
configSchema: ConfigSchema, configSchema: ConfigSchema,
defaultOptions, defaultOptions,

View file

@ -26,7 +26,7 @@ export const AddReactionsEvt = autoReactionsEvt({
); );
if (missingPermissions) { if (missingPermissions) {
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Cannot apply auto-reactions in <#${message.channel.id}>. ${missingPermissionError(missingPermissions)}`, body: `Cannot apply auto-reactions in <#${message.channel.id}>. ${missingPermissionError(missingPermissions)}`,
}); });
return; return;
@ -39,11 +39,11 @@ export const AddReactionsEvt = autoReactionsEvt({
if (isDiscordAPIError(e)) { if (isDiscordAPIError(e)) {
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
if (e.code === 10008) { if (e.code === 10008) {
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Make sure nothing is deleting the message before the reactions are applied.`, body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Make sure nothing is deleting the message before the reactions are applied.`,
}); });
} else { } else {
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Error code ${e.code}.`, body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Error code ${e.code}.`,
}); });
} }

View file

@ -57,7 +57,7 @@ const defaultOptions = {
/** /**
* Config preprocessor to set default values for triggers and perform extra validation * Config preprocessor to set default values for triggers and perform extra validation
*/ */
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => { const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = (options) => {
if (options.config?.rules) { if (options.config?.rules) {
// Loop through each rule // Loop through each rule
for (const [name, rule] of Object.entries(options.config.rules)) { for (const [name, rule] of Object.entries(options.config.rules)) {
@ -114,6 +114,21 @@ const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
]); ]);
} }
} }
if (triggerObj[triggerName].match_mime_type) {
const white = triggerObj[triggerName].match_mime_type.whitelist_enabled;
const black = triggerObj[triggerName].match_mime_type.blacklist_enabled;
if (white && black) {
throw new StrictValidationError([
`Cannot have both blacklist and whitelist enabled at rule <${rule.name}/match_mime_type>`,
]);
} else if (!white && !black) {
throw new StrictValidationError([
`Must have either blacklist or whitelist enabled at rule <${rule.name}/match_mime_type>`,
]);
}
}
} }
} }
} }
@ -159,7 +174,7 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
info: pluginInfo, info: pluginInfo,
// prettier-ignore // prettier-ignore
dependencies: [ dependencies: () => [
LogsPlugin, LogsPlugin,
ModActionsPlugin, ModActionsPlugin,
MutesPlugin, MutesPlugin,
@ -217,10 +232,10 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
30 * SECONDS, 30 * SECONDS,
); );
pluginData.state.onMessageCreateFn = message => runAutomodOnMessage(pluginData, message, false); pluginData.state.onMessageCreateFn = (message) => runAutomodOnMessage(pluginData, message, false);
pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn); pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn);
pluginData.state.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true); pluginData.state.onMessageUpdateFn = (message) => runAutomodOnMessage(pluginData, message, true);
pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn); pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn);
const countersPlugin = pluginData.getPlugin(CountersPlugin); const countersPlugin = pluginData.getPlugin(CountersPlugin);

View file

@ -17,13 +17,13 @@ export const AddRolesAction = automodAction({
defaultConfig: [], defaultConfig: [],
async apply({ pluginData, contexts, actionConfig, ruleName }) { async apply({ pluginData, contexts, actionConfig, ruleName }) {
const members = unique(contexts.map(c => c.member).filter(nonNullish)); const members = unique(contexts.map((c) => c.member).filter(nonNullish));
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES); const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
if (missingPermissions) { if (missingPermissions) {
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
}); });
return; return;
@ -41,10 +41,10 @@ export const AddRolesAction = automodAction({
if (rolesWeCannotAssign.length) { if (rolesWeCannotAssign.length) {
const roleNamesWeCannotAssign = rolesWeCannotAssign.map( const roleNamesWeCannotAssign = rolesWeCannotAssign.map(
roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
); );
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join( body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join(
"**, **", "**, **",
)}**`, )}**`,
@ -52,7 +52,7 @@ export const AddRolesAction = automodAction({
} }
await Promise.all( await Promise.all(
members.map(async member => { members.map(async (member) => {
const memberRoles = new Set(member.roles.cache.keys()); const memberRoles = new Set(member.roles.cache.keys());
for (const roleId of rolesToAssign) { for (const roleId of rolesToAssign) {
memberRoles.add(roleId as Snowflake); memberRoles.add(roleId as Snowflake);

View file

@ -2,6 +2,7 @@ import * as t from "io-ts";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { CountersPlugin } from "../../Counters/CountersPlugin"; import { CountersPlugin } from "../../Counters/CountersPlugin";
import { automodAction } from "../helpers"; import { automodAction } from "../helpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const AddToCounterAction = automodAction({ export const AddToCounterAction = automodAction({
configType: t.type({ configType: t.type({
@ -14,7 +15,7 @@ export const AddToCounterAction = automodAction({
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) { async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
const countersPlugin = pluginData.getPlugin(CountersPlugin); const countersPlugin = pluginData.getPlugin(CountersPlugin);
if (!countersPlugin.counterExists(actionConfig.counter)) { if (!countersPlugin.counterExists(actionConfig.counter)) {
pluginData.state.logs.log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``, body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
}); });
return; return;

View file

@ -1,18 +1,27 @@
import { Snowflake, TextChannel } from "discord.js"; import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
import * as t from "io-ts"; import * as t from "io-ts";
import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions"; import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { renderTemplate, TemplateParseError } from "../../../templateFormatter"; import {
createTypedTemplateSafeValueContainer,
renderTemplate,
TemplateParseError,
TemplateSafeValueContainer,
} from "../../../templateFormatter";
import { import {
createChunkedMessage, createChunkedMessage,
messageLink, messageLink,
stripObjectToScalars, stripObjectToScalars,
tAllowedMentions, tAllowedMentions,
tNormalizedNullOptional, tNormalizedNullOptional,
isTruthy,
verboseChannelMention, verboseChannelMention,
validateAndParseMessageContent,
} from "../../../utils"; } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { automodAction } from "../helpers"; import { automodAction } from "../helpers";
import { TemplateSafeUser, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { messageIsEmpty } from "../../../utils/messageIsEmpty";
export const AlertAction = automodAction({ export const AlertAction = automodAction({
configType: t.type({ configType: t.type({
@ -27,38 +36,44 @@ export const AlertAction = automodAction({
const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake); const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
if (channel && channel instanceof TextChannel) { if (channel && (channel instanceof TextChannel || channel instanceof ThreadChannel)) {
const text = actionConfig.text; const text = actionConfig.text;
const theMessageLink = const theMessageLink =
contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id); contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id);
const safeUsers = contexts.map(c => c.user && stripObjectToScalars(c.user)).filter(Boolean); const safeUsers = contexts.map((c) => (c.user ? userToTemplateSafeUser(c.user) : null)).filter(isTruthy);
const safeUser = safeUsers[0]; const safeUser = safeUsers[0];
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
const logMessage = await logs.getLogMessage(LogType.AUTOMOD_ACTION, { const logMessage = await logs.getLogMessage(
rule: ruleName, LogType.AUTOMOD_ACTION,
user: safeUser, createTypedTemplateSafeValueContainer({
users: safeUsers,
actionsTaken,
matchSummary: matchResult.summary,
});
let rendered;
try {
rendered = await renderTemplate(actionConfig.text, {
rule: ruleName, rule: ruleName,
user: safeUser, user: safeUser,
users: safeUsers, users: safeUsers,
text,
actionsTaken, actionsTaken,
matchSummary: matchResult.summary, matchSummary: matchResult.summary ?? "",
messageLink: theMessageLink, }),
logMessage, );
});
let rendered;
try {
rendered = await renderTemplate(
actionConfig.text,
new TemplateSafeValueContainer({
rule: ruleName,
user: safeUser,
users: safeUsers,
text,
actionsTaken,
matchSummary: matchResult.summary,
messageLink: theMessageLink,
logMessage: validateAndParseMessageContent(logMessage)?.content,
}),
);
} catch (err) { } catch (err) {
if (err instanceof TemplateParseError) { if (err instanceof TemplateParseError) {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Error in alert format of automod rule ${ruleName}: ${err.message}`, body: `Error in alert format of automod rule ${ruleName}: ${err.message}`,
}); });
return; return;
@ -67,6 +82,13 @@ export const AlertAction = automodAction({
throw err; throw err;
} }
if (messageIsEmpty(rendered)) {
pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Tried to send alert with an empty message for automod rule ${ruleName}`,
});
return;
}
try { try {
await createChunkedMessage( await createChunkedMessage(
channel, channel,
@ -75,13 +97,13 @@ export const AlertAction = automodAction({
); );
} catch (err) { } catch (err) {
if (err.code === 50001) { if (err.code === 50001) {
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Missing access to send alert to channel ${verboseChannelMention( body: `Missing access to send alert to channel ${verboseChannelMention(
channel, channel,
)} in automod rule **${ruleName}**`, )} in automod rule **${ruleName}**`,
}); });
} else { } else {
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention( body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention(
channel, channel,
)} in automod rule **${ruleName}**`, )} in automod rule **${ruleName}**`,
@ -89,7 +111,7 @@ export const AlertAction = automodAction({
} }
} }
} else { } else {
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`, body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`,
}); });
} }

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

View file

@ -3,6 +3,7 @@ import { AutomodActionBlueprint } from "../helpers";
import { AddRolesAction } from "./addRoles"; import { AddRolesAction } from "./addRoles";
import { AddToCounterAction } from "./addToCounter"; import { AddToCounterAction } from "./addToCounter";
import { AlertAction } from "./alert"; import { AlertAction } from "./alert";
import { ArchiveThreadAction } from "./archiveThread";
import { BanAction } from "./ban"; import { BanAction } from "./ban";
import { ChangeNicknameAction } from "./changeNickname"; import { ChangeNicknameAction } from "./changeNickname";
import { CleanAction } from "./clean"; import { CleanAction } from "./clean";
@ -32,6 +33,7 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
add_to_counter: AddToCounterAction, add_to_counter: AddToCounterAction,
set_counter: SetCounterAction, set_counter: SetCounterAction,
set_slowmode: SetSlowmodeAction, set_slowmode: SetSlowmodeAction,
archive_thread: ArchiveThreadAction,
}; };
export const AvailableActions = t.type({ export const AvailableActions = t.type({
@ -50,4 +52,5 @@ export const AvailableActions = t.type({
add_to_counter: AddToCounterAction.configType, add_to_counter: AddToCounterAction.configType,
set_counter: SetCounterAction.configType, set_counter: SetCounterAction.configType,
set_slowmode: SetSlowmodeAction.configType, set_slowmode: SetSlowmodeAction.configType,
archive_thread: ArchiveThreadAction.configType,
}); });

View file

@ -35,7 +35,7 @@ export const BanAction = automodAction({
hide: Boolean(actionConfig.hide_case), hide: Boolean(actionConfig.hide_case),
}; };
const userIdsToBan = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToBan = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const userId of userIdsToBan) { for (const userId of userIdsToBan) {

View file

@ -15,14 +15,14 @@ export const ChangeNicknameAction = automodAction({
defaultConfig: {}, defaultConfig: {},
async apply({ pluginData, contexts, actionConfig }) { async apply({ pluginData, contexts, actionConfig }) {
const members = unique(contexts.map(c => c.member).filter(nonNullish)); const members = unique(contexts.map((c) => c.member).filter(nonNullish));
for (const member of members) { for (const member of members) {
if (pluginData.state.recentNicknameChanges.has(member.id)) continue; if (pluginData.state.recentNicknameChanges.has(member.id)) continue;
const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name; const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name;
member.edit({ nick: newName }).catch(err => { member.edit({ nick: newName }).catch((err) => {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Failed to change the nickname of \`${member.id}\``, body: `Failed to change the nickname of \`${member.id}\``,
}); });
}); });

View file

@ -31,8 +31,8 @@ export const KickAction = automodAction({
hide: Boolean(actionConfig.hide_case), hide: Boolean(actionConfig.hide_case),
}; };
const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToKick = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const membersToKick = await asyncMap(userIdsToKick, id => resolveMember(pluginData.client, pluginData.guild, id)); const membersToKick = await asyncMap(userIdsToKick, (id) => resolveMember(pluginData.client, pluginData.guild, id));
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToKick) { for (const member of membersToKick) {

View file

@ -1,26 +1,25 @@
import * as t from "io-ts"; import * as t from "io-ts";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { stripObjectToScalars, unique } from "../../../utils"; import { isTruthy, stripObjectToScalars, unique } from "../../../utils";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { automodAction } from "../helpers"; import { automodAction } from "../helpers";
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
export const LogAction = automodAction({ export const LogAction = automodAction({
configType: t.boolean, configType: t.boolean,
defaultConfig: true, defaultConfig: true,
async apply({ pluginData, contexts, ruleName, matchResult }) { async apply({ pluginData, contexts, ruleName, matchResult }) {
const safeUsers = unique(contexts.map(c => c.user)) const users = unique(contexts.map((c) => c.user)).filter(isTruthy);
.filter(Boolean) const user = users[0];
.map(user => stripObjectToScalars(user));
const safeUser = safeUsers[0];
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
pluginData.getPlugin(LogsPlugin).log(LogType.AUTOMOD_ACTION, { pluginData.getPlugin(LogsPlugin).logAutomodAction({
rule: ruleName, rule: ruleName,
user: safeUser, user,
users: safeUsers, users,
actionsTaken, actionsTaken,
matchSummary: matchResult.summary, matchSummary: matchResult.summary ?? "",
}); });
}, },
}); });

View file

@ -40,7 +40,7 @@ export const MuteAction = automodAction({
hide: Boolean(actionConfig.hide_case), hide: Boolean(actionConfig.hide_case),
}; };
const userIdsToMute = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToMute = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const mutes = pluginData.getPlugin(MutesPlugin); const mutes = pluginData.getPlugin(MutesPlugin);
for (const userId of userIdsToMute) { for (const userId of userIdsToMute) {
@ -55,7 +55,7 @@ export const MuteAction = automodAction({
); );
} catch (e) { } catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Failed to mute <@!${userId}> in Automod rule \`${ruleName}\` because a mute role has not been specified in server config`, body: `Failed to mute <@!${userId}> in Automod rule \`${ruleName}\` because a mute role has not been specified in server config`,
}); });
} else { } else {

View file

@ -18,13 +18,13 @@ export const RemoveRolesAction = automodAction({
defaultConfig: [], defaultConfig: [],
async apply({ pluginData, contexts, actionConfig, ruleName }) { async apply({ pluginData, contexts, actionConfig, ruleName }) {
const members = unique(contexts.map(c => c.member).filter(nonNullish)); const members = unique(contexts.map((c) => c.member).filter(nonNullish));
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES); const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES);
if (missingPermissions) { if (missingPermissions) {
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`,
}); });
return; return;
@ -42,10 +42,10 @@ export const RemoveRolesAction = automodAction({
if (rolesWeCannotRemove.length) { if (rolesWeCannotRemove.length) {
const roleNamesWeCannotRemove = rolesWeCannotRemove.map( const roleNamesWeCannotRemove = rolesWeCannotRemove.map(
roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId,
); );
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
logs.log(LogType.BOT_ALERT, { logs.logBotAlert({
body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join( body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join(
"**, **", "**, **",
)}**`, )}**`,
@ -53,7 +53,7 @@ export const RemoveRolesAction = automodAction({
} }
await Promise.all( await Promise.all(
members.map(async member => { members.map(async (member) => {
const memberRoles = new Set(member.roles.cache.keys()); const memberRoles = new Set(member.roles.cache.keys());
for (const roleId of rolesToRemove) { for (const roleId of rolesToRemove) {
memberRoles.delete(roleId as Snowflake); memberRoles.delete(roleId as Snowflake);

View file

@ -1,8 +1,7 @@
import { MessageOptions, Permissions, Snowflake, TextChannel, User } from "discord.js"; import { MessageOptions, Permissions, Snowflake, TextChannel, ThreadChannel, User } from "discord.js";
import * as t from "io-ts"; import * as t from "io-ts";
import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { LogType } from "../../../data/LogType"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
import { renderTemplate } from "../../../templateFormatter";
import { import {
convertDelayStringToMS, convertDelayStringToMS,
noop, noop,
@ -11,11 +10,13 @@ import {
tMessageContent, tMessageContent,
tNullable, tNullable,
unique, unique,
validateAndParseMessageContent,
verboseChannelMention, verboseChannelMention,
} from "../../../utils"; } from "../../../utils";
import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
import { automodAction } from "../helpers"; import { automodAction } from "../helpers";
import { AutomodContext } from "../types"; import { AutomodContext } from "../types";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const ReplyAction = automodAction({ export const ReplyAction = automodAction({
configType: t.union([ configType: t.union([
@ -23,6 +24,7 @@ export const ReplyAction = automodAction({
t.type({ t.type({
text: tMessageContent, text: tMessageContent,
auto_delete: tNullable(t.union([tDelayString, t.number])), auto_delete: tNullable(t.union([tDelayString, t.number])),
inline: tNullable(t.boolean),
}), }),
]), ]),
@ -30,8 +32,11 @@ export const ReplyAction = automodAction({
async apply({ pluginData, contexts, actionConfig, ruleName }) { async apply({ pluginData, contexts, actionConfig, ruleName }) {
const contextsWithTextChannels = contexts const contextsWithTextChannels = contexts
.filter(c => c.message?.channel_id) .filter((c) => c.message?.channel_id)
.filter(c => pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake) instanceof TextChannel); .filter((c) => {
const channel = pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake);
return channel instanceof TextChannel || channel instanceof ThreadChannel;
});
const contextsByChannelId = contextsWithTextChannels.reduce((map: Map<string, AutomodContext[]>, context) => { const contextsByChannelId = contextsWithTextChannels.reduce((map: Map<string, AutomodContext[]>, context) => {
if (!map.has(context.message!.channel_id)) { if (!map.has(context.message!.channel_id)) {
@ -43,13 +48,17 @@ export const ReplyAction = automodAction({
}, new Map()); }, new Map());
for (const [channelId, _contexts] of contextsByChannelId.entries()) { for (const [channelId, _contexts] of contextsByChannelId.entries()) {
const users = unique(Array.from(new Set(_contexts.map(c => c.user).filter(Boolean)))) as User[]; const users = unique(Array.from(new Set(_contexts.map((c) => c.user).filter(Boolean)))) as User[];
const user = users[0]; const user = users[0];
const renderReplyText = async str => const renderReplyText = async (str: string) =>
renderTemplate(str, { renderTemplate(
user: userToConfigAccessibleUser(user), str,
}); new TemplateSafeValueContainer({
user: userToTemplateSafeUser(user),
}),
);
const formatted = const formatted =
typeof actionConfig === "string" typeof actionConfig === "string"
? await renderReplyText(actionConfig) ? await renderReplyText(actionConfig)
@ -65,7 +74,7 @@ export const ReplyAction = automodAction({
Permissions.FLAGS.SEND_MESSAGES | Permissions.FLAGS.VIEW_CHANNEL, Permissions.FLAGS.SEND_MESSAGES | Permissions.FLAGS.VIEW_CHANNEL,
) )
) { ) {
pluginData.state.logs.log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``, body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``,
}); });
continue; continue;
@ -76,7 +85,7 @@ export const ReplyAction = automodAction({
typeof formatted !== "string" && typeof formatted !== "string" &&
!hasDiscordPermissions(channel.permissionsFor(pluginData.client.user!.id), Permissions.FLAGS.EMBED_LINKS) !hasDiscordPermissions(channel.permissionsFor(pluginData.client.user!.id), Permissions.FLAGS.EMBED_LINKS)
) { ) {
pluginData.state.logs.log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Missing permissions to reply **with an embed** in ${verboseChannelMention( body: `Missing permissions to reply **with an embed** in ${verboseChannelMention(
channel, channel,
)} in Automod rule \`${ruleName}\``, )} in Automod rule \`${ruleName}\``,
@ -84,17 +93,27 @@ export const ReplyAction = automodAction({
continue; continue;
} }
const messageContent: MessageOptions = typeof formatted === "string" ? { content: formatted } : formatted; const messageContent = validateAndParseMessageContent(formatted);
const replyMsg = await channel.send({
const messageOpts: MessageOptions = {
...messageContent, ...messageContent,
allowedMentions: { allowedMentions: {
users: [user.id], users: [user.id],
}, },
}); };
if (typeof actionConfig !== "string" && actionConfig.inline) {
messageOpts.reply = {
failIfNotExists: false,
messageReference: _contexts[0].message!.id,
};
}
const replyMsg = await channel.send(messageOpts);
if (typeof actionConfig === "object" && actionConfig.auto_delete) { if (typeof actionConfig === "object" && actionConfig.auto_delete) {
const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!; const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!;
setTimeout(() => replyMsg.delete().catch(noop), delay); setTimeout(() => !replyMsg.deleted && replyMsg.delete().catch(noop), delay);
} }
} }
} }

View file

@ -2,6 +2,7 @@ import * as t from "io-ts";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { CountersPlugin } from "../../Counters/CountersPlugin"; import { CountersPlugin } from "../../Counters/CountersPlugin";
import { automodAction } from "../helpers"; import { automodAction } from "../helpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const SetCounterAction = automodAction({ export const SetCounterAction = automodAction({
configType: t.type({ configType: t.type({
@ -14,7 +15,7 @@ export const SetCounterAction = automodAction({
async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) { async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) {
const countersPlugin = pluginData.getPlugin(CountersPlugin); const countersPlugin = pluginData.getPlugin(CountersPlugin);
if (!countersPlugin.counterExists(actionConfig.counter)) { if (!countersPlugin.counterExists(actionConfig.counter)) {
pluginData.state.logs.log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``, body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``,
}); });
return; return;

View file

@ -4,6 +4,7 @@ import { ChannelTypeStrings } from "src/types";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils"; import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils";
import { automodAction } from "../helpers"; import { automodAction } from "../helpers";
import { LogsPlugin } from "../../Logs/LogsPlugin";
export const SetSlowmodeAction = automodAction({ export const SetSlowmodeAction = automodAction({
configType: t.type({ configType: t.type({
@ -53,7 +54,7 @@ export const SetSlowmodeAction = automodAction({
? `Duration is greater than maximum native slowmode duration` ? `Duration is greater than maximum native slowmode duration`
: e.message; : e.message;
pluginData.state.logs.log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).logBotAlert({
body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`, body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`,
}); });
} }

View file

@ -31,8 +31,8 @@ export const WarnAction = automodAction({
hide: Boolean(actionConfig.hide_case), hide: Boolean(actionConfig.hide_case),
}; };
const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish)); const userIdsToWarn = unique(contexts.map((c) => c.user?.id).filter(nonNullish));
const membersToWarn = await asyncMap(userIdsToWarn, id => resolveMember(pluginData.client, pluginData.guild, id)); const membersToWarn = await asyncMap(userIdsToWarn, (id) => resolveMember(pluginData.client, pluginData.guild, id));
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToWarn) { for (const member of membersToWarn) {

View file

@ -8,11 +8,15 @@ export const RunAutomodOnMemberUpdate = typedGuildEventListener<AutomodPluginTyp
event: "guildMemberUpdate", event: "guildMemberUpdate",
listener({ pluginData, args: { oldMember, newMember } }) { listener({ pluginData, args: { oldMember, newMember } }) {
if (!oldMember) return; if (!oldMember) return;
if (oldMember.partial) return;
if (isEqual(oldMember.roles, newMember.roles)) return; const oldRoles = [...oldMember.roles.cache.keys()];
const newRoles = [...newMember.roles.cache.keys()];
const addedRoles = diff(newMember.roles, oldMember.roles); if (isEqual(oldRoles, newRoles)) return;
const removedRoles = diff(oldMember.roles, newMember.roles);
const addedRoles = diff(newRoles, oldRoles);
const removedRoles = diff(oldRoles, newRoles);
if (addedRoles.length || removedRoles.length) { if (addedRoles.length || removedRoles.length) {
const context: AutomodContext = { const context: AutomodContext = {

View file

@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
export function clearOldRecentActions(pluginData: GuildPluginData<AutomodPluginType>) { export function clearOldRecentActions(pluginData: GuildPluginData<AutomodPluginType>) {
const now = Date.now(); const now = Date.now();
pluginData.state.recentActions = pluginData.state.recentActions.filter(info => { pluginData.state.recentActions = pluginData.state.recentActions.filter((info) => {
return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now; return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;
}); });
} }

View file

@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types";
export function clearOldRecentSpam(pluginData: GuildPluginData<AutomodPluginType>) { export function clearOldRecentSpam(pluginData: GuildPluginData<AutomodPluginType>) {
const now = Date.now(); const now = Date.now();
pluginData.state.recentSpam = pluginData.state.recentSpam.filter(spam => { pluginData.state.recentSpam = pluginData.state.recentSpam.filter((spam) => {
return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now; return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;
}); });
} }

View file

@ -6,7 +6,7 @@ export function clearRecentActionsForMessage(pluginData: GuildPluginData<Automod
const globalIdentifier = message.user_id; const globalIdentifier = message.user_id;
const perChannelIdentifier = `${message.channel_id}-${message.user_id}`; const perChannelIdentifier = `${message.channel_id}-${message.user_id}`;
pluginData.state.recentActions = pluginData.state.recentActions.filter(act => { pluginData.state.recentActions = pluginData.state.recentActions.filter((act) => {
return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier; return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier;
}); });
} }

View file

@ -60,7 +60,7 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
if (matchedSpam) { if (matchedSpam) {
const messages = matchedSpam.recentActions const messages = matchedSpam.recentActions
.map(action => action.context.message) .map((action) => action.context.message)
.filter(Boolean) .filter(Boolean)
.sort(sorter("posted_at")) as SavedMessage[]; .sort(sorter("posted_at")) as SavedMessage[];
@ -75,8 +75,8 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName:
return { return {
extraContexts: matchedSpam.recentActions extraContexts: matchedSpam.recentActions
.map(action => action.context) .map((action) => action.context)
.filter(_context => _context !== context), .filter((_context) => _context !== context),
extra: { extra: {
archiveId, archiveId,

View file

@ -7,7 +7,7 @@ export function findRecentSpam(
type: RecentActionType, type: RecentActionType,
identifier?: string, identifier?: string,
) { ) {
return pluginData.state.recentSpam.find(spam => { return pluginData.state.recentSpam.find((spam) => {
return spam.type === type && (!identifier || spam.identifiers.includes(identifier)); return spam.type === type && (!identifier || spam.identifiers.includes(identifier));
}); });
} }

View file

@ -11,7 +11,7 @@ export function getMatchingRecentActions(
) { ) {
to = to || Date.now(); to = to || Date.now();
return pluginData.state.recentActions.filter(action => { return pluginData.state.recentActions.filter((action) => {
return ( return (
action.type === type && action.type === type &&
(!identifier || action.identifier === identifier) && (!identifier || action.identifier === identifier) &&

View file

@ -29,6 +29,6 @@ export function getTextMatchPartialSummary(
const visibleName = context.member?.nickname || context.user!.username; const visibleName = context.member?.nickname || context.user!.username;
return `visible name: ${visibleName}`; return `visible name: ${visibleName}`;
} else if (type === "customstatus") { } else if (type === "customstatus") {
return `custom status: ${context.member!.presence?.activities.find(a => a.type === "CUSTOM")?.name}`; return `custom status: ${context.member!.presence?.activities.find((a) => a.type === "CUSTOM")?.name}`;
} }
} }

View file

@ -1,4 +1,4 @@
import { Constants } from "discord.js"; import { Constants, MessageEmbed } from "discord.js";
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { SavedMessage } from "../../../data/entities/SavedMessage"; import { SavedMessage } from "../../../data/entities/SavedMessage";
import { resolveMember } from "../../../utils"; import { resolveMember } from "../../../utils";
@ -32,9 +32,9 @@ export async function* matchMultipleTextTypesOnMessage(
yield ["message", msg.data.content]; yield ["message", msg.data.content];
} }
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) { if (trigger.match_embeds && msg.data.embeds?.length) {
const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0])); const copiedEmbed: MessageEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
if (copiedEmbed.type === "video") { if (copiedEmbed.video) {
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
} }
yield ["embed", JSON.stringify(copiedEmbed)]; yield ["embed", JSON.stringify(copiedEmbed)];

View file

@ -1,4 +1,4 @@
import { Snowflake, TextChannel } from "discord.js"; import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils"; import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
@ -19,7 +19,7 @@ export function resolveActionContactMethods(
} }
const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake); const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake);
if (!(channel instanceof TextChannel)) { if (!(channel instanceof TextChannel || channel instanceof ThreadChannel)) {
throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL); throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);
} }

View file

@ -1,4 +1,4 @@
import { Snowflake, TextChannel } from "discord.js"; import { Snowflake, TextChannel, ThreadChannel } from "discord.js";
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { availableActions } from "../actions/availableActions"; import { availableActions } from "../actions/availableActions";
import { CleanAction } from "../actions/clean"; import { CleanAction } from "../actions/clean";
@ -11,13 +11,25 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
const userId = context.user?.id || context.member?.id || context.message?.user_id; const userId = context.user?.id || context.member?.id || context.message?.user_id;
const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake)); const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake));
const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null; const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null;
const channelId = context.message?.channel_id;
const channel = channelId ? (pluginData.guild.channels.cache.get(channelId as Snowflake) as TextChannel) : null; const channelIdOrThreadId = context.message?.channel_id;
const channelOrThread = channelIdOrThreadId
? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as TextChannel | ThreadChannel)
: null;
const channelId = channelOrThread?.isThread() ? channelOrThread.parent?.id : channelIdOrThreadId;
const threadId = channelOrThread?.isThread() ? channelOrThread.id : null;
const channel = channelOrThread?.isThread() ? channelOrThread.parent : channelOrThread;
const categoryId = channel?.parentId; const categoryId = channel?.parentId;
// Don't apply Automod on Zeppelin itself
if (userId && userId === pluginData.client.user?.id) {
return;
}
const config = await pluginData.config.getMatchingConfig({ const config = await pluginData.config.getMatchingConfig({
channelId, channelId,
categoryId, categoryId,
threadId,
userId, userId,
member, member,
}); });

View file

@ -1,6 +1,6 @@
import { User } from "discord.js"; import { User } from "discord.js";
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel"; import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel";
@ -19,12 +19,12 @@ export async function setAntiraidLevel(
const logs = pluginData.getPlugin(LogsPlugin); const logs = pluginData.getPlugin(LogsPlugin);
if (user) { if (user) {
logs.log(LogType.SET_ANTIRAID_USER, { logs.logSetAntiraidUser({
level: newLevel ?? "off", level: newLevel ?? "off",
user: userToConfigAccessibleUser(user), user,
}); });
} else { } else {
logs.log(LogType.SET_ANTIRAID_AUTO, { logs.logSetAntiraidAuto({
level: newLevel ?? "off", level: newLevel ?? "off",
}); });
} }

View file

@ -11,6 +11,7 @@ import { KickTrigger } from "./kick";
import { LineSpamTrigger } from "./lineSpam"; import { LineSpamTrigger } from "./lineSpam";
import { LinkSpamTrigger } from "./linkSpam"; import { LinkSpamTrigger } from "./linkSpam";
import { MatchAttachmentTypeTrigger } from "./matchAttachmentType"; import { MatchAttachmentTypeTrigger } from "./matchAttachmentType";
import { MatchMimeTypeTrigger } from "./matchMimeType";
import { MatchInvitesTrigger } from "./matchInvites"; import { MatchInvitesTrigger } from "./matchInvites";
import { MatchLinksTrigger } from "./matchLinks"; import { MatchLinksTrigger } from "./matchLinks";
import { MatchRegexTrigger } from "./matchRegex"; import { MatchRegexTrigger } from "./matchRegex";
@ -37,6 +38,7 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
match_invites: MatchInvitesTrigger, match_invites: MatchInvitesTrigger,
match_links: MatchLinksTrigger, match_links: MatchLinksTrigger,
match_attachment_type: MatchAttachmentTypeTrigger, match_attachment_type: MatchAttachmentTypeTrigger,
match_mime_type: MatchMimeTypeTrigger,
member_join: MemberJoinTrigger, member_join: MemberJoinTrigger,
role_added: RoleAddedTrigger, role_added: RoleAddedTrigger,
role_removed: RoleRemovedTrigger, role_removed: RoleRemovedTrigger,
@ -72,6 +74,7 @@ export const AvailableTriggers = t.type({
match_invites: MatchInvitesTrigger.configType, match_invites: MatchInvitesTrigger.configType,
match_links: MatchLinksTrigger.configType, match_links: MatchLinksTrigger.configType,
match_attachment_type: MatchAttachmentTypeTrigger.configType, match_attachment_type: MatchAttachmentTypeTrigger.configType,
match_mime_type: MatchMimeTypeTrigger.configType,
member_join: MemberJoinTrigger.configType, member_join: MemberJoinTrigger.configType,
member_leave: MemberLeaveTrigger.configType, member_leave: MemberLeaveTrigger.configType,
role_added: RoleAddedTrigger.configType, role_added: RoleAddedTrigger.configType,

View file

@ -15,7 +15,7 @@ export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
}, },
async match({ triggerConfig, context }) { async match({ triggerConfig, context }) {
const foundFruit = triggerConfig.allowedFruits.find(fruit => context.message?.data.content === fruit); const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit);
if (foundFruit) { if (foundFruit) {
return { return {
extra: { extra: {

View file

@ -28,17 +28,15 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
return; return;
} }
if (!context.message.data.attachments) return null; if (!context.message.data.attachments) {
const attachments: any[] = context.message.data.attachments; return null;
}
for (const attachment of attachments) { for (const attachment of context.message.data.attachments) {
const attachmentType = attachment.filename const attachmentType = attachment.url.split(".").pop()!.toLowerCase();
.split(".")
.pop()
.toLowerCase();
const blacklist = trigger.blacklist_enabled const blacklist = trigger.blacklist_enabled
? (trigger.filetype_blacklist || []).map(_t => _t.toLowerCase()) ? (trigger.filetype_blacklist || []).map((_t) => _t.toLowerCase())
: null; : null;
if (blacklist && blacklist.includes(attachmentType)) { if (blacklist && blacklist.includes(attachmentType)) {
@ -51,7 +49,7 @@ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
} }
const whitelist = trigger.whitelist_enabled const whitelist = trigger.whitelist_enabled
? (trigger.filetype_whitelist || []).map(_t => _t.toLowerCase()) ? (trigger.filetype_whitelist || []).map((_t) => _t.toLowerCase())
: null; : null;
if (whitelist && !whitelist.includes(attachmentType)) { if (whitelist && !whitelist.includes(attachmentType)) {

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

View file

@ -64,7 +64,7 @@ export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other // When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a // characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
let pattern = trigger.loose_matching let pattern = trigger.loose_matching
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`) ? [...word].map((c) => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
: escapeStringRegexp(word); : escapeStringRegexp(word);
if (trigger.only_full_words) { if (trigger.only_full_words) {

View file

@ -30,7 +30,7 @@ export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
const totalCount = sumRecentActionCounts(matchingActions); const totalCount = sumRecentActionCounts(matchingActions);
if (totalCount >= triggerConfig.amount) { if (totalCount >= triggerConfig.amount) {
const extraContexts = matchingActions.map(a => a.context).filter(c => c !== context); const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context);
pluginData.state.recentSpam.push({ pluginData.state.recentSpam.push({
type: RecentActionType.MemberJoin, type: RecentActionType.MemberJoin,

View file

@ -18,11 +18,19 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd";
import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd"; import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
import { ServersCmd } from "./commands/ServersCmd"; import { ServersCmd } from "./commands/ServersCmd";
import { BotControlPluginType, ConfigSchema } from "./types"; import { BotControlPluginType, ConfigSchema } from "./types";
import { PluginPerformanceCmd } from "./commands/PluginPerformanceCmd";
import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd";
import { ChannelToServerCmd } from "./commands/ChannelToServerCmd";
import { RestPerformanceCmd } from "./commands/RestPerformanceCmd";
import { RateLimitPerformanceCmd } from "./commands/RateLimitPerformanceCmd";
const defaultOptions = { const defaultOptions = {
config: { config: {
can_use: false, can_use: false,
can_eligible: false, can_eligible: false,
can_performance: false,
can_add_server_from_invite: false,
can_list_dashboard_perms: false,
update_cmd: null, update_cmd: null,
}, },
}; };
@ -45,6 +53,11 @@ export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()({
ListDashboardUsersCmd, ListDashboardUsersCmd,
ListDashboardPermsCmd, ListDashboardPermsCmd,
EligibleCmd, EligibleCmd,
PluginPerformanceCmd,
RestPerformanceCmd,
RateLimitPerformanceCmd,
AddServerFromInviteCmd,
ChannelToServerCmd,
], ],
async afterLoad(pluginData) { async afterLoad(pluginData) {

Some files were not shown because too many files have changed in this diff Show more