diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..f0db6e8f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +.github +.idea +node_modules +/assets +/debug diff --git a/backend/.prettierignore b/backend/.prettierignore new file mode 100644 index 00000000..9b1c8b13 --- /dev/null +++ b/backend/.prettierignore @@ -0,0 +1 @@ +/dist diff --git a/backend/ormconfig.js b/backend/ormconfig.js index 9b8b311e..c1cad669 100644 --- a/backend/ormconfig.js +++ b/backend/ormconfig.js @@ -1,24 +1,24 @@ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); try { - fs.accessSync(path.resolve(__dirname, 'bot.env')); - require('dotenv').config({ path: path.resolve(__dirname, 'bot.env') }); + fs.accessSync(path.resolve(__dirname, "bot.env")); + require("dotenv").config({ path: path.resolve(__dirname, "bot.env") }); } catch { try { - fs.accessSync(path.resolve(__dirname, 'api.env')); - require('dotenv').config({ path: path.resolve(__dirname, 'api.env') }); + fs.accessSync(path.resolve(__dirname, "api.env")); + require("dotenv").config({ path: path.resolve(__dirname, "api.env") }); } catch { throw new Error("bot.env or api.env required"); } } -const moment = require('moment-timezone'); -moment.tz.setDefault('UTC'); +const moment = require("moment-timezone"); +moment.tz.setDefault("UTC"); -const entities = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/data/entities/*.js')); -const migrations = path.relative(process.cwd(), path.resolve(__dirname, 'dist/backend/src/migrations/*.js')); -const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, 'src/migrations')); +const entities = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/data/entities/*.js")); +const migrations = path.relative(process.cwd(), path.resolve(__dirname, "dist/backend/src/migrations/*.js")); +const migrationsDir = path.relative(process.cwd(), path.resolve(__dirname, "src/migrations")); module.exports = { type: "mysql", @@ -26,26 +26,29 @@ module.exports = { username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, - charset: 'utf8mb4', + charset: "utf8mb4", supportBigNumbers: true, bigNumberStrings: true, dateStrings: true, synchronize: false, connectTimeout: 2000, + logging: ["error", "warn"], + maxQueryExecutionTime: 250, + // Entities entities: [entities], // Pool options extra: { typeCast(field, next) { - if (field.type === 'DATETIME') { + if (field.type === "DATETIME") { const val = field.string(); - return val != null ? moment.utc(val).format('YYYY-MM-DD HH:mm:ss') : null; + return val != null ? moment.utc(val).format("YYYY-MM-DD HH:mm:ss") : null; } return next(); - } + }, }, // Migrations diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e71edda..79e1da28 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,7 +24,7 @@ "humanize-duration": "^3.15.0", "io-ts": "^2.0.0", "js-yaml": "^3.13.1", - "knub": "^30.0.0-beta.39", + "knub": "^30.0.0-beta.45", "knub-command-manager": "^9.1.0", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", @@ -53,7 +53,8 @@ "utf-8-validate": "^5.0.5", "uuid": "^3.3.2", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", - "zlib-sync": "^0.1.7" + "zlib-sync": "^0.1.7", + "zod": "^3.7.2" }, "devDependencies": { "@types/cors": "^2.8.5", @@ -3042,9 +3043,9 @@ } }, "node_modules/knub": { - "version": "30.0.0-beta.39", - "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz", - "integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==", + "version": "30.0.0-beta.45", + "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz", + "integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==", "dependencies": { "discord-api-types": "^0.22.0", "discord.js": "^13.0.1", @@ -5965,6 +5966,14 @@ "dependencies": { "nan": "^2.14.0" } + }, + "node_modules/zod": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz", + "integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -8281,9 +8290,9 @@ } }, "knub": { - "version": "30.0.0-beta.39", - "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.39.tgz", - "integrity": "sha512-L9RYkqh7YcWfw0ZXdGrKEZru/J+mkiyn+8vi1xCvjEdKMPdq4Gov/SG4suajMFhhX3RXdvh8BoE/3gbR2cq4xA==", + "version": "30.0.0-beta.45", + "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.45.tgz", + "integrity": "sha512-r1jtHBYthOn8zjgyILh418/Qnw8f/cUMzz5aky7+T5HLFV0BAiBzeg5TOb0UFMkn8ewIPSy8GTG1x/CIAv3s8Q==", "requires": { "discord-api-types": "^0.22.0", "discord.js": "^13.0.1", @@ -10527,6 +10536,11 @@ "requires": { "nan": "^2.14.0" } + }, + "zod": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.7.2.tgz", + "integrity": "sha512-JhYYcj+TS/a0+3kqxnbmuXMVtA+QkJUPu91beQTo1Y3xA891pHeMPQgVOSu97FdzAd056Yp87lpEi8Xvmd3zhw==" } } } diff --git a/backend/package.json b/backend/package.json index cdd7c8bf..74561e56 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,11 +7,11 @@ "watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"", "watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"", "build": "rimraf dist && tsc", - "start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9229 dist/backend/src/index.js init", - "start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js", + "start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9229 dist/backend/src/index.js init", + "start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/index.js", "watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"", - "start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init", - "start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js", + "start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 --inspect=0.0.0.0:9239 dist/backend/src/api/index.js init", + "start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps --stack-trace-limit=30 dist/backend/src/api/index.js", "watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"", "typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js", "migrate-prod": "npm run typeorm -- migration:run", @@ -39,7 +39,7 @@ "humanize-duration": "^3.15.0", "io-ts": "^2.0.0", "js-yaml": "^3.13.1", - "knub": "^30.0.0-beta.39", + "knub": "^30.0.0-beta.45", "knub-command-manager": "^9.1.0", "last-commit-log": "^2.1.0", "lodash.chunk": "^4.2.0", @@ -68,7 +68,8 @@ "utf-8-validate": "^5.0.5", "uuid": "^3.3.2", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", - "zlib-sync": "^0.1.7" + "zlib-sync": "^0.1.7", + "zod": "^3.7.2" }, "devDependencies": { "@types/cors": "^2.8.5", diff --git a/backend/register-tsconfig-paths.js b/backend/register-tsconfig-paths.js index 2cc33ff1..9f182acf 100644 --- a/backend/register-tsconfig-paths.js +++ b/backend/register-tsconfig-paths.js @@ -6,9 +6,9 @@ * https://github.com/TypeStrong/ts-node/pull/254 */ -const path = require('path'); -const tsconfig = require('./tsconfig.json'); -const tsconfigPaths = require('tsconfig-paths'); +const path = require("path"); +const tsconfig = require("./tsconfig.json"); +const tsconfigPaths = require("tsconfig-paths"); // E.g. ./dist/backend const baseUrl = path.resolve(tsconfig.compilerOptions.outDir, path.basename(__dirname)); diff --git a/backend/src/Queue.ts b/backend/src/Queue.ts index b8445f12..2ca63d2f 100644 --- a/backend/src/Queue.ts +++ b/backend/src/Queue.ts @@ -28,11 +28,11 @@ export class Queue { return this.queue.length + (this.running ? 1 : 0); } - public add(fn: TQueueFunction): Promise { - const promise = new Promise(resolve => { + public add(fn: TQueueFunction): Promise { + const promise = new Promise((resolve) => { this.queue.push(async () => { - await fn(); - resolve(); + const result = await fn(); + resolve(result); }); if (!this.running) this.next(); @@ -50,7 +50,7 @@ export class Queue { } const fn = this.queue.shift()!; - new Promise(resolve => { + new Promise((resolve) => { // Either fn() completes or the timeout is reached void fn().then(resolve); setTimeout(resolve, this._timeout); diff --git a/backend/src/QueuedEventEmitter.ts b/backend/src/QueuedEventEmitter.ts index 3da957d3..2402c491 100644 --- a/backend/src/QueuedEventEmitter.ts +++ b/backend/src/QueuedEventEmitter.ts @@ -42,7 +42,7 @@ export class QueuedEventEmitter { const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])]; let promise: Promise = Promise.resolve(); - listeners.forEach(listener => { + listeners.forEach((listener) => { promise = this.queue.add(listener.bind(null, ...args)); }); diff --git a/backend/src/RegExpRunner.ts b/backend/src/RegExpRunner.ts index 96d39be3..1ac5f47c 100644 --- a/backend/src/RegExpRunner.ts +++ b/backend/src/RegExpRunner.ts @@ -27,7 +27,7 @@ const INITIAL_REGEX_TIMEOUT = 5 * SECONDS; const INITIAL_REGEX_TIMEOUT_DURATION = 30 * SECONDS; const FINAL_REGEX_TIMEOUT = 5 * SECONDS; -const regexTimeoutUpgradePromise = new Promise(resolve => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION)); +const regexTimeoutUpgradePromise = new Promise((resolve) => setTimeout(resolve, INITIAL_REGEX_TIMEOUT_DURATION)); let newWorkerTimeout = INITIAL_REGEX_TIMEOUT; regexTimeoutUpgradePromise.then(() => (newWorkerTimeout = FINAL_REGEX_TIMEOUT)); diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index c891ac63..b59da332 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -33,21 +33,21 @@ function simpleDiscordAPIRequest(bearerToken, path): Promise { Authorization: `Bearer ${bearerToken}`, }, }, - res => { + (res) => { if (res.statusCode !== 200) { reject(new Error(`Discord API error ${res.statusCode}`)); return; } let rawData = ""; - res.on("data", data => (rawData += data)); + res.on("data", (data) => (rawData += data)); res.on("end", () => { resolve(JSON.parse(rawData)); }); }, ); - request.on("error", err => reject(err)); + request.on("error", (err) => reject(err)); }); } @@ -149,7 +149,7 @@ export function initAuth(app: express.Express) { return res.json({ valid: false }); } - res.json({ valid: true }); + res.json({ valid: true, userId }); }); app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { await apiLogins.expireApiKey(req.user!.apiKey); diff --git a/backend/src/api/docs.ts b/backend/src/api/docs.ts index 8dbeb116..29a3dba9 100644 --- a/backend/src/api/docs.ts +++ b/backend/src/api/docs.ts @@ -22,21 +22,21 @@ function formatConfigSchema(schema) { } else if (schema.name.startsWith("Optional<")) { return `Optional<${formatConfigSchema(schema.types[0])}>`; } else { - return schema.types.map(t => formatConfigSchema(t)).join(" | "); + return schema.types.map((t) => formatConfigSchema(t)).join(" | "); } } else if (schema._tag === "IntersectionType") { - return schema.types.map(t => formatConfigSchema(t)).join(" & "); + return schema.types.map((t) => formatConfigSchema(t)).join(" & "); } else { return schema.name; } } export function initDocs(app: express.Express) { - const docsPlugins = guildPlugins.filter(plugin => plugin.showInDocs); + const docsPlugins = guildPlugins.filter((plugin) => plugin.showInDocs); app.get("/docs/plugins", (req: express.Request, res: express.Response) => { res.json( - docsPlugins.map(plugin => { + docsPlugins.map((plugin) => { const thinInfo = plugin.info ? { prettyName: plugin.info.prettyName, legacy: plugin.info.legacy ?? false } : {}; return { name: plugin.name, @@ -56,7 +56,7 @@ export function initDocs(app: express.Express) { const name = plugin.name; const info = plugin.info || {}; - const commands = (plugin.commands || []).map(cmd => ({ + const commands = (plugin.commands || []).map((cmd) => ({ trigger: cmd.trigger, permission: cmd.permission, signature: cmd.signature, diff --git a/backend/src/api/guilds.ts b/backend/src/api/guilds.ts index 6f6a7a85..0706b4be 100644 --- a/backend/src/api/guilds.ts +++ b/backend/src/api/guilds.ts @@ -3,15 +3,21 @@ import express, { Request, Response } from "express"; import { YAMLException } from "js-yaml"; import { validateGuildConfig } from "../configValidator"; import { AllowedGuilds } from "../data/AllowedGuilds"; -import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments"; +import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments"; import { Configs } from "../data/Configs"; import { apiTokenAuthHandlers } from "./auth"; import { hasGuildPermission, requireGuildPermission } from "./permissions"; import { clientError, ok, serverError, unauthorized } from "./responses"; import { loadYamlSafely } from "../utils/loadYamlSafely"; import { ObjectAliasError } from "../utils/validateNoObjectAliases"; +import { isSnowflake } from "../utils"; +import moment from "moment-timezone"; +import { ApiAuditLog } from "../data/ApiAuditLog"; +import { AuditLogEventTypes } from "../data/apiAuditLogTypes"; +import { Queue } from "../Queue"; const apiPermissionAssignments = new ApiPermissionAssignments(); +const auditLog = new ApiAuditLog(); export function initGuildsAPI(app: express.Express) { const allowedGuilds = new AllowedGuilds(); @@ -25,6 +31,14 @@ export function initGuildsAPI(app: express.Express) { res.json(guilds); }); + guildRouter.get( + "/my-permissions", // a + async (req: Request, res: Response) => { + const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId); + res.json(permissions); + }, + ); + guildRouter.get("/:guildId", async (req: Request, res: Response) => { if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) { return unauthorized(res); @@ -101,5 +115,65 @@ export function initGuildsAPI(app: express.Express) { }, ); + const permissionManagementQueue = new Queue(); + guildRouter.post( + "/:guildId/set-target-permissions", + requireGuildPermission(ApiPermissions.ManageAccess), + async (req: Request, res: Response) => { + await permissionManagementQueue.add(async () => { + const { type, targetId, permissions, expiresAt } = req.body; + + if (type !== ApiPermissionTypes.User) { + return clientError(res, "Invalid type"); + } + if (!isSnowflake(targetId)) { + return clientError(res, "Invalid targetId"); + } + const validPermissions = new Set(Object.values(ApiPermissions)); + validPermissions.delete(ApiPermissions.Owner); + if (!Array.isArray(permissions) || permissions.some((p) => !validPermissions.has(p))) { + return clientError(res, "Invalid permissions"); + } + if (expiresAt != null && !moment.utc(expiresAt).isValid()) { + return clientError(res, "Invalid expiresAt"); + } + + const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); + if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) { + return clientError(res, "Can't change owner permissions"); + } + + if (permissions.length === 0) { + await apiPermissionAssignments.removeUser(req.params.guildId, targetId); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + }); + } else { + const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); + if (existing) { + await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + permissions, + expires_at: existing.expires_at, + }); + } else { + await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + permissions, + expires_at: expiresAt, + }); + } + } + + ok(res); + }); + }, + ); + app.use("/guilds", guildRouter); } diff --git a/backend/src/api/start.ts b/backend/src/api/start.ts index 2e3e2c7d..b4eed63b 100644 --- a/backend/src/api/start.ts +++ b/backend/src/api/start.ts @@ -6,6 +6,7 @@ import { initAuth } from "./auth"; import { initDocs } from "./docs"; import { initGuildsAPI } from "./guilds"; import { clientError, error, notFound } from "./responses"; +import { startBackgroundTasks } from "./tasks"; const app = express(); @@ -14,7 +15,11 @@ app.use( origin: process.env.DASHBOARD_URL, }), ); -app.use(express.json()); +app.use( + express.json({ + limit: "10mb", + }), +); initAuth(app); initGuildsAPI(app); @@ -43,3 +48,5 @@ app.use((req, res, next) => { const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000; app.listen(port, "0.0.0.0", () => console.log(`API server listening on port ${port}`)); // tslint:disable-line + +startBackgroundTasks(); diff --git a/backend/src/api/tasks.ts b/backend/src/api/tasks.ts new file mode 100644 index 00000000..6edf218d --- /dev/null +++ b/backend/src/api/tasks.ts @@ -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); +} diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts index 0c45c611..b5543dc4 100644 --- a/backend/src/configValidator.ts +++ b/backend/src/configValidator.ts @@ -36,7 +36,7 @@ export async function validateGuildConfig(config: any): Promise { const plugin = pluginNameToPlugin.get(pluginName)!; try { const mergedOptions = configUtils.mergeConfig(plugin.defaultOptions || {}, pluginOptions); - await plugin.configPreprocessor?.((mergedOptions as unknown) as PluginOptions, true); + await plugin.configPreprocessor?.(mergedOptions as unknown as PluginOptions, true); } catch (err) { if (err instanceof ConfigValidationError || err instanceof StrictValidationError) { return `${pluginName}: ${err.message}`; diff --git a/backend/src/data/AllowedGuilds.ts b/backend/src/data/AllowedGuilds.ts index e3399fea..747dbb87 100644 --- a/backend/src/data/AllowedGuilds.ts +++ b/backend/src/data/AllowedGuilds.ts @@ -2,6 +2,8 @@ import { getRepository, Repository } from "typeorm"; import { ApiPermissionTypes } from "./ApiPermissionAssignments"; import { BaseRepository } from "./BaseRepository"; import { AllowedGuild } from "./entities/AllowedGuild"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../utils"; export class AllowedGuilds extends BaseRepository { private allowedGuilds: Repository; @@ -37,7 +39,10 @@ export class AllowedGuilds extends BaseRepository { } updateInfo(id, name, icon, ownerId) { - return this.allowedGuilds.update({ id }, { name, icon, owner_id: ownerId }); + return this.allowedGuilds.update( + { id }, + { name, icon, owner_id: ownerId, updated_at: moment.utc().format(DBDateFormat) }, + ); } add(id, data: Partial> = {}) { diff --git a/backend/src/data/ApiAuditLog.ts b/backend/src/data/ApiAuditLog.ts new file mode 100644 index 00000000..199747ef --- /dev/null +++ b/backend/src/data/ApiAuditLog.ts @@ -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>; + + constructor() { + super(); + this.auditLog = getRepository(ApiAuditLogEntry); + } + + addEntry( + 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, + }); + } +} diff --git a/backend/src/data/ApiLogins.ts b/backend/src/data/ApiLogins.ts index 23bb7e49..ab8779d0 100644 --- a/backend/src/data/ApiLogins.ts +++ b/backend/src/data/ApiLogins.ts @@ -68,10 +68,7 @@ export class ApiLogins extends BaseRepository { token: hashedToken, user_id: userId, logged_in_at: moment.utc().format(DBDateFormat), - expires_at: moment - .utc() - .add(LOGIN_EXPIRY_TIME, "ms") - .format(DBDateFormat), + expires_at: moment.utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat), }); return `${loginId}.${token}`; @@ -96,10 +93,7 @@ export class ApiLogins extends BaseRepository { await this.apiLogins.update( { id: loginId }, { - expires_at: moment() - .utc() - .add(LOGIN_EXPIRY_TIME, "ms") - .format(DBDateFormat), + expires_at: moment().utc().add(LOGIN_EXPIRY_TIME, "ms").format(DBDateFormat), }, ); } diff --git a/backend/src/data/ApiPermissionAssignments.ts b/backend/src/data/ApiPermissionAssignments.ts index 29686adc..d6cf11db 100644 --- a/backend/src/data/ApiPermissionAssignments.ts +++ b/backend/src/data/ApiPermissionAssignments.ts @@ -2,6 +2,9 @@ import { ApiPermissions } from "@shared/apiPermissions"; import { getRepository, Repository } from "typeorm"; import { BaseRepository } from "./BaseRepository"; import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment"; +import { Permissions } from "discord.js"; +import { ApiAuditLog } from "./ApiAuditLog"; +import { AuditLogEventTypes } from "./apiAuditLogTypes"; export enum ApiPermissionTypes { User = "USER", @@ -10,10 +13,12 @@ export enum ApiPermissionTypes { export class ApiPermissionAssignments extends BaseRepository { private apiPermissions: Repository; + private auditLogs: ApiAuditLog; constructor() { super(); this.apiPermissions = getRepository(ApiPermissionAssignment); + this.auditLogs = new ApiAuditLog(); } getByGuildId(guildId) { @@ -43,16 +48,100 @@ export class ApiPermissionAssignments extends BaseRepository { }); } - addUser(guildId, userId, permissions: ApiPermissions[]) { + addUser(guildId, userId, permissions: ApiPermissions[], expiresAt: string | null = null) { return this.apiPermissions.insert({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId, permissions, + expires_at: expiresAt, }); } removeUser(guildId, userId) { return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId }); } + + async updateUserPermissions(guildId: string, userId: string, permissions: ApiPermissions[]): Promise { + 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], + }); + } + } } diff --git a/backend/src/data/ApiUserInfo.ts b/backend/src/data/ApiUserInfo.ts index 73633d01..b7cd34ae 100644 --- a/backend/src/data/ApiUserInfo.ts +++ b/backend/src/data/ApiUserInfo.ts @@ -22,7 +22,7 @@ export class ApiUserInfo extends BaseRepository { } update(id, data: ApiUserInfoData) { - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const repo = entityManager.getRepository(ApiUserInfoEntity); const existingInfo = await repo.findOne({ where: { id } }); diff --git a/backend/src/data/Configs.ts b/backend/src/data/Configs.ts index 3e0882f0..2b3e4089 100644 --- a/backend/src/data/Configs.ts +++ b/backend/src/data/Configs.ts @@ -41,11 +41,7 @@ export class Configs extends BaseRepository { } getActiveLargerThanId(id) { - return this.configs - .createQueryBuilder() - .where("id > :id", { id }) - .andWhere("is_active = 1") - .getMany(); + return this.configs.createQueryBuilder().where("id > :id", { id }).andWhere("is_active = 1").getMany(); } async hasConfig(key) { @@ -65,7 +61,7 @@ export class Configs extends BaseRepository { } async saveNewRevision(key, config, editedBy) { - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const repo = entityManager.getRepository(Config); // Mark all old revisions inactive await repo.update({ key }, { is_active: false }); diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index c666ff43..d50cfda6 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -13,9 +13,9 @@ "MEMBER_SOFTBAN": "🔨 {userMention(member)} was softbanned by {userMention(mod)}", "MEMBER_JOIN": "📥 {new} {userMention(member)} joined (created {account_age} ago)", "MEMBER_LEAVE": "📤 {userMention(member)} left the server", - "MEMBER_ROLE_ADD": "🔑 {userMention(member)}: role(s) **{roles}** added by {userMention(mod)}", - "MEMBER_ROLE_REMOVE": "🔑 {userMention(member)}: role(s) **{roles}** removed by {userMention(mod)}", - "MEMBER_ROLE_CHANGES": "🔑 {userMention(member)}: roles changed: added **{addedRoles}**, removed **{removedRoles}** by {userMention(mod)}", + "MEMBER_ROLE_ADD": "🔑 {userMention(member)} received roles: **{roles}**", + "MEMBER_ROLE_REMOVE": "🔑 {userMention(member)} lost roles: **{roles}**", + "MEMBER_ROLE_CHANGES": "🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", "MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin", @@ -50,9 +50,9 @@ "STAGE_INSTANCE_DELETE": "📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", "STAGE_INSTANCE_UPDATE": "📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", - "EMOJI_CREATE": "<{emoji.identifier}> Emoji `{emoji.name} ({emoji.id})` was created", - "EMOJI_DELETE": "👋 Emoji `{emoji.name} ({emoji.id})` was deleted", - "EMOJI_UPDATE": "<{newEmoji.identifier}> Emoji `{newEmoji.name} ({newEmoji.id})` was updated. Changes:\n{differenceString}", + "EMOJI_CREATE": "{emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", + "EMOJI_DELETE": "👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", + "EMOJI_UPDATE": "{newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", "STICKER_CREATE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", "STICKER_DELETE": "🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", diff --git a/backend/src/data/GuildArchives.ts b/backend/src/data/GuildArchives.ts index e55ac905..a84817ae 100644 --- a/backend/src/data/GuildArchives.ts +++ b/backend/src/data/GuildArchives.ts @@ -1,12 +1,17 @@ -import { Guild, Snowflake } from "discord.js"; +import { Guild, Snowflake, User } from "discord.js"; import moment from "moment-timezone"; import { isDefaultSticker } from "src/utils/isDefaultSticker"; import { getRepository, Repository } from "typeorm"; -import { renderTemplate } from "../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../templateFormatter"; import { trimLines } from "../utils"; import { BaseGuildRepository } from "./BaseGuildRepository"; import { ArchiveEntry } from "./entities/ArchiveEntry"; import { SavedMessage } from "./entities/SavedMessage"; +import { + channelToTemplateSafeChannel, + guildToTemplateSafeGuild, + userToTemplateSafeUser, +} from "../utils/templateSafeObjects"; const DEFAULT_EXPIRY_DAYS = 30; @@ -75,21 +80,25 @@ export class GuildArchives extends BaseGuildRepository { const msgLines: string[] = []; for (const msg of savedMessages) { const channel = guild.channels.cache.get(msg.channel_id as Snowflake); - const user = { ...msg.data.author, id: msg.user_id }; + const partialUser = new TemplateSafeValueContainer({ ...msg.data.author, id: msg.user_id }); - const line = await renderTemplate(MESSAGE_ARCHIVE_MESSAGE_FORMAT, { - id: msg.id, - timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"), - content: msg.data.content, - attachments: msg.data.attachments?.map(att => { - return JSON.stringify({ name: att.name, url: att.url, type: att.contentType }); + const line = await renderTemplate( + MESSAGE_ARCHIVE_MESSAGE_FORMAT, + new TemplateSafeValueContainer({ + id: msg.id, + timestamp: moment.utc(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"), + content: msg.data.content, + attachments: msg.data.attachments?.map((att) => { + return JSON.stringify({ name: att.name, url: att.url, type: att.contentType }); + }), + stickers: msg.data.stickers?.map((sti) => { + return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) }); + }), + user: partialUser, + channel: channel ? channelToTemplateSafeChannel(channel) : null, }), - stickers: msg.data.stickers?.map(sti => { - return JSON.stringify({ name: sti.name, id: sti.id, isDefault: isDefaultSticker(sti.id) }); - }), - user, - channel, - }); + ); + msgLines.push(line); } return msgLines; @@ -100,7 +109,12 @@ export class GuildArchives extends BaseGuildRepository { expiresAt = moment.utc().add(DEFAULT_EXPIRY_DAYS, "days"); } - const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild }); + const headerStr = await renderTemplate( + MESSAGE_ARCHIVE_HEADER_FORMAT, + new TemplateSafeValueContainer({ + guild: guildToTemplateSafeGuild(guild), + }), + ); const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild); const messagesStr = msgLines.join("\n"); diff --git a/backend/src/data/GuildCases.ts b/backend/src/data/GuildCases.ts index 349b1ce1..b13b7c4a 100644 --- a/backend/src/data/GuildCases.ts +++ b/backend/src/data/GuildCases.ts @@ -1,4 +1,4 @@ -import { getRepository, In, Repository } from "typeorm"; +import { getRepository, In, InsertResult, Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository"; import { CaseTypes } from "./CaseTypes"; import { connection } from "./db"; @@ -116,13 +116,30 @@ export class GuildCases extends BaseGuildRepository { ); } - async create(data): Promise { - const result = await this.cases.insert({ - ...data, - guild_id: this.guildId, - case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`, - }); + async createInternal(data): Promise { + return this.cases + .insert({ + ...data, + guild_id: this.guildId, + case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})`, + }) + .catch((err) => { + if (err?.code === "ER_DUP_ENTRY") { + if (data.audit_log_id) { + console.trace(`Tried to insert case with duplicate audit_log_id`); + return this.createInternal({ + ...data, + audit_log_id: undefined, + }); + } + } + throw err; + }); + } + + async create(data): Promise { + const result = await this.createInternal(data); return (await this.find(result.identifiers[0].id))!; } @@ -131,7 +148,7 @@ export class GuildCases extends BaseGuildRepository { } async softDelete(id: number, deletedById: string, deletedByName: string, deletedByText: string) { - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const cases = entityManager.getRepository(Case); const caseNotes = entityManager.getRepository(CaseNote); diff --git a/backend/src/data/GuildCounters.ts b/backend/src/data/GuildCounters.ts index 0a1ff57e..52f20293 100644 --- a/backend/src/data/GuildCounters.ts +++ b/backend/src/data/GuildCounters.ts @@ -17,19 +17,11 @@ const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT const decayQueue = new Queue(); async function deleteCountersMarkedToBeDeleted(): Promise { - await getRepository(Counter) - .createQueryBuilder() - .where("delete_at <= NOW()") - .delete() - .execute(); + await getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async function deleteTriggersMarkedToBeDeleted(): Promise { - await getRepository(CounterTrigger) - .createQueryBuilder() - .where("delete_at <= NOW()") - .delete() - .execute(); + await getRepository(CounterTrigger).createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS); @@ -97,10 +89,7 @@ export class GuildCounters extends BaseGuildRepository { criteria.id = Not(In(idsToKeep)); } - const deleteAt = moment - .utc() - .add(DELETE_UNUSED_COUNTERS_AFTER, "ms") - .format(DBDateFormat); + const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, "ms").format(DBDateFormat); await this.counters.update(criteria, { delete_at: deleteAt, @@ -108,11 +97,7 @@ export class GuildCounters extends BaseGuildRepository { } async deleteCountersMarkedToBeDeleted(): Promise { - await this.counters - .createQueryBuilder() - .where("delete_at <= NOW()") - .delete() - .execute(); + await this.counters.createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async changeCounterValue( @@ -230,14 +215,11 @@ export class GuildCounters extends BaseGuildRepository { const triggersToMark = await triggersToMarkQuery.getMany(); if (triggersToMark.length) { - const deleteAt = moment - .utc() - .add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms") - .format(DBDateFormat); + const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms").format(DBDateFormat); await this.counterTriggers.update( { - id: In(triggersToMark.map(t => t.id)), + id: In(triggersToMark.map((t) => t.id)), }, { delete_at: deleteAt, @@ -247,11 +229,7 @@ export class GuildCounters extends BaseGuildRepository { } async deleteTriggersMarkedToBeDeleted(): Promise { - await this.counterTriggers - .createQueryBuilder() - .where("delete_at <= NOW()") - .delete() - .execute(); + await this.counterTriggers.createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async initCounterTrigger( @@ -278,7 +256,7 @@ export class GuildCounters extends BaseGuildRepository { throw new Error(`Invalid comparison value: ${reverseComparisonValue}`); } - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const existing = await entityManager.findOne(CounterTrigger, { counter_id: counterId, name: triggerName, @@ -330,7 +308,7 @@ export class GuildCounters extends BaseGuildRepository { channelId = channelId || "0"; userId = userId || "0"; - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const previouslyTriggered = await entityManager.findOne(CounterTriggerState, { trigger_id: counterTrigger.id, user_id: userId!, @@ -378,7 +356,7 @@ export class GuildCounters extends BaseGuildRepository { async checkAllValuesForTrigger( counterTrigger: CounterTrigger, ): Promise> { - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const matchingValues = await entityManager .createQueryBuilder(CounterValue, "cv") .leftJoin( @@ -395,7 +373,7 @@ export class GuildCounters extends BaseGuildRepository { if (matchingValues.length) { await entityManager.insert( CounterTriggerState, - matchingValues.map(row => ({ + matchingValues.map((row) => ({ trigger_id: counterTrigger.id, channel_id: row.channel_id, user_id: row.user_id, @@ -403,7 +381,7 @@ export class GuildCounters extends BaseGuildRepository { ); } - return matchingValues.map(row => ({ + return matchingValues.map((row) => ({ channelId: row.channel_id, userId: row.user_id, })); @@ -429,7 +407,7 @@ export class GuildCounters extends BaseGuildRepository { channelId = channelId || "0"; userId = userId || "0"; - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const matchingValue = await entityManager .createQueryBuilder(CounterValue, "cv") .innerJoin( @@ -468,7 +446,7 @@ export class GuildCounters extends BaseGuildRepository { async checkAllValuesForReverseTrigger( counterTrigger: CounterTrigger, ): Promise> { - return connection.transaction(async entityManager => { + return connection.transaction(async (entityManager) => { const matchingValues: Array<{ id: string; triggerStateId: string; @@ -496,11 +474,11 @@ export class GuildCounters extends BaseGuildRepository { if (matchingValues.length) { await entityManager.delete(CounterTriggerState, { - id: In(matchingValues.map(v => v.triggerStateId)), + id: In(matchingValues.map((v) => v.triggerStateId)), }); } - return matchingValues.map(row => ({ + return matchingValues.map((row) => ({ channelId: row.channel_id, userId: row.user_id, })); diff --git a/backend/src/data/GuildEvents.ts b/backend/src/data/GuildEvents.ts index ead809ab..72c6177c 100644 --- a/backend/src/data/GuildEvents.ts +++ b/backend/src/data/GuildEvents.ts @@ -1,42 +1,54 @@ -import { QueuedEventEmitter } from "../QueuedEventEmitter"; -import { BaseGuildRepository } from "./BaseGuildRepository"; +import { Mute } from "./entities/Mute"; +import { ScheduledPost } from "./entities/ScheduledPost"; +import { Reminder } from "./entities/Reminder"; -export class GuildEvents extends BaseGuildRepository { - private queuedEventEmitter: QueuedEventEmitter; - private pluginListeners: Map>; +interface GuildEventArgs extends Record { + expiredMutes: [Mute[]]; + scheduledPosts: [ScheduledPost[]]; + reminders: [Reminder[]]; +} - constructor(guildId) { - super(guildId); - this.queuedEventEmitter = new QueuedEventEmitter(); +type GuildEvent = keyof GuildEventArgs; + +type GuildEventListener = (...args: GuildEventArgs[K]) => void; + +type ListenerMap = { + [K in GuildEvent]?: Array>; +}; + +const guildListeners: Map = new Map(); + +/** + * @return - Function to unregister the listener + */ +export function onGuildEvent( + guildId: string, + eventName: K, + listener: GuildEventListener, +): () => void { + if (!guildListeners.has(guildId)) { + guildListeners.set(guildId, {}); } - - public on(pluginName: string, eventName: string, fn) { - this.queuedEventEmitter.on(eventName, fn); - - if (!this.pluginListeners.has(pluginName)) { - this.pluginListeners.set(pluginName, new Map()); - } - - const pluginListeners = this.pluginListeners.get(pluginName)!; - if (!pluginListeners.has(eventName)) { - pluginListeners.set(eventName, []); - } - - const pluginEventListeners = pluginListeners.get(eventName)!; - pluginEventListeners.push(fn); + const listenerMap = guildListeners.get(guildId)!; + if (listenerMap[eventName] == null) { + listenerMap[eventName] = []; } + listenerMap[eventName]!.push(listener); - public offPlugin(pluginName: string) { - const pluginListeners = this.pluginListeners.get(pluginName) || new Map(); - for (const [eventName, listeners] of Array.from(pluginListeners.entries())) { - for (const listener of listeners) { - this.queuedEventEmitter.off(eventName, listener); - } - } - this.pluginListeners.delete(pluginName); + return () => { + listenerMap[eventName]!.splice(listenerMap[eventName]!.indexOf(listener), 1); + }; +} + +export function emitGuildEvent(guildId: string, eventName: K, args: GuildEventArgs[K]): void { + if (!guildListeners.has(guildId)) { + return; } - - public emit(eventName: string, args: any[] = []) { - return this.queuedEventEmitter.emit(eventName, args); + const listenerMap = guildListeners.get(guildId)!; + if (listenerMap[eventName] == null) { + return; + } + for (const listener of listenerMap[eventName]!) { + listener(...args); } } diff --git a/backend/src/data/GuildLogs.ts b/backend/src/data/GuildLogs.ts index 457beec6..b324984e 100644 --- a/backend/src/data/GuildLogs.ts +++ b/backend/src/data/GuildLogs.ts @@ -46,12 +46,12 @@ export class GuildLogs extends events.EventEmitter { } isLogIgnored(type: LogType, ignoreId: any) { - return this.ignoredLogs.some(info => type === info.type && ignoreId === info.ignoreId); + return this.ignoredLogs.some((info) => type === info.type && ignoreId === info.ignoreId); } clearIgnoredLog(type: LogType, ignoreId: any) { this.ignoredLogs.splice( - this.ignoredLogs.findIndex(info => type === info.type && ignoreId === info.ignoreId), + this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId), 1, ); } diff --git a/backend/src/data/GuildMemberTimezones.ts b/backend/src/data/GuildMemberTimezones.ts index 6c4ef82b..3f98b9ef 100644 --- a/backend/src/data/GuildMemberTimezones.ts +++ b/backend/src/data/GuildMemberTimezones.ts @@ -19,7 +19,7 @@ export class GuildMemberTimezones extends BaseGuildRepository { } async set(memberId, timezone: string) { - await connection.transaction(async entityManager => { + await connection.transaction(async (entityManager) => { const repo = entityManager.getRepository(MemberTimezone); const existingRow = await repo.findOne({ guild_id: this.guildId, diff --git a/backend/src/data/GuildMutes.ts b/backend/src/data/GuildMutes.ts index 35f86fc0..f95571a0 100644 --- a/backend/src/data/GuildMutes.ts +++ b/backend/src/data/GuildMutes.ts @@ -35,12 +35,7 @@ export class GuildMutes extends BaseGuildRepository { } async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise { - const expiresAt = expiryTime - ? moment - .utc() - .add(expiryTime, "ms") - .format("YYYY-MM-DD HH:mm:ss") - : null; + const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null; const result = await this.mutes.insert({ guild_id: this.guildId, @@ -53,12 +48,7 @@ export class GuildMutes extends BaseGuildRepository { } async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) { - const expiresAt = newExpiryTime - ? moment - .utc() - .add(newExpiryTime, "ms") - .format("YYYY-MM-DD HH:mm:ss") - : null; + const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null; if (rolesToRestore && rolesToRestore.length) { return this.mutes.update( @@ -89,7 +79,7 @@ export class GuildMutes extends BaseGuildRepository { .createQueryBuilder("mutes") .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere( - new Brackets(qb => { + new Brackets((qb) => { qb.where("expires_at > NOW()").orWhere("expires_at IS NULL"); }), ) diff --git a/backend/src/data/GuildNicknameHistory.ts b/backend/src/data/GuildNicknameHistory.ts index 51ed863c..9f75d24e 100644 --- a/backend/src/data/GuildNicknameHistory.ts +++ b/backend/src/data/GuildNicknameHistory.ts @@ -70,7 +70,7 @@ export class GuildNicknameHistory extends BaseGuildRepository { if (toDelete.length > 0) { await this.nicknameHistory.delete({ - id: In(toDelete.map(v => v.id)), + id: In(toDelete.map((v) => v.id)), }); } } diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index ff4daf7f..6d1e29c5 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -45,9 +45,90 @@ export class GuildSavedMessages extends BaseGuildRepository { timestamp: msg.createdTimestamp, }; - if (msg.attachments.size) data.attachments = [...msg.attachments.values()]; - if (msg.embeds.length) data.embeds = msg.embeds; - if (msg.stickers?.size) data.stickers = [...msg.stickers.values()]; + if (msg.attachments.size) { + data.attachments = Array.from(msg.attachments.values()).map((att) => ({ + id: att.id, + contentType: att.contentType, + name: att.name, + proxyURL: att.proxyURL, + size: att.size, + spoiler: att.spoiler, + url: att.url, + width: att.width, + })); + } + + if (msg.embeds.length) { + data.embeds = msg.embeds.map((embed) => ({ + title: embed.title, + description: embed.description, + url: embed.url, + timestamp: embed.timestamp, + color: embed.color, + + fields: embed.fields.map((field) => ({ + name: field.name, + value: field.value, + inline: field.inline, + })), + + author: embed.author + ? { + name: embed.author.name, + url: embed.author.url, + iconURL: embed.author.iconURL, + proxyIconURL: embed.author.proxyIconURL, + } + : undefined, + + thumbnail: embed.thumbnail + ? { + url: embed.thumbnail.url, + proxyURL: embed.thumbnail.proxyURL, + height: embed.thumbnail.height, + width: embed.thumbnail.width, + } + : undefined, + + image: embed.image + ? { + url: embed.image.url, + proxyURL: embed.image.proxyURL, + height: embed.image.height, + width: embed.image.width, + } + : undefined, + + video: embed.video + ? { + url: embed.video.url, + proxyURL: embed.video.proxyURL, + height: embed.video.height, + width: embed.video.width, + } + : undefined, + + footer: embed.footer + ? { + text: embed.footer.text, + iconURL: embed.footer.iconURL, + proxyIconURL: embed.footer.proxyIconURL, + } + : undefined, + })); + } + + if (msg.stickers?.size) { + data.stickers = Array.from(msg.stickers.values()).map((sticker) => ({ + format: sticker.format, + guildId: sticker.guildId, + id: sticker.id, + name: sticker.name, + description: sticker.description, + available: sticker.available, + type: sticker.type, + })); + } return data; } @@ -131,8 +212,12 @@ export class GuildSavedMessages extends BaseGuildRepository { try { await this.messages.insert(data); } catch (e) { - console.warn(e); // tslint:disable-line - return; + if (e?.code === "ER_DUP_ENTRY") { + console.trace(`Tried to insert duplicate message ID: ${data.id}`); + return; + } + + throw e; } const inserted = await this.messages.findOne(data.id); @@ -141,8 +226,10 @@ export class GuildSavedMessages extends BaseGuildRepository { } async createFromMsg(msg: Message, overrides = {}) { - const existingSavedMsg = await this.find(msg.id); - if (existingSavedMsg) return; + // FIXME: Hotfix + if (!msg.channel) { + return; + } const savedMessageData = this.msgToSavedMessageData(msg); const postedAt = moment.utc(msg.createdTimestamp, "x").format("YYYY-MM-DD HH:mm:ss"); diff --git a/backend/src/data/GuildScheduledPosts.ts b/backend/src/data/GuildScheduledPosts.ts index c302b81e..b2f32d54 100644 --- a/backend/src/data/GuildScheduledPosts.ts +++ b/backend/src/data/GuildScheduledPosts.ts @@ -11,10 +11,7 @@ export class GuildScheduledPosts extends BaseGuildRepository { } all(): Promise { - return this.scheduledPosts - .createQueryBuilder() - .where("guild_id = :guildId", { guildId: this.guildId }) - .getMany(); + return this.scheduledPosts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany(); } getDueScheduledPosts(): Promise { diff --git a/backend/src/data/GuildSlowmodes.ts b/backend/src/data/GuildSlowmodes.ts index b6452490..dac92ead 100644 --- a/backend/src/data/GuildSlowmodes.ts +++ b/backend/src/data/GuildSlowmodes.ts @@ -67,10 +67,7 @@ export class GuildSlowmodes extends BaseGuildRepository { const slowmode = await this.getChannelSlowmode(channelId); if (!slowmode) return; - const expiresAt = moment - .utc() - .add(slowmode.slowmode_seconds, "seconds") - .format("YYYY-MM-DD HH:mm:ss"); + const expiresAt = moment.utc().add(slowmode.slowmode_seconds, "seconds").format("YYYY-MM-DD HH:mm:ss"); if (await this.userHasSlowmode(channelId, userId)) { // Update existing diff --git a/backend/src/data/GuildTempbans.ts b/backend/src/data/GuildTempbans.ts index f5f52143..6085a193 100644 --- a/backend/src/data/GuildTempbans.ts +++ b/backend/src/data/GuildTempbans.ts @@ -30,10 +30,7 @@ export class GuildTempbans extends BaseGuildRepository { } async addTempban(userId, expiryTime, modId): Promise { - const expiresAt = moment - .utc() - .add(expiryTime, "ms") - .format("YYYY-MM-DD HH:mm:ss"); + const expiresAt = moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss"); const result = await this.tempbans.insert({ guild_id: this.guildId, @@ -47,10 +44,7 @@ export class GuildTempbans extends BaseGuildRepository { } async updateExpiryTime(userId, newExpiryTime, modId) { - const expiresAt = moment - .utc() - .add(newExpiryTime, "ms") - .format("YYYY-MM-DD HH:mm:ss"); + const expiresAt = moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss"); return this.tempbans.update( { diff --git a/backend/src/data/GuildVCAlerts.ts b/backend/src/data/GuildVCAlerts.ts index 4c64c197..4736204a 100644 --- a/backend/src/data/GuildVCAlerts.ts +++ b/backend/src/data/GuildVCAlerts.ts @@ -19,10 +19,7 @@ export class GuildVCAlerts extends BaseGuildRepository { } async getAllGuildAlerts(): Promise { - return this.allAlerts - .createQueryBuilder() - .where("guild_id = :guildId", { guildId: this.guildId }) - .getMany(); + return this.allAlerts.createQueryBuilder().where("guild_id = :guildId", { guildId: this.guildId }).getMany(); } async getAlertsByUserId(userId: string): Promise { diff --git a/backend/src/data/UsernameHistory.ts b/backend/src/data/UsernameHistory.ts index c17a747c..7aa4331a 100644 --- a/backend/src/data/UsernameHistory.ts +++ b/backend/src/data/UsernameHistory.ts @@ -67,7 +67,7 @@ export class UsernameHistory extends BaseRepository { if (toDelete.length > 0) { await this.usernameHistory.delete({ - id: In(toDelete.map(v => v.id)), + id: In(toDelete.map((v) => v.id)), }); } } diff --git a/backend/src/data/apiAuditLogTypes.ts b/backend/src/data/apiAuditLogTypes.ts new file mode 100644 index 00000000..8e9075af --- /dev/null +++ b/backend/src/data/apiAuditLogTypes.ts @@ -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 { + 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]; diff --git a/backend/src/data/cleanup/configs.ts b/backend/src/data/cleanup/configs.ts index 252b6c6d..1d22ea52 100644 --- a/backend/src/data/cleanup/configs.ts +++ b/backend/src/data/cleanup/configs.ts @@ -13,10 +13,7 @@ export async function cleanupConfigs() { let rows; // >1 month old: 1 config retained per month - const oneMonthCutoff = moment - .utc() - .subtract(30, "days") - .format(DBDateFormat); + const oneMonthCutoff = moment.utc().subtract(30, "days").format(DBDateFormat); do { rows = await connection.query( ` @@ -46,7 +43,7 @@ export async function cleanupConfigs() { if (rows.length > 0) { await configRepository.delete({ - id: In(rows.map(r => r.id)), + id: In(rows.map((r) => r.id)), }); } @@ -54,10 +51,7 @@ export async function cleanupConfigs() { } while (rows.length === CLEAN_PER_LOOP); // >2 weeks old: 1 config retained per day - const twoWeekCutoff = moment - .utc() - .subtract(2, "weeks") - .format(DBDateFormat); + const twoWeekCutoff = moment.utc().subtract(2, "weeks").format(DBDateFormat); do { rows = await connection.query( ` @@ -87,7 +81,7 @@ export async function cleanupConfigs() { if (rows.length > 0) { await configRepository.delete({ - id: In(rows.map(r => r.id)), + id: In(rows.map((r) => r.id)), }); } diff --git a/backend/src/data/cleanup/messages.ts b/backend/src/data/cleanup/messages.ts index 06d55c99..3dd094ff 100644 --- a/backend/src/data/cleanup/messages.ts +++ b/backend/src/data/cleanup/messages.ts @@ -11,61 +11,78 @@ import { SavedMessage } from "../entities/SavedMessage"; const RETENTION_PERIOD = 1 * DAYS; const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES; -const CLEAN_PER_LOOP = 500; +const CLEAN_PER_LOOP = 200; export async function cleanupMessages(): Promise { let cleaned = 0; const messagesRepository = getRepository(SavedMessage); - const deletedAtThreshold = moment - .utc() - .subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms") - .format(DBDateFormat); - const postedAtThreshold = moment - .utc() - .subtract(RETENTION_PERIOD, "ms") - .format(DBDateFormat); - const botPostedAtThreshold = moment - .utc() - .subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms") - .format(DBDateFormat); + const deletedAtThreshold = moment.utc().subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat); + const postedAtThreshold = moment.utc().subtract(RETENTION_PERIOD, "ms").format(DBDateFormat); + const botPostedAtThreshold = moment.utc().subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms").format(DBDateFormat); // SELECT + DELETE messages in batches // This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below // when a message was being inserted at the same time - let rows; + let ids: string[]; do { - rows = await connection.query( + const deletedMessageRows = await connection.query( ` SELECT id FROM messages WHERE ( - deleted_at IS NOT NULL - AND deleted_at <= ? - ) - OR ( - posted_at <= ? - AND is_permanent = 0 - ) - OR ( - is_bot = 1 - AND posted_at <= ? - AND is_permanent = 0 - ) + deleted_at IS NOT NULL + AND deleted_at <= ? + ) LIMIT ${CLEAN_PER_LOOP} `, - [deletedAtThreshold, postedAtThreshold, botPostedAtThreshold], + [deletedAtThreshold], ); - if (rows.length > 0) { + const oldPostedRows = await connection.query( + ` + SELECT id + FROM messages + WHERE ( + posted_at <= ? + AND is_permanent = 0 + ) + LIMIT ${CLEAN_PER_LOOP} + `, + [postedAtThreshold], + ); + + const oldBotPostedRows = await connection.query( + ` + SELECT id + FROM messages + WHERE ( + is_bot = 1 + AND posted_at <= ? + AND is_permanent = 0 + ) + LIMIT ${CLEAN_PER_LOOP} + `, + [botPostedAtThreshold], + ); + + ids = Array.from( + new Set([ + ...deletedMessageRows.map((r) => r.id), + ...oldPostedRows.map((r) => r.id), + ...oldBotPostedRows.map((r) => r.id), + ]), + ); + + if (ids.length > 0) { await messagesRepository.delete({ - id: In(rows.map(r => r.id)), + id: In(ids), }); } - cleaned += rows.length; - } while (rows.length === CLEAN_PER_LOOP); + cleaned += ids.length; + } while (ids.length > 0); return cleaned; } diff --git a/backend/src/data/cleanup/nicknames.ts b/backend/src/data/cleanup/nicknames.ts index 4907e004..addaa347 100644 --- a/backend/src/data/cleanup/nicknames.ts +++ b/backend/src/data/cleanup/nicknames.ts @@ -11,10 +11,7 @@ export async function cleanupNicknames(): Promise { let cleaned = 0; const nicknameHistoryRepository = getRepository(NicknameHistoryEntry); - const dateThreshold = moment - .utc() - .subtract(NICKNAME_RETENTION_PERIOD, "ms") - .format(DBDateFormat); + const dateThreshold = moment.utc().subtract(NICKNAME_RETENTION_PERIOD, "ms").format(DBDateFormat); // Clean old nicknames (NICKNAME_RETENTION_PERIOD) let rows; @@ -31,7 +28,7 @@ export async function cleanupNicknames(): Promise { if (rows.length > 0) { await nicknameHistoryRepository.delete({ - id: In(rows.map(r => r.id)), + id: In(rows.map((r) => r.id)), }); } diff --git a/backend/src/data/cleanup/usernames.ts b/backend/src/data/cleanup/usernames.ts index eea441d5..63ac79f8 100644 --- a/backend/src/data/cleanup/usernames.ts +++ b/backend/src/data/cleanup/usernames.ts @@ -11,10 +11,7 @@ export async function cleanupUsernames(): Promise { let cleaned = 0; const usernameHistoryRepository = getRepository(UsernameHistoryEntry); - const dateThreshold = moment - .utc() - .subtract(USERNAME_RETENTION_PERIOD, "ms") - .format(DBDateFormat); + const dateThreshold = moment.utc().subtract(USERNAME_RETENTION_PERIOD, "ms").format(DBDateFormat); // Clean old usernames (USERNAME_RETENTION_PERIOD) let rows; @@ -31,7 +28,7 @@ export async function cleanupUsernames(): Promise { if (rows.length > 0) { await usernameHistoryRepository.delete({ - id: In(rows.map(r => r.id)), + id: In(rows.map((r) => r.id)), }); } diff --git a/backend/src/data/entities/AllowedGuild.ts b/backend/src/data/entities/AllowedGuild.ts index 129603ac..2ded9b44 100644 --- a/backend/src/data/entities/AllowedGuild.ts +++ b/backend/src/data/entities/AllowedGuild.ts @@ -14,4 +14,10 @@ export class AllowedGuild { @Column() owner_id: string; + + @Column() + created_at: string; + + @Column() + updated_at: string; } diff --git a/backend/src/data/entities/ApiAuditLogEntry.ts b/backend/src/data/entities/ApiAuditLogEntry.ts new file mode 100644 index 00000000..0491c313 --- /dev/null +++ b/backend/src/data/entities/ApiAuditLogEntry.ts @@ -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 { + @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; +} diff --git a/backend/src/data/entities/ApiLogin.ts b/backend/src/data/entities/ApiLogin.ts index 25f2405a..0cc35f39 100644 --- a/backend/src/data/entities/ApiLogin.ts +++ b/backend/src/data/entities/ApiLogin.ts @@ -19,10 +19,7 @@ export class ApiLogin { @Column() expires_at: string; - @ManyToOne( - type => ApiUserInfo, - userInfo => userInfo.logins, - ) + @ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.logins) @JoinColumn({ name: "user_id" }) userInfo: ApiUserInfo; } diff --git a/backend/src/data/entities/ApiPermissionAssignment.ts b/backend/src/data/entities/ApiPermissionAssignment.ts index fcea7595..0160241f 100644 --- a/backend/src/data/entities/ApiPermissionAssignment.ts +++ b/backend/src/data/entities/ApiPermissionAssignment.ts @@ -1,5 +1,6 @@ import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm"; import { ApiUserInfo } from "./ApiUserInfo"; +import { ApiPermissionTypes } from "../ApiPermissionAssignments"; @Entity("api_permissions") export class ApiPermissionAssignment { @@ -7,9 +8,9 @@ export class ApiPermissionAssignment { @PrimaryColumn() guild_id: string; - @Column() + @Column({ type: String }) @PrimaryColumn() - type: string; + type: ApiPermissionTypes; @Column() @PrimaryColumn() @@ -18,10 +19,10 @@ export class ApiPermissionAssignment { @Column("simple-array") permissions: string[]; - @ManyToOne( - type => ApiUserInfo, - userInfo => userInfo.permissionAssignments, - ) + @Column({ type: String, nullable: true }) + expires_at: string | null; + + @ManyToOne((type) => ApiUserInfo, (userInfo) => userInfo.permissionAssignments) @JoinColumn({ name: "target_id" }) userInfo: ApiUserInfo; } diff --git a/backend/src/data/entities/ApiUserInfo.ts b/backend/src/data/entities/ApiUserInfo.ts index 32c3a1b0..b6146b23 100644 --- a/backend/src/data/entities/ApiUserInfo.ts +++ b/backend/src/data/entities/ApiUserInfo.ts @@ -20,15 +20,9 @@ export class ApiUserInfo { @Column() updated_at: string; - @OneToMany( - type => ApiLogin, - login => login.userInfo, - ) + @OneToMany((type) => ApiLogin, (login) => login.userInfo) logins: ApiLogin[]; - @OneToMany( - type => ApiPermissionAssignment, - p => p.userInfo, - ) + @OneToMany((type) => ApiPermissionAssignment, (p) => p.userInfo) permissionAssignments: ApiPermissionAssignment[]; } diff --git a/backend/src/data/entities/Case.ts b/backend/src/data/entities/Case.ts index f8980b33..84d24618 100644 --- a/backend/src/data/entities/Case.ts +++ b/backend/src/data/entities/Case.ts @@ -35,9 +35,6 @@ export class Case { */ @Column({ type: String, nullable: true }) log_message_id: string | null; - @OneToMany( - type => CaseNote, - note => note.case, - ) + @OneToMany((type) => CaseNote, (note) => note.case) notes: CaseNote[]; } diff --git a/backend/src/data/entities/CaseNote.ts b/backend/src/data/entities/CaseNote.ts index 3f2e8125..f883d79f 100644 --- a/backend/src/data/entities/CaseNote.ts +++ b/backend/src/data/entities/CaseNote.ts @@ -15,10 +15,7 @@ export class CaseNote { @Column() created_at: string; - @ManyToOne( - type => Case, - theCase => theCase.notes, - ) + @ManyToOne((type) => Case, (theCase) => theCase.notes) @JoinColumn({ name: "case_id" }) case: Case; } diff --git a/backend/src/data/entities/Config.ts b/backend/src/data/entities/Config.ts index 8e16514e..d5e391b4 100644 --- a/backend/src/data/entities/Config.ts +++ b/backend/src/data/entities/Config.ts @@ -22,7 +22,7 @@ export class Config { @Column() edited_at: string; - @ManyToOne(type => ApiUserInfo) + @ManyToOne((type) => ApiUserInfo) @JoinColumn({ name: "edited_by" }) userInfo: ApiUserInfo; } diff --git a/backend/src/data/entities/SavedMessage.ts b/backend/src/data/entities/SavedMessage.ts index 77294366..013dba80 100644 --- a/backend/src/data/entities/SavedMessage.ts +++ b/backend/src/data/entities/SavedMessage.ts @@ -1,16 +1,79 @@ -import { MessageAttachment, Sticker } from "discord.js"; +import { Snowflake } from "discord.js"; import { Column, Entity, PrimaryColumn } from "typeorm"; import { createEncryptedJsonTransformer } from "../encryptedJsonTransformer"; +export interface ISavedMessageAttachmentData { + id: Snowflake; + contentType: string | null; + name: string | null; + proxyURL: string; + size: number; + spoiler: boolean; + url: string; + width: number | null; +} + +export interface ISavedMessageEmbedData { + title: string | null; + description: string | null; + url: string | null; + timestamp: number | null; + color: number | null; + fields: Array<{ + name: string; + value: string; + inline: boolean; + }>; + author?: { + name?: string; + url?: string; + iconURL?: string; + proxyIconURL?: string; + }; + thumbnail?: { + url: string; + proxyURL?: string; + height?: number; + width?: number; + }; + image?: { + url: string; + proxyURL?: string; + height?: number; + width?: number; + }; + video?: { + url?: string; + proxyURL?: string; + height?: number; + width?: number; + }; + footer?: { + text?: string; + iconURL?: string; + proxyIconURL?: string; + }; +} + +export interface ISavedMessageStickerData { + format: string; + guildId: Snowflake | null; + id: Snowflake; + name: string; + description: string | null; + available: boolean | null; + type: string | null; +} + export interface ISavedMessageData { - attachments?: MessageAttachment[]; + attachments?: ISavedMessageAttachmentData[]; author: { username: string; discriminator: string; }; content: string; - embeds?: object[]; - stickers?: Sticker[]; + embeds?: ISavedMessageEmbedData[]; + stickers?: ISavedMessageStickerData[]; timestamp: number; } diff --git a/backend/src/data/entities/StarboardMessage.ts b/backend/src/data/entities/StarboardMessage.ts index 405222c1..2d8839e6 100644 --- a/backend/src/data/entities/StarboardMessage.ts +++ b/backend/src/data/entities/StarboardMessage.ts @@ -16,7 +16,7 @@ export class StarboardMessage { @Column() guild_id: string; - @OneToOne(type => SavedMessage) + @OneToOne((type) => SavedMessage) @JoinColumn({ name: "message_id" }) message: SavedMessage; } diff --git a/backend/src/data/entities/StarboardReaction.ts b/backend/src/data/entities/StarboardReaction.ts index 8e04a8fb..c2cb0258 100644 --- a/backend/src/data/entities/StarboardReaction.ts +++ b/backend/src/data/entities/StarboardReaction.ts @@ -16,7 +16,7 @@ export class StarboardReaction { @Column() reactor_id: string; - @OneToOne(type => SavedMessage) + @OneToOne((type) => SavedMessage) @JoinColumn({ name: "message_id" }) message: SavedMessage; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 33043b24..6b552733 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,4 @@ -import { Client, Intents, TextChannel } from "discord.js"; -import yaml from "js-yaml"; +import { Client, Constants, Intents, TextChannel, ThreadChannel } from "discord.js"; import { Knub, PluginError } from "knub"; import { PluginLoadError } from "knub/dist/plugins/PluginLoadError"; // Always use UTC internally @@ -18,8 +17,12 @@ import { RecoverablePluginError } from "./RecoverablePluginError"; import { SimpleError } from "./SimpleError"; import { ZeppelinGlobalConfig, ZeppelinGuildConfig } from "./types"; import { startUptimeCounter } from "./uptime"; -import { errorMessage, isDiscordAPIError, isDiscordHTTPError, successMessage } from "./utils"; +import { errorMessage, isDiscordAPIError, isDiscordHTTPError, SECONDS, successMessage } from "./utils"; import { loadYamlSafely } from "./utils/loadYamlSafely"; +import { DecayingCounter } from "./utils/DecayingCounter"; +import { PluginNotLoadedError } from "knub/dist/plugins/PluginNotLoadedError"; +import { logRestCall } from "./restCallStats"; +import { logRateLimit } from "./rateLimitStats"; if (!process.env.KEY) { // tslint:disable-next-line:no-console @@ -94,6 +97,25 @@ function errorHandler(err) { return; } + // FIXME: Hotfix + if (err.message && err.message.startsWith("Unknown custom override criteria")) { + // console.warn(err.message); + return; + } + + // FIXME: Hotfix + if (err.message && err.message.startsWith("Unknown override criteria")) { + // console.warn(err.message); + return; + } + + if (err instanceof PluginNotLoadedError) { + // We don't want to crash the bot here, although this *should not happen* + // TODO: Proper system for preventing plugin load/unload race conditions + console.error(err); + return; + } + // tslint:disable:no-console console.error(err); @@ -124,8 +146,8 @@ if (process.env.NODE_ENV === "production") { // Verify required Node.js version const REQUIRED_NODE_VERSION = "14.0.0"; -const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10)); -const actualVersionParts = process.versions.node.split(".").map(v => parseInt(v, 10)); +const requiredParts = REQUIRED_NODE_VERSION.split(".").map((v) => parseInt(v, 10)); +const actualVersionParts = process.versions.node.split(".").map((v) => parseInt(v, 10)); for (const [i, part] of actualVersionParts.entries()) { if (part > requiredParts[i]) break; if (part === requiredParts[i]) continue; @@ -136,10 +158,21 @@ moment.tz.setDefault("UTC"); logger.info("Connecting to database"); connect().then(async () => { + const RequestHandler = require("discord.js/src/rest/RequestHandler.js"); + const originalPush = RequestHandler.prototype.push; + // tslint:disable-next-line:only-arrow-functions + RequestHandler.prototype.push = function (...args) { + const request = args[0]; + logRestCall(request.method, request.path); + return originalPush.call(this, ...args); + }; + const client = new Client({ partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE", "REACTION"], - restTimeOffset: 150, + restGlobalRateLimit: 50, + // restTimeOffset: 1000, + // Disable mentions by default allowedMentions: { parse: [], @@ -166,11 +199,31 @@ connect().then(async () => { }); client.setMaxListeners(200); - client.on("rateLimit", rateLimitData => { - logger.info(`[429] ${JSON.stringify(rateLimitData)}`); + client.on(Constants.Events.RATE_LIMIT, (data) => { + // tslint:disable-next-line:no-console + // console.log(`[DEBUG] [RATE_LIMIT] ${JSON.stringify(data)}`); }); - client.on("error", err => { + const safe429DecayInterval = 5 * SECONDS; + const safe429MaxCount = 5; + const safe429Counter = new DecayingCounter(safe429DecayInterval); + client.on(Constants.Events.DEBUG, (errorText) => { + if (!errorText.includes("429")) { + return; + } + + // tslint:disable-next-line:no-console + console.warn(`[DEBUG] [WARN] [429] ${errorText}`); + + const value = safe429Counter.add(1); + if (value > safe429MaxCount) { + // tslint:disable-next-line:no-console + console.error(`Too many 429s (over ${safe429MaxCount} in ${safe429MaxCount * safe429DecayInterval}ms), exiting`); + process.exit(1); + } + }); + + client.on("error", (err) => { errorHandler(new DiscordJSError(err.message, (err as any).code, 0)); }); @@ -198,9 +251,9 @@ connect().then(async () => { } const configuredPlugins = ctx.config.plugins; - const basePluginNames = baseGuildPlugins.map(p => p.name); + const basePluginNames = baseGuildPlugins.map((p) => p.name); - return Array.from(plugins.keys()).filter(pluginName => { + return Array.from(plugins.keys()).filter((pluginName) => { if (basePluginNames.includes(pluginName)) return true; return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false; }); @@ -208,6 +261,13 @@ connect().then(async () => { async getConfig(id) { const key = id === "global" ? "global" : `guild-${id}`; + if (id !== "global") { + const allowedGuild = await allowedGuilds.find(id); + if (!allowedGuild) { + return {}; + } + } + const row = await guildConfigs.getActiveByKey(key); if (row) { try { @@ -239,13 +299,15 @@ connect().then(async () => { }, sendSuccessMessageFn(channel, body) { - const guildId = channel instanceof TextChannel ? channel.guild.id : undefined; + const guildId = + channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined; const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.success_emoji : undefined; channel.send(successMessage(body, emoji)); }, sendErrorMessageFn(channel, body) { - const guildId = channel instanceof TextChannel ? channel.guild.id : undefined; + const guildId = + channel instanceof TextChannel || channel instanceof ThreadChannel ? channel.guild.id : undefined; const emoji = guildId ? bot.getLoadedGuild(guildId)!.config.error_emoji : undefined; channel.send(errorMessage(body, emoji)); }, @@ -256,6 +318,10 @@ connect().then(async () => { startUptimeCounter(); }); + client.on(Constants.Events.RATE_LIMIT, (data) => { + logRateLimit(data); + }); + bot.initialize(); logger.info("Bot Initialized"); logger.info("Logging in..."); diff --git a/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts b/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts index f15417ef..a42c0c7f 100644 --- a/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts +++ b/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts @@ -9,7 +9,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration const migratedUsernames = new Set(); - await new Promise(async resolve => { + await new Promise(async (resolve) => { const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history"); stream.on("data", (row: any) => { migratedUsernames.add(row.key); @@ -18,7 +18,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration }); const migrateNextBatch = (): Promise<{ finished: boolean; migrated?: number }> => { - return new Promise(async resolve => { + return new Promise(async (resolve) => { const toInsert: any[][] = []; const toDelete: number[] = []; diff --git a/backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts b/backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts new file mode 100644 index 00000000..812d35ed --- /dev/null +++ b/backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class AddExpiresAtToApiPermissions1630837386329 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns("api_permissions", [ + new TableColumn({ + name: "expires_at", + type: "datetime", + isNullable: true, + default: null, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("api_permissions", "expires_at"); + } +} diff --git a/backend/src/migrations/1630837718830-CreateApiAuditLogTable.ts b/backend/src/migrations/1630837718830-CreateApiAuditLogTable.ts new file mode 100644 index 00000000..a9f6faea --- /dev/null +++ b/backend/src/migrations/1630837718830-CreateApiAuditLogTable.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; + +export class CreateApiAuditLogTable1630837718830 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropTable("api_audit_log"); + } +} diff --git a/backend/src/migrations/1630840428694-AddTimestampsToAllowedGuilds.ts b/backend/src/migrations/1630840428694-AddTimestampsToAllowedGuilds.ts new file mode 100644 index 00000000..8b29f669 --- /dev/null +++ b/backend/src/migrations/1630840428694-AddTimestampsToAllowedGuilds.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; + +export class AddTimestampsToAllowedGuilds1630840428694 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropColumn("allowed_guilds", "updated_at"); + await queryRunner.dropColumn("allowed_guilds", "created_at"); + } +} diff --git a/backend/src/migrations/1631474131804-AddIndexToIsBot.ts b/backend/src/migrations/1631474131804-AddIndexToIsBot.ts new file mode 100644 index 00000000..db839d97 --- /dev/null +++ b/backend/src/migrations/1631474131804-AddIndexToIsBot.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; + +export class AddIndexToIsBot1631474131804 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createIndex( + "messages", + new TableIndex({ + columnNames: ["is_bot"], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex( + "messages", + new TableIndex({ + columnNames: ["is_bot"], + }), + ); + } +} diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 14dcf85e..f9093ad1 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -62,6 +62,8 @@ const PluginOverrideCriteriaType: t.Type> = t.re const validTopLevelOverrideKeys = [ "channel", "category", + "thread", + "is_thread", "level", "user", "role", @@ -83,7 +85,7 @@ export function strictValidationErrorToConfigValidationError(err: StrictValidati return new ConfigValidationError( err .getErrors() - .map(e => e.toString()) + .map((e) => e.toString()) .join("\n"), ); } @@ -197,7 +199,7 @@ export async function sendSuccessMessage( return channel .send({ ...content }) // Force line break - .catch(err => { + .catch((err) => { const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id; logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); return undefined; @@ -218,7 +220,7 @@ export async function sendErrorMessage( return channel .send({ ...content }) // Force line break - .catch(err => { + .catch((err) => { const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : channel.id; logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); return undefined; @@ -232,7 +234,7 @@ export function getBaseUrl(pluginData: AnyPluginData) { export function isOwner(pluginData: AnyPluginData, userId: string) { const knub = pluginData.getKnubInstance() as TZeppelinKnub; - const owners = knub.getGlobalConfig().owners; + const owners = knub.getGlobalConfig()?.owners; if (!owners) { return false; } @@ -250,7 +252,7 @@ type AnyFn = (...args: any[]) => any; * Creates a public plugin function out of a function with pluginData as the first parameter */ export function mapToPublicFn(inputFn: T) { - return pluginData => { + return (pluginData) => { return (...args: Tail>): ReturnType => { return inputFn(pluginData, ...args); }; diff --git a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts index aa7ffd50..6c8251b8 100644 --- a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts +++ b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts @@ -25,7 +25,7 @@ export const AutoDeletePlugin = zeppelinGuildPlugin()({ configurationGuide: "Maximum deletion delay is currently 5 minutes", }, - dependencies: [TimeAndDatePlugin, LogsPlugin], + dependencies: () => [TimeAndDatePlugin, LogsPlugin], configSchema: ConfigSchema, defaultOptions, @@ -45,13 +45,13 @@ export const AutoDeletePlugin = zeppelinGuildPlugin()({ afterLoad(pluginData) { const { state, guild } = pluginData; - state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.guildSavedMessages.events.on("create", state.onMessageCreateFn); - state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg); state.guildSavedMessages.events.on("delete", state.onMessageDeleteFn); - state.onMessageDeleteBulkFn = msgs => onMessageDeleteBulk(pluginData, msgs); + state.onMessageDeleteBulkFn = (msgs) => onMessageDeleteBulk(pluginData, msgs); state.guildSavedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn); }, diff --git a/backend/src/plugins/AutoDelete/util/deleteNextItem.ts b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts index 332994cf..9ff6a8f0 100644 --- a/backend/src/plugins/AutoDelete/util/deleteNextItem.ts +++ b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts @@ -1,7 +1,7 @@ import { Permissions, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; import { resolveUser, verboseChannelMention } from "../../../utils"; @@ -27,7 +27,7 @@ export async function deleteNextItem(pluginData: GuildPluginData { + (channel as TextChannel).messages.delete(itemToDelete.message.id as Snowflake).catch((err) => { if (err.code === 10008) { // "Unknown Message", probably already deleted by automod or another bot, ignore return; @@ -59,10 +59,10 @@ export async function deleteNextItem(pluginData: GuildPluginData, msg: SavedMessage) { const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); @@ -14,7 +15,7 @@ export async function onMessageCreate(pluginData: GuildPluginData MAX_DELAY) { delay = MAX_DELAY; if (!pluginData.state.maxDelayWarningSent) { - pluginData.state.guildLogs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`, }); pluginData.state.maxDelayWarningSent = true; diff --git a/backend/src/plugins/AutoDelete/util/onMessageDelete.ts b/backend/src/plugins/AutoDelete/util/onMessageDelete.ts index 48b7bb72..984a7850 100644 --- a/backend/src/plugins/AutoDelete/util/onMessageDelete.ts +++ b/backend/src/plugins/AutoDelete/util/onMessageDelete.ts @@ -4,7 +4,7 @@ import { AutoDeletePluginType } from "../types"; import { scheduleNextDeletion } from "./scheduleNextDeletion"; export function onMessageDelete(pluginData: GuildPluginData, msg: SavedMessage) { - const indexToDelete = pluginData.state.deletionQueue.findIndex(item => item.message.id === msg.id); + const indexToDelete = pluginData.state.deletionQueue.findIndex((item) => item.message.id === msg.id); if (indexToDelete > -1) { pluginData.state.deletionQueue.splice(indexToDelete, 1); scheduleNextDeletion(pluginData); diff --git a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts index 886a9922..6633427d 100644 --- a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts +++ b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts @@ -33,7 +33,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin( `), }, - dependencies: [LogsPlugin], + dependencies: () => [LogsPlugin], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/AutoReactions/events/AddReactionsEvt.ts b/backend/src/plugins/AutoReactions/events/AddReactionsEvt.ts index 7f868af1..7649777c 100644 --- a/backend/src/plugins/AutoReactions/events/AddReactionsEvt.ts +++ b/backend/src/plugins/AutoReactions/events/AddReactionsEvt.ts @@ -26,7 +26,7 @@ export const AddReactionsEvt = autoReactionsEvt({ ); if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Cannot apply auto-reactions in <#${message.channel.id}>. ${missingPermissionError(missingPermissions)}`, }); return; @@ -39,11 +39,11 @@ export const AddReactionsEvt = autoReactionsEvt({ if (isDiscordAPIError(e)) { const logs = pluginData.getPlugin(LogsPlugin); if (e.code === 10008) { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Make sure nothing is deleting the message before the reactions are applied.`, }); } else { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Could not apply auto-reactions in <#${message.channel.id}> for message \`${message.id}\`. Error code ${e.code}.`, }); } diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 8170f632..9a9cbc76 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -57,7 +57,7 @@ const defaultOptions = { /** * Config preprocessor to set default values for triggers and perform extra validation */ -const configPreprocessor: ConfigPreprocessorFn = options => { +const configPreprocessor: ConfigPreprocessorFn = (options) => { if (options.config?.rules) { // Loop through each rule for (const [name, rule] of Object.entries(options.config.rules)) { @@ -114,6 +114,21 @@ const configPreprocessor: ConfigPreprocessorFn = 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()({ info: pluginInfo, // prettier-ignore - dependencies: [ + dependencies: () => [ LogsPlugin, ModActionsPlugin, MutesPlugin, @@ -217,10 +232,10 @@ export const AutomodPlugin = zeppelinGuildPlugin()({ 30 * SECONDS, ); - pluginData.state.onMessageCreateFn = message => runAutomodOnMessage(pluginData, message, false); + pluginData.state.onMessageCreateFn = (message) => runAutomodOnMessage(pluginData, message, false); pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn); - pluginData.state.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true); + pluginData.state.onMessageUpdateFn = (message) => runAutomodOnMessage(pluginData, message, true); pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn); const countersPlugin = pluginData.getPlugin(CountersPlugin); diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts index 91a12bca..ee577ae9 100644 --- a/backend/src/plugins/Automod/actions/addRoles.ts +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -17,13 +17,13 @@ export const AddRolesAction = automodAction({ defaultConfig: [], async apply({ pluginData, contexts, actionConfig, ruleName }) { - const members = unique(contexts.map(c => c.member).filter(nonNullish)); + const members = unique(contexts.map((c) => c.member).filter(nonNullish)); const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES); if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, }); return; @@ -41,10 +41,10 @@ export const AddRolesAction = automodAction({ if (rolesWeCannotAssign.length) { const roleNamesWeCannotAssign = rolesWeCannotAssign.map( - roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, + (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, ); const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Unable to assign the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotAssign.join( "**, **", )}**`, @@ -52,7 +52,7 @@ export const AddRolesAction = automodAction({ } await Promise.all( - members.map(async member => { + members.map(async (member) => { const memberRoles = new Set(member.roles.cache.keys()); for (const roleId of rolesToAssign) { memberRoles.add(roleId as Snowflake); diff --git a/backend/src/plugins/Automod/actions/addToCounter.ts b/backend/src/plugins/Automod/actions/addToCounter.ts index 74e1ddc5..04d73f83 100644 --- a/backend/src/plugins/Automod/actions/addToCounter.ts +++ b/backend/src/plugins/Automod/actions/addToCounter.ts @@ -2,6 +2,7 @@ import * as t from "io-ts"; import { LogType } from "../../../data/LogType"; import { CountersPlugin } from "../../Counters/CountersPlugin"; import { automodAction } from "../helpers"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const AddToCounterAction = automodAction({ configType: t.type({ @@ -14,7 +15,7 @@ export const AddToCounterAction = automodAction({ async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) { const countersPlugin = pluginData.getPlugin(CountersPlugin); if (!countersPlugin.counterExists(actionConfig.counter)) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``, }); return; diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts index 206c215c..b765811e 100644 --- a/backend/src/plugins/Automod/actions/alert.ts +++ b/backend/src/plugins/Automod/actions/alert.ts @@ -1,18 +1,27 @@ -import { Snowflake, TextChannel } from "discord.js"; +import { Snowflake, TextChannel, ThreadChannel } from "discord.js"; import * as t from "io-ts"; import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions"; import { LogType } from "../../../data/LogType"; -import { renderTemplate, TemplateParseError } from "../../../templateFormatter"; +import { + createTypedTemplateSafeValueContainer, + renderTemplate, + TemplateParseError, + TemplateSafeValueContainer, +} from "../../../templateFormatter"; import { createChunkedMessage, messageLink, stripObjectToScalars, tAllowedMentions, tNormalizedNullOptional, + isTruthy, verboseChannelMention, + validateAndParseMessageContent, } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; +import { TemplateSafeUser, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { messageIsEmpty } from "../../../utils/messageIsEmpty"; export const AlertAction = automodAction({ configType: t.type({ @@ -27,38 +36,44 @@ export const AlertAction = automodAction({ const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake); const logs = pluginData.getPlugin(LogsPlugin); - if (channel && channel instanceof TextChannel) { + if (channel && (channel instanceof TextChannel || channel instanceof ThreadChannel)) { const text = actionConfig.text; const theMessageLink = contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id); - const safeUsers = contexts.map(c => c.user && stripObjectToScalars(c.user)).filter(Boolean); + const safeUsers = contexts.map((c) => (c.user ? userToTemplateSafeUser(c.user) : null)).filter(isTruthy); const safeUser = safeUsers[0]; const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); - const logMessage = await logs.getLogMessage(LogType.AUTOMOD_ACTION, { - rule: ruleName, - user: safeUser, - users: safeUsers, - actionsTaken, - matchSummary: matchResult.summary, - }); - - let rendered; - try { - rendered = await renderTemplate(actionConfig.text, { + const logMessage = await logs.getLogMessage( + LogType.AUTOMOD_ACTION, + createTypedTemplateSafeValueContainer({ rule: ruleName, user: safeUser, users: safeUsers, - text, actionsTaken, - matchSummary: matchResult.summary, - messageLink: theMessageLink, - logMessage, - }); + matchSummary: matchResult.summary ?? "", + }), + ); + + let rendered; + try { + rendered = await renderTemplate( + actionConfig.text, + new TemplateSafeValueContainer({ + rule: ruleName, + user: safeUser, + users: safeUsers, + text, + actionsTaken, + matchSummary: matchResult.summary, + messageLink: theMessageLink, + logMessage: validateAndParseMessageContent(logMessage)?.content, + }), + ); } catch (err) { if (err instanceof TemplateParseError) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error in alert format of automod rule ${ruleName}: ${err.message}`, }); return; @@ -67,6 +82,13 @@ export const AlertAction = automodAction({ throw err; } + if (messageIsEmpty(rendered)) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Tried to send alert with an empty message for automod rule ${ruleName}`, + }); + return; + } + try { await createChunkedMessage( channel, @@ -75,13 +97,13 @@ export const AlertAction = automodAction({ ); } catch (err) { if (err.code === 50001) { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Missing access to send alert to channel ${verboseChannelMention( channel, )} in automod rule **${ruleName}**`, }); } else { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Error ${err.code || "UNKNOWN"} when sending alert to channel ${verboseChannelMention( channel, )} in automod rule **${ruleName}**`, @@ -89,7 +111,7 @@ export const AlertAction = automodAction({ } } } else { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${ruleName}**`, }); } diff --git a/backend/src/plugins/Automod/actions/archiveThread.ts b/backend/src/plugins/Automod/actions/archiveThread.ts new file mode 100644 index 00000000..6fe871a5 --- /dev/null +++ b/backend/src/plugins/Automod/actions/archiveThread.ts @@ -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); + } + }, +}); diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts index fbdcf8f9..76b2a60e 100644 --- a/backend/src/plugins/Automod/actions/availableActions.ts +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -3,6 +3,7 @@ import { AutomodActionBlueprint } from "../helpers"; import { AddRolesAction } from "./addRoles"; import { AddToCounterAction } from "./addToCounter"; import { AlertAction } from "./alert"; +import { ArchiveThreadAction } from "./archiveThread"; import { BanAction } from "./ban"; import { ChangeNicknameAction } from "./changeNickname"; import { CleanAction } from "./clean"; @@ -32,6 +33,7 @@ export const availableActions: Record> = { add_to_counter: AddToCounterAction, set_counter: SetCounterAction, set_slowmode: SetSlowmodeAction, + archive_thread: ArchiveThreadAction, }; export const AvailableActions = t.type({ @@ -50,4 +52,5 @@ export const AvailableActions = t.type({ add_to_counter: AddToCounterAction.configType, set_counter: SetCounterAction.configType, set_slowmode: SetSlowmodeAction.configType, + archive_thread: ArchiveThreadAction.configType, }); diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts index ff92be3a..e32279a7 100644 --- a/backend/src/plugins/Automod/actions/ban.ts +++ b/backend/src/plugins/Automod/actions/ban.ts @@ -35,7 +35,7 @@ export const BanAction = automodAction({ hide: Boolean(actionConfig.hide_case), }; - const userIdsToBan = unique(contexts.map(c => c.user?.id).filter(nonNullish)); + const userIdsToBan = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); const modActions = pluginData.getPlugin(ModActionsPlugin); for (const userId of userIdsToBan) { diff --git a/backend/src/plugins/Automod/actions/changeNickname.ts b/backend/src/plugins/Automod/actions/changeNickname.ts index d6dee527..0a368355 100644 --- a/backend/src/plugins/Automod/actions/changeNickname.ts +++ b/backend/src/plugins/Automod/actions/changeNickname.ts @@ -15,14 +15,14 @@ export const ChangeNicknameAction = automodAction({ defaultConfig: {}, async apply({ pluginData, contexts, actionConfig }) { - const members = unique(contexts.map(c => c.member).filter(nonNullish)); + const members = unique(contexts.map((c) => c.member).filter(nonNullish)); for (const member of members) { if (pluginData.state.recentNicknameChanges.has(member.id)) continue; const newName = typeof actionConfig === "string" ? actionConfig : actionConfig.name; - member.edit({ nick: newName }).catch(err => { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + member.edit({ nick: newName }).catch((err) => { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to change the nickname of \`${member.id}\``, }); }); diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts index 98408da3..9e6a792d 100644 --- a/backend/src/plugins/Automod/actions/kick.ts +++ b/backend/src/plugins/Automod/actions/kick.ts @@ -31,8 +31,8 @@ export const KickAction = automodAction({ hide: Boolean(actionConfig.hide_case), }; - const userIdsToKick = unique(contexts.map(c => c.user?.id).filter(nonNullish)); - const membersToKick = await asyncMap(userIdsToKick, id => resolveMember(pluginData.client, pluginData.guild, id)); + const userIdsToKick = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); + const membersToKick = await asyncMap(userIdsToKick, (id) => resolveMember(pluginData.client, pluginData.guild, id)); const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToKick) { diff --git a/backend/src/plugins/Automod/actions/log.ts b/backend/src/plugins/Automod/actions/log.ts index 7218ce5f..62f57a20 100644 --- a/backend/src/plugins/Automod/actions/log.ts +++ b/backend/src/plugins/Automod/actions/log.ts @@ -1,26 +1,25 @@ import * as t from "io-ts"; import { LogType } from "../../../data/LogType"; -import { stripObjectToScalars, unique } from "../../../utils"; +import { isTruthy, stripObjectToScalars, unique } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; export const LogAction = automodAction({ configType: t.boolean, defaultConfig: true, async apply({ pluginData, contexts, ruleName, matchResult }) { - const safeUsers = unique(contexts.map(c => c.user)) - .filter(Boolean) - .map(user => stripObjectToScalars(user)); - const safeUser = safeUsers[0]; + const users = unique(contexts.map((c) => c.user)).filter(isTruthy); + const user = users[0]; const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); - pluginData.getPlugin(LogsPlugin).log(LogType.AUTOMOD_ACTION, { + pluginData.getPlugin(LogsPlugin).logAutomodAction({ rule: ruleName, - user: safeUser, - users: safeUsers, + user, + users, actionsTaken, - matchSummary: matchResult.summary, + matchSummary: matchResult.summary ?? "", }); }, }); diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts index 564f65a8..a1bb9299 100644 --- a/backend/src/plugins/Automod/actions/mute.ts +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -40,7 +40,7 @@ export const MuteAction = automodAction({ hide: Boolean(actionConfig.hide_case), }; - const userIdsToMute = unique(contexts.map(c => c.user?.id).filter(nonNullish)); + const userIdsToMute = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); const mutes = pluginData.getPlugin(MutesPlugin); for (const userId of userIdsToMute) { @@ -55,7 +55,7 @@ export const MuteAction = automodAction({ ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to mute <@!${userId}> in Automod rule \`${ruleName}\` because a mute role has not been specified in server config`, }); } else { diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts index 0e125fb7..62b71480 100644 --- a/backend/src/plugins/Automod/actions/removeRoles.ts +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -18,13 +18,13 @@ export const RemoveRolesAction = automodAction({ defaultConfig: [], async apply({ pluginData, contexts, actionConfig, ruleName }) { - const members = unique(contexts.map(c => c.member).filter(nonNullish)); + const members = unique(contexts.map((c) => c.member).filter(nonNullish)); const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingPermissions(me.permissions, p.MANAGE_ROLES); if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, }); return; @@ -42,10 +42,10 @@ export const RemoveRolesAction = automodAction({ if (rolesWeCannotRemove.length) { const roleNamesWeCannotRemove = rolesWeCannotRemove.map( - roleId => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, + (roleId) => pluginData.guild.roles.cache.get(roleId as Snowflake)?.name || roleId, ); const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Unable to remove the following roles in Automod rule **${ruleName}**: **${roleNamesWeCannotRemove.join( "**, **", )}**`, @@ -53,7 +53,7 @@ export const RemoveRolesAction = automodAction({ } await Promise.all( - members.map(async member => { + members.map(async (member) => { const memberRoles = new Set(member.roles.cache.keys()); for (const roleId of rolesToRemove) { memberRoles.delete(roleId as Snowflake); diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts index ad682ee9..efe833cb 100644 --- a/backend/src/plugins/Automod/actions/reply.ts +++ b/backend/src/plugins/Automod/actions/reply.ts @@ -1,8 +1,7 @@ -import { MessageOptions, Permissions, Snowflake, TextChannel, User } from "discord.js"; +import { MessageOptions, Permissions, Snowflake, TextChannel, ThreadChannel, User } from "discord.js"; import * as t from "io-ts"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; -import { LogType } from "../../../data/LogType"; -import { renderTemplate } from "../../../templateFormatter"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { convertDelayStringToMS, noop, @@ -11,11 +10,13 @@ import { tMessageContent, tNullable, unique, + validateAndParseMessageContent, verboseChannelMention, } from "../../../utils"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions"; import { automodAction } from "../helpers"; import { AutomodContext } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const ReplyAction = automodAction({ configType: t.union([ @@ -23,6 +24,7 @@ export const ReplyAction = automodAction({ t.type({ text: tMessageContent, auto_delete: tNullable(t.union([tDelayString, t.number])), + inline: tNullable(t.boolean), }), ]), @@ -30,8 +32,11 @@ export const ReplyAction = automodAction({ async apply({ pluginData, contexts, actionConfig, ruleName }) { const contextsWithTextChannels = contexts - .filter(c => c.message?.channel_id) - .filter(c => pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake) instanceof TextChannel); + .filter((c) => c.message?.channel_id) + .filter((c) => { + const channel = pluginData.guild.channels.cache.get(c.message!.channel_id as Snowflake); + return channel instanceof TextChannel || channel instanceof ThreadChannel; + }); const contextsByChannelId = contextsWithTextChannels.reduce((map: Map, context) => { if (!map.has(context.message!.channel_id)) { @@ -43,13 +48,17 @@ export const ReplyAction = automodAction({ }, new Map()); for (const [channelId, _contexts] of contextsByChannelId.entries()) { - const users = unique(Array.from(new Set(_contexts.map(c => c.user).filter(Boolean)))) as User[]; + const users = unique(Array.from(new Set(_contexts.map((c) => c.user).filter(Boolean)))) as User[]; const user = users[0]; - const renderReplyText = async str => - renderTemplate(str, { - user: userToConfigAccessibleUser(user), - }); + const renderReplyText = async (str: string) => + renderTemplate( + str, + new TemplateSafeValueContainer({ + user: userToTemplateSafeUser(user), + }), + ); + const formatted = typeof actionConfig === "string" ? await renderReplyText(actionConfig) @@ -65,7 +74,7 @@ export const ReplyAction = automodAction({ Permissions.FLAGS.SEND_MESSAGES | Permissions.FLAGS.VIEW_CHANNEL, ) ) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to reply in ${verboseChannelMention(channel)} in Automod rule \`${ruleName}\``, }); continue; @@ -76,7 +85,7 @@ export const ReplyAction = automodAction({ typeof formatted !== "string" && !hasDiscordPermissions(channel.permissionsFor(pluginData.client.user!.id), Permissions.FLAGS.EMBED_LINKS) ) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to reply **with an embed** in ${verboseChannelMention( channel, )} in Automod rule \`${ruleName}\``, @@ -84,17 +93,27 @@ export const ReplyAction = automodAction({ continue; } - const messageContent: MessageOptions = typeof formatted === "string" ? { content: formatted } : formatted; - const replyMsg = await channel.send({ + const messageContent = validateAndParseMessageContent(formatted); + + const messageOpts: MessageOptions = { ...messageContent, allowedMentions: { users: [user.id], }, - }); + }; + + if (typeof actionConfig !== "string" && actionConfig.inline) { + messageOpts.reply = { + failIfNotExists: false, + messageReference: _contexts[0].message!.id, + }; + } + + const replyMsg = await channel.send(messageOpts); if (typeof actionConfig === "object" && actionConfig.auto_delete) { const delay = convertDelayStringToMS(String(actionConfig.auto_delete))!; - setTimeout(() => replyMsg.delete().catch(noop), delay); + setTimeout(() => !replyMsg.deleted && replyMsg.delete().catch(noop), delay); } } } diff --git a/backend/src/plugins/Automod/actions/setCounter.ts b/backend/src/plugins/Automod/actions/setCounter.ts index 150d77f7..5be82cf6 100644 --- a/backend/src/plugins/Automod/actions/setCounter.ts +++ b/backend/src/plugins/Automod/actions/setCounter.ts @@ -2,6 +2,7 @@ import * as t from "io-ts"; import { LogType } from "../../../data/LogType"; import { CountersPlugin } from "../../Counters/CountersPlugin"; import { automodAction } from "../helpers"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const SetCounterAction = automodAction({ configType: t.type({ @@ -14,7 +15,7 @@ export const SetCounterAction = automodAction({ async apply({ pluginData, contexts, actionConfig, matchResult, ruleName }) { const countersPlugin = pluginData.getPlugin(CountersPlugin); if (!countersPlugin.counterExists(actionConfig.counter)) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown counter \`${actionConfig.counter}\` in \`add_to_counter\` action of Automod rule \`${ruleName}\``, }); return; diff --git a/backend/src/plugins/Automod/actions/setSlowmode.ts b/backend/src/plugins/Automod/actions/setSlowmode.ts index d0d286a8..ad333e6d 100644 --- a/backend/src/plugins/Automod/actions/setSlowmode.ts +++ b/backend/src/plugins/Automod/actions/setSlowmode.ts @@ -4,6 +4,7 @@ import { ChannelTypeStrings } from "src/types"; import { LogType } from "../../../data/LogType"; import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils"; import { automodAction } from "../helpers"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const SetSlowmodeAction = automodAction({ configType: t.type({ @@ -53,7 +54,7 @@ export const SetSlowmodeAction = automodAction({ ? `Duration is greater than maximum native slowmode duration` : e.message; - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unable to set slowmode for channel ${channel.id} to ${slowmodeSeconds} seconds: ${errorMessage}`, }); } diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts index 29dd4955..59135cb2 100644 --- a/backend/src/plugins/Automod/actions/warn.ts +++ b/backend/src/plugins/Automod/actions/warn.ts @@ -31,8 +31,8 @@ export const WarnAction = automodAction({ hide: Boolean(actionConfig.hide_case), }; - const userIdsToWarn = unique(contexts.map(c => c.user?.id).filter(nonNullish)); - const membersToWarn = await asyncMap(userIdsToWarn, id => resolveMember(pluginData.client, pluginData.guild, id)); + const userIdsToWarn = unique(contexts.map((c) => c.user?.id).filter(nonNullish)); + const membersToWarn = await asyncMap(userIdsToWarn, (id) => resolveMember(pluginData.client, pluginData.guild, id)); const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToWarn) { diff --git a/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts index 611aadde..86e8ad46 100644 --- a/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts +++ b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts @@ -8,11 +8,15 @@ export const RunAutomodOnMemberUpdate = typedGuildEventListener) { 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; }); } diff --git a/backend/src/plugins/Automod/functions/clearOldRecentSpam.ts b/backend/src/plugins/Automod/functions/clearOldRecentSpam.ts index a0f34c8b..7cda8329 100644 --- a/backend/src/plugins/Automod/functions/clearOldRecentSpam.ts +++ b/backend/src/plugins/Automod/functions/clearOldRecentSpam.ts @@ -4,7 +4,7 @@ import { AutomodPluginType } from "../types"; export function clearOldRecentSpam(pluginData: GuildPluginData) { 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; }); } diff --git a/backend/src/plugins/Automod/functions/clearRecentActionsForMessage.ts b/backend/src/plugins/Automod/functions/clearRecentActionsForMessage.ts index 7aa9750a..e1cadc58 100644 --- a/backend/src/plugins/Automod/functions/clearRecentActionsForMessage.ts +++ b/backend/src/plugins/Automod/functions/clearRecentActionsForMessage.ts @@ -6,7 +6,7 @@ export function clearRecentActionsForMessage(pluginData: GuildPluginData { + pluginData.state.recentActions = pluginData.state.recentActions.filter((act) => { return act.identifier !== globalIdentifier && act.identifier !== perChannelIdentifier; }); } diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts index 009a1f52..1dbd233d 100644 --- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts +++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts @@ -60,7 +60,7 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: if (matchedSpam) { const messages = matchedSpam.recentActions - .map(action => action.context.message) + .map((action) => action.context.message) .filter(Boolean) .sort(sorter("posted_at")) as SavedMessage[]; @@ -75,8 +75,8 @@ export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: return { extraContexts: matchedSpam.recentActions - .map(action => action.context) - .filter(_context => _context !== context), + .map((action) => action.context) + .filter((_context) => _context !== context), extra: { archiveId, diff --git a/backend/src/plugins/Automod/functions/findRecentSpam.ts b/backend/src/plugins/Automod/functions/findRecentSpam.ts index 0e042ed5..07e68d77 100644 --- a/backend/src/plugins/Automod/functions/findRecentSpam.ts +++ b/backend/src/plugins/Automod/functions/findRecentSpam.ts @@ -7,7 +7,7 @@ export function findRecentSpam( type: RecentActionType, identifier?: string, ) { - return pluginData.state.recentSpam.find(spam => { + return pluginData.state.recentSpam.find((spam) => { return spam.type === type && (!identifier || spam.identifiers.includes(identifier)); }); } diff --git a/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts b/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts index 416c050b..75107e45 100644 --- a/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts +++ b/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts @@ -11,7 +11,7 @@ export function getMatchingRecentActions( ) { to = to || Date.now(); - return pluginData.state.recentActions.filter(action => { + return pluginData.state.recentActions.filter((action) => { return ( action.type === type && (!identifier || action.identifier === identifier) && diff --git a/backend/src/plugins/Automod/functions/getTextMatchPartialSummary.ts b/backend/src/plugins/Automod/functions/getTextMatchPartialSummary.ts index fe0466e4..31ece554 100644 --- a/backend/src/plugins/Automod/functions/getTextMatchPartialSummary.ts +++ b/backend/src/plugins/Automod/functions/getTextMatchPartialSummary.ts @@ -29,6 +29,6 @@ export function getTextMatchPartialSummary( const visibleName = context.member?.nickname || context.user!.username; return `visible name: ${visibleName}`; } else if (type === "customstatus") { - return `custom status: ${context.member!.presence?.activities.find(a => a.type === "CUSTOM")?.name}`; + return `custom status: ${context.member!.presence?.activities.find((a) => a.type === "CUSTOM")?.name}`; } } diff --git a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts index 10188f7e..08492589 100644 --- a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts +++ b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts @@ -1,4 +1,4 @@ -import { Constants } from "discord.js"; +import { Constants, MessageEmbed } from "discord.js"; import { GuildPluginData } from "knub"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { resolveMember } from "../../../utils"; @@ -32,9 +32,9 @@ export async function* matchMultipleTextTypesOnMessage( yield ["message", msg.data.content]; } - if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) { - const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0])); - if (copiedEmbed.type === "video") { + if (trigger.match_embeds && msg.data.embeds?.length) { + const copiedEmbed: MessageEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0])); + if (copiedEmbed.video) { copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched } yield ["embed", JSON.stringify(copiedEmbed)]; diff --git a/backend/src/plugins/Automod/functions/resolveActionContactMethods.ts b/backend/src/plugins/Automod/functions/resolveActionContactMethods.ts index 8dca9ee4..5ac49703 100644 --- a/backend/src/plugins/Automod/functions/resolveActionContactMethods.ts +++ b/backend/src/plugins/Automod/functions/resolveActionContactMethods.ts @@ -1,4 +1,4 @@ -import { Snowflake, TextChannel } from "discord.js"; +import { Snowflake, TextChannel, ThreadChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils"; @@ -19,7 +19,7 @@ export function resolveActionContactMethods( } const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake); - if (!(channel instanceof TextChannel)) { + if (!(channel instanceof TextChannel || channel instanceof ThreadChannel)) { throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL); } diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts index 67675565..edc75c13 100644 --- a/backend/src/plugins/Automod/functions/runAutomod.ts +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -1,4 +1,4 @@ -import { Snowflake, TextChannel } from "discord.js"; +import { Snowflake, TextChannel, ThreadChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { availableActions } from "../actions/availableActions"; import { CleanAction } from "../actions/clean"; @@ -11,13 +11,25 @@ export async function runAutomod(pluginData: GuildPluginData, const userId = context.user?.id || context.member?.id || context.message?.user_id; const user = context.user || (userId && pluginData.client.users!.cache.get(userId as Snowflake)); const member = context.member || (userId && pluginData.guild.members.cache.get(userId as Snowflake)) || null; - const channelId = context.message?.channel_id; - const channel = channelId ? (pluginData.guild.channels.cache.get(channelId as Snowflake) as TextChannel) : null; + + const channelIdOrThreadId = context.message?.channel_id; + const channelOrThread = channelIdOrThreadId + ? (pluginData.guild.channels.cache.get(channelIdOrThreadId as Snowflake) as TextChannel | ThreadChannel) + : null; + const channelId = channelOrThread?.isThread() ? channelOrThread.parent?.id : channelIdOrThreadId; + const threadId = channelOrThread?.isThread() ? channelOrThread.id : null; + const channel = channelOrThread?.isThread() ? channelOrThread.parent : channelOrThread; const categoryId = channel?.parentId; + // Don't apply Automod on Zeppelin itself + if (userId && userId === pluginData.client.user?.id) { + return; + } + const config = await pluginData.config.getMatchingConfig({ channelId, categoryId, + threadId, userId, member, }); diff --git a/backend/src/plugins/Automod/functions/setAntiraidLevel.ts b/backend/src/plugins/Automod/functions/setAntiraidLevel.ts index c4da61cd..c77cb414 100644 --- a/backend/src/plugins/Automod/functions/setAntiraidLevel.ts +++ b/backend/src/plugins/Automod/functions/setAntiraidLevel.ts @@ -1,6 +1,6 @@ import { User } from "discord.js"; import { GuildPluginData } from "knub"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { runAutomodOnAntiraidLevel } from "../events/runAutomodOnAntiraidLevel"; @@ -19,12 +19,12 @@ export async function setAntiraidLevel( const logs = pluginData.getPlugin(LogsPlugin); if (user) { - logs.log(LogType.SET_ANTIRAID_USER, { + logs.logSetAntiraidUser({ level: newLevel ?? "off", - user: userToConfigAccessibleUser(user), + user, }); } else { - logs.log(LogType.SET_ANTIRAID_AUTO, { + logs.logSetAntiraidAuto({ level: newLevel ?? "off", }); } diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index e62e811d..22c63cd9 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -11,6 +11,7 @@ import { KickTrigger } from "./kick"; import { LineSpamTrigger } from "./lineSpam"; import { LinkSpamTrigger } from "./linkSpam"; import { MatchAttachmentTypeTrigger } from "./matchAttachmentType"; +import { MatchMimeTypeTrigger } from "./matchMimeType"; import { MatchInvitesTrigger } from "./matchInvites"; import { MatchLinksTrigger } from "./matchLinks"; import { MatchRegexTrigger } from "./matchRegex"; @@ -37,6 +38,7 @@ export const availableTriggers: Record match_invites: MatchInvitesTrigger, match_links: MatchLinksTrigger, match_attachment_type: MatchAttachmentTypeTrigger, + match_mime_type: MatchMimeTypeTrigger, member_join: MemberJoinTrigger, role_added: RoleAddedTrigger, role_removed: RoleRemovedTrigger, @@ -72,6 +74,7 @@ export const AvailableTriggers = t.type({ match_invites: MatchInvitesTrigger.configType, match_links: MatchLinksTrigger.configType, match_attachment_type: MatchAttachmentTypeTrigger.configType, + match_mime_type: MatchMimeTypeTrigger.configType, member_join: MemberJoinTrigger.configType, member_leave: MemberLeaveTrigger.configType, role_added: RoleAddedTrigger.configType, diff --git a/backend/src/plugins/Automod/triggers/exampleTrigger.ts b/backend/src/plugins/Automod/triggers/exampleTrigger.ts index 7098e713..bf0880a9 100644 --- a/backend/src/plugins/Automod/triggers/exampleTrigger.ts +++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts @@ -15,7 +15,7 @@ export const ExampleTrigger = automodTrigger()({ }, async match({ triggerConfig, context }) { - const foundFruit = triggerConfig.allowedFruits.find(fruit => context.message?.data.content === fruit); + const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit); if (foundFruit) { return { extra: { diff --git a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts index ba8e4e59..d905fbc0 100644 --- a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts +++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts @@ -28,17 +28,15 @@ export const MatchAttachmentTypeTrigger = automodTrigger()({ return; } - if (!context.message.data.attachments) return null; - const attachments: any[] = context.message.data.attachments; + if (!context.message.data.attachments) { + return null; + } - for (const attachment of attachments) { - const attachmentType = attachment.filename - .split(".") - .pop() - .toLowerCase(); + for (const attachment of context.message.data.attachments) { + const attachmentType = attachment.url.split(".").pop()!.toLowerCase(); const blacklist = trigger.blacklist_enabled - ? (trigger.filetype_blacklist || []).map(_t => _t.toLowerCase()) + ? (trigger.filetype_blacklist || []).map((_t) => _t.toLowerCase()) : null; if (blacklist && blacklist.includes(attachmentType)) { @@ -51,7 +49,7 @@ export const MatchAttachmentTypeTrigger = automodTrigger()({ } const whitelist = trigger.whitelist_enabled - ? (trigger.filetype_whitelist || []).map(_t => _t.toLowerCase()) + ? (trigger.filetype_whitelist || []).map((_t) => _t.toLowerCase()) : null; if (whitelist && !whitelist.includes(attachmentType)) { diff --git a/backend/src/plugins/Automod/triggers/matchMimeType.ts b/backend/src/plugins/Automod/triggers/matchMimeType.ts new file mode 100644 index 00000000..6e1b447e --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchMimeType.ts @@ -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()({ + 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 || "", + 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!) + ); + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts index 387f2208..fda24726 100644 --- a/backend/src/plugins/Automod/triggers/matchWords.ts +++ b/backend/src/plugins/Automod/triggers/matchWords.ts @@ -64,7 +64,7 @@ export const MatchWordsTrigger = automodTrigger()({ // When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other // characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a let pattern = trigger.loose_matching - ? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`) + ? [...word].map((c) => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`) : escapeStringRegexp(word); if (trigger.only_full_words) { diff --git a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts index 7ab1964a..60ad02a7 100644 --- a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts +++ b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts @@ -30,7 +30,7 @@ export const MemberJoinSpamTrigger = automodTrigger()({ const totalCount = sumRecentActionCounts(matchingActions); if (totalCount >= triggerConfig.amount) { - const extraContexts = matchingActions.map(a => a.context).filter(c => c !== context); + const extraContexts = matchingActions.map((a) => a.context).filter((c) => c !== context); pluginData.state.recentSpam.push({ type: RecentActionType.MemberJoin, diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts index dd53d72c..9c6b0123 100644 --- a/backend/src/plugins/BotControl/BotControlPlugin.ts +++ b/backend/src/plugins/BotControl/BotControlPlugin.ts @@ -18,11 +18,19 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd"; import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd"; import { ServersCmd } from "./commands/ServersCmd"; import { BotControlPluginType, ConfigSchema } from "./types"; +import { PluginPerformanceCmd } from "./commands/PluginPerformanceCmd"; +import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd"; +import { ChannelToServerCmd } from "./commands/ChannelToServerCmd"; +import { RestPerformanceCmd } from "./commands/RestPerformanceCmd"; +import { RateLimitPerformanceCmd } from "./commands/RateLimitPerformanceCmd"; const defaultOptions = { config: { can_use: false, can_eligible: false, + can_performance: false, + can_add_server_from_invite: false, + can_list_dashboard_perms: false, update_cmd: null, }, }; @@ -45,6 +53,11 @@ export const BotControlPlugin = zeppelinGlobalPlugin()({ ListDashboardUsersCmd, ListDashboardPermsCmd, EligibleCmd, + PluginPerformanceCmd, + RestPerformanceCmd, + RateLimitPerformanceCmd, + AddServerFromInviteCmd, + ChannelToServerCmd, ], async afterLoad(pluginData) { diff --git a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts index 84cce432..b417fec6 100644 --- a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts +++ b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts @@ -35,7 +35,7 @@ export const AddDashboardUserCmd = botControlCmd({ await pluginData.state.apiPermissionAssignments.addUser(args.guildId, user.id, [ApiPermissions.EditConfig]); } - const userNameList = args.users.map(user => `<@!${user.id}> (**${user.tag}**, \`${user.id}\`)`); + const userNameList = args.users.map((user) => `<@!${user.id}> (**${user.tag}**, \`${user.id}\`)`); sendSuccessMessage( pluginData, msg.channel as TextChannel, diff --git a/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts b/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts new file mode 100644 index 00000000..385df13b --- /dev/null +++ b/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts @@ -0,0 +1,65 @@ +import { ApiPermissions } from "@shared/apiPermissions"; +import { TextChannel } from "discord.js"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { DBDateFormat, isGuildInvite, isSnowflake, resolveInvite } from "../../../utils"; +import { botControlCmd } from "../types"; +import moment from "moment-timezone"; +import { isEligible } from "../functions/isEligible"; + +export const AddServerFromInviteCmd = botControlCmd({ + trigger: ["add_server_from_invite", "allow_server_from_invite"], + permission: "can_add_server_from_invite", + + signature: { + user: ct.resolvedUser(), + inviteCode: ct.string(), + }, + + async run({ pluginData, message: msg, args }) { + const invite = await resolveInvite(pluginData.client, args.inviteCode, true); + if (!invite || !isGuildInvite(invite)) { + sendErrorMessage(pluginData, msg.channel as TextChannel, "Could not resolve invite"); // :D + return; + } + + const existing = await pluginData.state.allowedGuilds.find(invite.guild.id); + if (existing) { + sendErrorMessage(pluginData, msg.channel as TextChannel, "Server is already allowed!"); + return; + } + + const { result, explanation } = await isEligible(pluginData, args.user, invite); + if (!result) { + sendErrorMessage( + pluginData, + msg.channel as TextChannel, + `Could not add server because it's not eligible: ${explanation}`, + ); + return; + } + + await pluginData.state.allowedGuilds.add(invite.guild.id, { name: invite.guild.name }); + await pluginData.state.configs.saveNewRevision(`guild-${invite.guild.id}`, "plugins: {}", msg.author.id); + + await pluginData.state.apiPermissionAssignments.addUser(invite.guild.id, args.user.id, [ + ApiPermissions.ManageAccess, + ]); + + if (args.user.id !== msg.author.id) { + // Add temporary access to user who added server + await pluginData.state.apiPermissionAssignments.addUser( + invite.guild.id, + msg.author.id, + [ApiPermissions.ManageAccess], + moment.utc().add(1, "hour").format(DBDateFormat), + ); + } + + sendSuccessMessage( + pluginData, + msg.channel as TextChannel, + "Server was eligible and is now allowed to use Zeppelin!", + ); + }, +}); diff --git a/backend/src/plugins/BotControl/commands/AllowServerCmd.ts b/backend/src/plugins/BotControl/commands/AllowServerCmd.ts index 2ff34f6d..9e786b71 100644 --- a/backend/src/plugins/BotControl/commands/AllowServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/AllowServerCmd.ts @@ -2,8 +2,9 @@ import { ApiPermissions } from "@shared/apiPermissions"; import { TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { isSnowflake } from "../../../utils"; +import { DBDateFormat, isSnowflake } from "../../../utils"; import { botControlCmd } from "../types"; +import moment from "moment-timezone"; export const AllowServerCmd = botControlCmd({ trigger: ["allow_server", "allowserver", "add_server", "addserver"], @@ -38,7 +39,17 @@ export const AllowServerCmd = botControlCmd({ await pluginData.state.configs.saveNewRevision(`guild-${args.guildId}`, "plugins: {}", msg.author.id); if (args.userId) { - await pluginData.state.apiPermissionAssignments.addUser(args.guildId, args.userId, [ApiPermissions.EditConfig]); + await pluginData.state.apiPermissionAssignments.addUser(args.guildId, args.userId, [ApiPermissions.ManageAccess]); + } + + if (args.userId !== msg.author.id) { + // Add temporary access to user who added server + await pluginData.state.apiPermissionAssignments.addUser( + args.guildId, + msg.author.id, + [ApiPermissions.ManageAccess], + moment.utc().add(1, "hour").format(DBDateFormat), + ); } sendSuccessMessage(pluginData, msg.channel as TextChannel, "Server is now allowed to use Zeppelin!"); diff --git a/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts b/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts new file mode 100644 index 00000000..5e9dcbb8 --- /dev/null +++ b/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts @@ -0,0 +1,33 @@ +import { Guild, GuildChannel, TextChannel } from "discord.js"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { GuildInvite, isGuildInvite, resolveInvite, verboseUserMention } from "../../../utils"; +import { botControlCmd } from "../types"; +import { isEligible } from "../functions/isEligible"; + +export const ChannelToServerCmd = botControlCmd({ + trigger: ["channel_to_server", "channel2server"], + permission: null, + config: { + preFilters: [isOwnerPreFilter], + }, + + signature: { + channelId: ct.string(), + }, + + async run({ pluginData, message: msg, args }) { + const channel = pluginData.client.channels.cache.get(args.channelId); + if (!channel) { + sendErrorMessage(pluginData, msg.channel as TextChannel, "Channel not found in cache!"); + return; + } + + const channelName = channel.isVoice() ? channel.name : `#${(channel as TextChannel).name}`; + + const guild: Guild | null = (channel as GuildChannel).guild ?? null; + const guildInfo = guild ? `${guild.name} (\`${guild.id}\`)` : "Not a server"; + + msg.channel.send(`**Channel:** ${channelName} (\`${channel.type}\`) (<#${channel.id}>)\n**Server:** ${guildInfo}`); + }, +}); diff --git a/backend/src/plugins/BotControl/commands/EligibleCmd.ts b/backend/src/plugins/BotControl/commands/EligibleCmd.ts index 522cf3a0..0be29978 100644 --- a/backend/src/plugins/BotControl/commands/EligibleCmd.ts +++ b/backend/src/plugins/BotControl/commands/EligibleCmd.ts @@ -1,10 +1,9 @@ -import { TextChannel } from "discord.js"; +import { Guild, TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; -import { resolveInvite, verboseUserMention } from "../../../utils"; +import { GuildInvite, isGuildInvite, resolveInvite, verboseUserMention } from "../../../utils"; import { botControlCmd } from "../types"; - -const REQUIRED_MEMBER_COUNT = 5000; +import { isEligible } from "../functions/isEligible"; export const EligibleCmd = botControlCmd({ trigger: ["eligible", "is_eligible", "iseligible"], @@ -16,45 +15,19 @@ export const EligibleCmd = botControlCmd({ }, async run({ pluginData, message: msg, args }) { - if ((await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id)).length) { - sendSuccessMessage( - pluginData, - msg.channel as TextChannel, - `${verboseUserMention(args.user)} is an existing bot operator. They are eligible!`, - ); - return; - } - const invite = await resolveInvite(pluginData.client, args.inviteCode, true); - if (!invite || !invite.guild) { - sendErrorMessage(pluginData, msg.channel as TextChannel, "Could not resolve server from invite"); + if (!invite || !isGuildInvite(invite)) { + sendErrorMessage(pluginData, msg.channel as TextChannel, "Could not resolve invite"); return; } - if (invite.guild.features.includes("PARTNERED")) { - sendSuccessMessage(pluginData, msg.channel as TextChannel, `Server is partnered. It is eligible!`); + const { result, explanation } = await isEligible(pluginData, args.user, invite); + + if (result) { + sendSuccessMessage(pluginData, msg.channel as TextChannel, `Server is eligible: ${explanation}`); return; } - if (invite.guild.features.includes("VERIFIED")) { - sendSuccessMessage(pluginData, msg.channel as TextChannel, `Server is verified. It is eligible!`); - return; - } - - const memberCount = invite.memberCount || 0; - if (memberCount >= REQUIRED_MEMBER_COUNT) { - sendSuccessMessage( - pluginData, - msg.channel as TextChannel, - `Server has ${memberCount} members, which is equal or higher than the required ${REQUIRED_MEMBER_COUNT}. It is eligible!`, - ); - return; - } - - sendErrorMessage( - pluginData, - msg.channel as TextChannel, - `Server **${invite.guild.name}** (\`${invite.guild.id}\`) is not eligible`, - ); + sendErrorMessage(pluginData, msg.channel as TextChannel, `Server is **NOT** eligible: ${explanation}`); }, }); diff --git a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts index 4a8a609c..dcfb166c 100644 --- a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts +++ b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts @@ -7,11 +7,8 @@ import { resolveUser } from "../../../utils"; import { botControlCmd } from "../types"; export const ListDashboardPermsCmd = botControlCmd({ - trigger: ["list_dashboard_permissions", "list_dashboard_perms", "list_dash_permissionss", "list_dash_perms"], - permission: null, - config: { - preFilters: [isOwnerPreFilter], - }, + trigger: ["list_dashboard_permissions", "list_dashboard_perms", "list_dash_permissions", "list_dash_perms"], + permission: "can_list_dashboard_perms", signature: { guildId: ct.string({ option: true, shortcut: "g" }), diff --git a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts index 52c3083f..a97efca6 100644 --- a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts +++ b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts @@ -6,10 +6,7 @@ import { botControlCmd } from "../types"; export const ListDashboardUsersCmd = botControlCmd({ trigger: ["list_dashboard_users"], - permission: null, - config: { - preFilters: [isOwnerPreFilter], - }, + permission: "can_list_dashboard_perms", signature: { guildId: ct.string(), @@ -23,13 +20,21 @@ export const ListDashboardUsersCmd = botControlCmd({ } const dashboardUsers = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id); - const users = await Promise.all(dashboardUsers.map(perm => resolveUser(pluginData.client, perm.target_id))); - const userNameList = users.map(user => `<@!${user.id}> (**${user.tag}**, \`${user.id}\`)`); + const users = await Promise.all( + dashboardUsers.map(async (perm) => ({ + user: await resolveUser(pluginData.client, perm.target_id), + permission: perm, + })), + ); + const userNameList = users.map( + ({ user, permission }) => + `<@!${user.id}> (**${user.tag}**, \`${user.id}\`): ${permission.permissions.join(", ")}`, + ); sendSuccessMessage( pluginData, msg.channel as TextChannel, - `The following users have dashboard access for **${guild.name}**:\n\n${userNameList}`, + `The following users have dashboard access for **${guild.name}**:\n\n${userNameList.join("\n")}`, {}, ); }, diff --git a/backend/src/plugins/BotControl/commands/PluginPerformanceCmd.ts b/backend/src/plugins/BotControl/commands/PluginPerformanceCmd.ts new file mode 100644 index 00000000..afcb3d6c --- /dev/null +++ b/backend/src/plugins/BotControl/commands/PluginPerformanceCmd.ts @@ -0,0 +1,23 @@ +import { TextChannel } from "discord.js"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { createChunkedMessage, formatNumber, resolveInvite, sorter, verboseUserMention } from "../../../utils"; +import { botControlCmd } from "../types"; + +export const PluginPerformanceCmd = botControlCmd({ + trigger: ["plugin_performance"], + permission: "can_performance", + + signature: {}, + + async run({ pluginData, message: msg, args }) { + const stats = pluginData.getKnubInstance().getPluginPerformanceStats(); + const averageLoadTimeEntries = Object.entries(stats.averageLoadTimes); + averageLoadTimeEntries.sort(sorter((v) => v[1].time, "DESC")); + const lines = averageLoadTimeEntries.map( + ([pluginName, { time }]) => `${pluginName}: **${formatNumber(Math.round(time))}ms**`, + ); + const fullStats = `Average plugin load times:\n\n${lines.join("\n")}`; + createChunkedMessage(msg.channel as TextChannel, fullStats); + }, +}); diff --git a/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts b/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts new file mode 100644 index 00000000..ac355689 --- /dev/null +++ b/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts @@ -0,0 +1,40 @@ +import { botControlCmd } from "../types"; +import { getRateLimitStats } from "../../../rateLimitStats"; +import moment from "moment-timezone"; +import { GuildArchives } from "../../../data/GuildArchives"; +import { getBaseUrl, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { TextChannel } from "discord.js"; + +export const RateLimitPerformanceCmd = botControlCmd({ + trigger: ["rate_limit_performance"], + permission: "can_performance", + + signature: {}, + + async run({ pluginData, message: msg, args }) { + const logItems = getRateLimitStats(); + if (logItems.length === 0) { + sendSuccessMessage(pluginData, msg.channel as TextChannel, `No rate limits hit`); + return; + } + + logItems.reverse(); + const formatted = logItems.map((item) => { + const formattedTime = moment.utc(item.timestamp).format("YYYY-MM-DD HH:mm:ss.SSS"); + const items: string[] = [`[${formattedTime}]`]; + if (item.data.global) items.push("GLOBAL"); + items.push(item.data.method.toUpperCase()); + items.push(item.data.route); + items.push(`stalled for ${item.data.timeout}ms`); + items.push(`(max requests ${item.data.limit})`); + return items.join(" "); + }); + + const fullText = `Last ${logItems.length} rate limits hit:\n\n${formatted.join("\n")}`; + + const archives = GuildArchives.getGuildInstance("0"); + const archiveId = await archives.create(fullText, moment().add(1, "hour")); + const archiveUrl = archives.getUrl(getBaseUrl(pluginData), archiveId); + msg.channel.send(`Link: ${archiveUrl}`); + }, +}); diff --git a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts index 3dfafb42..dff4928d 100644 --- a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts +++ b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts @@ -34,7 +34,7 @@ export const RemoveDashboardUserCmd = botControlCmd({ await pluginData.state.apiPermissionAssignments.removeUser(args.guildId, user.id); } - const userNameList = args.users.map(user => `<@!${user.id}> (**${user.tag}**, \`${user.id}\`)`); + const userNameList = args.users.map((user) => `<@!${user.id}> (**${user.tag}**, \`${user.id}\`)`); sendSuccessMessage( pluginData, msg.channel as TextChannel, diff --git a/backend/src/plugins/BotControl/commands/RestPerformanceCmd.ts b/backend/src/plugins/BotControl/commands/RestPerformanceCmd.ts new file mode 100644 index 00000000..a1d8576d --- /dev/null +++ b/backend/src/plugins/BotControl/commands/RestPerformanceCmd.ts @@ -0,0 +1,27 @@ +import { TextChannel } from "discord.js"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { createChunkedMessage, formatNumber, resolveInvite, sorter, verboseUserMention } from "../../../utils"; +import { botControlCmd } from "../types"; +import { getTopRestCallStats } from "../../../restCallStats"; + +const leadingPathRegex = /(?<=\().+\/backend\//g; + +export const RestPerformanceCmd = botControlCmd({ + trigger: ["rest_performance"], + permission: "can_performance", + + signature: { + count: ct.number({ required: false }), + }, + + async run({ pluginData, message: msg, args }) { + const count = Math.max(1, Math.min(25, args.count || 5)); + const stats = getTopRestCallStats(count); + const formatted = stats.map((callStats) => { + const cleanSource = callStats.source.replace(leadingPathRegex, ""); + return `**${callStats.count} calls**\n${callStats.method.toUpperCase()} ${callStats.path}\n${cleanSource}`; + }); + createChunkedMessage(msg.channel as TextChannel, formatted.join("\n")); + }, +}); diff --git a/backend/src/plugins/BotControl/commands/ServersCmd.ts b/backend/src/plugins/BotControl/commands/ServersCmd.ts index d226e14c..f8c91b61 100644 --- a/backend/src/plugins/BotControl/commands/ServersCmd.ts +++ b/backend/src/plugins/BotControl/commands/ServersCmd.ts @@ -22,7 +22,7 @@ export const ServersCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const showList = Boolean(args.all || args.initialized || args.uninitialized || args.search); - const search = args.search ? new RegExp([...args.search].map(s => escapeStringRegexp(s)).join(".*"), "i") : null; + const search = args.search ? new RegExp([...args.search].map((s) => escapeStringRegexp(s)).join(".*"), "i") : null; const joinedGuilds = Array.from(pluginData.client.guilds.cache.values()); const loadedGuilds = pluginData.getKnubInstance().getLoadedGuilds(); @@ -32,21 +32,21 @@ export const ServersCmd = botControlCmd({ let filteredGuilds = Array.from(joinedGuilds); if (args.initialized) { - filteredGuilds = filteredGuilds.filter(g => loadedGuildsMap.has(g.id)); + filteredGuilds = filteredGuilds.filter((g) => loadedGuildsMap.has(g.id)); } if (args.uninitialized) { - filteredGuilds = filteredGuilds.filter(g => !loadedGuildsMap.has(g.id)); + filteredGuilds = filteredGuilds.filter((g) => !loadedGuildsMap.has(g.id)); } if (args.search) { - filteredGuilds = filteredGuilds.filter(g => search!.test(`${g.id} ${g.name}`)); + filteredGuilds = filteredGuilds.filter((g) => search!.test(`${g.id} ${g.name}`)); } if (filteredGuilds.length) { - filteredGuilds.sort(sorter(g => g.name.toLowerCase())); + filteredGuilds.sort(sorter((g) => g.name.toLowerCase())); const longestId = filteredGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0); - const lines = filteredGuilds.map(g => { + const lines = filteredGuilds.map((g) => { const paddedId = g.id.padEnd(longestId, " "); const owner = getUser(pluginData.client, g.ownerId); return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${owner.tag}** \`${owner.id}\`)`; @@ -57,7 +57,7 @@ export const ServersCmd = botControlCmd({ } } else { const total = joinedGuilds.length; - const initialized = joinedGuilds.filter(g => loadedGuildsMap.has(g.id)).length; + const initialized = joinedGuilds.filter((g) => loadedGuildsMap.has(g.id)).length; const unInitialized = total - initialized; msg.channel.send( diff --git a/backend/src/plugins/BotControl/functions/isEligible.ts b/backend/src/plugins/BotControl/functions/isEligible.ts new file mode 100644 index 00000000..164430f3 --- /dev/null +++ b/backend/src/plugins/BotControl/functions/isEligible.ts @@ -0,0 +1,46 @@ +import { User } from "discord.js"; +import { BotControlPluginType } from "../types"; +import { GlobalPluginData } from "knub"; +import { GuildInvite } from "../../../utils"; + +const REQUIRED_MEMBER_COUNT = 5000; + +export async function isEligible( + pluginData: GlobalPluginData, + user: User, + invite: GuildInvite, +): Promise<{ result: boolean; explanation: string }> { + if ((await pluginData.state.apiPermissionAssignments.getByUserId(user.id)).length) { + return { + result: true, + explanation: "User is an existing bot operator", + }; + } + + if (invite.guild.features.includes("PARTNERED")) { + return { + result: true, + explanation: "Server is partnered", + }; + } + + if (invite.guild.features.includes("VERIFIED")) { + return { + result: true, + explanation: "Server is verified", + }; + } + + const memberCount = invite.memberCount || 0; + if (memberCount >= REQUIRED_MEMBER_COUNT) { + return { + result: true, + explanation: `Server has ${memberCount} members, which is equal or higher than the required ${REQUIRED_MEMBER_COUNT}`, + }; + } + + return { + result: false, + explanation: "Server does not meet requirements", + }; +} diff --git a/backend/src/plugins/BotControl/types.ts b/backend/src/plugins/BotControl/types.ts index 1acac3a4..f2da13ef 100644 --- a/backend/src/plugins/BotControl/types.ts +++ b/backend/src/plugins/BotControl/types.ts @@ -9,6 +9,9 @@ import { tNullable } from "../../utils"; export const ConfigSchema = t.type({ can_use: t.boolean, can_eligible: t.boolean, + can_performance: t.boolean, + can_add_server_from_invite: t.boolean, + can_list_dashboard_perms: t.boolean, update_cmd: tNullable(t.string), }); export type TConfigSchema = t.TypeOf; diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts index 6f870fc6..5a8c1b45 100644 --- a/backend/src/plugins/Cases/CasesPlugin.ts +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -38,7 +38,11 @@ export const CasesPlugin = zeppelinGuildPlugin()({ `), }, - dependencies: [TimeAndDatePlugin], + dependencies: async () => [ + TimeAndDatePlugin, + // The `as any` cast here is to prevent TypeScript from locking up from the circular dependency + ((await import("../Logs/LogsPlugin")) as any).LogsPlugin, + ], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/Cases/functions/createCaseNote.ts b/backend/src/plugins/Cases/functions/createCaseNote.ts index c03a5b3c..30f0ac2e 100644 --- a/backend/src/plugins/Cases/functions/createCaseNote.ts +++ b/backend/src/plugins/Cases/functions/createCaseNote.ts @@ -22,7 +22,7 @@ export async function createCaseNote(pluginData: GuildPluginData `__[${d}]__`).join(" ") + " " + body; + body = args.noteDetails.map((d) => `__[${d}]__`).join(" ") + " " + body; } await pluginData.state.cases.createNote(theCase.id, { diff --git a/backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts b/backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts index 94f35d7f..eb28584d 100644 --- a/backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts +++ b/backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts @@ -7,11 +7,11 @@ export async function getCaseTypeAmountForUserId( userID: string, type: CaseTypes, ): Promise { - const cases = (await pluginData.state.cases.getByUserId(userID)).filter(c => !c.is_hidden); + const cases = (await pluginData.state.cases.getByUserId(userID)).filter((c) => !c.is_hidden); let typeAmount = 0; if (cases.length > 0) { - cases.forEach(singleCase => { + cases.forEach((singleCase) => { if (singleCase.type === type.valueOf()) { typeAmount++; } diff --git a/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts b/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts index 7f3f04d5..cba29ecb 100644 --- a/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts +++ b/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts @@ -6,6 +6,7 @@ import { isDiscordAPIError } from "../../../utils"; import { CasesPluginType } from "../types"; import { getCaseEmbed } from "./getCaseEmbed"; import { resolveCaseId } from "./resolveCaseId"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function postToCaseLogChannel( pluginData: GuildPluginData, @@ -26,7 +27,7 @@ export async function postToCaseLogChannel( result = await caseLogChannel.send({ ...content }); } catch (e) { if (isDiscordAPIError(e) && (e.code === 50013 || e.code === 50001)) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to post mod cases in <#${caseLogChannel.id}>`, }); return null; @@ -67,7 +68,7 @@ export async function postCaseToCaseLogChannel( } return postedMessage; } catch { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to post case #${theCase.case_number} to the case log channel`, }); return null; diff --git a/backend/src/plugins/Censor/CensorPlugin.ts b/backend/src/plugins/Censor/CensorPlugin.ts index 27f8ed9f..d8b74477 100644 --- a/backend/src/plugins/Censor/CensorPlugin.ts +++ b/backend/src/plugins/Censor/CensorPlugin.ts @@ -55,7 +55,7 @@ export const CensorPlugin = zeppelinGuildPlugin()({ legacy: true, }, - dependencies: [LogsPlugin], + dependencies: () => [LogsPlugin], configSchema: ConfigSchema, defaultOptions, @@ -71,10 +71,10 @@ export const CensorPlugin = zeppelinGuildPlugin()({ afterLoad(pluginData) { const { state, guild } = pluginData; - state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); - state.onMessageUpdateFn = msg => onMessageUpdate(pluginData, msg); + state.onMessageUpdateFn = (msg) => onMessageUpdate(pluginData, msg); state.savedMessages.events.on("update", state.onMessageUpdateFn); }, diff --git a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts index ffadcc67..5e92a305 100644 --- a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts +++ b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts @@ -19,7 +19,7 @@ export async function applyFiltersToMsg( let messageContent = savedMessage.data.content || ""; if (savedMessage.data.attachments) messageContent += " " + JSON.stringify(savedMessage.data.attachments); if (savedMessage.data.embeds) { - const embeds = (savedMessage.data.embeds as MessageEmbed[]).map(e => cloneDeep(e)); + const embeds = (savedMessage.data.embeds as MessageEmbed[]).map((e) => cloneDeep(e)); for (const embed of embeds) { if (embed.type === "video") { // Ignore video descriptions as they're not actually shown on the embed @@ -52,7 +52,7 @@ export async function applyFiltersToMsg( const inviteCodes = getInviteCodesInString(messageContent); const invites: Array = await Promise.all( - inviteCodes.map(code => resolveInvite(pluginData.client, code)), + inviteCodes.map((code) => resolveInvite(pluginData.client, code)), ); for (const invite of invites) { diff --git a/backend/src/plugins/Censor/util/censorMessage.ts b/backend/src/plugins/Censor/util/censorMessage.ts index 509680e2..a9549974 100644 --- a/backend/src/plugins/Censor/util/censorMessage.ts +++ b/backend/src/plugins/Censor/util/censorMessage.ts @@ -1,11 +1,11 @@ -import { Snowflake, TextChannel } from "discord.js"; +import { BaseGuildTextChannel, Snowflake, TextChannel, ThreadChannel } from "discord.js"; import { GuildPluginData } from "knub"; -import { deactivateMentions, disableCodeBlocks } from "knub/dist/helpers"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; import { resolveUser } from "../../../utils"; import { CensorPluginType } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function censorMessage( pluginData: GuildPluginData, @@ -22,13 +22,14 @@ export async function censorMessage( } const user = await resolveUser(pluginData.client, savedMessage.user_id); - const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)!; + const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)! as + | BaseGuildTextChannel + | ThreadChannel; - pluginData.state.serverLogs.log(LogType.CENSOR, { - user: userToConfigAccessibleUser(user), - channel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logCensor({ + user, + channel, reason, message: savedMessage, - messageText: disableCodeBlocks(deactivateMentions(savedMessage.data.content)), }); } diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts index 3349c242..9ff38a84 100644 --- a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts +++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts @@ -8,11 +8,11 @@ export const ChannelArchiverPlugin = zeppelinGuildPlugin [TimeAndDatePlugin], configSchema: t.type({}), // prettier-ignore commands: [ ArchiveChannelCmd, - ] + ], }); diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts index c1b3a871..2fe94bb9 100644 --- a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts +++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts @@ -68,9 +68,9 @@ export const ArchiveChannelCmd = channelArchiverCmd({ for (const message of messages.values()) { const ts = moment.utc(message.createdTimestamp).format("YYYY-MM-DD HH:mm:ss"); - let content = `[${ts}] [${message.author.id}] [${message.author.username}#${ - message.author.discriminator - }]: ${message.content || ""}`; + let content = `[${ts}] [${message.author.id}] [${message.author.username}#${message.author.discriminator}]: ${ + message.content || "" + }`; if (message.attachments.size) { if (args["attachment-channel"]) { diff --git a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts index 54196d78..cadee696 100644 --- a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts +++ b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts @@ -24,7 +24,7 @@ export const CompanionChannelsPlugin = zeppelinGuildPlugin [LogsPlugin], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/CompanionChannels/events/VoiceStateUpdateEvt.ts b/backend/src/plugins/CompanionChannels/events/VoiceStateUpdateEvt.ts index 07251fdb..0e73570e 100644 --- a/backend/src/plugins/CompanionChannels/events/VoiceStateUpdateEvt.ts +++ b/backend/src/plugins/CompanionChannels/events/VoiceStateUpdateEvt.ts @@ -6,7 +6,12 @@ export const VoiceStateUpdateEvt = companionChannelsEvt({ listener({ pluginData, args: { oldState, newState } }) { const oldChannel = oldState.channel; const newChannel = newState.channel; - const memberId = newState.member ? newState.member.id : oldState.member!.id; + + const memberId = newState.member?.id ?? oldState.member?.id; + if (!memberId) { + return; + } + handleCompanionPermissions(pluginData, memberId, newChannel, oldChannel); }, }); diff --git a/backend/src/plugins/CompanionChannels/functions/getCompanionChannelOptsForVoiceChannelId.ts b/backend/src/plugins/CompanionChannels/functions/getCompanionChannelOptsForVoiceChannelId.ts index bfd94cbe..8ecde5e7 100644 --- a/backend/src/plugins/CompanionChannels/functions/getCompanionChannelOptsForVoiceChannelId.ts +++ b/backend/src/plugins/CompanionChannels/functions/getCompanionChannelOptsForVoiceChannelId.ts @@ -14,9 +14,9 @@ export async function getCompanionChannelOptsForVoiceChannelId( const config = await pluginData.config.getMatchingConfig({ userId, channelId: voiceChannel.id }); return Object.values(config.entries) .filter( - opts => + (opts) => opts.voice_channel_ids.includes(voiceChannel.id) || (voiceChannel.parentId && opts.voice_channel_ids.includes(voiceChannel.parentId)), ) - .map(opts => Object.assign({}, defaultCompanionChannelOpts, opts)); + .map((opts) => Object.assign({}, defaultCompanionChannelOpts, opts)); } diff --git a/backend/src/plugins/CompanionChannels/functions/handleCompanionPermissions.ts b/backend/src/plugins/CompanionChannels/functions/handleCompanionPermissions.ts index ea9c58ef..ea09b548 100644 --- a/backend/src/plugins/CompanionChannels/functions/handleCompanionPermissions.ts +++ b/backend/src/plugins/CompanionChannels/functions/handleCompanionPermissions.ts @@ -5,6 +5,7 @@ import { isDiscordAPIError, MINUTES } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { CompanionChannelsPluginType, TCompanionChannelOpts } from "../types"; import { getCompanionChannelOptsForVoiceChannelId } from "./getCompanionChannelOptsForVoiceChannelId"; +import { filterObject } from "../../../utils/filterObject"; const ERROR_COOLDOWN_KEY = "errorCooldown"; const ERROR_COOLDOWN = 5 * MINUTES; @@ -63,18 +64,31 @@ export async function handleCompanionPermissions( const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!channel || !(channel instanceof TextChannel)) continue; pluginData.state.serverLogs.ignoreLog(LogType.CHANNEL_UPDATE, channelId, 3 * 1000); - await channel.permissionOverwrites.create(userId as Snowflake, new Permissions(BigInt(permissions)).serialize(), { + const fullSerialized = new Permissions(BigInt(permissions)).serialize(); + const onlyAllowed = filterObject(fullSerialized, (v) => v === true); + await channel.permissionOverwrites.create(userId, onlyAllowed, { reason: `Companion Channel for ${voiceChannel!.id} | User Joined`, }); } } catch (e) { - if (isDiscordAPIError(e) && e.code === 50001) { + if (isDiscordAPIError(e)) { const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { - body: `Missing permissions to handle companion channels. Pausing companion channels for 5 minutes or until the bot is reloaded on this server.`, - }); - pluginData.state.errorCooldownManager.setCooldown(ERROR_COOLDOWN_KEY, ERROR_COOLDOWN); - return; + + if (e.code === 50001) { + logs.logBotAlert({ + body: `One of the companion channels can't be accessed. Pausing companion channels for 5 minutes or until the bot is reloaded on this server.`, + }); + pluginData.state.errorCooldownManager.setCooldown(ERROR_COOLDOWN_KEY, ERROR_COOLDOWN); + return; + } + + if (e.code === 50013) { + logs.logBotAlert({ + body: `Missing permissions to handle companion channels. Pausing companion channels for 5 minutes or until the bot is reloaded on this server.`, + }); + pluginData.state.errorCooldownManager.setCooldown(ERROR_COOLDOWN_KEY, ERROR_COOLDOWN); + return; + } } throw e; diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index ee4a663a..4443bf37 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -1,46 +1,43 @@ import { PluginOptions } from "knub"; -import { StrictValidationError } from "src/validatorUtils"; -import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { availableTypes } from "./actions/availableActions"; import { ContextClickedEvt } from "./events/ContextClickedEvt"; import { ConfigSchema, ContextMenuPluginType } from "./types"; import { loadAllCommands } from "./utils/loadAllCommands"; +import { UtilityPlugin } from "../Utility/UtilityPlugin"; const defaultOptions: PluginOptions = { config: { - context_actions: {}, + can_use: false, + + user_muteindef: false, + user_mute1d: false, + user_mute1h: false, + user_info: false, + + message_clean10: false, + message_clean25: false, + message_clean50: false, }, -}; - -const configPreprocessor: ConfigPreprocessorFn = options => { - if (options.config.context_actions) { - for (const [name, contextMenu] of Object.entries(options.config.context_actions)) { - if (Object.entries(contextMenu.action).length !== 1) { - throw new StrictValidationError([`Invalid value for context_actions/${name}: Must have exactly one action.`]); - } - - const actionName = Object.entries(contextMenu.action)[0][0]; - if (!availableTypes[actionName].includes(contextMenu.type)) { - throw new StrictValidationError([ - `Invalid value for context_actions/${name}/${actionName}: ${actionName} is not allowed on type ${contextMenu.type}.`, - ]); - } - } - } - - return options; + overrides: [ + { + level: ">=50", + config: { + can_use: true, + }, + }, + ], }; export const ContextMenuPlugin = zeppelinGuildPlugin()({ name: "context_menu", + showInDocs: false, configSchema: ConfigSchema, + dependencies: () => [MutesPlugin, LogsPlugin, UtilityPlugin], defaultOptions, - configPreprocessor, // prettier-ignore events: [ @@ -56,6 +53,4 @@ export const ContextMenuPlugin = zeppelinGuildPlugin()({ afterLoad(pluginData) { loadAllCommands(pluginData); }, - - dependencies: [MutesPlugin, LogsPlugin], }); diff --git a/backend/src/plugins/ContextMenus/actions/availableActions.ts b/backend/src/plugins/ContextMenus/actions/availableActions.ts deleted file mode 100644 index 24816790..00000000 --- a/backend/src/plugins/ContextMenus/actions/availableActions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as t from "io-ts"; -import { ContextActionBlueprint } from "../helpers"; -import { CleanAction } from "./clean"; -import { MuteAction } from "./mute"; - -export const availableActions: Record> = { - mute: MuteAction, - clean: CleanAction, -}; - -export const AvailableActions = t.type({ - mute: MuteAction.configType, - clean: CleanAction.configType, -}); - -export const availableTypes: Record = { - mute: ["USER"], - clean: ["MESSAGE"], -}; diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 8184d480..1831f7d8 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -1,64 +1,51 @@ -import { TextChannel } from "discord.js"; -import * as t from "io-ts"; -import { canActOn } from "src/pluginUtils"; +import { ContextMenuInteraction, TextChannel } from "discord.js"; +import { GuildPluginData } from "knub"; import { LogType } from "../../../data/LogType"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; -import { tNullable } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { contextMenuAction } from "../helpers"; +import { ContextMenuPluginType } from "../types"; -export const CleanAction = contextMenuAction({ - configType: t.type({ - amount: tNullable(t.number), - targetUserOnly: tNullable(t.boolean), - "delete-pins": tNullable(t.boolean), - }), +export async function cleanAction( + pluginData: GuildPluginData, + amount: number, + interaction: ContextMenuInteraction, +) { + interaction.deferReply({ ephemeral: true }); + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + const utility = pluginData.getPlugin(UtilityPlugin); - defaultConfig: { - amount: 10, - targetUserOnly: false, - "delete-pins": false, - }, + if (!userCfg.can_use || !(await utility.hasPermission(executingMember, interaction.channelId, "can_clean"))) { + await interaction.followUp({ content: "Cannot clean: insufficient permissions" }); + return; + } - async apply({ pluginData, actionConfig, actionName, interaction }) { - interaction.deferReply({ ephemeral: true }); - const targetMessage = interaction.channel - ? await interaction.channel.messages.fetch(interaction.targetId) - : await (pluginData.guild.channels.resolve(interaction.channelId) as TextChannel).messages.fetch( - interaction.targetId, - ); - - const amount = actionConfig.amount ?? 10; - const targetUserOnly = actionConfig.targetUserOnly ?? false; - const deletePins = actionConfig["delete-pins"] ?? false; - - const user = targetUserOnly ? targetMessage.author.id : undefined; - const targetMember = await pluginData.guild.members.fetch(targetMessage.author.id); - const executingMember = await pluginData.guild.members.fetch(interaction.user.id); - const utility = pluginData.getPlugin(UtilityPlugin); - - if (targetUserOnly && !canActOn(pluginData, executingMember, targetMember)) { - interaction.followUp({ ephemeral: true, content: "Cannot clean users messages: insufficient permissions" }); - return; - } - - try { - interaction.followUp(`Cleaning... Amount: ${amount}, User Only: ${targetUserOnly}, Pins: ${deletePins}`); - utility.clean( - { count: amount, user, channel: targetMessage.channel.id, "delete-pins": deletePins }, - targetMessage, + const targetMessage = interaction.channel + ? await interaction.channel.messages.fetch(interaction.targetId) + : await (pluginData.guild.channels.resolve(interaction.channelId) as TextChannel).messages.fetch( + interaction.targetId, ); - } catch (e) { - interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); - if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { - body: `Failed to clean in <#${interaction.channelId}> in ContextMenu action \`${actionName}\``, - }); - } else { - throw e; - } + const targetUserOnly = false; + const deletePins = false; + const user = undefined; + + try { + interaction.followUp(`Cleaning... Amount: ${amount}, User Only: ${targetUserOnly}, Pins: ${deletePins}`); + utility.clean({ count: amount, user, channel: targetMessage.channel.id, "delete-pins": deletePins }, targetMessage); + } catch (e) { + interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to clean in <#${interaction.channelId}> in ContextMenu action \`clean\`:_ ${e}`, + }); + } else { + throw e; } - }, -}); + } +} diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index 00a9c131..5735337a 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -1,83 +1,67 @@ +import { ContextMenuInteraction } from "discord.js"; import humanizeDuration from "humanize-duration"; -import * as t from "io-ts"; +import { GuildPluginData } from "knub"; +import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin"; import { canActOn } from "src/pluginUtils"; import { LogType } from "../../../data/LogType"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; -import { convertDelayStringToMS, tDelayString, tNullable } from "../../../utils"; +import { convertDelayStringToMS } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin"; -import { contextMenuAction } from "../helpers"; -import { resolveActionContactMethods } from "../utils/resolveActionContactMethods"; +import { ContextMenuPluginType } from "../types"; -export const MuteAction = contextMenuAction({ - configType: t.type({ - reason: tNullable(t.string), - duration: tNullable(tDelayString), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), - remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), - restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), - postInCaseLog: tNullable(t.boolean), - hide_case: tNullable(t.boolean), - }), +export async function muteAction( + pluginData: GuildPluginData, + duration: string | undefined, + interaction: ContextMenuInteraction, +) { + interaction.deferReply({ ephemeral: true }); + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); - defaultConfig: { - notify: null, // Use defaults from ModActions - hide_case: false, - }, + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) { + await interaction.followUp({ content: "Cannot mute: insufficient permissions" }); + return; + } - async apply({ pluginData, actionConfig, actionName, interaction }) { - const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined; - const reason = actionConfig.reason || "Context Menu Action"; - const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; - const rolesToRemove = actionConfig.remove_roles_on_mute; - const rolesToRestore = actionConfig.restore_roles_on_mute; + const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; + const mutes = pluginData.getPlugin(MutesPlugin); + const userId = interaction.targetId; + const targetMember = await pluginData.guild.members.fetch(interaction.targetId); - const caseArgs: Partial = { - modId: pluginData.client.user!.id, - automatic: true, - postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined, - hide: Boolean(actionConfig.hide_case), - }; + if (!canActOn(pluginData, executingMember, targetMember)) { + interaction.followUp({ ephemeral: true, content: "Cannot mute: insufficient permissions" }); + return; + } - interaction.deferReply({ ephemeral: true }); - const mutes = pluginData.getPlugin(MutesPlugin); - const userId = interaction.targetId; - const targetMember = await pluginData.guild.members.fetch(interaction.targetId); - const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const caseArgs: Partial = { + modId: executingMember.id, + }; - if (!canActOn(pluginData, executingMember, targetMember)) { - interaction.followUp({ ephemeral: true, content: "Cannot mute: insufficient permissions" }); - return; + try { + const result = await mutes.muteUser(userId, durationMs, "Context Menu Action", { caseArgs }); + + const muteMessage = `Muted **${result.case.user_name}** ${ + durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" + } (Case #${result.case.case_number}) (user notified via ${ + result.notifyResult.method ?? "dm" + })\nPlease update the new case with the \`update\` command`; + + interaction.followUp({ ephemeral: true, content: muteMessage }); + } catch (e) { + interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to mute <@!${userId}> in ContextMenu action \`mute\` because a mute role has not been specified in server config`, + }); + } else { + throw e; } - - try { - const result = await mutes.muteUser( - userId, - duration, - reason, - { contactMethods, caseArgs, isAutomodAction: true }, - rolesToRemove, - rolesToRestore, - ); - - const muteMessage = `Muted **${result.case.user_name}** ${ - duration ? `for ${humanizeDuration(duration)}` : "indefinitely" - } (Case #${result.case.case_number}) (user notified via ${result.notifyResult.method ?? - "dm"})\nPlease update the new case with the \`update\` command`; - - interaction.followUp({ ephemeral: true, content: muteMessage }); - } catch (e) { - interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); - - if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { - body: `Failed to mute <@!${userId}> in ContextMenu action \`${actionName}\` because a mute role has not been specified in server config`, - }); - } else { - throw e; - } - } - }, -}); + } +} diff --git a/backend/src/plugins/ContextMenus/actions/userInfo.ts b/backend/src/plugins/ContextMenus/actions/userInfo.ts new file mode 100644 index 00000000..c4dd4a34 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/userInfo.ts @@ -0,0 +1,28 @@ +import { ContextMenuInteraction } from "discord.js"; +import { GuildPluginData } from "knub"; +import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; +import { ContextMenuPluginType } from "../types"; + +export async function userInfoAction( + pluginData: GuildPluginData, + interaction: ContextMenuInteraction, +) { + interaction.deferReply({ ephemeral: true }); + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + const utility = pluginData.getPlugin(UtilityPlugin); + + if (userCfg.can_use && (await utility.hasPermission(executingMember, interaction.channelId, "can_userinfo"))) { + const embed = await utility.userInfo(interaction.targetId, interaction.user.id); + if (!embed) { + await interaction.followUp({ content: "Cannot info: internal error" }); + return; + } + await interaction.followUp({ embeds: [embed] }); + } else { + await interaction.followUp({ content: "Cannot info: insufficient permissions" }); + } +} diff --git a/backend/src/plugins/ContextMenus/helpers.ts b/backend/src/plugins/ContextMenus/helpers.ts deleted file mode 100644 index d6e4ac6c..00000000 --- a/backend/src/plugins/ContextMenus/helpers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ContextMenuInteraction } from "discord.js"; -import * as t from "io-ts"; -import { GuildPluginData } from "knub"; -import { Awaitable } from "knub/dist/utils"; -import { ContextMenuPluginType } from "./types"; - -type ContextActionApplyFn = (meta: { - actionName: string; - pluginData: GuildPluginData; - actionConfig: TConfigType; - interaction: ContextMenuInteraction; -}) => Awaitable; - -export interface ContextActionBlueprint { - configType: TConfigType; - defaultConfig: Partial>; - - apply: ContextActionApplyFn>; -} - -export function contextMenuAction( - blueprint: ContextActionBlueprint, -): ContextActionBlueprint { - return blueprint; -} diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts index 9b34cb6a..e73d686d 100644 --- a/backend/src/plugins/ContextMenus/types.ts +++ b/backend/src/plugins/ContextMenus/types.ts @@ -1,29 +1,17 @@ import * as t from "io-ts"; -import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; +import { BasePluginType, typedGuildEventListener } from "knub"; import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; -import { tNullable } from "../../utils"; -import { AvailableActions } from "./actions/availableActions"; - -export enum ContextMenuTypes { - USER = 2, - MESSAGE = 3, -} - -export const ContextMenuTypeNameToNumber: Record = { - USER: 2, - MESSAGE: 3, -}; - -const ContextActionOpts = t.type({ - enabled: tNullable(t.boolean), - label: t.string, - type: t.keyof(ContextMenuTypes), - action: t.partial(AvailableActions.props), -}); -export type TContextActionOpts = t.TypeOf; export const ConfigSchema = t.type({ - context_actions: t.record(t.string, ContextActionOpts), + can_use: t.boolean, + + user_muteindef: t.boolean, + user_mute1d: t.boolean, + user_mute1h: t.boolean, + user_info: t.boolean, + message_clean10: t.boolean, + message_clean25: t.boolean, + message_clean50: t.boolean, }); export type TConfigSchema = t.TypeOf; @@ -34,5 +22,4 @@ export interface ContextMenuPluginType extends BasePluginType { }; } -export const contextMenuCmd = typedGuildCommand(); export const contextMenuEvt = typedGuildEventListener(); diff --git a/backend/src/plugins/ContextMenus/utils/contextRouter.ts b/backend/src/plugins/ContextMenus/utils/contextRouter.ts index 5004807f..1bda0ff8 100644 --- a/backend/src/plugins/ContextMenus/utils/contextRouter.ts +++ b/backend/src/plugins/ContextMenus/utils/contextRouter.ts @@ -1,7 +1,7 @@ import { ContextMenuInteraction } from "discord.js"; import { GuildPluginData } from "knub"; -import { availableActions } from "../actions/availableActions"; import { ContextMenuPluginType } from "../types"; +import { hardcodedActions } from "./hardcodedContextOptions"; export async function routeContextAction( pluginData: GuildPluginData, @@ -9,20 +9,5 @@ export async function routeContextAction( ) { const contextLink = await pluginData.state.contextMenuLinks.get(interaction.commandId); if (!contextLink) return; - const contextActions = Object.entries(pluginData.config.get().context_actions); - - const configLink = contextActions.find(x => x[0] === contextLink.action_name); - if (!configLink) return; - - for (const [actionName, actionConfig] of Object.entries(configLink[1].action)) { - if (actionConfig == null) return; - const action = availableActions[actionName]; - action.apply({ - actionName, - pluginData, - actionConfig, - interaction, - }); - return; - } + hardcodedActions[contextLink.action_name](pluginData, interaction); } diff --git a/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts b/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts new file mode 100644 index 00000000..84593ace --- /dev/null +++ b/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts @@ -0,0 +1,23 @@ +import { cleanAction } from "../actions/clean"; +import { muteAction } from "../actions/mute"; +import { userInfoAction } from "../actions/userInfo"; + +export const hardcodedContext: Record = { + user_muteindef: "Mute Indefinitely", + user_mute1d: "Mute for 1 day", + user_mute1h: "Mute for 1 hour", + user_info: "Get Info", + message_clean10: "Clean 10 messages", + message_clean25: "Clean 25 messages", + message_clean50: "Clean 50 messages", +}; + +export const hardcodedActions = { + user_muteindef: (pluginData, interaction) => muteAction(pluginData, undefined, interaction), + user_mute1d: (pluginData, interaction) => muteAction(pluginData, "1d", interaction), + user_mute1h: (pluginData, interaction) => muteAction(pluginData, "1h", interaction), + user_info: (pluginData, interaction) => userInfoAction(pluginData, interaction), + message_clean10: (pluginData, interaction) => cleanAction(pluginData, 10, interaction), + message_clean25: (pluginData, interaction) => cleanAction(pluginData, 25, interaction), + message_clean50: (pluginData, interaction) => cleanAction(pluginData, 50, interaction), +}; diff --git a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts index 93449819..8272d8b3 100644 --- a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts +++ b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts @@ -1,28 +1,33 @@ -import { ApplicationCommandData } from "discord.js"; -import { LogType } from "src/data/LogType"; -import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; +import { ApplicationCommandData, Constants } from "discord.js"; +import { LogType } from "../../../data/LogType"; +import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; import { GuildPluginData } from "knub"; -import { ContextMenuPluginType, ContextMenuTypeNameToNumber } from "../types"; +import { ContextMenuPluginType } from "../types"; +import { hardcodedContext } from "./hardcodedContextOptions"; export async function loadAllCommands(pluginData: GuildPluginData) { const comms = await pluginData.client.application!.commands; - const actions = pluginData.config.get().context_actions; + const cfg = pluginData.config.get(); const newCommands: ApplicationCommandData[] = []; const addedNames: string[] = []; - for (const [name, configAction] of Object.entries(actions)) { - if (!configAction.enabled) continue; + for (const [name, label] of Object.entries(hardcodedContext)) { + if (!cfg[name]) continue; + const type = name.startsWith("user") + ? Constants.ApplicationCommandTypes.USER + : Constants.ApplicationCommandTypes.MESSAGE; const data: ApplicationCommandData = { - type: ContextMenuTypeNameToNumber[configAction.type], - name: configAction.label, + type, + name: label, }; + addedNames.push(name); newCommands.push(data); } - const setCommands = await comms.set(newCommands, pluginData.guild.id).catch(e => { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, `Unable to overwrite context menus: ${e}`); + const setCommands = await comms.set(newCommands, pluginData.guild.id).catch((e) => { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unable to overwrite context menus: ${e}` }); return undefined; }); if (!setCommands) return; diff --git a/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts b/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts deleted file mode 100644 index bec64765..00000000 --- a/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Snowflake, TextChannel } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; -import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils"; -import { ContextMenuPluginType } from "../types"; - -export function resolveActionContactMethods( - pluginData: GuildPluginData, - actionConfig: { - notify?: string | null; - notifyChannel?: string | null; - }, -): UserNotificationMethod[] { - if (actionConfig.notify === "dm") { - return [{ type: "dm" }]; - } else if (actionConfig.notify === "channel") { - if (!actionConfig.notifyChannel) { - throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL); - } - - const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake); - if (!(channel instanceof TextChannel)) { - throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL); - } - - return [{ type: "channel", channel }]; - } else if (actionConfig.notify && disableUserNotificationStrings.includes(actionConfig.notify)) { - return []; - } - - return []; -} diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts index d2566515..07738195 100644 --- a/backend/src/plugins/Counters/CountersPlugin.ts +++ b/backend/src/plugins/Counters/CountersPlugin.ts @@ -55,7 +55,7 @@ const defaultOptions: PluginOptions = { ], }; -const configPreprocessor: ConfigPreprocessorFn = options => { +const configPreprocessor: ConfigPreprocessorFn = (options) => { for (const [counterName, counter] of Object.entries(options.config?.counters || {})) { counter.name = counterName; counter.per_user = counter.per_user ?? false; diff --git a/backend/src/plugins/Counters/commands/CountersListCmd.ts b/backend/src/plugins/Counters/commands/CountersListCmd.ts index c92a2fe4..f725ac71 100644 --- a/backend/src/plugins/Counters/commands/CountersListCmd.ts +++ b/backend/src/plugins/Counters/commands/CountersListCmd.ts @@ -13,13 +13,13 @@ export const CountersListCmd = typedGuildCommand()({ async run({ pluginData, message, args }) { const config = await pluginData.config.getForMessage(message); - const countersToShow = Array.from(Object.values(config.counters)).filter(c => c.can_view !== false); + const countersToShow = Array.from(Object.values(config.counters)).filter((c) => c.can_view !== false); if (!countersToShow.length) { sendErrorMessage(pluginData, message.channel, "No counters are configured for this server"); return; } - const counterLines = countersToShow.map(counter => { + const counterLines = countersToShow.map((counter) => { const title = counter.pretty_name ? `**${counter.pretty_name}** (\`${counter.name}\`)` : `\`${counter.name}\``; const types: string[] = []; diff --git a/backend/src/plugins/Counters/functions/changeCounterValue.ts b/backend/src/plugins/Counters/functions/changeCounterValue.ts index de61d9be..74a295f9 100644 --- a/backend/src/plugins/Counters/functions/changeCounterValue.ts +++ b/backend/src/plugins/Counters/functions/changeCounterValue.ts @@ -38,10 +38,10 @@ export async function changeCounterValue( if (triggers) { const triggersArr = Array.from(triggers.values()); await Promise.all( - triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + triggersArr.map((trigger) => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); await Promise.all( - triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + triggersArr.map((trigger) => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); } diff --git a/backend/src/plugins/Counters/functions/decayCounter.ts b/backend/src/plugins/Counters/functions/decayCounter.ts index c7b813ea..0317ee8c 100644 --- a/backend/src/plugins/Counters/functions/decayCounter.ts +++ b/backend/src/plugins/Counters/functions/decayCounter.ts @@ -25,8 +25,8 @@ export async function decayCounter( const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); if (triggers) { const triggersArr = Array.from(triggers.values()); - await Promise.all(triggersArr.map(trigger => checkAllValuesForTrigger(pluginData, counterName, trigger))); - await Promise.all(triggersArr.map(trigger => checkAllValuesForReverseTrigger(pluginData, counterName, trigger))); + await Promise.all(triggersArr.map((trigger) => checkAllValuesForTrigger(pluginData, counterName, trigger))); + await Promise.all(triggersArr.map((trigger) => checkAllValuesForReverseTrigger(pluginData, counterName, trigger))); } lock.unlock(); diff --git a/backend/src/plugins/Counters/functions/setCounterValue.ts b/backend/src/plugins/Counters/functions/setCounterValue.ts index 697c8503..7a988ee2 100644 --- a/backend/src/plugins/Counters/functions/setCounterValue.ts +++ b/backend/src/plugins/Counters/functions/setCounterValue.ts @@ -35,10 +35,10 @@ export async function setCounterValue( if (triggers) { const triggersArr = Array.from(triggers.values()); await Promise.all( - triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + triggersArr.map((trigger) => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); await Promise.all( - triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + triggersArr.map((trigger) => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), ); } diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts index c1c183eb..391ee191 100644 --- a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts +++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts @@ -1,9 +1,18 @@ import { parseSignature, typedGuildCommand } from "knub"; import { commandTypes } from "../../commandTypes"; -import { stripObjectToScalars } from "../../utils"; +import { stripObjectToScalars, UnknownUser } from "../../utils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { runEvent } from "./functions/runEvent"; import { ConfigSchema, CustomEventsPluginType } from "./types"; +import { createTypedTemplateSafeValueContainer, TemplateSafeValueContainer } from "../../templateFormatter"; +import { Channel, GuildChannel, GuildMember, ThreadChannel, User } from "discord.js"; +import { + channelToTemplateSafeChannel, + memberToTemplateSafeMember, + messageToTemplateSafeMessage, + userToTemplateSafeUser, +} from "../../utils/templateSafeObjects"; +import { isScalar } from "../../utils/isScalar"; const defaultOptions = { config: { @@ -28,8 +37,25 @@ export const CustomEventsPlugin = zeppelinGuildPlugin()( permission: `events.${key}.trigger.can_use`, signature, run({ message, args }) { - const strippedMsg = stripObjectToScalars(message, ["channel", "author"]); - runEvent(pluginData, event, { msg: message, args }, { args, msg: strippedMsg }); + const safeArgs = new TemplateSafeValueContainer(); + for (const [argKey, argValue] of Object.entries(args as Record)) { + if (argValue instanceof User || argValue instanceof UnknownUser) { + safeArgs[argKey] = userToTemplateSafeUser(argValue); + } else if (argValue instanceof GuildMember) { + safeArgs[argKey] = memberToTemplateSafeMember(argValue); + } else if (argValue instanceof GuildChannel || argValue instanceof ThreadChannel) { + safeArgs[argKey] = channelToTemplateSafeChannel(argValue); + } else if (isScalar(argValue)) { + safeArgs[argKey] = argValue; + } + } + + const values = createTypedTemplateSafeValueContainer({ + ...safeArgs, + msg: messageToTemplateSafeMessage(message), + }); + + runEvent(pluginData, event, { msg: message, args }, values); }, }); pluginData.commands.add(eventCommand); diff --git a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts index 6621763d..52d6fd59 100644 --- a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts +++ b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts @@ -2,7 +2,7 @@ import { Snowflake } from "discord.js"; import * as t from "io-ts"; import { GuildPluginData } from "knub"; import { canActOn } from "../../../pluginUtils"; -import { renderTemplate } from "../../../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { resolveMember } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; @@ -17,7 +17,7 @@ export type TAddRoleAction = t.TypeOf; export async function addRoleAction( pluginData: GuildPluginData, action: TAddRoleAction, - values: any, + values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { @@ -28,9 +28,11 @@ export async function addRoleAction( if (event.trigger.type === "command" && !canActOn(pluginData, eventData.msg.member, target)) { throw new ActionError("Missing permissions"); } - - const rolesToAdd = Array.isArray(action.role) ? action.role : [action.role]; - await target.edit({ - roles: Array.from(new Set([...target.roles.cache.values(), ...rolesToAdd])) as Snowflake[], - }); + const rolesToAdd = (Array.isArray(action.role) ? action.role : [action.role]).filter( + (id) => !target.roles.cache.has(id), + ); + if (rolesToAdd.length === 0) { + throw new ActionError("Target already has the role(s) specified"); + } + await target.roles.add(rolesToAdd); } diff --git a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts index e9e4fac1..c3203389 100644 --- a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts +++ b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts @@ -1,7 +1,7 @@ import * as t from "io-ts"; import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes"; -import { renderTemplate } from "../../../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; @@ -18,7 +18,7 @@ export type TCreateCaseAction = t.TypeOf; export async function createCaseAction( pluginData: GuildPluginData, action: TCreateCaseAction, - values: any, + values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts index 11974a7a..73994d2e 100644 --- a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts +++ b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts @@ -4,6 +4,7 @@ import { GuildPluginData } from "knub"; import { convertDelayStringToMS, noop, tDelayString } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; +import { TemplateSafeValueContainer } from "../../../templateFormatter"; export const MakeRoleMentionableAction = t.type({ type: t.literal("make_role_mentionable"), @@ -15,7 +16,7 @@ export type TMakeRoleMentionableAction = t.TypeOf, action: TMakeRoleMentionableAction, - values: any, + values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts index 505cb3f7..1975b1c1 100644 --- a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts +++ b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts @@ -3,6 +3,7 @@ import * as t from "io-ts"; import { GuildPluginData } from "knub"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; +import { TemplateSafeValueContainer } from "../../../templateFormatter"; export const MakeRoleUnmentionableAction = t.type({ type: t.literal("make_role_unmentionable"), @@ -13,7 +14,7 @@ export type TMakeRoleUnmentionableAction = t.TypeOf, action: TMakeRoleUnmentionableAction, - values: any, + values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { diff --git a/backend/src/plugins/CustomEvents/actions/messageAction.ts b/backend/src/plugins/CustomEvents/actions/messageAction.ts index 90630f1f..3c4bba46 100644 --- a/backend/src/plugins/CustomEvents/actions/messageAction.ts +++ b/backend/src/plugins/CustomEvents/actions/messageAction.ts @@ -1,7 +1,7 @@ import { Snowflake, TextChannel } from "discord.js"; import * as t from "io-ts"; import { GuildPluginData } from "knub"; -import { renderTemplate } from "../../../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType } from "../types"; @@ -15,7 +15,7 @@ export type TMessageAction = t.TypeOf; export async function messageAction( pluginData: GuildPluginData, action: TMessageAction, - values: any, + values: TemplateSafeValueContainer, ) { const targetChannelId = await renderTemplate(action.channel, values, false); const targetChannel = pluginData.guild.channels.cache.get(targetChannelId as Snowflake); diff --git a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts index 08507bac..73c6725c 100644 --- a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts +++ b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts @@ -2,7 +2,7 @@ import { Snowflake, VoiceChannel } from "discord.js"; import * as t from "io-ts"; import { GuildPluginData } from "knub"; import { canActOn } from "../../../pluginUtils"; -import { renderTemplate } from "../../../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { resolveMember } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; @@ -17,7 +17,7 @@ export type TMoveToVoiceChannelAction = t.TypeOf, action: TMoveToVoiceChannelAction, - values: any, + values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { diff --git a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts index 80acd61c..6b9e9b1b 100644 --- a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts +++ b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts @@ -1,8 +1,9 @@ -import { Permissions, Snowflake, TextChannel } from "discord.js"; +import { Permissions, Snowflake, TextChannel, PermissionString } from "discord.js"; import * as t from "io-ts"; import { GuildPluginData } from "knub"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; +import { TemplateSafeValueContainer } from "../../../templateFormatter"; export const SetChannelPermissionOverridesAction = t.type({ type: t.literal("set_channel_permission_overrides"), @@ -21,7 +22,7 @@ export type TSetChannelPermissionOverridesAction = t.TypeOf, action: TSetChannelPermissionOverridesAction, - values: any, + values: TemplateSafeValueContainer, event: TCustomEvent, eventData: any, ) { @@ -31,10 +32,17 @@ export async function setChannelPermissionOverridesAction( } for (const override of action.overrides) { - channel.permissionOverwrites.create( - override.id as Snowflake, - new Permissions(BigInt(override.allow)).add(BigInt(override.deny)).serialize(), - ); + const allow = new Permissions(BigInt(override.allow)).serialize(); + const deny = new Permissions(BigInt(override.deny)).serialize(); + const perms: Partial> = {}; + for (const key in allow) { + if (allow[key]) { + perms[key] = true; + } else if (deny[key]) { + perms[key] = false; + } + } + channel.permissionOverwrites.create(override.id as Snowflake, perms); /* await channel.permissionOverwrites overwritePermissions( diff --git a/backend/src/plugins/CustomEvents/functions/runEvent.ts b/backend/src/plugins/CustomEvents/functions/runEvent.ts index 1322d41b..404774fd 100644 --- a/backend/src/plugins/CustomEvents/functions/runEvent.ts +++ b/backend/src/plugins/CustomEvents/functions/runEvent.ts @@ -10,12 +10,13 @@ import { messageAction } from "../actions/messageAction"; import { moveToVoiceChannelAction } from "../actions/moveToVoiceChannelAction"; import { setChannelPermissionOverridesAction } from "../actions/setChannelPermissionOverrides"; import { CustomEventsPluginType, TCustomEvent } from "../types"; +import { TemplateSafeValueContainer } from "../../../templateFormatter"; export async function runEvent( pluginData: GuildPluginData, event: TCustomEvent, eventData: any, - values: any, + values: TemplateSafeValueContainer, ) { try { for (const action of event.actions) { diff --git a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts index 3f241c65..880bd94e 100644 --- a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts +++ b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts @@ -1,9 +1,11 @@ import * as t from "io-ts"; -import { GuildPluginData } from "knub"; +import { GuildPluginData, typedGuildEventListener } from "knub"; import { AllowedGuilds } from "../../data/AllowedGuilds"; import { MINUTES } from "../../utils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { GuildInfoSaverPluginType } from "./types"; +import { Guild } from "discord.js"; +import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; export const GuildInfoSaverPlugin = zeppelinGuildPlugin()({ name: "guild_info_saver", @@ -11,13 +13,18 @@ export const GuildInfoSaverPlugin = zeppelinGuildPlugin updateGuildInfo(pluginData), 60 * MINUTES); + void updateGuildInfo(pluginData.guild); + pluginData.state.updateInterval = setInterval(() => updateGuildInfo(pluginData.guild), 60 * MINUTES); }, beforeUnload(pluginData) { @@ -25,11 +32,13 @@ export const GuildInfoSaverPlugin = zeppelinGuildPlugin) { - pluginData.state.allowedGuilds.updateInfo( - pluginData.guild.id, - pluginData.guild.name, - pluginData.guild.iconURL(), - pluginData.guild.ownerId, - ); +async function updateGuildInfo(guild: Guild) { + const allowedGuilds = new AllowedGuilds(); + const existingData = (await allowedGuilds.find(guild.id))!; + allowedGuilds.updateInfo(guild.id, guild.name, guild.iconURL(), guild.ownerId); + + if (existingData.owner_id !== guild.ownerId || existingData.created_at === existingData.updated_at) { + const apiPermissions = new ApiPermissionAssignments(); + apiPermissions.applyOwnerChange(guild.id, guild.ownerId); + } } diff --git a/backend/src/plugins/GuildInfoSaver/types.ts b/backend/src/plugins/GuildInfoSaver/types.ts index 25926bff..29013186 100644 --- a/backend/src/plugins/GuildInfoSaver/types.ts +++ b/backend/src/plugins/GuildInfoSaver/types.ts @@ -3,7 +3,6 @@ import { AllowedGuilds } from "../../data/AllowedGuilds"; export interface GuildInfoSaverPluginType extends BasePluginType { state: { - allowedGuilds: AllowedGuilds; updateInterval: NodeJS.Timeout; }; } diff --git a/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts b/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts index bc4e8d0f..c93ad872 100644 --- a/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts +++ b/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts @@ -5,7 +5,7 @@ export const GuildBanRemoveAlertsEvt = locateUserEvt({ async listener(meta) { const alerts = await meta.pluginData.state.alerts.getAlertsByUserId(meta.args.ban.user.id); - alerts.forEach(alert => { + alerts.forEach((alert) => { meta.pluginData.state.alerts.delete(alert.id); }); }, diff --git a/backend/src/plugins/LocateUser/events/SendAlertsEvts.ts b/backend/src/plugins/LocateUser/events/SendAlertsEvts.ts index 552fb8ef..6510540c 100644 --- a/backend/src/plugins/LocateUser/events/SendAlertsEvts.ts +++ b/backend/src/plugins/LocateUser/events/SendAlertsEvts.ts @@ -6,7 +6,10 @@ export const VoiceStateUpdateAlertEvt = locateUserEvt({ event: "voiceStateUpdate", async listener(meta) { - const memberId = meta.args.oldState.member ? meta.args.oldState.member.id : meta.args.newState.member!.id; + const memberId = meta.args.oldState.member?.id ?? meta.args.newState.member?.id; + if (!memberId) { + return; + } if (meta.args.newState.channel != null) { if (meta.pluginData.state.usersWithAlerts.includes(memberId)) { @@ -16,7 +19,7 @@ export const VoiceStateUpdateAlertEvt = locateUserEvt({ const triggeredAlerts = await meta.pluginData.state.alerts.getAlertsByUserId(memberId); const voiceChannel = meta.args.oldState.channel!; - triggeredAlerts.forEach(alert => { + triggeredAlerts.forEach((alert) => { const txtChannel = meta.pluginData.guild.channels.resolve(alert.channel_id as Snowflake) as TextChannel; txtChannel.send({ content: `🔴 <@!${alert.requestor_id}> the user <@!${alert.user_id}> disconnected out of \`${voiceChannel.name}\``, diff --git a/backend/src/plugins/LocateUser/utils/fillAlertsList.ts b/backend/src/plugins/LocateUser/utils/fillAlertsList.ts index 3c5c90d1..56e4315f 100644 --- a/backend/src/plugins/LocateUser/utils/fillAlertsList.ts +++ b/backend/src/plugins/LocateUser/utils/fillAlertsList.ts @@ -4,7 +4,7 @@ import { LocateUserPluginType } from "../types"; export async function fillActiveAlertsList(pluginData: GuildPluginData) { const allAlerts = await pluginData.state.alerts.getAllGuildAlerts(); - allAlerts.forEach(alert => { + allAlerts.forEach((alert) => { if (!pluginData.state.usersWithAlerts.includes(alert.user_id)) { pluginData.state.usersWithAlerts.push(alert.user_id); } diff --git a/backend/src/plugins/LocateUser/utils/sendAlerts.ts b/backend/src/plugins/LocateUser/utils/sendAlerts.ts index 67f865bf..d93bf47d 100644 --- a/backend/src/plugins/LocateUser/utils/sendAlerts.ts +++ b/backend/src/plugins/LocateUser/utils/sendAlerts.ts @@ -10,7 +10,7 @@ export async function sendAlerts(pluginData: GuildPluginData { + triggeredAlerts.forEach((alert) => { const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${alert.body}\`\n`; const txtChannel = pluginData.guild.channels.resolve(alert.channel_id as Snowflake) as TextChannel; sendWhere(pluginData, member, txtChannel, prepend); diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts index d4285de8..4e79f583 100644 --- a/backend/src/plugins/Logs/LogsPlugin.ts +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -1,4 +1,4 @@ -import { PluginOptions } from "knub"; +import { CooldownManager, PluginOptions } from "knub"; import DefaultLogMessages from "../../data/DefaultLogMessages.json"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; @@ -7,7 +7,6 @@ import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { LogType } from "../../data/LogType"; import { logger } from "../../logger"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; -import { CasesPlugin } from "../Cases/CasesPlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { LogsChannelCreateEvt, LogsChannelDeleteEvt, LogsChannelUpdateEvt } from "./events/LogsChannelModifyEvts"; @@ -30,13 +29,89 @@ import { import { LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt } from "./events/LogsThreadModifyEvts"; import { LogsGuildMemberUpdateEvt } from "./events/LogsUserUpdateEvts"; import { LogsVoiceStateUpdateEvt } from "./events/LogsVoiceChannelEvts"; -import { ConfigSchema, FORMAT_NO_TIMESTAMP, LogsPluginType } from "./types"; +import { ConfigSchema, FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel } from "./types"; import { getLogMessage } from "./util/getLogMessage"; import { log } from "./util/log"; import { onMessageDelete } from "./util/onMessageDelete"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk"; import { onMessageUpdate } from "./util/onMessageUpdate"; import { Util } from "discord.js"; +import { + createTypedTemplateSafeValueContainer, + TemplateSafeValueContainer, + TypedTemplateSafeValueContainer, +} from "../../templateFormatter"; +import { mapToPublicFn } from "../../pluginUtils"; + +import { logAutomodAction } from "./logFunctions/logAutomodAction"; +import { logBotAlert } from "./logFunctions/logBotAlert"; +import { logCaseCreate } from "./logFunctions/logCaseCreate"; +import { logCaseDelete } from "./logFunctions/logCaseDelete"; +import { logCaseUpdate } from "./logFunctions/logCaseUpdate"; +import { logCensor } from "./logFunctions/logCensor"; +import { logChannelCreate } from "./logFunctions/logChannelCreate"; +import { logChannelDelete } from "./logFunctions/logChannelDelete"; +import { logChannelUpdate } from "./logFunctions/logChannelUpdate"; +import { logClean } from "./logFunctions/logClean"; +import { logEmojiCreate } from "./logFunctions/logEmojiCreate"; +import { logEmojiDelete } from "./logFunctions/logEmojiDelete"; +import { logEmojiUpdate } from "./logFunctions/logEmojiUpdate"; +import { logMassBan } from "./logFunctions/logMassBan"; +import { logMassMute } from "./logFunctions/logMassMute"; +import { logMassUnban } from "./logFunctions/logMassUnban"; +import { logMemberBan } from "./logFunctions/logMemberBan"; +import { logMemberForceban } from "./logFunctions/logMemberForceban"; +import { logMemberJoin } from "./logFunctions/logMemberJoin"; +import { logMemberJoinWithPriorRecords } from "./logFunctions/logMemberJoinWithPriorRecords"; +import { logMemberKick } from "./logFunctions/logMemberKick"; +import { logMemberLeave } from "./logFunctions/logMemberLeave"; +import { logMemberMute } from "./logFunctions/logMemberMute"; +import { logMemberMuteExpired } from "./logFunctions/logMemberMuteExpired"; +import { logMemberMuteRejoin } from "./logFunctions/logMemberMuteRejoin"; +import { logMemberNickChange } from "./logFunctions/logMemberNickChange"; +import { logMemberNote } from "./logFunctions/logMemberNote"; +import { logMemberRestore } from "./logFunctions/logMemberRestore"; +import { logMemberRoleAdd } from "./logFunctions/logMemberRoleAdd"; +import { logMemberRoleChanges } from "./logFunctions/logMemberRoleChanges"; +import { logMemberRoleRemove } from "./logFunctions/logMemberRoleRemove"; +import { logMemberTimedBan } from "./logFunctions/logMemberTimedBan"; +import { logMemberTimedMute } from "./logFunctions/logMemberTimedMute"; +import { logMemberTimedUnmute } from "./logFunctions/logMemberTimedUnmute"; +import { logMemberUnban } from "./logFunctions/logMemberUnban"; +import { logMemberUnmute } from "./logFunctions/logMemberUnmute"; +import { logMemberWarn } from "./logFunctions/logMemberWarn"; +import { logMessageDelete } from "./logFunctions/logMessageDelete"; +import { logMessageDeleteAuto } from "./logFunctions/logMessageDeleteAuto"; +import { logMessageDeleteBare } from "./logFunctions/logMessageDeleteBare"; +import { logMessageDeleteBulk } from "./logFunctions/logMessageDeleteBulk"; +import { logMessageEdit } from "./logFunctions/logMessageEdit"; +import { logMessageSpamDetected } from "./logFunctions/logMessageSpamDetected"; +import { logOtherSpamDetected } from "./logFunctions/logOtherSpamDetected"; +import { logPostedScheduledMessage } from "./logFunctions/logPostedScheduledMessage"; +import { logRepeatedMessage } from "./logFunctions/logRepeatedMessage"; +import { logRoleCreate } from "./logFunctions/logRoleCreate"; +import { logRoleDelete } from "./logFunctions/logRoleDelete"; +import { logRoleUpdate } from "./logFunctions/logRoleUpdate"; +import { logScheduledMessage } from "./logFunctions/logScheduledMessage"; +import { logScheduledRepeatedMessage } from "./logFunctions/logScheduledRepeatedMessage"; +import { logSetAntiraidAuto } from "./logFunctions/logSetAntiraidAuto"; +import { logSetAntiraidUser } from "./logFunctions/logSetAntiraidUser"; +import { logStageInstanceCreate } from "./logFunctions/logStageInstanceCreate"; +import { logStageInstanceDelete } from "./logFunctions/logStageInstanceDelete"; +import { logStageInstanceUpdate } from "./logFunctions/logStageInstanceUpdate"; +import { logStickerCreate } from "./logFunctions/logStickerCreate"; +import { logStickerDelete } from "./logFunctions/logStickerDelete"; +import { logStickerUpdate } from "./logFunctions/logStickerUpdate"; +import { logThreadCreate } from "./logFunctions/logThreadCreate"; +import { logThreadDelete } from "./logFunctions/logThreadDelete"; +import { logThreadUpdate } from "./logFunctions/logThreadUpdate"; +import { logVoiceChannelForceDisconnect } from "./logFunctions/logVoiceChannelForceDisconnect"; +import { logVoiceChannelForceMove } from "./logFunctions/logVoiceChannelForceMove"; +import { logVoiceChannelJoin } from "./logFunctions/logVoiceChannelJoin"; +import { logVoiceChannelLeave } from "./logFunctions/logVoiceChannelLeave"; +import { logVoiceChannelMove } from "./logFunctions/logVoiceChannelMove"; +import { logMemberTimedUnban } from "./logFunctions/logMemberTimedUnban"; +import { logDmFailed } from "./logFunctions/logDmFailed"; const defaultOptions: PluginOptions = { config: { @@ -68,7 +143,11 @@ export const LogsPlugin = zeppelinGuildPlugin()({ prettyName: "Logs", }, - dependencies: [TimeAndDatePlugin, CasesPlugin], + dependencies: async () => [ + TimeAndDatePlugin, + // The `as any` cast here is to prevent TypeScript from locking up from the circular dependency + ((await import("../Cases/CasesPlugin")) as any).CasesPlugin, + ], configSchema: ConfigSchema, defaultOptions, @@ -98,17 +177,85 @@ export const LogsPlugin = zeppelinGuildPlugin()({ ], public: { - log(pluginData) { - return (type: LogType, data: any) => { - return log(pluginData, type, data); + getLogMessage: (pluginData) => { + return ( + type: TLogType, + data: TypedTemplateSafeValueContainer, + opts?: Pick, + ) => { + return getLogMessage(pluginData, type, data, opts); }; }, - getLogMessage(pluginData) { - return (type: LogType, data: any) => { - return getLogMessage(pluginData, type, data); - }; - }, + logAutomodAction: mapToPublicFn(logAutomodAction), + logBotAlert: mapToPublicFn(logBotAlert), + logCaseCreate: mapToPublicFn(logCaseCreate), + logCaseDelete: mapToPublicFn(logCaseDelete), + logCaseUpdate: mapToPublicFn(logCaseUpdate), + logCensor: mapToPublicFn(logCensor), + logChannelCreate: mapToPublicFn(logChannelCreate), + logChannelDelete: mapToPublicFn(logChannelDelete), + logChannelUpdate: mapToPublicFn(logChannelUpdate), + logClean: mapToPublicFn(logClean), + logEmojiCreate: mapToPublicFn(logEmojiCreate), + logEmojiDelete: mapToPublicFn(logEmojiDelete), + logEmojiUpdate: mapToPublicFn(logEmojiUpdate), + logMassBan: mapToPublicFn(logMassBan), + logMassMute: mapToPublicFn(logMassMute), + logMassUnban: mapToPublicFn(logMassUnban), + logMemberBan: mapToPublicFn(logMemberBan), + logMemberForceban: mapToPublicFn(logMemberForceban), + logMemberJoin: mapToPublicFn(logMemberJoin), + logMemberJoinWithPriorRecords: mapToPublicFn(logMemberJoinWithPriorRecords), + logMemberKick: mapToPublicFn(logMemberKick), + logMemberLeave: mapToPublicFn(logMemberLeave), + logMemberMute: mapToPublicFn(logMemberMute), + logMemberMuteExpired: mapToPublicFn(logMemberMuteExpired), + logMemberMuteRejoin: mapToPublicFn(logMemberMuteRejoin), + logMemberNickChange: mapToPublicFn(logMemberNickChange), + logMemberNote: mapToPublicFn(logMemberNote), + logMemberRestore: mapToPublicFn(logMemberRestore), + logMemberRoleAdd: mapToPublicFn(logMemberRoleAdd), + logMemberRoleChanges: mapToPublicFn(logMemberRoleChanges), + logMemberRoleRemove: mapToPublicFn(logMemberRoleRemove), + logMemberTimedBan: mapToPublicFn(logMemberTimedBan), + logMemberTimedMute: mapToPublicFn(logMemberTimedMute), + logMemberTimedUnban: mapToPublicFn(logMemberTimedUnban), + logMemberTimedUnmute: mapToPublicFn(logMemberTimedUnmute), + logMemberUnban: mapToPublicFn(logMemberUnban), + logMemberUnmute: mapToPublicFn(logMemberUnmute), + logMemberWarn: mapToPublicFn(logMemberWarn), + logMessageDelete: mapToPublicFn(logMessageDelete), + logMessageDeleteAuto: mapToPublicFn(logMessageDeleteAuto), + logMessageDeleteBare: mapToPublicFn(logMessageDeleteBare), + logMessageDeleteBulk: mapToPublicFn(logMessageDeleteBulk), + logMessageEdit: mapToPublicFn(logMessageEdit), + logMessageSpamDetected: mapToPublicFn(logMessageSpamDetected), + logOtherSpamDetected: mapToPublicFn(logOtherSpamDetected), + logPostedScheduledMessage: mapToPublicFn(logPostedScheduledMessage), + logRepeatedMessage: mapToPublicFn(logRepeatedMessage), + logRoleCreate: mapToPublicFn(logRoleCreate), + logRoleDelete: mapToPublicFn(logRoleDelete), + logRoleUpdate: mapToPublicFn(logRoleUpdate), + logScheduledMessage: mapToPublicFn(logScheduledMessage), + logScheduledRepeatedMessage: mapToPublicFn(logScheduledRepeatedMessage), + logSetAntiraidAuto: mapToPublicFn(logSetAntiraidAuto), + logSetAntiraidUser: mapToPublicFn(logSetAntiraidUser), + logStageInstanceCreate: mapToPublicFn(logStageInstanceCreate), + logStageInstanceDelete: mapToPublicFn(logStageInstanceDelete), + logStageInstanceUpdate: mapToPublicFn(logStageInstanceUpdate), + logStickerCreate: mapToPublicFn(logStickerCreate), + logStickerDelete: mapToPublicFn(logStickerDelete), + logStickerUpdate: mapToPublicFn(logStickerUpdate), + logThreadCreate: mapToPublicFn(logThreadCreate), + logThreadDelete: mapToPublicFn(logThreadDelete), + logThreadUpdate: mapToPublicFn(logThreadUpdate), + logVoiceChannelForceDisconnect: mapToPublicFn(logVoiceChannelForceDisconnect), + logVoiceChannelForceMove: mapToPublicFn(logVoiceChannelForceMove), + logVoiceChannelJoin: mapToPublicFn(logVoiceChannelJoin), + logVoiceChannelLeave: mapToPublicFn(logVoiceChannelLeave), + logVoiceChannelMove: mapToPublicFn(logVoiceChannelMove), + logDmFailed: mapToPublicFn(logDmFailed), }, beforeLoad(pluginData) { @@ -119,7 +266,8 @@ export const LogsPlugin = zeppelinGuildPlugin()({ state.archives = GuildArchives.getGuildInstance(guild.id); state.cases = GuildCases.getGuildInstance(guild.id); - state.batches = new Map(); + state.buffers = new Map(); + state.channelCooldowns = new CooldownManager(); state.regexRunner = getRegExpRunner(`guild-${pluginData.guild.id}`); }, @@ -130,10 +278,10 @@ export const LogsPlugin = zeppelinGuildPlugin()({ state.logListener = ({ type, data }) => log(pluginData, type, data); state.guildLogs.on("log", state.logListener); - state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg); state.savedMessages.events.on("delete", state.onMessageDeleteFn); - state.onMessageDeleteBulkFn = msg => onMessageDeleteBulk(pluginData, msg); + state.onMessageDeleteBulkFn = (msg) => onMessageDeleteBulk(pluginData, msg); state.savedMessages.events.on("deleteBulk", state.onMessageDeleteBulkFn); state.onMessageUpdateFn = (newMsg, oldMsg) => onMessageUpdate(pluginData, newMsg, oldMsg); @@ -141,15 +289,19 @@ export const LogsPlugin = zeppelinGuildPlugin()({ state.regexRunnerRepeatedTimeoutListener = (regexSource, timeoutMs, failedTimes) => { logger.warn(`Disabled heavy regex temporarily: ${regexSource}`); - log(pluginData, LogType.BOT_ALERT, { - body: - ` + log( + pluginData, + LogType.BOT_ALERT, + createTypedTemplateSafeValueContainer({ + body: + ` The following regex has taken longer than ${timeoutMs}ms for ${failedTimes} times and has been temporarily disabled: `.trim() + - "\n```" + - Util.escapeCodeBlock(regexSource) + - "```", - }); + "\n```" + + Util.escapeCodeBlock(regexSource) + + "```", + }), + ); }; state.regexRunner.on("repeatedTimeout", state.regexRunnerRepeatedTimeoutListener); }, diff --git a/backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts b/backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts index a6aa8c64..33bd7df9 100644 --- a/backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts +++ b/backend/src/plugins/Logs/events/LogsChannelModifyEvts.ts @@ -1,14 +1,19 @@ import { LogType } from "../../../data/LogType"; import { differenceToString, getScalarDifference } from "../../../utils"; -import { channelToConfigAccessibleChannel } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; import { logsEvt } from "../types"; +import { logChannelCreate } from "../logFunctions/logChannelCreate"; +import { logChannelDelete } from "../logFunctions/logChannelDelete"; +import { logChannelUpdate } from "../logFunctions/logChannelUpdate"; +import { TextChannel, VoiceChannel } from "discord.js"; +import { filterObject } from "../../../utils/filterObject"; export const LogsChannelCreateEvt = logsEvt({ event: "channelCreate", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.CHANNEL_CREATE, { - channel: channelToConfigAccessibleChannel(meta.args.channel), + logChannelCreate(meta.pluginData, { + channel: meta.args.channel, }); }, }); @@ -17,27 +22,42 @@ export const LogsChannelDeleteEvt = logsEvt({ event: "channelDelete", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.CHANNEL_DELETE, { - channel: channelToConfigAccessibleChannel(meta.args.channel), + logChannelDelete(meta.pluginData, { + channel: meta.args.channel, }); }, }); +const validChannelDiffProps: Set = new Set([ + "name", + "parentId", + "nsfw", + "rateLimitPerUser", + "topic", + "bitrate", +]); + export const LogsChannelUpdateEvt = logsEvt({ event: "channelUpdate", async listener(meta) { - const diff = getScalarDifference(meta.args.oldChannel, meta.args.newChannel); + if (meta.args.oldChannel?.partial) { + return; + } + + const oldChannelDiffProps = filterObject(meta.args.oldChannel || {}, (v, k) => validChannelDiffProps.has(k)); + const newChannelDiffProps = filterObject(meta.args.newChannel, (v, k) => validChannelDiffProps.has(k)); + const diff = getScalarDifference(oldChannelDiffProps, newChannelDiffProps); const differenceString = differenceToString(diff); - meta.pluginData.state.guildLogs.log( - LogType.CHANNEL_UPDATE, - { - oldChannel: channelToConfigAccessibleChannel(meta.args.oldChannel), - newChannel: channelToConfigAccessibleChannel(meta.args.newChannel), - differenceString, - }, - meta.args.newChannel.id, - ); + if (differenceString.trim() === "") { + return; + } + + logChannelUpdate(meta.pluginData, { + oldChannel: meta.args.oldChannel, + newChannel: meta.args.newChannel, + differenceString, + }); }, }); diff --git a/backend/src/plugins/Logs/events/LogsEmojiAndStickerModifyEvts.ts b/backend/src/plugins/Logs/events/LogsEmojiAndStickerModifyEvts.ts index 4f8ce433..29b8eb61 100644 --- a/backend/src/plugins/Logs/events/LogsEmojiAndStickerModifyEvts.ts +++ b/backend/src/plugins/Logs/events/LogsEmojiAndStickerModifyEvts.ts @@ -1,18 +1,20 @@ -import { LogType } from "../../../data/LogType"; import { differenceToString, getScalarDifference } from "../../../utils"; -import { - channelToConfigAccessibleChannel, - emojiToConfigAccessibleEmoji, - stickerToConfigAccessibleSticker, -} from "../../../utils/configAccessibleObjects"; import { logsEvt } from "../types"; +import { logEmojiCreate } from "../logFunctions/logEmojiCreate"; +import { logEmojiDelete } from "../logFunctions/logEmojiDelete"; +import { logEmojiUpdate } from "../logFunctions/logEmojiUpdate"; +import { logStickerCreate } from "../logFunctions/logStickerCreate"; +import { logStickerDelete } from "../logFunctions/logStickerDelete"; +import { logStickerUpdate } from "../logFunctions/logStickerUpdate"; +import { Emoji, GuildEmoji, Sticker, ThreadChannel } from "discord.js"; +import { filterObject } from "../../../utils/filterObject"; export const LogsEmojiCreateEvt = logsEvt({ event: "emojiCreate", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.EMOJI_CREATE, { - emoji: emojiToConfigAccessibleEmoji(meta.args.emoji), + logEmojiCreate(meta.pluginData, { + emoji: meta.args.emoji, }); }, }); @@ -21,22 +23,30 @@ export const LogsEmojiDeleteEvt = logsEvt({ event: "emojiDelete", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.EMOJI_DELETE, { - emoji: emojiToConfigAccessibleEmoji(meta.args.emoji), + logEmojiDelete(meta.pluginData, { + emoji: meta.args.emoji, }); }, }); +const validEmojiDiffProps: Set = new Set(["name"]); + export const LogsEmojiUpdateEvt = logsEvt({ event: "emojiUpdate", async listener(meta) { - const diff = getScalarDifference(meta.args.oldEmoji, meta.args.newEmoji); + const oldEmojiDiffProps = filterObject(meta.args.oldEmoji || {}, (v, k) => validEmojiDiffProps.has(k)); + const newEmojiDiffProps = filterObject(meta.args.newEmoji, (v, k) => validEmojiDiffProps.has(k)); + const diff = getScalarDifference(oldEmojiDiffProps, newEmojiDiffProps); const differenceString = differenceToString(diff); - meta.pluginData.state.guildLogs.log(LogType.EMOJI_UPDATE, { - oldEmoji: emojiToConfigAccessibleEmoji(meta.args.oldEmoji), - newEmoji: emojiToConfigAccessibleEmoji(meta.args.newEmoji), + if (differenceString === "") { + return; + } + + logEmojiUpdate(meta.pluginData, { + oldEmoji: meta.args.oldEmoji, + newEmoji: meta.args.newEmoji, differenceString, }); }, @@ -46,8 +56,8 @@ export const LogsStickerCreateEvt = logsEvt({ event: "stickerCreate", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.STICKER_CREATE, { - sticker: stickerToConfigAccessibleSticker(meta.args.sticker), + logStickerCreate(meta.pluginData, { + sticker: meta.args.sticker, }); }, }); @@ -56,27 +66,31 @@ export const LogsStickerDeleteEvt = logsEvt({ event: "stickerDelete", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.STICKER_DELETE, { - sticker: stickerToConfigAccessibleSticker(meta.args.sticker), + logStickerDelete(meta.pluginData, { + sticker: meta.args.sticker, }); }, }); +const validStickerDiffProps: Set = new Set(["name"]); + export const LogsStickerUpdateEvt = logsEvt({ event: "stickerUpdate", async listener(meta) { - const diff = getScalarDifference(meta.args.oldSticker, meta.args.newSticker); + const oldStickerDiffProps = filterObject(meta.args.oldSticker || {}, (v, k) => validStickerDiffProps.has(k)); + const newStickerDiffProps = filterObject(meta.args.newSticker, (v, k) => validStickerDiffProps.has(k)); + const diff = getScalarDifference(oldStickerDiffProps, newStickerDiffProps); const differenceString = differenceToString(diff); - meta.pluginData.state.guildLogs.log( - LogType.STICKER_UPDATE, - { - oldSticker: stickerToConfigAccessibleSticker(meta.args.oldSticker), - newSticker: stickerToConfigAccessibleSticker(meta.args.newSticker), - differenceString, - }, - meta.args.newSticker.id, - ); + if (differenceString === "") { + return; + } + + logStickerUpdate(meta.pluginData, { + oldSticker: meta.args.oldSticker, + newSticker: meta.args.newSticker, + differenceString, + }); }, }); diff --git a/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts b/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts index 580ce85e..f9baa897 100644 --- a/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts +++ b/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts @@ -1,8 +1,11 @@ import { GuildAuditLogs } from "discord.js"; import { LogType } from "../../../data/LogType"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { safeFindRelevantAuditLogEntry } from "../../../utils/safeFindRelevantAuditLogEntry"; import { logsEvt } from "../types"; +import { logMemberBan } from "../logFunctions/logMemberBan"; +import { isLogIgnored } from "../util/isLogIgnored"; +import { logMemberUnban } from "../logFunctions/logMemberUnban"; export const LogsGuildBanAddEvt = logsEvt({ event: "guildBanAdd", @@ -11,21 +14,22 @@ export const LogsGuildBanAddEvt = logsEvt({ const pluginData = meta.pluginData; const user = meta.args.ban.user; + if (isLogIgnored(pluginData, LogType.MEMBER_BAN, user.id)) { + return; + } + const relevantAuditLogEntry = await safeFindRelevantAuditLogEntry( pluginData, GuildAuditLogs.Actions.MEMBER_BAN_ADD as number, user.id, ); const mod = relevantAuditLogEntry?.executor ?? null; - - pluginData.state.guildLogs.log( - LogType.MEMBER_BAN, - { - mod: mod ? userToConfigAccessibleUser(mod) : {}, - user: userToConfigAccessibleUser(user), - }, - user.id, - ); + logMemberBan(meta.pluginData, { + mod, + user, + caseNumber: 0, + reason: "", + }); }, }); @@ -36,6 +40,10 @@ export const LogsGuildBanRemoveEvt = logsEvt({ const pluginData = meta.pluginData; const user = meta.args.ban.user; + if (isLogIgnored(pluginData, LogType.MEMBER_UNBAN, user.id)) { + return; + } + const relevantAuditLogEntry = await safeFindRelevantAuditLogEntry( pluginData, GuildAuditLogs.Actions.MEMBER_BAN_REMOVE as number, @@ -43,13 +51,11 @@ export const LogsGuildBanRemoveEvt = logsEvt({ ); const mod = relevantAuditLogEntry?.executor ?? null; - pluginData.state.guildLogs.log( - LogType.MEMBER_UNBAN, - { - mod: mod ? userToConfigAccessibleUser(mod) : {}, - userId: user.id, - }, - user.id, - ); + logMemberUnban(pluginData, { + mod, + userId: user.id, + caseNumber: 0, + reason: "", + }); }, }); diff --git a/backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts b/backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts index 099c3cb3..c86a1fc3 100644 --- a/backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts +++ b/backend/src/plugins/Logs/events/LogsGuildMemberAddEvt.ts @@ -1,9 +1,10 @@ import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; import { LogType } from "../../../data/LogType"; -import { memberToConfigAccessibleMember } from "../../../utils/configAccessibleObjects"; -import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { logsEvt } from "../types"; +import { logMemberJoin } from "../logFunctions/logMemberJoin"; +import { logMemberJoinWithPriorRecords } from "../logFunctions/logMemberJoinWithPriorRecords"; export const LogsGuildMemberAddEvt = logsEvt({ event: "guildMemberAdd", @@ -12,43 +13,37 @@ export const LogsGuildMemberAddEvt = logsEvt({ const pluginData = meta.pluginData; const member = meta.args.member; - const newThreshold = moment.utc().valueOf() - 1000 * 60 * 60; - const accountAge = humanizeDuration(moment.utc().valueOf() - member.user.createdTimestamp, { - largest: 2, - round: true, + logMemberJoin(pluginData, { + member, }); - pluginData.state.guildLogs.log(LogType.MEMBER_JOIN, { - member: memberToConfigAccessibleMember(member), - new: member.user.createdTimestamp >= newThreshold ? " :new:" : "", - account_age: accountAge, - }); + // TODO: Uncomment below once circular dependencies in Knub have been fixed - const cases = (await pluginData.state.cases.with("notes").getByUserId(member.id)).filter(c => !c.is_hidden); - cases.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)); - - if (cases.length) { - const recentCaseLines: string[] = []; - const recentCases = cases.slice(0, 2); - const casesPlugin = pluginData.getPlugin(CasesPlugin); - for (const theCase of recentCases) { - recentCaseLines.push((await casesPlugin.getCaseSummary(theCase))!); - } - - let recentCaseSummary = recentCaseLines.join("\n"); - if (recentCases.length < cases.length) { - const remaining = cases.length - recentCases.length; - if (remaining === 1) { - recentCaseSummary += `\n*+${remaining} case*`; - } else { - recentCaseSummary += `\n*+${remaining} cases*`; - } - } - - pluginData.state.guildLogs.log(LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS, { - member: memberToConfigAccessibleMember(member), - recentCaseSummary, - }); - } + // const cases = (await pluginData.state.cases.with("notes").getByUserId(member.id)).filter(c => !c.is_hidden); + // cases.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)); + // + // if (cases.length) { + // const recentCaseLines: string[] = []; + // const recentCases = cases.slice(0, 2); + // const casesPlugin = pluginData.getPlugin(CasesPlugin); + // for (const theCase of recentCases) { + // recentCaseLines.push((await casesPlugin.getCaseSummary(theCase))!); + // } + // + // let recentCaseSummary = recentCaseLines.join("\n"); + // if (recentCases.length < cases.length) { + // const remaining = cases.length - recentCases.length; + // if (remaining === 1) { + // recentCaseSummary += `\n*+${remaining} case*`; + // } else { + // recentCaseSummary += `\n*+${remaining} cases*`; + // } + // } + // + // logMemberJoinWithPriorRecords(pluginData, { + // member, + // recentCaseSummary, + // }); + // } }, }); diff --git a/backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts b/backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts index 8b18516d..289372e1 100644 --- a/backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts +++ b/backend/src/plugins/Logs/events/LogsGuildMemberRemoveEvt.ts @@ -1,13 +1,14 @@ import { LogType } from "../../../data/LogType"; -import { memberToConfigAccessibleMember } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { logsEvt } from "../types"; +import { logMemberLeave } from "../logFunctions/logMemberLeave"; export const LogsGuildMemberRemoveEvt = logsEvt({ event: "guildMemberRemove", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.MEMBER_LEAVE, { - member: memberToConfigAccessibleMember(meta.args.member), + logMemberLeave(meta.pluginData, { + member: meta.args.member, }); }, }); diff --git a/backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts b/backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts index e9ad1b07..81d6e2bf 100644 --- a/backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts +++ b/backend/src/plugins/Logs/events/LogsRoleModifyEvts.ts @@ -1,14 +1,19 @@ import { LogType } from "../../../data/LogType"; import { differenceToString, getScalarDifference } from "../../../utils"; -import { roleToConfigAccessibleRole } from "../../../utils/configAccessibleObjects"; +import { roleToTemplateSafeRole } from "../../../utils/templateSafeObjects"; import { logsEvt } from "../types"; +import { logRoleCreate } from "../logFunctions/logRoleCreate"; +import { logRoleDelete } from "../logFunctions/logRoleDelete"; +import { logRoleUpdate } from "../logFunctions/logRoleUpdate"; +import { GuildEmoji, Role } from "discord.js"; +import { filterObject } from "../../../utils/filterObject"; export const LogsRoleCreateEvt = logsEvt({ event: "roleCreate", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.ROLE_CREATE, { - role: roleToConfigAccessibleRole(meta.args.role), + logRoleCreate(meta.pluginData, { + role: meta.args.role, }); }, }); @@ -17,22 +22,26 @@ export const LogsRoleDeleteEvt = logsEvt({ event: "roleDelete", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.ROLE_DELETE, { - role: roleToConfigAccessibleRole(meta.args.role), + logRoleDelete(meta.pluginData, { + role: meta.args.role, }); }, }); +const validRoleDiffProps: Set = new Set(["name", "hoist", "color", "mentionable"]); + export const LogsRoleUpdateEvt = logsEvt({ event: "roleUpdate", async listener(meta) { - const diff = getScalarDifference(meta.args.oldRole, meta.args.newRole); + const oldRoleDiffProps = filterObject(meta.args.oldRole || {}, (v, k) => validRoleDiffProps.has(k)); + const newRoleDiffProps = filterObject(meta.args.newRole, (v, k) => validRoleDiffProps.has(k)); + const diff = getScalarDifference(oldRoleDiffProps, newRoleDiffProps); const differenceString = differenceToString(diff); - meta.pluginData.state.guildLogs.log(LogType.ROLE_UPDATE, { - newRole: roleToConfigAccessibleRole(meta.args.newRole), - oldRole: roleToConfigAccessibleRole(meta.args.oldRole), + logRoleUpdate(meta.pluginData, { + newRole: meta.args.newRole, + oldRole: meta.args.oldRole, differenceString, }); }, diff --git a/backend/src/plugins/Logs/events/LogsStageInstanceModifyEvts.ts b/backend/src/plugins/Logs/events/LogsStageInstanceModifyEvts.ts index b2cb0989..f9ddb54a 100644 --- a/backend/src/plugins/Logs/events/LogsStageInstanceModifyEvts.ts +++ b/backend/src/plugins/Logs/events/LogsStageInstanceModifyEvts.ts @@ -1,7 +1,12 @@ import { LogType } from "../../../data/LogType"; import { differenceToString, getScalarDifference } from "../../../utils"; -import { channelToConfigAccessibleChannel, stageToConfigAccessibleStage } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, stageToTemplateSafeStage } from "../../../utils/templateSafeObjects"; import { logsEvt } from "../types"; +import { logStageInstanceCreate } from "../logFunctions/logStageInstanceCreate"; +import { Role, StageChannel, StageInstance } from "discord.js"; +import { logStageInstanceDelete } from "../logFunctions/logStageInstanceDelete"; +import { logStageInstanceUpdate } from "../logFunctions/logStageInstanceUpdate"; +import { filterObject } from "../../../utils/filterObject"; export const LogsStageInstanceCreateEvt = logsEvt({ event: "stageInstanceCreate", @@ -9,11 +14,11 @@ export const LogsStageInstanceCreateEvt = logsEvt({ async listener(meta) { const stageChannel = meta.args.stageInstance.channel ?? - (await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId))!; + ((await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId)) as StageChannel); - meta.pluginData.state.guildLogs.log(LogType.STAGE_INSTANCE_CREATE, { - stageInstance: stageToConfigAccessibleStage(meta.args.stageInstance), - stageChannel: channelToConfigAccessibleChannel(stageChannel), + logStageInstanceCreate(meta.pluginData, { + stageInstance: meta.args.stageInstance, + stageChannel, }); }, }); @@ -24,30 +29,42 @@ export const LogsStageInstanceDeleteEvt = logsEvt({ async listener(meta) { const stageChannel = meta.args.stageInstance.channel ?? - (await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId))!; + ((await meta.pluginData.guild.channels.fetch(meta.args.stageInstance.channelId)) as StageChannel); - meta.pluginData.state.guildLogs.log(LogType.STAGE_INSTANCE_DELETE, { - stageInstance: stageToConfigAccessibleStage(meta.args.stageInstance), - stageChannel: channelToConfigAccessibleChannel(stageChannel), + logStageInstanceDelete(meta.pluginData, { + stageInstance: meta.args.stageInstance, + stageChannel, }); }, }); +const validStageInstanceDiffProps: Set = new Set([ + "topic", + "privacyLevel", + "discoverableDisabled", +]); + export const LogsStageInstanceUpdateEvt = logsEvt({ event: "stageInstanceUpdate", async listener(meta) { const stageChannel = meta.args.newStageInstance.channel ?? - (await meta.pluginData.guild.channels.fetch(meta.args.newStageInstance.channelId))!; + ((await meta.pluginData.guild.channels.fetch(meta.args.newStageInstance.channelId)) as StageChannel); - const diff = getScalarDifference(meta.args.oldStageInstance, meta.args.newStageInstance); + const oldStageInstanceDiffProps = filterObject(meta.args.oldStageInstance || {}, (v, k) => + validStageInstanceDiffProps.has(k), + ); + const newStageInstanceDiffProps = filterObject(meta.args.newStageInstance, (v, k) => + validStageInstanceDiffProps.has(k), + ); + const diff = getScalarDifference(oldStageInstanceDiffProps, newStageInstanceDiffProps); const differenceString = differenceToString(diff); - meta.pluginData.state.guildLogs.log(LogType.STAGE_INSTANCE_UPDATE, { - oldStageInstance: stageToConfigAccessibleStage(meta.args.oldStageInstance), - newStageInstance: stageToConfigAccessibleStage(meta.args.newStageInstance), - stageChannel: channelToConfigAccessibleChannel(stageChannel), + logStageInstanceUpdate(meta.pluginData, { + oldStageInstance: meta.args.oldStageInstance, + newStageInstance: meta.args.newStageInstance, + stageChannel, differenceString, }); }, diff --git a/backend/src/plugins/Logs/events/LogsThreadModifyEvts.ts b/backend/src/plugins/Logs/events/LogsThreadModifyEvts.ts index f20c15e4..b760d21d 100644 --- a/backend/src/plugins/Logs/events/LogsThreadModifyEvts.ts +++ b/backend/src/plugins/Logs/events/LogsThreadModifyEvts.ts @@ -1,14 +1,19 @@ import { LogType } from "../../../data/LogType"; import { differenceToString, getScalarDifference } from "../../../utils"; -import { channelToConfigAccessibleChannel } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; import { logsEvt } from "../types"; +import { logThreadCreate } from "../logFunctions/logThreadCreate"; +import { logThreadDelete } from "../logFunctions/logThreadDelete"; +import { logThreadUpdate } from "../logFunctions/logThreadUpdate"; +import { TextChannel, ThreadChannel, VoiceChannel } from "discord.js"; +import { filterObject } from "../../../utils/filterObject"; export const LogsThreadCreateEvt = logsEvt({ event: "threadCreate", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.THREAD_CREATE, { - thread: channelToConfigAccessibleChannel(meta.args.thread), + logThreadCreate(meta.pluginData, { + thread: meta.args.thread, }); }, }); @@ -17,27 +22,27 @@ export const LogsThreadDeleteEvt = logsEvt({ event: "threadDelete", async listener(meta) { - meta.pluginData.state.guildLogs.log(LogType.THREAD_DELETE, { - thread: channelToConfigAccessibleChannel(meta.args.thread), + logThreadDelete(meta.pluginData, { + thread: meta.args.thread, }); }, }); +const validThreadDiffProps: Set = new Set(["name", "autoArchiveDuration", "rateLimitPerUser"]); + export const LogsThreadUpdateEvt = logsEvt({ event: "threadUpdate", async listener(meta) { - const diff = getScalarDifference(meta.args.oldThread, meta.args.newThread, ["messageCount", "archiveTimestamp"]); + const oldThreadDiffProps = filterObject(meta.args.oldThread || {}, (v, k) => validThreadDiffProps.has(k)); + const newThreadDiffProps = filterObject(meta.args.newThread, (v, k) => validThreadDiffProps.has(k)); + const diff = getScalarDifference(oldThreadDiffProps, newThreadDiffProps); const differenceString = differenceToString(diff); - meta.pluginData.state.guildLogs.log( - LogType.THREAD_UPDATE, - { - oldThread: channelToConfigAccessibleChannel(meta.args.oldThread), - newThread: channelToConfigAccessibleChannel(meta.args.newThread), - differenceString, - }, - meta.args.newThread.id, - ); + logThreadUpdate(meta.pluginData, { + oldThread: meta.args.oldThread, + newThread: meta.args.newThread, + differenceString, + }); }, }); diff --git a/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts b/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts index a5f7e9c7..061ab4fe 100644 --- a/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts +++ b/backend/src/plugins/Logs/events/LogsUserUpdateEvts.ts @@ -1,10 +1,14 @@ import { GuildAuditLogs } from "discord.js"; import diff from "lodash.difference"; import isEqual from "lodash.isequal"; -import { memberToConfigAccessibleMember, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { safeFindRelevantAuditLogEntry } from "../../../utils/safeFindRelevantAuditLogEntry"; import { logsEvt } from "../types"; +import { logMemberNickChange } from "../logFunctions/logMemberNickChange"; +import { logMemberRoleChanges } from "../logFunctions/logMemberRoleChanges"; +import { logMemberRoleAdd } from "../logFunctions/logMemberRoleAdd"; +import { logMemberRoleRemove } from "../logFunctions/logMemberRoleRemove"; export const LogsGuildMemberUpdateEvt = logsEvt({ event: "guildMemberUpdate", @@ -13,22 +17,24 @@ export const LogsGuildMemberUpdateEvt = logsEvt({ const pluginData = meta.pluginData; const oldMember = meta.args.oldMember; const member = meta.args.newMember; + const oldRoles = [...oldMember.roles.cache.keys()]; + const currentRoles = [...member.roles.cache.keys()]; - if (!oldMember) return; - - const logMember = memberToConfigAccessibleMember(member); + if (!oldMember || oldMember.partial) { + return; + } if (member.nickname !== oldMember.nickname) { - pluginData.state.guildLogs.log(LogType.MEMBER_NICK_CHANGE, { - member: logMember, + logMemberNickChange(pluginData, { + member, oldNick: oldMember.nickname != null ? oldMember.nickname : "", newNick: member.nickname != null ? member.nickname : "", }); } - if (!isEqual(oldMember.roles, member.roles)) { - const addedRoles = diff([...member.roles.cache.keys()], [...oldMember.roles.cache.keys()]); - const removedRoles = diff([...oldMember.roles.cache.keys()], [...member.roles.cache.keys()]); + if (!isEqual(oldRoles, currentRoles)) { + const addedRoles = diff(currentRoles, oldRoles); + const removedRoles = diff(oldRoles, currentRoles); let skip = false; if ( @@ -47,59 +53,36 @@ export const LogsGuildMemberUpdateEvt = logsEvt({ } if (!skip) { - const relevantAuditLogEntry = await safeFindRelevantAuditLogEntry( - pluginData, - GuildAuditLogs.Actions.MEMBER_ROLE_UPDATE as number, - member.id, - ); - const mod = relevantAuditLogEntry?.executor ?? null; - if (addedRoles.length && removedRoles.length) { // Roles added *and* removed - pluginData.state.guildLogs.log( - LogType.MEMBER_ROLE_CHANGES, - { - member: logMember, - addedRoles: addedRoles - .map(roleId => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - removedRoles: removedRoles - .map(roleId => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: mod ? userToConfigAccessibleUser(mod) : {}, - }, - member.id, - ); + logMemberRoleChanges(pluginData, { + member, + addedRoles: addedRoles.map( + (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, + ), + removedRoles: removedRoles.map( + (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, + ), + mod: null, + }); } else if (addedRoles.length) { // Roles added - pluginData.state.guildLogs.log( - LogType.MEMBER_ROLE_ADD, - { - member: logMember, - roles: addedRoles - .map(roleId => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: mod ? userToConfigAccessibleUser(mod) : {}, - }, - member.id, - ); + logMemberRoleAdd(pluginData, { + member, + roles: addedRoles.map( + (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, + ), + mod: null, + }); } else if (removedRoles.length && !addedRoles.length) { // Roles removed - pluginData.state.guildLogs.log( - LogType.MEMBER_ROLE_REMOVE, - { - member: logMember, - roles: removedRoles - .map(roleId => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: mod ? userToConfigAccessibleUser(mod) : {}, - }, - member.id, - ); + logMemberRoleRemove(pluginData, { + member, + roles: removedRoles.map( + (roleId) => pluginData.guild.roles.cache.get(roleId) ?? { id: roleId, name: `Unknown (${roleId})` }, + ), + mod: null, + }); } } } diff --git a/backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts b/backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts index 935c7847..51e349e7 100644 --- a/backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts +++ b/backend/src/plugins/Logs/events/LogsVoiceChannelEvts.ts @@ -1,9 +1,9 @@ -import { - channelToConfigAccessibleChannel, - memberToConfigAccessibleMember, -} from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { logsEvt } from "../types"; +import { logVoiceChannelLeave } from "../logFunctions/logVoiceChannelLeave"; +import { logVoiceChannelJoin } from "../logFunctions/logVoiceChannelJoin"; +import { logVoiceChannelMove } from "../logFunctions/logVoiceChannelMove"; export const LogsVoiceStateUpdateEvt = logsEvt({ event: "voiceStateUpdate", @@ -11,25 +11,30 @@ export const LogsVoiceStateUpdateEvt = logsEvt({ async listener(meta) { const oldChannel = meta.args.oldState.channel; const newChannel = meta.args.newState.channel; - const member = meta.args.newState.member ?? meta.args.oldState.member!; + const member = meta.args.newState.member ?? meta.args.oldState.member; + + if (!member) { + return; + } if (!newChannel && oldChannel) { // Leave evt - meta.pluginData.state.guildLogs.log(LogType.VOICE_CHANNEL_LEAVE, { - member: memberToConfigAccessibleMember(member), - channel: channelToConfigAccessibleChannel(oldChannel!), + logVoiceChannelLeave(meta.pluginData, { + member, + channel: oldChannel, }); } else if (!oldChannel && newChannel) { // Join Evt - meta.pluginData.state.guildLogs.log(LogType.VOICE_CHANNEL_JOIN, { - member: memberToConfigAccessibleMember(member), - channel: channelToConfigAccessibleChannel(newChannel), + logVoiceChannelJoin(meta.pluginData, { + member, + channel: newChannel, }); - } else { - meta.pluginData.state.guildLogs.log(LogType.VOICE_CHANNEL_MOVE, { - member: memberToConfigAccessibleMember(member), - oldChannel: channelToConfigAccessibleChannel(oldChannel!), - newChannel: channelToConfigAccessibleChannel(newChannel!), + } else if (oldChannel && newChannel) { + if (oldChannel.id === newChannel.id) return; + logVoiceChannelMove(meta.pluginData, { + member, + oldChannel, + newChannel, }); } }, diff --git a/backend/src/plugins/Logs/logFunctions/logAutomodAction.ts b/backend/src/plugins/Logs/logFunctions/logAutomodAction.ts new file mode 100644 index 00000000..d7046826 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logAutomodAction.ts @@ -0,0 +1,33 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogAutomodActionData { + rule: string; + user?: User | null; + users: User[]; + actionsTaken: string; + matchSummary: string; +} + +export function logAutomodAction(pluginData: GuildPluginData, data: LogAutomodActionData) { + return log( + pluginData, + LogType.AUTOMOD_ACTION, + createTypedTemplateSafeValueContainer({ + rule: data.rule, + user: data.user ? userToTemplateSafeUser(data.user) : null, + users: data.users.map((user) => userToTemplateSafeUser(user)), + actionsTaken: data.actionsTaken, + matchSummary: data.matchSummary ?? "", + }), + { + userId: data.user ? data.user.id : null, + bot: data.user ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logBotAlert.ts b/backend/src/plugins/Logs/logFunctions/logBotAlert.ts new file mode 100644 index 00000000..9f47dea0 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logBotAlert.ts @@ -0,0 +1,20 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; + +interface LogBotAlertData { + body: string; +} + +export function logBotAlert(pluginData: GuildPluginData, data: LogBotAlertData) { + return log( + pluginData, + LogType.BOT_ALERT, + createTypedTemplateSafeValueContainer({ + body: data.body, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logCaseCreate.ts b/backend/src/plugins/Logs/logFunctions/logCaseCreate.ts new file mode 100644 index 00000000..412dc8fa --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logCaseCreate.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogCaseCreateData { + mod: User; + userId: string; + caseNum: number; + caseType: string; + reason: string; +} + +export function logCaseCreate(pluginData: GuildPluginData, data: LogCaseCreateData) { + return log( + pluginData, + LogType.CASE_CREATE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + userId: data.userId, + caseNum: data.caseNum, + caseType: data.caseType, + reason: data.reason, + }), + { + userId: data.userId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logCaseDelete.ts b/backend/src/plugins/Logs/logFunctions/logCaseDelete.ts new file mode 100644 index 00000000..85c2e0d8 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logCaseDelete.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { caseToTemplateSafeCase, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; +import { Case } from "../../../data/entities/Case"; + +interface LogCaseDeleteData { + mod: GuildMember; + case: Case; +} + +export function logCaseDelete(pluginData: GuildPluginData, data: LogCaseDeleteData) { + return log( + pluginData, + LogType.CASE_DELETE, + createTypedTemplateSafeValueContainer({ + mod: memberToTemplateSafeMember(data.mod), + case: caseToTemplateSafeCase(data.case), + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logCaseUpdate.ts b/backend/src/plugins/Logs/logFunctions/logCaseUpdate.ts new file mode 100644 index 00000000..d8db4499 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logCaseUpdate.ts @@ -0,0 +1,28 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogCaseUpdateData { + mod: User; + caseNumber: number; + caseType: string; + note: string; +} + +export function logCaseUpdate(pluginData: GuildPluginData, data: LogCaseUpdateData) { + return log( + pluginData, + LogType.CASE_UPDATE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + caseNumber: data.caseNumber, + caseType: data.caseType, + note: data.note, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logCensor.ts b/backend/src/plugins/Logs/logFunctions/logCensor.ts new file mode 100644 index 00000000..2385273f --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logCensor.ts @@ -0,0 +1,41 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js"; +import { + channelToTemplateSafeChannel, + savedMessageToTemplateSafeSavedMessage, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; +import { SavedMessage } from "../../../data/entities/SavedMessage"; +import { UnknownUser } from "../../../utils"; +import { deactivateMentions, disableCodeBlocks } from "knub/dist/helpers"; + +interface LogCensorData { + user: User | UnknownUser; + channel: BaseGuildTextChannel | ThreadChannel; + reason: string; + message: SavedMessage; +} + +export function logCensor(pluginData: GuildPluginData, data: LogCensorData) { + return log( + pluginData, + LogType.CENSOR, + createTypedTemplateSafeValueContainer({ + user: userToTemplateSafeUser(data.user), + channel: channelToTemplateSafeChannel(data.channel), + reason: data.reason, + message: savedMessageToTemplateSafeSavedMessage(data.message), + messageText: disableCodeBlocks(deactivateMentions(data.message.data.content)), + }), + { + userId: data.user.id, + channel: data.channel.id, + category: data.channel.parentId, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logChannelCreate.ts b/backend/src/plugins/Logs/logFunctions/logChannelCreate.ts new file mode 100644 index 00000000..02ccba3d --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logChannelCreate.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildChannel, NewsChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogChannelCreateData { + channel: GuildChannel | NewsChannel; +} + +export function logChannelCreate(pluginData: GuildPluginData, data: LogChannelCreateData) { + return log( + pluginData, + LogType.CHANNEL_CREATE, + createTypedTemplateSafeValueContainer({ + channel: channelToTemplateSafeChannel(data.channel), + }), + { + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logChannelDelete.ts b/backend/src/plugins/Logs/logFunctions/logChannelDelete.ts new file mode 100644 index 00000000..547ed963 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logChannelDelete.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildChannel, NewsChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogChannelDeleteData { + channel: GuildChannel | NewsChannel; +} + +export function logChannelDelete(pluginData: GuildPluginData, data: LogChannelDeleteData) { + return log( + pluginData, + LogType.CHANNEL_DELETE, + createTypedTemplateSafeValueContainer({ + channel: channelToTemplateSafeChannel(data.channel), + }), + { + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logChannelUpdate.ts b/backend/src/plugins/Logs/logFunctions/logChannelUpdate.ts new file mode 100644 index 00000000..ebe2ceb5 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logChannelUpdate.ts @@ -0,0 +1,29 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildChannel, NewsChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogChannelUpdateData { + oldChannel: GuildChannel | NewsChannel; + newChannel: GuildChannel | NewsChannel; + differenceString: string; +} + +export function logChannelUpdate(pluginData: GuildPluginData, data: LogChannelUpdateData) { + return log( + pluginData, + LogType.CHANNEL_UPDATE, + createTypedTemplateSafeValueContainer({ + oldChannel: channelToTemplateSafeChannel(data.oldChannel), + newChannel: channelToTemplateSafeChannel(data.newChannel), + differenceString: data.differenceString, + }), + { + channel: data.newChannel.id, + category: data.newChannel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logClean.ts b/backend/src/plugins/Logs/logFunctions/logClean.ts new file mode 100644 index 00000000..4ef2077e --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logClean.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, User } from "discord.js"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogCleanData { + mod: User; + channel: BaseGuildTextChannel; + count: number; + archiveUrl: string; +} + +export function logClean(pluginData: GuildPluginData, data: LogCleanData) { + return log( + pluginData, + LogType.CLEAN, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + channel: channelToTemplateSafeChannel(data.channel), + count: data.count, + archiveUrl: data.archiveUrl, + }), + { + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logDmFailed.ts b/backend/src/plugins/Logs/logFunctions/logDmFailed.ts new file mode 100644 index 00000000..32ba3bde --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logDmFailed.ts @@ -0,0 +1,28 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { UnknownUser } from "../../../utils"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogDmFailedData { + source: string; + user: User | UnknownUser; +} + +export function logDmFailed(pluginData: GuildPluginData, data: LogDmFailedData) { + return log( + pluginData, + LogType.DM_FAILED, + createTypedTemplateSafeValueContainer({ + source: data.source, + user: userToTemplateSafeUser(data.user), + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logEmojiCreate.ts b/backend/src/plugins/Logs/logFunctions/logEmojiCreate.ts new file mode 100644 index 00000000..d49d940b --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logEmojiCreate.ts @@ -0,0 +1,22 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Emoji } from "discord.js"; +import { emojiToTemplateSafeEmoji } from "../../../utils/templateSafeObjects"; + +interface LogEmojiCreateData { + emoji: Emoji; +} + +export function logEmojiCreate(pluginData: GuildPluginData, data: LogEmojiCreateData) { + return log( + pluginData, + LogType.EMOJI_CREATE, + createTypedTemplateSafeValueContainer({ + emoji: emojiToTemplateSafeEmoji(data.emoji), + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logEmojiDelete.ts b/backend/src/plugins/Logs/logFunctions/logEmojiDelete.ts new file mode 100644 index 00000000..6f5b717c --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logEmojiDelete.ts @@ -0,0 +1,22 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Emoji } from "discord.js"; +import { emojiToTemplateSafeEmoji } from "../../../utils/templateSafeObjects"; + +interface LogEmojiDeleteData { + emoji: Emoji; +} + +export function logEmojiDelete(pluginData: GuildPluginData, data: LogEmojiDeleteData) { + return log( + pluginData, + LogType.EMOJI_DELETE, + createTypedTemplateSafeValueContainer({ + emoji: emojiToTemplateSafeEmoji(data.emoji), + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logEmojiUpdate.ts b/backend/src/plugins/Logs/logFunctions/logEmojiUpdate.ts new file mode 100644 index 00000000..9934a045 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logEmojiUpdate.ts @@ -0,0 +1,26 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Emoji } from "discord.js"; +import { emojiToTemplateSafeEmoji } from "../../../utils/templateSafeObjects"; + +interface LogEmojiUpdateData { + oldEmoji: Emoji; + newEmoji: Emoji; + differenceString: string; +} + +export function logEmojiUpdate(pluginData: GuildPluginData, data: LogEmojiUpdateData) { + return log( + pluginData, + LogType.EMOJI_UPDATE, + createTypedTemplateSafeValueContainer({ + oldEmoji: emojiToTemplateSafeEmoji(data.oldEmoji), + newEmoji: emojiToTemplateSafeEmoji(data.newEmoji), + differenceString: data.differenceString, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMassBan.ts b/backend/src/plugins/Logs/logFunctions/logMassBan.ts new file mode 100644 index 00000000..e920fe03 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMassBan.ts @@ -0,0 +1,26 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogMassBanData { + mod: User; + count: number; + reason: string; +} + +export function logMassBan(pluginData: GuildPluginData, data: LogMassBanData) { + return log( + pluginData, + LogType.MASSBAN, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + count: data.count, + reason: data.reason, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMassMute.ts b/backend/src/plugins/Logs/logFunctions/logMassMute.ts new file mode 100644 index 00000000..f7a510a9 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMassMute.ts @@ -0,0 +1,24 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogMassMuteData { + mod: User; + count: number; +} + +export function logMassMute(pluginData: GuildPluginData, data: LogMassMuteData) { + return log( + pluginData, + LogType.MASSMUTE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + count: data.count, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMassUnban.ts b/backend/src/plugins/Logs/logFunctions/logMassUnban.ts new file mode 100644 index 00000000..138c5be9 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMassUnban.ts @@ -0,0 +1,26 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogMassUnbanData { + mod: User; + count: number; + reason: string; +} + +export function logMassUnban(pluginData: GuildPluginData, data: LogMassUnbanData) { + return log( + pluginData, + LogType.MASSUNBAN, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + count: data.count, + reason: data.reason, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberBan.ts b/backend/src/plugins/Logs/logFunctions/logMemberBan.ts new file mode 100644 index 00000000..21777c2b --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberBan.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberBanData { + mod: User | UnknownUser | null; + user: User | UnknownUser; + caseNumber: number; + reason: string; +} + +export function logMemberBan(pluginData: GuildPluginData, data: LogMemberBanData) { + return log( + pluginData, + LogType.MEMBER_BAN, + createTypedTemplateSafeValueContainer({ + mod: data.mod ? userToTemplateSafeUser(data.mod) : null, + user: userToTemplateSafeUser(data.user), + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberForceban.ts b/backend/src/plugins/Logs/logFunctions/logMemberForceban.ts new file mode 100644 index 00000000..2f9acaa3 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberForceban.ts @@ -0,0 +1,30 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, Snowflake } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMemberForcebanData { + mod: GuildMember; + userId: Snowflake; + caseNumber: number; + reason: string; +} + +export function logMemberForceban(pluginData: GuildPluginData, data: LogMemberForcebanData) { + return log( + pluginData, + LogType.MEMBER_FORCEBAN, + createTypedTemplateSafeValueContainer({ + mod: memberToTemplateSafeMember(data.mod), + userId: data.userId, + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.userId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberJoin.ts b/backend/src/plugins/Logs/logFunctions/logMemberJoin.ts new file mode 100644 index 00000000..0daf7a33 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberJoin.ts @@ -0,0 +1,35 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; +import moment from "moment-timezone"; +import humanizeDuration from "humanize-duration"; + +interface LogMemberJoinData { + member: GuildMember; +} + +export function logMemberJoin(pluginData: GuildPluginData, data: LogMemberJoinData) { + const newThreshold = moment.utc().valueOf() - 1000 * 60 * 60; + const accountAge = humanizeDuration(moment.utc().valueOf() - data.member.user.createdTimestamp, { + largest: 2, + round: true, + }); + + return log( + pluginData, + LogType.MEMBER_JOIN, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + new: data.member.user.createdTimestamp >= newThreshold ? " :new:" : "", + account_age: accountAge, + }), + { + userId: data.member.id, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberJoinWithPriorRecords.ts b/backend/src/plugins/Logs/logFunctions/logMemberJoinWithPriorRecords.ts new file mode 100644 index 00000000..92ca6cb7 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberJoinWithPriorRecords.ts @@ -0,0 +1,30 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMemberJoinWithPriorRecordsData { + member: GuildMember; + recentCaseSummary: string; +} + +export function logMemberJoinWithPriorRecords( + pluginData: GuildPluginData, + data: LogMemberJoinWithPriorRecordsData, +) { + return log( + pluginData, + LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + recentCaseSummary: data.recentCaseSummary, + }), + { + userId: data.member.id, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberKick.ts b/backend/src/plugins/Logs/logFunctions/logMemberKick.ts new file mode 100644 index 00000000..704b8769 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberKick.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberKickData { + mod: User | UnknownUser | null; + user: User; + caseNumber: number; + reason: string; +} + +export function logMemberKick(pluginData: GuildPluginData, data: LogMemberKickData) { + return log( + pluginData, + LogType.MEMBER_KICK, + createTypedTemplateSafeValueContainer({ + mod: data.mod ? userToTemplateSafeUser(data.mod) : null, + user: userToTemplateSafeUser(data.user), + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.user.id, + bot: data.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberLeave.ts b/backend/src/plugins/Logs/logFunctions/logMemberLeave.ts new file mode 100644 index 00000000..05460a20 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberLeave.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, PartialGuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMemberLeaveData { + member: GuildMember | PartialGuildMember; +} + +export function logMemberLeave(pluginData: GuildPluginData, data: LogMemberLeaveData) { + return log( + pluginData, + LogType.MEMBER_LEAVE, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + }), + { + userId: data.member.id, + bot: data.member.user?.bot ?? false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberMute.ts b/backend/src/plugins/Logs/logFunctions/logMemberMute.ts new file mode 100644 index 00000000..c99fd300 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberMute.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberMuteData { + mod: User | UnknownUser; + user: User | UnknownUser; + caseNumber: number; + reason: string; +} + +export function logMemberMute(pluginData: GuildPluginData, data: LogMemberMuteData) { + return log( + pluginData, + LogType.MEMBER_MUTE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + user: userToTemplateSafeUser(data.user), + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberMuteExpired.ts b/backend/src/plugins/Logs/logFunctions/logMemberMuteExpired.ts new file mode 100644 index 00000000..1a969120 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberMuteExpired.ts @@ -0,0 +1,43 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { + memberToTemplateSafeMember, + TemplateSafeUnknownMember, + TemplateSafeUnknownUser, +} from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberMuteExpiredData { + member: GuildMember | UnknownUser; +} + +export function logMemberMuteExpired(pluginData: GuildPluginData, data: LogMemberMuteExpiredData) { + const member = + data.member instanceof GuildMember + ? memberToTemplateSafeMember(data.member) + : new TemplateSafeUnknownMember({ + ...data.member, + user: new TemplateSafeUnknownUser({ ...data.member }), + }); + + const roles = data.member instanceof GuildMember ? Array.from(data.member.roles.cache.keys()) : []; + + const bot = data.member instanceof GuildMember ? data.member.user.bot : false; + + return log( + pluginData, + LogType.MEMBER_MUTE_EXPIRED, + createTypedTemplateSafeValueContainer({ + member, + }), + { + userId: data.member.id, + roles, + bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberMuteRejoin.ts b/backend/src/plugins/Logs/logFunctions/logMemberMuteRejoin.ts new file mode 100644 index 00000000..fcd9a641 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberMuteRejoin.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMemberMuteRejoinData { + member: GuildMember; +} + +export function logMemberMuteRejoin(pluginData: GuildPluginData, data: LogMemberMuteRejoinData) { + return log( + pluginData, + LogType.MEMBER_MUTE_REJOIN, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + }), + { + userId: data.member.id, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberNickChange.ts b/backend/src/plugins/Logs/logFunctions/logMemberNickChange.ts new file mode 100644 index 00000000..ddab9b08 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberNickChange.ts @@ -0,0 +1,30 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMemberNickChangeData { + member: GuildMember; + oldNick: string; + newNick: string; +} + +export function logMemberNickChange(pluginData: GuildPluginData, data: LogMemberNickChangeData) { + return log( + pluginData, + LogType.MEMBER_NICK_CHANGE, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + oldNick: data.oldNick, + newNick: data.newNick, + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberNote.ts b/backend/src/plugins/Logs/logFunctions/logMemberNote.ts new file mode 100644 index 00000000..4f05c9a9 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberNote.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberNoteData { + mod: User; + user: User | UnknownUser; + caseNumber: number; + reason: string; +} + +export function logMemberNote(pluginData: GuildPluginData, data: LogMemberNoteData) { + return log( + pluginData, + LogType.MEMBER_NOTE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + user: userToTemplateSafeUser(data.user), + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberRestore.ts b/backend/src/plugins/Logs/logFunctions/logMemberRestore.ts new file mode 100644 index 00000000..d6eab53f --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberRestore.ts @@ -0,0 +1,28 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMemberRestoreData { + member: GuildMember; + restoredData: string; +} + +export function logMemberRestore(pluginData: GuildPluginData, data: LogMemberRestoreData) { + return log( + pluginData, + LogType.MEMBER_RESTORE, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + restoredData: data.restoredData, + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts b/backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts new file mode 100644 index 00000000..2db68838 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberRoleAdd.ts @@ -0,0 +1,30 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, Role, User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogMemberRoleAddData { + mod: User | null; + member: GuildMember; + roles: Role[]; +} + +export function logMemberRoleAdd(pluginData: GuildPluginData, data: LogMemberRoleAddData) { + return log( + pluginData, + LogType.MEMBER_ROLE_ADD, + createTypedTemplateSafeValueContainer({ + mod: data.mod ? userToTemplateSafeUser(data.mod) : null, + member: memberToTemplateSafeMember(data.member), + roles: data.roles.map((r) => r.name).join(", "), + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts b/backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts new file mode 100644 index 00000000..7eee36a9 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberRoleChanges.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, Role, User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberRoleChangesData { + mod: User | UnknownUser | null; + member: GuildMember; + addedRoles: Role[]; + removedRoles: Role[]; +} + +export function logMemberRoleChanges(pluginData: GuildPluginData, data: LogMemberRoleChangesData) { + return log( + pluginData, + LogType.MEMBER_ROLE_CHANGES, + createTypedTemplateSafeValueContainer({ + mod: data.mod ? userToTemplateSafeUser(data.mod) : null, + member: memberToTemplateSafeMember(data.member), + addedRoles: data.addedRoles.map((r) => r.name).join(", "), + removedRoles: data.removedRoles.map((r) => r.name).join(", "), + }), + { + userId: data.member.id, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts b/backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts new file mode 100644 index 00000000..54dfff29 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberRoleRemove.ts @@ -0,0 +1,30 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, Role, User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogMemberRoleRemoveData { + mod: User | null; + member: GuildMember; + roles: Role[]; +} + +export function logMemberRoleRemove(pluginData: GuildPluginData, data: LogMemberRoleRemoveData) { + return log( + pluginData, + LogType.MEMBER_ROLE_REMOVE, + createTypedTemplateSafeValueContainer({ + mod: data.mod ? userToTemplateSafeUser(data.mod) : null, + member: memberToTemplateSafeMember(data.member), + roles: data.roles.map((r) => r.name).join(", "), + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberTimedBan.ts b/backend/src/plugins/Logs/logFunctions/logMemberTimedBan.ts new file mode 100644 index 00000000..aa4c7846 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberTimedBan.ts @@ -0,0 +1,34 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberTimedBanData { + mod: User | UnknownUser; + user: User | UnknownUser; + banTime: string; + caseNumber: number; + reason: string; +} + +export function logMemberTimedBan(pluginData: GuildPluginData, data: LogMemberTimedBanData) { + return log( + pluginData, + LogType.MEMBER_TIMED_BAN, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + user: userToTemplateSafeUser(data.user), + banTime: data.banTime, + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberTimedMute.ts b/backend/src/plugins/Logs/logFunctions/logMemberTimedMute.ts new file mode 100644 index 00000000..70fd663c --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberTimedMute.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberTimedMuteData { + mod: User | UnknownUser; + user: User | UnknownUser; + time: string; + caseNumber: number; + reason: string; +} + +export function logMemberTimedMute(pluginData: GuildPluginData, data: LogMemberTimedMuteData) { + return log( + pluginData, + LogType.MEMBER_TIMED_MUTE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + user: userToTemplateSafeUser(data.user), + time: data.time, + caseNumber: data.caseNumber, + reason: data.reason, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberTimedUnban.ts b/backend/src/plugins/Logs/logFunctions/logMemberTimedUnban.ts new file mode 100644 index 00000000..8716157b --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberTimedUnban.ts @@ -0,0 +1,33 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberTimedUnbanData { + mod: User | UnknownUser; + userId: string; + banTime: string; + caseNumber: number; + reason: string; +} + +export function logMemberTimedUnban(pluginData: GuildPluginData, data: LogMemberTimedUnbanData) { + return log( + pluginData, + LogType.MEMBER_TIMED_UNBAN, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + userId: data.userId, + banTime: data.banTime, + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.userId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberTimedUnmute.ts b/backend/src/plugins/Logs/logFunctions/logMemberTimedUnmute.ts new file mode 100644 index 00000000..72ccea64 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberTimedUnmute.ts @@ -0,0 +1,34 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberTimedUnmuteData { + mod: User; + user: User | UnknownUser; + time: string; + caseNumber: number; + reason: string; +} + +export function logMemberTimedUnmute(pluginData: GuildPluginData, data: LogMemberTimedUnmuteData) { + return log( + pluginData, + LogType.MEMBER_TIMED_UNMUTE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + user: userToTemplateSafeUser(data.user), + time: data.time, + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberUnban.ts b/backend/src/plugins/Logs/logFunctions/logMemberUnban.ts new file mode 100644 index 00000000..d9702531 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberUnban.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, Snowflake, User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberUnbanData { + mod: User | UnknownUser | null; + userId: Snowflake; + caseNumber: number; + reason: string; +} + +export function logMemberUnban(pluginData: GuildPluginData, data: LogMemberUnbanData) { + return log( + pluginData, + LogType.MEMBER_UNBAN, + createTypedTemplateSafeValueContainer({ + mod: data.mod ? userToTemplateSafeUser(data.mod) : null, + userId: data.userId, + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.userId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberUnmute.ts b/backend/src/plugins/Logs/logFunctions/logMemberUnmute.ts new file mode 100644 index 00000000..ef2aca40 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberUnmute.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember, User } from "discord.js"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; +import { UnknownUser } from "../../../utils"; + +interface LogMemberUnmuteData { + mod: User; + user: User | UnknownUser; + caseNumber: number; + reason: string; +} + +export function logMemberUnmute(pluginData: GuildPluginData, data: LogMemberUnmuteData) { + return log( + pluginData, + LogType.MEMBER_UNMUTE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + user: userToTemplateSafeUser(data.user), + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMemberWarn.ts b/backend/src/plugins/Logs/logFunctions/logMemberWarn.ts new file mode 100644 index 00000000..180582b1 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMemberWarn.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMemberWarnData { + mod: GuildMember; + member: GuildMember; + caseNumber: number; + reason: string; +} + +export function logMemberWarn(pluginData: GuildPluginData, data: LogMemberWarnData) { + return log( + pluginData, + LogType.MEMBER_WARN, + createTypedTemplateSafeValueContainer({ + mod: memberToTemplateSafeMember(data.mod), + member: memberToTemplateSafeMember(data.member), + caseNumber: data.caseNumber, + reason: data.reason, + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts new file mode 100644 index 00000000..1b752866 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts @@ -0,0 +1,56 @@ +import { GuildPluginData } from "knub"; +import { FORMAT_NO_TIMESTAMP, LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js"; +import { + channelToTemplateSafeChannel, + savedMessageToTemplateSafeSavedMessage, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; +import moment from "moment-timezone"; +import { ISavedMessageAttachmentData, SavedMessage } from "../../../data/entities/SavedMessage"; +import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; +import { UnknownUser, useMediaUrls } from "../../../utils"; + +interface LogMessageDeleteData { + user: User | UnknownUser; + channel: BaseGuildTextChannel | ThreadChannel; + message: SavedMessage; +} + +export function logMessageDelete(pluginData: GuildPluginData, data: LogMessageDeleteData) { + // Replace attachment URLs with media URLs + if (data.message.data.attachments) { + for (const attachment of data.message.data.attachments as ISavedMessageAttachmentData[]) { + attachment.url = useMediaUrls(attachment.url); + } + } + + // See comment on FORMAT_NO_TIMESTAMP in types.ts + const config = pluginData.config.get(); + const timestampFormat = + (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format; + + return log( + pluginData, + LogType.MESSAGE_DELETE, + createTypedTemplateSafeValueContainer({ + user: userToTemplateSafeUser(data.user), + channel: channelToTemplateSafeChannel(data.channel), + message: savedMessageToTemplateSafeSavedMessage(data.message), + messageDate: pluginData + .getPlugin(TimeAndDatePlugin) + .inGuildTz(moment.utc(data.message.data.timestamp, "x")) + .format(timestampFormat), + }), + { + userId: data.user.id, + channel: data.channel.id, + category: data.channel.parentId, + messageTextContent: data.message.data.content, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDeleteAuto.ts b/backend/src/plugins/Logs/logFunctions/logMessageDeleteAuto.ts new file mode 100644 index 00000000..4ce4694c --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMessageDeleteAuto.ts @@ -0,0 +1,39 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, User } from "discord.js"; +import { + channelToTemplateSafeChannel, + savedMessageToTemplateSafeSavedMessage, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; +import { SavedMessage } from "../../../data/entities/SavedMessage"; +import { UnknownUser } from "../../../utils"; + +interface LogMessageDeleteAutoData { + message: SavedMessage; + user: User | UnknownUser; + channel: BaseGuildTextChannel; + messageDate: string; +} + +export function logMessageDeleteAuto(pluginData: GuildPluginData, data: LogMessageDeleteAutoData) { + return log( + pluginData, + LogType.MESSAGE_DELETE_AUTO, + createTypedTemplateSafeValueContainer({ + message: savedMessageToTemplateSafeSavedMessage(data.message), + user: userToTemplateSafeUser(data.user), + channel: channelToTemplateSafeChannel(data.channel), + messageDate: data.messageDate, + }), + { + userId: data.user.id, + bot: data.user instanceof User ? data.user.bot : false, + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDeleteBare.ts b/backend/src/plugins/Logs/logFunctions/logMessageDeleteBare.ts new file mode 100644 index 00000000..6b980951 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMessageDeleteBare.ts @@ -0,0 +1,27 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogMessageDeleteBareData { + messageId: string; + channel: BaseGuildTextChannel | ThreadChannel; +} + +export function logMessageDeleteBare(pluginData: GuildPluginData, data: LogMessageDeleteBareData) { + return log( + pluginData, + LogType.MESSAGE_DELETE_BARE, + createTypedTemplateSafeValueContainer({ + messageId: data.messageId, + channel: channelToTemplateSafeChannel(data.channel), + }), + { + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDeleteBulk.ts b/backend/src/plugins/Logs/logFunctions/logMessageDeleteBulk.ts new file mode 100644 index 00000000..01f38279 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMessageDeleteBulk.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogMessageDeleteBulkData { + count: number; + authorIds: string[]; + channel: BaseGuildTextChannel | ThreadChannel; + archiveUrl: string; +} + +export function logMessageDeleteBulk(pluginData: GuildPluginData, data: LogMessageDeleteBulkData) { + return log( + pluginData, + LogType.MESSAGE_DELETE_BULK, + createTypedTemplateSafeValueContainer({ + count: data.count, + authorIds: data.authorIds, + channel: channelToTemplateSafeChannel(data.channel), + archiveUrl: data.archiveUrl, + }), + { + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMessageEdit.ts b/backend/src/plugins/Logs/logFunctions/logMessageEdit.ts new file mode 100644 index 00000000..a77ce103 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMessageEdit.ts @@ -0,0 +1,39 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js"; +import { + channelToTemplateSafeChannel, + savedMessageToTemplateSafeSavedMessage, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; +import { SavedMessage } from "../../../data/entities/SavedMessage"; +import { UnknownUser } from "../../../utils"; + +interface LogMessageEditData { + user: User | UnknownUser; + channel: BaseGuildTextChannel | ThreadChannel; + before: SavedMessage; + after: SavedMessage; +} + +export function logMessageEdit(pluginData: GuildPluginData, data: LogMessageEditData) { + return log( + pluginData, + LogType.MESSAGE_EDIT, + createTypedTemplateSafeValueContainer({ + user: userToTemplateSafeUser(data.user), + channel: channelToTemplateSafeChannel(data.channel), + before: savedMessageToTemplateSafeSavedMessage(data.before), + after: savedMessageToTemplateSafeSavedMessage(data.after), + }), + { + userId: data.user.id, + channel: data.channel.id, + messageTextContent: data.after.data.content, + bot: data.user instanceof User ? data.user.bot : false, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logMessageSpamDetected.ts b/backend/src/plugins/Logs/logFunctions/logMessageSpamDetected.ts new file mode 100644 index 00000000..94e41eda --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logMessageSpamDetected.ts @@ -0,0 +1,38 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, GuildChannel, GuildMember, ThreadChannel } from "discord.js"; +import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogMessageSpamDetectedData { + member: GuildMember; + channel: GuildChannel | ThreadChannel; + description: string; + limit: number; + interval: number; + archiveUrl: string; +} + +export function logMessageSpamDetected(pluginData: GuildPluginData, data: LogMessageSpamDetectedData) { + return log( + pluginData, + LogType.MESSAGE_SPAM_DETECTED, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + channel: channelToTemplateSafeChannel(data.channel), + description: data.description, + limit: data.limit, + interval: data.interval, + archiveUrl: data.archiveUrl, + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + channel: data.channel.id, + category: data.channel.parentId, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logOtherSpamDetected.ts b/backend/src/plugins/Logs/logFunctions/logOtherSpamDetected.ts new file mode 100644 index 00000000..d4c4d0c5 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logOtherSpamDetected.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { GuildMember } from "discord.js"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogOtherSpamDetectedData { + member: GuildMember; + description: string; + limit: number; + interval: number; +} + +export function logOtherSpamDetected(pluginData: GuildPluginData, data: LogOtherSpamDetectedData) { + return log( + pluginData, + LogType.OTHER_SPAM_DETECTED, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + description: data.description, + limit: data.limit, + interval: data.interval, + }), + { + userId: data.member.id, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts b/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts new file mode 100644 index 00000000..ee4ffd1e --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts @@ -0,0 +1,34 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, User } from "discord.js"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogPostedScheduledMessageData { + author: User; + channel: BaseGuildTextChannel; + messageId: string; +} + +export function logPostedScheduledMessage( + pluginData: GuildPluginData, + data: LogPostedScheduledMessageData, +) { + return log( + pluginData, + LogType.POSTED_SCHEDULED_MESSAGE, + createTypedTemplateSafeValueContainer({ + author: userToTemplateSafeUser(data.author), + channel: channelToTemplateSafeChannel(data.channel), + messageId: data.messageId, + }), + { + userId: data.author.id, + bot: data.author.bot, + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logRepeatedMessage.ts b/backend/src/plugins/Logs/logFunctions/logRepeatedMessage.ts new file mode 100644 index 00000000..d17b5a55 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logRepeatedMessage.ts @@ -0,0 +1,39 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogRepeatedMessageData { + author: User; + channel: BaseGuildTextChannel | ThreadChannel; + datetime: string; + date: string; + time: string; + repeatInterval: string; + repeatDetails: string; +} + +export function logRepeatedMessage(pluginData: GuildPluginData, data: LogRepeatedMessageData) { + return log( + pluginData, + LogType.REPEATED_MESSAGE, + createTypedTemplateSafeValueContainer({ + author: userToTemplateSafeUser(data.author), + channel: channelToTemplateSafeChannel(data.channel), + datetime: data.datetime, + date: data.date, + time: data.time, + repeatInterval: data.repeatInterval, + repeatDetails: data.repeatDetails, + }), + { + userId: data.author.id, + bot: data.author.bot, + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logRoleCreate.ts b/backend/src/plugins/Logs/logFunctions/logRoleCreate.ts new file mode 100644 index 00000000..b4137bf2 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logRoleCreate.ts @@ -0,0 +1,22 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Role } from "discord.js"; +import { roleToTemplateSafeRole } from "../../../utils/templateSafeObjects"; + +interface LogRoleCreateData { + role: Role; +} + +export function logRoleCreate(pluginData: GuildPluginData, data: LogRoleCreateData) { + return log( + pluginData, + LogType.ROLE_CREATE, + createTypedTemplateSafeValueContainer({ + role: roleToTemplateSafeRole(data.role), + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logRoleDelete.ts b/backend/src/plugins/Logs/logFunctions/logRoleDelete.ts new file mode 100644 index 00000000..bce63794 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logRoleDelete.ts @@ -0,0 +1,22 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Role } from "discord.js"; +import { roleToTemplateSafeRole } from "../../../utils/templateSafeObjects"; + +interface LogRoleDeleteData { + role: Role; +} + +export function logRoleDelete(pluginData: GuildPluginData, data: LogRoleDeleteData) { + return log( + pluginData, + LogType.ROLE_DELETE, + createTypedTemplateSafeValueContainer({ + role: roleToTemplateSafeRole(data.role), + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logRoleUpdate.ts b/backend/src/plugins/Logs/logFunctions/logRoleUpdate.ts new file mode 100644 index 00000000..be36dfda --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logRoleUpdate.ts @@ -0,0 +1,26 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Role } from "discord.js"; +import { roleToTemplateSafeRole } from "../../../utils/templateSafeObjects"; + +interface LogRoleUpdateData { + oldRole: Role; + newRole: Role; + differenceString: string; +} + +export function logRoleUpdate(pluginData: GuildPluginData, data: LogRoleUpdateData) { + return log( + pluginData, + LogType.ROLE_UPDATE, + createTypedTemplateSafeValueContainer({ + oldRole: roleToTemplateSafeRole(data.oldRole), + newRole: roleToTemplateSafeRole(data.newRole), + differenceString: data.differenceString, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logScheduledMessage.ts b/backend/src/plugins/Logs/logFunctions/logScheduledMessage.ts new file mode 100644 index 00000000..27408798 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logScheduledMessage.ts @@ -0,0 +1,35 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogScheduledMessageData { + author: User; + channel: BaseGuildTextChannel | ThreadChannel; + datetime: string; + date: string; + time: string; +} + +export function logScheduledMessage(pluginData: GuildPluginData, data: LogScheduledMessageData) { + return log( + pluginData, + LogType.SCHEDULED_MESSAGE, + createTypedTemplateSafeValueContainer({ + author: userToTemplateSafeUser(data.author), + channel: channelToTemplateSafeChannel(data.channel), + datetime: data.datetime, + date: data.date, + time: data.time, + }), + { + userId: data.author.id, + bot: data.author.bot, + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logScheduledRepeatedMessage.ts b/backend/src/plugins/Logs/logFunctions/logScheduledRepeatedMessage.ts new file mode 100644 index 00000000..55d24df3 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logScheduledRepeatedMessage.ts @@ -0,0 +1,42 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogScheduledRepeatedMessageData { + author: User; + channel: BaseGuildTextChannel | ThreadChannel; + datetime: string; + date: string; + time: string; + repeatInterval: string; + repeatDetails: string; +} + +export function logScheduledRepeatedMessage( + pluginData: GuildPluginData, + data: LogScheduledRepeatedMessageData, +) { + return log( + pluginData, + LogType.SCHEDULED_REPEATED_MESSAGE, + createTypedTemplateSafeValueContainer({ + author: userToTemplateSafeUser(data.author), + channel: channelToTemplateSafeChannel(data.channel), + datetime: data.datetime, + date: data.date, + time: data.time, + repeatInterval: data.repeatInterval, + repeatDetails: data.repeatDetails, + }), + { + userId: data.author.id, + bot: data.author.bot, + channel: data.channel.id, + category: data.channel.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logSetAntiraidAuto.ts b/backend/src/plugins/Logs/logFunctions/logSetAntiraidAuto.ts new file mode 100644 index 00000000..5e3261a7 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logSetAntiraidAuto.ts @@ -0,0 +1,20 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; + +interface LogSetAntiraidAutoData { + level: string; +} + +export function logSetAntiraidAuto(pluginData: GuildPluginData, data: LogSetAntiraidAutoData) { + return log( + pluginData, + LogType.SET_ANTIRAID_AUTO, + createTypedTemplateSafeValueContainer({ + level: data.level, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logSetAntiraidUser.ts b/backend/src/plugins/Logs/logFunctions/logSetAntiraidUser.ts new file mode 100644 index 00000000..bd65a67d --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logSetAntiraidUser.ts @@ -0,0 +1,27 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { User } from "discord.js"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; + +interface LogSetAntiraidUserData { + level: string; + user: User; +} + +export function logSetAntiraidUser(pluginData: GuildPluginData, data: LogSetAntiraidUserData) { + return log( + pluginData, + LogType.SET_ANTIRAID_USER, + createTypedTemplateSafeValueContainer({ + level: data.level, + user: userToTemplateSafeUser(data.user), + }), + { + userId: data.user.id, + bot: data.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logStageInstanceCreate.ts b/backend/src/plugins/Logs/logFunctions/logStageInstanceCreate.ts new file mode 100644 index 00000000..f1602c3b --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logStageInstanceCreate.ts @@ -0,0 +1,27 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { StageChannel, StageInstance } from "discord.js"; +import { channelToTemplateSafeChannel, stageToTemplateSafeStage } from "../../../utils/templateSafeObjects"; + +interface LogStageInstanceCreateData { + stageInstance: StageInstance; + stageChannel: StageChannel; +} + +export function logStageInstanceCreate(pluginData: GuildPluginData, data: LogStageInstanceCreateData) { + return log( + pluginData, + LogType.STAGE_INSTANCE_CREATE, + createTypedTemplateSafeValueContainer({ + stageInstance: stageToTemplateSafeStage(data.stageInstance), + stageChannel: channelToTemplateSafeChannel(data.stageChannel), + }), + { + channel: data.stageInstance.channel!.id, + category: data.stageInstance.channel!.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logStageInstanceDelete.ts b/backend/src/plugins/Logs/logFunctions/logStageInstanceDelete.ts new file mode 100644 index 00000000..bc2841b0 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logStageInstanceDelete.ts @@ -0,0 +1,27 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { StageChannel, StageInstance } from "discord.js"; +import { channelToTemplateSafeChannel, stageToTemplateSafeStage } from "../../../utils/templateSafeObjects"; + +interface LogStageInstanceDeleteData { + stageInstance: StageInstance; + stageChannel: StageChannel; +} + +export function logStageInstanceDelete(pluginData: GuildPluginData, data: LogStageInstanceDeleteData) { + return log( + pluginData, + LogType.STAGE_INSTANCE_DELETE, + createTypedTemplateSafeValueContainer({ + stageInstance: stageToTemplateSafeStage(data.stageInstance), + stageChannel: channelToTemplateSafeChannel(data.stageChannel), + }), + { + channel: data.stageInstance.channel!.id, + category: data.stageInstance.channel!.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logStageInstanceUpdate.ts b/backend/src/plugins/Logs/logFunctions/logStageInstanceUpdate.ts new file mode 100644 index 00000000..fe02db88 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logStageInstanceUpdate.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { StageChannel, StageInstance } from "discord.js"; +import { channelToTemplateSafeChannel, stageToTemplateSafeStage } from "../../../utils/templateSafeObjects"; + +interface LogStageInstanceUpdateData { + oldStageInstance: StageInstance; + newStageInstance: StageInstance; + stageChannel: StageChannel; + differenceString: string; +} + +export function logStageInstanceUpdate(pluginData: GuildPluginData, data: LogStageInstanceUpdateData) { + return log( + pluginData, + LogType.STAGE_INSTANCE_UPDATE, + createTypedTemplateSafeValueContainer({ + oldStageInstance: stageToTemplateSafeStage(data.oldStageInstance), + newStageInstance: stageToTemplateSafeStage(data.newStageInstance), + stageChannel: channelToTemplateSafeChannel(data.stageChannel), + differenceString: data.differenceString, + }), + { + channel: data.newStageInstance.channel!.id, + category: data.newStageInstance.channel!.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logStickerCreate.ts b/backend/src/plugins/Logs/logFunctions/logStickerCreate.ts new file mode 100644 index 00000000..4e8a46be --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logStickerCreate.ts @@ -0,0 +1,22 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Sticker } from "discord.js"; +import { stickerToTemplateSafeSticker } from "../../../utils/templateSafeObjects"; + +interface LogStickerCreateData { + sticker: Sticker; +} + +export function logStickerCreate(pluginData: GuildPluginData, data: LogStickerCreateData) { + return log( + pluginData, + LogType.STICKER_CREATE, + createTypedTemplateSafeValueContainer({ + sticker: stickerToTemplateSafeSticker(data.sticker), + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logStickerDelete.ts b/backend/src/plugins/Logs/logFunctions/logStickerDelete.ts new file mode 100644 index 00000000..59f7128a --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logStickerDelete.ts @@ -0,0 +1,22 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Sticker } from "discord.js"; +import { stickerToTemplateSafeSticker } from "../../../utils/templateSafeObjects"; + +interface LogStickerDeleteData { + sticker: Sticker; +} + +export function logStickerDelete(pluginData: GuildPluginData, data: LogStickerDeleteData) { + return log( + pluginData, + LogType.STICKER_DELETE, + createTypedTemplateSafeValueContainer({ + sticker: stickerToTemplateSafeSticker(data.sticker), + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logStickerUpdate.ts b/backend/src/plugins/Logs/logFunctions/logStickerUpdate.ts new file mode 100644 index 00000000..b1f7ea04 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logStickerUpdate.ts @@ -0,0 +1,26 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { Sticker } from "discord.js"; +import { stickerToTemplateSafeSticker } from "../../../utils/templateSafeObjects"; + +interface LogStickerUpdateData { + oldSticker: Sticker; + newSticker: Sticker; + differenceString: string; +} + +export function logStickerUpdate(pluginData: GuildPluginData, data: LogStickerUpdateData) { + return log( + pluginData, + LogType.STICKER_UPDATE, + createTypedTemplateSafeValueContainer({ + oldSticker: stickerToTemplateSafeSticker(data.oldSticker), + newSticker: stickerToTemplateSafeSticker(data.newSticker), + differenceString: data.differenceString, + }), + {}, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logThreadCreate.ts b/backend/src/plugins/Logs/logFunctions/logThreadCreate.ts new file mode 100644 index 00000000..f36a2f76 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logThreadCreate.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { ThreadChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogThreadCreateData { + thread: ThreadChannel; +} + +export function logThreadCreate(pluginData: GuildPluginData, data: LogThreadCreateData) { + return log( + pluginData, + LogType.THREAD_CREATE, + createTypedTemplateSafeValueContainer({ + thread: channelToTemplateSafeChannel(data.thread), + }), + { + channel: data.thread.parentId, + category: data.thread.parent?.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logThreadDelete.ts b/backend/src/plugins/Logs/logFunctions/logThreadDelete.ts new file mode 100644 index 00000000..2467682f --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logThreadDelete.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { ThreadChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogThreadDeleteData { + thread: ThreadChannel; +} + +export function logThreadDelete(pluginData: GuildPluginData, data: LogThreadDeleteData) { + return log( + pluginData, + LogType.THREAD_DELETE, + createTypedTemplateSafeValueContainer({ + thread: channelToTemplateSafeChannel(data.thread), + }), + { + channel: data.thread.parentId, + category: data.thread.parent?.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logThreadUpdate.ts b/backend/src/plugins/Logs/logFunctions/logThreadUpdate.ts new file mode 100644 index 00000000..80e20acc --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logThreadUpdate.ts @@ -0,0 +1,29 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { ThreadChannel } from "discord.js"; +import { channelToTemplateSafeChannel } from "../../../utils/templateSafeObjects"; + +interface LogThreadUpdateData { + oldThread: ThreadChannel; + newThread: ThreadChannel; + differenceString: string; +} + +export function logThreadUpdate(pluginData: GuildPluginData, data: LogThreadUpdateData) { + return log( + pluginData, + LogType.THREAD_UPDATE, + createTypedTemplateSafeValueContainer({ + oldThread: channelToTemplateSafeChannel(data.oldThread), + newThread: channelToTemplateSafeChannel(data.newThread), + differenceString: data.differenceString, + }), + { + channel: data.newThread.parentId, + category: data.newThread.parent?.parentId, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logVoiceChannelForceDisconnect.ts b/backend/src/plugins/Logs/logFunctions/logVoiceChannelForceDisconnect.ts new file mode 100644 index 00000000..4bdca809 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logVoiceChannelForceDisconnect.ts @@ -0,0 +1,39 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildVoiceChannel, GuildMember, User } from "discord.js"; +import { + channelToTemplateSafeChannel, + memberToTemplateSafeMember, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; + +interface LogVoiceChannelForceDisconnectData { + mod: User; + member: GuildMember; + oldChannel: BaseGuildVoiceChannel; +} + +export function logVoiceChannelForceDisconnect( + pluginData: GuildPluginData, + data: LogVoiceChannelForceDisconnectData, +) { + return log( + pluginData, + LogType.VOICE_CHANNEL_FORCE_DISCONNECT, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + member: memberToTemplateSafeMember(data.member), + oldChannel: channelToTemplateSafeChannel(data.oldChannel), + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + channel: data.oldChannel.id, + category: data.oldChannel.parentId, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logVoiceChannelForceMove.ts b/backend/src/plugins/Logs/logFunctions/logVoiceChannelForceMove.ts new file mode 100644 index 00000000..5f5ec1e7 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logVoiceChannelForceMove.ts @@ -0,0 +1,41 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildVoiceChannel, GuildMember, User } from "discord.js"; +import { + channelToTemplateSafeChannel, + memberToTemplateSafeMember, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; + +interface LogVoiceChannelForceMoveData { + mod: User; + member: GuildMember; + oldChannel: BaseGuildVoiceChannel; + newChannel: BaseGuildVoiceChannel; +} + +export function logVoiceChannelForceMove( + pluginData: GuildPluginData, + data: LogVoiceChannelForceMoveData, +) { + return log( + pluginData, + LogType.VOICE_CHANNEL_FORCE_MOVE, + createTypedTemplateSafeValueContainer({ + mod: userToTemplateSafeUser(data.mod), + member: memberToTemplateSafeMember(data.member), + oldChannel: channelToTemplateSafeChannel(data.oldChannel), + newChannel: channelToTemplateSafeChannel(data.newChannel), + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + channel: data.newChannel.id, + category: data.newChannel.parentId, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logVoiceChannelJoin.ts b/backend/src/plugins/Logs/logFunctions/logVoiceChannelJoin.ts new file mode 100644 index 00000000..07c44309 --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logVoiceChannelJoin.ts @@ -0,0 +1,30 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildVoiceChannel, GuildMember } from "discord.js"; +import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogVoiceChannelJoinData { + member: GuildMember; + channel: BaseGuildVoiceChannel; +} + +export function logVoiceChannelJoin(pluginData: GuildPluginData, data: LogVoiceChannelJoinData) { + return log( + pluginData, + LogType.VOICE_CHANNEL_JOIN, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + channel: channelToTemplateSafeChannel(data.channel), + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + channel: data.channel.id, + category: data.channel.parentId, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logVoiceChannelLeave.ts b/backend/src/plugins/Logs/logFunctions/logVoiceChannelLeave.ts new file mode 100644 index 00000000..3fc67abb --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logVoiceChannelLeave.ts @@ -0,0 +1,30 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildVoiceChannel, GuildMember } from "discord.js"; +import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogVoiceChannelLeaveData { + member: GuildMember; + channel: BaseGuildVoiceChannel; +} + +export function logVoiceChannelLeave(pluginData: GuildPluginData, data: LogVoiceChannelLeaveData) { + return log( + pluginData, + LogType.VOICE_CHANNEL_LEAVE, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + channel: channelToTemplateSafeChannel(data.channel), + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + channel: data.channel.id, + category: data.channel.parentId, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/logFunctions/logVoiceChannelMove.ts b/backend/src/plugins/Logs/logFunctions/logVoiceChannelMove.ts new file mode 100644 index 00000000..14e47ccd --- /dev/null +++ b/backend/src/plugins/Logs/logFunctions/logVoiceChannelMove.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { log } from "../util/log"; +import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { BaseGuildVoiceChannel, GuildMember } from "discord.js"; +import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; + +interface LogVoiceChannelMoveData { + member: GuildMember; + oldChannel: BaseGuildVoiceChannel; + newChannel: BaseGuildVoiceChannel; +} + +export function logVoiceChannelMove(pluginData: GuildPluginData, data: LogVoiceChannelMoveData) { + return log( + pluginData, + LogType.VOICE_CHANNEL_MOVE, + createTypedTemplateSafeValueContainer({ + member: memberToTemplateSafeMember(data.member), + oldChannel: channelToTemplateSafeChannel(data.oldChannel), + newChannel: channelToTemplateSafeChannel(data.newChannel), + }), + { + userId: data.member.id, + roles: Array.from(data.member.roles.cache.keys()), + channel: data.newChannel.id, + category: data.newChannel.parentId, + bot: data.member.user.bot, + }, + ); +} diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts index 56178d6b..d1905b8e 100644 --- a/backend/src/plugins/Logs/types.ts +++ b/backend/src/plugins/Logs/types.ts @@ -1,12 +1,34 @@ import * as t from "io-ts"; -import { BasePluginType, typedGuildEventListener } from "knub"; +import { z } from "zod"; +import { BasePluginType, CooldownManager, typedGuildEventListener } from "knub"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { RegExpRunner } from "../../RegExpRunner"; -import { tMessageContent, tNullable } from "../../utils"; +import { StrictMessageContent, tMessageContent, tNullable } from "../../utils"; import { TRegex } from "../../validatorUtils"; +import { LogType } from "../../data/LogType"; +import { GuildMember } from "discord.js"; +import { + TemplateSafeCase, + TemplateSafeChannel, + TemplateSafeEmoji, + TemplateSafeMember, + TemplateSafeRole, + TemplateSafeSavedMessage, + TemplateSafeStage, + TemplateSafeSticker, + TemplateSafeUnknownMember, + TemplateSafeUnknownUser, + TemplateSafeUser, +} from "../../utils/templateSafeObjects"; +import { + TemplateSafeValue, + TemplateSafeValueContainer, + TypedTemplateSafeValueContainer, +} from "../../templateFormatter"; +import { MessageBuffer } from "../../utils/MessageBuffer"; export const tLogFormats = t.record(t.string, t.union([t.string, tMessageContent])); export type TLogFormats = t.TypeOf; @@ -64,7 +86,8 @@ export interface LogsPluginType extends BasePluginType { logListener; - batches: Map; + buffers: Map; + channelCooldowns: CooldownManager; onMessageDeleteFn; onMessageDeleteBulkFn; @@ -73,3 +96,425 @@ export interface LogsPluginType extends BasePluginType { } export const logsEvt = typedGuildEventListener(); + +export const LogTypeData = z.object({ + [LogType.MEMBER_WARN]: z.object({ + mod: z.instanceof(TemplateSafeMember), + member: z.instanceof(TemplateSafeMember), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_MUTE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + user: z.instanceof(TemplateSafeUser), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_UNMUTE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + user: z.instanceof(TemplateSafeUser), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_MUTE_EXPIRED]: z.object({ + member: z.instanceof(TemplateSafeMember).or(z.instanceof(TemplateSafeUnknownMember)), + }), + + [LogType.MEMBER_KICK]: z.object({ + mod: z.instanceof(TemplateSafeUser).or(z.null()), + user: z.instanceof(TemplateSafeUser), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_BAN]: z.object({ + mod: z.instanceof(TemplateSafeUser).or(z.null()), + user: z.instanceof(TemplateSafeUser), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_UNBAN]: z.object({ + mod: z.instanceof(TemplateSafeUser).or(z.null()), + userId: z.string(), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_FORCEBAN]: z.object({ + mod: z.instanceof(TemplateSafeUser), + userId: z.string(), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_JOIN]: z.object({ + member: z.instanceof(TemplateSafeMember), + new: z.string(), + account_age: z.string(), + }), + + [LogType.MEMBER_LEAVE]: z.object({ + member: z.instanceof(TemplateSafeMember), + }), + + [LogType.MEMBER_ROLE_ADD]: z.object({ + mod: z.instanceof(TemplateSafeUser).or(z.null()), + member: z.instanceof(TemplateSafeMember), + roles: z.string(), + }), + + [LogType.MEMBER_ROLE_REMOVE]: z.object({ + mod: z.instanceof(TemplateSafeUser).or(z.null()), + member: z.instanceof(TemplateSafeMember), + roles: z.string(), + }), + + [LogType.MEMBER_NICK_CHANGE]: z.object({ + member: z.instanceof(TemplateSafeMember), + oldNick: z.string(), + newNick: z.string(), + }), + + [LogType.MEMBER_RESTORE]: z.object({ + member: z.instanceof(TemplateSafeMember), + restoredData: z.string(), + }), + + [LogType.CHANNEL_CREATE]: z.object({ + channel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.CHANNEL_DELETE]: z.object({ + channel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.CHANNEL_UPDATE]: z.object({ + oldChannel: z.instanceof(TemplateSafeChannel), + newChannel: z.instanceof(TemplateSafeChannel), + differenceString: z.string(), + }), + + [LogType.THREAD_CREATE]: z.object({ + thread: z.instanceof(TemplateSafeChannel), + }), + + [LogType.THREAD_DELETE]: z.object({ + thread: z.instanceof(TemplateSafeChannel), + }), + + [LogType.THREAD_UPDATE]: z.object({ + oldThread: z.instanceof(TemplateSafeChannel), + newThread: z.instanceof(TemplateSafeChannel), + differenceString: z.string(), + }), + + [LogType.ROLE_CREATE]: z.object({ + role: z.instanceof(TemplateSafeRole), + }), + + [LogType.ROLE_DELETE]: z.object({ + role: z.instanceof(TemplateSafeRole), + }), + + [LogType.ROLE_UPDATE]: z.object({ + oldRole: z.instanceof(TemplateSafeRole), + newRole: z.instanceof(TemplateSafeRole), + differenceString: z.string(), + }), + + [LogType.MESSAGE_EDIT]: z.object({ + user: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + before: z.instanceof(TemplateSafeSavedMessage), + after: z.instanceof(TemplateSafeSavedMessage), + }), + + [LogType.MESSAGE_DELETE]: z.object({ + user: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + messageDate: z.string(), + message: z.instanceof(TemplateSafeSavedMessage), + }), + + [LogType.MESSAGE_DELETE_BULK]: z.object({ + count: z.number(), + authorIds: z.array(z.string()), + channel: z.instanceof(TemplateSafeChannel), + archiveUrl: z.string(), + }), + + [LogType.MESSAGE_DELETE_BARE]: z.object({ + messageId: z.string(), + channel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.VOICE_CHANNEL_JOIN]: z.object({ + member: z.instanceof(TemplateSafeMember), + channel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.VOICE_CHANNEL_LEAVE]: z.object({ + member: z.instanceof(TemplateSafeMember), + channel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.VOICE_CHANNEL_MOVE]: z.object({ + member: z.instanceof(TemplateSafeMember), + oldChannel: z.instanceof(TemplateSafeChannel), + newChannel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.STAGE_INSTANCE_CREATE]: z.object({ + stageInstance: z.instanceof(TemplateSafeStage), + stageChannel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.STAGE_INSTANCE_DELETE]: z.object({ + stageInstance: z.instanceof(TemplateSafeStage), + stageChannel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.STAGE_INSTANCE_UPDATE]: z.object({ + oldStageInstance: z.instanceof(TemplateSafeStage), + newStageInstance: z.instanceof(TemplateSafeStage), + stageChannel: z.instanceof(TemplateSafeChannel), + differenceString: z.string(), + }), + + [LogType.EMOJI_CREATE]: z.object({ + emoji: z.instanceof(TemplateSafeEmoji), + }), + + [LogType.EMOJI_DELETE]: z.object({ + emoji: z.instanceof(TemplateSafeEmoji), + }), + + [LogType.EMOJI_UPDATE]: z.object({ + oldEmoji: z.instanceof(TemplateSafeEmoji), + newEmoji: z.instanceof(TemplateSafeEmoji), + differenceString: z.string(), + }), + + [LogType.STICKER_CREATE]: z.object({ + sticker: z.instanceof(TemplateSafeSticker), + }), + + [LogType.STICKER_DELETE]: z.object({ + sticker: z.instanceof(TemplateSafeSticker), + }), + + [LogType.STICKER_UPDATE]: z.object({ + oldSticker: z.instanceof(TemplateSafeSticker), + newSticker: z.instanceof(TemplateSafeSticker), + differenceString: z.string(), + }), + + [LogType.MESSAGE_SPAM_DETECTED]: z.object({ + member: z.instanceof(TemplateSafeMember), + channel: z.instanceof(TemplateSafeChannel), + description: z.string(), + limit: z.number(), + interval: z.number(), + archiveUrl: z.string(), + }), + + [LogType.CENSOR]: z.object({ + user: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + reason: z.string(), + message: z.instanceof(TemplateSafeSavedMessage), + messageText: z.string(), + }), + + [LogType.CLEAN]: z.object({ + mod: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + count: z.number(), + archiveUrl: z.string(), + }), + + [LogType.CASE_CREATE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + userId: z.string(), + caseNum: z.number(), + caseType: z.string(), + reason: z.string(), + }), + + [LogType.MASSUNBAN]: z.object({ + mod: z.instanceof(TemplateSafeUser), + count: z.number(), + reason: z.string(), + }), + + [LogType.MASSBAN]: z.object({ + mod: z.instanceof(TemplateSafeUser), + count: z.number(), + reason: z.string(), + }), + + [LogType.MASSMUTE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + count: z.number(), + }), + + [LogType.MEMBER_TIMED_MUTE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + user: z.instanceof(TemplateSafeUser), + time: z.string(), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_TIMED_UNMUTE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + user: z.instanceof(TemplateSafeUser), + time: z.string(), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_TIMED_BAN]: z.object({ + mod: z.instanceof(TemplateSafeUser), + user: z.instanceof(TemplateSafeUser), + banTime: z.string(), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_TIMED_UNBAN]: z.object({ + mod: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)), + userId: z.string(), + banTime: z.string(), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS]: z.object({ + member: z.instanceof(TemplateSafeMember), + recentCaseSummary: z.string(), + }), + + [LogType.OTHER_SPAM_DETECTED]: z.object({ + member: z.instanceof(TemplateSafeMember), + description: z.string(), + limit: z.number(), + interval: z.number(), + }), + + [LogType.MEMBER_ROLE_CHANGES]: z.object({ + mod: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)).or(z.null()), + member: z.instanceof(TemplateSafeMember), + addedRoles: z.string(), + removedRoles: z.string(), + }), + + [LogType.VOICE_CHANNEL_FORCE_MOVE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + member: z.instanceof(TemplateSafeMember), + oldChannel: z.instanceof(TemplateSafeChannel), + newChannel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.VOICE_CHANNEL_FORCE_DISCONNECT]: z.object({ + mod: z.instanceof(TemplateSafeUser), + member: z.instanceof(TemplateSafeMember), + oldChannel: z.instanceof(TemplateSafeChannel), + }), + + [LogType.CASE_UPDATE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + caseNumber: z.number(), + caseType: z.string(), + note: z.string(), + }), + + [LogType.MEMBER_MUTE_REJOIN]: z.object({ + member: z.instanceof(TemplateSafeMember), + }), + + [LogType.SCHEDULED_MESSAGE]: z.object({ + author: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + datetime: z.string(), + date: z.string(), + time: z.string(), + }), + + [LogType.POSTED_SCHEDULED_MESSAGE]: z.object({ + author: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + messageId: z.string(), + }), + + [LogType.BOT_ALERT]: z.object({ + body: z.string(), + }), + + [LogType.AUTOMOD_ACTION]: z.object({ + rule: z.string(), + user: z.instanceof(TemplateSafeUser).nullable(), + users: z.array(z.instanceof(TemplateSafeUser)), + actionsTaken: z.string(), + matchSummary: z.string(), + }), + + [LogType.SCHEDULED_REPEATED_MESSAGE]: z.object({ + author: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + datetime: z.string(), + date: z.string(), + time: z.string(), + repeatInterval: z.string(), + repeatDetails: z.string(), + }), + + [LogType.REPEATED_MESSAGE]: z.object({ + author: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + datetime: z.string(), + date: z.string(), + time: z.string(), + repeatInterval: z.string(), + repeatDetails: z.string(), + }), + + [LogType.MESSAGE_DELETE_AUTO]: z.object({ + message: z.instanceof(TemplateSafeSavedMessage), + user: z.instanceof(TemplateSafeUser), + channel: z.instanceof(TemplateSafeChannel), + messageDate: z.string(), + }), + + [LogType.SET_ANTIRAID_USER]: z.object({ + level: z.string(), + user: z.instanceof(TemplateSafeUser), + }), + + [LogType.SET_ANTIRAID_AUTO]: z.object({ + level: z.string(), + }), + + [LogType.MEMBER_NOTE]: z.object({ + mod: z.instanceof(TemplateSafeUser), + user: z.instanceof(TemplateSafeUser), + caseNumber: z.number(), + reason: z.string(), + }), + + [LogType.CASE_DELETE]: z.object({ + mod: z.instanceof(TemplateSafeMember), + case: z.instanceof(TemplateSafeCase), + }), + + [LogType.DM_FAILED]: z.object({ + source: z.string(), + user: z.instanceof(TemplateSafeUser).or(z.instanceof(TemplateSafeUnknownUser)), + }), +}); + +export type ILogTypeData = z.infer; diff --git a/backend/src/plugins/Logs/util/getLogMessage.ts b/backend/src/plugins/Logs/util/getLogMessage.ts index 8b77b736..3d6bcec5 100644 --- a/backend/src/plugins/Logs/util/getLogMessage.ts +++ b/backend/src/plugins/Logs/util/getLogMessage.ts @@ -3,27 +3,34 @@ import { GuildPluginData } from "knub"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; -import { renderTemplate, TemplateParseError } from "../../../templateFormatter"; +import { + renderTemplate, + TemplateParseError, + TemplateSafeValueContainer, + TypedTemplateSafeValueContainer, +} from "../../../templateFormatter"; import { messageSummary, renderRecursively, resolveMember, + validateAndParseMessageContent, verboseChannelMention, verboseUserMention, verboseUserName, } from "../../../utils"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; -import { FORMAT_NO_TIMESTAMP, LogsPluginType, TLogChannel } from "../types"; +import { FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel } from "../types"; import { - getConfigAccessibleMemberLevel, - IConfigAccessibleMember, - memberToConfigAccessibleMember, -} from "../../../utils/configAccessibleObjects"; + getTemplateSafeMemberLevel, + TemplateSafeMember, + memberToTemplateSafeMember, + TemplateSafeUser, +} from "../../../utils/templateSafeObjects"; -export async function getLogMessage( +export async function getLogMessage( pluginData: GuildPluginData, - type: LogType, - data: any, + type: TLogType, + data: TypedTemplateSafeValueContainer, opts?: Pick, ): Promise { const config = pluginData.config.get(); @@ -42,35 +49,41 @@ export async function getLogMessage( const isoTimestamp = time.toISOString(); const timestamp = timestampFormat ? time.format(timestampFormat) : ""; - const values = { + const values = new TemplateSafeValueContainer({ ...data, timestamp, - userMention: async inputUserOrMember => { - if (!inputUserOrMember) return ""; + userMention: async (inputUserOrMember: unknown) => { + if (!inputUserOrMember) { + return ""; + } - const usersOrMembers = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember]; + const inputArray = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember]; + // TODO: Resolve IDs to users/members + const usersOrMembers = inputArray.filter( + (v) => v instanceof TemplateSafeUser || v instanceof TemplateSafeMember, + ) as Array; const mentions: string[] = []; for (const userOrMember of usersOrMembers) { let user; - let member: IConfigAccessibleMember | null = null; + let member: TemplateSafeMember | null = null; if (userOrMember.user) { - member = userOrMember as IConfigAccessibleMember; + member = userOrMember as TemplateSafeMember; user = member.user; } else { user = userOrMember; const apiMember = await resolveMember(pluginData.client, pluginData.guild, user.id); if (apiMember) { - member = memberToConfigAccessibleMember(apiMember); + member = memberToTemplateSafeMember(apiMember); } } - const level = member ? getConfigAccessibleMemberLevel(pluginData, member) : 0; + const level = member ? getTemplateSafeMemberLevel(pluginData, member) : 0; const memberConfig = (await pluginData.config.getMatchingConfig({ level, - memberRoles: member ? member.roles.map(r => r.id) : [], + memberRoles: member ? member.roles.map((r) => r.id) : [], userId: user.id, })) || ({} as any); @@ -84,7 +97,7 @@ export async function getLogMessage( return mentions.join(", "); }, - channelMention: channel => { + channelMention: (channel) => { if (!channel) return ""; return verboseChannelMention(channel); }, @@ -92,16 +105,16 @@ export async function getLogMessage( if (!msg) return ""; return messageSummary(msg); }, - }; + }); if (type === LogType.BOT_ALERT) { const valuesWithoutTmplEval = { ...values }; - values.tmplEval = str => { + values.tmplEval = (str) => { return renderTemplate(str, valuesWithoutTmplEval); }; } - const renderLogString = str => renderTemplate(str, values); + const renderLogString = (str) => renderTemplate(str, values); let formatted; try { @@ -121,8 +134,14 @@ export async function getLogMessage( if (timestamp) { formatted = `\`[${timestamp}]\` ${formatted}`; } - } else if (formatted != null && formatted.embed && includeEmbedTimestamp) { - formatted.embed.timestamp = isoTimestamp; + } else if (formatted != null) { + formatted = validateAndParseMessageContent(formatted); + + if (formatted.embeds && Array.isArray(formatted.embeds) && includeEmbedTimestamp) { + for (const embed of formatted.embeds) { + embed.timestamp = isoTimestamp; + } + } } return formatted; diff --git a/backend/src/plugins/Logs/util/isLogIgnored.ts b/backend/src/plugins/Logs/util/isLogIgnored.ts new file mode 100644 index 00000000..093fb9a6 --- /dev/null +++ b/backend/src/plugins/Logs/util/isLogIgnored.ts @@ -0,0 +1,7 @@ +import { GuildPluginData } from "knub"; +import { LogsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; + +export function isLogIgnored(pluginData: GuildPluginData, type: LogType, ignoreId: string) { + return pluginData.state.guildLogs.isLogIgnored(type, ignoreId); +} diff --git a/backend/src/plugins/Logs/util/log.ts b/backend/src/plugins/Logs/util/log.ts index b8e6eed0..eb350d1d 100644 --- a/backend/src/plugins/Logs/util/log.ts +++ b/backend/src/plugins/Logs/util/log.ts @@ -1,11 +1,12 @@ -import { MessageMentionTypes, Snowflake, TextChannel } from "discord.js"; +import { MessageEmbedOptions, MessageMentionTypes, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; -import { SavedMessage } from "../../../data/entities/SavedMessage"; -import { LogType } from "../../../data/LogType"; import { allowTimeout } from "../../../RegExpRunner"; -import { createChunkedMessage, get, noop } from "../../../utils"; -import { LogsPluginType, TLogChannelMap } from "../types"; +import { ILogTypeData, LogsPluginType, TLogChannel, TLogChannelMap } from "../types"; import { getLogMessage } from "./getLogMessage"; +import { TypedTemplateSafeValueContainer } from "../../../templateFormatter"; +import { LogType } from "../../../data/LogType"; +import { MessageBuffer } from "../../../utils/MessageBuffer"; +import { createChunkedMessage, isDiscordAPIError, MINUTES } from "../../../utils"; const excludedUserProps = ["user", "member", "mod"]; const excludedRoleProps = ["message.member.roles", "member.roles"]; @@ -14,147 +15,126 @@ function isRoleArray(value: any): value is string[] { return Array.isArray(value); } -export async function log(pluginData: GuildPluginData, type: LogType, data: any) { +interface ExclusionData { + userId?: Snowflake | null; + bot?: boolean | null; + roles?: Snowflake[] | null; + channel?: Snowflake | null; + category?: Snowflake | null; + messageTextContent?: string | null; +} + +const DEFAULT_BATCH_TIME = 1000; +const MIN_BATCH_TIME = 250; +const MAX_BATCH_TIME = 5000; + +async function shouldExclude( + pluginData: GuildPluginData, + opts: TLogChannel, + exclusionData: ExclusionData, +): Promise { + if (opts.excluded_users && exclusionData.userId && opts.excluded_users.includes(exclusionData.userId)) { + return true; + } + + if (opts.exclude_bots && exclusionData.bot) { + return true; + } + + if (opts.excluded_roles && exclusionData.roles) { + for (const role of exclusionData.roles) { + if (opts.excluded_roles.includes(role)) { + return true; + } + } + } + + if (opts.excluded_channels && exclusionData.channel && opts.excluded_channels.includes(exclusionData.channel)) { + return true; + } + + if (opts.excluded_categories && exclusionData.category && opts.excluded_categories.includes(exclusionData.category)) { + return true; + } + + if (opts.excluded_message_regexes && exclusionData.messageTextContent) { + for (const regex of opts.excluded_message_regexes) { + const matches = await pluginData.state.regexRunner + .exec(regex, exclusionData.messageTextContent) + .catch(allowTimeout); + if (matches) { + return true; + } + } + } + + return false; +} + +export async function log( + pluginData: GuildPluginData, + type: TLogType, + data: TypedTemplateSafeValueContainer, + exclusionData: ExclusionData = {}, +) { const logChannels: TLogChannelMap = pluginData.config.get().channels; const typeStr = LogType[type]; logChannelLoop: for (const [channelId, opts] of Object.entries(logChannels)) { const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!channel || !(channel instanceof TextChannel)) continue; + if (pluginData.state.channelCooldowns.isOnCooldown(channelId)) continue; + if (opts.include?.length && !opts.include.includes(typeStr)) continue; + if (opts.exclude && opts.exclude.includes(typeStr)) continue; + if (await shouldExclude(pluginData, opts, exclusionData)) continue; - if ((opts.include && opts.include.includes(typeStr)) || (opts.exclude && !opts.exclude.includes(typeStr))) { - // If this log entry is about an excluded user, skip it - // TODO: Quick and dirty solution, look into changing at some point - if (opts.excluded_users) { - for (const prop of excludedUserProps) { - if (data && data[prop] && opts.excluded_users.includes(data[prop].id)) { - continue logChannelLoop; - } - } - } + const message = await getLogMessage(pluginData, type, data, { + format: opts.format, + include_embed_timestamp: opts.include_embed_timestamp, + timestamp_format: opts.timestamp_format, + }); + if (!message) return; - // If we're excluding bots and the logged user is a bot, skip it - if (opts.exclude_bots) { - for (const prop of excludedUserProps) { - if (data && data[prop] && data[prop].bot) { - continue logChannelLoop; - } - } - } - - if (opts.excluded_roles) { - for (const value of Object.values(data || {})) { - if (value instanceof SavedMessage) { - const member = pluginData.guild.members.cache.get(value.user_id as Snowflake); - for (const role of member?.roles.cache || []) { - if (opts.excluded_roles.includes(role[0])) { - continue logChannelLoop; + // Initialize message buffer for this channel + if (!pluginData.state.buffers.has(channelId)) { + const batchTime = Math.min(Math.max(opts.batch_time ?? DEFAULT_BATCH_TIME, MIN_BATCH_TIME), MAX_BATCH_TIME); + pluginData.state.buffers.set( + channelId, + new MessageBuffer({ + timeout: batchTime, + textSeparator: "\n", + consume: (part) => { + const parse: MessageMentionTypes[] = pluginData.config.get().allow_user_mentions ? ["users"] : []; + const promise = + part.content && !part.embeds?.length + ? createChunkedMessage(channel, part.content, { parse }) + : channel.send({ + ...part, + allowedMentions: { parse }, + }); + promise.catch((err) => { + if (isDiscordAPIError(err)) { + // Missing Access / Missing Permissions + // TODO: Show/log this somewhere + if (err.code === 50001 || err.code === 50013) { + pluginData.state.channelCooldowns.setCooldown(channelId, 2 * MINUTES); + return; + } } - } - } - } - for (const prop of excludedRoleProps) { - const roles = get(data, prop); - if (!isRoleArray(roles)) { - continue; - } - - for (const role of roles) { - if (opts.excluded_roles.includes(role)) { - continue logChannelLoop; - } - } - } - } - - // If this entry is from an excluded channel, skip it - if (opts.excluded_channels) { - if ( - type === LogType.MESSAGE_DELETE || - type === LogType.MESSAGE_DELETE_BARE || - type === LogType.MESSAGE_EDIT || - type === LogType.MESSAGE_SPAM_DETECTED || - type === LogType.CENSOR || - type === LogType.CLEAN - ) { - if (opts.excluded_channels.includes(data.channel.id)) { - continue logChannelLoop; - } - } - } - - // If this entry is from an excluded category, skip it - if (opts.excluded_categories) { - if ( - type === LogType.MESSAGE_DELETE || - type === LogType.MESSAGE_DELETE_BARE || - type === LogType.MESSAGE_EDIT || - type === LogType.MESSAGE_SPAM_DETECTED || - type === LogType.CENSOR || - type === LogType.CLEAN - ) { - if (data.channel.parentId && opts.excluded_categories.includes(data.channel.parentId)) { - continue logChannelLoop; - } - } - } - - // If this entry contains a message with an excluded regex, skip it - if (type === LogType.MESSAGE_DELETE && opts.excluded_message_regexes && data.message.data.content) { - for (const regex of opts.excluded_message_regexes) { - const matches = await pluginData.state.regexRunner.exec(regex, data.message.data.content).catch(allowTimeout); - if (matches) { - continue logChannelLoop; - } - } - } - - if (type === LogType.MESSAGE_EDIT && opts.excluded_message_regexes && data.before.data.content) { - for (const regex of opts.excluded_message_regexes) { - const matches = await pluginData.state.regexRunner.exec(regex, data.before.data.content).catch(allowTimeout); - if (matches) { - continue logChannelLoop; - } - } - } - - const message = await getLogMessage(pluginData, type, data, { - format: opts.format, - include_embed_timestamp: opts.include_embed_timestamp, - timestamp_format: opts.timestamp_format, - }); - - if (message) { - // For non-string log messages (i.e. embeds) batching or chunking is not possible, so send them immediately - if (typeof message !== "string") { - await channel.send(message).catch(noop); - return; - } - - // Default to batched unless explicitly disabled - const batched = opts.batched ?? true; - const batchTime = opts.batch_time ?? 1000; - const cfg = pluginData.config.get(); - const parse: MessageMentionTypes[] = cfg.allow_user_mentions ? ["users"] : []; - - if (batched) { - // If we're batching log messages, gather all log messages within the set batch_time into a single message - if (!pluginData.state.batches.has(channel.id)) { - pluginData.state.batches.set(channel.id, []); - setTimeout(async () => { - const batchedMessage = pluginData.state.batches.get(channel.id)!.join("\n"); - pluginData.state.batches.delete(channel.id); - createChunkedMessage(channel, batchedMessage, { parse }).catch(noop); - }, batchTime); - } - - pluginData.state.batches.get(channel.id)!.push(message); - } else { - // If we're not batching log messages, just send them immediately - await createChunkedMessage(channel, message, { parse }).catch(noop); - } - } + // tslint:disable-next-line:no-console + console.warn(`Error while sending ${typeStr} log to ${pluginData.guild.id}/${channelId}: ${err.message}`); + }); + }, + }), + ); } + + // Add log message to buffer + const buffer = pluginData.state.buffers.get(channelId)!; + buffer.push({ + content: typeof message === "string" ? message : message.content || "", + embeds: typeof message === "string" ? [] : ((message.embeds || []) as MessageEmbedOptions[]), + }); } } diff --git a/backend/src/plugins/Logs/util/onMessageDelete.ts b/backend/src/plugins/Logs/util/onMessageDelete.ts index f1f64893..0cbcc03a 100644 --- a/backend/src/plugins/Logs/util/onMessageDelete.ts +++ b/backend/src/plugins/Logs/util/onMessageDelete.ts @@ -1,51 +1,38 @@ -import { MessageAttachment, Snowflake } from "discord.js"; +import { Snowflake, TextChannel, ThreadChannel } from "discord.js"; import { GuildPluginData } from "knub"; -import moment from "moment-timezone"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; -import { resolveUser, useMediaUrls } from "../../../utils"; -import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; -import { FORMAT_NO_TIMESTAMP, LogsPluginType } from "../types"; +import { resolveUser } from "../../../utils"; +import { LogsPluginType } from "../types"; +import { logMessageDelete } from "../logFunctions/logMessageDelete"; +import { isLogIgnored } from "./isLogIgnored"; +import { logMessageDeleteBare } from "../logFunctions/logMessageDeleteBare"; export async function onMessageDelete(pluginData: GuildPluginData, savedMessage: SavedMessage) { const user = await resolveUser(pluginData.client, savedMessage.user_id); - const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)!; + const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake) as + | TextChannel + | ThreadChannel + | null; + + if (channel == null) { + return; + } + + if (isLogIgnored(pluginData, LogType.MESSAGE_DELETE, savedMessage.id)) { + return; + } if (user) { - // Replace attachment URLs with media URLs - if (savedMessage.data.attachments) { - for (const attachment of savedMessage.data.attachments as MessageAttachment[]) { - attachment.url = useMediaUrls(attachment.url); - } - } - - // See comment on FORMAT_NO_TIMESTAMP in types.ts - const config = pluginData.config.get(); - const timestampFormat = - (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format; - - pluginData.state.guildLogs.log( - LogType.MESSAGE_DELETE, - { - user: userToConfigAccessibleUser(user), - channel: channelToConfigAccessibleChannel(channel), - messageDate: pluginData - .getPlugin(TimeAndDatePlugin) - .inGuildTz(moment.utc(savedMessage.data.timestamp, "x")) - .format(timestampFormat), - message: savedMessage, - }, - savedMessage.id, - ); + logMessageDelete(pluginData, { + user, + channel, + message: savedMessage, + }); } else { - pluginData.state.guildLogs.log( - LogType.MESSAGE_DELETE_BARE, - { - messageId: savedMessage.id, - channel: channelToConfigAccessibleChannel(channel), - }, - savedMessage.id, - ); + logMessageDeleteBare(pluginData, { + messageId: savedMessage.id, + channel, + }); } } diff --git a/backend/src/plugins/Logs/util/onMessageDeleteBulk.ts b/backend/src/plugins/Logs/util/onMessageDeleteBulk.ts index 500b5cb7..1dec0101 100644 --- a/backend/src/plugins/Logs/util/onMessageDeleteBulk.ts +++ b/backend/src/plugins/Logs/util/onMessageDeleteBulk.ts @@ -1,24 +1,28 @@ -import { Snowflake } from "discord.js"; +import { BaseGuildTextChannel, Snowflake, ThreadChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; import { getBaseUrl } from "../../../pluginUtils"; import { LogsPluginType } from "../types"; +import { logMessageDeleteBulk } from "../logFunctions/logMessageDeleteBulk"; +import { isLogIgnored } from "./isLogIgnored"; export async function onMessageDeleteBulk(pluginData: GuildPluginData, savedMessages: SavedMessage[]) { - const channel = pluginData.guild.channels.cache.get(savedMessages[0].channel_id as Snowflake); + if (isLogIgnored(pluginData, LogType.MESSAGE_DELETE, savedMessages[0].id)) { + return; + } + + const channel = pluginData.guild.channels.cache.get(savedMessages[0].channel_id as Snowflake) as + | BaseGuildTextChannel + | ThreadChannel; const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild); const archiveUrl = pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId); - const authorIds = Array.from(new Set(savedMessages.map(item => `\`${item.user_id}\``))).join(", "); + const authorIds = Array.from(new Set(savedMessages.map((item) => `\`${item.user_id}\``))); - pluginData.state.guildLogs.log( - LogType.MESSAGE_DELETE_BULK, - { - count: savedMessages.length, - authorIds, - channel, - archiveUrl, - }, - savedMessages[0].id, - ); + logMessageDeleteBulk(pluginData, { + count: savedMessages.length, + authorIds, + channel, + archiveUrl, + }); } diff --git a/backend/src/plugins/Logs/util/onMessageUpdate.ts b/backend/src/plugins/Logs/util/onMessageUpdate.ts index 1d0efd1a..efd92d28 100644 --- a/backend/src/plugins/Logs/util/onMessageUpdate.ts +++ b/backend/src/plugins/Logs/util/onMessageUpdate.ts @@ -1,11 +1,12 @@ -import { MessageEmbed, Snowflake } from "discord.js"; +import { BaseGuildTextChannel, MessageEmbed, Snowflake, ThreadChannel } from "discord.js"; import { GuildPluginData } from "knub"; import cloneDeep from "lodash.clonedeep"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; import { resolveUser } from "../../../utils"; import { LogsPluginType } from "../types"; +import { logMessageEdit } from "../logFunctions/logMessageEdit"; export async function onMessageUpdate( pluginData: GuildPluginData, @@ -16,12 +17,12 @@ export async function onMessageUpdate( let logUpdate = false; const oldEmbedsToCompare = ((oldSavedMessage.data.embeds || []) as MessageEmbed[]) - .map(e => cloneDeep(e)) - .filter(e => (e as MessageEmbed).type === "rich"); + .map((e) => cloneDeep(e)) + .filter((e) => (e as MessageEmbed).type === "rich"); const newEmbedsToCompare = ((savedMessage.data.embeds || []) as MessageEmbed[]) - .map(e => cloneDeep(e)) - .filter(e => (e as MessageEmbed).type === "rich"); + .map((e) => cloneDeep(e)) + .filter((e) => (e as MessageEmbed).type === "rich"); for (const embed of [...oldEmbedsToCompare, ...newEmbedsToCompare]) { if (embed.thumbnail) { @@ -48,11 +49,13 @@ export async function onMessageUpdate( } const user = await resolveUser(pluginData.client, savedMessage.user_id); - const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)!; + const channel = pluginData.guild.channels.resolve(savedMessage.channel_id as Snowflake)! as + | BaseGuildTextChannel + | ThreadChannel; - pluginData.state.guildLogs.log(LogType.MESSAGE_EDIT, { - user: userToConfigAccessibleUser(user), - channel: channelToConfigAccessibleChannel(channel), + logMessageEdit(pluginData, { + user, + channel, before: oldSavedMessage, after: savedMessage, }); diff --git a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts index eb529ffa..c8a603e1 100644 --- a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts +++ b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts @@ -5,6 +5,7 @@ import { SaveMessagesToDBCmd } from "./commands/SaveMessagesToDB"; import { SavePinsToDBCmd } from "./commands/SavePinsToDB"; import { MessageCreateEvt, MessageDeleteBulkEvt, MessageDeleteEvt, MessageUpdateEvt } from "./events/SaveMessagesEvts"; import { ConfigSchema, MessageSaverPluginType } from "./types"; +import { Queue } from "../../Queue"; const defaultOptions: PluginOptions = { config: { diff --git a/backend/src/plugins/MessageSaver/events/SaveMessagesEvts.ts b/backend/src/plugins/MessageSaver/events/SaveMessagesEvts.ts index 97f4fe6d..86fa4dd0 100644 --- a/backend/src/plugins/MessageSaver/events/SaveMessagesEvts.ts +++ b/backend/src/plugins/MessageSaver/events/SaveMessagesEvts.ts @@ -1,5 +1,9 @@ -import { Message } from "discord.js"; +import { Constants, Message, MessageType, Snowflake } from "discord.js"; import { messageSaverEvt } from "../types"; +import { SECONDS } from "../../../utils"; +import moment from "moment-timezone"; + +const AFFECTED_MESSAGE_TYPES: MessageType[] = ["DEFAULT", "REPLY", "APPLICATION_COMMAND"]; export const MessageCreateEvt = messageSaverEvt({ event: "messageCreate", @@ -7,8 +11,17 @@ export const MessageCreateEvt = messageSaverEvt({ allowSelf: true, async listener(meta) { - // Only save regular chat messages - if (meta.args.message.type !== "DEFAULT" && meta.args.message.type !== "REPLY") { + if (!AFFECTED_MESSAGE_TYPES.includes(meta.args.message.type)) { + return; + } + + // Don't save partial messages + if (meta.args.message.partial) { + return; + } + + // Don't save the bot's own messages + if (meta.args.message.author.id === meta.pluginData.client.user?.id) { return; } @@ -26,6 +39,10 @@ export const MessageUpdateEvt = messageSaverEvt({ return; } + if (meta.args.oldMessage?.partial) { + return; + } + await meta.pluginData.state.savedMessages.saveEditFromMsg(meta.args.newMessage as Message); }, }); @@ -51,7 +68,7 @@ export const MessageDeleteBulkEvt = messageSaverEvt({ allowSelf: true, async listener(meta) { - const ids = meta.args.messages.map(m => m.id); + const ids = meta.args.messages.map((m) => m.id); await meta.pluginData.state.savedMessages.markBulkAsDeleted(ids); }, }); diff --git a/backend/src/plugins/MessageSaver/saveMessagesToDB.ts b/backend/src/plugins/MessageSaver/saveMessagesToDB.ts index db7e9b9d..e4206fce 100644 --- a/backend/src/plugins/MessageSaver/saveMessagesToDB.ts +++ b/backend/src/plugins/MessageSaver/saveMessagesToDB.ts @@ -15,7 +15,7 @@ export async function saveMessagesToDB( let thisMsg: Message; try { - thisMsg = await channel.messages.fetch(id as Snowflake); + thisMsg = await channel.messages.fetch(id); if (!thisMsg) { failed.push(id); diff --git a/backend/src/plugins/MessageSaver/types.ts b/backend/src/plugins/MessageSaver/types.ts index 68f815be..28495da3 100644 --- a/backend/src/plugins/MessageSaver/types.ts +++ b/backend/src/plugins/MessageSaver/types.ts @@ -1,6 +1,7 @@ import * as t from "io-ts"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; +import { Queue } from "../../Queue"; export const ConfigSchema = t.type({ can_manage: t.boolean, diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index d6c19a5a..452cabf6 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -1,4 +1,4 @@ -import { GuildMember, Message } from "discord.js"; +import { GuildMember, Message, Snowflake } from "discord.js"; import { EventEmitter } from "events"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; @@ -38,6 +38,7 @@ import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKi import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt"; import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt"; import { banUserId } from "./functions/banUserId"; +import { hasMutePermission } from "./functions/hasMutePerm"; import { kickMember } from "./functions/kickMember"; import { offModActionsEvent } from "./functions/offModActionsEvent"; import { onModActionsEvent } from "./functions/onModActionsEvent"; @@ -45,6 +46,7 @@ import { outdatedTempbansLoop } from "./functions/outdatedTempbansLoop"; import { updateCase } from "./functions/updateCase"; import { warnMember } from "./functions/warnMember"; import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types"; +import { LogsPlugin } from "../Logs/LogsPlugin"; const defaultOptions = { config: { @@ -120,16 +122,11 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ `), }, - dependencies: [TimeAndDatePlugin, CasesPlugin, MutesPlugin], + dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin], configSchema: ConfigSchema, defaultOptions, - events: [ - CreateBanCaseOnManualBanEvt, - CreateUnbanCaseOnManualUnbanEvt, - CreateKickCaseOnManualKickEvt, - PostAlertOnMemberJoinEvt, - ], + events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt], commands: [ UpdateCmd, @@ -181,6 +178,12 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ }; }, + hasMutePermission(pluginData) { + return (member: GuildMember, channelId: Snowflake) => { + return hasMutePermission(pluginData, member, channelId); + }; + }, + on: mapToPublicFn(onModActionsEvent), off: mapToPublicFn(offModActionsEvent), getEventEmitter(pluginData) { diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts index dd968c76..b28b1eeb 100644 --- a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts +++ b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts @@ -1,4 +1,4 @@ -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { CaseTypes } from "../../../data/CaseTypes"; import { Case } from "../../../data/entities/Case"; @@ -8,6 +8,7 @@ import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from ". import { resolveMember, resolveUser } from "../../../utils"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; import { modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; const opts = { mod: ct.member({ option: true }), @@ -79,8 +80,8 @@ export const AddCaseCmd = modActionsCmd({ } // Log the action - pluginData.state.serverLogs.log(LogType.CASE_CREATE, { - mod: userToConfigAccessibleUser(mod.user), + pluginData.getPlugin(LogsPlugin).logCaseCreate({ + mod: mod.user, userId: user.id, caseNum: theCase.case_number, caseType: type.toUpperCase(), diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts index 6437a5e9..40a352c7 100644 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ b/backend/src/plugins/ModActions/commands/BanCmd.ts @@ -1,6 +1,6 @@ import humanizeDuration from "humanize-duration"; import { getMemberLevel } from "knub/dist/helpers"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; @@ -14,6 +14,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach import { isBanned } from "../functions/isBanned"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; import { modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; const opts = { mod: ct.member({ option: true }), @@ -108,14 +109,22 @@ export const BanCmd = modActionsCmd({ reason, noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`], }); - const logtype = time ? LogType.MEMBER_TIMED_BAN : LogType.MEMBER_BAN; - pluginData.state.serverLogs.log(logtype, { - mod: userToConfigAccessibleUser(mod.user), - user: userToConfigAccessibleUser(user), - caseNumber: createdCase.case_number, - reason, - banTime: time ? humanizeDuration(time) : null, - }); + if (time) { + pluginData.getPlugin(LogsPlugin).logMemberTimedBan({ + mod: mod.user, + user, + caseNumber: createdCase.case_number, + reason, + banTime: humanizeDuration(time), + }); + } else { + pluginData.getPlugin(LogsPlugin).logMemberBan({ + mod: mod.user, + user, + caseNumber: createdCase.case_number, + reason, + }); + } sendSuccessMessage( pluginData, diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts index 45acbb73..14e89870 100644 --- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts +++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts @@ -46,9 +46,9 @@ export const CasesModCmd = modActionsCmd({ pluginData.client, msg.channel, totalPages, - async page => { + async (page) => { const cases = await casesPlugin.getRecentCasesByMod(modId, casesPerPage, (page - 1) * casesPerPage); - const lines = await asyncMap(cases, c => casesPlugin.getCaseSummary(c, true, msg.author.id)); + const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id)); const firstCaseNum = (page - 1) * casesPerPage + 1; const lastCaseNum = page * casesPerPage; diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts index 39d4050b..90023026 100644 --- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts +++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts @@ -53,13 +53,13 @@ export const CasesUserCmd = modActionsCmd({ if (typesToShow.length > 0) { // Reversed: Hide specified types - if (args.reverseFilters) cases = cases.filter(c => !typesToShow.includes(c.type)); + if (args.reverseFilters) cases = cases.filter((c) => !typesToShow.includes(c.type)); // Normal: Show only specified types - else cases = cases.filter(c => typesToShow.includes(c.type)); + else cases = cases.filter((c) => typesToShow.includes(c.type)); } - const normalCases = cases.filter(c => !c.is_hidden); - const hiddenCases = cases.filter(c => c.is_hidden); + const normalCases = cases.filter((c) => !c.is_hidden); + const hiddenCases = cases.filter((c) => c.is_hidden); const userName = user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : user.tag; @@ -83,7 +83,7 @@ export const CasesUserCmd = modActionsCmd({ } else { // Compact view (= regular message with a preview of each case) const casesPlugin = pluginData.getPlugin(CasesPlugin); - const lines = await asyncMap(casesToDisplay, c => casesPlugin.getCaseSummary(c, true, msg.author.id)); + const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id)); const prefix = getGuildPrefix(pluginData); const linesPerChunk = 10; diff --git a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts b/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts index 925d97c6..352b6d00 100644 --- a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts +++ b/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts @@ -1,6 +1,6 @@ import { TextChannel } from "discord.js"; import { helpers } from "knub"; -import { memberToConfigAccessibleMember } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { Case } from "../../../data/entities/Case"; import { LogType } from "../../../data/LogType"; @@ -81,9 +81,9 @@ export const DeleteCaseCmd = modActionsCmd({ ); const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.CASE_DELETE, { - mod: memberToConfigAccessibleMember(message.member), - case: stripObjectToScalars(theCase), + logs.logCaseDelete({ + mod: message.member, + case: theCase, }); } diff --git a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts index 8a1df43e..1df592b6 100644 --- a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts +++ b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts @@ -1,5 +1,5 @@ import { Snowflake } from "discord.js"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; @@ -10,6 +10,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach import { ignoreEvent } from "../functions/ignoreEvent"; import { isBanned } from "../functions/isBanned"; import { IgnoredEventType, modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; const opts = { mod: ct.member({ option: true }), @@ -91,8 +92,8 @@ export const ForcebanCmd = modActionsCmd({ sendSuccessMessage(pluginData, msg.channel, `Member forcebanned (Case #${createdCase.case_number})`); // Log the action - pluginData.state.serverLogs.log(LogType.MEMBER_FORCEBAN, { - mod: userToConfigAccessibleUser(mod.user), + pluginData.getPlugin(LogsPlugin).logMemberForceban({ + mod, userId: user.id, caseNumber: createdCase.case_number, reason, diff --git a/backend/src/plugins/ModActions/commands/MassBanCmd.ts b/backend/src/plugins/ModActions/commands/MassBanCmd.ts index e4e4fd44..b11f730b 100644 --- a/backend/src/plugins/ModActions/commands/MassBanCmd.ts +++ b/backend/src/plugins/ModActions/commands/MassBanCmd.ts @@ -1,7 +1,7 @@ import { Snowflake, TextChannel } from "discord.js"; import { waitForReply } from "knub/dist/helpers"; import { performance } from "perf_hooks"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; @@ -12,6 +12,7 @@ import { MINUTES, noop } from "../../../utils"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; import { ignoreEvent } from "../functions/ignoreEvent"; import { IgnoredEventType, modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const MassbanCmd = modActionsCmd({ trigger: "massban", @@ -129,8 +130,8 @@ export const MassbanCmd = modActionsCmd({ sendErrorMessage(pluginData, msg.channel, "All bans failed. Make sure the IDs are valid."); } else { // Some or all bans were successful. Create a log entry for the mass ban and notify the user. - pluginData.state.serverLogs.log(LogType.MASSBAN, { - mod: userToConfigAccessibleUser(msg.author), + pluginData.getPlugin(LogsPlugin).logMassBan({ + mod: msg.author, count: successfulBanCount, reason: banReason, }); diff --git a/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts b/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts index ea5e5770..83044c85 100644 --- a/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts +++ b/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts @@ -1,6 +1,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { waitForReply } from "knub/dist/helpers"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; @@ -10,6 +10,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach import { ignoreEvent } from "../functions/ignoreEvent"; import { isBanned } from "../functions/isBanned"; import { IgnoredEventType, modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const MassunbanCmd = modActionsCmd({ trigger: "massunban", @@ -41,7 +42,7 @@ export const MassunbanCmd = modActionsCmd({ // Ignore automatic unban cases and logs for these users // We'll create our own cases below and post a single "mass unbanned" log instead - args.userIds.forEach(userId => { + args.userIds.forEach((userId) => { // Use longer timeouts since this can take a while ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 120 * 1000); pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 120 * 1000); @@ -83,26 +84,26 @@ export const MassunbanCmd = modActionsCmd({ sendErrorMessage(pluginData, msg.channel, "All unbans failed. Make sure the IDs are valid and banned."); } else { // Some or all unbans were successful. Create a log entry for the mass unban and notify the user. - pluginData.state.serverLogs.log(LogType.MASSUNBAN, { - mod: userToConfigAccessibleUser(msg.author), + pluginData.getPlugin(LogsPlugin).logMassUnban({ + mod: msg.author, count: successfulUnbanCount, reason: unbanReason, }); if (failedUnbans.length) { - const notBanned = failedUnbans.filter(x => x.reason === UnbanFailReasons.NOT_BANNED); - const unbanFailed = failedUnbans.filter(x => x.reason === UnbanFailReasons.UNBAN_FAILED); + const notBanned = failedUnbans.filter((x) => x.reason === UnbanFailReasons.NOT_BANNED); + const unbanFailed = failedUnbans.filter((x) => x.reason === UnbanFailReasons.UNBAN_FAILED); let failedMsg = ""; if (notBanned.length > 0) { failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`; - notBanned.forEach(fail => { + notBanned.forEach((fail) => { failedMsg += " " + fail.userId; }); } if (unbanFailed.length > 0) { failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`; - unbanFailed.forEach(fail => { + unbanFailed.forEach((fail) => { failedMsg += " " + fail.userId; }); } diff --git a/backend/src/plugins/ModActions/commands/MassmuteCmd.ts b/backend/src/plugins/ModActions/commands/MassmuteCmd.ts index 5c82dd5b..ecd696df 100644 --- a/backend/src/plugins/ModActions/commands/MassmuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/MassmuteCmd.ts @@ -1,6 +1,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { waitForReply } from "knub/dist/helpers"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; @@ -8,6 +8,7 @@ import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin"; import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; import { modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const MassmuteCmd = modActionsCmd({ trigger: "massmute", @@ -52,7 +53,7 @@ export const MassmuteCmd = modActionsCmd({ // Ignore automatic mute cases and logs for these users // We'll create our own cases below and post a single "mass muted" log instead - args.userIds.forEach(userId => { + args.userIds.forEach((userId) => { // Use longer timeouts since this can take a while pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000); }); @@ -86,8 +87,8 @@ export const MassmuteCmd = modActionsCmd({ sendErrorMessage(pluginData, msg.channel, "All mutes failed. Make sure the IDs are valid."); } else { // Success on all or some mutes - pluginData.state.serverLogs.log(LogType.MASSMUTE, { - mod: userToConfigAccessibleUser(msg.author), + pluginData.getPlugin(LogsPlugin).logMassMute({ + mod: msg.author, count: successfulMuteCount, }); diff --git a/backend/src/plugins/ModActions/commands/NoteCmd.ts b/backend/src/plugins/ModActions/commands/NoteCmd.ts index 92e1b0fa..569831a8 100644 --- a/backend/src/plugins/ModActions/commands/NoteCmd.ts +++ b/backend/src/plugins/ModActions/commands/NoteCmd.ts @@ -1,4 +1,4 @@ -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; @@ -7,6 +7,7 @@ import { resolveUser } from "../../../utils"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; import { modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const NoteCmd = modActionsCmd({ trigger: "note", @@ -41,9 +42,9 @@ export const NoteCmd = modActionsCmd({ reason, }); - pluginData.state.serverLogs.log(LogType.MEMBER_NOTE, { - mod: userToConfigAccessibleUser(msg.author), - user: userToConfigAccessibleUser(user), + pluginData.getPlugin(LogsPlugin).logMemberNote({ + mod: msg.author, + user, caseNumber: createdCase.case_number, reason, }); diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts index 0e199b25..eb235917 100644 --- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnbanCmd.ts @@ -1,5 +1,5 @@ import { Snowflake } from "discord.js"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; @@ -9,6 +9,7 @@ import { resolveUser } from "../../../utils"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; import { ignoreEvent } from "../functions/ignoreEvent"; import { IgnoredEventType, modActionsCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; const opts = { mod: ct.member({ option: true }), @@ -73,11 +74,11 @@ export const UnbanCmd = modActionsCmd({ sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); // Log the action - pluginData.state.serverLogs.log(LogType.MEMBER_UNBAN, { - mod: userToConfigAccessibleUser(mod.user), + pluginData.getPlugin(LogsPlugin).logMemberUnban({ + mod: mod.user, userId: user.id, caseNumber: createdCase.case_number, - reason, + reason: reason ?? "", }); pluginData.state.events.emit("unban", user.id); diff --git a/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts index 875f8cd7..1afa0a01 100644 --- a/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts @@ -1,5 +1,5 @@ import { GuildAuditLogs, User } from "discord.js"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { Case } from "../../../data/entities/Case"; import { LogType } from "../../../data/LogType"; @@ -9,6 +9,7 @@ import { CasesPlugin } from "../../Cases/CasesPlugin"; import { clearIgnoredEvents } from "../functions/clearIgnoredEvents"; import { isEventIgnored } from "../functions/isEventIgnored"; import { IgnoredEventType, modActionsEvt } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; /** * Create a BAN case automatically when a user is banned manually. @@ -65,9 +66,9 @@ export const CreateBanCaseOnManualBanEvt = modActionsEvt({ } } - pluginData.state.serverLogs.log(LogType.MEMBER_BAN, { - mod: mod ? userToConfigAccessibleUser(mod) : null, - user: userToConfigAccessibleUser(user), + pluginData.getPlugin(LogsPlugin).logMemberBan({ + mod: mod ? userToTemplateSafeUser(mod) : null, + user: userToTemplateSafeUser(user), caseNumber: createdCase?.case_number ?? 0, reason, }); diff --git a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts index d69ede2a..421c8681 100644 --- a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts @@ -1,5 +1,5 @@ import { GuildAuditLogs, User } from "discord.js"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { Case } from "../../../data/entities/Case"; import { LogType } from "../../../data/LogType"; @@ -10,6 +10,7 @@ import { CasesPlugin } from "../../Cases/CasesPlugin"; import { clearIgnoredEvents } from "../functions/clearIgnoredEvents"; import { isEventIgnored } from "../functions/isEventIgnored"; import { IgnoredEventType, modActionsEvt } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; /** * Create a KICK case automatically when a user is kicked manually. @@ -58,9 +59,9 @@ export const CreateKickCaseOnManualKickEvt = modActionsEvt({ } } - pluginData.state.serverLogs.log(LogType.MEMBER_KICK, { - user: userToConfigAccessibleUser(member.user!), - mod: mod ? userToConfigAccessibleUser(mod) : null, + pluginData.getPlugin(LogsPlugin).logMemberKick({ + user: member.user!, + mod, caseNumber: createdCase?.case_number ?? 0, reason: kickAuditLogEntry.reason || "", }); diff --git a/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts index 83143606..de86e6e4 100644 --- a/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts +++ b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts @@ -1,5 +1,5 @@ import { GuildAuditLogs, User } from "discord.js"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { Case } from "../../../data/entities/Case"; import { LogType } from "../../../data/LogType"; @@ -9,6 +9,7 @@ import { CasesPlugin } from "../../Cases/CasesPlugin"; import { clearIgnoredEvents } from "../functions/clearIgnoredEvents"; import { isEventIgnored } from "../functions/isEventIgnored"; import { IgnoredEventType, modActionsEvt } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; /** * Create an UNBAN case automatically when a user is unbanned manually. @@ -63,10 +64,11 @@ export const CreateUnbanCaseOnManualUnbanEvt = modActionsEvt({ } } - pluginData.state.serverLogs.log(LogType.MEMBER_UNBAN, { - mod: mod ? userToConfigAccessibleUser(mod) : null, + pluginData.getPlugin(LogsPlugin).logMemberUnban({ + mod, userId: user.id, caseNumber: createdCase?.case_number ?? 0, + reason: "", }); pluginData.state.events.emit("unban", user.id); diff --git a/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts index 136e41cc..39a11c25 100644 --- a/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts +++ b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts @@ -24,14 +24,14 @@ export const PostAlertOnMemberJoinEvt = modActionsEvt({ if (actions.length) { const alertChannel = pluginData.guild.channels.cache.get(alertChannelId as Snowflake); if (!alertChannel) { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Unknown \`alert_channel\` configured for \`mod_actions\`: \`${alertChannelId}\``, }); return; } if (!(alertChannel instanceof TextChannel)) { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Non-text channel configured as \`alert_channel\` in \`mod_actions\`: \`${alertChannelId}\``, }); return; @@ -40,7 +40,7 @@ export const PostAlertOnMemberJoinEvt = modActionsEvt({ const botMember = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); const botPerms = alertChannel.permissionsFor(botMember ?? pluginData.client.user!.id); if (!hasDiscordPermissions(botPerms, Permissions.FLAGS.SEND_MESSAGES)) { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Missing "Send Messages" permissions for the \`alert_channel\` configured in \`mod_actions\`: \`${alertChannelId}\``, }); return; diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 3462842d..0fc69371 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -1,11 +1,11 @@ import { DiscordAPIError, Snowflake, User } from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; -import { renderTemplate } from "../../../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { createUserNotificationError, notifyUser, @@ -18,6 +18,7 @@ import { CasesPlugin } from "../../Cases/CasesPlugin"; import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from "../types"; import { getDefaultContactMethods } from "./getDefaultContactMethods"; import { ignoreEvent } from "./ignoreEvent"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; /** * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. @@ -47,24 +48,30 @@ export async function banUserId( if (contactMethods.length) { if (!banTime && config.ban_message) { - const banMessage = await renderTemplate(config.ban_message, { - guildName: pluginData.guild.name, - reason, - moderator: banOptions.caseArgs?.modId - ? stripObjectToScalars(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) - : {}, - }); + const banMessage = await renderTemplate( + config.ban_message, + new TemplateSafeValueContainer({ + guildName: pluginData.guild.name, + reason, + moderator: banOptions.caseArgs?.modId + ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) + : null, + }), + ); notifyResult = await notifyUser(user, banMessage, contactMethods); } else if (banTime && config.tempban_message) { - const banMessage = await renderTemplate(config.tempban_message, { - guildName: pluginData.guild.name, - reason, - moderator: banOptions.caseArgs?.modId - ? stripObjectToScalars(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) - : {}, - banTime: humanizeDuration(banTime), - }); + const banMessage = await renderTemplate( + config.tempban_message, + new TemplateSafeValueContainer({ + guildName: pluginData.guild.name, + reason, + moderator: banOptions.caseArgs?.modId + ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) + : null, + banTime: humanizeDuration(banTime), + }), + ); notifyResult = await notifyUser(user, banMessage, contactMethods); } else { @@ -128,14 +135,23 @@ export async function banUserId( // Log the action const mod = await resolveUser(pluginData.client, modId); - const logtype = banTime ? LogType.MEMBER_TIMED_BAN : LogType.MEMBER_BAN; - pluginData.state.serverLogs.log(logtype, { - mod: userToConfigAccessibleUser(mod), - user: userToConfigAccessibleUser(user), - caseNumber: createdCase.case_number, - reason, - banTime: banTime ? humanizeDuration(banTime) : null, - }); + + if (banTime) { + pluginData.getPlugin(LogsPlugin).logMemberTimedBan({ + mod, + user, + caseNumber: createdCase.case_number, + reason: reason ?? "", + banTime: humanizeDuration(banTime), + }); + } else { + pluginData.getPlugin(LogsPlugin).logMemberBan({ + mod, + user, + caseNumber: createdCase.case_number, + reason: reason ?? "", + }); + } pluginData.state.events.emit("ban", user.id, reason, banOptions.isAutomodAction); diff --git a/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts b/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts index cbcf082f..c16dabcf 100644 --- a/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts +++ b/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts @@ -7,7 +7,7 @@ export function clearIgnoredEvents( userId: string, ) { pluginData.state.ignoredEvents.splice( - pluginData.state.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), + pluginData.state.ignoredEvents.findIndex((info) => type === info.type && userId === info.userId), 1, ); } diff --git a/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts b/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts index 809fe909..77194a3f 100644 --- a/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts +++ b/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts @@ -1,6 +1,6 @@ import { MessageAttachment } from "discord.js"; export function formatReasonWithAttachments(reason: string, attachments: MessageAttachment[]) { - const attachmentUrls = attachments.map(a => a.url); + const attachmentUrls = attachments.map((a) => a.url); return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); } diff --git a/backend/src/plugins/ModActions/functions/hasMutePerm.ts b/backend/src/plugins/ModActions/functions/hasMutePerm.ts new file mode 100644 index 00000000..b26edd4d --- /dev/null +++ b/backend/src/plugins/ModActions/functions/hasMutePerm.ts @@ -0,0 +1,11 @@ +import { GuildMember, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../types"; + +export async function hasMutePermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute; +} diff --git a/backend/src/plugins/ModActions/functions/isBanned.ts b/backend/src/plugins/ModActions/functions/isBanned.ts index 09ec1e2e..57ea8bf4 100644 --- a/backend/src/plugins/ModActions/functions/isBanned.ts +++ b/backend/src/plugins/ModActions/functions/isBanned.ts @@ -13,7 +13,7 @@ export async function isBanned( ): Promise { const botMember = pluginData.guild.members.cache.get(pluginData.client.user!.id); if (botMember && !hasDiscordPermissions(botMember.permissions, Permissions.FLAGS.BAN_MEMBERS)) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing "Ban Members" permission to check for existing bans`, }); return false; @@ -37,7 +37,7 @@ export async function isBanned( } if (isDiscordAPIError(e) && e.code === 50013) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing "Ban Members" permission to check for existing bans`, }); } diff --git a/backend/src/plugins/ModActions/functions/isEventIgnored.ts b/backend/src/plugins/ModActions/functions/isEventIgnored.ts index 32ae4acf..8ec27baf 100644 --- a/backend/src/plugins/ModActions/functions/isEventIgnored.ts +++ b/backend/src/plugins/ModActions/functions/isEventIgnored.ts @@ -6,5 +6,5 @@ export function isEventIgnored( type: IgnoredEventType, userId: string, ) { - return pluginData.state.ignoredEvents.some(info => type === info.type && userId === info.userId); + return pluginData.state.ignoredEvents.some((info) => type === info.type && userId === info.userId); } diff --git a/backend/src/plugins/ModActions/functions/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts index 3ffcab6b..d70722a1 100644 --- a/backend/src/plugins/ModActions/functions/kickMember.ts +++ b/backend/src/plugins/ModActions/functions/kickMember.ts @@ -1,14 +1,15 @@ import { GuildMember } from "discord.js"; import { GuildPluginData } from "knub"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; -import { renderTemplate } from "../../../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { createUserNotificationError, notifyUser, resolveUser, ucfirst, UserNotificationResult } from "../../../utils"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { IgnoredEventType, KickOptions, KickResult, ModActionsPluginType } from "../types"; import { getDefaultContactMethods } from "./getDefaultContactMethods"; import { ignoreEvent } from "./ignoreEvent"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; /** * Kick the specified server member. Generates a case. @@ -30,13 +31,16 @@ export async function kickMember( if (contactMethods.length) { if (config.kick_message) { - const kickMessage = await renderTemplate(config.kick_message, { - guildName: pluginData.guild.name, - reason, - moderator: kickOptions.caseArgs?.modId - ? userToConfigAccessibleUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId)) - : {}, - }); + const kickMessage = await renderTemplate( + config.kick_message, + new TemplateSafeValueContainer({ + guildName: pluginData.guild.name, + reason, + moderator: kickOptions.caseArgs?.modId + ? userToTemplateSafeUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId)) + : null, + }), + ); notifyResult = await notifyUser(member.user, kickMessage, contactMethods); } else { @@ -72,11 +76,11 @@ export async function kickMember( // Log the action const mod = await resolveUser(pluginData.client, modId); - pluginData.state.serverLogs.log(LogType.MEMBER_KICK, { - mod: userToConfigAccessibleUser(mod), - user: userToConfigAccessibleUser(member.user), + pluginData.getPlugin(LogsPlugin).logMemberKick({ + mod, + user: member.user, caseNumber: createdCase.case_number, - reason, + reason: reason ?? "", }); pluginData.state.events.emit("kick", member.id, reason, kickOptions.isAutomodAction); diff --git a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts index 935e16a4..ca3c79ef 100644 --- a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts +++ b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts @@ -4,7 +4,7 @@ import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { LogType } from "src/data/LogType"; import { logger } from "src/logger"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { resolveUser, SECONDS } from "../../../utils"; import { CasesPlugin } from "../../Cases/CasesPlugin"; @@ -12,6 +12,7 @@ import { IgnoredEventType, ModActionsPluginType } from "../types"; import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; import { ignoreEvent } from "./ignoreEvent"; import { isBanned } from "./isBanned"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; const TEMPBAN_LOOP_TIME = 60 * SECONDS; @@ -34,7 +35,7 @@ export async function outdatedTempbansLoop(pluginData: GuildPluginData, @@ -20,13 +21,16 @@ export async function warnMember( let notifyResult: UserNotificationResult; if (config.warn_message) { - const warnMessage = await renderTemplate(config.warn_message, { - guildName: pluginData.guild.name, - reason, - moderator: warnOptions.caseArgs?.modId - ? userToConfigAccessibleUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId)) - : {}, - }); + const warnMessage = await renderTemplate( + config.warn_message, + new TemplateSafeValueContainer({ + guildName: pluginData.guild.name, + reason, + moderator: warnOptions.caseArgs?.modId + ? userToTemplateSafeUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId)) + : null, + }), + ); const contactMethods = warnOptions?.contactMethods ? warnOptions.contactMethods : getDefaultContactMethods(pluginData, "warn"); @@ -70,11 +74,11 @@ export async function warnMember( }); const mod = await pluginData.guild.members.fetch(modId as Snowflake); - pluginData.state.serverLogs.log(LogType.MEMBER_WARN, { - mod: memberToConfigAccessibleMember(mod), - member: memberToConfigAccessibleMember(member), + pluginData.getPlugin(LogsPlugin).logMemberWarn({ + mod, + member, caseNumber: createdCase.case_number, - reason, + reason: reason ?? "", }); pluginData.state.events.emit("warn", member.id, reason, warnOptions.isAutomodAction); diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index 3f0d9aae..ad1c0c66 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -59,8 +59,6 @@ const defaultOptions = { }; const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000; -let FIRST_CHECK_TIME = Date.now(); -const FIRST_CHECK_INCREMENT = 5 * 1000; export const MutesPlugin = zeppelinGuildPlugin()({ name: "mutes", @@ -70,10 +68,9 @@ export const MutesPlugin = zeppelinGuildPlugin()({ }, configSchema: ConfigSchema, + dependencies: () => [CasesPlugin, LogsPlugin], defaultOptions, - dependencies: [CasesPlugin, LogsPlugin], - // prettier-ignore commands: [ MutesCmd, @@ -116,17 +113,11 @@ export const MutesPlugin = zeppelinGuildPlugin()({ }, afterLoad(pluginData) { - // Check for expired mutes every 5s - const firstCheckTime = Math.max(Date.now(), FIRST_CHECK_TIME) + FIRST_CHECK_INCREMENT; - FIRST_CHECK_TIME = firstCheckTime; - - setTimeout(() => { - clearExpiredMutes(pluginData); - pluginData.state.muteClearIntervalId = setInterval( - () => clearExpiredMutes(pluginData), - EXPIRED_MUTE_CHECK_INTERVAL, - ); - }, firstCheckTime - Date.now()); + clearExpiredMutes(pluginData); + pluginData.state.muteClearIntervalId = setInterval( + () => clearExpiredMutes(pluginData), + EXPIRED_MUTE_CHECK_INTERVAL, + ); }, beforeUnload(pluginData) { diff --git a/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts index ad5b9666..cd18bf0f 100644 --- a/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts +++ b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts @@ -13,7 +13,7 @@ export const ClearBannedMutesCmd = mutesCmd({ const activeMutes = await pluginData.state.mutes.getActiveMutes(); const bans = await pluginData.guild.bans.fetch({ cache: true }); - const bannedIds = bans.map(b => b.user.id); + const bannedIds = bans.map((b) => b.user.id); await msg.channel.send(`Found ${activeMutes.length} mutes and ${bannedIds.length} bans, cross-referencing...`); diff --git a/backend/src/plugins/Mutes/commands/MutesCmd.ts b/backend/src/plugins/Mutes/commands/MutesCmd.ts index 99356e54..eeff2c4f 100644 --- a/backend/src/plugins/Mutes/commands/MutesCmd.ts +++ b/backend/src/plugins/Mutes/commands/MutesCmd.ts @@ -53,12 +53,12 @@ export const MutesCmd = mutesCmd({ if (args.manual) { // Show only manual mutes (i.e. "Muted" role added without a logged mute) - const muteUserIds = new Set(activeMutes.map(m => m.user_id)); + const muteUserIds = new Set(activeMutes.map((m) => m.user_id)); const manuallyMutedMembers: GuildMember[] = []; const muteRole = pluginData.config.get().mute_role; if (muteRole) { - pluginData.guild.members.cache.forEach(member => { + pluginData.guild.members.cache.forEach((member) => { if (muteUserIds.has(member.id)) return; if (member.roles.cache.has(muteRole as Snowflake)) manuallyMutedMembers.push(member); }); @@ -66,7 +66,7 @@ export const MutesCmd = mutesCmd({ totalMutes = manuallyMutedMembers.length; - lines = manuallyMutedMembers.map(member => { + lines = manuallyMutedMembers.map((member) => { return `<@!${member.id}> (**${member.user.tag}**, \`${member.id}\`) 🔧 Manual mute`; }); } else { @@ -76,11 +76,8 @@ export const MutesCmd = mutesCmd({ // Filter: mute age if (args.age) { - const cutoff = moment - .utc() - .subtract(args.age, "ms") - .format(DBDateFormat); - filteredMutes = filteredMutes.filter(m => m.created_at <= cutoff); + const cutoff = moment.utc().subtract(args.age, "ms").format(DBDateFormat); + filteredMutes = filteredMutes.filter((m) => m.created_at <= cutoff); hasFilters = true; } @@ -93,7 +90,7 @@ export const MutesCmd = mutesCmd({ if (!member) { if (!bannedIds) { const bans = await pluginData.guild.bans.fetch({ cache: true }); - bannedIds = bans.map(u => u.user.id); + bannedIds = bans.map((u) => u.user.id); } muteWithDetails.banned = bannedIds.includes(mute.user_id); @@ -106,18 +103,18 @@ export const MutesCmd = mutesCmd({ // Filter: left the server if (args.left != null) { - filteredMutes = filteredMutes.filter(m => (args.left && !m.member) || (!args.left && m.member)); + filteredMutes = filteredMutes.filter((m) => (args.left && !m.member) || (!args.left && m.member)); hasFilters = true; } totalMutes = filteredMutes.length; // Create a message line for each mute - const caseIds = filteredMutes.map(m => m.case_id).filter(v => !!v); + const caseIds = filteredMutes.map((m) => m.case_id).filter((v) => !!v); const muteCases = caseIds.length ? await pluginData.state.cases.get(caseIds) : []; const muteCasesById = muteCases.reduce((map, c) => map.set(c.id, c), new Map()); - lines = filteredMutes.map(mute => { + lines = filteredMutes.map((mute) => { const user = pluginData.client.users.resolve(mute.user_id as Snowflake); const username = user ? user.tag : "Unknown#0000"; const theCase = muteCasesById.get(mute.case_id); @@ -152,7 +149,7 @@ export const MutesCmd = mutesCmd({ let currentPage = 1; const totalPages = Math.ceil(lines.length / mutesPerPage); - const drawListPage = async page => { + const drawListPage = async (page) => { page = Math.max(1, Math.min(totalPages, page)); currentPage = page; @@ -197,19 +194,9 @@ export const MutesCmd = mutesCmd({ const idMod = `${listMessage.id}:muteList`; const buttons: MessageButton[] = []; - buttons.push( - new MessageButton() - .setStyle("SECONDARY") - .setEmoji("⬅") - .setCustomId(`previousButton:${idMod}`), - ); + buttons.push(new MessageButton().setStyle("SECONDARY").setEmoji("⬅").setCustomId(`previousButton:${idMod}`)); - buttons.push( - new MessageButton() - .setStyle("SECONDARY") - .setEmoji("➡") - .setCustomId(`nextButton:${idMod}`), - ); + buttons.push(new MessageButton().setStyle("SECONDARY").setEmoji("➡").setCustomId(`nextButton:${idMod}`)); const row = new MessageActionRow().addComponents(buttons); await listMessage.edit({ components: [row] }); diff --git a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts index 7d4ece2d..20611e45 100644 --- a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts +++ b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts @@ -1,8 +1,9 @@ import { Snowflake } from "discord.js"; -import { memberToConfigAccessibleMember } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { mutesEvt } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; /** * Reapply active mutes on join @@ -20,8 +21,8 @@ export const ReapplyActiveMuteOnJoinEvt = mutesEvt({ memberRoleLock.unlock(); } - pluginData.state.serverLogs.log(LogType.MEMBER_MUTE_REJOIN, { - member: memberToConfigAccessibleMember(member), + pluginData.getPlugin(LogsPlugin).logMemberMuteRejoin({ + member, }); } }, diff --git a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts index d812dd9d..dc83fee6 100644 --- a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts +++ b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts @@ -1,15 +1,16 @@ import { Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import { memberToConfigAccessibleMember } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; -import { resolveMember, UnknownUser } from "../../../utils"; +import { resolveMember, UnknownUser, verboseUserMention } from "../../../utils"; import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { MutesPluginType } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function clearExpiredMutes(pluginData: GuildPluginData) { const expiredMutes = await pluginData.state.mutes.getExpiredMutes(); for (const mute of expiredMutes) { - const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id); + const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true); if (member) { try { @@ -17,34 +18,31 @@ export async function clearExpiredMutes(pluginData: GuildPluginData roleId !== muteRole); for (const toRestore of mute.roles_to_restore) { - if (guildRoles.has(toRestore as Snowflake) && toRestore !== muteRole) newRoles.push(toRestore); + if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) { + newRoles.push(toRestore); + } } - await member.roles.set(newRoles as Snowflake[]); + await member.roles.set(newRoles); } lock.unlock(); } catch { - pluginData.state.serverLogs.log(LogType.BOT_ALERT, { - body: `Failed to remove mute role from {userMention(member)}`, - member: memberToConfigAccessibleMember(member), + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to remove mute role from ${verboseUserMention(member.user)}`, }); } } await pluginData.state.mutes.clear(mute.user_id); - pluginData.state.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, { - member: member - ? memberToConfigAccessibleMember(member) - : { id: mute.user_id, user: new UnknownUser({ id: mute.user_id }) }, + pluginData.getPlugin(LogsPlugin).logMemberMuteExpired({ + member: member || new UnknownUser({ id: mute.user_id }), }); pluginData.state.events.emit("unmute", mute.user_id); diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index 88001f44..cf0aeca0 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -1,13 +1,13 @@ import { Snowflake, TextChannel, User } from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { Case } from "../../../data/entities/Case"; import { LogType } from "../../../data/LogType"; import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; -import { renderTemplate } from "../../../templateFormatter"; +import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; import { notifyUser, resolveMember, @@ -67,12 +67,12 @@ export async function muteUser( if (!Array.isArray(removeRoles)) { if (removeRoles) { // exclude managed roles from being removed - const managedRoles = pluginData.guild.roles.cache.filter(x => x.managed).map(y => y.id); - newRoles = currentUserRoles.filter(r => !managedRoles.includes(r)); + const managedRoles = pluginData.guild.roles.cache.filter((x) => x.managed).map((y) => y.id); + newRoles = currentUserRoles.filter((r) => !managedRoles.includes(r)); await member.roles.set(newRoles as Snowflake[]); } } else { - newRoles = currentUserRoles.filter(x => !(removeRoles).includes(x)); + newRoles = currentUserRoles.filter((x) => !(removeRoles).includes(x)); await member.roles.set(newRoles as Snowflake[]); } @@ -82,7 +82,7 @@ export async function muteUser( rolesToRestore = currentUserRoles; } } else { - rolesToRestore = currentUserRoles.filter(x => (restoreRoles).includes(x)); + rolesToRestore = currentUserRoles.filter((x) => (restoreRoles).includes(x)); } // Apply mute role if it's missing @@ -93,25 +93,25 @@ export async function muteUser( const actualMuteRole = pluginData.guild.roles.cache.get(muteRole as Snowflake); if (!actualMuteRole) { lock.unlock(); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Cannot mute users, specified mute role Id is invalid`, }); throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); } const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); - const zepRoles = pluginData.guild.roles.cache.filter(x => zep!.roles.cache.has(x.id)); + const zepRoles = pluginData.guild.roles.cache.filter((x) => zep!.roles.cache.has(x.id)); // If we have roles and one of them is above the muted role, throw generic error - if (zepRoles.size >= 0 && zepRoles.some(zepRole => zepRole.position > actualMuteRole.position)) { + if (zepRoles.size >= 0 && zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) { lock.unlock(); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Cannot mute user ${member.id}: ${e}`, }); throw e; } else { // Otherwise, throw error that mute role is above zeps roles lock.unlock(); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, }); throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP); @@ -151,14 +151,17 @@ export async function muteUser( const muteMessage = template && - (await renderTemplate(template, { - guildName: pluginData.guild.name, - reason: reason || "None", - time: timeUntilUnmute, - moderator: muteOptions.caseArgs?.modId - ? userToConfigAccessibleUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId)) - : "", - })); + (await renderTemplate( + template, + new TemplateSafeValueContainer({ + guildName: pluginData.guild.name, + reason: reason || "None", + time: timeUntilUnmute, + moderator: muteOptions.caseArgs?.modId + ? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId)) + : null, + }), + )); if (muteMessage && user instanceof User) { let contactMethods: UserNotificationMethod[] = []; @@ -224,19 +227,19 @@ export async function muteUser( // Log the action const mod = await resolveUser(pluginData.client, muteOptions.caseArgs?.modId); if (muteTime) { - pluginData.state.serverLogs.log(LogType.MEMBER_TIMED_MUTE, { - mod: userToConfigAccessibleUser(mod), - user: userToConfigAccessibleUser(user), + pluginData.getPlugin(LogsPlugin).logMemberTimedMute({ + mod, + user, time: timeUntilUnmute, caseNumber: theCase.case_number, - reason, + reason: reason ?? "", }); } else { - pluginData.state.serverLogs.log(LogType.MEMBER_MUTE, { - mod: userToConfigAccessibleUser(mod), - user: userToConfigAccessibleUser(user), + pluginData.getPlugin(LogsPlugin).logMemberMute({ + mod, + user, caseNumber: theCase.case_number, - reason, + reason: reason ?? "", }); } diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts index 35fd1880..0f2d88d5 100644 --- a/backend/src/plugins/Mutes/functions/unmuteUser.ts +++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts @@ -1,7 +1,7 @@ import { Snowflake } from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; -import { userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; import { resolveMember, resolveUser } from "../../../utils"; @@ -10,6 +10,7 @@ import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CaseArgs } from "../../Cases/types"; import { MutesPluginType, UnmuteResult } from "../types"; import { memberHasMutedRole } from "./memberHasMutedRole"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function unmuteUser( pluginData: GuildPluginData, @@ -19,7 +20,7 @@ export async function unmuteUser( ): Promise { const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(userId); const user = await resolveUser(pluginData.client, userId); - const member = await resolveMember(pluginData.client, pluginData.guild, userId); // Grab the fresh member so we don't have stale role info + const member = await resolveMember(pluginData.client, pluginData.guild, userId, true); // Grab the fresh member so we don't have stale role info const modId = caseArgs.modId || pluginData.client.user!.id; if (!existingMute && member && !memberHasMutedRole(pluginData, member)) return null; @@ -42,12 +43,13 @@ export async function unmuteUser( } if (existingMute?.roles_to_restore) { const guildRoles = pluginData.guild.roles.cache; - let newRoles = [...member.roles.cache.keys()]; - newRoles = muteRole && newRoles.includes(muteRole) ? newRoles.splice(newRoles.indexOf(muteRole), 1) : newRoles; + const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole); for (const toRestore of existingMute.roles_to_restore) { - if (guildRoles.has(toRestore as Snowflake) && toRestore !== muteRole) newRoles.push(toRestore); + if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) { + newRoles.push(toRestore); + } } - await member.roles.set(newRoles as Snowflake[]); + await member.roles.set(newRoles); } lock.unlock(); @@ -87,19 +89,19 @@ export async function unmuteUser( // Log the action const mod = await pluginData.client.users.fetch(modId as Snowflake); if (unmuteTime) { - pluginData.state.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, { - mod: userToConfigAccessibleUser(mod), - user: userToConfigAccessibleUser(user), + pluginData.getPlugin(LogsPlugin).logMemberTimedUnmute({ + mod, + user, caseNumber: createdCase.case_number, time: timeUntilUnmute, - reason: caseArgs.reason, + reason: caseArgs.reason ?? "", }); } else { - pluginData.state.serverLogs.log(LogType.MEMBER_UNMUTE, { - mod: userToConfigAccessibleUser(mod), - user: userToConfigAccessibleUser(user), + pluginData.getPlugin(LogsPlugin).logMemberUnmute({ + mod, + user, caseNumber: createdCase.case_number, - reason: caseArgs.reason, + reason: caseArgs.reason ?? "", }); } diff --git a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts index 250cace5..0efb5fe3 100644 --- a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts +++ b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts @@ -35,8 +35,9 @@ export const NameHistoryPlugin = zeppelinGuildPlugin()({ // prettier-ignore events: [ - ChannelJoinEvt, - MessageCreateEvt, + // FIXME: Temporary + // ChannelJoinEvt, + // MessageCreateEvt, ], beforeLoad(pluginData) { diff --git a/backend/src/plugins/NameHistory/commands/NamesCmd.ts b/backend/src/plugins/NameHistory/commands/NamesCmd.ts index 710e7ba6..fcb8c242 100644 --- a/backend/src/plugins/NameHistory/commands/NamesCmd.ts +++ b/backend/src/plugins/NameHistory/commands/NamesCmd.ts @@ -26,9 +26,9 @@ export const NamesCmd = nameHistoryCmd({ } const nicknameRows = nicknames.map( - r => `\`[${r.timestamp}]\` ${r.nickname ? `**${disableCodeBlocks(r.nickname)}**` : "*None*"}`, + (r) => `\`[${r.timestamp}]\` ${r.nickname ? `**${disableCodeBlocks(r.nickname)}**` : "*None*"}`, ); - const usernameRows = usernames.map(r => `\`[${r.timestamp}]\` **${disableCodeBlocks(r.username)}**`); + const usernameRows = usernames.map((r) => `\`[${r.timestamp}]\` **${disableCodeBlocks(r.username)}**`); const user = await pluginData.client.users.fetch(args.userId as Snowflake).catch(() => null); const currentUsername = user ? user.tag : args.userId; diff --git a/backend/src/plugins/Persist/PersistPlugin.ts b/backend/src/plugins/Persist/PersistPlugin.ts index ede40235..3ba19fc3 100644 --- a/backend/src/plugins/Persist/PersistPlugin.ts +++ b/backend/src/plugins/Persist/PersistPlugin.ts @@ -27,7 +27,7 @@ export const PersistPlugin = zeppelinGuildPlugin()({ `), }, - dependencies: [LogsPlugin], + dependencies: () => [LogsPlugin], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/Persist/events/LoadDataEvt.ts b/backend/src/plugins/Persist/events/LoadDataEvt.ts index 75c3871d..4d2becb4 100644 --- a/backend/src/plugins/Persist/events/LoadDataEvt.ts +++ b/backend/src/plugins/Persist/events/LoadDataEvt.ts @@ -1,6 +1,6 @@ import { GuildMemberEditData, Permissions } from "discord.js"; import intersection from "lodash.intersection"; -import { memberToConfigAccessibleMember } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { canAssignRole } from "../../../utils/canAssignRole"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; @@ -37,7 +37,7 @@ export const LoadDataEvt = persistEvt({ if (config.persisted_roles) requiredPermissions |= p.MANAGE_ROLES; const missingPermissions = getMissingPermissions(me.permissions, requiredPermissions); if (missingPermissions) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions for persist plugin: ${missingPermissionError(missingPermissions)}`, }); return; @@ -47,7 +47,7 @@ export const LoadDataEvt = persistEvt({ if (config.persisted_roles) { for (const roleId of config.persisted_roles) { if (!canAssignRole(pluginData.guild, me, roleId)) { - pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Missing permissions to assign role \`${roleId}\` in persist plugin`, }); return; @@ -61,7 +61,7 @@ export const LoadDataEvt = persistEvt({ if (rolesToRestore.length) { restoredData.push("roles"); - toRestore.roles = Array.from(new Set([...rolesToRestore, ...member.roles.cache])); + toRestore.roles = Array.from(new Set([...rolesToRestore, ...member.roles.cache.keys()])); } } @@ -74,8 +74,8 @@ export const LoadDataEvt = persistEvt({ await member.edit(toRestore, "Restored upon rejoin"); await pluginData.state.persistedData.clear(member.id); - pluginData.state.logs.log(LogType.MEMBER_RESTORE, { - member: memberToConfigAccessibleMember(member), + pluginData.getPlugin(LogsPlugin).logMemberRestore({ + member, restoredData: restoredData.join(", "), }); } diff --git a/backend/src/plugins/Persist/events/StoreDataEvt.ts b/backend/src/plugins/Persist/events/StoreDataEvt.ts index aad0c48e..45dbda4b 100644 --- a/backend/src/plugins/Persist/events/StoreDataEvt.ts +++ b/backend/src/plugins/Persist/events/StoreDataEvt.ts @@ -16,7 +16,7 @@ export const StoreDataEvt = persistEvt({ const persistedRoles = config.persisted_roles; if (persistedRoles.length && member.roles) { - const rolesToPersist = intersection(persistedRoles, member.roles); + const rolesToPersist = intersection(persistedRoles, [...member.roles.cache.keys()]); if (rolesToPersist.length) { persist = true; persistData.roles = rolesToPersist; diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts index 493f8adb..1b176252 100644 --- a/backend/src/plugins/Post/PostPlugin.ts +++ b/backend/src/plugins/Post/PostPlugin.ts @@ -13,6 +13,7 @@ import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd"; import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd"; import { ConfigSchema, PostPluginType } from "./types"; import { scheduledPostLoop } from "./util/scheduledPostLoop"; +import { LogsPlugin } from "../Logs/LogsPlugin"; const defaultOptions: PluginOptions = { config: { @@ -35,7 +36,7 @@ export const PostPlugin = zeppelinGuildPlugin()({ prettyName: "Post", }, - dependencies: [TimeAndDatePlugin], + dependencies: () => [TimeAndDatePlugin, LogsPlugin], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/Post/commands/PostEmbedCmd.ts b/backend/src/plugins/Post/commands/PostEmbedCmd.ts index 6db7d1cc..e822cd3b 100644 --- a/backend/src/plugins/Post/commands/PostEmbedCmd.ts +++ b/backend/src/plugins/Post/commands/PostEmbedCmd.ts @@ -82,6 +82,6 @@ export const PostEmbedCmd = postCmd({ ); } - actualPostCmd(pluginData, msg, args.channel, { embed }, args); + actualPostCmd(pluginData, msg, args.channel, { embeds: [embed] }, args); }, }); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts index 7ac59698..a0a88766 100644 --- a/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts @@ -21,9 +21,8 @@ export const ScheduledPostsListCmd = postCmd({ scheduledPosts.sort(sorter("post_at")); let i = 1; - const postLines = scheduledPosts.map(p => { - let previewText = - p.content.content || (p.content.embed && (p.content.embed.description || p.content.embed.title)) || ""; + const postLines = scheduledPosts.map((p) => { + let previewText = p.content.content || p.content.embeds?.[0]?.description || p.content.embeds?.[0]?.title || ""; const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH; @@ -37,7 +36,7 @@ export const ScheduledPostsListCmd = postCmd({ .format(timeAndDate.getDateFormat("pretty_datetime")); const parts = [`\`#${i++}\` \`[${prettyPostAt}]\` ${previewText}${isTruncated ? "..." : ""}`]; if (p.attachments.length) parts.push("*(with attachment)*"); - if (p.content.embed) parts.push("*(embed)*"); + if (p.content.embeds?.length) parts.push("*(embed)*"); if (p.repeat_until) { parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`); } diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts index d6865759..32320368 100644 --- a/backend/src/plugins/Post/util/actualPostCmd.ts +++ b/backend/src/plugins/Post/util/actualPostCmd.ts @@ -1,8 +1,8 @@ -import { Channel, Message, TextChannel } from "discord.js"; +import { Channel, Message, NewsChannel, TextChannel, ThreadChannel } from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { DBDateFormat, errorMessage, MINUTES, StrictMessageContent } from "../../../utils"; @@ -10,6 +10,7 @@ import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; import { PostPluginType } from "../types"; import { parseScheduleTime } from "./parseScheduleTime"; import { postMessage } from "./postMessage"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; const MIN_REPEAT_TIME = 5 * MINUTES; const MAX_REPEAT_TIME = Math.pow(2, 32); @@ -28,8 +29,12 @@ export async function actualPostCmd( "repeat-times"?: number; } = {}, ) { - if (!(targetChannel instanceof TextChannel)) { - msg.channel.send(errorMessage("Channel is not a text channel")); + if ( + !(targetChannel instanceof TextChannel) && + !(targetChannel instanceof NewsChannel) && + !(targetChannel instanceof ThreadChannel) + ) { + msg.channel.send(errorMessage("Specified channel is not a text channel, announcement channel, or thread")); return; } @@ -142,35 +147,27 @@ export async function actualPostCmd( channel_id: targetChannel.id, content, attachments: [...msg.attachments.values()], - post_at: postAt - .clone() - .tz("Etc/UTC") - .format(DBDateFormat), + post_at: postAt.clone().tz("Etc/UTC").format(DBDateFormat), enable_mentions: opts["enable-mentions"], repeat_interval: opts.repeat, - repeat_until: repeatUntil - ? repeatUntil - .clone() - .tz("Etc/UTC") - .format(DBDateFormat) - : null, + repeat_until: repeatUntil ? repeatUntil.clone().tz("Etc/UTC").format(DBDateFormat) : null, repeat_times: repeatTimes ?? null, }); if (opts.repeat) { - pluginData.state.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, { - author: userToConfigAccessibleUser(msg.author), - channel: channelToConfigAccessibleChannel(targetChannel), + pluginData.getPlugin(LogsPlugin).logScheduledRepeatedMessage({ + author: msg.author, + channel: targetChannel, datetime: postAt.format(timeAndDate.getDateFormat("pretty_datetime")), date: postAt.format(timeAndDate.getDateFormat("date")), time: postAt.format(timeAndDate.getDateFormat("time")), repeatInterval: humanizeDuration(opts.repeat), - repeatDetails: repeatDetailsStr, + repeatDetails: repeatDetailsStr!, }); } else { - pluginData.state.logs.log(LogType.SCHEDULED_MESSAGE, { - author: userToConfigAccessibleUser(msg.author), - channel: channelToConfigAccessibleChannel(targetChannel), + pluginData.getPlugin(LogsPlugin).logScheduledMessage({ + author: msg.author, + channel: targetChannel, datetime: postAt.format(timeAndDate.getDateFormat("pretty_datetime")), date: postAt.format(timeAndDate.getDateFormat("date")), time: postAt.format(timeAndDate.getDateFormat("time")), @@ -184,14 +181,14 @@ export async function actualPostCmd( } if (opts.repeat) { - pluginData.state.logs.log(LogType.REPEATED_MESSAGE, { - author: userToConfigAccessibleUser(msg.author), - channel: channelToConfigAccessibleChannel(targetChannel), + pluginData.getPlugin(LogsPlugin).logRepeatedMessage({ + author: msg.author, + channel: targetChannel, datetime: postAt.format(timeAndDate.getDateFormat("pretty_datetime")), date: postAt.format(timeAndDate.getDateFormat("date")), time: postAt.format(timeAndDate.getDateFormat("time")), repeatInterval: humanizeDuration(opts.repeat), - repeatDetails: repeatDetailsStr, + repeatDetails: repeatDetailsStr ?? "", }); } diff --git a/backend/src/plugins/Post/util/postMessage.ts b/backend/src/plugins/Post/util/postMessage.ts index 9d4b67f4..0395867c 100644 --- a/backend/src/plugins/Post/util/postMessage.ts +++ b/backend/src/plugins/Post/util/postMessage.ts @@ -1,4 +1,4 @@ -import { Message, MessageAttachment, MessageOptions, TextChannel } from "discord.js"; +import { Message, MessageAttachment, MessageOptions, NewsChannel, TextChannel, ThreadChannel } from "discord.js"; import fs from "fs"; import { GuildPluginData } from "knub"; import { downloadFile } from "../../../utils"; @@ -9,7 +9,7 @@ const fsp = fs.promises; export async function postMessage( pluginData: GuildPluginData, - channel: TextChannel, + channel: TextChannel | NewsChannel | ThreadChannel, content: MessageOptions, attachments: MessageAttachment[] = [], enableMentions: boolean = false, @@ -30,6 +30,7 @@ export async function postMessage( name: attachments[0].name, file: await fsp.readFile(downloadedAttachment.path), }; + content.files = [file.file]; } if (enableMentions) { @@ -38,7 +39,7 @@ export async function postMessage( }; } - const createdMsg = await channel.send({ ...content, files: [file] }); + const createdMsg = await channel.send(content); pluginData.state.savedMessages.setPermanent(createdMsg.id); if (downloadedAttachment) { diff --git a/backend/src/plugins/Post/util/scheduledPostLoop.ts b/backend/src/plugins/Post/util/scheduledPostLoop.ts index 766daed6..2a946c1e 100644 --- a/backend/src/plugins/Post/util/scheduledPostLoop.ts +++ b/backend/src/plugins/Post/util/scheduledPostLoop.ts @@ -1,12 +1,13 @@ import { Snowflake, TextChannel, User } from "discord.js"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; -import { DBDateFormat, SECONDS } from "../../../utils"; +import { DBDateFormat, SECONDS, verboseChannelMention, verboseUserMention } from "../../../utils"; import { PostPluginType } from "../types"; import { postMessage } from "./postMessage"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS; @@ -30,16 +31,16 @@ export async function scheduledPostLoop(pluginData: GuildPluginData = { const MAXIMUM_COMPONENT_ROWS = 5; -const configPreprocessor: ConfigPreprocessorFn = options => { +const configPreprocessor: ConfigPreprocessorFn = (options) => { if (options.config.button_groups) { for (const [groupName, group] of Object.entries(options.config.button_groups)) { const defaultButtonNames = Object.keys(group.default_buttons); @@ -108,7 +108,7 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( prettyName: "Reaction roles", }, - dependencies: [LogsPlugin], + dependencies: () => [LogsPlugin], configSchema: ConfigSchema, defaultOptions, @@ -117,13 +117,13 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( RefreshReactionRolesCmd, ClearReactionRolesCmd, InitReactionRolesCmd, - PostButtonRolesCmd, + // PostButtonRolesCmd, ], // prettier-ignore events: [ AddReactionRoleEvt, - ButtonInteractionEvt, + // ButtonInteractionEvt, MessageDeletedEvt, ], configPreprocessor, @@ -141,11 +141,11 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( }, afterLoad(pluginData) { - let autoRefreshInterval = pluginData.config.get().auto_refresh_interval; - if (autoRefreshInterval != null) { - autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval); - autoRefreshLoop(pluginData, autoRefreshInterval); - } + // let autoRefreshInterval = pluginData.config.get().auto_refresh_interval; + // if (autoRefreshInterval != null) { + // autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval); + // autoRefreshLoop(pluginData, autoRefreshInterval); + // } }, beforeUnload(pluginData) { diff --git a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts index 229b8fbe..3bdd6243 100644 --- a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts @@ -23,7 +23,7 @@ export const ClearReactionRolesCmd = reactionRolesCmd({ let targetMessage: Message; try { - targetMessage = await args.message.channel.messages.fetch(args.message.messageId as Snowflake); + targetMessage = await args.message.channel.messages.fetch(args.message.messageId); } catch (err) { if (isDiscordAPIError(err) && err.code === 50001) { sendErrorMessage(pluginData, msg.channel, "Missing access to the specified message"); diff --git a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts index dba3db59..e724168a 100644 --- a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts @@ -40,7 +40,7 @@ export const InitReactionRolesCmd = reactionRolesCmd({ let targetMessage; try { - targetMessage = await args.message.channel.messages.fetch(args.message.messageId as Snowflake).catch(noop); + targetMessage = await args.message.channel.messages.fetch(args.message.messageId); } catch (e) { if (isDiscordAPIError(e)) { sendErrorMessage(pluginData, msg.channel, `Error ${e.code} while getting message: ${e.message}`); @@ -58,17 +58,15 @@ export const InitReactionRolesCmd = reactionRolesCmd({ const emojiRolePairs: TReactionRolePair[] = args.reactionRolePairs .trim() .split("\n") - .map(v => v.split(/[\s=,]+/).map(v => v.trim())) // tslint:disable-line - .map( - (pair): TReactionRolePair => { - const customEmojiMatch = pair[0].match(/^$/); - if (customEmojiMatch) { - return [customEmojiMatch[2], pair[1], customEmojiMatch[1]]; - } else { - return pair as TReactionRolePair; - } - }, - ); + .map((v) => v.split(/[\s=,]+/).map((v) => v.trim())) // tslint:disable-line + .map((pair): TReactionRolePair => { + const customEmojiMatch = pair[0].match(/^$/); + if (customEmojiMatch) { + return [customEmojiMatch[2], pair[1], customEmojiMatch[1]]; + } else { + return pair as TReactionRolePair; + } + }); // Verify the specified emojis and roles are valid and usable for (const pair of emojiRolePairs) { diff --git a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts index 040acf1b..c9747ed2 100644 --- a/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/PostButtonRolesCmd.ts @@ -31,9 +31,7 @@ export const PostButtonRolesCmd = reactionRolesCmd({ const buttons: MessageButton[] = []; const toInsert: Array<{ customId; buttonGroup; buttonName }> = []; for (const [buttonName, button] of Object.entries(group.default_buttons)) { - const customId = createHash("md5") - .update(`${buttonName}${moment.utc().valueOf()}`) - .digest("hex"); + const customId = createHash("md5").update(`${buttonName}${moment.utc().valueOf()}`).digest("hex"); const btn = new MessageButton() .setLabel(button.label ?? "") diff --git a/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts b/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts index 1d7a116f..324405b3 100644 --- a/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts +++ b/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts @@ -29,7 +29,7 @@ export const AddReactionRoleEvt = reactionRolesEvt({ if (emoji.name === CLEAR_ROLES_EMOJI) { // User reacted with "clear roles" emoji -> clear their roles - const reactionRoleRoleIds = reactionRoles.map(rr => rr.role_id); + const reactionRoleRoleIds = reactionRoles.map((rr) => rr.role_id); for (const roleId of reactionRoleRoleIds) { addMemberPendingRoleChange(pluginData, userId, "-", roleId); } diff --git a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts b/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts index 3d7fb3d5..65ace443 100644 --- a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts +++ b/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts @@ -49,24 +49,18 @@ export const ButtonInteractionEvt = reactionRolesEvt({ const group = cfg.button_groups[context.groupName]; if (!group) { await sendEphemeralReply(int, `A configuration error was encountered, please contact the Administrators!`); - meta.pluginData - .getPlugin(LogsPlugin) - .log( - LogType.BOT_ALERT, - `**A configuration error occurred** on buttons for message ${int.message.id}, group **${context.groupName}** not found in config`, - ); + meta.pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `**A configuration error occurred** on buttons for message ${int.message.id}, group **${context.groupName}** not found in config`, + }); return; } // Verify that detected action is known by us if (!(Object).values(ButtonMenuActions).includes(context.action)) { await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`); - meta.pluginData - .getPlugin(LogsPlugin) - .log( - LogType.BOT_ALERT, - `**A internal error occurred** on buttons for message ${int.message.id}, action **${context.action}** is not known`, - ); + meta.pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `**A internal error occurred** on buttons for message ${int.message.id}, action **${context.action}** is not known`, + }); return; } diff --git a/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts index 8dafc9fe..a2641bfe 100644 --- a/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts +++ b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts @@ -25,17 +25,17 @@ export async function applyReactionRoleReactionsToMessage( let targetMessage; try { - targetMessage = await channel.messages.fetch(messageId as Snowflake); + targetMessage = await channel.messages.fetch(messageId, { force: true }); } catch (e) { if (isDiscordAPIError(e)) { if (e.code === 10008) { // Unknown message, remove reaction roles from the message - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Removed reaction roles from unknown message ${channelId}/${messageId} (${pluginData.guild.id})`, }); await pluginData.state.reactionRoles.removeFromMessage(messageId); } else { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Error ${e.code} when applying reaction roles to message ${channelId}/${messageId}: ${e.message}`, }); } @@ -53,7 +53,7 @@ export async function applyReactionRoleReactionsToMessage( } catch (e) { if (isDiscordAPIError(e)) { errors.push(`Error ${e.code} while removing old reactions: ${e.message}`); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Error ${e.code} while removing old reaction role reactions from message ${channelId}/${messageId}: ${e.message}`, }); return errors; @@ -65,7 +65,7 @@ export async function applyReactionRoleReactionsToMessage( await sleep(1500); // Add reaction role reactions - const emojisToAdd = reactionRoles.map(rr => rr.emoji); + const emojisToAdd = reactionRoles.map((rr) => rr.emoji); emojisToAdd.push(CLEAR_ROLES_EMOJI); for (const rawEmoji of emojisToAdd) { @@ -77,13 +77,13 @@ export async function applyReactionRoleReactionsToMessage( if (e.code === 10014) { pluginData.state.reactionRoles.removeFromMessage(messageId, rawEmoji); errors.push(`Unknown emoji: ${rawEmoji}`); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Could not add unknown reaction role emoji ${rawEmoji} to message ${channelId}/${messageId}`, }); continue; } else if (e.code === 50013) { errors.push(`Missing permissions to apply reactions`); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Error ${e.code} while applying reaction role reactions to ${channelId}/${messageId}: ${e.message}`, }); break; diff --git a/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts b/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts index 446cc0ee..8c9884a9 100644 --- a/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts +++ b/backend/src/plugins/ReactionRoles/util/buttonActionHandlers.ts @@ -18,12 +18,9 @@ export async function handleOpenMenu( content: `A configuration error was encountered, please contact the Administrators!`, ephemeral: true, }); - pluginData - .getPlugin(LogsPlugin) - .log( - LogType.BOT_ALERT, - `**A configuration error occurred** on buttons for message ${int.message.id}, no menus found in config`, - ); + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `**A configuration error occurred** on buttons for message ${int.message.id}, no menus found in config`, + }); return; } @@ -48,12 +45,9 @@ export async function handleOpenMenu( content: `A configuration error was encountered, please contact the Administrators!`, ephemeral: true, }); - pluginData - .getPlugin(LogsPlugin) - .log( - LogType.BOT_ALERT, - `**A configuration error occurred** on buttons for message ${int.message.id}, menu **${context.roleOrMenu}** not found in config`, - ); + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `**A configuration error occurred** on buttons for message ${int.message.id}, menu **${context.roleOrMenu}** not found in config`, + }); return; } const rows = splitButtonsIntoRows(menuButtons, Object.values(group.button_menus[context.roleOrMenu])); // new MessageActionRow().addComponents(menuButtons); @@ -73,12 +67,9 @@ export async function handleModifyRole( content: `A configuration error was encountered, please contact the Administrators!`, ephemeral: true, }); - pluginData - .getPlugin(LogsPlugin) - .log( - LogType.BOT_ALERT, - `**A configuration error occurred** on buttons for message ${int.message.id}, role **${context.roleOrMenu}** not found on server`, - ); + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `**A configuration error occurred** on buttons for message ${int.message.id}, role **${context.roleOrMenu}** not found on server`, + }); return; } @@ -96,11 +87,8 @@ export async function handleModifyRole( content: "A configuration error was encountered, please contact the Administrators!", ephemeral: true, }); - pluginData - .getPlugin(LogsPlugin) - .log( - LogType.BOT_ALERT, - `**A configuration error occurred** on buttons for message ${int.message.id}, error: ${e}. We might be missing permissions!`, - ); + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `**A configuration error occurred** on buttons for message ${int.message.id}, error: ${e}. We might be missing permissions!`, + }); } } diff --git a/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts b/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts index c365c18f..c1d2b42a 100644 --- a/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts +++ b/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts @@ -5,7 +5,7 @@ import { refreshReactionRoles } from "./refreshReactionRoles"; export async function runAutoRefresh(pluginData: GuildPluginData) { // Refresh reaction roles on all reaction role messages const reactionRoles = await pluginData.state.reactionRoles.all(); - const idPairs = new Set(reactionRoles.map(r => `${r.channel_id}-${r.message_id}`)); + const idPairs = new Set(reactionRoles.map((r) => `${r.channel_id}-${r.message_id}`)); for (const pair of idPairs) { const [channelId, messageId] = pair.split("-"); await refreshReactionRoles(pluginData, channelId, messageId); diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts index e1a39417..058c5a46 100644 --- a/backend/src/plugins/Reminders/RemindersPlugin.ts +++ b/backend/src/plugins/Reminders/RemindersPlugin.ts @@ -29,7 +29,7 @@ export const RemindersPlugin = zeppelinGuildPlugin()({ prettyName: "Reminders", }, - dependencies: [TimeAndDatePlugin], + dependencies: () => [TimeAndDatePlugin], configSchema: ConfigSchema, defaultOptions, diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts index 7cce547f..24c765a7 100644 --- a/backend/src/plugins/Reminders/commands/RemindCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts @@ -53,10 +53,7 @@ export const RemindCmd = remindersCmd({ await pluginData.state.reminders.add( msg.author.id, msg.channel.id, - reminderTime - .clone() - .tz("Etc/UTC") - .format("YYYY-MM-DD HH:mm:ss"), + reminderTime.clone().tz("Etc/UTC").format("YYYY-MM-DD HH:mm:ss"), reminderBody, moment.utc().format("YYYY-MM-DD HH:mm:ss"), ); diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts index 1b7dcfff..c643b6d1 100644 --- a/backend/src/plugins/Roles/RolesPlugin.ts +++ b/backend/src/plugins/Roles/RolesPlugin.ts @@ -7,6 +7,7 @@ import { MassAddRoleCmd } from "./commands/MassAddRoleCmd"; import { MassRemoveRoleCmd } from "./commands/MassRemoveRoleCmd"; import { RemoveRoleCmd } from "./commands/RemoveRoleCmd"; import { ConfigSchema, RolesPluginType } from "./types"; +import { LogsPlugin } from "../Logs/LogsPlugin"; const defaultOptions: PluginOptions = { config: { @@ -41,6 +42,7 @@ export const RolesPlugin = zeppelinGuildPlugin()({ }, configSchema: ConfigSchema, + dependencies: () => [LogsPlugin], defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Roles/commands/AddRoleCmd.ts b/backend/src/plugins/Roles/commands/AddRoleCmd.ts index 66dc2096..23229ca5 100644 --- a/backend/src/plugins/Roles/commands/AddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/AddRoleCmd.ts @@ -1,10 +1,11 @@ import { GuildChannel } from "discord.js"; -import { memberToConfigAccessibleMember, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { LogType } from "../../../data/LogType"; import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { resolveRoleId, verboseUserMention } from "../../../utils"; import { rolesCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const AddRoleCmd = rolesCmd({ trigger: "addrole", @@ -37,7 +38,7 @@ export const AddRoleCmd = rolesCmd({ // Sanity check: make sure the role is configured properly const role = (msg.channel as GuildChannel).guild.roles.cache.get(roleId); if (!role) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); @@ -53,10 +54,10 @@ export const AddRoleCmd = rolesCmd({ await args.member.roles.add(roleId); - pluginData.state.logs.log(LogType.MEMBER_ROLE_ADD, { - member: memberToConfigAccessibleMember(args.member), - roles: role.name, - mod: userToConfigAccessibleUser(msg.author), + pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({ + mod: msg.author, + member: args.member, + roles: [role], }); sendSuccessMessage( diff --git a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts index 5f15a3f0..0b09535c 100644 --- a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts @@ -1,11 +1,12 @@ import { GuildMember } from "discord.js"; -import { memberToConfigAccessibleMember, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; import { canActOn, sendErrorMessage } from "../../../pluginUtils"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils"; import { rolesCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const MassAddRoleCmd = rolesCmd({ trigger: "massaddrole", @@ -52,14 +53,14 @@ export const MassAddRoleCmd = rolesCmd({ const role = pluginData.guild.roles.cache.get(roleId); if (!role) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); return; } - const membersWithoutTheRole = members.filter(m => !m.roles.cache.has(roleId)); + const membersWithoutTheRole = members.filter((m) => !m.roles.cache.has(roleId)); let assigned = 0; const failed: string[] = []; const alreadyHadRole = members.length - membersWithoutTheRole.length; @@ -74,10 +75,10 @@ export const MassAddRoleCmd = rolesCmd({ try { pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, member.id); await member.roles.add(roleId); - pluginData.state.logs.log(LogType.MEMBER_ROLE_ADD, { - member: memberToConfigAccessibleMember(member), - roles: role.name, - mod: userToConfigAccessibleUser(msg.author), + pluginData.getPlugin(LogsPlugin).logMemberRoleAdd({ + member, + roles: [role], + mod: msg.author, }); assigned++; } catch (e) { diff --git a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts index 357f8049..e5cedc42 100644 --- a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts @@ -1,11 +1,12 @@ import { GuildMember } from "discord.js"; -import { memberToConfigAccessibleMember, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; import { canActOn, sendErrorMessage } from "../../../pluginUtils"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils"; import { rolesCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const MassRemoveRoleCmd = rolesCmd({ trigger: "massremoverole", @@ -52,14 +53,14 @@ export const MassRemoveRoleCmd = rolesCmd({ const role = pluginData.guild.roles.cache.get(roleId); if (!role) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); return; } - const membersWithTheRole = members.filter(m => m.roles.cache.has(roleId)); + const membersWithTheRole = members.filter((m) => m.roles.cache.has(roleId)); let assigned = 0; const failed: string[] = []; const didNotHaveRole = members.length - membersWithTheRole.length; @@ -74,10 +75,10 @@ export const MassRemoveRoleCmd = rolesCmd({ try { pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); await member.roles.remove(roleId); - pluginData.state.logs.log(LogType.MEMBER_ROLE_REMOVE, { - member: memberToConfigAccessibleMember(member), - roles: role.name, - mod: userToConfigAccessibleUser(msg.author), + pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({ + member, + roles: [role], + mod: msg.author, }); assigned++; } catch (e) { diff --git a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts index fdab1e23..63a843c8 100644 --- a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts @@ -1,10 +1,11 @@ import { GuildChannel } from "discord.js"; -import { memberToConfigAccessibleMember, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { LogType } from "../../../data/LogType"; import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { resolveRoleId, verboseUserMention } from "../../../utils"; import { rolesCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const RemoveRoleCmd = rolesCmd({ trigger: "removerole", @@ -37,7 +38,7 @@ export const RemoveRoleCmd = rolesCmd({ // Sanity check: make sure the role is configured properly const role = (msg.channel as GuildChannel).guild.roles.cache.get(roleId); if (!role) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); @@ -53,16 +54,16 @@ export const RemoveRoleCmd = rolesCmd({ await args.member.roles.remove(roleId); - pluginData.state.logs.log(LogType.MEMBER_ROLE_REMOVE, { - member: memberToConfigAccessibleMember(args.member), - roles: role.name, - mod: userToConfigAccessibleUser(msg.author), + pluginData.getPlugin(LogsPlugin).logMemberRoleRemove({ + mod: msg.author, + member: args.member, + roles: [role], }); sendSuccessMessage( pluginData, msg.channel, - `Removed role **${role.name}** removed from ${verboseUserMention(args.member.user)}!`, + `Removed role **${role.name}** from ${verboseUserMention(args.member.user)}!`, ); }, }); diff --git a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts index 981776fc..82396cb9 100644 --- a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts +++ b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts @@ -70,7 +70,7 @@ export const SelfGrantableRolesPlugin = zeppelinGuildPlugin { + configPreprocessor: (options) => { const config = options.config; for (const [key, entry] of Object.entries(config.entries)) { // Apply default entry config @@ -79,7 +79,7 @@ export const SelfGrantableRolesPlugin = zeppelinGuildPlugin a.toLowerCase()); + entry.roles[roleId] = aliases.map((a) => a.toLowerCase()); } } } diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts index 8fa9cca4..ce8a3a73 100644 --- a/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts @@ -31,7 +31,7 @@ export const RoleAddCmd = selfGrantableRolesCmd({ const hasUnknownRoles = matchedRoleIds.length !== roleNames.length; const rolesToAdd: Map = Array.from(matchedRoleIds.values()) - .map(id => pluginData.guild.roles.cache.get(id as Snowflake)!) + .map((id) => pluginData.guild.roles.cache.get(id as Snowflake)!) .filter(Boolean) .reduce((map, role) => { map.set(role.id, role); @@ -94,7 +94,7 @@ export const RoleAddCmd = selfGrantableRolesCmd({ } const mentionRoles = pluginData.config.get().mention_roles; - const addedRolesStr = Array.from(rolesToAdd.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)); + const addedRolesStr = Array.from(rolesToAdd.values()).map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)); const addedRolesWord = rolesToAdd.size === 1 ? "role" : "roles"; const messageParts: string[] = []; @@ -104,11 +104,11 @@ export const RoleAddCmd = selfGrantableRolesCmd({ const skippedRolesStr = skipped.size ? "skipped " + Array.from(skipped.values()) - .map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)) + .map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)) .join(",") : null; const removedRolesStr = removed.size - ? "removed " + Array.from(removed.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)) + ? "removed " + Array.from(removed.values()).map((r) => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`)) : null; const skippedRemovedStr = [skippedRolesStr, removedRolesStr].filter(Boolean).join(" and "); diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts index 7a4bb83b..007aa459 100644 --- a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts @@ -29,20 +29,20 @@ export const RoleRemoveCmd = selfGrantableRolesCmd({ const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries); const rolesToRemove = Array.from(matchedRoleIds.values()).map( - id => pluginData.guild.roles.cache.get(id as Snowflake)!, + (id) => pluginData.guild.roles.cache.get(id as Snowflake)!, ); - const roleIdsToRemove = rolesToRemove.map(r => r.id); + const roleIdsToRemove = rolesToRemove.map((r) => r.id); // Remove the roles if (rolesToRemove.length) { - const newRoleIds = msg.member.roles.cache.filter(role => !roleIdsToRemove.includes(role.id)); + const newRoleIds = msg.member.roles.cache.filter((role) => !roleIdsToRemove.includes(role.id)); try { await msg.member.edit({ roles: newRoleIds, }); - const removedRolesStr = rolesToRemove.map(r => `**${r.name}**`); + const removedRolesStr = rolesToRemove.map((r) => `**${r.name}**`); const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles"; if (rolesToRemove.length !== roleNames.length) { diff --git a/backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts b/backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts index 69daae02..42a6dd34 100644 --- a/backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts +++ b/backend/src/plugins/SelfGrantableRoles/util/findMatchingRoles.ts @@ -11,5 +11,5 @@ export function findMatchingRoles(roleNames: string[], entries: TSelfGrantableRo return map; }, new Map()); - return roleNames.map(roleName => aliasToRoleId.get(roleName)).filter(Boolean); + return roleNames.map((roleName) => aliasToRoleId.get(roleName)).filter(Boolean); } diff --git a/backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts b/backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts index 65495be4..25482d1c 100644 --- a/backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts +++ b/backend/src/plugins/SelfGrantableRoles/util/getApplyingEntries.ts @@ -11,5 +11,5 @@ export async function getApplyingEntries( ([k, e]) => e.can_use && !(!e.can_ignore_cooldown && pluginData.state.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)), ) - .map(pair => pair[1]); + .map((pair) => pair[1]); } diff --git a/backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts b/backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts index 7f19a7bb..d0c6d6d2 100644 --- a/backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts +++ b/backend/src/plugins/SelfGrantableRoles/util/normalizeRoleNames.ts @@ -1,3 +1,3 @@ export function normalizeRoleNames(roleNames: string[]) { - return roleNames.map(v => v.toLowerCase()); + return roleNames.map((v) => v.toLowerCase()); } diff --git a/backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts b/backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts index efd460d3..3baefee5 100644 --- a/backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts +++ b/backend/src/plugins/SelfGrantableRoles/util/splitRoleNames.ts @@ -1,6 +1,6 @@ export function splitRoleNames(roleNames: string[]) { return roleNames - .map(v => v.split(/[\s,]+/)) + .map((v) => v.split(/[\s,]+/)) .flat() .filter(Boolean); } diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts index f7dd06a3..8bdc76e2 100644 --- a/backend/src/plugins/Slowmode/SlowmodePlugin.ts +++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts @@ -42,7 +42,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin()({ prettyName: "Slowmode", }, - dependencies: [LogsPlugin], + dependencies: () => [LogsPlugin], configSchema: ConfigSchema, defaultOptions, @@ -69,7 +69,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin()({ state.serverLogs = new GuildLogs(pluginData.guild.id); state.clearInterval = setInterval(() => clearExpiredSlowmodes(pluginData), BOT_SLOWMODE_CLEAR_INTERVAL); - state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); }, diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts index 0ef7733c..58e2bffd 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts @@ -30,7 +30,7 @@ export const SlowmodeListCmd = slowmodeCmd({ } if (slowmodes.length) { - const lines = slowmodes.map(slowmode => { + const lines = slowmodes.map((slowmode) => { const humanized = humanizeDuration(slowmode.seconds * 1000); const type = slowmode.native ? "native slowmode" : "bot slowmode"; diff --git a/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts index 3a29a711..0e7c651b 100644 --- a/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts +++ b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts @@ -1,10 +1,11 @@ import { GuildChannel, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; -import { isDiscordAPIError, UnknownUser } from "../../../utils"; +import { isDiscordAPIError, UnknownUser, verboseChannelMention, verboseUserMention } from "../../../utils"; import { SlowmodePluginType } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function applyBotSlowmodeToUserId( pluginData: GuildPluginData, @@ -27,16 +28,14 @@ export async function applyBotSlowmodeToUserId( logger.warn( `Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${pluginData.guild.name} (${pluginData.guild.id})`, ); - pluginData.state.logs.log(LogType.BOT_ALERT, { - body: `Missing permissions to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, - user: userToConfigAccessibleUser(user), - channel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Missing permissions to apply bot slowmode to ${verboseUserMention(user)} in ${verboseChannelMention( + channel, + )}`, }); } else { - pluginData.state.logs.log(LogType.BOT_ALERT, { - body: `Failed to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, - user: userToConfigAccessibleUser(user), - channel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to apply bot slowmode to ${verboseUserMention(user)} in ${verboseChannelMention(channel)}`, }); throw e; } diff --git a/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts index f8490e4d..4dcfe634 100644 --- a/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts +++ b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts @@ -1,11 +1,12 @@ import { GuildChannel, Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; import { logger } from "../../../logger"; -import { UnknownUser } from "../../../utils"; +import { UnknownUser, verboseChannelMention, verboseUserMention } from "../../../utils"; import { SlowmodePluginType } from "../types"; import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function clearExpiredSlowmodes(pluginData: GuildPluginData) { const expiredSlowmodeUsers = await pluginData.state.slowmodes.getExpiredSlowmodeUsers(); @@ -25,10 +26,10 @@ export async function clearExpiredSlowmodes(pluginData: GuildPluginData new UnknownUser({ id: user.user_id })); - pluginData.state.logs.log(LogType.BOT_ALERT, { - body: `Failed to clear slowmode permissions from {userMention(user)} in {channelMention(channel)}`, - user: userToConfigAccessibleUser(await realUser), - channel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to clear slowmode permissions from ${verboseUserMention( + await realUser, + )} in ${verboseChannelMention(channel)}`, }); } } diff --git a/backend/src/plugins/Slowmode/util/onMessageCreate.ts b/backend/src/plugins/Slowmode/util/onMessageCreate.ts index 097d10b1..2af5a9bf 100644 --- a/backend/src/plugins/Slowmode/util/onMessageCreate.ts +++ b/backend/src/plugins/Slowmode/util/onMessageCreate.ts @@ -1,7 +1,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { SavedMessage } from "../../../data/entities/SavedMessage"; -import { LogType } from "../../../data/LogType"; import { hasPermission } from "../../../pluginUtils"; import { resolveMember } from "../../../utils"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions"; @@ -40,7 +39,7 @@ export async function onMessageCreate(pluginData: GuildPluginData. ${missingPermissionError(missingPermissions)}`, }); return; @@ -49,13 +48,19 @@ export async function onMessageCreate(pluginData: GuildPluginData()({ legacy: true, }, - dependencies: [LogsPlugin], + dependencies: () => [LogsPlugin], configSchema: ConfigSchema, defaultOptions, @@ -82,7 +82,7 @@ export const SpamPlugin = zeppelinGuildPlugin()({ const { state } = pluginData; state.expiryInterval = setInterval(() => clearOldRecentActions(pluginData), 1000 * 60); - state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); }, diff --git a/backend/src/plugins/Spam/events/SpamVoiceEvt.ts b/backend/src/plugins/Spam/events/SpamVoiceEvt.ts index 36aba638..ea354a98 100644 --- a/backend/src/plugins/Spam/events/SpamVoiceEvt.ts +++ b/backend/src/plugins/Spam/events/SpamVoiceEvt.ts @@ -9,6 +9,7 @@ export const SpamVoiceStateUpdateEvt = spamEvt({ if (!member) return; const channel = meta.args.newState.channel; if (!channel) return; + if (channel.id === meta.args.oldState?.channelId) return; const config = await meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id }); const maxVoiceMoves = config.max_voice_moves; diff --git a/backend/src/plugins/Spam/util/clearOldRecentActions.ts b/backend/src/plugins/Spam/util/clearOldRecentActions.ts index 4f42211c..1c0857b3 100644 --- a/backend/src/plugins/Spam/util/clearOldRecentActions.ts +++ b/backend/src/plugins/Spam/util/clearOldRecentActions.ts @@ -6,5 +6,7 @@ const MAX_INTERVAL = 300; export function clearOldRecentActions(pluginData: GuildPluginData) { // TODO: Figure out expiry time from longest interval in the config? const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL; - pluginData.state.recentActions = pluginData.state.recentActions.filter(action => action.timestamp >= expiryTimestamp); + pluginData.state.recentActions = pluginData.state.recentActions.filter( + (action) => action.timestamp >= expiryTimestamp, + ); } diff --git a/backend/src/plugins/Spam/util/clearRecentUserActions.ts b/backend/src/plugins/Spam/util/clearRecentUserActions.ts index cfaf7b53..f4fab914 100644 --- a/backend/src/plugins/Spam/util/clearRecentUserActions.ts +++ b/backend/src/plugins/Spam/util/clearRecentUserActions.ts @@ -7,7 +7,7 @@ export function clearRecentUserActions( userId: string, actionGroupId: string, ) { - pluginData.state.recentActions = pluginData.state.recentActions.filter(action => { + pluginData.state.recentActions = pluginData.state.recentActions.filter((action) => { return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId; }); } diff --git a/backend/src/plugins/Spam/util/getRecentActions.ts b/backend/src/plugins/Spam/util/getRecentActions.ts index ff3f31ac..de652a28 100644 --- a/backend/src/plugins/Spam/util/getRecentActions.ts +++ b/backend/src/plugins/Spam/util/getRecentActions.ts @@ -8,7 +8,7 @@ export function getRecentActions( actionGroupId: string, since: number, ) { - return pluginData.state.recentActions.filter(action => { + return pluginData.state.recentActions.filter((action) => { if (action.timestamp < since) return false; if (action.type !== type) return false; if (action.actionGroupId !== actionGroupId) return false; diff --git a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts index e34b34c2..ab95f5b3 100644 --- a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts @@ -1,10 +1,7 @@ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; -import { - channelToConfigAccessibleChannel, - memberToConfigAccessibleMember, -} from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; @@ -95,7 +92,7 @@ export async function logAndDetectMessageSpam( ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Failed to mute <@!${member.id}> in \`spam\` plugin because a mute role has not been specified in server config`, }); } else { @@ -106,8 +103,8 @@ export async function logAndDetectMessageSpam( // Get the offending message IDs // We also get the IDs of any messages after the last offending message, to account for lag before detection - const savedMessages = recentActions.map(a => a.extraData as SavedMessage); - const msgIds = savedMessages.map(m => m.id); + const savedMessages = recentActions.map((a) => a.extraData as SavedMessage); + const msgIds = savedMessages.map((m) => m.id); const lastDetectedMsgId = msgIds[msgIds.length - 1]; const additionalMessages = await pluginData.state.savedMessages.getUserMessagesByChannelAfterId( @@ -115,11 +112,11 @@ export async function logAndDetectMessageSpam( savedMessage.channel_id, lastDetectedMsgId, ); - additionalMessages.forEach(m => msgIds.push(m.id)); + additionalMessages.forEach((m) => msgIds.push(m.id)); // Then, if enabled, remove the spam messages if (spamConfig.clean !== false) { - msgIds.forEach(id => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); + msgIds.forEach((id) => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); (pluginData.guild.channels.cache.get(savedMessage.channel_id as Snowflake)! as TextChannel | undefined) ?.bulkDelete(msgIds as Snowflake[]) .catch(noop); @@ -129,7 +126,7 @@ export async function logAndDetectMessageSpam( const uniqueMessages = Array.from(new Set([...savedMessages, ...additionalMessages])); uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1)); const lastHandledMsgId = uniqueMessages - .map(m => m.id) + .map((m) => m.id) .reduce((last, id): string => { return id > last ? id : last; }); @@ -181,9 +178,9 @@ export async function logAndDetectMessageSpam( } // Create a log entry - logs.log(LogType.MESSAGE_SPAM_DETECTED, { - member: memberToConfigAccessibleMember(member!), - channel: channelToConfigAccessibleChannel(channel!), + logs.logMessageSpamDetected({ + member: member!, + channel: channel!, description, limit: spamConfig.count, interval: spamConfig.interval, @@ -191,7 +188,7 @@ export async function logAndDetectMessageSpam( }); } }, - err => { + (err) => { logger.error(`Error while detecting spam:\n${err}`); }, ); diff --git a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts index b9d5fd6d..4a1256d1 100644 --- a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts @@ -1,5 +1,5 @@ import { GuildPluginData } from "knub"; -import { memberToConfigAccessibleMember } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; import { LogType } from "../../../data/LogType"; import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; @@ -57,7 +57,7 @@ export async function logAndDetectOtherSpam( ); } catch (e) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Failed to mute <@!${member.id}> in \`spam\` plugin because a mute role has not been specified in server config`, }); } else { @@ -78,8 +78,8 @@ export async function logAndDetectOtherSpam( // Clear recent cases clearRecentUserActions(pluginData, RecentActionType.VoiceChannelMove, userId, actionGroupId); - logs.log(LogType.OTHER_SPAM_DETECTED, { - member: memberToConfigAccessibleMember(member!), + logs.logOtherSpamDetected({ + member: member!, description, limit: spamConfig.count, interval: spamConfig.interval, diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts index a7ab3ff2..d6a60a11 100644 --- a/backend/src/plugins/Starboard/StarboardPlugin.ts +++ b/backend/src/plugins/Starboard/StarboardPlugin.ts @@ -157,7 +157,7 @@ export const StarboardPlugin = zeppelinGuildPlugin()({ afterLoad(pluginData) { const { state } = pluginData; - state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg); state.savedMessages.events.on("delete", state.onMessageDeleteFn); }, diff --git a/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts index 6727e5c7..45b7e731 100644 --- a/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts +++ b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts @@ -26,22 +26,22 @@ export const StarboardReactionAddEvt = starboardEvt({ } const member = await resolveMember(pluginData.client, pluginData.guild, userId); - if (!member || member.user.bot) return; + if (!member || member!.user.bot) return; const config = await pluginData.config.getMatchingConfig({ + userId, member, - channelId: msg.channel.id, - categoryId: (msg.channel as TextChannel).parentId, + message: msg, }); const boardLock = await pluginData.locks.acquire(allStarboardsLock()); const applicableStarboards = Object.values(config.boards) - .filter(board => board.enabled) + .filter((board) => board.enabled) // Can't star messages in the starboard channel itself - .filter(board => board.channel_id !== msg.channel.id) + .filter((board) => board.channel_id !== msg.channel.id) // Matching emoji - .filter(board => { + .filter((board) => { return board.star_emoji!.some((boardEmoji: string) => { if (emoji.id) { // Custom emoji @@ -79,9 +79,7 @@ export const StarboardReactionAddEvt = starboardEvt({ const channel = pluginData.guild.channels.cache.get( starboardMessage.starboard_channel_id as Snowflake, ) as TextChannel; - const realStarboardMessage = await channel.messages.fetch( - starboardMessage.starboard_message_id as Snowflake, - ); + const realStarboardMessage = await channel.messages.fetch(starboardMessage.starboard_message_id); await updateStarboardMessageStarCount( starboard, msg, diff --git a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts index dc6e8781..053389db 100644 --- a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts +++ b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts @@ -59,10 +59,7 @@ export function createStarboardEmbedFromMessage( // If there are no embeds, add the first image attachment explicitly else if (msg.attachments.size) { for (const attachment of msg.attachments) { - const ext = path - .extname(attachment[1].name!) - .slice(1) - .toLowerCase(); + const ext = path.extname(attachment[1].name!).slice(1).toLowerCase(); if (imageAttachmentExtensions.includes(ext)) { embed.image = { url: attachment[1].url }; diff --git a/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts index 1a331358..6535852f 100644 --- a/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts +++ b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts @@ -1,6 +1,19 @@ +import { GuildPluginData } from "knub"; import { StarboardMessage } from "../../../data/entities/StarboardMessage"; import { noop } from "../../../utils"; +import { StarboardPluginType } from "../types"; -export async function removeMessageFromStarboard(pluginData, msg: StarboardMessage) { - await pluginData.client.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop); +export async function removeMessageFromStarboard( + pluginData: GuildPluginData, + msg: StarboardMessage, +) { + // fixes stuck entries on starboard_reactions table after messages being deleted, probably should add a cleanup script for this as well, i.e. DELETE FROM starboard_reactions WHERE message_id NOT IN (SELECT id FROM starboard_messages) + await pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.message_id).catch(noop); + + // this code is now Almeida-certified and no longer ugly :ok_hand: :cake: + const channel = pluginData.client.channels.cache.find((c) => c.id === msg.starboard_channel_id); + if (!channel?.isText()) return; + const message = await channel.messages.fetch(msg.starboard_message_id).catch(noop); + if (!message?.deletable) return; + await message.delete().catch(noop); } diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts index 0ea369a0..c6674189 100644 --- a/backend/src/plugins/Tags/TagsPlugin.ts +++ b/backend/src/plugins/Tags/TagsPlugin.ts @@ -21,6 +21,7 @@ import { findTagByName } from "./util/findTagByName"; import { onMessageCreate } from "./util/onMessageCreate"; import { onMessageDelete } from "./util/onMessageDelete"; import { renderTagBody } from "./util/renderTagBody"; +import { LogsPlugin } from "../Logs/LogsPlugin"; const defaultOptions: PluginOptions = { config: { @@ -60,6 +61,7 @@ export const TagsPlugin = zeppelinGuildPlugin()({ }, configSchema: ConfigSchema, + dependencies: () => [LogsPlugin], defaultOptions, // prettier-ignore @@ -112,10 +114,10 @@ export const TagsPlugin = zeppelinGuildPlugin()({ afterLoad(pluginData) { const { state, guild } = pluginData; - state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.onMessageCreateFn = (msg) => onMessageCreate(pluginData, msg); state.savedMessages.events.on("create", state.onMessageCreateFn); - state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.onMessageDeleteFn = (msg) => onMessageDelete(pluginData, msg); state.savedMessages.events.on("delete", state.onMessageDeleteFn); const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); @@ -131,6 +133,10 @@ export const TagsPlugin = zeppelinGuildPlugin()({ return Date.now(); } + if (!Number.isNaN(Number(str))) { + return Number(str); // Unix timestamp as a string + } + return moment.tz(str, "YYYY-MM-DD HH:mm:ss", tz).valueOf(); }, @@ -154,6 +160,14 @@ export const TagsPlugin = zeppelinGuildPlugin()({ let reference; let delay; + for (const [i, arg] of args.entries()) { + if (typeof arg === "number") { + args[i] = String(arg); + } else if (typeof arg !== "string") { + args[i] = ""; + } + } + if (args.length >= 2) { // (time, delay) reference = this.parseDateTime(args[0]); @@ -165,10 +179,7 @@ export const TagsPlugin = zeppelinGuildPlugin()({ } const delayMS = convertDelayStringToMS(delay) ?? 0; - return moment - .utc(reference, "x") - .add(delayMS) - .valueOf(); + return moment.utc(reference, "x").add(delayMS).valueOf(); }, timeSub(...args) { @@ -176,6 +187,14 @@ export const TagsPlugin = zeppelinGuildPlugin()({ let reference; let delay; + for (const [i, arg] of args.entries()) { + if (typeof arg === "number") { + args[i] = String(arg); + } else if (typeof arg !== "string") { + args[i] = ""; + } + } + if (args.length >= 2) { // (time, delay) reference = this.parseDateTime(args[0]); @@ -187,10 +206,7 @@ export const TagsPlugin = zeppelinGuildPlugin()({ } const delayMS = convertDelayStringToMS(delay) ?? 0; - return moment - .utc(reference, "x") - .subtract(delayMS) - .valueOf(); + return moment.utc(reference, "x").subtract(delayMS).valueOf(); }, timeAgo(delay) { @@ -208,7 +224,7 @@ export const TagsPlugin = zeppelinGuildPlugin()({ return timeAndDate.inGuildTz(parsed).format("YYYY-MM-DD"); }, - mention: input => { + mention: (input) => { if (typeof input !== "string") { return ""; } @@ -231,7 +247,7 @@ export const TagsPlugin = zeppelinGuildPlugin()({ return ""; }, - isMention: input => { + isMention: (input) => { if (typeof input !== "string") { return false; } diff --git a/backend/src/plugins/Tags/commands/TagEvalCmd.ts b/backend/src/plugins/Tags/commands/TagEvalCmd.ts index 9ce1bf00..bf9c3f1f 100644 --- a/backend/src/plugins/Tags/commands/TagEvalCmd.ts +++ b/backend/src/plugins/Tags/commands/TagEvalCmd.ts @@ -1,4 +1,4 @@ -import { memberToConfigAccessibleMember, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage } from "../../../pluginUtils"; import { TemplateParseError } from "../../../templateFormatter"; @@ -20,13 +20,13 @@ export const TagEvalCmd = tagsCmd({ args.body, [], { - member: memberToConfigAccessibleMember(msg.member), - user: userToConfigAccessibleUser(msg.member.user), + member: memberToTemplateSafeMember(msg.member), + user: userToTemplateSafeUser(msg.member.user), }, { member: msg.member }, ); - if (!rendered.content && !rendered.embed) { + if (!rendered.content && !rendered.embeds?.length) { sendErrorMessage(pluginData, msg.channel, "Evaluation resulted in an empty text"); return; } diff --git a/backend/src/plugins/Tags/commands/TagListCmd.ts b/backend/src/plugins/Tags/commands/TagListCmd.ts index 41febd5d..c50baaa5 100644 --- a/backend/src/plugins/Tags/commands/TagListCmd.ts +++ b/backend/src/plugins/Tags/commands/TagListCmd.ts @@ -13,7 +13,7 @@ export const TagListCmd = tagsCmd({ } const prefix = (await pluginData.config.getForMessage(msg)).prefix; - const tagNames = tags.map(tag => tag.tag).sort(); + const tagNames = tags.map((tag) => tag.tag).sort(); createChunkedMessage(msg.channel, `Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\``); }, diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts index 17ad0fc8..71247dec 100644 --- a/backend/src/plugins/Tags/types.ts +++ b/backend/src/plugins/Tags/types.ts @@ -7,6 +7,7 @@ import { GuildTags } from "../../data/GuildTags"; import { tEmbed, tNullable } from "../../utils"; export const Tag = t.union([t.string, tEmbed]); +export type TTag = t.TypeOf; export const TagCategory = t.type({ prefix: tNullable(t.string), diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts index 43448df5..f64906e8 100644 --- a/backend/src/plugins/Tags/util/onMessageCreate.ts +++ b/backend/src/plugins/Tags/util/onMessageCreate.ts @@ -8,6 +8,7 @@ import { messageIsEmpty } from "../../../utils/messageIsEmpty"; import { validate } from "../../../validatorUtils"; import { TagsPluginType } from "../types"; import { matchAndRenderTagFromString } from "./matchAndRenderTagFromString"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export async function onMessageCreate(pluginData: GuildPluginData, msg: SavedMessage) { if (msg.is_bot) return; @@ -78,7 +79,7 @@ export async function onMessageCreate(pluginData: GuildPluginData pluginData.cooldowns.isOnCooldown(cd[0])); + const isOnCooldown = cooldowns.some((cd) => pluginData.cooldowns.isOnCooldown(cd[0])); if (isOnCooldown) return; for (const cd of cooldowns) { @@ -87,14 +88,14 @@ export async function onMessageCreate(pluginData: GuildPluginData, - body: t.TypeOf, - args: any[] = [], + body: TTag, + args: TemplateSafeValue[] = [], extraData = {}, subTagPermissionMatchParams?: ExtendedMatchParams, + tagFnCallsObj = { calls: 0 }, ): Promise { const dynamicVars = {}; - const maxTagFnCalls = 25; - let tagFnCalls = 0; - const data = { + const data = new TemplateSafeValueContainer({ args, ...extraData, ...pluginData.state.tagFunctions, @@ -34,7 +34,7 @@ export async function renderTagBody( return dynamicVars[name] == null ? "" : dynamicVars[name]; }, tag: async (name, ...subTagArgs) => { - if (tagFnCalls++ > maxTagFnCalls) return "\\_recursion\\_"; + if (++tagFnCallsObj.calls > MAX_TAG_FN_CALLS) return ""; if (typeof name !== "string") return ""; if (name === "") return ""; @@ -48,16 +48,23 @@ export async function renderTagBody( return ""; } - const rendered = await renderTagBody(pluginData, subTagBody, subTagArgs, subTagPermissionMatchParams); + const rendered = await renderTagBody( + pluginData, + subTagBody, + subTagArgs, + extraData, + subTagPermissionMatchParams, + tagFnCallsObj, + ); return rendered.content!; }, - }; + }); if (typeof body === "string") { // Plain text tag return { content: await renderTemplate(body, data) }; } else { // Embed - return renderRecursively(body, str => renderTemplate(str, data)); + return renderRecursively(body, (str) => renderTemplate(str, data)); } } diff --git a/backend/src/plugins/Tags/util/renderTagFromString.ts b/backend/src/plugins/Tags/util/renderTagFromString.ts index d54d836e..b482741d 100644 --- a/backend/src/plugins/Tags/util/renderTagFromString.ts +++ b/backend/src/plugins/Tags/util/renderTagFromString.ts @@ -1,13 +1,11 @@ import { GuildMember } from "discord.js"; -import * as t from "io-ts"; import { GuildPluginData } from "knub"; import { parseArguments } from "knub-command-manager"; -import { memberToConfigAccessibleMember, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; -import { LogType } from "../../../data/LogType"; +import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { TemplateParseError } from "../../../templateFormatter"; -import { StrictMessageContent } from "../../../utils"; +import { StrictMessageContent, validateAndParseMessageContent } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; -import { Tag, TagsPluginType } from "../types"; +import { TagsPluginType, TTag } from "../types"; import { renderTagBody } from "./renderTagBody"; export async function renderTagFromString( @@ -15,28 +13,30 @@ export async function renderTagFromString( str: string, prefix: string, tagName: string, - tagBody: t.TypeOf, + tagBody: TTag, member: GuildMember, ): Promise { const variableStr = str.slice(prefix.length + tagName.length).trim(); - const tagArgs = parseArguments(variableStr).map(v => v.value); + const tagArgs = parseArguments(variableStr).map((v) => v.value); // Format the string try { - return renderTagBody( + const rendered = await renderTagBody( pluginData, tagBody, tagArgs, { - member: memberToConfigAccessibleMember(member), - user: userToConfigAccessibleUser(member.user), + member: memberToTemplateSafeMember(member), + user: userToTemplateSafeUser(member.user), }, { member }, ); + + return validateAndParseMessageContent(rendered); } catch (e) { if (e instanceof TemplateParseError) { const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: `Failed to render tag \`${prefix}${tagName}\`: ${e.message}`, }); return null; diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index a2e9a6e7..5a3e6636 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -1,3 +1,4 @@ +import { GuildMember, MessageEmbedOptions, Snowflake } from "discord.js"; import { PluginOptions } from "knub"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; @@ -36,9 +37,12 @@ import { UserInfoCmd } from "./commands/UserInfoCmd"; import { VcdisconnectCmd } from "./commands/VcdisconnectCmd"; import { VcmoveAllCmd, VcmoveCmd } from "./commands/VcmoveCmd"; import { AutoJoinThreadEvt, AutoJoinThreadSyncEvt } from "./events/AutoJoinThreadEvt"; +import { getUserInfoEmbed } from "./functions/getUserInfoEmbed"; +import { hasPermission } from "./functions/hasPermission"; import { activeReloads } from "./guildReloads"; import { refreshMembersIfNeeded } from "./refreshMembers"; import { ConfigSchema, UtilityPluginType } from "./types"; +import { LogsPlugin } from "../Logs/LogsPlugin"; const defaultOptions: PluginOptions = { config: { @@ -115,7 +119,7 @@ export const UtilityPlugin = zeppelinGuildPlugin()({ prettyName: "Utility", }, - dependencies: [TimeAndDatePlugin, ModActionsPlugin], + dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin], configSchema: ConfigSchema, defaultOptions, @@ -162,6 +166,18 @@ export const UtilityPlugin = zeppelinGuildPlugin()({ cleanCmd(pluginData, args, msg); }; }, + + userInfo(pluginData) { + return (userId: Snowflake, requestMemberId?: Snowflake) => { + return getUserInfoEmbed(pluginData, userId, false, requestMemberId); + }; + }, + + hasPermission(pluginData) { + return (member: GuildMember, channelId: Snowflake, permission: string) => { + return hasPermission(pluginData, member, channelId, permission); + }; + }, }, beforeLoad(pluginData) { diff --git a/backend/src/plugins/Utility/commands/AboutCmd.ts b/backend/src/plugins/Utility/commands/AboutCmd.ts index 0d6f8493..369c1201 100644 --- a/backend/src/plugins/Utility/commands/AboutCmd.ts +++ b/backend/src/plugins/Utility/commands/AboutCmd.ts @@ -7,6 +7,7 @@ import { getCurrentUptime } from "../../../uptime"; import { multiSorter, resolveMember, sorter } from "../../../utils"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; import { utilityCmd } from "../types"; +import { shuffle } from "lodash"; export const AboutCmd = utilityCmd({ trigger: "about", @@ -46,18 +47,15 @@ export const AboutCmd = utilityCmd({ const basicInfoRows = [ ["Uptime", prettyUptime], - ["Last reload", `${lastReload} ago`], - ["Last update", lastUpdate], + ["Last config reload", `${lastReload} ago`], + ["Last bot update", lastUpdate], ["Version", version], ["API latency", `${pluginData.client.ws.ping}ms`], ["Server timezone", timeAndDate.getGuildTz()], ]; const loadedPlugins = Array.from( - pluginData - .getKnubInstance() - .getLoadedGuild(pluginData.guild.id)! - .loadedPlugins.keys(), + pluginData.getKnubInstance().getLoadedGuild(pluginData.guild.id)!.loadedPlugins.keys(), ); loadedPlugins.sort(); @@ -80,26 +78,28 @@ export const AboutCmd = utilityCmd({ }; const supporters = await pluginData.state.supporters.getAll(); - supporters.sort( - multiSorter([ - [r => r.amount, "DESC"], - [r => r.name.toLowerCase(), "ASC"], - ]), - ); + const shuffledSupporters = shuffle(supporters); if (supporters.length) { + const formattedSupporters = shuffledSupporters + // Bold every other supporter to make them easy to recognize from each other + .map((s, i) => (i % 2 === 0 ? `**${s.name}**` : `__${s.name}__`)) + .join(" "); + aboutContent.embeds![0].fields!.push({ name: "Zeppelin supporters 🎉", - value: supporters.map(s => `**${s.name}** ${s.amount ? `${s.amount}€/mo` : ""}`.trim()).join("\n"), + value: + "These amazing people have supported Zeppelin development by pledging on [Patreon](https://www.patreon.com/zeppelinbot):\n\n" + + formattedSupporters, inline: false, }); } // For the embed color, find the highest colored role the bot has - this is their color on the server as well const botMember = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); - let botRoles = botMember?.roles.cache.map(r => (msg.channel as GuildChannel).guild.roles.cache.get(r.id)!) || []; - botRoles = botRoles.filter(r => !!r); // Drop any unknown roles - botRoles = botRoles.filter(r => r.color); // Filter to those with a color + let botRoles = botMember?.roles.cache.map((r) => (msg.channel as GuildChannel).guild.roles.cache.get(r.id)!) || []; + botRoles = botRoles.filter((r) => !!r); // Drop any unknown roles + botRoles = botRoles.filter((r) => r.color); // Filter to those with a color botRoles.sort(sorter("position", "DESC")); // Sort by position (highest first) if (botRoles.length) { aboutContent.embeds![0].color = botRoles[0].color; diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts index c7f8e6c3..60e79f26 100644 --- a/backend/src/plugins/Utility/commands/CleanCmd.ts +++ b/backend/src/plugins/Utility/commands/CleanCmd.ts @@ -1,7 +1,7 @@ import { Message, Snowflake, TextChannel, User } from "discord.js"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; -import { channelToConfigAccessibleChannel, userToConfigAccessibleUser } from "../../../utils/configAccessibleObjects"; +import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { LogType } from "../../../data/LogType"; @@ -11,10 +11,13 @@ import { allowTimeout } from "../../../RegExpRunner"; import { DAYS, getInviteCodesInString, noop, SECONDS } from "../../../utils"; import { utilityCmd, UtilityPluginType } from "../types"; import { boolean, number } from "io-ts"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { humanizeDurationShort } from "../../../humanizeDurationShort"; const MAX_CLEAN_COUNT = 150; const MAX_CLEAN_TIME = 1 * DAYS; -const CLEAN_COMMAND_DELETE_DELAY = 5 * SECONDS; +const MAX_CLEAN_API_REQUESTS = 20; +const CLEAN_COMMAND_DELETE_DELAY = 10 * SECONDS; export async function cleanMessages( pluginData: GuildPluginData, @@ -27,10 +30,10 @@ export async function cleanMessages( // Delete & archive in ID order savedMessages = Array.from(savedMessages).sort((a, b) => (a.id > b.id ? 1 : -1)); - const idsToDelete = savedMessages.map(m => m.id) as Snowflake[]; + const idsToDelete = savedMessages.map((m) => m.id) as Snowflake[]; // Make sure the deletions aren't double logged - idsToDelete.forEach(id => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); + idsToDelete.forEach((id) => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, idsToDelete[0]); // Actually delete the messages @@ -42,9 +45,9 @@ export async function cleanMessages( const baseUrl = getBaseUrl(pluginData); const archiveUrl = pluginData.state.archives.getUrl(baseUrl, archiveId); - pluginData.state.logs.log(LogType.CLEAN, { - mod: userToConfigAccessibleUser(mod), - channel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logClean({ + mod, + channel, count: savedMessages.length, archiveUrl, }); @@ -101,47 +104,41 @@ export async function cleanCmd(pluginData: GuildPluginData, a const cleaningMessage = msg.channel.send("Cleaning..."); - const messagesToClean: SavedMessage[] = []; + const messagesToClean: Message[] = []; let beforeId = msg.id; const timeCutoff = msg.createdTimestamp - MAX_CLEAN_TIME; const upToMsgId = args["to-id"]; let foundId = false; const deletePins = args["delete-pins"] != null ? args["delete-pins"] : false; - let pins: Message[] = []; + let pinIds: Set = new Set(); if (!deletePins) { - pins = [...(await msg.channel.messages.fetchPinned().catch(() => [])).values()]; + pinIds = new Set((await msg.channel.messages.fetchPinned()).keys()); } + let note: string | null = null; + let requests = 0; while (messagesToClean.length < args.count) { const potentialMessages = await targetChannel.messages.fetch({ before: beforeId, - limit: args.count, + limit: 100, }); if (potentialMessages.size === 0) break; - const existingStored = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]); - const alreadyStored = existingStored.map(stored => stored.id); - const messagesToStore = [ - ...potentialMessages.filter(potentialMsg => !alreadyStored.includes(potentialMsg.id)).values(), - ]; - await pluginData.state.savedMessages.createFromMessages(messagesToStore); + requests++; - const potentialMessagesToClean = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]); - if (potentialMessagesToClean.length === 0) break; - - const filtered: SavedMessage[] = []; - for (const message of potentialMessagesToClean) { - const contentString = message.data.content || ""; - if (args.user && message.user_id !== args.user) continue; - if (args.bots && !message.is_bot) continue; - if (!deletePins && pins.find(x => x.id === message.id) != null) continue; + const filtered: Message[] = []; + for (const message of potentialMessages.values()) { + const contentString = message.content || ""; + if (args.user && message.author.id !== args.user) continue; + if (args.bots && !message.author.bot) continue; + if (!deletePins && pinIds.has(message.id)) continue; if (args["has-invites"] && getInviteCodesInString(contentString).length === 0) continue; if (upToMsgId != null && message.id < upToMsgId) { foundId = true; break; } - if (moment.utc(message.posted_at).valueOf() < timeCutoff) continue; + if (message.createdTimestamp < timeCutoff) continue; if (args.match && !(await pluginData.state.regexRunner.exec(args.match, contentString).catch(allowTimeout))) { continue; } @@ -154,16 +151,38 @@ export async function cleanCmd(pluginData: GuildPluginData, a beforeId = potentialMessages.lastKey()!; - if (foundId || moment.utc(potentialMessages.last()!.createdTimestamp).valueOf() < timeCutoff) { + if (foundId) { break; } + + if (messagesToClean.length < args.count) { + if (potentialMessages.last()!.createdTimestamp < timeCutoff) { + note = `stopped looking after reaching ${humanizeDurationShort(MAX_CLEAN_TIME)} old messages`; + break; + } + + if (requests >= MAX_CLEAN_API_REQUESTS) { + note = `stopped looking after ${requests * 100} messages`; + break; + } + } } let responseMsg: Message | undefined; if (messagesToClean.length > 0) { - const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author); + // Save to-be-deleted messages that were missing from the database + const existingStored = await pluginData.state.savedMessages.getMultiple(messagesToClean.map((m) => m.id)); + const alreadyStored = existingStored.map((stored) => stored.id); + const messagesToStore = messagesToClean.filter((potentialMsg) => !alreadyStored.includes(potentialMsg.id)); + await pluginData.state.savedMessages.createFromMessages(messagesToStore); + + const savedMessagesToClean = await pluginData.state.savedMessages.getMultiple(messagesToClean.map((m) => m.id)); + const cleanResult = await cleanMessages(pluginData, targetChannel, savedMessagesToClean, msg.author); let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`; + if (note) { + responseText += ` (${note})`; + } if (targetChannel.id !== msg.channel.id) { responseText += ` in <#${targetChannel.id}>: ${cleanResult.archiveUrl}`; } @@ -183,7 +202,8 @@ export async function cleanCmd(pluginData: GuildPluginData, a responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText); } else { - responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`); + const responseText = `Found no messages to clean${note ? ` (${note})` : ""}!`; + responseMsg = await sendErrorMessage(pluginData, msg.channel, responseText); } await (await cleaningMessage).delete(); diff --git a/backend/src/plugins/Utility/commands/HelpCmd.ts b/backend/src/plugins/Utility/commands/HelpCmd.ts index 112e932e..b03d3aa4 100644 --- a/backend/src/plugins/Utility/commands/HelpCmd.ts +++ b/backend/src/plugins/Utility/commands/HelpCmd.ts @@ -34,6 +34,7 @@ export const HelpCmd = utilityCmd({ plugin, command: registeredCommand, }); + break; } } } @@ -57,10 +58,7 @@ export const HelpCmd = utilityCmd({ const description = command.config!.extra!.blueprint.description; const usage = command.config!.extra!.blueprint.usage; - const commandSlug = trigger - .trim() - .toLowerCase() - .replace(/\s/g, "-"); + const commandSlug = trigger.trim().toLowerCase().replace(/\s/g, "-"); let snippet = `**${prefix}${trigger}**`; if (description) snippet += `\n${description}`; diff --git a/backend/src/plugins/Utility/commands/JumboCmd.ts b/backend/src/plugins/Utility/commands/JumboCmd.ts index b688b686..d07855c1 100644 --- a/backend/src/plugins/Utility/commands/JumboCmd.ts +++ b/backend/src/plugins/Utility/commands/JumboCmd.ts @@ -15,16 +15,13 @@ async function getBufferFromUrl(url: string): Promise { } function bufferToPhotonImage(input: Buffer): photon.PhotonImage { - const base64 = input - .toString("base64") - .replace(/^data:image\/\w+;base64,/, ""); + const base64 = input.toString("base64").replace(/^data:image\/\w+;base64,/, ""); return photon.PhotonImage.new_from_base64(base64); } function photonImageToBuffer(image: photon.PhotonImage): Buffer { - const base64 = image.get_base64() - .replace(/^data:image\/\w+;base64,/, ""); + const base64 = image.get_base64().replace(/^data:image\/\w+;base64,/, ""); return Buffer.from(base64, "base64"); } @@ -34,7 +31,7 @@ function resizeBuffer(input: Buffer, width: number, height: number): Buffer { return photonImageToBuffer(photonImage); } -const CDN_URL = "https://twemoji.maxcdn.com/2/svg"; +const CDN_URL = "https://twemoji.maxcdn.com/"; export const JumboCmd = utilityCmd({ trigger: "jumbo", @@ -67,25 +64,26 @@ export const JumboCmd = utilityCmd({ } url += `${results[2]}${extension}`; if (extension === ".png") { - const image = await resizeBuffer(await getBufferFromUrl(url), size, size); + const image = resizeBuffer(await getBufferFromUrl(url), size, size); file = new MessageAttachment(image, `emoji${extension}`); } else { const image = await getBufferFromUrl(url); file = new MessageAttachment(image, `emoji${extension}`); } } else { - let url = CDN_URL + `/${twemoji.convert.toCodePoint(args.emoji)}.svg`; + let url = `${twemoji.base}${twemoji.size}/${twemoji.convert.toCodePoint(args.emoji)}${twemoji.ext}`; let image: Buffer | undefined; try { - image = await resizeBuffer(await getBufferFromUrl(url), size, size); - } catch { - if (url.toLocaleLowerCase().endsWith("fe0f.svg")) { - url = url.slice(0, url.lastIndexOf("-fe0f")) + ".svg"; + const downloadedBuffer = await getBufferFromUrl(url); + image = resizeBuffer(await getBufferFromUrl(url), size, size); + } catch (err) { + if (url.toLocaleLowerCase().endsWith("fe0f.png")) { + url = url.slice(0, url.lastIndexOf("-fe0f")) + ".png"; image = await resizeBuffer(await getBufferFromUrl(url), size, size); } } if (!image) { - sendErrorMessage(pluginData, msg.channel, "Invalid emoji"); + sendErrorMessage(pluginData, msg.channel, "Error occurred while jumboing default emoji"); return; } diff --git a/backend/src/plugins/Utility/commands/NicknameCmd.ts b/backend/src/plugins/Utility/commands/NicknameCmd.ts index 75f6699d..f689a6af 100644 --- a/backend/src/plugins/Utility/commands/NicknameCmd.ts +++ b/backend/src/plugins/Utility/commands/NicknameCmd.ts @@ -20,7 +20,7 @@ export const NicknameCmd = utilityCmd({ if (!args.member.nickname) { msg.channel.send(`<@!${args.member.id}> does not have a nickname`); } else { - msg.channel.send(`The nickname of <@!${args.member.id}> is **${Util.escapeBold(args.nickname)}**`); + msg.channel.send(`The nickname of <@!${args.member.id}> is **${Util.escapeBold(args.member.nickname)}**`); } return; } diff --git a/backend/src/plugins/Utility/commands/RolesCmd.ts b/backend/src/plugins/Utility/commands/RolesCmd.ts index 2aa3f459..6734d167 100644 --- a/backend/src/plugins/Utility/commands/RolesCmd.ts +++ b/backend/src/plugins/Utility/commands/RolesCmd.ts @@ -28,7 +28,7 @@ export const RolesCmd = utilityCmd({ if (args.search) { const searchStr = args.search.toLowerCase(); - roles = roles.filter(r => r.name.toLowerCase().includes(searchStr) || r.id === searchStr); + roles = roles.filter((r) => r.name.toLowerCase().includes(searchStr) || r.id === searchStr); } if (args.counts) { @@ -75,7 +75,7 @@ export const RolesCmd = utilityCmd({ } else if (sort === "memberCount" && args.counts) { roles.sort(sorter("_memberCount", sortDir)); } else if (sort === "name") { - roles.sort(sorter(r => r.name.toLowerCase(), sortDir)); + roles.sort(sorter((r) => r.name.toLowerCase(), sortDir)); } else { sendErrorMessage(pluginData, msg.channel, "Unknown sorting method"); return; @@ -85,7 +85,7 @@ export const RolesCmd = utilityCmd({ const chunks = chunkArray(roles, 20); for (const [i, chunk] of chunks.entries()) { - const roleLines = chunk.map(role => { + const roleLines = chunk.map((role) => { const paddedId = role.id.padEnd(longestId, " "); let line = `${paddedId} ${role.name}`; if (role._memberCount != null) { diff --git a/backend/src/plugins/Utility/commands/SourceCmd.ts b/backend/src/plugins/Utility/commands/SourceCmd.ts index ada75479..b5e4e802 100644 --- a/backend/src/plugins/Utility/commands/SourceCmd.ts +++ b/backend/src/plugins/Utility/commands/SourceCmd.ts @@ -21,7 +21,7 @@ export const SourceCmd = utilityCmd({ return; } - const message = await args.message.channel.messages.fetch(args.message.messageId as Snowflake).catch(() => null); + const message = await args.message.channel.messages.fetch(args.message.messageId); if (!message) { sendErrorMessage(pluginData, cmdMessage.channel, "Unknown message"); return; diff --git a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts index 5bb69961..def3201c 100644 --- a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts +++ b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts @@ -1,13 +1,14 @@ import { VoiceChannel } from "discord.js"; import { - channelToConfigAccessibleChannel, - memberToConfigAccessibleMember, - userToConfigAccessibleUser, -} from "../../../utils/configAccessibleObjects"; + channelToTemplateSafeChannel, + memberToTemplateSafeMember, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { LogType } from "../../../data/LogType"; import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { utilityCmd } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const VcdisconnectCmd = utilityCmd({ trigger: ["vcdisconnect", "vcdisc", "vcdc", "vckick", "vck"], @@ -38,10 +39,10 @@ export const VcdisconnectCmd = utilityCmd({ return; } - pluginData.state.logs.log(LogType.VOICE_CHANNEL_FORCE_DISCONNECT, { - mod: userToConfigAccessibleUser(msg.author), - member: memberToConfigAccessibleMember(args.member), - oldChannel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logVoiceChannelForceDisconnect({ + mod: msg.author, + member: args.member, + oldChannel: channel, }); sendSuccessMessage(pluginData, msg.channel, `**${args.member.user.tag}** disconnected from **${channel.name}**`); diff --git a/backend/src/plugins/Utility/commands/VcmoveCmd.ts b/backend/src/plugins/Utility/commands/VcmoveCmd.ts index f2feb8ef..935fa0b1 100644 --- a/backend/src/plugins/Utility/commands/VcmoveCmd.ts +++ b/backend/src/plugins/Utility/commands/VcmoveCmd.ts @@ -1,15 +1,16 @@ import { Snowflake, VoiceChannel } from "discord.js"; import { - channelToConfigAccessibleChannel, - memberToConfigAccessibleMember, - userToConfigAccessibleUser, -} from "../../../utils/configAccessibleObjects"; + channelToTemplateSafeChannel, + memberToTemplateSafeMember, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { LogType } from "../../../data/LogType"; import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { channelMentionRegex, isSnowflake, simpleClosestStringMatch } from "../../../utils"; import { utilityCmd } from "../types"; import { ChannelTypeStrings } from "../../../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const VcmoveCmd = utilityCmd({ trigger: "vcmove", @@ -49,7 +50,7 @@ export const VcmoveCmd = utilityCmd({ const voiceChannels = [...pluginData.guild.channels.cache.values()].filter( (c): c is VoiceChannel => c.type === ChannelTypeStrings.VOICE, ); - const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, ch => ch.name); + const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { sendErrorMessage(pluginData, msg.channel, "No matching voice channels"); return; @@ -68,7 +69,7 @@ export const VcmoveCmd = utilityCmd({ return; } - const oldVoiceChannel = pluginData.guild.channels.cache.get(args.member.voice.channelId); + const oldVoiceChannel = pluginData.guild.channels.cache.get(args.member.voice.channelId) as VoiceChannel; try { await args.member.edit({ @@ -79,11 +80,11 @@ export const VcmoveCmd = utilityCmd({ return; } - pluginData.state.logs.log(LogType.VOICE_CHANNEL_FORCE_MOVE, { - mod: userToConfigAccessibleUser(msg.author), - member: memberToConfigAccessibleMember(args.member), - oldChannel: channelToConfigAccessibleChannel(oldVoiceChannel!), - newChannel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logVoiceChannelForceMove({ + mod: msg.author, + member: args.member, + oldChannel: oldVoiceChannel, + newChannel: channel, }); sendSuccessMessage(pluginData, msg.channel, `**${args.member.user.tag}** moved to **${channel.name}**`); @@ -128,7 +129,7 @@ export const VcmoveAllCmd = utilityCmd({ const voiceChannels = [...pluginData.guild.channels.cache.values()].filter( (c): c is VoiceChannel => c.type === ChannelTypeStrings.VOICE, ); - const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, ch => ch.name); + const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { sendErrorMessage(pluginData, msg.channel, "No matching voice channels"); return; @@ -179,11 +180,11 @@ export const VcmoveAllCmd = utilityCmd({ continue; } - pluginData.state.logs.log(LogType.VOICE_CHANNEL_FORCE_MOVE, { - mod: userToConfigAccessibleUser(msg.author), - member: memberToConfigAccessibleMember(currMember), - oldChannel: channelToConfigAccessibleChannel(args.oldChannel), - newChannel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logVoiceChannelForceMove({ + mod: msg.author, + member: currMember, + oldChannel: args.oldChannel, + newChannel: channel, }); } diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts index 5052ac11..9d050b3f 100644 --- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts @@ -96,8 +96,8 @@ export async function getChannelInfoEmbed( if (channel.type === ChannelTypeStrings.VOICE || channel.type === ChannelTypeStrings.STAGE) { const voiceMembers = Array.from((channel as VoiceChannel | StageChannel).members.values()); - const muted = voiceMembers.filter(vm => vm.voice.mute || vm.voice.selfMute); - const deafened = voiceMembers.filter(vm => vm.voice.deaf || vm.voice.selfDeaf); + const muted = voiceMembers.filter((vm) => vm.voice.mute || vm.voice.selfMute); + const deafened = voiceMembers.filter((vm) => vm.voice.deaf || vm.voice.selfDeaf); const voiceOrStage = channel.type === ChannelTypeStrings.VOICE ? "Voice" : "Stage"; embed.fields.push({ @@ -112,10 +112,10 @@ export async function getChannelInfoEmbed( if (channel.type === ChannelTypeStrings.CATEGORY) { const textChannels = pluginData.guild.channels.cache.filter( - ch => ch.parentId === channel.id && ch.type !== ChannelTypeStrings.VOICE, + (ch) => ch.parentId === channel.id && ch.type !== ChannelTypeStrings.VOICE, ); const voiceChannels = pluginData.guild.channels.cache.filter( - ch => + (ch) => ch.parentId === channel.id && (ch.type === ChannelTypeStrings.VOICE || ch.type === ChannelTypeStrings.STAGE), ); diff --git a/backend/src/plugins/Utility/functions/getEmojiInfoEmbed.ts b/backend/src/plugins/Utility/functions/getEmojiInfoEmbed.ts index 02228924..a97d5310 100644 --- a/backend/src/plugins/Utility/functions/getEmojiInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getEmojiInfoEmbed.ts @@ -7,7 +7,7 @@ export async function getEmojiInfoEmbed( pluginData: GuildPluginData, emojiId: string, ): Promise { - const emoji = pluginData.guild.emojis.cache.find(e => e.id === emojiId); + const emoji = pluginData.guild.emojis.cache.find((e) => e.id === emojiId); if (!emoji) { return null; } diff --git a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts index a95de14b..a0a987e7 100644 --- a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts @@ -65,32 +65,33 @@ export async function getInviteInfoEmbed( `), inline: true, }); + if (invite.channel) { + const channelName = + invite.channel.type === ChannelTypeStrings.VOICE ? `🔉 ${invite.channel.name}` : `#${invite.channel.name}`; - const channelName = - invite.channel.type === ChannelTypeStrings.VOICE ? `🔉 ${invite.channel.name}` : `#${invite.channel.name}`; + const channelCreatedAtTimestamp = snowflakeToTimestamp(invite.channel.id); + const channelCreatedAt = moment.utc(channelCreatedAtTimestamp, "x"); + const channelAge = humanizeDuration(Date.now() - channelCreatedAtTimestamp, { + largest: 2, + round: true, + }); - const channelCreatedAtTimestamp = snowflakeToTimestamp(invite.channel.id); - const channelCreatedAt = moment.utc(channelCreatedAtTimestamp, "x"); - const channelAge = humanizeDuration(Date.now() - channelCreatedAtTimestamp, { - largest: 2, - round: true, - }); - - let channelInfo = trimLines(` + let channelInfo = trimLines(` Name: **${channelName}** ID: \`${invite.channel.id}\` Created: **${channelAge} ago** `); - if (invite.channel.type !== ChannelTypeStrings.VOICE) { - channelInfo += `\nMention: <#${invite.channel.id}>`; - } + if (invite.channel.type !== ChannelTypeStrings.VOICE) { + channelInfo += `\nMention: <#${invite.channel.id}>`; + } - embed.fields.push({ - name: preEmbedPadding + "Channel information", - value: channelInfo, - inline: true, - }); + embed.fields.push({ + name: preEmbedPadding + "Channel information", + value: channelInfo, + inline: true, + }); + } if (invite.inviter) { embed.fields.push({ @@ -119,9 +120,7 @@ export async function getInviteInfoEmbed( /*if (invite.channel.icon) { embed.author.icon_url = `https://cdn.discordapp.com/channel-icons/${invite.channel.id}/${invite.channel.icon}.png?size=256`; - }*/ const channelCreatedAtTimestamp = snowflakeToTimestamp( - invite.channel.id, - ); + }*/ const channelCreatedAtTimestamp = snowflakeToTimestamp(invite.channel.id); const channelCreatedAt = moment.utc(channelCreatedAtTimestamp, "x"); const channelAge = humanizeDuration(Date.now() - channelCreatedAtTimestamp, { largest: 2, diff --git a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts index 4f16c31a..36a664e3 100644 --- a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts @@ -35,13 +35,13 @@ export async function getRoleInfoEmbed( round: true, }); - const rolePerms = Object.keys(role.permissions.toJSON()).map(p => + const rolePerms = Object.keys(role.permissions.toJSON()).map((p) => p // Voice channel related permission names start with 'voice' .replace(/^voice/i, "") .replace(/([a-z])([A-Z])/g, "$1 $2") .toLowerCase() - .replace(/(^\w{1})|(\s{1}\w{1})/g, l => l.toUpperCase()), + .replace(/(^\w{1})|(\s{1}\w{1})/g, (l) => l.toUpperCase()), ); // -1 because of the @everyone role @@ -54,10 +54,7 @@ export async function getRoleInfoEmbed( ID: \`${role.id}\` Created: **${roleAge} ago** (\`${prettyCreatedAt}\`) Position: **${role.position} / ${totalGuildRoles}** - Color: **#${role.color - .toString(16) - .toUpperCase() - .padStart(6, "0")}** + Color: **#${role.color.toString(16).toUpperCase().padStart(6, "0")}** Mentionable: **${role.mentionable ? "Yes" : "No"}** Hoisted: **${role.hoist ? "Yes" : "No"}** Permissions: \`${rolePerms.length ? rolePerms.join(", ") : "None"}\` diff --git a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts index a0922399..03d7212d 100644 --- a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts @@ -1,4 +1,4 @@ -import { MessageEmbedOptions, Snowflake } from "discord.js"; +import { MessageEmbedOptions, PremiumTier, Snowflake } from "discord.js"; import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; @@ -19,6 +19,13 @@ import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; import { UtilityPluginType } from "../types"; import { getGuildPreview } from "./getGuildPreview"; +const PremiumTiers: Record = { + NONE: 0, + TIER_1: 1, + TIER_2: 2, + TIER_3: 3, +}; + export async function getServerInfoEmbed( pluginData: GuildPluginData, serverId: string, @@ -127,7 +134,7 @@ export async function getServerInfoEmbed( } if (!onlineMemberCount && thisServer) { - onlineMemberCount = thisServer.members.cache.filter(m => m.presence?.status !== "offline").size; // Extremely inaccurate fallback + onlineMemberCount = thisServer.members.cache.filter((m) => m.presence?.status !== "offline").size; // Extremely inaccurate fallback } const offlineMemberCount = totalMembers - onlineMemberCount; @@ -155,9 +162,9 @@ export async function getServerInfoEmbed( // CHANNEL COUNTS if (thisServer) { const totalChannels = thisServer.channels.cache.size; - const categories = thisServer.channels.cache.filter(channel => channel.type === ChannelTypeStrings.CATEGORY); - const textChannels = thisServer.channels.cache.filter(channel => channel.type === ChannelTypeStrings.TEXT); - const voiceChannels = thisServer.channels.cache.filter(channel => channel.type === ChannelTypeStrings.VOICE); + const categories = thisServer.channels.cache.filter((channel) => channel.type === ChannelTypeStrings.CATEGORY); + const textChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelTypeStrings.TEXT); + const voiceChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelTypeStrings.VOICE); embed.fields.push({ name: preEmbedPadding + "Channels", @@ -179,20 +186,22 @@ export async function getServerInfoEmbed( } if (restGuild) { + const premiumTierValue = PremiumTiers[restGuild.premiumTier]; + const maxEmojis = { 0: 50, 1: 100, 2: 150, 3: 250, - }[restGuild.premiumTier] || 50; + }[premiumTierValue] ?? 50; const maxStickers = { 0: 0, 1: 15, 2: 30, 3: 60, - }[restGuild.premiumTier] || 0; + }[premiumTierValue] ?? 0; otherStats.push(`Emojis: **${restGuild.emojis.cache.size}** / ${maxEmojis * 2}`); otherStats.push(`Stickers: **${restGuild.stickers.cache.size}** / ${maxStickers}`); @@ -202,7 +211,9 @@ export async function getServerInfoEmbed( } if (thisServer) { - otherStats.push(`Boosts: **${thisServer.premiumSubscriptionCount ?? 0}** (level ${thisServer.premiumTier})`); + otherStats.push( + `Boosts: **${thisServer.premiumSubscriptionCount ?? 0}** (level ${PremiumTiers[thisServer.premiumTier]})`, + ); } embed.fields.push({ diff --git a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts index 78970a0e..39cf4166 100644 --- a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts @@ -36,7 +36,7 @@ export async function getUserInfoEmbed( const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); embed.author = { - name: `User: ${user.tag}`, + name: `${user.bot ? "Bot" : "User"}: ${user.tag}`, }; const avatarURL = user.displayAvatarURL(); @@ -54,7 +54,7 @@ export async function getUserInfoEmbed( if (compact) { embed.fields.push({ - name: preEmbedPadding + "User information", + name: preEmbedPadding + `${user.bot ? "Bot" : "User"} information`, value: trimLines(` Profile: <@!${user.id}> Created: **${accountAge} ago** (\`${prettyCreatedAt}\`) @@ -70,11 +70,12 @@ export async function getUserInfoEmbed( largest: 2, round: true, }); - embed.fields[0].value += `\nJoined: **${joinAge} ago** (\`${prettyJoinedAt}\`)`; + + embed.fields[0].value += `\n${user.bot ? "Added" : "Joined"}: **${joinAge} ago** (\`${prettyJoinedAt}\`)`; } else { embed.fields.push({ name: preEmbedPadding + "!! NOTE !!", - value: "User is not on the server", + value: `${user.bot ? "Bot" : "User"} is not on the server`, }); } @@ -82,7 +83,7 @@ export async function getUserInfoEmbed( } embed.fields.push({ - name: preEmbedPadding + "User information", + name: preEmbedPadding + `${user.bot ? "Bot" : "User"} information`, value: trimLines(` Name: **${user.tag}** ID: \`${user.id}\` @@ -101,16 +102,14 @@ export async function getUserInfoEmbed( largest: 2, round: true, }); - const roles = member.roles.cache - .map(role => pluginData.guild.roles.cache.get(role.id)) - .filter((r): r is Role => !!r); + const roles = Array.from(member.roles.cache.values()).filter((r) => r.id !== pluginData.guild.id); roles.sort(sorter("position", "DESC")); embed.fields.push({ name: preEmbedPadding + "Member information", value: trimLines(` - Joined: **${joinAge} ago** (\`${prettyJoinedAt}\`) - ${roles.length > 0 ? "Roles: " + roles.map(r => `<@&${r.id}>`).join(", ") : ""} + ${user.bot ? "Added" : "Joined"}: **${joinAge} ago** (\`${prettyJoinedAt}\`) + ${roles.length > 0 ? "Roles: " + roles.map((r) => `<@&${r.id}>`).join(", ") : ""} `), }); @@ -128,17 +127,17 @@ export async function getUserInfoEmbed( } else { embed.fields.push({ name: preEmbedPadding + "Member information", - value: "⚠ User is not on the server", + value: `⚠ ${user.bot ? "Bot" : "User"} is not on the server`, }); } - const cases = (await pluginData.state.cases.getByUserId(user.id)).filter(c => !c.is_hidden); + const cases = (await pluginData.state.cases.getByUserId(user.id)).filter((c) => !c.is_hidden); if (cases.length > 0) { cases.sort((a, b) => { return a.created_at < b.created_at ? 1 : -1; }); - const caseSummary = cases.slice(0, 3).map(c => { + const caseSummary = cases.slice(0, 3).map((c) => { const summaryText = `${CaseTypes[c.type]} (#${c.case_number})`; if (c.log_message_id) { diff --git a/backend/src/plugins/Utility/functions/hasPermission.ts b/backend/src/plugins/Utility/functions/hasPermission.ts new file mode 100644 index 00000000..919d28f7 --- /dev/null +++ b/backend/src/plugins/Utility/functions/hasPermission.ts @@ -0,0 +1,12 @@ +import { GuildMember, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { UtilityPluginType } from "../types"; + +export async function hasPermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, + permission: string, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId }))[permission]; +} diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index 4717dd31..b2b417b7 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -90,7 +90,7 @@ export async function displaySearch( const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE; - const loadSearchPage = async page => { + const loadSearchPage = async (page) => { if (searching) return; searching = true; @@ -101,7 +101,7 @@ export async function displaySearch( searchMsgPromise = originalSearchMsg.edit("Searching..."); } else { searchMsgPromise = msg.channel.send("Searching..."); - searchMsgPromise.then(m => (originalSearchMsg = m)); + searchMsgPromise.then((m) => (originalSearchMsg = m)); } let searchResult; @@ -182,10 +182,7 @@ export async function displaySearch( .setEmoji("➡") .setCustomId(`nextButton:${idMod}`) .setDisabled(currentPage === searchResult.lastPage), - new MessageButton() - .setStyle("SECONDARY") - .setEmoji("🔄") - .setCustomId(`reloadButton:${idMod}`), + new MessageButton().setStyle("SECONDARY").setEmoji("🔄").setCustomId(`reloadButton:${idMod}`), ); const row = new MessageActionRow().addComponents(buttons); @@ -308,7 +305,7 @@ async function performMemberSearch( if (args.role) { const roleIds = args.role.split(","); - matchingMembers = matchingMembers.filter(member => { + matchingMembers = matchingMembers.filter((member) => { for (const role of roleIds) { if (!member.roles.cache.has(role as Snowflake)) return false; } @@ -318,11 +315,11 @@ async function performMemberSearch( } if (args.voice) { - matchingMembers = matchingMembers.filter(m => m.voice.channelId); + matchingMembers = matchingMembers.filter((m) => m.voice.channelId); } if (args.bot) { - matchingMembers = matchingMembers.filter(m => m.user.bot); + matchingMembers = matchingMembers.filter((m) => m.user.bot); } if (args.query) { @@ -379,7 +376,7 @@ async function performMemberSearch( }); } else { */ - matchingMembers = await asyncFilter(matchingMembers, async member => { + matchingMembers = await asyncFilter(matchingMembers, async (member) => { if (member.nickname && (await execRegExp(queryRegex, member.nickname).catch(allowTimeout))) { return true; } @@ -396,12 +393,12 @@ async function performMemberSearch( const realSortDir = sortDir === "-" ? "DESC" : "ASC"; if (sortBy === "id") { - matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir)); + matchingMembers.sort(sorter((m) => BigInt(m.id), realSortDir)); } else { matchingMembers.sort( multiSorter([ - [m => m.user.username.toLowerCase(), realSortDir], - [m => m.discriminator, realSortDir], + [(m) => m.user.username.toLowerCase(), realSortDir], + [(m) => m.discriminator, realSortDir], ]), ); } @@ -435,7 +432,7 @@ async function performBanSearch( throw new SearchError(`Unable to search bans: missing "Ban Members" permission`); } - let matchingBans = (await pluginData.guild.bans.fetch({ cache: false })).map(x => x.user); + let matchingBans = (await pluginData.guild.bans.fetch({ cache: false })).map((x) => x.user); if (args.query) { let isSafeRegex = true; @@ -450,7 +447,7 @@ async function performBanSearch( } const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex); - matchingBans = await asyncFilter(matchingBans, async user => { + matchingBans = await asyncFilter(matchingBans, async (user) => { const fullUsername = user.tag; if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true; return false; @@ -461,12 +458,12 @@ async function performBanSearch( const realSortDir = sortDir === "-" ? "DESC" : "ASC"; if (sortBy === "id") { - matchingBans.sort(sorter(m => BigInt(m.id), realSortDir)); + matchingBans.sort(sorter((m) => BigInt(m.id), realSortDir)); } else { matchingBans.sort( multiSorter([ - [m => m.username.toLowerCase(), realSortDir], - [m => m.discriminator, realSortDir], + [(m) => m.username.toLowerCase(), realSortDir], + [(m) => m.discriminator, realSortDir], ]), ); } @@ -491,7 +488,7 @@ async function performBanSearch( function formatSearchResultList(members: Array): string { const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); - const lines = members.map(member => { + const lines = members.map((member) => { const paddedId = member.id.padEnd(longestId, " "); let line; if (member instanceof GuildMember) { @@ -506,5 +503,5 @@ function formatSearchResultList(members: Array): string { } function formatSearchResultIdList(members: Array): string { - return members.map(m => m.id).join(" "); + return members.map((m) => m.id).join(" "); } diff --git a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts index c311c794..1bbcdb45 100644 --- a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts +++ b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts @@ -3,6 +3,7 @@ import { GuildLogs } from "../../data/GuildLogs"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { SendWelcomeMessageEvt } from "./events/SendWelcomeMessageEvt"; import { ConfigSchema, WelcomeMessagePluginType } from "./types"; +import { LogsPlugin } from "../Logs/LogsPlugin"; const defaultOptions: PluginOptions = { config: { @@ -20,6 +21,7 @@ export const WelcomeMessagePlugin = zeppelinGuildPlugin [LogsPlugin], defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts b/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts index 852c3650..772f27a9 100644 --- a/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts +++ b/backend/src/plugins/WelcomeMessage/events/SendWelcomeMessageEvt.ts @@ -1,14 +1,16 @@ import { Snowflake, TextChannel } from "discord.js"; import { - channelToConfigAccessibleChannel, - memberToConfigAccessibleMember, - userToConfigAccessibleUser, -} from "../../../utils/configAccessibleObjects"; + channelToTemplateSafeChannel, + guildToTemplateSafeGuild, + memberToTemplateSafeMember, + userToTemplateSafeUser, +} from "../../../utils/templateSafeObjects"; import { LogType } from "../../../data/LogType"; -import { renderTemplate, TemplateParseError } from "../../../templateFormatter"; -import { createChunkedMessage, stripObjectToScalars } from "../../../utils"; +import { renderTemplate, TemplateParseError, TemplateSafeValueContainer } from "../../../templateFormatter"; +import { createChunkedMessage, stripObjectToScalars, verboseChannelMention, verboseUserMention } from "../../../utils"; import { sendDM } from "../../../utils/sendDM"; import { welcomeMessageEvt } from "../types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; export const SendWelcomeMessageEvt = welcomeMessageEvt({ event: "guildMemberAdd", @@ -31,15 +33,17 @@ export const SendWelcomeMessageEvt = welcomeMessageEvt({ let formatted; try { - const strippedMember = stripObjectToScalars(member, ["user", "guild"]); - formatted = await renderTemplate(config.message, { - member: strippedMember, - user: strippedMember["user"], - guild: strippedMember["guild"], - }); + formatted = await renderTemplate( + config.message, + new TemplateSafeValueContainer({ + member: memberToTemplateSafeMember(member), + user: userToTemplateSafeUser(member.user), + guild: guildToTemplateSafeGuild(member.guild), + }), + ); } catch (e) { if (e instanceof TemplateParseError) { - pluginData.state.logs.log(LogType.BOT_ALERT, { + pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Error formatting welcome message: ${e.message}`, }); return; @@ -52,9 +56,9 @@ export const SendWelcomeMessageEvt = welcomeMessageEvt({ try { await sendDM(member.user, formatted, "welcome message"); } catch { - pluginData.state.logs.log(LogType.DM_FAILED, { + pluginData.getPlugin(LogsPlugin).logDmFailed({ source: "welcome message", - user: userToConfigAccessibleUser(member.user), + user: member.user, }); } } @@ -64,12 +68,14 @@ export const SendWelcomeMessageEvt = welcomeMessageEvt({ if (!channel || !(channel instanceof TextChannel)) return; try { - await createChunkedMessage(channel, formatted); + await createChunkedMessage(channel, formatted, { + parse: ["users"], + }); } catch { - pluginData.state.logs.log(LogType.BOT_ALERT, { - body: `Failed send a welcome message for {userMention(member)} to {channelMention(channel)}`, - member: memberToConfigAccessibleMember(member), - channel: channelToConfigAccessibleChannel(channel), + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed send a welcome message for ${verboseUserMention(member.user)} to ${verboseChannelMention( + channel, + )}`, }); } } diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts index 9bcd3417..0bc7dbce 100644 --- a/backend/src/plugins/ZeppelinPluginBlueprint.ts +++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts @@ -41,7 +41,7 @@ export function zeppelinGuildPlugin(): < - TBlueprint extends ZeppelinGuildPluginBlueprint> + TBlueprint extends ZeppelinGuildPluginBlueprint>, >( blueprint: TBlueprint, ) => TBlueprint & { @@ -50,9 +50,9 @@ export function zeppelinGuildPlugin(): < export function zeppelinGuildPlugin(...args) { if (args.length) { - const blueprint = (typedGuildPlugin( + const blueprint = typedGuildPlugin( ...(args as Parameters), - ) as unknown) as ZeppelinGuildPluginBlueprint; + ) as unknown as ZeppelinGuildPluginBlueprint; blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint, blueprint.configPreprocessor); return blueprint; } else { @@ -75,7 +75,7 @@ export function zeppelinGlobalPlugin(): < - TBlueprint extends ZeppelinGlobalPluginBlueprint + TBlueprint extends ZeppelinGlobalPluginBlueprint, >( blueprint: TBlueprint, ) => TBlueprint & { @@ -84,9 +84,9 @@ export function zeppelinGlobalPlugin(): < export function zeppelinGlobalPlugin(...args) { if (args.length) { - const blueprint = (typedGlobalPlugin( + const blueprint = typedGlobalPlugin( ...(args as Parameters), - ) as unknown) as ZeppelinGlobalPluginBlueprint; + ) as unknown as ZeppelinGlobalPluginBlueprint; // @ts-ignore FIXME: Check the types here blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint, blueprint.configPreprocessor); return blueprint; diff --git a/backend/src/rateLimitStats.ts b/backend/src/rateLimitStats.ts new file mode 100644 index 00000000..eb3c142f --- /dev/null +++ b/backend/src/rateLimitStats.ts @@ -0,0 +1,24 @@ +import { RateLimitData } from "discord.js"; + +type RateLimitLogItem = { + timestamp: number; + data: RateLimitData; +}; + +const rateLimitLog: RateLimitLogItem[] = []; + +const MAX_RATE_LIMIT_LOG_ITEMS = 100; + +export function logRateLimit(data: RateLimitData) { + rateLimitLog.push({ + timestamp: Date.now(), + data, + }); + if (rateLimitLog.length > MAX_RATE_LIMIT_LOG_ITEMS) { + rateLimitLog.splice(0, rateLimitLog.length - MAX_RATE_LIMIT_LOG_ITEMS); + } +} + +export function getRateLimitStats(): RateLimitLogItem[] { + return Array.from(rateLimitLog); +} diff --git a/backend/src/restCallStats.ts b/backend/src/restCallStats.ts new file mode 100644 index 00000000..a6d5c8ea --- /dev/null +++ b/backend/src/restCallStats.ts @@ -0,0 +1,35 @@ +import { sorter } from "./utils"; + +Error.stackTraceLimit = Infinity; + +type CallStats = { method: string; path: string; source: string; count: number }; +const restCallStats: Map = new Map(); + +const looseSnowflakeRegex = /\d{15,}/g; +const queryParamsRegex = /\?.*$/g; + +export function logRestCall(method: string, path: string) { + const anonymizedPath = path.replace(looseSnowflakeRegex, "0000").replace(queryParamsRegex, ""); + const stackLines = (new Error().stack || "").split("\n").slice(10); // Remove initial fluff + const firstSrcLine = stackLines.findIndex((line) => line.includes("/backend/src")); + const source = stackLines + .slice(firstSrcLine !== -1 ? firstSrcLine : -5) + .filter((l) => !l.includes("processTicksAndRejections")) + .join("\n"); + const key = `${method}|${anonymizedPath}|${source}`; + if (!restCallStats.has(key)) { + restCallStats.set(key, { + method, + path: anonymizedPath, + source, + count: 0, + }); + } + restCallStats.get(key)!.count++; +} + +export function getTopRestCallStats(count: number): CallStats[] { + const stats = Array.from(restCallStats.values()); + stats.sort(sorter("count", "DESC")); + return stats.slice(0, count); +} diff --git a/backend/src/templateFormatter.test.ts b/backend/src/templateFormatter.test.ts index 0d692e8b..24aaa881 100644 --- a/backend/src/templateFormatter.test.ts +++ b/backend/src/templateFormatter.test.ts @@ -1,12 +1,12 @@ import test from "ava"; -import { parseTemplate, renderParsedTemplate, renderTemplate } from "./templateFormatter"; +import { parseTemplate, renderParsedTemplate, renderTemplate, TemplateSafeValueContainer } from "./templateFormatter"; -test("Parses plain string templates correctly", t => { +test("Parses plain string templates correctly", (t) => { const result = parseTemplate("foo bar baz"); t.deepEqual(result, ["foo bar baz"]); }); -test("Parses templates with variables correctly", t => { +test("Parses templates with variables correctly", (t) => { const result = parseTemplate("foo {bar} baz"); t.deepEqual(result, [ "foo ", @@ -18,7 +18,7 @@ test("Parses templates with variables correctly", t => { ]); }); -test("Parses templates with function variables correctly", t => { +test("Parses templates with function variables correctly", (t) => { const result = parseTemplate('foo {bar("str", 5.07)} baz'); t.deepEqual(result, [ "foo ", @@ -30,7 +30,7 @@ test("Parses templates with function variables correctly", t => { ]); }); -test("Parses function variables with variable arguments correctly", t => { +test("Parses function variables with variable arguments correctly", (t) => { const result = parseTemplate('foo {bar("str", 5.07, someVar)} baz'); t.deepEqual(result, [ "foo ", @@ -49,7 +49,7 @@ test("Parses function variables with variable arguments correctly", t => { ]); }); -test("Parses function variables with function variable arguments correctly", t => { +test("Parses function variables with function variable arguments correctly", (t) => { const result = parseTemplate('foo {bar("str", 5.07, deeply(nested(8)))} baz'); t.deepEqual(result, [ "foo ", @@ -73,9 +73,9 @@ test("Parses function variables with function variable arguments correctly", t = ]); }); -test("Renders a parsed template correctly", async t => { +test("Renders a parsed template correctly", async (t) => { const parseResult = parseTemplate('foo {bar("str", 5.07, deeply(nested(8)))} baz'); - const values = { + const values = new TemplateSafeValueContainer({ bar(strArg, numArg, varArg) { return `${strArg} ${numArg} !${varArg}!`; }, @@ -85,24 +85,24 @@ test("Renders a parsed template correctly", async t => { nested(numArg) { return `?${numArg}?`; }, - }; + }); const renderResult = await renderParsedTemplate(parseResult, values); t.is(renderResult, "foo str 5.07 !! baz"); }); -test("Supports base values in renderTemplate", async t => { +test("Supports base values in renderTemplate", async (t) => { const result = await renderTemplate('{if("", "+", "-")} {if(1, "+", "-")}'); t.is(result, "- +"); }); -test("Edge case #1", async t => { +test("Edge case #1", async (t) => { const result = await renderTemplate("{foo} {bar()}"); // No "Unclosed function" exception = success t.pass(); }); -test("Parses empty string args as empty strings", async t => { +test("Parses empty string args as empty strings", async (t) => { const result = parseTemplate('{foo("")}'); t.deepEqual(result, [ { diff --git a/backend/src/templateFormatter.ts b/backend/src/templateFormatter.ts index 145d7f1d..0dcb7bde 100644 --- a/backend/src/templateFormatter.ts +++ b/backend/src/templateFormatter.ts @@ -34,8 +34,62 @@ function newTemplateVar(): ITemplateVar { type ParsedTemplate = Array; +export type TemplateSafeValue = + | string + | number + | boolean + | null + | undefined + | ((...args: any[]) => TemplateSafeValue | Promise) + | TemplateSafeValueContainer + | TemplateSafeValue[]; + +function isTemplateSafeValue(value: unknown): value is TemplateSafeValue { + return ( + value == null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "function" || + (Array.isArray(value) && value.every((v) => isTemplateSafeValue(v))) || + value instanceof TemplateSafeValueContainer + ); +} + +export class TemplateSafeValueContainer { + // Fake property used for stricter type checks since TypeScript uses structural typing + _isTemplateSafeValueContainer: true; + + [key: string]: TemplateSafeValue; + + constructor(data: Record = {}) { + for (const [key, value] of Object.entries(data)) { + if (!isTemplateSafeValue(value)) { + // tslint:disable:no-console + console.error("=== CONTEXT FOR UNSAFE VALUE ==="); + console.error("stringified:", JSON.stringify(value)); + console.error("typeof:", typeof value); + console.error("constructor name:", (value as any)?.constructor?.name); + console.error("=== /CONTEXT FOR UNSAFE VALUE ==="); + // tslint:enable:no-console + throw new Error(`Unsafe value for key "${key}" in SafeTemplateValueContainer`); + } + + this[key] = value; + } + } +} + +export type TypedTemplateSafeValueContainer = TemplateSafeValueContainer & T; + +export function createTypedTemplateSafeValueContainer>( + data: T, +): TypedTemplateSafeValueContainer { + return new TemplateSafeValueContainer(data) as TypedTemplateSafeValueContainer; +} + function cleanUpParseResult(arr) { - arr.forEach(item => { + arr.forEach((item) => { if (typeof item === "object") { delete item._state; delete item._parent; @@ -218,7 +272,14 @@ export function parseTemplate(str: string): ParsedTemplate { return result; } -async function evaluateTemplateVariable(theVar: ITemplateVar, values) { +async function evaluateTemplateVariable( + theVar: ITemplateVar, + values: TemplateSafeValueContainer, +): Promise { + if (!(values instanceof TemplateSafeValueContainer)) { + throw new Error("evaluateTemplateVariable() called with unsafe values"); + } + const value = has(values, theVar.identifier) ? get(values, theVar.identifier) : undefined; if (typeof value === "function") { @@ -238,13 +299,17 @@ async function evaluateTemplateVariable(theVar: ITemplateVar, values) { } const result = await value(...args); + if (!isTemplateSafeValue(result)) { + throw new Error(`Template function ${theVar.identifier} returned unsafe value`); + } + return result == null ? "" : result; } return value == null ? "" : value; } -export async function renderParsedTemplate(parsedTemplate: ParsedTemplate, values: any) { +export async function renderParsedTemplate(parsedTemplate: ParsedTemplate, values: TemplateSafeValueContainer) { let result = ""; for (const part of parsedTemplate) { @@ -323,6 +388,10 @@ const baseValues = { ucfirst(arg) { return baseValues.upperFirst(arg); }, + strlen(arg) { + if (typeof arg !== "string") return 0; + return [...arg].length; + }, rand(from, to, seed = null) { if (isNaN(from)) return 0; @@ -341,6 +410,10 @@ const baseValues = { return Math.round(randValue * (to - from) + from); }, + round(arg, decimals = 0) { + if (isNaN(arg)) return 0; + return decimals === 0 ? Math.round(arg) : arg.toFixed(decimals); + }, add(...args) { return args.reduce((result, arg) => { if (isNaN(arg)) return result; @@ -380,9 +453,13 @@ const baseValues = { }, }; -export async function renderTemplate(template: string, values = {}, includeBaseValues = true) { +export async function renderTemplate( + template: string, + values: TemplateSafeValueContainer = new TemplateSafeValueContainer(), + includeBaseValues = true, +) { if (includeBaseValues) { - values = Object.assign({}, baseValues, values); + values = new TemplateSafeValueContainer(Object.assign({}, baseValues, values)); } let parseResult: ParsedTemplate; diff --git a/backend/src/utils.test.ts b/backend/src/utils.test.ts index abd89774..ecf773a3 100644 --- a/backend/src/utils.test.ts +++ b/backend/src/utils.test.ts @@ -5,51 +5,51 @@ import { ErisAllowedMentionFormat } from "./utils/erisAllowedMentionsToDjsMentio type AssertEquals = TActual extends TExpected ? true : false; -test("getUrlsInString(): detects full links", t => { +test("getUrlsInString(): detects full links", (t) => { const urls = getUrlsInString("foo https://google.com/ bar"); t.is(urls.length, 1); t.is(urls[0].hostname, "google.com"); }); -test("getUrlsInString(): detects partial links", t => { +test("getUrlsInString(): detects partial links", (t) => { const urls = getUrlsInString("foo google.com bar"); t.is(urls.length, 1); t.is(urls[0].hostname, "google.com"); }); -test("getUrlsInString(): detects subdomains", t => { +test("getUrlsInString(): detects subdomains", (t) => { const urls = getUrlsInString("foo photos.google.com bar"); t.is(urls.length, 1); t.is(urls[0].hostname, "photos.google.com"); }); -test("delay strings: basic support", t => { +test("delay strings: basic support", (t) => { const delayString = "2w4d7h32m17s"; const expected = 1_582_337_000; t.is(convertDelayStringToMS(delayString), expected); }); -test("delay strings: default unit (minutes)", t => { +test("delay strings: default unit (minutes)", (t) => { t.is(convertDelayStringToMS("10"), 10 * 60 * 1000); }); -test("delay strings: custom default unit", t => { +test("delay strings: custom default unit", (t) => { t.is(convertDelayStringToMS("10", "s"), 10 * 1000); }); -test("delay strings: reverse conversion", t => { +test("delay strings: reverse conversion", (t) => { const ms = 1_582_337_020; const expected = "2w4d7h32m17s20x"; t.is(convertMSToDelayString(ms), expected); }); -test("delay strings: reverse conversion (conservative)", t => { +test("delay strings: reverse conversion (conservative)", (t) => { const ms = 1_209_600_000; const expected = "2w"; t.is(convertMSToDelayString(ms), expected); }); -test("tAllowedMentions matches Eris's AllowedMentions", t => { +test("tAllowedMentions matches Eris's AllowedMentions", (t) => { type TAllowedMentions = ioTs.TypeOf; const typeTest: AssertEquals = true; t.pass(); diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 370bf048..e5bde6fe 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,6 +1,7 @@ import { Client, Constants, + DiscordAPIError, Emoji, Guild, GuildAuditLogs, @@ -37,12 +38,13 @@ import moment from "moment-timezone"; import tlds from "tlds"; import tmp from "tmp"; import { URL } from "url"; -import { SavedMessage } from "./data/entities/SavedMessage"; +import { ISavedMessageAttachmentData, SavedMessage } from "./data/entities/SavedMessage"; import { SimpleCache } from "./SimpleCache"; import { ChannelTypeStrings } from "./types"; import { sendDM } from "./utils/sendDM"; import { waitForButtonConfirm } from "./utils/waitForInteraction"; import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils"; +import { z, ZodError } from "zod"; const fsp = fs.promises; @@ -84,8 +86,8 @@ export function isDiscordHTTPError(err: Error | string) { return typeof err === "object" && err.constructor?.name === DISCORD_HTTP_ERROR_NAME; } -export function isDiscordAPIError(err: Error | string) { - return typeof err === "object" && err.constructor?.name === DISCORD_REST_ERROR_NAME; +export function isDiscordAPIError(err: Error | string): err is DiscordAPIError { + return err instanceof DiscordAPIError; } export function tNullable>(type: T) { @@ -104,7 +106,7 @@ export const tNormalizedNullOrUndefined = new t.Type typeof v === "undefined", (v, c) => (v == null ? t.success(undefined) : t.failure(v, c, "Value must be null or undefined")), - s => undefined, + (s) => undefined, ); /** @@ -152,10 +154,10 @@ export function tDeepPartial(type: T): TDeepPartial { } else if (type instanceof t.DictionaryType) { return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial; } else if (type instanceof t.UnionType) { - return t.union(type.types.map(unionType => tDeepPartial(unionType))) as TDeepPartial; + return t.union(type.types.map((unionType) => tDeepPartial(unionType))) as TDeepPartial; } else if (type instanceof t.IntersectionType) { - const types = type.types.map(intersectionType => tDeepPartial(intersectionType)); - return (t.intersection(types as [t.Mixed, t.Mixed]) as unknown) as TDeepPartial; + const types = type.types.map((intersectionType) => tDeepPartial(intersectionType)); + return t.intersection(types as [t.Mixed, t.Mixed]) as unknown as TDeepPartial; } else if (type instanceof t.ArrayType) { return t.array(tDeepPartial(type.type)) as TDeepPartial; } else { @@ -319,14 +321,88 @@ export const tEmbed = t.type({ ), }); +export const zEmbedInput = z.object({ + title: z.string().optional(), + description: z.string().optional(), + url: z.string().optional(), + timestamp: z.number().optional(), + color: z.number().optional(), + + footer: z.optional( + z.object({ + text: z.string(), + icon_url: z.string().optional(), + }), + ), + + image: z.optional( + z.object({ + url: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + }), + ), + + thumbnail: z.optional( + z.object({ + url: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + }), + ), + + video: z.optional( + z.object({ + url: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + }), + ), + + provider: z.optional( + z.object({ + name: z.string(), + url: z.string().optional(), + }), + ), + + fields: z.optional( + z.array( + z.object({ + name: z.string().optional(), + value: z.string().optional(), + inline: z.boolean().optional(), + }), + ), + ), + + author: z + .optional( + z.object({ + name: z.string(), + url: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + }), + ) + .nullable(), +}); + export type EmbedWith = MessageEmbedOptions & Pick, T>; +export const zStrictMessageContent = z.object({ + content: z.string().optional(), + tts: z.boolean().optional(), + embeds: z.array(zEmbedInput).optional(), +}); + +export type ZStrictMessageContent = z.infer; + export type StrictMessageContent = { content?: string; tts?: boolean; - disableEveryone?: boolean; - embed?: MessageEmbedOptions; + embeds?: MessageEmbedOptions[]; }; export const tStrictMessageContent = t.type({ @@ -334,10 +410,65 @@ export const tStrictMessageContent = t.type({ tts: tNullable(t.boolean), disableEveryone: tNullable(t.boolean), embed: tNullable(tEmbed), + embeds: tNullable(t.array(tEmbed)), }); export const tMessageContent = t.union([t.string, tStrictMessageContent]); +export function validateAndParseMessageContent(input: unknown): StrictMessageContent { + if (input == null) { + return {}; + } + + if (typeof input !== "object") { + return { content: String(input) }; + } + + // Migrate embed -> embeds + if ((input as any).embed) { + (input as any).embeds = [(input as any).embed]; + delete (input as any).embed; + } + + dropNullValuesRecursively(input); + + try { + return zStrictMessageContent.parse(input) as unknown as StrictMessageContent; + } catch (err) { + if (err instanceof ZodError) { + // TODO: Allow error to be thrown and handle at use location + return {}; + } + + throw err; + } +} + +function dropNullValuesRecursively(obj: any) { + if (obj == null) { + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + dropNullValuesRecursively(item); + } + } + + if (typeof obj !== "object") { + return; + } + + for (const [key, value] of Object.entries(obj)) { + if (value == null) { + delete obj[key]; + continue; + } + + dropNullValuesRecursively(value); + } +} + /** * Mirrors AllowedMentions from Eris */ @@ -361,34 +492,34 @@ export const tAlphanumeric = new t.Type( "tAlphanumeric", (s): s is string => typeof s === "string", (from, to) => - either.chain(t.string.validate(from, to), s => { + either.chain(t.string.validate(from, to), (s) => { return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s); }), - s => s, + (s) => s, ); export const tDateTime = new t.Type( "tDateTime", (s): s is string => typeof s === "string", (from, to) => - either.chain(t.string.validate(from, to), s => { + either.chain(t.string.validate(from, to), (s) => { const parsed = s.length === 10 ? moment.utc(s, "YYYY-MM-DD") : s.length === 19 ? moment.utc(s, "YYYY-MM-DD HH:mm:ss") : null; return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime"); }), - s => s, + (s) => s, ); export const tDelayString = new t.Type( "tDelayString", (s): s is string => typeof s === "string", (from, to) => - either.chain(t.string.validate(from, to), s => { + either.chain(t.string.validate(from, to), (s) => { const ms = convertDelayStringToMS(s); return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s); }), - s => s, + (s) => s, ); // To avoid running into issues with the JS max date vaLue, we cap maximum delay strings *far* below that. @@ -477,8 +608,8 @@ export function stripObjectToScalars(obj, includedNested: string[] = []) { } else if (typeof obj[key] === "object") { const prefix = `${key}.`; const nestedNested = includedNested - .filter(p => p === key || p.startsWith(prefix)) - .map(p => (p === key ? p : p.slice(prefix.length))); + .filter((p) => p === key || p.startsWith(prefix)) + .map((p) => (p === key ? p : p.slice(prefix.length))); if (nestedNested.length) { result[key] = stripObjectToScalars(obj[key], nestedNested); @@ -497,7 +628,7 @@ export function isSnowflake(v: string): boolean { } export function sleep(ms: number): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { setTimeout(resolve, ms); }); } @@ -545,7 +676,7 @@ export async function findRelevantAuditLogEntry( const cutoffTS = Date.now() - 1000 * 60 * 2; - const relevantEntry = entries.find(entry => { + const relevantEntry = entries.find((entry) => { return (entry.target as { id }).id === userId && entry.createdTimestamp >= cutoffTS; }); @@ -612,10 +743,12 @@ export function isNotNull(value): value is Exclude { // discordapp.com/invite/ // discord.gg/invite/ // discord.gg/ -const quickInviteDetection = /(?:discord.com|discordapp.com)\/invite\/([a-z0-9\-]+)|discord.gg\/(?:\S+\/)?([a-z0-9\-]+)/gi; +// discord.com/friend-invite/ +const quickInviteDetection = + /discord(?:app)?\.com\/(?:friend-)?invite\/([a-z0-9\-]+)|discord\.gg\/(?:\S+\/)?([a-z0-9\-]+)/gi; const isInviteHostRegex = /(?:^|\.)(?:discord.gg|discord.com|discordapp.com)$/i; -const longInvitePathRegex = /^\/invite\/([a-z0-9\-]+)$/i; +const longInvitePathRegex = /^\/(?:friend-)?invite\/([a-z0-9\-]+)$/i; export function getInviteCodesInString(str: string): string[] { const inviteCodes: string[] = []; @@ -626,19 +759,19 @@ export function getInviteCodesInString(str: string): string[] { // Quick detection const quickDetectionMatch = str.matchAll(quickInviteDetection); if (quickDetectionMatch) { - inviteCodes.push(...[...quickDetectionMatch].map(m => m[1] || m[2])); + inviteCodes.push(...[...quickDetectionMatch].map((m) => m[1] || m[2])); } // Deep detection via URL parsing const linksInString = getUrlsInString(str, true); - const potentialInviteLinks = linksInString.filter(url => isInviteHostRegex.test(url.hostname)); - const withNormalizedPaths = potentialInviteLinks.map(url => { + const potentialInviteLinks = linksInString.filter((url) => isInviteHostRegex.test(url.hostname)); + const withNormalizedPaths = potentialInviteLinks.map((url) => { url.pathname = url.pathname.replace(/\/{2,}/g, "/").replace(/\/+$/g, ""); return url; }); const codesFromInviteLinks = withNormalizedPaths - .map(url => { + .map((url) => { // discord.gg/[anything/] if (url.hostname === "discord.gg") { const parts = url.pathname.split("/").filter(Boolean); @@ -647,6 +780,8 @@ export function getInviteCodesInString(str: string): string[] { // discord.com/invite/[/anything] // discordapp.com/invite/[/anything] + // discord.com/friend-invite/[/anything] + // discordapp.com/friend-invite/[/anything] const longInviteMatch = url.pathname.match(longInvitePathRegex); if (longInviteMatch) { return longInviteMatch[1]; @@ -682,7 +817,7 @@ export function trimLines(str: string) { return str .trim() .split("\n") - .map(l => l.trim()) + .map((l) => l.trim()) .join("\n") .trim(); } @@ -690,7 +825,7 @@ export function trimLines(str: string) { export function trimEmptyLines(str: string) { return str .split("\n") - .filter(l => l.trim() !== "") + .filter((l) => l.trim() !== "") .join("\n"); } @@ -726,7 +861,7 @@ export function trimIndents(str: string, indentLength: number) { const regex = new RegExp(`^\\s{0,${indentLength}}`, "g"); return str .split("\n") - .map(line => line.replace(regex, "")) + .map((line) => line.replace(regex, "")) .join("\n"); } @@ -737,7 +872,7 @@ export function indentLine(str: string, indentLength: number) { export function indentLines(str: string, indentLength: number) { return str .split("\n") - .map(line => indentLine(line, indentLength)) + .map((line) => indentLine(line, indentLength)) .join("\n"); } @@ -843,7 +978,7 @@ export function chunkMessageLines(str: string, maxChunkLength = 1990): string[] const chunks = chunkLines(str, maxChunkLength); let openCodeBlock = false; - return chunks.map(chunk => { + return chunks.map((chunk) => { // If the chunk starts with a newline, add an invisible unicode char so Discord doesn't strip it away if (chunk[0] === "\n") chunk = "\u200b" + chunk; // If the chunk ends with a newline, add an invisible unicode char so Discord doesn't strip it away @@ -871,7 +1006,7 @@ export function chunkMessageLines(str: string, maxChunkLength = 1990): string[] } export async function createChunkedMessage( - channel: TextChannel | User, + channel: TextChannel | ThreadChannel | User, messageText: string, allowedMentions?: MessageMentionOptions, ) { @@ -885,14 +1020,14 @@ export async function createChunkedMessage( * Downloads the file from the given URL to a temporary file, with retry support */ export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path: string; deleteFn: () => void }> { - return new Promise(resolve => { + return new Promise((resolve) => { tmp.file((err, path, fd, deleteFn) => { if (err) throw err; const writeStream = fs.createWriteStream(path); https - .get(attachmentUrl, res => { + .get(attachmentUrl, (res) => { res.pipe(writeStream); writeStream.on("finish", () => { writeStream.end(); @@ -902,7 +1037,7 @@ export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path }); }); }) - .on("error", httpsErr => { + .on("error", (httpsErr) => { fsp.unlink(path); if (retries === 0) { @@ -927,7 +1062,7 @@ export function simpleClosestStringMatch(searchStr, haystack, getter?) { const normalizedSearchStr = searchStr.toLowerCase(); // See if any haystack item contains a part of the search string - const itemsWithRankings: Array> = haystack.map(item => { + const itemsWithRankings: Array> = haystack.map((item) => { const itemStr: string = getter ? getter(item) : item; const normalizedItemStr = itemStr.toLowerCase(); @@ -966,14 +1101,14 @@ type sorterFn = (a: any, b: any) => number; function resolveGetter(getter: sorterGetterResolvable): sorterGetterFn { if (typeof getter === "string") { - return obj => obj[getter]; + return (obj) => obj[getter]; } return getter; } export function multiSorter(getters: Array): sorterFn { - const resolvedGetters: sorterGetterFnWithDirection[] = getters.map(getter => { + const resolvedGetters: sorterGetterFnWithDirection[] = getters.map((getter) => { if (Array.isArray(getter)) { return [resolveGetter(getter[0]), getter[1]] as sorterGetterFnWithDirection; } else { @@ -1154,7 +1289,7 @@ export function resolveUserId(bot: Client, value: string) { // A non-mention, full username? const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/); if (usernameMatch) { - const user = bot.users.cache.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); + const user = bot.users.cache.find((u) => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); if (user) return user.id; } @@ -1269,9 +1404,9 @@ export async function resolveRoleId(bot: Client, guildId: string, value: string) // Role name const roleList = (await bot.guilds.fetch(guildId as Snowflake)).roles.cache; - const role = roleList.filter(x => x.name.toLocaleLowerCase() === value.toLocaleLowerCase()); - if (role[0]) { - return role[0].id; + const role = roleList.filter((x) => x.name.toLocaleLowerCase() === value.toLocaleLowerCase()); + if (role.size >= 1) { + return role.firstKey(); } // Role ID @@ -1325,14 +1460,14 @@ export function messageSummary(msg: SavedMessage) { let result = "```\n" + (msg.data.content ? Util.escapeCodeBlock(msg.data.content) : "") + "```"; // Rich embed - const richEmbed = (msg.data.embeds || []).find(e => (e as MessageEmbed).type === "rich"); + const richEmbed = (msg.data.embeds || []).find((e) => (e as MessageEmbed).type === "rich"); if (richEmbed) result += "Embed:```" + Util.escapeCodeBlock(JSON.stringify(richEmbed)) + "```"; // Attachments - if (msg.data.attachments) { + if (msg.data.attachments && msg.data.attachments.length) { result += "Attachments:\n" + - msg.data.attachments.map((a: MessageAttachment) => disableLinkPreviews(a.url)).join("\n") + + msg.data.attachments.map((a: ISavedMessageAttachmentData) => disableLinkPreviews(a.url)).join("\n") + "\n"; } @@ -1355,7 +1490,7 @@ export function verboseUserName(user: User | UnknownUser): string { return `**${user.tag}** (\`${user.id}\`)`; } -export function verboseChannelMention(channel: GuildChannel): string { +export function verboseChannelMention(channel: GuildChannel | ThreadChannel): string { const plainTextName = channel.type === ChannelTypeStrings.VOICE || channel.type === ChannelTypeStrings.STAGE ? channel.name @@ -1453,7 +1588,7 @@ export function canUseEmoji(client: Client, emoji: string): boolean { return true; } else if (isSnowflake(emoji)) { for (const guild of client.guilds.cache) { - if (guild[1].emojis.cache.some(e => (e as any).id === emoji)) { + if (guild[1].emojis.cache.some((e) => (e as any).id === emoji)) { return true; } } @@ -1500,4 +1635,9 @@ export function unique(arr: T[]): T[] { return Array.from(new Set(arr)); } +// From https://github.com/microsoft/TypeScript/pull/29955#issuecomment-470062531 +export function isTruthy(value: T): value is Exclude { + return Boolean(value); +} + export const DBDateFormat = "YYYY-MM-DD HH:mm:ss"; diff --git a/backend/src/utils/DecayingCounter.ts b/backend/src/utils/DecayingCounter.ts new file mode 100644 index 00000000..baa59ae0 --- /dev/null +++ b/backend/src/utils/DecayingCounter.ts @@ -0,0 +1,21 @@ +/** + * This is not related to Zeppelin's counters feature + */ +export class DecayingCounter { + protected value = 0; + + constructor(protected decayInterval: number) { + setInterval(() => { + this.value = Math.max(0, this.value - 1); + }, decayInterval); + } + + add(count = 1): number { + this.value += count; + return this.value; + } + + get(): number { + return this.value; + } +} diff --git a/backend/src/utils/MessageBuffer.ts b/backend/src/utils/MessageBuffer.ts new file mode 100644 index 00000000..17022357 --- /dev/null +++ b/backend/src/utils/MessageBuffer.ts @@ -0,0 +1,151 @@ +import { StrictMessageContent } from "../utils"; +import Timeout = NodeJS.Timeout; +import { calculateEmbedSize } from "./calculateEmbedSize"; + +type ConsumeFn = (part: StrictMessageContent) => void; + +type ContentType = "mixed" | "plain" | "embeds"; + +export type MessageBufferContent = Pick; + +type Chunk = { + type: ContentType; + content: MessageBufferContent; +}; + +export interface MessageBufferOpts { + consume?: ConsumeFn; + timeout?: number; + textSeparator?: string; +} + +const MAX_CHARS_PER_MESSAGE = 2000; +const MAX_EMBED_LENGTH_PER_MESSAGE = 6000; +const MAX_EMBEDS_PER_MESSAGE = 10; + +/** + * Allows buffering and automatic partitioning of message contents. Useful for e.g. high volume log channels, message chunking, etc. + */ +export class MessageBuffer { + protected autoConsumeFn: ConsumeFn | null = null; + + protected timeoutMs: number | null = null; + + protected textSeparator: string = ""; + + protected chunk: Chunk | null = null; + + protected chunkTimeout: Timeout | null = null; + + protected finalizedChunks: MessageBufferContent[] = []; + + constructor(opts: MessageBufferOpts = {}) { + if (opts.consume) { + this.autoConsumeFn = opts.consume; + } + + if (opts.timeout) { + this.timeoutMs = opts.timeout; + } + + if (opts.textSeparator) { + this.textSeparator = opts.textSeparator; + } + } + + push(content: MessageBufferContent): void { + let contentType: ContentType; + if (content.content && !content.embeds?.length) { + contentType = "plain"; + } else if (content.embeds?.length && !content.content) { + contentType = "embeds"; + } else { + contentType = "mixed"; + } + + // Plain text can't be merged with mixed or embeds + if (contentType === "plain" && this.chunk && this.chunk.type !== "plain") { + this.startNewChunk(contentType); + } + // Mixed can't be merged at all + if (contentType === "mixed" && this.chunk) { + this.startNewChunk(contentType); + } + + if (!this.chunk) this.startNewChunk(contentType); + const chunk = this.chunk!; + + if (content.content) { + if (chunk.content.content && chunk.content.content.length + content.content.length > MAX_CHARS_PER_MESSAGE) { + this.startNewChunk(contentType); + } + + if (chunk.content.content == null || chunk.content.content === "") { + chunk.content.content = content.content; + } else { + chunk.content.content += this.textSeparator + content.content; + } + } + + if (content.embeds) { + if (chunk.content.embeds) { + if (chunk.content.embeds.length + content.embeds.length > MAX_EMBEDS_PER_MESSAGE) { + this.startNewChunk(contentType); + } else { + const existingEmbedsLength = chunk.content.embeds.reduce((sum, embed) => sum + calculateEmbedSize(embed), 0); + const embedsLength = content.embeds.reduce((sum, embed) => sum + calculateEmbedSize(embed), 0); + if (existingEmbedsLength + embedsLength > MAX_EMBED_LENGTH_PER_MESSAGE) { + this.startNewChunk(contentType); + } + } + } + + if (chunk.content.embeds == null) chunk.content.embeds = []; + chunk.content.embeds.push(...content.embeds); + } + } + + protected startNewChunk(type: ContentType): void { + if (this.chunk) { + this.finalizeChunk(); + } + this.chunk = { + type, + content: {}, + }; + if (this.timeoutMs) { + this.chunkTimeout = setTimeout(() => this.finalizeChunk(), this.timeoutMs); + } + } + + protected finalizeChunk(): void { + if (!this.chunk) return; + const chunk = this.chunk; + this.chunk = null; + + if (this.chunkTimeout) { + clearTimeout(this.chunkTimeout); + this.chunkTimeout = null; + } + + // Discard empty chunks + if (!chunk.content.content && !chunk.content.embeds?.length) return; + + if (this.autoConsumeFn) { + this.autoConsumeFn(chunk.content); + return; + } + + this.finalizedChunks.push(chunk.content); + } + + consume(): StrictMessageContent[] { + return Array.from(this.finalizedChunks); + this.finalizedChunks = []; + } + + finalizeAndConsume(): StrictMessageContent[] { + this.finalizeChunk(); + return this.consume(); + } +} diff --git a/backend/src/utils/calculateEmbedSize.ts b/backend/src/utils/calculateEmbedSize.ts new file mode 100644 index 00000000..124aa03b --- /dev/null +++ b/backend/src/utils/calculateEmbedSize.ts @@ -0,0 +1,17 @@ +import { MessageEmbedOptions } from "discord.js"; + +function sumStringLengthsRecursively(obj: any): number { + if (obj == null) return 0; + if (typeof obj === "string") return obj.length; + if (Array.isArray(obj)) { + return obj.reduce((sum, item) => sum + sumStringLengthsRecursively(item), 0); + } + if (typeof obj === "object") { + return Array.from(Object.values(obj)).reduce((sum: number, item) => sum + sumStringLengthsRecursively(item), 0); + } + return 0; +} + +export function calculateEmbedSize(embed: MessageEmbedOptions): number { + return sumStringLengthsRecursively(embed); +} diff --git a/backend/src/utils/configAccessibleObjects.ts b/backend/src/utils/configAccessibleObjects.ts deleted file mode 100644 index 2e80c7f0..00000000 --- a/backend/src/utils/configAccessibleObjects.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - Emoji, - GuildChannel, - GuildMember, - PartialGuildMember, - Role, - Snowflake, - StageInstance, - Sticker, - ThreadChannel, - User, -} from "discord.js"; -import { UnknownUser } from "src/utils"; -import { GuildPluginData } from "knub"; - -export interface IConfigAccessibleUser { - id: Snowflake | string; - username: string; - discriminator: string; - mention: string; - tag: string; - avatarURL?: string; - bot?: boolean; - createdAt?: number; -} - -export interface IConfigAccessibleRole { - id: Snowflake; - name: string; - createdAt: number; - hexColor: string; - hoist: boolean; -} - -export interface IConfigAccessibleMember extends IConfigAccessibleUser { - user: IConfigAccessibleUser; - nick: string; - roles: IConfigAccessibleRole[]; - joinedAt?: number; - // guildAvatarURL: string, Once DJS supports per-server avatars - guildName: string; -} - -export function userToConfigAccessibleUser(user: User | UnknownUser): IConfigAccessibleUser { - if (user.tag === "Unknown#0000") { - const toReturnPartial: IConfigAccessibleUser = { - id: user.id, - username: "Unknown", - discriminator: "0000", - mention: `<@${user.id}>`, - tag: "Unknown#0000", - }; - - return toReturnPartial; - } - - const properUser = user as User; - const toReturn: IConfigAccessibleUser = { - id: properUser.id, - username: properUser.username, - discriminator: properUser.discriminator, - mention: `<@${properUser.id}>`, - tag: properUser.tag, - avatarURL: properUser.displayAvatarURL({ dynamic: true }), - bot: properUser.bot, - createdAt: properUser.createdTimestamp, - }; - - return toReturn; -} - -export function roleToConfigAccessibleRole(role: Role): IConfigAccessibleRole { - const toReturn: IConfigAccessibleRole = { - id: role.id, - name: role.name, - createdAt: role.createdTimestamp, - hexColor: role.hexColor, - hoist: role.hoist, - }; - - return toReturn; -} - -export function memberToConfigAccessibleMember(member: GuildMember | PartialGuildMember): IConfigAccessibleMember { - const user = userToConfigAccessibleUser(member.user!); - - const toReturn: IConfigAccessibleMember = { - ...user, - user, - nick: member.nickname ?? "*None*", - roles: [...member.roles.cache.mapValues(r => roleToConfigAccessibleRole(r)).values()], - joinedAt: member.joinedTimestamp ?? undefined, - guildName: member.guild.name, - }; - - return toReturn; -} - -export interface IConfigAccessibleChannel { - id: Snowflake; - name: string; - mention: string; - parentId?: Snowflake; -} - -export function channelToConfigAccessibleChannel(channel: GuildChannel | ThreadChannel): IConfigAccessibleChannel { - const toReturn: IConfigAccessibleChannel = { - id: channel.id, - name: channel.name, - mention: `<#${channel.id}>`, - parentId: channel.parentId ?? undefined, - }; - - return toReturn; -} - -export interface IConfigAccessibleStage { - channelId: Snowflake; - channelMention: string; - createdAt: number; - discoverable: boolean; - topic: string; -} - -export function stageToConfigAccessibleStage(stage: StageInstance): IConfigAccessibleStage { - const toReturn: IConfigAccessibleStage = { - channelId: stage.channelId, - channelMention: `<#${stage.channelId}>`, - createdAt: stage.createdTimestamp, - discoverable: !stage.discoverableDisabled, - topic: stage.topic, - }; - - return toReturn; -} - -export interface IConfigAccessibleEmoji { - id: Snowflake; - name: string; - createdAt?: number; - animated: boolean; - identifier: string; -} - -export function emojiToConfigAccessibleEmoji(emoji: Emoji): IConfigAccessibleEmoji { - const toReturn: IConfigAccessibleEmoji = { - id: emoji.id!, - name: emoji.name!, - createdAt: emoji.createdTimestamp ?? undefined, - animated: emoji.animated ?? false, - identifier: emoji.identifier, - }; - - return toReturn; -} - -export interface IConfigAccessibleSticker { - id: Snowflake; - guildId?: Snowflake; - packId?: Snowflake; - name: string; - description: string; - tags: string; - format: string; - animated: boolean; - url: string; -} - -export function stickerToConfigAccessibleSticker(sticker: Sticker): IConfigAccessibleSticker { - const toReturn: IConfigAccessibleSticker = { - id: sticker.id, - guildId: sticker.guildId ?? undefined, - packId: sticker.packId ?? undefined, - name: sticker.name, - description: sticker.description ?? "", - tags: sticker.tags?.join(", ") ?? "", - format: sticker.format, - animated: sticker.format === "PNG" ? false : true, - url: sticker.url, - }; - - return toReturn; -} - -export function getConfigAccessibleMemberLevel( - pluginData: GuildPluginData, - member: IConfigAccessibleMember, -): number { - if (member.id === pluginData.guild.ownerId) { - return 99999; - } - - const levels = pluginData.fullConfig.levels ?? {}; - for (const [id, level] of Object.entries(levels)) { - if (member.id === id || member.roles?.find(r => r.id === id)) { - return level as number; - } - } - - return 0; -} diff --git a/backend/src/utils/crypt.test.ts b/backend/src/utils/crypt.test.ts index 38b381ca..54417c8b 100644 --- a/backend/src/utils/crypt.test.ts +++ b/backend/src/utils/crypt.test.ts @@ -1,7 +1,7 @@ import test from "ava"; import { decrypt, encrypt } from "./crypt"; -test("encrypt() followed by decrypt()", t => { +test("encrypt() followed by decrypt()", (t) => { const original = "banana 123 👀 💕"; // Includes emojis to verify utf8 stuff works const encrypted = encrypt(original); const decrypted = decrypt(encrypted); diff --git a/backend/src/utils/filterObject.ts b/backend/src/utils/filterObject.ts new file mode 100644 index 00000000..0456ebcb --- /dev/null +++ b/backend/src/utils/filterObject.ts @@ -0,0 +1,16 @@ +type FilterResult = { + [K in keyof T]?: T[K]; +}; + +/** + * Filter an object's properties based on its values and keys + * @return New object with filtered properties + */ +export function filterObject( + object: T, + filterFn: (value: T[K], key: K) => boolean, +): FilterResult { + return Object.fromEntries( + Object.entries(object).filter(([key, value]) => filterFn(value as any, key as keyof T)), + ) as FilterResult; +} diff --git a/backend/src/utils/isScalar.ts b/backend/src/utils/isScalar.ts new file mode 100644 index 00000000..b5d9eb64 --- /dev/null +++ b/backend/src/utils/isScalar.ts @@ -0,0 +1,3 @@ +export function isScalar(value: unknown): value is string | number | boolean | null | undefined { + return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} diff --git a/backend/src/utils/normalizeText.test.ts b/backend/src/utils/normalizeText.test.ts index 9c9c03be..cd5a139c 100644 --- a/backend/src/utils/normalizeText.test.ts +++ b/backend/src/utils/normalizeText.test.ts @@ -1,25 +1,25 @@ import test from "ava"; import { normalizeText } from "./normalizeText"; -test("Replaces special characters", t => { +test("Replaces special characters", (t) => { const from = "𝗧:regional_indicator_e:ᔕ7 𝗧:regional_indicator_e:ᔕ7 𝗧:regional_indicator_e:ᔕ7"; const to = "test test test"; t.deepEqual(normalizeText(from), to); }); -test("Does not change lowercase ASCII text", t => { +test("Does not change lowercase ASCII text", (t) => { const text = "lorem ipsum dolor sit amet consectetur adipiscing elit"; t.deepEqual(normalizeText(text), text); }); -test("Replaces whitespace", t => { +test("Replaces whitespace", (t) => { const from = "foo bar"; const to = "foo bar"; t.deepEqual(normalizeText(from), to); }); -test("Result is always lowercase", t => { +test("Result is always lowercase", (t) => { const from = "TEST"; const to = "test"; t.deepEqual(normalizeText(from), to); diff --git a/backend/src/utils/parseFuzzyTimezone.ts b/backend/src/utils/parseFuzzyTimezone.ts index ef570e26..d4989681 100644 --- a/backend/src/utils/parseFuzzyTimezone.ts +++ b/backend/src/utils/parseFuzzyTimezone.ts @@ -1,7 +1,7 @@ import escapeStringRegexp from "escape-string-regexp"; import moment from "moment-timezone"; -const normalizeTzName = str => str.replace(/[^a-zA-Z0-9+\-]/g, "").toLowerCase(); +const normalizeTzName = (str) => str.replace(/[^a-zA-Z0-9+\-]/g, "").toLowerCase(); const validTimezones = moment.tz.names(); const normalizedTimezoneMap = validTimezones.reduce((map, tz) => { diff --git a/backend/src/utils/safeFindRelevantAuditLogEntry.ts b/backend/src/utils/safeFindRelevantAuditLogEntry.ts index 8845e52e..717c9923 100644 --- a/backend/src/utils/safeFindRelevantAuditLogEntry.ts +++ b/backend/src/utils/safeFindRelevantAuditLogEntry.ts @@ -19,7 +19,7 @@ export async function safeFindRelevantAuditLogEntry( } catch (e) { if (isDiscordAPIError(e) && e.code === 50013) { const logs = pluginData.getPlugin(LogsPlugin); - logs.log(LogType.BOT_ALERT, { + logs.logBotAlert({ body: "Missing permissions to read audit log", }); return; diff --git a/backend/src/utils/tColor.ts b/backend/src/utils/tColor.ts index ccc4f6a3..f240f7f4 100644 --- a/backend/src/utils/tColor.ts +++ b/backend/src/utils/tColor.ts @@ -8,9 +8,9 @@ export const tColor = new t.Type( "tColor", (s): s is number => typeof s === "number", (from, to) => - either.chain(t.string.validate(from, to), input => { + either.chain(t.string.validate(from, to), (input) => { const parsedColor = parseColor(input); return parsedColor == null ? t.failure(from, to, "Invalid color") : t.success(rgbToInt(parsedColor)); }), - s => intToRgb(s).join(","), + (s) => intToRgb(s).join(","), ); diff --git a/backend/src/utils/tValidTimezone.ts b/backend/src/utils/tValidTimezone.ts index 43c61c3e..35fc97c5 100644 --- a/backend/src/utils/tValidTimezone.ts +++ b/backend/src/utils/tValidTimezone.ts @@ -6,8 +6,8 @@ export const tValidTimezone = new t.Type( "tValidTimezone", (s): s is string => typeof s === "string", (from, to) => - either.chain(t.string.validate(from, to), input => { + either.chain(t.string.validate(from, to), (input) => { return isValidTimezone(input) ? t.success(input) : t.failure(from, to, `Invalid timezone: ${input}`); }), - s => s, + (s) => s, ); diff --git a/backend/src/utils/templateSafeObjects.ts b/backend/src/utils/templateSafeObjects.ts new file mode 100644 index 00000000..d57e1fca --- /dev/null +++ b/backend/src/utils/templateSafeObjects.ts @@ -0,0 +1,468 @@ +import { + Emoji, + Guild, + GuildChannel, + GuildMember, + Message, + PartialGuildMember, + Role, + Snowflake, + StageInstance, + Sticker, + ThreadChannel, + User, +} from "discord.js"; +import { UnknownUser } from "src/utils"; +import { GuildPluginData } from "knub"; +import { TemplateSafeValueContainer, TypedTemplateSafeValueContainer } from "../templateFormatter"; +import { + ISavedMessageAttachmentData, + ISavedMessageData, + ISavedMessageEmbedData, + ISavedMessageStickerData, + SavedMessage, +} from "../data/entities/SavedMessage"; +import { Case } from "../data/entities/Case"; + +type InputProps = Omit< + { + [K in keyof T]: T[K]; + }, + "_isTemplateSafeValueContainer" +>; + +export class TemplateSafeGuild extends TemplateSafeValueContainer { + id: Snowflake; + name: string; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeUser extends TemplateSafeValueContainer { + id: Snowflake | string; + username: string; + discriminator: string; + mention: string; + tag: string; + avatarURL?: string; + bot?: boolean; + createdAt?: number; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeUnknownUser extends TemplateSafeValueContainer { + id: Snowflake; + username: string; + discriminator: string; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeRole extends TemplateSafeValueContainer { + id: Snowflake; + name: string; + createdAt: number; + hexColor: string; + hoist: boolean; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeMember extends TemplateSafeUser { + user: TemplateSafeUser; + nick: string; + roles: TemplateSafeRole[]; + joinedAt?: number; + // guildAvatarURL: string, Once DJS supports per-server avatars + guildName: string; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeUnknownMember extends TemplateSafeUnknownUser { + user: TemplateSafeUnknownUser; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeChannel extends TemplateSafeValueContainer { + id: Snowflake; + name: string; + mention: string; + parentId?: Snowflake; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeStage extends TemplateSafeValueContainer { + channelId: Snowflake; + channelMention: string; + createdAt: number; + discoverable: boolean; + topic: string; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeEmoji extends TemplateSafeValueContainer { + id: Snowflake; + name: string; + createdAt?: number; + animated: boolean; + identifier: string; + mention: string; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeSticker extends TemplateSafeValueContainer { + id: Snowflake; + guildId?: Snowflake; + packId?: Snowflake; + name: string; + description: string; + tags: string; + format: string; + animated: boolean; + url: string; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeSavedMessage extends TemplateSafeValueContainer { + id: string; + guild_id: string; + channel_id: string; + user_id: string; + is_bot: boolean; + data: TemplateSafeSavedMessageData; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeSavedMessageData extends TemplateSafeValueContainer { + attachments?: Array>; + author: TypedTemplateSafeValueContainer<{ + username: string; + discriminator: string; + }>; + content: string; + embeds?: Array>; + stickers?: Array>; + timestamp: number; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeCase extends TemplateSafeValueContainer { + id: number; + guild_id: string; + case_number: number; + user_id: string; + user_name: string; + mod_id: string | null; + mod_name: string | null; + type: number; + audit_log_id: string | null; + created_at: string; + is_hidden: boolean; + pp_id: string | null; + pp_name: string | null; + log_message_id: string | null; + + constructor(data: InputProps) { + super(data); + } +} + +export class TemplateSafeMessage extends TemplateSafeValueContainer { + id: string; + content: string; + author: TemplateSafeUser; + channel: TemplateSafeChannel; + + constructor(data: InputProps) { + super(data); + } +} + +// =================== +// CONVERTER FUNCTIONS +// =================== + +export function guildToTemplateSafeGuild(guild: Guild): TemplateSafeGuild { + return new TemplateSafeGuild({ + id: guild.id, + name: guild.name, + }); +} + +export function userToTemplateSafeUser(user: User | UnknownUser): TemplateSafeUser { + if (user instanceof UnknownUser) { + return new TemplateSafeUser({ + id: user.id, + username: "Unknown", + discriminator: "0000", + mention: `<@${user.id}>`, + tag: "Unknown#0000", + }); + } + + return new TemplateSafeUser({ + id: user.id, + username: user.username, + discriminator: user.discriminator, + mention: `<@${user.id}>`, + tag: user.tag, + avatarURL: user.displayAvatarURL?.({ dynamic: true }), + bot: user.bot, + createdAt: user.createdTimestamp, + }); +} + +export function roleToTemplateSafeRole(role: Role): TemplateSafeRole { + return new TemplateSafeRole({ + id: role.id, + name: role.name, + createdAt: role.createdTimestamp, + hexColor: role.hexColor, + hoist: role.hoist, + }); +} + +export function memberToTemplateSafeMember(member: GuildMember | PartialGuildMember): TemplateSafeMember { + const templateSafeUser = userToTemplateSafeUser(member.user!); + + return new TemplateSafeMember({ + ...templateSafeUser, + user: templateSafeUser, + nick: member.nickname ?? "*None*", + roles: [...member.roles.cache.mapValues((r) => roleToTemplateSafeRole(r)).values()], + joinedAt: member.joinedTimestamp ?? undefined, + guildName: member.guild.name, + }); +} + +export function channelToTemplateSafeChannel(channel: GuildChannel | ThreadChannel): TemplateSafeChannel { + return new TemplateSafeChannel({ + id: channel.id, + name: channel.name, + mention: `<#${channel.id}>`, + parentId: channel.parentId ?? undefined, + }); +} + +export function stageToTemplateSafeStage(stage: StageInstance): TemplateSafeStage { + return new TemplateSafeStage({ + channelId: stage.channelId, + channelMention: `<#${stage.channelId}>`, + createdAt: stage.createdTimestamp, + discoverable: !stage.discoverableDisabled, + topic: stage.topic, + }); +} + +export function emojiToTemplateSafeEmoji(emoji: Emoji): TemplateSafeEmoji { + return new TemplateSafeEmoji({ + id: emoji.id!, + name: emoji.name!, + createdAt: emoji.createdTimestamp ?? undefined, + animated: emoji.animated ?? false, + identifier: emoji.identifier, + mention: emoji.animated ? `` : `<:${emoji.name}:${emoji.id}>`, + }); +} + +export function stickerToTemplateSafeSticker(sticker: Sticker): TemplateSafeSticker { + return new TemplateSafeSticker({ + id: sticker.id, + guildId: sticker.guildId ?? undefined, + packId: sticker.packId ?? undefined, + name: sticker.name, + description: sticker.description ?? "", + tags: sticker.tags?.join(", ") ?? "", + format: sticker.format, + animated: sticker.format === "PNG" ? false : true, + url: sticker.url, + }); +} + +export function savedMessageToTemplateSafeSavedMessage(savedMessage: SavedMessage): TemplateSafeSavedMessage { + return new TemplateSafeSavedMessage({ + id: savedMessage.id, + channel_id: savedMessage.channel_id, + guild_id: savedMessage.guild_id, + is_bot: savedMessage.is_bot, + user_id: savedMessage.user_id, + + data: new TemplateSafeSavedMessageData({ + attachments: (savedMessage.data.attachments ?? []).map( + (att) => + new TemplateSafeValueContainer({ + id: att.id, + contentType: att.contentType, + name: att.name, + proxyURL: att.proxyURL, + size: att.size, + spoiler: att.spoiler, + url: att.url, + width: att.width, + }) as TypedTemplateSafeValueContainer, + ), + + author: new TemplateSafeValueContainer({ + username: savedMessage.data.author.username, + discriminator: savedMessage.data.author.discriminator, + }) as TypedTemplateSafeValueContainer, + + content: savedMessage.data.content, + + embeds: (savedMessage.data.embeds ?? []).map( + (embed) => + new TemplateSafeValueContainer({ + title: embed.title, + description: embed.description, + url: embed.url, + timestamp: embed.timestamp, + color: embed.color, + + fields: (embed.fields ?? []).map( + (field) => + new TemplateSafeValueContainer({ + name: field.name, + value: field.value, + inline: field.inline, + }), + ), + + author: embed.author + ? new TemplateSafeValueContainer({ + name: embed.author?.name, + url: embed.author?.url, + iconURL: embed.author?.iconURL, + proxyIconURL: embed.author?.proxyIconURL, + }) + : undefined, + + thumbnail: embed.thumbnail + ? new TemplateSafeValueContainer({ + url: embed.thumbnail?.url, + proxyURL: embed.thumbnail?.url, + height: embed.thumbnail?.height, + width: embed.thumbnail?.width, + }) + : undefined, + + image: embed.image + ? new TemplateSafeValueContainer({ + url: embed.image?.url, + proxyURL: embed.image?.url, + height: embed.image?.height, + width: embed.image?.width, + }) + : undefined, + + video: embed.video + ? new TemplateSafeValueContainer({ + url: embed.video?.url, + proxyURL: embed.video?.url, + height: embed.video?.height, + width: embed.video?.width, + }) + : undefined, + + footer: embed.footer + ? new TemplateSafeValueContainer({ + text: embed.footer.text, + iconURL: embed.footer.iconURL, + proxyIconURL: embed.footer.proxyIconURL, + }) + : undefined, + }) as TypedTemplateSafeValueContainer, + ), + + stickers: (savedMessage.data.stickers ?? []).map( + (sticker) => + new TemplateSafeValueContainer({ + format: sticker.format, + guildId: sticker.guildId, + id: sticker.id, + name: sticker.name, + description: sticker.description, + available: sticker.available, + type: sticker.type, + }) as TypedTemplateSafeValueContainer, + ), + + timestamp: savedMessage.data.timestamp, + }), + }); +} + +export function caseToTemplateSafeCase(theCase: Case): TemplateSafeCase { + return new TemplateSafeCase({ + id: theCase.id, + guild_id: theCase.guild_id, + case_number: theCase.case_number, + user_id: theCase.user_id, + user_name: theCase.user_name, + mod_id: theCase.mod_id, + mod_name: theCase.mod_name, + type: theCase.type, + audit_log_id: theCase.audit_log_id, + created_at: theCase.created_at, + is_hidden: theCase.is_hidden, + pp_id: theCase.pp_id, + pp_name: theCase.pp_name, + log_message_id: theCase.log_message_id, + }); +} + +export function messageToTemplateSafeMessage(message: Message): TemplateSafeMessage { + return new TemplateSafeMessage({ + id: message.id, + content: message.content, + author: userToTemplateSafeUser(message.author), + channel: channelToTemplateSafeChannel(message.channel as GuildChannel | ThreadChannel), + }); +} + +export function getTemplateSafeMemberLevel(pluginData: GuildPluginData, member: TemplateSafeMember): number { + if (member.id === pluginData.guild.ownerId) { + return 99999; + } + + const levels = pluginData.fullConfig.levels ?? {}; + for (const [id, level] of Object.entries(levels)) { + if (member.id === id || member.roles?.find((r) => r.id === id)) { + return level as number; + } + } + + return 0; +} diff --git a/backend/src/utils/typeUtils.ts b/backend/src/utils/typeUtils.ts index 619f6cec..a32aa0e1 100644 --- a/backend/src/utils/typeUtils.ts +++ b/backend/src/utils/typeUtils.ts @@ -1,8 +1,7 @@ // From https://stackoverflow.com/a/56370310/316944 export type Tail = ((...t: T) => void) extends (h: any, ...r: infer R) => void ? R : never; -export declare type WithRequiredProps = T & - { - // https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript#removing-the-mapped-type-modifier - [PK in K]-?: Exclude; - }; +export declare type WithRequiredProps = T & { + // https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript#removing-the-mapped-type-modifier + [PK in K]-?: Exclude; +}; diff --git a/backend/src/utils/validateNoObjectAliases.test.ts b/backend/src/utils/validateNoObjectAliases.test.ts index fe2ff6bf..b418f72b 100644 --- a/backend/src/utils/validateNoObjectAliases.test.ts +++ b/backend/src/utils/validateNoObjectAliases.test.ts @@ -1,7 +1,7 @@ import test from "ava"; import { ObjectAliasError, validateNoObjectAliases } from "./validateNoObjectAliases"; -test("validateNoObjectAliases() disallows object aliases at top level", t => { +test("validateNoObjectAliases() disallows object aliases at top level", (t) => { const obj: any = { objectRef: { foo: "bar", @@ -12,7 +12,7 @@ test("validateNoObjectAliases() disallows object aliases at top level", t => { t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError }); }); -test("validateNoObjectAliases() disallows aliases to nested objects", t => { +test("validateNoObjectAliases() disallows aliases to nested objects", (t) => { const obj: any = { nested: { objectRef: { @@ -25,7 +25,7 @@ test("validateNoObjectAliases() disallows aliases to nested objects", t => { t.throws(() => validateNoObjectAliases(obj), { instanceOf: ObjectAliasError }); }); -test("validateNoObjectAliases() disallows nested object aliases", t => { +test("validateNoObjectAliases() disallows nested object aliases", (t) => { const obj: any = { nested: { objectRef: { diff --git a/backend/src/utils/waitForInteraction.ts b/backend/src/utils/waitForInteraction.ts index afc3641f..d39467c9 100644 --- a/backend/src/utils/waitForInteraction.ts +++ b/backend/src/utils/waitForInteraction.ts @@ -1,24 +1,25 @@ import { MessageActionRow, MessageButton, MessageComponentInteraction, MessageOptions, TextChannel } from "discord.js"; import { noop } from "knub/dist/utils"; import moment from "moment"; +import uuidv4 from "uuid/v4"; export async function waitForButtonConfirm( channel: TextChannel, toPost: MessageOptions, options?: WaitForOptions, ): Promise { - return new Promise(async resolve => { + return new Promise(async (resolve) => { const idMod = `${channel.guild.id}-${moment.utc().valueOf()}`; const row = new MessageActionRow().addComponents([ new MessageButton() .setStyle("SUCCESS") .setLabel(options?.confirmText || "Confirm") - .setCustomId(`confirmButton:${idMod}`), + .setCustomId(`confirmButton:${idMod}:${uuidv4()}`), new MessageButton() .setStyle("DANGER") .setLabel(options?.cancelText || "Cancel") - .setCustomId(`cancelButton:${idMod}`), + .setCustomId(`cancelButton:${idMod}:${uuidv4()}`), ]); const message = await channel.send({ ...toPost, components: [row] }); @@ -28,10 +29,10 @@ export async function waitForButtonConfirm( if (options?.restrictToId && options.restrictToId !== interaction.user.id) { interaction.reply({ content: `You are not permitted to use these buttons.`, ephemeral: true }); } else { - if (interaction.customId === `confirmButton:${idMod}`) { + if (interaction.customId.startsWith(`confirmButton:${idMod}:`)) { message.delete(); resolve(true); - } else if (interaction.customId === `cancelButton:${idMod}`) { + } else if (interaction.customId.startsWith(`cancelButton:${idMod}:`)) { message.delete(); resolve(false); } diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts index 795e67ff..d41d6c4a 100644 --- a/backend/src/validation.test.ts +++ b/backend/src/validation.test.ts @@ -3,7 +3,7 @@ import * as t from "io-ts"; import { tDeepPartial } from "./utils"; import * as validatorUtils from "./validatorUtils"; -test("tDeepPartial works", ava => { +test("tDeepPartial works", (ava) => { const originalSchema = t.type({ listOfThings: t.record( t.string, diff --git a/backend/src/validatorUtils.ts b/backend/src/validatorUtils.ts index 8a6863f9..7b75637c 100644 --- a/backend/src/validatorUtils.ts +++ b/backend/src/validatorUtils.ts @@ -25,7 +25,7 @@ export const TRegex = new t.Type( "TRegex", (s): s is RegExp => s instanceof RegExp, (from, to) => - either.chain(t.string.validate(from, to), s => { + either.chain(t.string.validate(from, to), (s) => { try { return t.success(inputPatternToRegExp(s)); } catch (err) { @@ -36,7 +36,7 @@ export const TRegex = new t.Type( throw err; } }), - s => `/${s.source}/${s.flags}`, + (s) => `/${s.source}/${s.flags}`, ); // From io-ts/lib/PathReporter @@ -57,7 +57,7 @@ function stringify(v) { // tslint:disable function getContextPath(context) { return context - .map(function(_a) { + .map(function (_a) { var key = _a.key, type = _a.type; return key + ": " + type.name; @@ -80,8 +80,8 @@ export class StrictValidationError extends Error { } const report = fold((errors: any): StrictValidationError | void => { - const errorStrings = errors.map(err => { - const context = err.context.map(c => c.key).filter(k => k && !k.startsWith("{")); + const errorStrings = errors.map((err) => { + const context = err.context.map((c) => c.key).filter((k) => k && !k.startsWith("{")); while (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1); const value = stringify(err.value); @@ -99,8 +99,8 @@ export function validate(schema: t.Type, value: any): StrictValidationError pipe( validationResult, fold( - err => report(validationResult), - result => null, + (err) => report(validationResult), + (result) => null, ), ) || null ); @@ -119,8 +119,8 @@ export function decodeAndValidateStrict( return pipe( validationResult, fold( - err => report(validationResult), - result => { + (err) => report(validationResult), + (result) => { // Make sure there are no extra properties if (debug) { console.log( @@ -133,7 +133,7 @@ export function decodeAndValidateStrict( } if (JSON.stringify(value) !== JSON.stringify(result)) { const diff = deepDiff(result, value); - const errors = diff.filter(d => d.kind === "N").map(d => `Unknown property <${d.path.join(".")}>`); + const errors = diff.filter((d) => d.kind === "N").map((d) => `Unknown property <${d.path.join(".")}>`); if (errors.length) return new StrictValidationError(errors); } diff --git a/backend/start-dev.js b/backend/start-dev.js index 5b6c3b44..70c912f2 100644 --- a/backend/start-dev.js +++ b/backend/start-dev.js @@ -5,9 +5,7 @@ const childProcess = require("child_process"); -const cmd = process.platform === "win32" - ? "npm.cmd" - : "npm"; +const cmd = process.platform === "win32" ? "npm.cmd" : "npm"; childProcess.spawn(cmd, ["run", "start-bot-dev"], { stdio: [process.stdin, process.stdout, process.stderr], diff --git a/backend/tsconfig.json b/backend/tsconfig.json index d20c0579..23f838e2 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -7,17 +7,13 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "target": "es2020", - "lib": [ - "esnext" - ], + "lib": ["esnext"], "baseUrl": ".", "resolveJsonModule": true, "esModuleInterop": true, "outDir": "./dist", "paths": { - "@shared/*": [ - "../shared/src/*" - ] + "@shared/*": ["../shared/src/*"] }, "sourceMap": true, "alwaysStrict": true, @@ -26,7 +22,5 @@ "strict": true, "strictPropertyInitialization": false }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/dashboard/.htmlnanorc.js b/dashboard/.htmlnanorc.js index 7aeaf2ab..5dff0968 100644 --- a/dashboard/.htmlnanorc.js +++ b/dashboard/.htmlnanorc.js @@ -1,3 +1,3 @@ module.exports = { - collapseWhitespace: false + collapseWhitespace: false, }; diff --git a/dashboard/.prettierignore b/dashboard/.prettierignore new file mode 100644 index 00000000..9b1c8b13 --- /dev/null +++ b/dashboard/.prettierignore @@ -0,0 +1 @@ +/dist diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index f395a81f..7aeb3c64 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "dependencies": { "highlight.js": "^9.15.10", + "humanize-duration": "^3.27.0", "js-yaml": "^3.13.1", "marked": "^0.7.0", "modern-css-reset": "^1.0.4", + "moment": "^2.29.1", "vue": "^2.6.10", "vue-highlightjs": "git://github.com/Dragory/vue-highlightjs.git#pass-hljs-instance", "vue-material-design-icons": "^4.1.0", @@ -6038,6 +6040,11 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "node_modules/humanize-duration": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.0.tgz", + "integrity": "sha512-qLo/08cNc3Tb0uD7jK0jAcU5cnqCM0n568918E7R2XhMr/+7F37p4EY062W/stg7tmzvknNn9b/1+UhVRzsYrQ==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -7164,6 +7171,14 @@ "resolved": "https://registry.npmjs.org/modern-css-reset/-/modern-css-reset-1.4.0.tgz", "integrity": "sha512-0crZmSFmrxkI7159rvQWjpDhy0u4+Awg/iOycJdlVn0RSeft/a+6BrQHR3IqvmdK25sqt0o6Z5Ap7cWgUee2rw==" }, + "node_modules/moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "engines": { + "node": "*" + } + }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -17836,6 +17851,11 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "humanize-duration": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.0.tgz", + "integrity": "sha512-qLo/08cNc3Tb0uD7jK0jAcU5cnqCM0n568918E7R2XhMr/+7F37p4EY062W/stg7tmzvknNn9b/1+UhVRzsYrQ==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -18685,6 +18705,11 @@ "resolved": "https://registry.npmjs.org/modern-css-reset/-/modern-css-reset-1.4.0.tgz", "integrity": "sha512-0crZmSFmrxkI7159rvQWjpDhy0u4+Awg/iOycJdlVn0RSeft/a+6BrQHR3IqvmdK25sqt0o6Z5Ap7cWgUee2rw==" }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 01b2e226..2a3ae71f 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -39,9 +39,11 @@ }, "dependencies": { "highlight.js": "^9.15.10", + "humanize-duration": "^3.27.0", "js-yaml": "^3.13.1", "marked": "^0.7.0", "modern-css-reset": "^1.0.4", + "moment": "^2.29.1", "vue": "^2.6.10", "vue-highlightjs": "git://github.com/Dragory/vue-highlightjs.git#pass-hljs-instance", "vue-material-design-icons": "^4.1.0", diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 5782aa47..2a5fa858 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -22,13 +22,13 @@ function buildQueryString(params: QueryParamObject) { return ( "?" + Array.from(Object.entries(params)) - .map(pair => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1] || "")}`) + .map((pair) => `${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1] || "")}`) .join("&") ); } export function request(resource, fetchOpts: RequestInit = {}) { - return fetch(`${apiUrl}/${resource}`, fetchOpts).then(async res => { + return fetch(`${apiUrl}/${resource}`, fetchOpts).then(async (res) => { if (!res.ok) { if (res.status === 401) { RootStore.dispatch("auth/expiredLogin"); diff --git a/dashboard/src/auth.ts b/dashboard/src/auth.ts index 3dd1efb7..bb5c1b9e 100644 --- a/dashboard/src/auth.ts +++ b/dashboard/src/auth.ts @@ -16,8 +16,8 @@ export const authGuard: NavigationGuard = async (to, from, next) => { export const loginCallbackGuard: NavigationGuard = async (to, from, next) => { if (to.query.apiKey) { - await RootStore.dispatch("auth/setApiKey", to.query.apiKey); - next("/dashboard"); + await RootStore.dispatch("auth/setApiKey", { key: to.query.apiKey }); + window.location.href = "/dashboard"; } else { window.location.href = `/?error=noAccess`; } diff --git a/dashboard/src/components/dashboard/GuildAccess.vue b/dashboard/src/components/dashboard/GuildAccess.vue index e19acde1..c4aa9b9d 100644 --- a/dashboard/src/components/dashboard/GuildAccess.vue +++ b/dashboard/src/components/dashboard/GuildAccess.vue @@ -1,70 +1,182 @@ diff --git a/dashboard/src/components/dashboard/GuildConfigEditor.vue b/dashboard/src/components/dashboard/GuildConfigEditor.vue index e6f780e2..191892ad 100644 --- a/dashboard/src/components/dashboard/GuildConfigEditor.vue +++ b/dashboard/src/components/dashboard/GuildConfigEditor.vue @@ -24,7 +24,6 @@ @init="editorInit" lang="yaml" theme="tomorrow_night" - :width="editorWidth" :height="editorHeight" ref="aceEditor" /> @@ -38,6 +37,7 @@ import AceEditor from "vue2-ace-editor"; let editorKeybindListener; + let windowResizeListener; export default { components: { @@ -69,6 +69,12 @@ window.removeEventListener("keydown", editorKeybindListener); editorKeybindListener = null; } + + if (windowResizeListener) { + window.removeEventListener("resize", windowResizeListener); + windowResizeListener = null; + } + next(); }, data() { @@ -105,6 +111,7 @@ tabSize: 2 }); + // Add Ctrl+S/Cmd+S save shortcut const isMac = /mac/i.test(navigator.platform); const modKeyPressed = (ev: KeyboardEvent) => (isMac ? ev.metaKey : ev.ctrlKey); const nonModKeyPressed = (ev: KeyboardEvent) => (isMac ? ev.ctrlKey : ev.metaKey); @@ -130,7 +137,24 @@ }; window.addEventListener("keydown", editorKeybindListener); + // Auto-fit editor to window this.fitEditorToWindow(); + + if (windowResizeListener) { + window.removeEventListener("resize", windowResizeListener); + } + + let debounceTimeout; + windowResizeListener = (ev: UIEvent) => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + + debounceTimeout = setTimeout(() => { + this.fitEditorToWindow(); + }, 350); + }; + window.addEventListener("resize", windowResizeListener); }, fitEditorToWindow() { const mainContainer = document.querySelector('.dashboard'); diff --git a/dashboard/src/components/dashboard/GuildList.vue b/dashboard/src/components/dashboard/GuildList.vue index 3b126ce6..2842d7f3 100644 --- a/dashboard/src/components/dashboard/GuildList.vue +++ b/dashboard/src/components/dashboard/GuildList.vue @@ -17,9 +17,8 @@
{{ guild.id }}
- Info Config - Access + Access
@@ -28,12 +27,15 @@ - diff --git a/dashboard/src/directives/trim-indents.ts b/dashboard/src/directives/trim-indents.ts index 79f61ba3..eff9b92e 100644 --- a/dashboard/src/directives/trim-indents.ts +++ b/dashboard/src/directives/trim-indents.ts @@ -19,7 +19,7 @@ Vue.directive("trim-indents", { el.innerHTML = withoutStartEndWhitespace .split("\n") - .map(line => line.slice(spacesToTrim)) + .map((line) => line.slice(spacesToTrim)) .join("\n"); }, }); diff --git a/dashboard/src/index.html b/dashboard/src/index.html index b1be5ddf..7151c5b0 100644 --- a/dashboard/src/index.html +++ b/dashboard/src/index.html @@ -1,18 +1,20 @@ - + - - - - - Zeppelin - Moderation bot for Discord - - - + + + + + Zeppelin - Moderation bot for Discord + + + -
- +
+ diff --git a/dashboard/src/splash.html b/dashboard/src/splash.html index 4b2abb9f..18da7ee9 100644 --- a/dashboard/src/splash.html +++ b/dashboard/src/splash.html @@ -2,7 +2,7 @@
- +

Zeppelin

diff --git a/dashboard/src/store/auth.ts b/dashboard/src/store/auth.ts index 4e24658c..457284b3 100644 --- a/dashboard/src/store/auth.ts +++ b/dashboard/src/store/auth.ts @@ -12,6 +12,7 @@ export const AuthStore: Module = { apiKey: null, loadedInitialAuth: false, authRefreshInterval: null, + userId: null, }, actions: { @@ -23,7 +24,7 @@ export const AuthStore: Module = { try { const result = await post("auth/validate-key", { key: storedKey }); if (result.valid) { - await dispatch("setApiKey", storedKey); + await dispatch("setApiKey", { key: storedKey, userId: result.userId }); return; } } catch {} // tslint:disable-line @@ -35,9 +36,9 @@ export const AuthStore: Module = { commit("markInitialAuthLoaded"); }, - setApiKey({ commit, state, dispatch }, newKey: string) { - localStorage.setItem("apiKey", newKey); - commit("setApiKey", newKey); + setApiKey({ commit, state, dispatch }, { key, userId }) { + localStorage.setItem("apiKey", key); + commit("setApiKey", { key, userId }); dispatch("startAuthAutoRefresh"); }, @@ -64,7 +65,7 @@ export const AuthStore: Module = { await dispatch("endAuthAutoRefresh"); localStorage.removeItem("apiKey"); - commit("setApiKey", null); + commit("setApiKey", { key: null, userId: null }); }, async logout({ dispatch }) { @@ -79,8 +80,9 @@ export const AuthStore: Module = { }, mutations: { - setApiKey(state: AuthState, key) { + setApiKey(state: AuthState, { key, userId }) { state.apiKey = key; + state.userId = userId; }, setAuthRefreshInterval(state: AuthState, interval: IntervalType | null) { diff --git a/dashboard/src/store/guilds.ts b/dashboard/src/store/guilds.ts index ce9306dc..10c2eb70 100644 --- a/dashboard/src/store/guilds.ts +++ b/dashboard/src/store/guilds.ts @@ -11,7 +11,6 @@ export const GuildStore: Module = { availableGuildsLoadStatus: LoadStatus.None, available: new Map(), configs: {}, - myPermissions: {}, guildPermissionAssignments: {}, }, @@ -48,9 +47,14 @@ export const GuildStore: Module = { await post(`guilds/${guildId}/config`, { config }); }, - async checkPermission({ commit }, { guildId, permission }) { - const result = await post(`guilds/${guildId}/check-permission`, { permission }); - commit("setMyPermission", { guildId, permission, value: result.result }); + async loadMyPermissionAssignments({ commit }) { + const myPermissionAssignments = await get(`guilds/my-permissions`); + for (const permissionAssignment of myPermissionAssignments) { + commit("setGuildPermissionAssignments", { + guildId: permissionAssignment.guild_id, + permissionAssignments: [permissionAssignment], + }); + } }, async loadGuildPermissionAssignments({ commit }, guildId) { @@ -58,8 +62,9 @@ export const GuildStore: Module = { commit("setGuildPermissionAssignments", { guildId, permissionAssignments }); }, - async setTargetPermissions({ commit }, { guildId, targetId, type, permissions }) { - commit("setTargetPermissions", { guildId, targetId, type, permissions }); + async setTargetPermissions({ commit }, { guildId, targetId, type, permissions, expiresAt }) { + await post(`guilds/${guildId}/set-target-permissions`, { guildId, targetId, type, permissions, expiresAt }); + commit("setTargetPermissions", { guildId, targetId, type, permissions, expiresAt }); }, }, @@ -77,28 +82,44 @@ export const GuildStore: Module = { Vue.set(state.configs, guildId, config); }, - setMyPermission(state: GuildState, { guildId, permission, value }) { - Vue.set(state.myPermissions, guildId, state.myPermissions[guildId] || {}); - Vue.set(state.myPermissions[guildId], permission, value); - }, - setGuildPermissionAssignments(state: GuildState, { guildId, permissionAssignments }) { + if (!state.guildPermissionAssignments) { + Vue.set(state, "guildPermissionAssignments", {}); + } + Vue.set( state.guildPermissionAssignments, guildId, - permissionAssignments.map(p => ({ + permissionAssignments.map((p) => ({ ...p, permissions: new Set(p.permissions), })), ); }, - setTargetPermissions(state: GuildState, { guildId, targetId, type, permissions }) { + setTargetPermissions(state: GuildState, { guildId, targetId, type, permissions, expiresAt }) { const guildPermissionAssignments = state.guildPermissionAssignments[guildId] || []; - const itemToEdit = guildPermissionAssignments.find(p => p.target_id === targetId && p.type === type); - if (!itemToEdit) return; + if (permissions.length === 0) { + // No permissions -> remove permission assignment + guildPermissionAssignments.splice( + guildPermissionAssignments.findIndex((p) => p.target_id === targetId && p.type === type), + 1, + ); + } else { + // Update/add permission assignment + const itemToEdit = guildPermissionAssignments.find((p) => p.target_id === targetId && p.type === type); + if (itemToEdit) { + itemToEdit.permissions = new Set(permissions); + } else { + state.guildPermissionAssignments[guildId].push({ + type, + target_id: targetId, + permissions: new Set(permissions), + expires_at: expiresAt, + }); + } + } - itemToEdit.permissions = permissions; state.guildPermissionAssignments = { ...state.guildPermissionAssignments }; }, }, diff --git a/dashboard/src/store/types.ts b/dashboard/src/store/types.ts index b11193aa..c7e2987b 100644 --- a/dashboard/src/store/types.ts +++ b/dashboard/src/store/types.ts @@ -1,5 +1,4 @@ import { ApiPermissions } from "@shared/apiPermissions"; -import { ApiPermissionTypes } from "../../../backend/src/data/ApiPermissionAssignments"; export enum LoadStatus { None = 1, @@ -14,6 +13,14 @@ export interface AuthState { apiKey: string | null; loadedInitialAuth: boolean; authRefreshInterval: IntervalType | null; + userId: string | null; +} + +export interface GuildPermissionAssignment { + type: string; + target_id: string; + permissions: Set; + expires_at: string | null; } export interface GuildState { @@ -29,17 +36,8 @@ export interface GuildState { configs: { [key: string]: string; }; - myPermissions: { - [guildId: string]: { - [K in ApiPermissions]?: boolean; - }; - }; guildPermissionAssignments: { - [guildId: string]: Array<{ - target_id: string; - type: ApiPermissionTypes; - permissions: Set; - }>; + [guildId: string]: GuildPermissionAssignment[]; }; } diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js index 800b9f1a..d3fbd401 100644 --- a/dashboard/tailwind.config.js +++ b/dashboard/tailwind.config.js @@ -3,22 +3,22 @@ module.exports = { theme: { extend: { lineHeight: { - zero: '0' + zero: "0", }, flex: { - full: '0 0 100%', - flexible: '1 1 0' - } + full: "0 0 100%", + flexible: "1 1 0", + }, }, screens: { - sm: '640px', - md: '768px', - 'until-lg': { max: '1023px' }, - lg: '1024px', - xl: '1280px', - '2xl': '1536px' - } + sm: "640px", + md: "768px", + "until-lg": { max: "1023px" }, + lg: "1024px", + xl: "1280px", + "2xl": "1536px", + }, }, variants: {}, - plugins: [] -} + plugins: [], +}; diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json index 42745a2a..1bd1fd32 100644 --- a/dashboard/tsconfig.json +++ b/dashboard/tsconfig.json @@ -9,10 +9,7 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "strict": false, - "lib": [ - "esnext", - "dom" - ], + "lib": ["esnext", "dom"], "baseUrl": ".", "resolveJsonModule": true, "esModuleInterop": true, diff --git a/dashboard/webpack.config.js b/dashboard/webpack.config.js index e9f10e51..e4b78765 100644 --- a/dashboard/webpack.config.js +++ b/dashboard/webpack.config.js @@ -1,42 +1,40 @@ -require('dotenv').config(); +require("dotenv").config(); -const path = require('path'); -const VueLoaderPlugin = require('vue-loader/lib/plugin'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const DotenvPlugin = require('dotenv-webpack'); -const merge = require('webpack-merge'); +const path = require("path"); +const VueLoaderPlugin = require("vue-loader/lib/plugin"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const DotenvPlugin = require("dotenv-webpack"); +const merge = require("webpack-merge"); -const targetDir = path.normalize(path.join(__dirname, 'dist')); +const targetDir = path.normalize(path.join(__dirname, "dist")); -if (! process.env.NODE_ENV) { - console.error('Please set NODE_ENV'); +if (!process.env.NODE_ENV) { + console.error("Please set NODE_ENV"); process.exit(1); } const babelOpts = { - presets: [ - '@babel/preset-env', - ], + presets: ["@babel/preset-env"], }; -const tsconfig = require('./tsconfig.json'); +const tsconfig = require("./tsconfig.json"); const pathAliases = Object.entries(tsconfig.compilerOptions.paths || []).reduce((aliases, pair) => { let alias = pair[0]; - if (alias.endsWith('/*')) alias = alias.slice(0, -2); + if (alias.endsWith("/*")) alias = alias.slice(0, -2); let aliasPath = pair[1][0]; - if (aliasPath.endsWith('/*')) aliasPath = aliasPath.slice(0, -2); + if (aliasPath.endsWith("/*")) aliasPath = aliasPath.slice(0, -2); aliases[alias] = path.resolve(__dirname, aliasPath); return aliases; }, {}); let config = { - entry: './src/main.ts', + entry: "./src/main.ts", output: { - filename: '[name].[hash].js', + filename: "[name].[hash].js", path: targetDir, - publicPath: '/', + publicPath: "/", }, module: { rules: [ @@ -50,11 +48,11 @@ let config = { exclude: /node_modules/, use: [ { - loader: 'babel-loader', + loader: "babel-loader", options: babelOpts, }, { - loader: 'ts-loader', + loader: "ts-loader", options: { appendTsSuffixTo: [/\.vue$/], }, @@ -65,7 +63,7 @@ let config = { test: /\.m?js$/, exclude: /node_modules/, use: { - loader: 'babel-loader', + loader: "babel-loader", options: babelOpts, }, }, @@ -90,26 +88,23 @@ let config = { loader: "postcss-loader", options: { ident: "postcss", - plugins: loader => { + plugins: (loader) => { const plugins = [ - require('postcss-import')({ + require("postcss-import")({ resolve(id, base, options) { // Since WebStorm doesn't resolve imports from node_modules without a tilde (~) prefix, // strip the tilde here to get the best of both worlds (webstorm support + postcss-import support) - if (id[0] === '~') id = id.slice(1); + if (id[0] === "~") id = id.slice(1); // Call the original resolver after stripping the tilde - return require('postcss-import/lib/resolve-id')(id, base, options); + return require("postcss-import/lib/resolve-id")(id, base, options); }, }), - require('postcss-nesting')(), - require('tailwindcss')(), + require("postcss-nesting")(), + require("tailwindcss")(), ]; if (process.env.NODE_ENV === "production") { - plugins.push( - require('postcss-preset-env')(), - require('cssnano')(), - ); + plugins.push(require("postcss-preset-env")(), require("cssnano")()); } return plugins; @@ -137,9 +132,9 @@ let config = { { loader: "html-loader", options: { - root: path.resolve(__dirname, 'src'), - attrs: ['img:src', 'link:href'], - ...(process.env.NODE_ENV === 'production' && { + root: path.resolve(__dirname, "src"), + attrs: ["img:src", "link:href"], + ...(process.env.NODE_ENV === "production" && { minimize: true, removeComments: true, collapseWhitespace: true, @@ -153,29 +148,29 @@ let config = { plugins: [ new VueLoaderPlugin(), new HtmlWebpackPlugin({ - template: 'src/index.html', + template: "src/index.html", files: { - "css": ["./src/style/initial.pcss"], - "js": ["./src/main.ts"], + css: ["./src/style/initial.pcss"], + js: ["./src/main.ts"], }, }), new DotenvPlugin(), ], resolve: { - extensions: ['.ts', '.tsx', '.js', '.mjs', '.vue'], + extensions: [".ts", ".tsx", ".js", ".mjs", ".vue"], alias: pathAliases, }, }; -if (process.env.NODE_ENV === 'production') { +if (process.env.NODE_ENV === "production") { config = merge(config, { - mode: 'production', - devtool: 'source-map', + mode: "production", + devtool: "source-map", }); } else { config = merge(config, { - mode: 'development', - devtool: 'eval', + mode: "development", + devtool: "eval", devServer: { ...(process.env.DEV_HOST ? { host: process.env.DEV_HOST } : undefined), historyApiFallback: true, diff --git a/debug/.gitignore b/debug/.gitignore new file mode 100644 index 00000000..120f485d --- /dev/null +++ b/debug/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore diff --git a/package-lock.json b/package-lock.json index fd9fbc39..c0f6c73e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "devDependencies": { "husky": "^3.0.9", "lint-staged": "^9.4.2", - "prettier": "^1.19.1", + "prettier": "^2.4.0", "tslint": "^5.13.1", "tslint-config-prettier": "^1.18.0", "typescript": "^4.3.4" @@ -1794,15 +1794,15 @@ } }, "node_modules/prettier": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", - "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", + "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", "dev": true, "bin": { "prettier": "bin-prettier.js" }, "engines": { - "node": ">=4" + "node": ">=10.13.0" } }, "node_modules/pump": { @@ -3747,9 +3747,9 @@ } }, "prettier": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", - "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", + "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", "dev": true }, "pump": { diff --git a/package.json b/package.json index 2178161f..1511e040 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,14 @@ "description": "", "private": true, "scripts": { - "format": "prettier --write \"./{backend,dashboard}/{,!(node_modules)/**/}/*.ts\"", + "format": "prettier --write './**/*.{css,html,js,json,ts,tsx}'", "lint": "tslint \"./{backend,dashboard}/{,!(node_modules)/**/}/*.ts\"", - "codestyle-check": "prettier --check \"./{backend,dashboard}/{,!(node_modules)/**/}/*.ts\"" + "codestyle-check": "prettier --check './**/*.{css,html,js,json,ts,tsx}'" }, "devDependencies": { "husky": "^3.0.9", "lint-staged": "^9.4.2", - "prettier": "^1.19.1", + "prettier": "^2.4.0", "tslint": "^5.13.1", "tslint-config-prettier": "^1.18.0", "typescript": "^4.3.4" diff --git a/presetup-configurator/.gitignore b/presetup-configurator/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/presetup-configurator/.gitignore @@ -0,0 +1 @@ +/build diff --git a/presetup-configurator/.prettierignore b/presetup-configurator/.prettierignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/presetup-configurator/.prettierignore @@ -0,0 +1 @@ +/build diff --git a/presetup-configurator/package-lock.json b/presetup-configurator/package-lock.json index 37cd3f41..3d0c4d68 100644 --- a/presetup-configurator/package-lock.json +++ b/presetup-configurator/package-lock.json @@ -1,7 +1,441 @@ { "name": "zeppelin-presetup-configurator", + "lockfileVersion": 2, "requires": true, - "lockfileVersion": 1, + "packages": { + "": { + "name": "zeppelin-presetup-configurator", + "dependencies": { + "js-yaml": "^4.0.0", + "react": "^17.0.1", + "react-dom": "^17.0.1" + }, + "devDependencies": { + "@snowpack/plugin-typescript": "^1.2.1", + "@types/node": "^14.14.21", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "snowpack": "^3.0.11" + } + }, + "node_modules/@snowpack/plugin-typescript": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@snowpack/plugin-typescript/-/plugin-typescript-1.2.1.tgz", + "integrity": "sha512-wU+JNaMVkqGsqTaUY7TnEMhGt/3URTgA9dpMCtZX6wn/ceA7Gwlmue/sOLynf0OTNLygHPvjiQECQYkEi3LTtg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "npm-run-path": "^4.0.1" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@types/node": { + "version": "14.14.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.21.tgz", + "integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.0.tgz", + "integrity": "sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.0.tgz", + "integrity": "sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", + "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.8.32", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.32.tgz", + "integrity": "sha512-5IzQapMW/wFy5oxziHCJzawk26K3xeyrIAQPnPN3c0Q84hqRw6IfGDGfGWOdJNw5tAx77yvwqZ4r1QMpo6emJA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + } + }, + "node_modules/execa": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz", + "integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.1.tgz", + "integrity": "sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", + "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/is-docker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.1.1.tgz", + "integrity": "sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz", + "integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/open/-/open-7.3.1.tgz", + "integrity": "sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/react": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz", + "integrity": "sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz", + "integrity": "sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.1" + }, + "peerDependencies": { + "react": "17.0.1" + } + }, + "node_modules/rollup": { + "version": "2.36.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.36.2.tgz", + "integrity": "sha512-qjjiuJKb+/8n0EZyQYVW+gFU4bNRBcZaXVzUgSVrGw0HlQBlK2aWyaOMMs1Ufic1jV69b9kW3u3i9B+hISDm3A==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.1.2" + } + }, + "node_modules/rollup/node_modules/fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "deprecated": "\"Please update to latest v2.3 or v2.2\"", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.1.tgz", + "integrity": "sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "node_modules/snowpack": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/snowpack/-/snowpack-3.0.11.tgz", + "integrity": "sha512-lBxgkvWTgdg0szE31JUt01wQkA9Lnmm+6lxqeV9rxDfflpx7ASnldVHFvu7Se70QJmPTQB0UJjfKI+xmYGwiiQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.8.7", + "open": "^7.0.4", + "rollup": "^2.34.0" + }, + "bin": { + "snowpack": "index.bin.js", + "sp": "index.bin.js" + }, + "engines": { + "node": ">=10.19.0" + }, + "optionalDependencies": { + "fsevents": "^2.2.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + }, "dependencies": { "@snowpack/plugin-typescript": { "version": "1.2.1", diff --git a/presetup-configurator/snowpack.config.js b/presetup-configurator/snowpack.config.js index 0125dd6f..2d15cba7 100644 --- a/presetup-configurator/snowpack.config.js +++ b/presetup-configurator/snowpack.config.js @@ -1,8 +1,6 @@ module.exports = { mount: { - "src": "/", + src: "/", }, - plugins: [ - '@snowpack/plugin-typescript', - ], + plugins: ["@snowpack/plugin-typescript"], }; diff --git a/presetup-configurator/src/App.tsx b/presetup-configurator/src/App.tsx index 5201bdf8..54e9e365 100644 --- a/presetup-configurator/src/App.tsx +++ b/presetup-configurator/src/App.tsx @@ -3,9 +3,11 @@ import { Configurator } from "./Configurator"; import "./App.css"; export function App() { - return
-
- + return ( +
+
+ +
-
; + ); } diff --git a/presetup-configurator/src/Configurator.css b/presetup-configurator/src/Configurator.css index 8bebd777..7d8ba426 100644 --- a/presetup-configurator/src/Configurator.css +++ b/presetup-configurator/src/Configurator.css @@ -1,5 +1,4 @@ .Configurator { - } .Configurator .options { diff --git a/presetup-configurator/src/Configurator.tsx b/presetup-configurator/src/Configurator.tsx index 8e2222de..02fd9d7d 100644 --- a/presetup-configurator/src/Configurator.tsx +++ b/presetup-configurator/src/Configurator.tsx @@ -5,7 +5,7 @@ import yaml from "js-yaml"; import "./Configurator.css"; export function Configurator() { - const [prefix, setPrefix] = useState('!'); + const [prefix, setPrefix] = useState("!"); const [levels, setLevels] = useState([]); const [withModCommands, setWithModCommands] = useState(false); @@ -42,7 +42,7 @@ export function Configurator() { resultObj.plugins.mutes = { config: { mute_role: muteRoleId, - } + }, }; if (dmModActionReasons) { @@ -108,7 +108,7 @@ export function Configurator() { setCopied(true); } - const [copyResetTimeout, setCopyResetTimeout] = useState(null); + const [copyResetTimeout, setCopyResetTimeout] = useState(null); useEffect(() => { if (!copied) { return; @@ -129,8 +129,9 @@ export function Configurator() {

Prefix

@@ -142,24 +143,30 @@ export function Configurator() {

Mod commands

{withModCommands && (
@@ -169,21 +176,23 @@ export function Configurator() {

Logs

- {withLogs && ( - - )} + {withLogs && }
{/* Result */} -