diff --git a/.clabot b/.clabot index 55210ae1..366c4f32 100644 --- a/.clabot +++ b/.clabot @@ -1,37 +1,40 @@ { "contributors": [ - "BanTheNons", - "CleverSource", - "DarkView", - "DenverCoder1", - "Jernik", - "Rstar284", "almeidx", "axisiscool", + "BanTheNons", + "Benricheson101", + "brawaru", + "CleverSource", + "Dalkskkskk", + "DarkView", + "DenverCoder1", "dexbiobot", "greenbigfrog", + "hawkeye7662", + "iamshoXy", + "Jernik", "k200-1", + "LilyBergonzat", + "martinbndr", "metal0", + "Obliie", "paolojpa", "roflmaoqwerty", + "Rstar284", + "rubyowo", + "rukogit", + "Scraayp", + "TheKodeToad", "thewilloftheshadow", "usoka", "vcokltfre", - "Dragory", - "rubyowo", - "Dalkskkskk", - "iamshoXy", - "Scraayp", - "app/dependabot", - "dependabot[bot]", + "WeebHiroyuki", "zayKenyon", - "rukogit", - "Obliie", - "brawaru", - "Benricheson101", - "hawkeye7662", - "LilyBergonzat", - "martinbndr" + + "Dragory", + "app/dependabot", + "dependabot[bot]" ], "message": "Thank you for contributing to Zeppelin! We require contributors to sign our Contributor License Agreement (CLA). To let us review and merge your code, please visit https://github.com/ZeppelinBot/CLA to sign the CLA!" } diff --git a/.env.example b/.env.example index 7878e9be..d0cd702a 100644 --- a/.env.example +++ b/.env.example @@ -22,8 +22,10 @@ STAFF= DEFAULT_ALLOWED_SERVERS= # Only required if relevant feature is used -#PHISHERMAN_API_KEY= +#FISHFISH_API_KEY= +#DEFAULT_SUCCESS_EMOJI= +#DEFAULT_ERROR_EMOJI= # ========================== # DEVELOPMENT diff --git a/.github/workflows/codequality.yml b/.github/workflows/codequality.yml index 0d17dbc9..a5a59872 100644 --- a/.github/workflows/codequality.yml +++ b/.github/workflows/codequality.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: - node-version: [18.16] + node-version: [22] steps: - uses: actions/checkout@v1 diff --git a/.nvmrc b/.nvmrc index 3c032078..2bd5a0a9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +22 diff --git a/Dockerfile b/Dockerfile index 5015b021..a3c1a1a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20 +FROM node:22 AS build RUN mkdir /zeppelin RUN chown node:node /zeppelin @@ -32,3 +32,8 @@ RUN npm run build # Prune dev dependencies WORKDIR /zeppelin RUN npm prune --omit=dev + +FROM node:22-alpine AS main + +USER node +COPY --from=build --chown=node:node /zeppelin /zeppelin diff --git a/backend/package.json b/backend/package.json index 43a43383..8fb9adbf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,9 @@ "description": "", "private": true, "type": "module", + "exports": { + "./*": "./dist/*" + }, "scripts": { "watch": "tsc-watch --build --onSuccess \"node start-dev.js\"", "watch-yaml-parse-test": "tsc-watch --build --onSuccess \"node dist/yamlParseTest.js\"", @@ -26,7 +29,7 @@ "migrate-rollback-prod": "npm run migrate-rollback", "migrate-rollback-dev": "npm run build && npm run migrate-rollback", "validate-active-configs": "node --enable-source-maps dist/validateActiveConfigs.js > ../config-errors.txt", - "export-config-json-schema": "node --enable-source-maps dist/exportSchemas.js > ../config-schema.json", + "export-config-json-schema": "node --enable-source-maps dist/exportSchemas.js ../config-checker/public/config-schema.json", "test": "npm run build && npm run run-tests", "run-tests": "ava", "test-watch": "tsc-watch --build --onSuccess \"npx ava\"" @@ -38,18 +41,18 @@ "cors": "^2.8.5", "cross-env": "^7.0.3", "deep-diff": "^1.0.2", - "discord.js": "^14.14.1", + "discord.js": "^14.19.3", "dotenv": "^4.0.0", "emoji-regex": "^8.0.0", "escape-string-regexp": "^1.0.5", "express": "^4.20.0", "fp-ts": "^2.0.1", "humanize-duration": "^3.15.0", - "js-yaml": "^3.13.1", - "knub": "^32.0.0-next.21", + "js-yaml": "^4.1.0", + "knub": "^32.0.0-next.25", "knub-command-manager": "^9.1.0", "last-commit-log": "^2.1.0", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "moment-timezone": "^0.5.21", "multer": "^1.4.5-lts.1", "mysql2": "^3.9.8", @@ -72,15 +75,14 @@ "utf-8-validate": "^5.0.5", "uuid": "^9.0.0", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", - "zlib-sync": "^0.1.7", - "zod": "^3.7.2" + "zod": "^3.25.17" }, "devDependencies": { "@types/cors": "^2.8.5", "@types/express": "^4.16.1", "@types/jest": "^24.0.15", "@types/js-yaml": "^3.12.1", - "@types/lodash.at": "^4.6.3", + "@types/lodash-es": "^4.17.12", "@types/moment-timezone": "^0.5.6", "@types/multer": "^1.4.7", "@types/passport": "^1.0.0", diff --git a/backend/src/RegExpRunner.ts b/backend/src/RegExpRunner.ts index 6d835a84..ea37fe03 100644 --- a/backend/src/RegExpRunner.ts +++ b/backend/src/RegExpRunner.ts @@ -1,5 +1,5 @@ -import { EventEmitter } from "events"; import { CooldownManager } from "knub"; +import { EventEmitter } from "node:events"; import { RegExpWorker, TimeoutError } from "regexp-worker"; import { MINUTES, SECONDS } from "./utils.js"; import Timeout = NodeJS.Timeout; diff --git a/backend/src/SimpleCache.ts b/backend/src/SimpleCache.ts index 57a58bf7..4161ec79 100644 --- a/backend/src/SimpleCache.ts +++ b/backend/src/SimpleCache.ts @@ -46,7 +46,7 @@ export class SimpleCache { }); if (this.maxItems && this.store.size > this.maxItems) { - const keyToDelete = this.store.keys().next().value; + const keyToDelete = this.store.keys().next().value!; this.store.delete(keyToDelete); } } diff --git a/backend/src/api/docs.ts b/backend/src/api/docs.ts index cee346a0..59aef88b 100644 --- a/backend/src/api/docs.ts +++ b/backend/src/api/docs.ts @@ -1,130 +1,135 @@ import express from "express"; -import z from "zod"; -import { guildPlugins } from "../plugins/availablePlugins.js"; -import { guildPluginInfo } from "../plugins/pluginInfo.js"; +import z from "zod/v4"; +import { availableGuildPlugins } from "../plugins/availablePlugins.js"; +import { ZeppelinGuildPluginInfo } from "../types.js"; import { indentLines } from "../utils.js"; import { notFound } from "./responses.js"; +import { $ZodPipeDef } from "zod/v4/core"; -function isZodObject(schema: z.ZodTypeAny): schema is z.ZodObject { - return schema._def.typeName === "ZodObject"; +function isZodObject(schema: z.ZodType): schema is z.ZodObject { + return schema.def.type === "object"; } -function isZodRecord(schema: z.ZodTypeAny): schema is z.ZodRecord { - return schema._def.typeName === "ZodRecord"; +function isZodRecord(schema: z.ZodType): schema is z.ZodRecord { + return schema.def.type === "record"; } -function isZodEffects(schema: z.ZodTypeAny): schema is z.ZodEffects { - return schema._def.typeName === "ZodEffects"; +function isZodOptional(schema: z.ZodType): schema is z.ZodOptional { + return schema.def.type === "optional"; } -function isZodOptional(schema: z.ZodTypeAny): schema is z.ZodOptional { - return schema._def.typeName === "ZodOptional"; +function isZodArray(schema: z.ZodType): schema is z.ZodArray { + return schema.def.type === "array"; } -function isZodArray(schema: z.ZodTypeAny): schema is z.ZodArray { - return schema._def.typeName === "ZodArray"; +function isZodUnion(schema: z.ZodType): schema is z.ZodUnion { + return schema.def.type === "union"; } -function isZodUnion(schema: z.ZodTypeAny): schema is z.ZodUnion { - return schema._def.typeName === "ZodUnion"; +function isZodNullable(schema: z.ZodType): schema is z.ZodNullable { + return schema.def.type === "nullable"; } -function isZodNullable(schema: z.ZodTypeAny): schema is z.ZodNullable { - return schema._def.typeName === "ZodNullable"; +function isZodDefault(schema: z.ZodType): schema is z.ZodDefault { + return schema.def.type === "default"; } -function isZodDefault(schema: z.ZodTypeAny): schema is z.ZodDefault { - return schema._def.typeName === "ZodDefault"; +function isZodLiteral(schema: z.ZodType): schema is z.ZodLiteral { + return schema.def.type === "literal"; } -function isZodLiteral(schema: z.ZodTypeAny): schema is z.ZodLiteral { - return schema._def.typeName === "ZodLiteral"; +function isZodIntersection(schema: z.ZodType): schema is z.ZodIntersection { + return schema.def.type === "intersection"; } -function isZodIntersection(schema: z.ZodTypeAny): schema is z.ZodIntersection { - return schema._def.typeName === "ZodIntersection"; -} - -function formatZodConfigSchema(schema: z.ZodTypeAny) { +function formatZodConfigSchema(schema: z.ZodType) { if (isZodObject(schema)) { return ( `{\n` + - Object.entries(schema._def.shape()) - .map(([k, value]) => indentLines(`${k}: ${formatZodConfigSchema(value as z.ZodTypeAny)}`, 2)) + Object.entries(schema.def.shape) + .map(([k, value]) => indentLines(`${k}: ${formatZodConfigSchema(value as z.ZodType)}`, 2)) .join("\n") + "\n}" ); } if (isZodRecord(schema)) { - return "{\n" + indentLines(`[string]: ${formatZodConfigSchema(schema._def.valueType)}`, 2) + "\n}"; - } - if (isZodEffects(schema)) { - return formatZodConfigSchema(schema._def.schema); + return "{\n" + indentLines(`[string]: ${formatZodConfigSchema(schema.valueType as z.ZodType)}`, 2) + "\n}"; } if (isZodOptional(schema)) { - return `Optional<${formatZodConfigSchema(schema._def.innerType)}>`; + return `Optional<${formatZodConfigSchema(schema.def.innerType)}>`; } if (isZodArray(schema)) { - return `Array<${formatZodConfigSchema(schema._def.type)}>`; + return `Array<${formatZodConfigSchema(schema.def.element)}>`; } if (isZodUnion(schema)) { - return schema._def.options.map((t) => formatZodConfigSchema(t)).join(" | "); + return schema.def.options.map((t) => formatZodConfigSchema(t)).join(" | "); } if (isZodNullable(schema)) { - return `Nullable<${formatZodConfigSchema(schema._def.innerType)}>`; + return `Nullable<${formatZodConfigSchema(schema.def.innerType)}>`; } if (isZodDefault(schema)) { - return formatZodConfigSchema(schema._def.innerType); + return formatZodConfigSchema(schema.def.innerType); } if (isZodLiteral(schema)) { - return schema._def.value; + return schema.def.values; } if (isZodIntersection(schema)) { - return [formatZodConfigSchema(schema._def.left), formatZodConfigSchema(schema._def.right)].join(" & "); + return [ + formatZodConfigSchema(schema.def.left as z.ZodType), + formatZodConfigSchema(schema.def.right as z.ZodType), + ].join(" & "); } - if (schema._def.typeName === "ZodString") { + if (schema.def.type === "string") { return "string"; } - if (schema._def.typeName === "ZodNumber") { + if (schema.def.type === "number") { return "number"; } - if (schema._def.typeName === "ZodBoolean") { + if (schema.def.type === "boolean") { return "boolean"; } - if (schema._def.typeName === "ZodNever") { + if (schema.def.type === "never") { return "never"; } + if (schema.def.type === "pipe") { + return formatZodConfigSchema((schema.def as $ZodPipeDef).in as z.ZodType); + } return "unknown"; } +const availableGuildPluginsByName = availableGuildPlugins.reduce>( + (map, obj) => { + map[obj.plugin.name] = obj; + return map; + }, + {}, +); + export function initDocs(router: express.Router) { - const docsPluginNames = Object.keys(guildPluginInfo).filter((k) => guildPluginInfo[k].showInDocs); + const docsPlugins = availableGuildPlugins.filter((obj) => obj.docs.type !== "internal"); router.get("/docs/plugins", (req: express.Request, res: express.Response) => { res.json( - docsPluginNames.map((pluginName) => { - const info = guildPluginInfo[pluginName]; - const thinInfo = info ? { prettyName: info.prettyName, legacy: info.legacy ?? false } : {}; - return { - name: pluginName, - info: thinInfo, - }; - }), + docsPlugins.map((obj) => ({ + name: obj.plugin.name, + info: { + prettyName: obj.docs.prettyName, + type: obj.docs.type, + }, + })), ); }); router.get("/docs/plugins/:pluginName", (req: express.Request, res: express.Response) => { - const name = req.params.pluginName; - const baseInfo = guildPluginInfo[name]; - if (!baseInfo) { + const pluginInfo = availableGuildPluginsByName[req.params.pluginName]; + if (!pluginInfo) { return notFound(res); } - const plugin = guildPlugins.find((p) => p.name === name)!; - const { configSchema, ...info } = baseInfo; + const { configSchema, ...info } = pluginInfo.docs; const formattedConfigSchema = formatZodConfigSchema(configSchema); - const messageCommands = (plugin.messageCommands || []).map((cmd) => ({ + const messageCommands = (pluginInfo.plugin.messageCommands || []).map((cmd) => ({ trigger: cmd.trigger, permission: cmd.permission, signature: cmd.signature, @@ -133,10 +138,10 @@ export function initDocs(router: express.Router) { config: cmd.config, })); - const defaultOptions = plugin.defaultOptions || {}; + const defaultOptions = pluginInfo.docs.configSchema.safeParse({}).data ?? {}; res.json({ - name, + name: pluginInfo.plugin.name, info, configSchema: formattedConfigSchema, defaultOptions, diff --git a/backend/src/api/guilds/importExport.ts b/backend/src/api/guilds/importExport.ts index 73d8af2f..15f5d277 100644 --- a/backend/src/api/guilds/importExport.ts +++ b/backend/src/api/guilds/importExport.ts @@ -1,7 +1,7 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import express, { Request, Response } from "express"; import moment from "moment-timezone"; -import { z } from "zod"; +import { z } from "zod/v4"; import { GuildCases } from "../../data/GuildCases.js"; import { Case } from "../../data/entities/Case.js"; import { MINUTES } from "../../utils.js"; diff --git a/backend/src/api/guilds/misc.ts b/backend/src/api/guilds/misc.ts index 3ab72dd7..4b5e074a 100644 --- a/backend/src/api/guilds/misc.ts +++ b/backend/src/api/guilds/misc.ts @@ -1,6 +1,6 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import express, { Request, Response } from "express"; -import jsYaml from "js-yaml"; +import { YAMLException } from "js-yaml"; import moment from "moment-timezone"; import { Queue } from "../../Queue.js"; import { validateGuildConfig } from "../../configValidator.js"; @@ -15,8 +15,6 @@ import { ObjectAliasError } from "../../utils/validateNoObjectAliases.js"; import { hasGuildPermission, requireGuildPermission } from "../permissions.js"; import { clientError, ok, serverError, unauthorized } from "../responses.js"; -const YAMLException = jsYaml.YAMLException; - const apiPermissionAssignments = new ApiPermissionAssignments(); const auditLog = new ApiAuditLog(); diff --git a/backend/src/api/start.ts b/backend/src/api/start.ts index 9fdf2976..b259d22a 100644 --- a/backend/src/api/start.ts +++ b/backend/src/api/start.ts @@ -28,10 +28,10 @@ app.use(multer().none()); const rootRouter = express.Router(); -initAuth(app); -initGuildsAPI(app); -initArchives(app); -initDocs(app); +initAuth(rootRouter); +initGuildsAPI(rootRouter); +initArchives(rootRouter); +initDocs(rootRouter); // Default route rootRouter.get("/", (req, res) => { diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts index 5fb00a2e..e66f97c5 100644 --- a/backend/src/configValidator.ts +++ b/backend/src/configValidator.ts @@ -1,13 +1,12 @@ -import { ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from "knub"; -import moment from "moment-timezone"; -import { ZodError } from "zod"; -import { guildPlugins } from "./plugins/availablePlugins.js"; -import { ZeppelinGuildConfig, zZeppelinGuildConfig } from "./types.js"; +import { BaseConfig, ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from "knub"; +import { z, ZodError } from "zod/v4"; +import { availableGuildPlugins } from "./plugins/availablePlugins.js"; +import { zZeppelinGuildConfig } from "./types.js"; import { formatZodIssue } from "./utils/formatZodIssue.js"; const pluginNameToPlugin = new Map>(); -for (const plugin of guildPlugins) { - pluginNameToPlugin.set(plugin.name, plugin); +for (const pluginInfo of availableGuildPlugins) { + pluginNameToPlugin.set(pluginInfo.plugin.name, pluginInfo.plugin); } export async function validateGuildConfig(config: any): Promise { @@ -16,14 +15,7 @@ export async function validateGuildConfig(config: any): Promise { return validationResult.error.issues.map(formatZodIssue).join("\n"); } - const guildConfig = config as ZeppelinGuildConfig; - - if (guildConfig.timezone) { - const validTimezones = moment.tz.names(); - if (!validTimezones.includes(guildConfig.timezone)) { - return `Invalid timezone: ${guildConfig.timezone}`; - } - } + const guildConfig = config as BaseConfig; if (guildConfig.plugins) { for (const [pluginName, pluginOptions] of Object.entries(guildConfig.plugins)) { @@ -36,15 +28,21 @@ export async function validateGuildConfig(config: any): Promise { } const plugin = pluginNameToPlugin.get(pluginName)!; - const configManager = new PluginConfigManager(plugin.defaultOptions || { config: {} }, pluginOptions, { - levels: {}, - parser: plugin.configParser, - }); + const configManager = new PluginConfigManager( + pluginOptions, + { + configSchema: plugin.configSchema, + defaultOverrides: plugin.defaultOverrides ?? [], + levels: {}, + customOverrideCriteriaFunctions: plugin.customOverrideCriteriaFunctions, + }, + ); + try { await configManager.init(); } catch (err) { if (err instanceof ZodError) { - return `${pluginName}: ${err.issues.map(formatZodIssue).join("\n")}`; + return `${pluginName}:\n${z.prettifyError(err)}`; } if (err instanceof ConfigValidationError) { return `${pluginName}: ${err.message}`; diff --git a/backend/src/data/FishFish.ts b/backend/src/data/FishFish.ts new file mode 100644 index 00000000..7f502e5d --- /dev/null +++ b/backend/src/data/FishFish.ts @@ -0,0 +1,173 @@ +import z from "zod/v4"; +import { env } from "../env.js"; +import { HOURS, MINUTES, SECONDS } from "../utils.js"; + +const API_ROOT = "https://api.fishfish.gg/v1"; + +const zDomainCategory = z.literal(["safe", "malware", "phishing"]); + +const zDomain = z.object({ + name: z.string(), + category: zDomainCategory, + description: z.string(), + added: z.number(), + checked: z.number(), +}); +export type FishFishDomain = z.output; + +const FULL_REFRESH_INTERVAL = 6 * HOURS; +const domains = new Map(); + +let sessionTokenPromise: Promise | null = null; + +const WS_RECONNECT_DELAY = 30 * SECONDS; +let updatesWs: WebSocket | null = null; + +export class FishFishError extends Error {} + +const zTokenResponse = z.object({ + expires: z.number(), + token: z.string(), +}); + +async function getSessionToken(): Promise { + if (sessionTokenPromise) { + return sessionTokenPromise; + } + + const apiKey = env.FISHFISH_API_KEY; + if (!apiKey) { + throw new FishFishError("FISHFISH_API_KEY is missing"); + } + + sessionTokenPromise = (async () => { + const response = await fetch(`${API_ROOT}/users/@me/tokens`, { + method: "POST", + headers: { + Authorization: apiKey, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new FishFishError(`Failed to get session token: ${response.status} ${response.statusText}`); + } + + const parseResult = zTokenResponse.safeParse(await response.json()); + if (!parseResult.success) { + throw new FishFishError(`Parse error when fetching session token: ${parseResult.error.message}`); + } + + const timeUntilExpiry = Date.now() - parseResult.data.expires * 1000; + setTimeout(() => { + sessionTokenPromise = null; + }, timeUntilExpiry - 1 * MINUTES); // Subtract a minute to ensure we refresh before expiry + + return parseResult.data.token; + })(); + sessionTokenPromise.catch((err) => { + sessionTokenPromise = null; + throw err; + }); + + return sessionTokenPromise; +} + +async function fishFishApiCall(method: string, path: string, query: Record = {}): Promise { + const sessionToken = await getSessionToken(); + const queryParams = new URLSearchParams(query); + const response = await fetch(`https://api.fishfish.gg/v1/${path}?${queryParams}`, { + method, + headers: { + Authorization: sessionToken, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new FishFishError(`FishFish API call failed: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +async function subscribeToFishFishUpdates(): Promise { + if (updatesWs) { + return; + } + const sessionToken = await getSessionToken(); + console.log("[FISHFISH] Connecting to WebSocket for real-time updates"); + updatesWs = new WebSocket("wss://api.fishfish.gg/v1/stream", { + headers: { + Authorization: sessionToken, + }, + }); + updatesWs.addEventListener("open", () => { + console.log("[FISHFISH] WebSocket connection established"); + }); + updatesWs.addEventListener("message", (event) => { + console.log("[FISHFISH] ws update:", event.data); + }); + updatesWs.addEventListener("error", (error) => { + console.error(`[FISHFISH] WebSocket error: ${error.message}`); + }); + updatesWs.addEventListener("close", () => { + console.log("[FISHFISH] WebSocket connection closed, reconnecting after delay"); + updatesWs = null; + setTimeout(() => { + subscribeToFishFishUpdates(); + }, WS_RECONNECT_DELAY); + }); +} + +async function refreshFishFishDomains() { + const rawData = await fishFishApiCall("GET", "domains", { full: "true" }); + const parseResult = z.array(zDomain).safeParse(rawData); + if (!parseResult.success) { + throw new FishFishError(`Parse error when refreshing domains: ${parseResult.error.message}`); + } + + domains.clear(); + for (const domain of parseResult.data) { + domains.set(domain.name, domain); + } + + domains.set("malware-link.test.zeppelin.gg", { + name: "malware-link.test.zeppelin.gg", + category: "malware", + description: "", + added: Date.now(), + checked: Date.now(), + }); + domains.set("phishing-link.test.zeppelin.gg", { + name: "phishing-link.test.zeppelin.gg", + category: "phishing", + description: "", + added: Date.now(), + checked: Date.now(), + }); + domains.set("safe-link.test.zeppelin.gg", { + name: "safe-link.test.zeppelin.gg", + category: "safe", + description: "", + added: Date.now(), + checked: Date.now(), + }); + + console.log("[FISHFISH] Refreshed FishFish domains, total count:", domains.size); +} + +export async function initFishFish() { + if (!env.FISHFISH_API_KEY) { + console.warn("[FISHFISH] FISHFISH_API_KEY is not set, FishFish functionality will be disabled."); + return; + } + + await refreshFishFishDomains(); + void subscribeToFishFishUpdates(); + setInterval(() => refreshFishFishDomains(), FULL_REFRESH_INTERVAL); +} + +export function getFishFishDomain(domain: string): FishFishDomain | undefined { + return domains.get(domain.toLowerCase()); +} diff --git a/backend/src/data/GuildCases.ts b/backend/src/data/GuildCases.ts index b04eea63..ea834351 100644 --- a/backend/src/data/GuildCases.ts +++ b/backend/src/data/GuildCases.ts @@ -1,4 +1,4 @@ -import { In, InsertResult, Repository } from "typeorm"; +import { FindOptionsWhere, In, InsertResult, Repository } from "typeorm"; import { Queue } from "../Queue.js"; import { chunkArray } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; @@ -73,34 +73,69 @@ export class GuildCases extends BaseGuildRepository { }); } - async getByUserId(userId: string): Promise { + async getByUserId( + userId: string, + filters: Omit, "guild_id" | "user_id"> = {}, + ): Promise { + return this.cases.find({ + relations: this.getRelations(), + where: { + guild_id: this.guildId, + user_id: userId, + ...filters, + }, + }); + } + + async getRecentByUserId(userId: string, count: number, skip = 0): Promise { return this.cases.find({ relations: this.getRelations(), where: { guild_id: this.guildId, user_id: userId, }, + skip, + take: count, + order: { + case_number: "DESC", + }, }); } - async getTotalCasesByModId(modId: string): Promise { + async getTotalCasesByModId( + modId: string, + filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, + ): Promise { return this.cases.count({ where: { guild_id: this.guildId, mod_id: modId, is_hidden: false, + ...filters, }, }); } - async getRecentByModId(modId: string, count: number, skip = 0): Promise { + async getRecentByModId( + modId: string, + count: number, + skip = 0, + filters: Omit, "guild_id" | "mod_id"> = {}, + ): Promise { + const where: FindOptionsWhere = { + guild_id: this.guildId, + mod_id: modId, + is_hidden: false, + ...filters, + }; + + if (where.is_hidden === true) { + delete where.is_hidden; + } + return this.cases.find({ relations: this.getRelations(), - where: { - guild_id: this.guildId, - mod_id: modId, - is_hidden: false, - }, + where, skip, take: count, order: { diff --git a/backend/src/data/Phisherman.ts b/backend/src/data/Phisherman.ts deleted file mode 100644 index a72f0c30..00000000 --- a/backend/src/data/Phisherman.ts +++ /dev/null @@ -1,253 +0,0 @@ -import crypto from "crypto"; -import moment from "moment-timezone"; -import { Repository } from "typeorm"; -import { env } from "../env.js"; -import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils.js"; -import { dataSource } from "./dataSource.js"; -import { PhishermanCacheEntry } from "./entities/PhishermanCacheEntry.js"; -import { PhishermanKeyCacheEntry } from "./entities/PhishermanKeyCacheEntry.js"; -import { PhishermanDomainInfo, PhishermanUnknownDomain } from "./types/phisherman.js"; - -const API_URL = "https://api.phisherman.gg"; -const MASTER_API_KEY = env.PHISHERMAN_API_KEY; - -let caughtDomainTrackingMap: Map> = new Map(); - -const pendingApiRequests: Map> = new Map(); -const pendingDomainInfoChecks: Map> = new Map(); - -type MemoryCacheEntry = { - info: PhishermanDomainInfo | null; - expires: number; -}; -const memoryCache: Map = new Map(); - -setInterval(() => { - const now = Date.now(); - for (const [key, entry] of memoryCache.entries()) { - if (entry.expires <= now) { - memoryCache.delete(key); - } - } -}, 2 * MINUTES); - -const UNKNOWN_DOMAIN_CACHE_LIFETIME = 2 * MINUTES; -const DETECTED_DOMAIN_CACHE_LIFETIME = 15 * MINUTES; -const SAFE_DOMAIN_CACHE_LIFETIME = 7 * DAYS; - -const KEY_VALIDITY_LIFETIME = 24 * HOURS; - -let cacheRepository: Repository | null = null; -function getCacheRepository(): Repository { - if (cacheRepository == null) { - cacheRepository = dataSource.getRepository(PhishermanCacheEntry); - } - return cacheRepository; -} - -let keyCacheRepository: Repository | null = null; -function getKeyCacheRepository(): Repository { - if (keyCacheRepository == null) { - keyCacheRepository = dataSource.getRepository(PhishermanKeyCacheEntry); - } - return keyCacheRepository; -} - -class PhishermanApiError extends Error { - method: string; - url: string; - status: number; - - constructor(method: string, url: string, status: number, message: string) { - super(message); - this.method = method; - this.url = url; - this.status = status; - } - - toString() { - return `Error ${this.status} in ${this.method} ${this.url}: ${this.message}`; - } -} - -export function hasPhishermanMasterAPIKey() { - return MASTER_API_KEY != null && MASTER_API_KEY !== ""; -} - -export function phishermanDomainIsSafe(info: PhishermanDomainInfo): boolean { - return info.classification === "safe"; -} - -const leadingSlashRegex = /^\/+/g; -function trimLeadingSlash(str: string): string { - return str.replace(leadingSlashRegex, ""); -} - -/** - * Make an arbitrary API call to the Phisherman API - */ -async function apiCall( - method: "GET" | "POST", - resource: string, - payload?: Record | null, -): Promise { - if (!hasPhishermanMasterAPIKey()) { - throw new Error("Phisherman master API key missing"); - } - - const url = `${API_URL}/${trimLeadingSlash(resource)}`; - const key = `${method} ${url}`; - - if (pendingApiRequests.has(key)) { - return pendingApiRequests.get(key)! as Promise; - } - - let requestPromise = (async () => { - const response = await fetch(url, { - method, - headers: new Headers({ - Accept: "application/json", - "Content-Type": "application/json", - Authorization: `Bearer ${MASTER_API_KEY}`, - }), - body: payload ? JSON.stringify(payload) : undefined, - }); - const data = await response.json().catch(() => null); - if (!response.ok || (data as any)?.success === false) { - throw new PhishermanApiError(method, url, response.status, (data as any)?.message ?? ""); - } - return data; - })(); - requestPromise = requestPromise.finally(() => { - pendingApiRequests.delete(key); - }); - pendingApiRequests.set(key, requestPromise); - return requestPromise as Promise; -} - -type DomainInfoApiCallResult = PhishermanUnknownDomain | PhishermanDomainInfo; -async function fetchDomainInfo(domain: string): Promise { - // tslint:disable-next-line:no-console - console.log(`[PHISHERMAN] Requesting domain information: ${domain}`); - const result = await apiCall>("GET", `/v2/domains/info/${domain}`); - const firstKey = Object.keys(result)[0]; - const domainInfo = firstKey ? result[firstKey] : null; - if (!domainInfo) { - // tslint:disable-next-line:no-console - console.warn(`Unexpected Phisherman API response for ${domain}:`, result); - return null; - } - if (domainInfo.classification === "unknown") { - return null; - } - return domainInfo; -} - -export async function getPhishermanDomainInfo(domain: string): Promise { - if (pendingDomainInfoChecks.has(domain)) { - return pendingDomainInfoChecks.get(domain)!; - } - - let promise = (async () => { - if (memoryCache.has(domain)) { - return memoryCache.get(domain)!.info; - } - - const dbCache = getCacheRepository(); - const existingCachedEntry = await dbCache.findOne({ - where: { domain }, - }); - if (existingCachedEntry) { - return existingCachedEntry.data; - } - - const freshData = await fetchDomainInfo(domain); - const expiryTime = - freshData === null - ? UNKNOWN_DOMAIN_CACHE_LIFETIME - : phishermanDomainIsSafe(freshData) - ? SAFE_DOMAIN_CACHE_LIFETIME - : DETECTED_DOMAIN_CACHE_LIFETIME; - memoryCache.set(domain, { - info: freshData, - expires: Date.now() + expiryTime, - }); - - if (freshData) { - // Database cache only stores safe/detected domains, not unknown ones - await dbCache.insert({ - domain, - data: freshData, - expires_at: moment().add(expiryTime, "ms").format(DBDateFormat), - }); - } - - return freshData; - })(); - promise = promise.finally(() => { - pendingDomainInfoChecks.delete(domain); - }); - pendingDomainInfoChecks.set(domain, promise); - - return promise; -} - -export async function phishermanApiKeyIsValid(apiKey: string): Promise { - if (apiKey === MASTER_API_KEY) { - return true; - } - - const keyCache = getKeyCacheRepository(); - const hash = crypto.createHash("sha256").update(apiKey).digest("hex"); - const entry = await keyCache.findOne({ - where: { hash }, - }); - if (entry) { - return entry.is_valid; - } - - const { valid: isValid } = await apiCall<{ valid: boolean }>("POST", "/zeppelin/check-key", { apiKey }); - - await keyCache.insert({ - hash, - is_valid: isValid, - expires_at: moment().add(KEY_VALIDITY_LIFETIME, "ms").format(DBDateFormat), - }); - - return isValid; -} - -export function trackPhishermanCaughtDomain(apiKey: string, domain: string) { - if (!caughtDomainTrackingMap.has(apiKey)) { - caughtDomainTrackingMap.set(apiKey, new Map()); - } - const apiKeyMap = caughtDomainTrackingMap.get(apiKey)!; - if (!apiKeyMap.has(domain)) { - apiKeyMap.set(domain, []); - } - const timestamps = apiKeyMap.get(domain)!; - timestamps.push(Date.now()); -} - -export async function reportTrackedDomainsToPhisherman() { - const result = {}; - for (const [apiKey, domains] of caughtDomainTrackingMap.entries()) { - result[apiKey] = {}; - for (const [domain, timestamps] of domains.entries()) { - result[apiKey][domain] = timestamps; - } - } - - if (Object.keys(result).length > 0) { - await apiCall("POST", "/v2/phish/caught/bulk", result); - caughtDomainTrackingMap = new Map(); - } -} - -export async function deleteStalePhishermanCacheEntries() { - await getCacheRepository().createQueryBuilder().where("expires_at <= NOW()").delete().execute(); -} - -export async function deleteStalePhishermanKeyCacheEntries() { - await getKeyCacheRepository().createQueryBuilder().where("expires_at <= NOW()").delete().execute(); -} diff --git a/backend/src/data/entities/PhishermanCacheEntry.ts b/backend/src/data/entities/PhishermanCacheEntry.ts deleted file mode 100644 index e7dd0bdd..00000000 --- a/backend/src/data/entities/PhishermanCacheEntry.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Column, Entity, PrimaryColumn } from "typeorm"; -import { PhishermanDomainInfo } from "../types/phisherman.js"; - -@Entity("phisherman_cache") -export class PhishermanCacheEntry { - @Column() - @PrimaryColumn() - id: number; - - @Column() - domain: string; - - @Column("simple-json") - data: PhishermanDomainInfo; - - @Column() - expires_at: string; -} diff --git a/backend/src/data/entities/PhishermanKeyCacheEntry.ts b/backend/src/data/entities/PhishermanKeyCacheEntry.ts deleted file mode 100644 index c4286d1c..00000000 --- a/backend/src/data/entities/PhishermanKeyCacheEntry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Column, Entity, PrimaryColumn } from "typeorm"; - -@Entity("phisherman_key_cache") -export class PhishermanKeyCacheEntry { - @Column() - @PrimaryColumn() - id: number; - - @Column() - hash: string; - - @Column() - is_valid: boolean; - - @Column() - expires_at: string; -} diff --git a/backend/src/data/loops/expiringMutesLoop.ts b/backend/src/data/loops/expiringMutesLoop.ts index 5e163559..6f1a68ff 100644 --- a/backend/src/data/loops/expiringMutesLoop.ts +++ b/backend/src/data/loops/expiringMutesLoop.ts @@ -16,7 +16,7 @@ function muteToKey(mute: Mute) { return `${mute.guild_id}/${mute.user_id}`; } -async function broadcastExpiredMute(guildId: string, userId: string, tries = 0) { +async function broadcastExpiredMute(guildId: string, userId: string, tries = 0): Promise { const mute = await getMutesRepository().findMute(guildId, userId); if (!mute) { // Mute was already cleared @@ -27,7 +27,7 @@ async function broadcastExpiredMute(guildId: string, userId: string, tries = 0) return; } - console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`); + // console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`); if (!hasGuildEventListener(mute.guild_id, "expiredMute")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { @@ -42,7 +42,7 @@ async function broadcastExpiredMute(guildId: string, userId: string, tries = 0) } function broadcastTimeoutMuteToRenew(mute: Mute, tries = 0) { - console.log(`[EXPIRING MUTES LOOP] Broadcasting timeout mute to renew: ${mute.guild_id}/${mute.user_id}`); + // console.log(`[EXPIRING MUTES LOOP] Broadcasting timeout mute to renew: ${mute.guild_id}/${mute.user_id}`); if (!hasGuildEventListener(mute.guild_id, "timeoutMuteToRenew")) { // If there are no listeners registered for the server yet, try again in a bit if (tries < MAX_TRIES_PER_SERVER) { diff --git a/backend/src/data/loops/phishermanLoops.ts b/backend/src/data/loops/phishermanLoops.ts deleted file mode 100644 index 2550d450..00000000 --- a/backend/src/data/loops/phishermanLoops.ts +++ /dev/null @@ -1,28 +0,0 @@ -// tslint:disable:no-console - -import { MINUTES } from "../../utils.js"; -import { - deleteStalePhishermanCacheEntries, - deleteStalePhishermanKeyCacheEntries, - reportTrackedDomainsToPhisherman, -} from "../Phisherman.js"; - -const CACHE_CLEANUP_LOOP_INTERVAL = 15 * MINUTES; -const REPORT_LOOP_INTERVAL = 15 * MINUTES; - -export async function runPhishermanCacheCleanupLoop() { - console.log("[PHISHERMAN] Deleting stale cache entries"); - await deleteStalePhishermanCacheEntries().catch((err) => console.warn(err)); - - console.log("[PHISHERMAN] Deleting stale key cache entries"); - await deleteStalePhishermanKeyCacheEntries().catch((err) => console.warn(err)); - - setTimeout(() => runPhishermanCacheCleanupLoop(), CACHE_CLEANUP_LOOP_INTERVAL); -} - -export async function runPhishermanReportingLoop() { - console.log("[PHISHERMAN] Reporting tracked domains"); - await reportTrackedDomainsToPhisherman().catch((err) => console.warn(err)); - - setTimeout(() => runPhishermanReportingLoop(), REPORT_LOOP_INTERVAL); -} diff --git a/backend/src/data/types/phisherman.ts b/backend/src/data/types/phisherman.ts deleted file mode 100644 index 418fd43a..00000000 --- a/backend/src/data/types/phisherman.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface PhishermanUnknownDomain { - classification: "unknown"; -} - -export interface PhishermanDomainInfo { - status: string; - lastChecked: string; - verifiedPhish: boolean; - classification: "safe" | "malicious"; - created: string; - firstSeen: string | null; - lastSeen: string | null; - targetedBrand: string; - phishCaught: number; - details: PhishermanDomainInfoDetails; -} - -export interface PhishermanDomainInfoDetails { - phishTankId: string | null; - urlScanId: string; - websiteScreenshot: string; - ip_address: string; - asn: PhishermanDomainInfoAsn; - registry: string; - country: string; -} - -export interface PhishermanDomainInfoAsn { - asn: string; - asn_name: string; - route: string; -} diff --git a/backend/src/env.ts b/backend/src/env.ts index e4b9214d..260abbe9 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -1,7 +1,7 @@ import dotenv from "dotenv"; import fs from "fs"; import path from "path"; -import { z } from "zod"; +import { z } from "zod/v4"; import { rootDir } from "./paths.js"; const envType = z.object({ @@ -37,6 +37,10 @@ const envType = z.object({ .optional(), PHISHERMAN_API_KEY: z.string().optional(), + FISHFISH_API_KEY: z.string().optional(), + + DEFAULT_SUCCESS_EMOJI: z.string().optional().default("✅"), + DEFAULT_ERROR_EMOJI: z.string().optional().default("❌"), DB_HOST: z.string().optional(), DB_PORT: z.preprocess((v) => Number(v), z.number()).optional(), diff --git a/backend/src/exportSchemas.ts b/backend/src/exportSchemas.ts index 7298ff13..003a1216 100644 --- a/backend/src/exportSchemas.ts +++ b/backend/src/exportSchemas.ts @@ -1,23 +1,91 @@ -import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { guildPluginInfo } from "./plugins/pluginInfo.js"; +import fs from "node:fs"; +import { z } from "zod/v4"; +import { availableGuildPlugins } from "./plugins/availablePlugins.js"; import { zZeppelinGuildConfig } from "./types.js"; +import { deepPartial } from "./utils/zodDeepPartial.js"; -const pluginSchemaMap = Object.entries(guildPluginInfo).reduce((map, [pluginName, pluginInfo]) => { - if (pluginInfo.configSchema) { - map[pluginName] = pluginInfo.configSchema; +const basePluginOverrideCriteriaSchema = z.strictObject({ + channel: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + category: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + level: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + user: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + role: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + thread: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + is_thread: z.boolean().nullable().optional(), + thread_type: z.literal(["public", "private"]).nullable().optional(), + extra: z.any().optional(), +}); + +const pluginOverrideCriteriaSchema = basePluginOverrideCriteriaSchema.extend({ + get zzz_dummy_property_do_not_use() { + return pluginOverrideCriteriaSchema.optional(); + }, + get all() { + return z.array(pluginOverrideCriteriaSchema).optional(); + }, + get any() { + return z.array(pluginOverrideCriteriaSchema).optional(); + }, + get not() { + return pluginOverrideCriteriaSchema.optional(); + }, +}).meta({ + id: "overrideCriteria", +}); + +const outputPath = process.argv[2]; +if (!outputPath) { + console.error("Output path required"); + process.exit(1); +} + +const partialConfigs = new Map(); +function getPartialConfig(configSchema: z.ZodType) { + if (!partialConfigs.has(configSchema)) { + partialConfigs.set(configSchema, deepPartial(configSchema)); } + return partialConfigs.get(configSchema)!; +} + +function overrides(configSchema: z.ZodType): z.ZodType { + const partialConfig = getPartialConfig(configSchema); + return pluginOverrideCriteriaSchema.extend({ + config: partialConfig, + }); +} + +const pluginSchemaMap = availableGuildPlugins.reduce((map, pluginInfo) => { + map[pluginInfo.plugin.name] = z.object({ + config: pluginInfo.docs.configSchema.optional(), + overrides: z.array(overrides(pluginInfo.docs.configSchema)).optional(), + }); return map; }, {}); -const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).merge( - z.strictObject({ - plugins: z.strictObject(pluginSchemaMap).partial(), - }), -); +const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).extend({ + plugins: z.strictObject(pluginSchemaMap).partial().optional(), +}); -const jsonSchema = zodToJsonSchema(fullSchema); +const jsonSchema = z.toJSONSchema(fullSchema, { io: "input", cycles: "ref" }); -console.log(JSON.stringify(jsonSchema, null, 2)); +fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2), { encoding: "utf8" }); process.exit(0); diff --git a/backend/src/humanizeDuration.ts b/backend/src/humanizeDuration.ts new file mode 100644 index 00000000..787fd210 --- /dev/null +++ b/backend/src/humanizeDuration.ts @@ -0,0 +1,34 @@ +import humanizeduration from "humanize-duration"; + +export const delayStringMultipliers = { + y: 1000 * 60 * 60 * 24 * (365 + 1 / 4 - 1 / 100 + 1 / 400), + mo: (1000 * 60 * 60 * 24 * (365 + 1 / 4 - 1 / 100 + 1 / 400)) / 12, + w: 1000 * 60 * 60 * 24 * 7, + d: 1000 * 60 * 60 * 24, + h: 1000 * 60 * 60, + m: 1000 * 60, + s: 1000, + x: 1, +}; + +export const humanizeDurationShort = humanizeduration.humanizer({ + language: "shortEn", + languages: { + shortEn: { + y: () => "y", + mo: () => "mo", + w: () => "w", + d: () => "d", + h: () => "h", + m: () => "m", + s: () => "s", + ms: () => "ms", + }, + }, + spacer: "", + unitMeasures: delayStringMultipliers, +}); + +export const humanizeDuration = humanizeduration.humanizer({ + unitMeasures: delayStringMultipliers, +}); diff --git a/backend/src/humanizeDurationShort.ts b/backend/src/humanizeDurationShort.ts deleted file mode 100644 index 05b8ad39..00000000 --- a/backend/src/humanizeDurationShort.ts +++ /dev/null @@ -1,18 +0,0 @@ -import humanizeDuration from "humanize-duration"; - -export const humanizeDurationShort = humanizeDuration.humanizer({ - language: "shortEn", - languages: { - shortEn: { - y: () => "y", - mo: () => "mo", - w: () => "w", - d: () => "d", - h: () => "h", - m: () => "m", - s: () => "s", - ms: () => "ms", - }, - }, - spacer: "", -}); diff --git a/backend/src/index.ts b/backend/src/index.ts index e74f7629..4c1c12d6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,7 +12,6 @@ import { TextChannel, ThreadChannel, } from "discord.js"; -import { EventEmitter } from "events"; import { Knub, PluginError, PluginLoadError, PluginNotLoadedError } from "knub"; import moment from "moment-timezone"; import { performance } from "perf_hooks"; @@ -22,9 +21,9 @@ import { RecoverablePluginError } from "./RecoverablePluginError.js"; import { SimpleError } from "./SimpleError.js"; import { AllowedGuilds } from "./data/AllowedGuilds.js"; import { Configs } from "./data/Configs.js"; +import { FishFishError, initFishFish } from "./data/FishFish.js"; import { GuildLogs } from "./data/GuildLogs.js"; import { LogType } from "./data/LogType.js"; -import { hasPhishermanMasterAPIKey } from "./data/Phisherman.js"; import { dataSource } from "./data/dataSource.js"; import { connect } from "./data/db.js"; import { runExpiredArchiveDeletionLoop } from "./data/loops/expiredArchiveDeletionLoop.js"; @@ -33,14 +32,13 @@ import { runExpiringMutesLoop } from "./data/loops/expiringMutesLoop.js"; import { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop.js"; import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop.js"; import { runMemberCacheDeletionLoop } from "./data/loops/memberCacheDeletionLoop.js"; -import { runPhishermanCacheCleanupLoop, runPhishermanReportingLoop } from "./data/loops/phishermanLoops.js"; import { runSavedMessageCleanupLoop } from "./data/loops/savedMessageCleanupLoop.js"; import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop.js"; import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop.js"; import { consumeQueryStats } from "./data/queryLogger.js"; import { env } from "./env.js"; import { logger } from "./logger.js"; -import { baseGuildPlugins, globalPlugins, guildPlugins } from "./plugins/availablePlugins.js"; +import { availableGlobalPlugins, availableGuildPlugins } from "./plugins/availablePlugins.js"; import { setProfiler } from "./profiler.js"; import { logRateLimit } from "./rateLimitStats.js"; import { startUptimeCounter } from "./uptime.js"; @@ -143,6 +141,12 @@ function errorHandler(err) { return; } + if (err instanceof FishFishError) { + // FishFish errors are not critical, so we just log them + console.error(`[FISHFISH] ${err.message}`); + return; + } + // tslint:disable:no-console console.error(err); @@ -166,10 +170,8 @@ function errorHandler(err) { // tslint:enable:no-console } -if (process.env.NODE_ENV === "production") { - process.on("uncaughtException", errorHandler); - process.on("unhandledRejection", errorHandler); -} +process.on("uncaughtException", errorHandler); +process.on("unhandledRejection", errorHandler); // Verify required Node.js version const REQUIRED_NODE_VERSION = "16.9.0"; @@ -252,8 +254,8 @@ connect().then(async () => { GatewayIntentBits.GuildVoiceStates, ], }); - // FIXME: TS doesn't see Client as a child of EventEmitter for some reason - (client as unknown as EventEmitter).setMaxListeners(200); + + client.setMaxListeners(200); const safe429DecayInterval = 5 * SECONDS; const safe429MaxCount = 5; @@ -275,6 +277,10 @@ connect().then(async () => { }); client.on("error", (err) => { + if (err instanceof PluginLoadError) { + errorHandler(err); + return; + } errorHandler(new DiscordJSError(err.message, (err as any).code, 0)); }); @@ -282,8 +288,8 @@ connect().then(async () => { const guildConfigs = new Configs(); const bot = new Knub(client, { - guildPlugins, - globalPlugins, + guildPlugins: availableGuildPlugins.map((obj) => obj.plugin), + globalPlugins: availableGlobalPlugins.map((obj) => obj.plugin), options: { canLoadGuild(guildId): Promise { @@ -292,7 +298,7 @@ connect().then(async () => { /** * Plugins are enabled if they... - * - are base plugins, i.e. always enabled, or + * - are marked to be autoloaded, or * - are explicitly enabled in the guild config * Dependencies are also automatically loaded by Knub. */ @@ -302,10 +308,10 @@ connect().then(async () => { } const configuredPlugins = ctx.config.plugins; - const basePluginNames = baseGuildPlugins.map((p) => p.name); + const autoloadPluginNames = availableGuildPlugins.filter((obj) => obj.autoload).map((obj) => obj.plugin.name); return Array.from(plugins.keys()).filter((pluginName) => { - if (basePluginNames.includes(pluginName)) return true; + if (autoloadPluginNames.includes(pluginName)) return true; return configuredPlugins[pluginName] && (configuredPlugins[pluginName] as any).enabled !== false; }); }, @@ -323,12 +329,30 @@ connect().then(async () => { if (row) { try { const loaded = loadYamlSafely(row.config); + + if (loaded.success_emoji || loaded.error_emoji) { + const deprecatedKeys = [] as string[]; + const exampleConfig = `plugins:\n common:\n config:\n success_emoji: "👍"\n error_emoji: "👎"`; + + if (loaded.success_emoji) { + deprecatedKeys.push("success_emoji"); + } + + if (loaded.error_emoji) { + deprecatedKeys.push("error_emoji"); + } + + // logger.warn(`Deprecated config properties found in "${key}": ${deprecatedKeys.join(", ")}`); + // logger.warn(`You can now configure those emojis in the "common" plugin config\n${exampleConfig}`); + } + // Remove deprecated properties some may still have in their config delete loaded.success_emoji; delete loaded.error_emoji; + return loaded; } catch (err) { - logger.error(`Error while loading config "${key}": ${err.message}`); + logger.error(`Error while loading config "${key}"`); return {}; } } @@ -385,6 +409,8 @@ connect().then(async () => { enableProfiling(); } + initFishFish(); + runExpiringMutesLoop(); await sleep(10 * SECONDS); runExpiringTempbansLoop(); @@ -402,13 +428,6 @@ connect().then(async () => { runExpiredMemberCacheDeletionLoop(); await sleep(10 * SECONDS); runMemberCacheDeletionLoop(); - - if (hasPhishermanMasterAPIKey()) { - await sleep(10 * SECONDS); - runPhishermanCacheCleanupLoop(); - await sleep(10 * SECONDS); - runPhishermanReportingLoop(); - } }); let lowestGlobalRemaining = Infinity; diff --git a/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts b/backend/src/migrations/1556909512501-MigrateUsernamesToNewHistoryTable.ts index 33dc43c3..2d1be496 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); diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 8cbc5b95..6d9c0cf8 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -3,18 +3,35 @@ */ import { + BitField, + BitFieldResolvable, + ChatInputCommandInteraction, + CommandInteraction, GuildMember, + InteractionEditReplyOptions, + InteractionReplyOptions, + InteractionResponse, Message, MessageCreateOptions, - MessageMentionOptions, + MessageEditOptions, + MessageFlags, + MessageFlagsString, + ModalSubmitInteraction, PermissionsBitField, TextBasedChannel, } from "discord.js"; -import { AnyPluginData, BasePluginData, CommandContext, ExtendedMatchParams, GuildPluginData, helpers } from "knub"; -import { logger } from "./logger.js"; +import { + AnyPluginData, + BasePluginData, + CommandContext, + ExtendedMatchParams, + GuildPluginData, + helpers, + PluginConfigManager, +} from "knub"; +import z from "zod/v4"; import { isStaff } from "./staff.js"; import { TZeppelinKnub } from "./types.js"; -import { errorMessage, successMessage } from "./utils.js"; import { Tail } from "./utils/typeUtils.js"; const { getMemberLevel } = helpers; @@ -49,46 +66,118 @@ export async function hasPermission( return helpers.hasPermission(config, permission); } -export async function sendSuccessMessage( - pluginData: AnyPluginData, - channel: TextBasedChannel, - body: string, - allowedMentions?: MessageMentionOptions, -): Promise { - const emoji = pluginData.fullConfig.success_emoji || undefined; - const formattedBody = successMessage(body, emoji); - const content: MessageCreateOptions = allowedMentions - ? { content: formattedBody, allowedMentions } - : { content: formattedBody }; +export type GenericCommandSource = Message | CommandInteraction | ModalSubmitInteraction; - return channel - .send({ ...content }) // Force line break - .catch((err) => { - const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; - logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); - return undefined; - }); +export function isContextInteraction( + context: GenericCommandSource, +): context is CommandInteraction | ModalSubmitInteraction { + return context instanceof CommandInteraction || context instanceof ModalSubmitInteraction; } -export async function sendErrorMessage( - pluginData: AnyPluginData, - channel: TextBasedChannel, - body: string, - allowedMentions?: MessageMentionOptions, -): Promise { - const emoji = pluginData.fullConfig.error_emoji || undefined; - const formattedBody = errorMessage(body, emoji); - const content: MessageCreateOptions = allowedMentions - ? { content: formattedBody, allowedMentions } - : { content: formattedBody }; +export function isContextMessage(context: GenericCommandSource): context is Message { + return context instanceof Message; +} - return channel - .send({ ...content }) // Force line break - .catch((err) => { - const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; - logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); - return undefined; +export async function getContextChannel(context: GenericCommandSource): Promise { + if (isContextInteraction(context)) { + return context.channel; + } + if (context instanceof Message) { + return context.channel; + } + throw new Error("Unknown context type"); +} + +export function getContextChannelId(context: GenericCommandSource): string | null { + return context.channelId; +} + +export async function fetchContextChannel(context: GenericCommandSource) { + if (!context.guild) { + throw new Error("Missing context guild"); + } + const channelId = getContextChannelId(context); + if (!channelId) { + throw new Error("Missing context channel ID"); + } + return (await context.guild.channels.fetch(channelId))!; +} + +function flagsWithEphemeral( + flags: BitFieldResolvable, + ephemeral: boolean, +): BitFieldResolvable, TType | MessageFlags.Ephemeral> { + if (!ephemeral) { + return flags; + } + return new BitField(flags).add(MessageFlags.Ephemeral) as any; +} + +export type ContextResponseOptions = MessageCreateOptions & InteractionReplyOptions & InteractionEditReplyOptions; +export type ContextResponse = Message | InteractionResponse; + +export async function sendContextResponse( + context: GenericCommandSource, + content: string | ContextResponseOptions, + ephemeral = false, +): Promise { + if (isContextInteraction(context)) { + const options = { ...(typeof content === "string" ? { content: content } : content), fetchReply: true }; + + if (context.replied) { + return context.followUp({ + ...options, + flags: flagsWithEphemeral(options.flags, ephemeral), + }); + } + if (context.deferred) { + return context.editReply(options); + } + + const replyResult = await context.reply({ + ...options, + flags: flagsWithEphemeral(options.flags, ephemeral), + withResponse: true, }); + return replyResult.resource!.message!; + } + + const contextChannel = await fetchContextChannel(context); + if (!contextChannel?.isSendable()) { + throw new Error("Context channel does not exist or is not sendable"); + } + + return contextChannel.send(content); +} + +export type ContextResponseEditOptions = MessageEditOptions & InteractionEditReplyOptions; + +export function editContextResponse( + response: ContextResponse, + content: string | ContextResponseEditOptions, +): Promise { + return response.edit(content); +} + +export async function deleteContextResponse(response: ContextResponse): Promise { + await response.delete(); +} + +export async function getConfigForContext>( + config: PluginConfigManager, + context: GenericCommandSource, +): Promise> { + if (context instanceof ChatInputCommandInteraction) { + // TODO: Support for modal interactions (here and Knub) + return config.getForInteraction(context); + } + const channel = await getContextChannel(context); + const member = isContextMessage(context) && context.inGuild() ? await resolveMessageMember(context) : null; + + return config.getMatchingConfig({ + channel, + member, + }); } export function getBaseUrl(pluginData: AnyPluginData) { @@ -136,4 +225,6 @@ export function makePublicFn, T extends }; } -// ??? +export function resolveMessageMember(message: Message) { + return Promise.resolve(message.member || message.guild.members.fetch(message.author.id)); +} diff --git a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts index ab9d8ba7..a60b800f 100644 --- a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts +++ b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts @@ -1,4 +1,4 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; @@ -8,19 +8,11 @@ import { onMessageCreate } from "./util/onMessageCreate.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk.js"; -const defaultOptions: PluginOptions = { - config: { - enabled: false, - delay: "5s", - }, -}; - export const AutoDeletePlugin = guildPlugin()({ name: "auto_delete", dependencies: () => [TimeAndDatePlugin, LogsPlugin], - configParser: (input) => zAutoDeleteConfig.parse(input), - defaultOptions, + configSchema: zAutoDeleteConfig, beforeLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/AutoDelete/info.ts b/backend/src/plugins/AutoDelete/docs.ts similarity index 67% rename from backend/src/plugins/AutoDelete/info.ts rename to backend/src/plugins/AutoDelete/docs.ts index dc4ac730..fb661019 100644 --- a/backend/src/plugins/AutoDelete/info.ts +++ b/backend/src/plugins/AutoDelete/docs.ts @@ -1,10 +1,11 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { zAutoDeleteConfig } from "./types.js"; -export const autoDeletePluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const autoDeletePluginDocs: ZeppelinPluginDocs = { + type: "stable", + configSchema: zAutoDeleteConfig, + prettyName: "Auto-delete", description: "Allows Zeppelin to auto-delete messages from a channel after a delay", configurationGuide: "Maximum deletion delay is currently 5 minutes", - configSchema: zAutoDeleteConfig, }; diff --git a/backend/src/plugins/AutoDelete/types.ts b/backend/src/plugins/AutoDelete/types.ts index 749eec49..32260d61 100644 --- a/backend/src/plugins/AutoDelete/types.ts +++ b/backend/src/plugins/AutoDelete/types.ts @@ -1,5 +1,5 @@ import { BasePluginType } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { SavedMessage } from "../../data/entities/SavedMessage.js"; @@ -14,12 +14,12 @@ export interface IDeletionQueueItem { } export const zAutoDeleteConfig = z.strictObject({ - enabled: z.boolean(), - delay: zDelayString, + enabled: z.boolean().default(false), + delay: zDelayString.default("5s"), }); export interface AutoDeletePluginType extends BasePluginType { - config: z.output; + configSchema: typeof zAutoDeleteConfig; state: { guildSavedMessages: GuildSavedMessages; guildLogs: GuildLogs; diff --git a/backend/src/plugins/AutoDelete/util/deleteNextItem.ts b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts index af40ac93..217f69cb 100644 --- a/backend/src/plugins/AutoDelete/util/deleteNextItem.ts +++ b/backend/src/plugins/AutoDelete/util/deleteNextItem.ts @@ -1,4 +1,4 @@ -import { ChannelType, PermissionsBitField, Snowflake } from "discord.js"; +import { PermissionsBitField, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { LogType } from "../../../data/LogType.js"; @@ -17,8 +17,8 @@ export async function deleteNextItem(pluginData: GuildPluginData = { - config: { - can_manage: false, - }, - overrides: [ - { - level: ">=100", - config: { - can_manage: true, - }, +const defaultOverrides: Array> = [ + { + level: ">=100", + config: { + can_manage: true, }, - ], -}; + }, +]; export const AutoReactionsPlugin = guildPlugin()({ name: "auto_reactions", @@ -29,8 +25,8 @@ export const AutoReactionsPlugin = guildPlugin()({ LogsPlugin, ], - configParser: (input) => zAutoReactionsConfig.parse(input), - defaultOptions, + configSchema: zAutoReactionsConfig, + defaultOverrides, // prettier-ignore messageCommands: [ @@ -50,4 +46,8 @@ export const AutoReactionsPlugin = guildPlugin()({ state.autoReactions = GuildAutoReactions.getGuildInstance(guild.id); state.cache = new Map(); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts b/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts index fc950aa7..eba1f92b 100644 --- a/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts +++ b/backend/src/plugins/AutoReactions/commands/DisableAutoReactionsCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { autoReactionsCmd } from "../types.js"; export const DisableAutoReactionsCmd = autoReactionsCmd({ @@ -14,12 +13,12 @@ export const DisableAutoReactionsCmd = autoReactionsCmd({ async run({ message: msg, args, pluginData }) { const autoReaction = await pluginData.state.autoReactions.getForChannel(args.channelId); if (!autoReaction) { - sendErrorMessage(pluginData, msg.channel, `Auto-reactions aren't enabled in <#${args.channelId}>`); + void pluginData.state.common.sendErrorMessage(msg, `Auto-reactions aren't enabled in <#${args.channelId}>`); return; } await pluginData.state.autoReactions.removeFromChannel(args.channelId); pluginData.state.cache.delete(args.channelId); - sendSuccessMessage(pluginData, msg.channel, `Auto-reactions disabled in <#${args.channelId}>`); + void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions disabled in <#${args.channelId}>`); }, }); diff --git a/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts b/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts index 6eed443a..c1250b53 100644 --- a/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts +++ b/backend/src/plugins/AutoReactions/commands/NewAutoReactionsCmd.ts @@ -1,6 +1,5 @@ import { PermissionsBitField } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { canUseEmoji, customEmojiRegex, isEmoji } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -25,9 +24,8 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingChannelPermissions(me, args.channel, requiredPermissions); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + pluginData.state.common.sendErrorMessage( + msg, `Cannot set auto-reactions for that channel. ${missingPermissionError(missingPermissions)}`, ); return; @@ -35,7 +33,7 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ for (const reaction of args.reactions) { if (!isEmoji(reaction)) { - sendErrorMessage(pluginData, msg.channel, "One or more of the specified reactions were invalid!"); + void pluginData.state.common.sendErrorMessage(msg, "One or more of the specified reactions were invalid!"); return; } @@ -45,7 +43,10 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ if (customEmojiMatch) { // Custom emoji if (!canUseEmoji(pluginData.client, customEmojiMatch[2])) { - sendErrorMessage(pluginData, msg.channel, "I can only use regular emojis and custom emojis from this server"); + pluginData.state.common.sendErrorMessage( + msg, + "I can only use regular emojis and custom emojis from this server", + ); return; } @@ -60,6 +61,6 @@ export const NewAutoReactionsCmd = autoReactionsCmd({ await pluginData.state.autoReactions.set(args.channel.id, finalReactions); pluginData.state.cache.delete(args.channel.id); - sendSuccessMessage(pluginData, msg.channel, `Auto-reactions set for <#${args.channel.id}>`); + void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions set for <#${args.channel.id}>`); }, }); diff --git a/backend/src/plugins/AutoReactions/info.ts b/backend/src/plugins/AutoReactions/docs.ts similarity index 69% rename from backend/src/plugins/AutoReactions/info.ts rename to backend/src/plugins/AutoReactions/docs.ts index a2817502..8d81e060 100644 --- a/backend/src/plugins/AutoReactions/info.ts +++ b/backend/src/plugins/AutoReactions/docs.ts @@ -1,12 +1,13 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zAutoReactionsConfig } from "./types.js"; -export const autoReactionsInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const autoReactionsPluginDocs: ZeppelinPluginDocs = { + type: "stable", + configSchema: zAutoReactionsConfig, + prettyName: "Auto-reactions", description: trimPluginDescription(` Allows setting up automatic reactions to all new messages on a channel `), - configSchema: zAutoReactionsConfig, }; diff --git a/backend/src/plugins/AutoReactions/types.ts b/backend/src/plugins/AutoReactions/types.ts index f794b246..1d234a35 100644 --- a/backend/src/plugins/AutoReactions/types.ts +++ b/backend/src/plugins/AutoReactions/types.ts @@ -1,21 +1,23 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildAutoReactions } from "../../data/GuildAutoReactions.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { AutoReaction } from "../../data/entities/AutoReaction.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zAutoReactionsConfig = z.strictObject({ - can_manage: z.boolean(), + can_manage: z.boolean().default(false), }); export interface AutoReactionsPluginType extends BasePluginType { - config: z.output; + configSchema: typeof zAutoReactionsConfig; state: { logs: GuildLogs; savedMessages: GuildSavedMessages; autoReactions: GuildAutoReactions; cache: Map; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 873f10fe..9e260cdf 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -8,6 +8,7 @@ import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; import { MINUTES, SECONDS } from "../../utils.js"; import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap.js"; import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { CountersPlugin } from "../Counters/CountersPlugin.js"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; @@ -33,29 +34,6 @@ import { clearOldRecentActions } from "./functions/clearOldRecentActions.js"; import { clearOldRecentSpam } from "./functions/clearOldRecentSpam.js"; import { AutomodPluginType, zAutomodConfig } from "./types.js"; -const defaultOptions = { - config: { - rules: {}, - antiraid_levels: ["low", "medium", "high"], - can_set_antiraid: false, - can_view_antiraid: false, - }, - overrides: [ - { - level: ">=50", - config: { - can_view_antiraid: true, - }, - }, - { - level: ">=100", - config: { - can_set_antiraid: true, - }, - }, - ], -}; - export const AutomodPlugin = guildPlugin()({ name: "automod", @@ -70,8 +48,7 @@ export const AutomodPlugin = guildPlugin()({ RoleManagerPlugin, ], - defaultOptions, - configParser: (input) => zAutomodConfig.parse(input), + configSchema: zAutomodConfig, customOverrideCriteriaFunctions: { antiraid_level: (pluginData, matchParams, value) => { @@ -117,6 +94,10 @@ export const AutomodPlugin = guildPlugin()({ state.cachedAntiraidLevel = await state.antiraidLevels.get(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + async afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts index a1204314..57726e21 100644 --- a/backend/src/plugins/Automod/actions/addRoles.ts +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -1,5 +1,5 @@ import { PermissionFlagsBits, Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { nonNullish, unique, zSnowflake } from "../../../utils.js"; import { canAssignRole } from "../../../utils/canAssignRole.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; diff --git a/backend/src/plugins/Automod/actions/addToCounter.ts b/backend/src/plugins/Automod/actions/addToCounter.ts index 17a74462..1225651b 100644 --- a/backend/src/plugins/Automod/actions/addToCounter.ts +++ b/backend/src/plugins/Automod/actions/addToCounter.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { zBoundedCharacters } from "../../../utils.js"; import { CountersPlugin } from "../../Counters/CountersPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts index 02cc5565..56a70f23 100644 --- a/backend/src/plugins/Automod/actions/alert.ts +++ b/backend/src/plugins/Automod/actions/alert.ts @@ -1,5 +1,5 @@ import { Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer, diff --git a/backend/src/plugins/Automod/actions/archiveThread.ts b/backend/src/plugins/Automod/actions/archiveThread.ts index ca1e6242..ee891669 100644 --- a/backend/src/plugins/Automod/actions/archiveThread.ts +++ b/backend/src/plugins/Automod/actions/archiveThread.ts @@ -1,5 +1,5 @@ import { AnyThreadChannel } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { noop } from "../../../utils.js"; import { automodAction } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts index 62a27e15..605aed0a 100644 --- a/backend/src/plugins/Automod/actions/ban.ts +++ b/backend/src/plugins/Automod/actions/ban.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { convertDelayStringToMS, nonNullish, @@ -47,6 +47,7 @@ export const BanAction = automodAction({ await modActions.banUserId( userId, reason, + reason, { contactMethods, caseArgs, diff --git a/backend/src/plugins/Automod/actions/changeNickname.ts b/backend/src/plugins/Automod/actions/changeNickname.ts index a2a9ad4a..3af2567a 100644 --- a/backend/src/plugins/Automod/actions/changeNickname.ts +++ b/backend/src/plugins/Automod/actions/changeNickname.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { nonNullish, unique, zBoundedCharacters } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts index 87f302d5..e70bbb05 100644 --- a/backend/src/plugins/Automod/actions/changePerms.ts +++ b/backend/src/plugins/Automod/actions/changePerms.ts @@ -1,6 +1,6 @@ import { PermissionsBitField, PermissionsString } from "discord.js"; import { U } from "ts-toolbelt"; -import z from "zod"; +import z from "zod/v4"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { isValidSnowflake, keys, noop, zBoundedCharacters } from "../../../utils.js"; import { @@ -65,11 +65,17 @@ const permissionNames = keys(PermissionsBitField.Flags) as U.ListOf; const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const; +const permissionTypeMap = allPermissionNames.reduce((map, permName) => { + map[permName] = z.boolean().nullable(); + return map; +}, {} as Record>); +const zPermissionsMap = z.strictObject(permissionTypeMap); + export const ChangePermsAction = automodAction({ configSchema: z.strictObject({ target: zBoundedCharacters(1, 2000), channel: zBoundedCharacters(1, 2000).nullable().default(null), - perms: z.record(z.enum(allPermissionNames), z.boolean().nullable()), + perms: zPermissionsMap.partial(), }), async apply({ pluginData, contexts, actionConfig, ruleName }) { diff --git a/backend/src/plugins/Automod/actions/clean.ts b/backend/src/plugins/Automod/actions/clean.ts index c44cd67a..35eaf4f8 100644 --- a/backend/src/plugins/Automod/actions/clean.ts +++ b/backend/src/plugins/Automod/actions/clean.ts @@ -1,5 +1,5 @@ import { GuildTextBasedChannel, Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { LogType } from "../../../data/LogType.js"; import { noop } from "../../../utils.js"; import { automodAction } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/actions/exampleAction.ts b/backend/src/plugins/Automod/actions/exampleAction.ts index f5406694..fcfc62be 100644 --- a/backend/src/plugins/Automod/actions/exampleAction.ts +++ b/backend/src/plugins/Automod/actions/exampleAction.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { zBoundedCharacters } from "../../../utils.js"; import { automodAction } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts index a1d7ef7c..0485818c 100644 --- a/backend/src/plugins/Automod/actions/kick.ts +++ b/backend/src/plugins/Automod/actions/kick.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; @@ -33,7 +33,7 @@ export const KickAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToKick) { if (!member) continue; - await modActions.kickMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); + await modActions.kickMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/actions/log.ts b/backend/src/plugins/Automod/actions/log.ts index 4b2af000..92928455 100644 --- a/backend/src/plugins/Automod/actions/log.ts +++ b/backend/src/plugins/Automod/actions/log.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { isTruthy, unique } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts index 3708e71e..a00e2fd9 100644 --- a/backend/src/plugins/Automod/actions/mute.ts +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { convertDelayStringToMS, @@ -57,6 +57,7 @@ export const MuteAction = automodAction({ userId, duration, reason, + reason, { contactMethods, caseArgs, isAutomodAction: true }, rolesToRemove, rolesToRestore, diff --git a/backend/src/plugins/Automod/actions/pauseInvites.ts b/backend/src/plugins/Automod/actions/pauseInvites.ts index 2fef2b33..c5b32c01 100644 --- a/backend/src/plugins/Automod/actions/pauseInvites.ts +++ b/backend/src/plugins/Automod/actions/pauseInvites.ts @@ -1,5 +1,5 @@ import { GuildFeature } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { automodAction } from "../helpers.js"; export const PauseInvitesAction = automodAction({ diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts index 5c2cef66..ccd9e843 100644 --- a/backend/src/plugins/Automod/actions/removeRoles.ts +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -1,5 +1,5 @@ import { PermissionFlagsBits, Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { nonNullish, unique, zSnowflake } from "../../../utils.js"; import { canAssignRole } from "../../../utils/canAssignRole.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts index aade027d..c2ae6283 100644 --- a/backend/src/plugins/Automod/actions/reply.ts +++ b/backend/src/plugins/Automod/actions/reply.ts @@ -1,5 +1,5 @@ import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { convertDelayStringToMS, diff --git a/backend/src/plugins/Automod/actions/setCounter.ts b/backend/src/plugins/Automod/actions/setCounter.ts index 8b60a43a..e581799e 100644 --- a/backend/src/plugins/Automod/actions/setCounter.ts +++ b/backend/src/plugins/Automod/actions/setCounter.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../../data/GuildCounters.js"; import { zBoundedCharacters } from "../../../utils.js"; import { CountersPlugin } from "../../Counters/CountersPlugin.js"; diff --git a/backend/src/plugins/Automod/actions/setSlowmode.ts b/backend/src/plugins/Automod/actions/setSlowmode.ts index d23e1673..1f6b045a 100644 --- a/backend/src/plugins/Automod/actions/setSlowmode.ts +++ b/backend/src/plugins/Automod/actions/setSlowmode.ts @@ -1,12 +1,12 @@ import { ChannelType, GuildTextBasedChannel, Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { convertDelayStringToMS, isDiscordAPIError, zDelayString, zSnowflake } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { automodAction } from "../helpers.js"; export const SetSlowmodeAction = automodAction({ configSchema: z.strictObject({ - channels: z.array(zSnowflake), + channels: z.array(zSnowflake).nullable().default([]), duration: zDelayString.nullable().default("10s"), }), diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts index 81b18033..83f940c6 100644 --- a/backend/src/plugins/Automod/actions/startThread.ts +++ b/backend/src/plugins/Automod/actions/startThread.ts @@ -1,5 +1,5 @@ import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils.js"; import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts index 9c36f637..99fa0684 100644 --- a/backend/src/plugins/Automod/actions/warn.ts +++ b/backend/src/plugins/Automod/actions/warn.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; @@ -33,7 +33,7 @@ export const WarnAction = automodAction({ const modActions = pluginData.getPlugin(ModActionsPlugin); for (const member of membersToWarn) { if (!member) continue; - await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); + await modActions.warnMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true }); } }, }); diff --git a/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts b/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts index 187696ea..7686fd8c 100644 --- a/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts +++ b/backend/src/plugins/Automod/commands/AntiraidClearCmd.ts @@ -1,5 +1,4 @@ import { guildPluginMessageCommand } from "knub"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { AutomodPluginType } from "../types.js"; @@ -9,6 +8,6 @@ export const AntiraidClearCmd = guildPluginMessageCommand()({ async run({ pluginData, message }) { await setAntiraidLevel(pluginData, null, message.author); - sendSuccessMessage(pluginData, message.channel, "Anti-raid turned **off**"); + void pluginData.state.common.sendSuccessMessage(message, "Anti-raid turned **off**"); }, }); diff --git a/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts b/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts index cd942ea0..1b8beaf2 100644 --- a/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts +++ b/backend/src/plugins/Automod/commands/SetAntiraidCmd.ts @@ -1,6 +1,5 @@ import { guildPluginMessageCommand } from "knub"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { AutomodPluginType } from "../types.js"; @@ -15,11 +14,11 @@ export const SetAntiraidCmd = guildPluginMessageCommand()({ async run({ pluginData, message, args }) { const config = pluginData.config.get(); if (!config.antiraid_levels.includes(args.level)) { - sendErrorMessage(pluginData, message.channel, "Unknown anti-raid level"); + pluginData.state.common.sendErrorMessage(message, "Unknown anti-raid level"); return; } await setAntiraidLevel(pluginData, args.level, message.author); - sendSuccessMessage(pluginData, message.channel, `Anti-raid level set to **${args.level}**`); + pluginData.state.common.sendSuccessMessage(message, `Anti-raid level set to **${args.level}**`); }, }); diff --git a/backend/src/plugins/Automod/constants.ts b/backend/src/plugins/Automod/constants.ts index 034122e5..aecbc97d 100644 --- a/backend/src/plugins/Automod/constants.ts +++ b/backend/src/plugins/Automod/constants.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { MINUTES, SECONDS } from "../../utils.js"; export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS; diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/docs.ts similarity index 96% rename from backend/src/plugins/Automod/info.ts rename to backend/src/plugins/Automod/docs.ts index 5c3efeaf..0ba0ab38 100644 --- a/backend/src/plugins/Automod/info.ts +++ b/backend/src/plugins/Automod/docs.ts @@ -1,11 +1,12 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zAutomodConfig } from "./types.js"; -export const automodPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, - prettyName: "Automod", +export const automodPluginDocs: ZeppelinPluginDocs = { + type: "stable", configSchema: zAutomodConfig, + + prettyName: "Automod", description: trimPluginDescription(` Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention. `), diff --git a/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts index f2d9baf5..596e2148 100644 --- a/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts +++ b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts @@ -1,6 +1,5 @@ import { guildPluginEventListener } from "knub"; -import diff from "lodash/difference.js"; -import isEqual from "lodash/isEqual.js"; +import { difference, isEqual } from "lodash-es"; import { runAutomod } from "../functions/runAutomod.js"; import { AutomodContext, AutomodPluginType } from "../types.js"; @@ -15,8 +14,8 @@ export const RunAutomodOnMemberUpdate = guildPluginEventListener, rule: TRule, context: AutomodContext) { - const cooldownKey = `${rule.name}-${context.user?.id}`; +export function applyCooldown( + pluginData: GuildPluginData, + rule: TRule, + ruleName: string, + context: AutomodContext, +) { + const cooldownKey = `${ruleName}-${context.user?.id}`; const cooldownTime = convertDelayStringToMS(rule.cooldown, "s"); if (cooldownTime) pluginData.state.cooldownManager.setCooldown(cooldownKey, cooldownTime); diff --git a/backend/src/plugins/Automod/functions/checkCooldown.ts b/backend/src/plugins/Automod/functions/checkCooldown.ts index 0f45485f..bb7d1cec 100644 --- a/backend/src/plugins/Automod/functions/checkCooldown.ts +++ b/backend/src/plugins/Automod/functions/checkCooldown.ts @@ -1,8 +1,13 @@ import { GuildPluginData } from "knub"; import { AutomodContext, AutomodPluginType, TRule } from "../types.js"; -export function checkCooldown(pluginData: GuildPluginData, rule: TRule, context: AutomodContext) { - const cooldownKey = `${rule.name}-${context.user?.id}`; +export function checkCooldown( + pluginData: GuildPluginData, + rule: TRule, + ruleName: string, + context: AutomodContext, +) { + const cooldownKey = `${ruleName}-${context.user?.id}`; return pluginData.state.cooldownManager.isOnCooldown(cooldownKey); } diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts index 366d8746..d92e82cc 100644 --- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts +++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts @@ -1,6 +1,6 @@ -import z from "zod"; +import z from "zod/v4"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; -import { humanizeDurationShort } from "../../../humanizeDurationShort.js"; +import { humanizeDurationShort } from "../../../humanizeDuration.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils.js"; import { RecentActionType } from "../constants.js"; diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts index 63e5128f..df95fa27 100644 --- a/backend/src/plugins/Automod/functions/runAutomod.ts +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -49,7 +49,7 @@ export async function runAutomod(pluginData: GuildPluginData, } if (!rule.affects_self && userId && userId === pluginData.client.user?.id) continue; - if (rule.cooldown && checkCooldown(pluginData, rule, context)) { + if (rule.cooldown && checkCooldown(pluginData, rule, ruleName, context)) { continue; } @@ -87,7 +87,7 @@ export async function runAutomod(pluginData: GuildPluginData, } if (matchResult) { - if (rule.cooldown) applyCooldown(pluginData, rule, context); + if (rule.cooldown) applyCooldown(pluginData, rule, ruleName, context); contexts = [context, ...(matchResult.extraContexts || [])]; @@ -164,6 +164,18 @@ export async function runAutomod(pluginData: GuildPluginData, ); } } + + // Log all automod rules by default + if (rule.actions.log == null) { + availableActions.log.apply({ + ruleName, + pluginData, + contexts, + actionConfig: true, + matchResult, + prettyName, + }); + } } if (profilingEnabled()) { diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts index b4545193..df4f7b97 100644 --- a/backend/src/plugins/Automod/helpers.ts +++ b/backend/src/plugins/Automod/helpers.ts @@ -1,5 +1,5 @@ import { GuildPluginData } from "knub"; -import z, { ZodTypeAny } from "zod"; +import z, { ZodTypeAny } from "zod/v4"; import { Awaitable } from "../../utils/typeUtils.js"; import { AutomodContext, AutomodPluginType } from "./types.js"; diff --git a/backend/src/plugins/Automod/triggers/antiraidLevel.ts b/backend/src/plugins/Automod/triggers/antiraidLevel.ts index 1aacbd4d..7e7558ae 100644 --- a/backend/src/plugins/Automod/triggers/antiraidLevel.ts +++ b/backend/src/plugins/Automod/triggers/antiraidLevel.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; interface AntiraidLevelTriggerResult {} diff --git a/backend/src/plugins/Automod/triggers/anyMessage.ts b/backend/src/plugins/Automod/triggers/anyMessage.ts index 84f61426..95967dc2 100644 --- a/backend/src/plugins/Automod/triggers/anyMessage.ts +++ b/backend/src/plugins/Automod/triggers/anyMessage.ts @@ -1,5 +1,5 @@ import { Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { verboseChannelMention } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/ban.ts b/backend/src/plugins/Automod/triggers/ban.ts index 5ed8180d..25c03f79 100644 --- a/backend/src/plugins/Automod/triggers/ban.ts +++ b/backend/src/plugins/Automod/triggers/ban.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface diff --git a/backend/src/plugins/Automod/triggers/counterTrigger.ts b/backend/src/plugins/Automod/triggers/counterTrigger.ts index a9866eea..53fc8c5f 100644 --- a/backend/src/plugins/Automod/triggers/counterTrigger.ts +++ b/backend/src/plugins/Automod/triggers/counterTrigger.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line diff --git a/backend/src/plugins/Automod/triggers/exampleTrigger.ts b/backend/src/plugins/Automod/triggers/exampleTrigger.ts index 90424996..063f2b39 100644 --- a/backend/src/plugins/Automod/triggers/exampleTrigger.ts +++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; interface ExampleMatchResultType { diff --git a/backend/src/plugins/Automod/triggers/kick.ts b/backend/src/plugins/Automod/triggers/kick.ts index 266ab7a7..0e0d8deb 100644 --- a/backend/src/plugins/Automod/triggers/kick.ts +++ b/backend/src/plugins/Automod/triggers/kick.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface diff --git a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts index 5c871cc5..5848a175 100644 --- a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts +++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts @@ -1,6 +1,6 @@ import { escapeInlineCode, Snowflake } from "discord.js"; import { extname } from "path"; -import z from "zod"; +import z from "zod/v4"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; @@ -9,30 +9,12 @@ interface MatchResultType { mode: "blacklist" | "whitelist"; } -const configSchema = z - .strictObject({ - filetype_blacklist: z.array(z.string().max(32)).max(255).default([]), - blacklist_enabled: z.boolean().default(false), - filetype_whitelist: z.array(z.string().max(32)).max(255).default([]), - whitelist_enabled: z.boolean().default(false), - }) - .transform((parsed, ctx) => { - if (parsed.blacklist_enabled && parsed.whitelist_enabled) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Cannot have both blacklist and whitelist enabled", - }); - return z.NEVER; - } - if (!parsed.blacklist_enabled && !parsed.whitelist_enabled) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Must have either blacklist or whitelist enabled", - }); - return z.NEVER; - } - return parsed; - }); +const configSchema = z.strictObject({ + whitelist_enabled: z.boolean().default(false), + filetype_whitelist: z.array(z.string().max(32)).max(255).default([]), + blacklist_enabled: z.boolean().default(false), + filetype_blacklist: z.array(z.string().max(32)).max(255).default([]), +}); export const MatchAttachmentTypeTrigger = automodTrigger()({ configSchema, diff --git a/backend/src/plugins/Automod/triggers/matchInvites.ts b/backend/src/plugins/Automod/triggers/matchInvites.ts index 54c881c9..b286c193 100644 --- a/backend/src/plugins/Automod/triggers/matchInvites.ts +++ b/backend/src/plugins/Automod/triggers/matchInvites.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from "../../../utils.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts index e40be002..9955b310 100644 --- a/backend/src/plugins/Automod/triggers/matchLinks.ts +++ b/backend/src/plugins/Automod/triggers/matchLinks.ts @@ -1,11 +1,10 @@ import { escapeInlineCode } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { allowTimeout } from "../../../RegExpRunner.js"; -import { phishermanDomainIsSafe } from "../../../data/Phisherman.js"; -import { getUrlsInString, zRegex } from "../../../utils.js"; +import { getFishFishDomain } from "../../../data/FishFish.js"; +import { getUrlsInString, inputPatternToRegExp, zRegex } from "../../../utils.js"; import { mergeRegexes } from "../../../utils/mergeRegexes.js"; import { mergeWordsIntoRegex } from "../../../utils/mergeWordsIntoRegex.js"; -import { PhishermanPlugin } from "../../Phisherman/PhishermanPlugin.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; import { automodTrigger } from "../helpers.js"; @@ -40,6 +39,7 @@ const configSchema = z.strictObject({ include_verified: z.boolean().optional(), }) .optional(), + include_malicious: z.boolean().default(false), only_real_links: z.boolean().default(true), match_messages: z.boolean().default(true), match_embeds: z.boolean().default(true), @@ -73,7 +73,7 @@ export const MatchLinksTrigger = automodTrigger()({ if (trigger.exclude_regex) { if (!regexCache.has(trigger.exclude_regex)) { - const toCache = mergeRegexes(trigger.exclude_regex, "i"); + const toCache = mergeRegexes(trigger.exclude_regex.map(pattern => inputPatternToRegExp(pattern)), "i"); regexCache.set(trigger.exclude_regex, toCache); } const regexes = regexCache.get(trigger.exclude_regex)!; @@ -88,7 +88,7 @@ export const MatchLinksTrigger = automodTrigger()({ if (trigger.include_regex) { if (!regexCache.has(trigger.include_regex)) { - const toCache = mergeRegexes(trigger.include_regex, "i"); + const toCache = mergeRegexes(trigger.include_regex.map(pattern => inputPatternToRegExp(pattern)), "i"); regexCache.set(trigger.include_regex, toCache); } const regexes = regexCache.get(trigger.include_regex)!; @@ -155,22 +155,18 @@ export const MatchLinksTrigger = automodTrigger()({ } } - if (trigger.phisherman) { - const phishermanResult = await pluginData.getPlugin(PhishermanPlugin).getDomainInfo(normalizedHostname); - if (phishermanResult != null && !phishermanDomainIsSafe(phishermanResult)) { - if ( - (trigger.phisherman.include_suspected && !phishermanResult.verifiedPhish) || - (trigger.phisherman.include_verified && phishermanResult.verifiedPhish) - ) { - const suspectedVerified = phishermanResult.verifiedPhish ? "verified" : "suspected"; - return { - extra: { - type, - link: link.input, - details: `using Phisherman (${suspectedVerified})`, - }, - }; - } + const includeMalicious = + trigger.include_malicious || trigger.phisherman?.include_suspected || trigger.phisherman?.include_verified; + if (includeMalicious) { + const domainInfo = getFishFishDomain(normalizedHostname); + if (domainInfo && domainInfo.category !== "safe") { + return { + extra: { + type, + link: link.input, + details: `(known ${domainInfo.category} domain)`, + }, + }; } } } diff --git a/backend/src/plugins/Automod/triggers/matchMimeType.ts b/backend/src/plugins/Automod/triggers/matchMimeType.ts index 725665e4..8a1bb8f5 100644 --- a/backend/src/plugins/Automod/triggers/matchMimeType.ts +++ b/backend/src/plugins/Automod/triggers/matchMimeType.ts @@ -1,5 +1,5 @@ import { escapeInlineCode } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; @@ -8,30 +8,12 @@ interface MatchResultType { mode: "blacklist" | "whitelist"; } -const configSchema = z - .strictObject({ - mime_type_blacklist: z.array(z.string().max(255)).max(255).default([]), - blacklist_enabled: z.boolean().default(false), - mime_type_whitelist: z.array(z.string().max(255)).max(255).default([]), - whitelist_enabled: z.boolean().default(false), - }) - .transform((parsed, ctx) => { - if (parsed.blacklist_enabled && parsed.whitelist_enabled) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Cannot have both blacklist and whitelist enabled", - }); - return z.NEVER; - } - if (!parsed.blacklist_enabled && !parsed.whitelist_enabled) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Must have either blacklist or whitelist enabled", - }); - return z.NEVER; - } - return parsed; - }); +const configSchema = z.strictObject({ + whitelist_enabled: z.boolean().default(false), + mime_type_whitelist: z.array(z.string().max(32)).max(255).default([]), + blacklist_enabled: z.boolean().default(false), + mime_type_blacklist: z.array(z.string().max(32)).max(255).default([]), +}); export const MatchMimeTypeTrigger = automodTrigger()({ configSchema, diff --git a/backend/src/plugins/Automod/triggers/matchRegex.ts b/backend/src/plugins/Automod/triggers/matchRegex.ts index 507d4bc6..4298cfc3 100644 --- a/backend/src/plugins/Automod/triggers/matchRegex.ts +++ b/backend/src/plugins/Automod/triggers/matchRegex.ts @@ -1,6 +1,6 @@ -import z from "zod"; +import z from "zod/v4"; import { allowTimeout } from "../../../RegExpRunner.js"; -import { zRegex } from "../../../utils.js"; +import { inputPatternToRegExp, zRegex } from "../../../utils.js"; import { mergeRegexes } from "../../../utils/mergeRegexes.js"; import { normalizeText } from "../../../utils/normalizeText.js"; import { stripMarkdown } from "../../../utils/stripMarkdown.js"; @@ -38,7 +38,7 @@ export const MatchRegexTrigger = automodTrigger()({ if (!regexCache.has(trigger)) { const flags = trigger.case_sensitive ? "" : "i"; - const toCache = mergeRegexes(trigger.patterns, flags); + const toCache = mergeRegexes(trigger.patterns.map(pattern => inputPatternToRegExp(pattern)), flags); regexCache.set(trigger, toCache); } const regexes = regexCache.get(trigger)!; diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts index ef3f0ca2..7967c9de 100644 --- a/backend/src/plugins/Automod/triggers/matchWords.ts +++ b/backend/src/plugins/Automod/triggers/matchWords.ts @@ -1,5 +1,5 @@ import escapeStringRegexp from "escape-string-regexp"; -import z from "zod"; +import z from "zod/v4"; import { normalizeText } from "../../../utils/normalizeText.js"; import { stripMarkdown } from "../../../utils/stripMarkdown.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; @@ -19,7 +19,7 @@ const configSchema = z.strictObject({ only_full_words: z.boolean().default(true), normalize: z.boolean().default(false), loose_matching: z.boolean().default(false), - loose_matching_threshold: z.number().int().default(4), + loose_matching_threshold: z.number().int().default(1), strip_markdown: z.boolean().default(false), match_messages: z.boolean().default(true), match_embeds: z.boolean().default(false), diff --git a/backend/src/plugins/Automod/triggers/memberJoin.ts b/backend/src/plugins/Automod/triggers/memberJoin.ts index 4d694e42..ed0d6c7a 100644 --- a/backend/src/plugins/Automod/triggers/memberJoin.ts +++ b/backend/src/plugins/Automod/triggers/memberJoin.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts index 3e53a62d..316d9213 100644 --- a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts +++ b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { RecentActionType } from "../constants.js"; import { findRecentSpam } from "../functions/findRecentSpam.js"; diff --git a/backend/src/plugins/Automod/triggers/memberLeave.ts b/backend/src/plugins/Automod/triggers/memberLeave.ts index 2c6bb9f2..6f4c392f 100644 --- a/backend/src/plugins/Automod/triggers/memberLeave.ts +++ b/backend/src/plugins/Automod/triggers/memberLeave.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; const configSchema = z.strictObject({}); diff --git a/backend/src/plugins/Automod/triggers/mute.ts b/backend/src/plugins/Automod/triggers/mute.ts index b8be5713..3f840dd3 100644 --- a/backend/src/plugins/Automod/triggers/mute.ts +++ b/backend/src/plugins/Automod/triggers/mute.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface diff --git a/backend/src/plugins/Automod/triggers/note.ts b/backend/src/plugins/Automod/triggers/note.ts index 22ccc40f..26efe482 100644 --- a/backend/src/plugins/Automod/triggers/note.ts +++ b/backend/src/plugins/Automod/triggers/note.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts index 584a7a20..50091607 100644 --- a/backend/src/plugins/Automod/triggers/roleAdded.ts +++ b/backend/src/plugins/Automod/triggers/roleAdded.ts @@ -1,5 +1,5 @@ import { Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { renderUsername, zSnowflake } from "../../../utils.js"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/roleRemoved.ts b/backend/src/plugins/Automod/triggers/roleRemoved.ts index 3ce92474..cd500a0c 100644 --- a/backend/src/plugins/Automod/triggers/roleRemoved.ts +++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts @@ -1,5 +1,5 @@ import { Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { renderUsername, zSnowflake } from "../../../utils.js"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/threadArchive.ts b/backend/src/plugins/Automod/triggers/threadArchive.ts index 599f64c4..28a53180 100644 --- a/backend/src/plugins/Automod/triggers/threadArchive.ts +++ b/backend/src/plugins/Automod/triggers/threadArchive.ts @@ -1,5 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/threadCreate.ts b/backend/src/plugins/Automod/triggers/threadCreate.ts index 5f1e6db2..a11c8813 100644 --- a/backend/src/plugins/Automod/triggers/threadCreate.ts +++ b/backend/src/plugins/Automod/triggers/threadCreate.ts @@ -1,5 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts index ff01b165..bb286408 100644 --- a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts +++ b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { RecentActionType } from "../constants.js"; import { findRecentSpam } from "../functions/findRecentSpam.js"; diff --git a/backend/src/plugins/Automod/triggers/threadDelete.ts b/backend/src/plugins/Automod/triggers/threadDelete.ts index fb1d27f2..774fdf30 100644 --- a/backend/src/plugins/Automod/triggers/threadDelete.ts +++ b/backend/src/plugins/Automod/triggers/threadDelete.ts @@ -1,5 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/threadUnarchive.ts b/backend/src/plugins/Automod/triggers/threadUnarchive.ts index 6b690832..d180b082 100644 --- a/backend/src/plugins/Automod/triggers/threadUnarchive.ts +++ b/backend/src/plugins/Automod/triggers/threadUnarchive.ts @@ -1,5 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import z from "zod"; +import z from "zod/v4"; import { renderUsername } from "../../../utils.js"; import { automodTrigger } from "../helpers.js"; diff --git a/backend/src/plugins/Automod/triggers/unban.ts b/backend/src/plugins/Automod/triggers/unban.ts index fd52aef6..c29856bb 100644 --- a/backend/src/plugins/Automod/triggers/unban.ts +++ b/backend/src/plugins/Automod/triggers/unban.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface diff --git a/backend/src/plugins/Automod/triggers/unmute.ts b/backend/src/plugins/Automod/triggers/unmute.ts index 59d47030..076ac99d 100644 --- a/backend/src/plugins/Automod/triggers/unmute.ts +++ b/backend/src/plugins/Automod/triggers/unmute.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface diff --git a/backend/src/plugins/Automod/triggers/warn.ts b/backend/src/plugins/Automod/triggers/warn.ts index 9d94235f..cfec4ba7 100644 --- a/backend/src/plugins/Automod/triggers/warn.ts +++ b/backend/src/plugins/Automod/triggers/warn.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { automodTrigger } from "../helpers.js"; // tslint:disable-next-line:no-empty-interface diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index 25d8cea6..51fc9375 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -1,6 +1,6 @@ import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js"; -import { BasePluginType, CooldownManager } from "knub"; -import z from "zod"; +import { BasePluginType, CooldownManager, pluginUtils } from "knub"; +import z from "zod/v4"; import { Queue } from "../../Queue.js"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels.js"; @@ -9,6 +9,7 @@ import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { SavedMessage } from "../../data/entities/SavedMessage.js"; import { entries, zBoundedRecord, zDelayString } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { CounterEvents } from "../Counters/types.js"; import { ModActionType, ModActionsEvents } from "../ModActions/types.js"; import { MutesEvents } from "../Mutes/types.js"; @@ -45,22 +46,6 @@ const zActionsMap = z const zRule = z.strictObject({ enabled: z.boolean().default(true), - // Typed as "never" because you are not expected to supply this directly. - // The transform instead picks it up from the property key and the output type is a string. - name: z - .never() - .optional() - .transform((_, ctx) => { - const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); - if (!ruleName) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Automod rules must have names", - }); - return z.NEVER; - } - return ruleName; - }), pretty_name: z.string().optional(), presets: z.array(z.string().max(100)).max(25).default([]), affects_bots: z.boolean().default(false), @@ -68,21 +53,19 @@ const zRule = z.strictObject({ cooldown: zDelayString.nullable().default(null), allow_further_rules: z.boolean().default(false), triggers: z.array(zTriggersMap), - actions: zActionsMap.refine((v) => !(v.clean && v.start_thread), { - message: "Cannot have both clean and start_thread active at the same time", - }), + actions: zActionsMap, }); export type TRule = z.infer; export const zAutomodConfig = z.strictObject({ - rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255), - antiraid_levels: z.array(z.string().max(100)).max(10), - can_set_antiraid: z.boolean(), - can_view_antiraid: z.boolean(), + rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255).default({}), + antiraid_levels: z.array(z.string().max(100)).max(10).default(["low", "medium", "high"]), + can_set_antiraid: z.boolean().default(false), + can_view_antiraid: z.boolean().default(false), }); export interface AutomodPluginType extends BasePluginType { - config: z.output; + configSchema: typeof zAutomodConfig; customOverrideCriteria: { antiraid_level?: string; @@ -140,6 +123,8 @@ export interface AutomodPluginType extends BasePluginType { modActionsListeners: Map; mutesListeners: Map; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts index f2224e6a..bad32a4d 100644 --- a/backend/src/plugins/BotControl/BotControlPlugin.ts +++ b/backend/src/plugins/BotControl/BotControlPlugin.ts @@ -4,7 +4,6 @@ import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { Configs } from "../../data/Configs.js"; import { GuildArchives } from "../../data/GuildArchives.js"; -import { sendSuccessMessage } from "../../pluginUtils.js"; import { getActiveReload, resetActiveReload } from "./activeReload.js"; import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd.js"; import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd.js"; @@ -24,21 +23,9 @@ import { RestPerformanceCmd } from "./commands/RestPerformanceCmd.js"; import { ServersCmd } from "./commands/ServersCmd.js"; import { BotControlPluginType, zBotControlConfig } from "./types.js"; -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, - }, -}; - export const BotControlPlugin = globalPlugin()({ name: "bot_control", - configParser: (input) => zBotControlConfig.parse(input), - defaultOptions, + configSchema: zBotControlConfig, // prettier-ignore messageCommands: [ @@ -77,7 +64,7 @@ export const BotControlPlugin = globalPlugin()({ if (guild) { const channel = guild.channels.cache.get(channelId as Snowflake); if (channel instanceof TextChannel) { - sendSuccessMessage(pluginData, channel, "Global plugins reloaded!"); + void channel.send("Global plugins reloaded!"); } } } diff --git a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts index 42cfc25d..200f3d16 100644 --- a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts +++ b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts @@ -1,6 +1,6 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -19,7 +19,7 @@ export const AddDashboardUserCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } @@ -36,10 +36,7 @@ export const AddDashboardUserCmd = botControlCmd({ } const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`); - sendSuccessMessage( - pluginData, - msg.channel, - `The following users were given dashboard access for **${guild.name}**:\n\n${userNameList}`, - ); + + msg.channel.send(`The following users were given dashboard access for **${guild.name}**:\n\n${userNameList}`); }, }); diff --git a/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts b/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts index 4bda1a9d..04da9b4d 100644 --- a/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts +++ b/backend/src/plugins/BotControl/commands/AddServerFromInviteCmd.ts @@ -1,7 +1,6 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { DBDateFormat, isGuildInvite, resolveInvite } from "../../../utils.js"; import { isEligible } from "../functions/isEligible.js"; import { botControlCmd } from "../types.js"; @@ -18,19 +17,19 @@ export const AddServerFromInviteCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const invite = await resolveInvite(pluginData.client, args.inviteCode, true); if (!invite || !isGuildInvite(invite)) { - sendErrorMessage(pluginData, msg.channel, "Could not resolve invite"); // :D + void msg.channel.send("Could not resolve invite"); // :D return; } const existing = await pluginData.state.allowedGuilds.find(invite.guild.id); if (existing) { - sendErrorMessage(pluginData, msg.channel, "Server is already allowed!"); + void msg.channel.send("Server is already allowed!"); return; } const { result, explanation } = await isEligible(pluginData, args.user, invite); if (!result) { - sendErrorMessage(pluginData, msg.channel, `Could not add server because it's not eligible: ${explanation}`); + msg.channel.send(`Could not add server because it's not eligible: ${explanation}`); return; } @@ -51,6 +50,6 @@ export const AddServerFromInviteCmd = botControlCmd({ ); } - sendSuccessMessage(pluginData, msg.channel, "Server was eligible and is now allowed to use Zeppelin!"); + msg.channel.send("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 6dd0751f..42ffb715 100644 --- a/backend/src/plugins/BotControl/commands/AllowServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/AllowServerCmd.ts @@ -1,7 +1,7 @@ import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { DBDateFormat, isSnowflake } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -20,17 +20,17 @@ export const AllowServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const existing = await pluginData.state.allowedGuilds.find(args.guildId); if (existing) { - sendErrorMessage(pluginData, msg.channel, "Server is already allowed!"); + void msg.channel.send("Server is already allowed!"); return; } if (!isSnowflake(args.guildId)) { - sendErrorMessage(pluginData, msg.channel, "Invalid server ID!"); + void msg.channel.send("Invalid server ID!"); return; } if (args.userId && !isSnowflake(args.userId)) { - sendErrorMessage(pluginData, msg.channel, "Invalid user ID!"); + void msg.channel.send("Invalid user ID!"); return; } @@ -51,6 +51,6 @@ export const AllowServerCmd = botControlCmd({ ); } - sendSuccessMessage(pluginData, msg.channel, "Server is now allowed to use Zeppelin!"); + void msg.channel.send("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 index 927a75ab..a4f53675 100644 --- a/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/ChannelToServerCmd.ts @@ -1,5 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const ChannelToServerCmd = botControlCmd({ @@ -16,7 +16,7 @@ export const ChannelToServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const channel = pluginData.client.channels.cache.get(args.channelId); if (!channel) { - sendErrorMessage(pluginData, msg.channel, "Channel not found in cache!"); + void msg.channel.send("Channel not found in cache!"); return; } diff --git a/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts b/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts index 17515a8b..8a753392 100644 --- a/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/DisallowServerCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { noop } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -18,7 +18,7 @@ export const DisallowServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const existing = await pluginData.state.allowedGuilds.find(args.guildId); if (!existing) { - sendErrorMessage(pluginData, msg.channel, "That server is not allowed in the first place!"); + void msg.channel.send("That server is not allowed in the first place!"); return; } @@ -27,6 +27,6 @@ export const DisallowServerCmd = botControlCmd({ .get(args.guildId as Snowflake) ?.leave() .catch(noop); - sendSuccessMessage(pluginData, msg.channel, "Server removed!"); + void msg.channel.send("Server removed!"); }, }); diff --git a/backend/src/plugins/BotControl/commands/EligibleCmd.ts b/backend/src/plugins/BotControl/commands/EligibleCmd.ts index 136266fd..0f753f7b 100644 --- a/backend/src/plugins/BotControl/commands/EligibleCmd.ts +++ b/backend/src/plugins/BotControl/commands/EligibleCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { isGuildInvite, resolveInvite } from "../../../utils.js"; import { isEligible } from "../functions/isEligible.js"; import { botControlCmd } from "../types.js"; @@ -16,17 +15,17 @@ export const EligibleCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const invite = await resolveInvite(pluginData.client, args.inviteCode, true); if (!invite || !isGuildInvite(invite)) { - sendErrorMessage(pluginData, msg.channel, "Could not resolve invite"); + void msg.channel.send("Could not resolve invite"); return; } const { result, explanation } = await isEligible(pluginData, args.user, invite); if (result) { - sendSuccessMessage(pluginData, msg.channel, `Server is eligible: ${explanation}`); + void msg.channel.send(`Server is eligible: ${explanation}`); return; } - sendErrorMessage(pluginData, msg.channel, `Server is **NOT** eligible: ${explanation}`); + void msg.channel.send(`Server is **NOT** eligible: ${explanation}`); }, }); diff --git a/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts b/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts index 02e026fd..7f3a3578 100644 --- a/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/LeaveServerCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const LeaveServerCmd = botControlCmd({ @@ -16,7 +16,7 @@ export const LeaveServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) { - sendErrorMessage(pluginData, msg.channel, "I am not in that guild"); + void msg.channel.send("I am not in that guild"); return; } @@ -26,10 +26,10 @@ export const LeaveServerCmd = botControlCmd({ try { await pluginData.client.guilds.cache.get(args.guildId as Snowflake)?.leave(); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Failed to leave guild: ${e.message}`); + void msg.channel.send(`Failed to leave guild: ${e.message}`); return; } - sendSuccessMessage(pluginData, msg.channel, `Left guild **${guildName}**`); + void msg.channel.send(`Left guild **${guildName}**`); }, }); diff --git a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts index 540459ec..c75e520a 100644 --- a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts +++ b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts @@ -1,7 +1,6 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { AllowedGuild } from "../../../data/entities/AllowedGuild.js"; import { ApiPermissionAssignment } from "../../../data/entities/ApiPermissionAssignment.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { renderUsername, resolveUser } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -16,7 +15,7 @@ export const ListDashboardPermsCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { if (!args.user && !args.guildId) { - sendErrorMessage(pluginData, msg.channel, "Must specify at least guildId, user, or both."); + void msg.channel.send("Must specify at least guildId, user, or both."); return; } @@ -24,7 +23,7 @@ export const ListDashboardPermsCmd = botControlCmd({ if (args.guildId) { guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } } @@ -33,7 +32,7 @@ export const ListDashboardPermsCmd = botControlCmd({ if (args.user) { existingUserAssignment = await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id); if (existingUserAssignment.length === 0) { - sendErrorMessage(pluginData, msg.channel, "The user has no assigned permissions."); + void msg.channel.send("The user has no assigned permissions."); return; } } @@ -54,11 +53,7 @@ export const ListDashboardPermsCmd = botControlCmd({ } if (finalMessage === "") { - sendErrorMessage( - pluginData, - msg.channel, - `The user ${userInfo} has no assigned permissions on the specified server.`, - ); + msg.channel.send(`The user ${userInfo} has no assigned permissions on the specified server.`); return; } // Else display all users that have permissions on the specified guild @@ -67,7 +62,7 @@ export const ListDashboardPermsCmd = botControlCmd({ const existingGuildAssignment = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id); if (existingGuildAssignment.length === 0) { - sendErrorMessage(pluginData, msg.channel, `The server ${guildInfo} has no assigned permissions.`); + msg.channel.send(`The server ${guildInfo} has no assigned permissions.`); return; } @@ -80,6 +75,9 @@ export const ListDashboardPermsCmd = botControlCmd({ } } - await sendSuccessMessage(pluginData, msg.channel, finalMessage.trim(), {}); + await msg.channel.send({ + content: finalMessage.trim(), + allowedMentions: {}, + }); }, }); diff --git a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts index cc23caa4..59171660 100644 --- a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts +++ b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { renderUsername, resolveUser } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -14,7 +13,7 @@ export const ListDashboardUsersCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } @@ -30,11 +29,9 @@ export const ListDashboardUsersCmd = botControlCmd({ `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`): ${permission.permissions.join(", ")}`, ); - sendSuccessMessage( - pluginData, - msg.channel, - `The following users have dashboard access for **${guild.name}**:\n\n${userNameList.join("\n")}`, - {}, - ); + msg.channel.send({ + content: `The following users have dashboard access for **${guild.name}**:\n\n${userNameList.join("\n")}`, + allowedMentions: {}, + }); }, }); diff --git a/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts b/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts index c0c5bef1..39675bb9 100644 --- a/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts +++ b/backend/src/plugins/BotControl/commands/RateLimitPerformanceCmd.ts @@ -1,6 +1,6 @@ import moment from "moment-timezone"; import { GuildArchives } from "../../../data/GuildArchives.js"; -import { getBaseUrl, sendSuccessMessage } from "../../../pluginUtils.js"; +import { getBaseUrl } from "../../../pluginUtils.js"; import { getRateLimitStats } from "../../../rateLimitStats.js"; import { botControlCmd } from "../types.js"; @@ -13,7 +13,7 @@ export const RateLimitPerformanceCmd = botControlCmd({ async run({ pluginData, message: msg }) { const logItems = getRateLimitStats(); if (logItems.length === 0) { - sendSuccessMessage(pluginData, msg.channel, `No rate limits hit`); + void msg.channel.send(`No rate limits hit`); return; } diff --git a/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts b/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts index 6c2b921f..c95ea1dd 100644 --- a/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts +++ b/backend/src/plugins/BotControl/commands/ReloadGlobalPluginsCmd.ts @@ -1,4 +1,4 @@ -import { isStaffPreFilter, sendErrorMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { getActiveReload, setActiveReload } from "../activeReload.js"; import { botControlCmd } from "../types.js"; @@ -14,7 +14,7 @@ export const ReloadGlobalPluginsCmd = botControlCmd({ const guildId = "guild" in message.channel ? message.channel.guild.id : null; if (!guildId) { - sendErrorMessage(pluginData, message.channel, "This command can only be used in a server"); + void message.channel.send("This command can only be used in a server"); return; } diff --git a/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts b/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts index a8b593de..b7c94bb3 100644 --- a/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts +++ b/backend/src/plugins/BotControl/commands/ReloadServerCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { botControlCmd } from "../types.js"; export const ReloadServerCmd = botControlCmd({ @@ -16,18 +16,18 @@ export const ReloadServerCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { if (!pluginData.client.guilds.cache.has(args.guildId as Snowflake)) { - sendErrorMessage(pluginData, msg.channel, "I am not in that guild"); + void msg.channel.send("I am not in that guild"); return; } try { await pluginData.getKnubInstance().reloadGuild(args.guildId); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Failed to reload guild: ${e.message}`); + void msg.channel.send(`Failed to reload guild: ${e.message}`); return; } const guild = await pluginData.client.guilds.fetch(args.guildId as Snowflake); - sendSuccessMessage(pluginData, msg.channel, `Reloaded guild **${guild?.name || "???"}**`); + void msg.channel.send(`Reloaded guild **${guild?.name || "???"}**`); }, }); diff --git a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts index 86066734..8b5ad44e 100644 --- a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts +++ b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts @@ -1,5 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { isStaffPreFilter } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { botControlCmd } from "../types.js"; @@ -18,7 +18,7 @@ export const RemoveDashboardUserCmd = botControlCmd({ async run({ pluginData, message: msg, args }) { const guild = await pluginData.state.allowedGuilds.find(args.guildId); if (!guild) { - sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); + void msg.channel.send("Server is not using Zeppelin"); return; } @@ -35,10 +35,7 @@ export const RemoveDashboardUserCmd = botControlCmd({ } const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`); - sendSuccessMessage( - pluginData, - msg.channel, - `The following users were removed from the dashboard for **${guild.name}**:\n\n${userNameList}`, - ); + + msg.channel.send(`The following users were removed from the dashboard for **${guild.name}**:\n\n${userNameList}`); }, }); diff --git a/backend/src/plugins/BotControl/docs.ts b/backend/src/plugins/BotControl/docs.ts new file mode 100644 index 00000000..d36ad42e --- /dev/null +++ b/backend/src/plugins/BotControl/docs.ts @@ -0,0 +1,13 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { trimPluginDescription } from "../../utils.js"; +import { zBotControlConfig } from "./types.js"; + +export const botControlPluginDocs: ZeppelinPluginDocs = { + type: "stable", + configSchema: zBotControlConfig, + + prettyName: "Bot control", + description: trimPluginDescription(` + Contains commands to manage allowed servers + `), +}; diff --git a/backend/src/plugins/BotControl/types.ts b/backend/src/plugins/BotControl/types.ts index 05f8fb31..2cdef313 100644 --- a/backend/src/plugins/BotControl/types.ts +++ b/backend/src/plugins/BotControl/types.ts @@ -1,5 +1,5 @@ import { BasePluginType, globalPluginEventListener, globalPluginMessageCommand } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { Configs } from "../../data/Configs.js"; @@ -7,16 +7,16 @@ import { GuildArchives } from "../../data/GuildArchives.js"; import { zBoundedCharacters } from "../../utils.js"; export const zBotControlConfig = z.strictObject({ - can_use: z.boolean(), - can_eligible: z.boolean(), - can_performance: z.boolean(), - can_add_server_from_invite: z.boolean(), - can_list_dashboard_perms: z.boolean(), - update_cmd: zBoundedCharacters(0, 2000).nullable(), + can_use: z.boolean().default(false), + can_eligible: z.boolean().default(false), + can_performance: z.boolean().default(false), + can_add_server_from_invite: z.boolean().default(false), + can_list_dashboard_perms: z.boolean().default(false), + update_cmd: zBoundedCharacters(0, 2000).nullable().default(null), }); export interface BotControlPluginType extends BasePluginType { - config: z.output; + configSchema: typeof zBotControlConfig; state: { archives: GuildArchives; allowedGuilds: AllowedGuilds; diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts index 8e5e66f3..2b97246c 100644 --- a/backend/src/plugins/Cases/CasesPlugin.ts +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -20,23 +20,11 @@ function getLogsPlugin(): Promise { return import("../Logs/LogsPlugin.js") as Promise; } -const defaultOptions = { - config: { - log_automatic_actions: true, - case_log_channel: null, - show_relative_times: true, - relative_time_cutoff: "7d", - case_colors: null, - case_icons: null, - }, -}; - export const CasesPlugin = guildPlugin()({ name: "cases", dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getLogsPlugin()).LogsPlugin], - configParser: (input) => zCasesConfig.parse(input), - defaultOptions, + configSchema: zCasesConfig, public(pluginData) { return { diff --git a/backend/src/plugins/Cases/info.ts b/backend/src/plugins/Cases/docs.ts similarity index 69% rename from backend/src/plugins/Cases/info.ts rename to backend/src/plugins/Cases/docs.ts index 4cda78db..a247177f 100644 --- a/backend/src/plugins/Cases/info.ts +++ b/backend/src/plugins/Cases/docs.ts @@ -1,11 +1,12 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zCasesConfig } from "./types.js"; -export const casesPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, - prettyName: "Cases", +export const casesPluginDocs: ZeppelinPluginDocs = { + type: "stable", configSchema: zCasesConfig, + + prettyName: "Cases", description: trimPluginDescription(` This plugin contains basic configuration for cases created by other plugins `), diff --git a/backend/src/plugins/Cases/functions/getCaseEmbed.ts b/backend/src/plugins/Cases/functions/getCaseEmbed.ts index 38a09285..15bcae1c 100644 --- a/backend/src/plugins/Cases/functions/getCaseEmbed.ts +++ b/backend/src/plugins/Cases/functions/getCaseEmbed.ts @@ -1,4 +1,10 @@ -import { escapeCodeBlock, MessageCreateOptions, MessageEditOptions } from "discord.js"; +import { + escapeCodeBlock, + InteractionEditReplyOptions, + InteractionReplyOptions, + MessageCreateOptions, + MessageEditOptions, +} from "discord.js"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { CaseTypes } from "../../../data/CaseTypes.js"; @@ -14,7 +20,7 @@ export async function getCaseEmbed( caseOrCaseId: Case | number, requestMemberId?: string, noOriginalCaseLink?: boolean, -): Promise { +): Promise { const theCase = await pluginData.state.cases.with("notes").find(resolveCaseId(caseOrCaseId)); if (!theCase) { throw new Error("Unknown case"); diff --git a/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts b/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts index 6333b1e6..79b178de 100644 --- a/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts +++ b/backend/src/plugins/Cases/functions/getRecentCasesByMod.ts @@ -1,4 +1,5 @@ import { GuildPluginData } from "knub"; +import { FindOptionsWhere } from "typeorm"; import { Case } from "../../../data/entities/Case.js"; import { CasesPluginType } from "../types.js"; @@ -7,6 +8,7 @@ export function getRecentCasesByMod( modId: string, count: number, skip = 0, + filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, ): Promise { - return pluginData.state.cases.getRecentByModId(modId, count, skip); + return pluginData.state.cases.getRecentByModId(modId, count, skip, filters); } diff --git a/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts b/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts index a2401cbc..989b978a 100644 --- a/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts +++ b/backend/src/plugins/Cases/functions/getTotalCasesByMod.ts @@ -1,6 +1,12 @@ import { GuildPluginData } from "knub"; +import { FindOptionsWhere } from "typeorm"; +import { Case } from "../../../data/entities/Case.js"; import { CasesPluginType } from "../types.js"; -export function getTotalCasesByMod(pluginData: GuildPluginData, modId: string): Promise { - return pluginData.state.cases.getTotalCasesByModId(modId); +export function getTotalCasesByMod( + pluginData: GuildPluginData, + modId: string, + filters: Omit, "guild_id" | "mod_id" | "is_hidden"> = {}, +): Promise { + return pluginData.state.cases.getTotalCasesByModId(modId, filters); } diff --git a/backend/src/plugins/Cases/types.ts b/backend/src/plugins/Cases/types.ts index cb8ec139..46519ac4 100644 --- a/backend/src/plugins/Cases/types.ts +++ b/backend/src/plugins/Cases/types.ts @@ -1,6 +1,6 @@ import { BasePluginType } from "knub"; import { U } from "ts-toolbelt"; -import z from "zod"; +import z from "zod/v4"; import { CaseNameToType, CaseTypes } from "../../data/CaseTypes.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; @@ -10,17 +10,27 @@ import { zColor } from "../../utils/zColor.js"; const caseKeys = keys(CaseNameToType) as U.ListOf; +const caseColorsTypeMap = caseKeys.reduce((map, key) => { + map[key] = zColor; + return map; +}, {} as Record); + +const caseIconsTypeMap = caseKeys.reduce((map, key) => { + map[key] = zBoundedCharacters(0, 100); + return map; +}, {} as Record); + export const zCasesConfig = z.strictObject({ - log_automatic_actions: z.boolean(), - case_log_channel: zSnowflake.nullable(), - show_relative_times: z.boolean(), + log_automatic_actions: z.boolean().default(true), + case_log_channel: zSnowflake.nullable().default(null), + show_relative_times: z.boolean().default(true), relative_time_cutoff: zDelayString.default("1w"), - case_colors: z.record(z.enum(caseKeys), zColor).nullable(), - case_icons: z.record(z.enum(caseKeys), zBoundedCharacters(0, 100)).nullable(), + case_colors: z.strictObject(caseColorsTypeMap).partial().nullable().default(null), + case_icons: z.strictObject(caseIconsTypeMap).partial().nullable().default(null), }); export interface CasesPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zCasesConfig; state: { logs: GuildLogs; cases: GuildCases; diff --git a/backend/src/plugins/Censor/CensorPlugin.ts b/backend/src/plugins/Censor/CensorPlugin.ts index c9503690..ad91b27e 100644 --- a/backend/src/plugins/Censor/CensorPlugin.ts +++ b/backend/src/plugins/Censor/CensorPlugin.ts @@ -1,4 +1,4 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { PluginOverride, guildPlugin } from "knub"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; @@ -7,46 +7,26 @@ import { CensorPluginType, zCensorConfig } from "./types.js"; import { onMessageCreate } from "./util/onMessageCreate.js"; import { onMessageUpdate } from "./util/onMessageUpdate.js"; -const defaultOptions: PluginOptions = { - config: { - filter_zalgo: false, - filter_invites: false, - invite_guild_whitelist: null, - invite_guild_blacklist: null, - invite_code_whitelist: null, - invite_code_blacklist: null, - allow_group_dm_invites: false, - - filter_domains: false, - domain_whitelist: null, - domain_blacklist: null, - - blocked_tokens: null, - blocked_words: null, - blocked_regex: null, - }, - - overrides: [ - { - level: ">=50", - config: { - filter_zalgo: false, - filter_invites: false, - filter_domains: false, - blocked_tokens: null, - blocked_words: null, - blocked_regex: null, - }, +const defaultOverrides: Array> = [ + { + level: ">=50", + config: { + filter_zalgo: false, + filter_invites: false, + filter_domains: false, + blocked_tokens: null, + blocked_words: null, + blocked_regex: null, }, - ], -}; + }, +]; export const CensorPlugin = guildPlugin()({ name: "censor", dependencies: () => [LogsPlugin], - configParser: (input) => zCensorConfig.parse(input), - defaultOptions, + configSchema: zCensorConfig, + defaultOverrides, beforeLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Censor/info.ts b/backend/src/plugins/Censor/docs.ts similarity index 68% rename from backend/src/plugins/Censor/info.ts rename to backend/src/plugins/Censor/docs.ts index 3cb0cc5f..bba98f41 100644 --- a/backend/src/plugins/Censor/info.ts +++ b/backend/src/plugins/Censor/docs.ts @@ -1,12 +1,12 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zCensorConfig } from "./types.js"; -export const censorPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, - legacy: true, - prettyName: "Censor", +export const censorPluginDocs: ZeppelinPluginDocs = { + type: "legacy", configSchema: zCensorConfig, + + prettyName: "Censor", description: trimPluginDescription(` Censor words, tokens, links, regex, etc. For more advanced filtering, check out the Automod plugin! diff --git a/backend/src/plugins/Censor/types.ts b/backend/src/plugins/Censor/types.ts index 62ed3816..9e36f316 100644 --- a/backend/src/plugins/Censor/types.ts +++ b/backend/src/plugins/Censor/types.ts @@ -1,28 +1,31 @@ import { BasePluginType } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { zBoundedCharacters, zRegex, zSnowflake } from "../../utils.js"; export const zCensorConfig = z.strictObject({ - filter_zalgo: z.boolean(), - filter_invites: z.boolean(), - invite_guild_whitelist: z.array(zSnowflake).nullable(), - invite_guild_blacklist: z.array(zSnowflake).nullable(), - invite_code_whitelist: z.array(zBoundedCharacters(0, 16)).nullable(), - invite_code_blacklist: z.array(zBoundedCharacters(0, 16)).nullable(), - allow_group_dm_invites: z.boolean(), - filter_domains: z.boolean(), - domain_whitelist: z.array(zBoundedCharacters(0, 255)).nullable(), - domain_blacklist: z.array(zBoundedCharacters(0, 255)).nullable(), - blocked_tokens: z.array(zBoundedCharacters(0, 2000)).nullable(), - blocked_words: z.array(zBoundedCharacters(0, 2000)).nullable(), - blocked_regex: z.array(zRegex(z.string().max(1000))).nullable(), + filter_zalgo: z.boolean().default(false), + filter_invites: z.boolean().default(false), + invite_guild_whitelist: z.array(zSnowflake).nullable().default(null), + invite_guild_blacklist: z.array(zSnowflake).nullable().default(null), + invite_code_whitelist: z.array(zBoundedCharacters(0, 16)).nullable().default(null), + invite_code_blacklist: z.array(zBoundedCharacters(0, 16)).nullable().default(null), + allow_group_dm_invites: z.boolean().default(false), + filter_domains: z.boolean().default(false), + domain_whitelist: z.array(zBoundedCharacters(0, 255)).nullable().default(null), + domain_blacklist: z.array(zBoundedCharacters(0, 255)).nullable().default(null), + blocked_tokens: z.array(zBoundedCharacters(0, 2000)).nullable().default(null), + blocked_words: z.array(zBoundedCharacters(0, 2000)).nullable().default(null), + blocked_regex: z + .array(zRegex(z.string().max(1000))) + .nullable() + .default(null), }); export interface CensorPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zCensorConfig; state: { serverLogs: GuildLogs; savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts index e1cf25a0..641ba7ed 100644 --- a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts +++ b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts @@ -1,13 +1,13 @@ import { Invite } from "discord.js"; import escapeStringRegexp from "escape-string-regexp"; import { GuildPluginData } from "knub"; -import cloneDeep from "lodash/cloneDeep.js"; import { allowTimeout } from "../../../RegExpRunner.js"; import { ZalgoRegex } from "../../../data/Zalgo.js"; import { ISavedMessageEmbedData, SavedMessage } from "../../../data/entities/SavedMessage.js"; import { getInviteCodesInString, getUrlsInString, + inputPatternToRegExp, isGuildInvite, resolveInvite, resolveMember, @@ -27,7 +27,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 ManipulatedEmbedData[]).map((e) => cloneDeep(e)); + const embeds = (savedMessage.data.embeds as ManipulatedEmbedData[]).map((e) => structuredClone(e)); for (const embed of embeds) { if (embed.type === "video") { // Ignore video descriptions as they're not actually shown on the embed @@ -147,7 +147,8 @@ export async function applyFiltersToMsg( } // Filter regex - for (const regex of config.blocked_regex || []) { + for (const pattern of config.blocked_regex || []) { + const regex = inputPatternToRegExp(pattern); // We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly const matches = (await pluginData.state.regexRunner.exec(regex, savedMessage.data.content).catch(allowTimeout)) || diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts index 5423a747..3dc5c049 100644 --- a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts +++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts @@ -1,17 +1,21 @@ import { guildPlugin } from "knub"; -import z from "zod"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd.js"; -import { ChannelArchiverPluginType } from "./types.js"; +import { ChannelArchiverPluginType, zChannelArchiverPluginConfig } from "./types.js"; export const ChannelArchiverPlugin = guildPlugin()({ name: "channel_archiver", dependencies: () => [TimeAndDatePlugin], - configParser: (input) => z.strictObject({}).parse(input), + configSchema: zChannelArchiverPluginConfig, // prettier-ignore messageCommands: [ ArchiveChannelCmd, ], + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts index 8edd19fe..138a3da9 100644 --- a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts +++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts @@ -1,7 +1,7 @@ import { Snowflake } from "discord.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { isOwner, sendErrorMessage } from "../../../pluginUtils.js"; +import { isOwner } from "../../../pluginUtils.js"; import { SECONDS, confirm, noop, renderUsername } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { rehostAttachment } from "../rehostAttachment.js"; @@ -32,12 +32,12 @@ export const ArchiveChannelCmd = channelArchiverCmd({ async run({ message: msg, args, pluginData }) { if (!args["attachment-channel"]) { - const confirmed = await confirm(msg.channel, msg.author.id, { + const confirmed = await confirm(msg, msg.author.id, { content: "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", }); if (!confirmed) { - sendErrorMessage(pluginData, msg.channel, "Canceled"); + void pluginData.state.common.sendErrorMessage(msg, "Canceled"); return; } } diff --git a/backend/src/plugins/ChannelArchiver/types.ts b/backend/src/plugins/ChannelArchiver/types.ts index 024edf3d..440b2f80 100644 --- a/backend/src/plugins/ChannelArchiver/types.ts +++ b/backend/src/plugins/ChannelArchiver/types.ts @@ -1,5 +1,14 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; +import { z } from "zod/v4"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; -export interface ChannelArchiverPluginType extends BasePluginType {} +export const zChannelArchiverPluginConfig = z.strictObject({}); + +export interface ChannelArchiverPluginType extends BasePluginType { + configSchema: typeof zChannelArchiverPluginConfig; + state: { + common: pluginUtils.PluginPublicInterface; + }; +} export const channelArchiverCmd = guildPluginMessageCommand(); diff --git a/backend/src/plugins/Common/CommonPlugin.ts b/backend/src/plugins/Common/CommonPlugin.ts new file mode 100644 index 00000000..6befad91 --- /dev/null +++ b/backend/src/plugins/Common/CommonPlugin.ts @@ -0,0 +1,71 @@ +import { Attachment, MessageMentionOptions, SendableChannels, TextBasedChannel } from "discord.js"; +import { guildPlugin } from "knub"; +import { GenericCommandSource, sendContextResponse } from "../../pluginUtils.js"; +import { errorMessage, successMessage } from "../../utils.js"; +import { getErrorEmoji, getSuccessEmoji } from "./functions/getEmoji.js"; +import { CommonPluginType, zCommonConfig } from "./types.js"; + +export const CommonPlugin = guildPlugin()({ + name: "common", + dependencies: () => [], + configSchema: zCommonConfig, + public(pluginData) { + return { + getSuccessEmoji, + getErrorEmoji, + + sendSuccessMessage: async ( + context: GenericCommandSource | SendableChannels, + body: string, + allowedMentions?: MessageMentionOptions, + responseInteraction?: never, + ephemeral = true, + ) => { + const emoji = getSuccessEmoji(pluginData); + const formattedBody = successMessage(body, emoji); + const content = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody }; + if ("isSendable" in context) { + return context.send(content); + } + return sendContextResponse(context, content, ephemeral); + }, + + sendErrorMessage: async ( + context: GenericCommandSource | SendableChannels, + body: string, + allowedMentions?: MessageMentionOptions, + responseInteraction?: never, + ephemeral = true, + ) => { + const emoji = getErrorEmoji(pluginData); + const formattedBody = errorMessage(body, emoji); + const content = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody }; + if ("isSendable" in context) { + return context.send(content); + } + return sendContextResponse(context, content, ephemeral); + }, + + storeAttachmentsAsMessage: async (attachments: Attachment[], backupChannel?: TextBasedChannel | null) => { + const attachmentChannelId = pluginData.config.get().attachment_storing_channel; + const channel = attachmentChannelId + ? (pluginData.guild.channels.cache.get(attachmentChannelId) as TextBasedChannel) ?? backupChannel + : backupChannel; + + if (!channel) { + throw new Error( + "Cannot store attachments: no attachment storing channel configured, and no backup channel passed", + ); + } + if (!channel.isSendable()) { + throw new Error("Passed attachment storage channel is not sendable"); + } + + return channel.send({ + content: `Storing ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}`, + files: attachments.map((a) => a.url), + }); + }, + }; + }, +}); diff --git a/backend/src/plugins/Common/docs.ts b/backend/src/plugins/Common/docs.ts new file mode 100644 index 00000000..cf4a45fa --- /dev/null +++ b/backend/src/plugins/Common/docs.ts @@ -0,0 +1,9 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zCommonConfig } from "./types.js"; + +export const commonPluginDocs: ZeppelinPluginDocs = { + type: "stable", + configSchema: zCommonConfig, + + prettyName: "Common", +}; diff --git a/backend/src/plugins/Common/functions/getEmoji.ts b/backend/src/plugins/Common/functions/getEmoji.ts new file mode 100644 index 00000000..33614606 --- /dev/null +++ b/backend/src/plugins/Common/functions/getEmoji.ts @@ -0,0 +1,11 @@ +import { GuildPluginData } from "knub"; +import { CommonPluginType } from "../types.js"; +import { env } from "../../../env.js"; + +export function getSuccessEmoji(pluginData: GuildPluginData) { + return pluginData.config.get().success_emoji ?? env.DEFAULT_SUCCESS_EMOJI; +} + +export function getErrorEmoji(pluginData: GuildPluginData) { + return pluginData.config.get().error_emoji ?? env.DEFAULT_ERROR_EMOJI; +} diff --git a/backend/src/plugins/Common/types.ts b/backend/src/plugins/Common/types.ts new file mode 100644 index 00000000..9a397864 --- /dev/null +++ b/backend/src/plugins/Common/types.ts @@ -0,0 +1,12 @@ +import { BasePluginType } from "knub"; +import z from "zod/v4"; + +export const zCommonConfig = z.strictObject({ + success_emoji: z.string().nullable().default(null), + error_emoji: z.string().nullable().default(null), + attachment_storing_channel: z.nullable(z.string()).default(null), +}); + +export interface CommonPluginType extends BasePluginType { + configSchema: typeof zCommonConfig; +} diff --git a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts index fd6fe3f7..1dee4fae 100644 --- a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts +++ b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts @@ -4,18 +4,11 @@ import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { VoiceStateUpdateEvt } from "./events/VoiceStateUpdateEvt.js"; import { CompanionChannelsPluginType, zCompanionChannelsConfig } from "./types.js"; -const defaultOptions = { - config: { - entries: {}, - }, -}; - export const CompanionChannelsPlugin = guildPlugin()({ name: "companion_channels", dependencies: () => [LogsPlugin], - configParser: (input) => zCompanionChannelsConfig.parse(input), - defaultOptions, + configSchema: zCompanionChannelsConfig, events: [VoiceStateUpdateEvt], diff --git a/backend/src/plugins/CompanionChannels/info.ts b/backend/src/plugins/CompanionChannels/docs.ts similarity index 76% rename from backend/src/plugins/CompanionChannels/info.ts rename to backend/src/plugins/CompanionChannels/docs.ts index d4fb7860..caf9ea39 100644 --- a/backend/src/plugins/CompanionChannels/info.ts +++ b/backend/src/plugins/CompanionChannels/docs.ts @@ -1,11 +1,12 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zCompanionChannelsConfig } from "./types.js"; -export const companionChannelsPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, - prettyName: "Companion channels", +export const companionChannelsPluginDocs: ZeppelinPluginDocs = { + type: "stable", configSchema: zCompanionChannelsConfig, + + prettyName: "Companion channels", description: trimPluginDescription(` Set up 'companion channels' between text and voice channels. Once set up, any time a user joins one of the specified voice channels, diff --git a/backend/src/plugins/CompanionChannels/types.ts b/backend/src/plugins/CompanionChannels/types.ts index e009f560..6f0bc12f 100644 --- a/backend/src/plugins/CompanionChannels/types.ts +++ b/backend/src/plugins/CompanionChannels/types.ts @@ -1,5 +1,5 @@ import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { GuildLogs } from "../../data/GuildLogs.js"; import { zBoundedCharacters, zSnowflake } from "../../utils.js"; @@ -13,11 +13,11 @@ export const zCompanionChannelOpts = z.strictObject({ export type TCompanionChannelOpts = z.infer; export const zCompanionChannelsConfig = z.strictObject({ - entries: z.record(zBoundedCharacters(0, 100), zCompanionChannelOpts), + entries: z.record(zBoundedCharacters(0, 100), zCompanionChannelOpts).default({}), }); export interface CompanionChannelsPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zCompanionChannelsConfig; state: { errorCooldownManager: CooldownManager; serverLogs: GuildLogs; diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index 330b409a..e4d33a78 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -1,54 +1,41 @@ -import { PluginOptions, guildPlugin } from "knub"; -import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks.js"; +import { PluginOverride, guildPlugin } from "knub"; +import { GuildCases } from "../../data/GuildCases.js"; +import { CasesPlugin } from "../Cases/CasesPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; +import { ModActionsPlugin } from "../ModActions/ModActionsPlugin.js"; import { MutesPlugin } from "../Mutes/MutesPlugin.js"; import { UtilityPlugin } from "../Utility/UtilityPlugin.js"; -import { ContextClickedEvt } from "./events/ContextClickedEvt.js"; +import { BanCmd } from "./commands/BanUserCtxCmd.js"; +import { CleanCmd } from "./commands/CleanMessageCtxCmd.js"; +import { ModMenuCmd } from "./commands/ModMenuUserCtxCmd.js"; +import { MuteCmd } from "./commands/MuteUserCtxCmd.js"; +import { NoteCmd } from "./commands/NoteUserCtxCmd.js"; +import { WarnCmd } from "./commands/WarnUserCtxCmd.js"; import { ContextMenuPluginType, zContextMenusConfig } from "./types.js"; -import { loadAllCommands } from "./utils/loadAllCommands.js"; -const defaultOptions: PluginOptions = { - config: { - can_use: false, +const defaultOverrides: Array> = [ + { + level: ">=50", + config: { + can_use: true, - user_muteindef: false, - user_mute1d: false, - user_mute1h: false, - user_info: false, - - message_clean10: false, - message_clean25: false, - message_clean50: false, - }, - overrides: [ - { - level: ">=50", - config: { - can_use: true, - }, + can_open_mod_menu: true, }, - ], -}; + }, +]; export const ContextMenuPlugin = guildPlugin()({ name: "context_menu", - dependencies: () => [MutesPlugin, LogsPlugin, UtilityPlugin], - configParser: (input) => zContextMenusConfig.parse(input), - defaultOptions, + dependencies: () => [CasesPlugin, MutesPlugin, ModActionsPlugin, LogsPlugin, UtilityPlugin], + configSchema: zContextMenusConfig, + defaultOverrides, - // prettier-ignore - events: [ - ContextClickedEvt, - ], + contextMenuCommands: [ModMenuCmd, NoteCmd, WarnCmd, MuteCmd, BanCmd, CleanCmd], beforeLoad(pluginData) { const { state, guild } = pluginData; - state.contextMenuLinks = new GuildContextMenuLinks(guild.id); - }, - - afterLoad(pluginData) { - loadAllCommands(pluginData); + state.cases = GuildCases.getGuildInstance(guild.id); }, }); diff --git a/backend/src/plugins/ContextMenus/actions/ban.ts b/backend/src/plugins/ContextMenus/actions/ban.ts new file mode 100644 index 00000000..3a3e282e --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/ban.ts @@ -0,0 +1,116 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { GuildPluginData } from "knub"; +import { humanizeDuration } from "../../../humanizeDuration.js"; +import { logger } from "../../../logger.js"; +import { canActOn } from "../../../pluginUtils.js"; +import { convertDelayStringToMS, renderUserUsername } from "../../../utils.js"; +import { CaseArgs } from "../../Cases/types.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; +import { updateAction } from "./update.js"; + +async function banAction( + pluginData: GuildPluginData, + duration: string | undefined, + reason: string | undefined, + evidence: string | undefined, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, +) { + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasBanPermission(executingMember, interaction.channelId))) { + await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interactionToReply.editReply({ content: "Cannot ban: insufficient permissions", embeds: [], components: [] }); + return; + } + + const caseArgs: Partial = { + modId: executingMember.id, + }; + + const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; + const result = await modactions.banUserId(target, reason, reason, { caseArgs }, durationMs); + if (result.status === "failed") { + await interactionToReply.editReply({ content: "Error: Failed to ban user", embeds: [], components: [] }); + return; + } + + const userName = renderUserUsername(targetMember.user); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; + const banMessage = `Banned **${userName}** ${ + durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" + } (Case #${result.case.case_number})${messageResultText}`; + + if (evidence) { + await updateAction(pluginData, executingMember, result.case, evidence); + } + + await interactionToReply.editReply({ content: banMessage, embeds: [], components: [] }); +} + +export async function launchBanActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.BAN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Ban"); + const durationIn = new TextInputBuilder() + .setCustomId("duration") + .setLabel("Duration (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Short); + const reasonIn = new TextInputBuilder() + .setCustomId("reason") + .setLabel("Reason (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const durationRow = new ActionRowBuilder().addComponents(durationIn); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(durationRow, reasonRow, evidenceRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Ban interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); + + await banAction(pluginData, duration, reason, evidence, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts index 9278f04b..69f17336 100644 --- a/backend/src/plugins/ContextMenus/actions/clean.ts +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -1,16 +1,26 @@ -import { ContextMenuCommandInteraction, TextChannel } from "discord.js"; +import { + ActionRowBuilder, + Message, + MessageContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import { GuildPluginData } from "knub"; -import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; +import { logger } from "../../../logger.js"; import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; export async function cleanAction( pluginData: GuildPluginData, amount: number, - interaction: ContextMenuCommandInteraction, + target: string, + targetMessage: Message, + targetChannelId: string, + interaction: ModalSubmitInteraction, ) { - await interaction.deferReply({ ephemeral: true }); const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -18,33 +28,80 @@ export async function cleanAction( }); const utility = pluginData.getPlugin(UtilityPlugin); - if (!userCfg.can_use || !(await utility.hasPermission(executingMember, interaction.channelId, "can_clean"))) { - await interaction.followUp({ content: "Cannot clean: insufficient permissions" }); + if (!userCfg.can_use || !(await utility.hasPermission(executingMember, targetChannelId, "can_clean"))) { + await interaction + .editReply({ content: "Cannot clean: insufficient permissions", embeds: [], components: [] }) + .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); return; } - 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 targetChannel = await pluginData.guild.channels.fetch(targetChannelId); + if (!targetChannel?.isTextBased()) { + await interaction + .editReply({ content: "Cannot clean: target channel is not a text channel", embeds: [], components: [] }) + .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); + return; + } - const targetUserOnly = false; - const deletePins = false; - const user = undefined; + await interaction + .editReply({ + content: `Cleaning ${amount} messages from ${target}...`, + embeds: [], + components: [], + }) + .catch((err) => logger.error(`Clean interaction reply failed: ${err}`)); - try { - await 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) { - await interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + const fetchMessagesResult = await utility.fetchChannelMessagesToClean(targetChannel, { + count: amount, + beforeId: targetMessage.id, + }); + if ("error" in fetchMessagesResult) { + interaction.editReply(fetchMessagesResult.error); + return; + } - 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; - } + if (fetchMessagesResult.messages.length > 0) { + await utility.cleanMessages(targetChannel, fetchMessagesResult.messages, interaction.user); + interaction.editReply( + `Cleaned ${fetchMessagesResult.messages.length} ${ + fetchMessagesResult.messages.length === 1 ? "message" : "messages" + }`, + ); + } else { + interaction.editReply("No messages to clean"); } } + +export async function launchCleanActionModal( + pluginData: GuildPluginData, + interaction: MessageContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.CLEAN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Clean"); + const amountIn = new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short); + const amountRow = new ActionRowBuilder().addComponents(amountIn); + modal.addComponents(amountRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + await submitted.deferReply({ ephemeral: true }); + + const amount = submitted.fields.getTextInputValue("amount"); + if (isNaN(Number(amount))) { + interaction.editReply({ content: `Error: Amount '${amount}' is invalid`, embeds: [], components: [] }); + return; + } + + await cleanAction( + pluginData, + Number(amount), + target, + interaction.targetMessage, + interaction.channelId, + submitted, + ); + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index d7dc6052..e2901e6e 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -1,21 +1,36 @@ -import { ContextMenuCommandInteraction } from "discord.js"; -import humanizeDuration from "humanize-duration"; +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import { GuildPluginData } from "knub"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; +import { logger } from "../../../logger.js"; import { canActOn } from "../../../pluginUtils.js"; import { convertDelayStringToMS } from "../../../utils.js"; import { CaseArgs } from "../../Cases/types.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { MutesPlugin } from "../../Mutes/MutesPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; +import { updateAction } from "./update.js"; -export async function muteAction( +async function muteAction( pluginData: GuildPluginData, duration: string | undefined, - interaction: ContextMenuCommandInteraction, + reason: string | undefined, + evidence: string | undefined, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, ) { - await interaction.deferReply({ ephemeral: true }); + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; const executingMember = await pluginData.guild.members.fetch(interaction.user.id); const userCfg = await pluginData.config.getMatchingConfig({ channelId: interaction.channelId, @@ -24,43 +39,100 @@ export async function muteAction( const modactions = pluginData.getPlugin(ModActionsPlugin); if (!userCfg.can_use || !(await modactions.hasMutePermission(executingMember, interaction.channelId))) { - await interaction.followUp({ content: "Cannot mute: insufficient permissions" }); + await interactionToReply.editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }); return; } - 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 targetMember = await pluginData.guild.members.fetch(target); if (!canActOn(pluginData, executingMember, targetMember)) { - await interaction.followUp({ ephemeral: true, content: "Cannot mute: insufficient permissions" }); + await interactionToReply.editReply({ + content: "Cannot mute: insufficient permissions", + embeds: [], + components: [], + }); return; } const caseArgs: Partial = { modId: executingMember.id, }; + const mutes = pluginData.getPlugin(MutesPlugin); + const durationMs = duration ? convertDelayStringToMS(duration)! : undefined; try { - const result = await mutes.muteUser(userId, durationMs, "Context Menu Action", { caseArgs }); - + const result = await mutes.muteUser(target, durationMs, reason, reason, { caseArgs }); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; 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`; + } (Case #${result.case!.case_number})${messageResultText}`; - await interaction.followUp({ ephemeral: true, content: muteMessage }); + if (evidence) { + await updateAction(pluginData, executingMember, result.case!, evidence); + } + + await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); } catch (e) { - await interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + await interactionToReply.editReply({ + content: "Plugin error, please check your BOT_ALERTs", + embeds: [], + components: [], + }); 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`, + body: `Failed to mute <@!${target}> in ContextMenu action \`mute\` because a mute role has not been specified in server config`, }); } else { throw e; } } } + +export async function launchMuteActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.MUTE}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Mute"); + const durationIn = new TextInputBuilder() + .setCustomId("duration") + .setLabel("Duration (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Short); + const reasonIn = new TextInputBuilder() + .setCustomId("reason") + .setLabel("Reason (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const durationRow = new ActionRowBuilder().addComponents(durationIn); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(durationRow, reasonRow, evidenceRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Mute interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const duration = submitted.fields.getTextInputValue("duration"); + const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); + + await muteAction(pluginData, duration, reason, evidence, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/note.ts b/backend/src/plugins/ContextMenus/actions/note.ts new file mode 100644 index 00000000..566d44ad --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/note.ts @@ -0,0 +1,103 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../data/CaseTypes.js"; +import { logger } from "../../../logger.js"; +import { canActOn } from "../../../pluginUtils.js"; +import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; +import { renderUserUsername } from "../../../utils.js"; +import { LogsPlugin } from "../../Logs/LogsPlugin.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; + +async function noteAction( + pluginData: GuildPluginData, + reason: string, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, +) { + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasNotePermission(executingMember, interaction.channelId))) { + await interactionToReply.editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interactionToReply.editReply({ + content: "Cannot note: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: target, + modId: executingMember.id, + type: CaseTypes.Note, + reason, + }); + + pluginData.getPlugin(LogsPlugin).logMemberNote({ + mod: interaction.user, + user: targetMember.user, + caseNumber: createdCase.case_number, + reason, + }); + + const userName = renderUserUsername(targetMember.user); + await interactionToReply.editReply({ + content: `Note added on **${userName}** (Case #${createdCase.case_number})`, + embeds: [], + components: [], + }); +} + +export async function launchNoteActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.NOTE}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Note"); + const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Note").setStyle(TextInputStyle.Paragraph); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + modal.addComponents(reasonRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Note interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const reason = submitted.fields.getTextInputValue("reason"); + + await noteAction(pluginData, reason, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/update.ts b/backend/src/plugins/ContextMenus/actions/update.ts new file mode 100644 index 00000000..841b406b --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/update.ts @@ -0,0 +1,28 @@ +import { GuildMember } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../data/CaseTypes.js"; +import { Case } from "../../../data/entities/Case.js"; +import { CasesPlugin } from "../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../Logs/LogsPlugin.js"; +import { ContextMenuPluginType } from "../types.js"; + +export async function updateAction( + pluginData: GuildPluginData, + executingMember: GuildMember, + theCase: Case, + value: string, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + await casesPlugin.createCaseNote({ + caseId: theCase.case_number, + modId: executingMember.id, + body: value, + }); + + void pluginData.getPlugin(LogsPlugin).logCaseUpdate({ + mod: executingMember.user, + caseNumber: theCase.case_number, + caseType: CaseTypes[theCase.type], + note: value, + }); +} diff --git a/backend/src/plugins/ContextMenus/actions/userInfo.ts b/backend/src/plugins/ContextMenus/actions/userInfo.ts deleted file mode 100644 index cb47016c..00000000 --- a/backend/src/plugins/ContextMenus/actions/userInfo.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ContextMenuCommandInteraction } from "discord.js"; -import { GuildPluginData } from "knub"; -import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; - -export async function userInfoAction( - pluginData: GuildPluginData, - interaction: ContextMenuCommandInteraction, -) { - await 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); - 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/actions/warn.ts b/backend/src/plugins/ContextMenus/actions/warn.ts new file mode 100644 index 00000000..e0e34707 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/warn.ts @@ -0,0 +1,108 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ContextMenuCommandInteraction, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { GuildPluginData } from "knub"; +import { logger } from "../../../logger.js"; +import { canActOn } from "../../../pluginUtils.js"; +import { renderUserUsername } from "../../../utils.js"; +import { CaseArgs } from "../../Cases/types.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { MODAL_TIMEOUT } from "../commands/ModMenuUserCtxCmd.js"; +import { ContextMenuPluginType, ModMenuActionType } from "../types.js"; +import { updateAction } from "./update.js"; + +async function warnAction( + pluginData: GuildPluginData, + reason: string, + evidence: string | undefined, + target: string, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + submitInteraction: ModalSubmitInteraction, +) { + const interactionToReply = interaction.isButton() ? interaction : submitInteraction; + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + + const modactions = pluginData.getPlugin(ModActionsPlugin); + if (!userCfg.can_use || !(await modactions.hasWarnPermission(executingMember, interaction.channelId))) { + await interactionToReply.editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const targetMember = await pluginData.guild.members.fetch(target); + if (!canActOn(pluginData, executingMember, targetMember)) { + await interactionToReply.editReply({ + content: "Cannot warn: insufficient permissions", + embeds: [], + components: [], + }); + return; + } + + const caseArgs: Partial = { + modId: executingMember.id, + }; + + const result = await modactions.warnMember(targetMember, reason, reason, { caseArgs }); + if (result.status === "failed") { + await interactionToReply.editReply({ content: "Error: Failed to warn user", embeds: [], components: [] }); + return; + } + + const userName = renderUserUsername(targetMember.user); + const messageResultText = result.notifyResult.text ? ` (${result.notifyResult.text})` : ""; + const muteMessage = `Warned **${userName}** (Case #${result.case.case_number})${messageResultText}`; + + if (evidence) { + await updateAction(pluginData, executingMember, result.case, evidence); + } + + await interactionToReply.editReply({ content: muteMessage, embeds: [], components: [] }); +} + +export async function launchWarnActionModal( + pluginData: GuildPluginData, + interaction: ButtonInteraction | ContextMenuCommandInteraction, + target: string, +) { + const modalId = `${ModMenuActionType.WARN}:${interaction.id}`; + const modal = new ModalBuilder().setCustomId(modalId).setTitle("Warn"); + const reasonIn = new TextInputBuilder().setCustomId("reason").setLabel("Reason").setStyle(TextInputStyle.Paragraph); + const evidenceIn = new TextInputBuilder() + .setCustomId("evidence") + .setLabel("Evidence (Optional)") + .setRequired(false) + .setStyle(TextInputStyle.Paragraph); + const reasonRow = new ActionRowBuilder().addComponents(reasonIn); + const evidenceRow = new ActionRowBuilder().addComponents(evidenceIn); + modal.addComponents(reasonRow, evidenceRow); + + await interaction.showModal(modal); + await interaction + .awaitModalSubmit({ time: MODAL_TIMEOUT, filter: (i) => i.customId == modalId }) + .then(async (submitted) => { + if (interaction.isButton()) { + await submitted.deferUpdate().catch((err) => logger.error(`Warn interaction defer failed: ${err}`)); + } else if (interaction.isContextMenuCommand()) { + await submitted.deferReply({ ephemeral: true }); + } + + const reason = submitted.fields.getTextInputValue("reason"); + const evidence = submitted.fields.getTextInputValue("evidence"); + + await warnAction(pluginData, reason, evidence, target, interaction, submitted); + }); +} diff --git a/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts new file mode 100644 index 00000000..dfff9088 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/BanUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchBanActionModal } from "../actions/ban.js"; + +export const BanCmd = guildPluginUserContextMenuCommand({ + name: "Ban", + defaultMemberPermissions: PermissionFlagsBits.BanMembers.toString(), + async run({ pluginData, interaction }) { + await launchBanActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts new file mode 100644 index 00000000..83508ae3 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/CleanMessageCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginMessageContextMenuCommand } from "knub"; +import { launchCleanActionModal } from "../actions/clean.js"; + +export const CleanCmd = guildPluginMessageContextMenuCommand({ + name: "Clean", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), + async run({ pluginData, interaction }) { + await launchCleanActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts new file mode 100644 index 00000000..e3091ca8 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/ModMenuUserCtxCmd.ts @@ -0,0 +1,328 @@ +import { + APIEmbed, + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ContextMenuCommandInteraction, + GuildMember, + PermissionFlagsBits, + User, +} from "discord.js"; +import { GuildPluginData, guildPluginUserContextMenuCommand } from "knub"; +import { Case } from "../../../data/entities/Case.js"; +import { logger } from "../../../logger.js"; +import { SECONDS, UnknownUser, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils.js"; +import { asyncMap } from "../../../utils/async.js"; +import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields.js"; +import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; +import { CasesPlugin } from "../../Cases/CasesPlugin.js"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; +import { getUserInfoEmbed } from "../../Utility/functions/getUserInfoEmbed.js"; +import { launchBanActionModal } from "../actions/ban.js"; +import { launchMuteActionModal } from "../actions/mute.js"; +import { launchNoteActionModal } from "../actions/note.js"; +import { launchWarnActionModal } from "../actions/warn.js"; +import { + ContextMenuPluginType, + LoadModMenuPageFn, + ModMenuActionOpts, + ModMenuActionType, + ModMenuNavigationType, +} from "../types.js"; + +export const MODAL_TIMEOUT = 60 * SECONDS; +const MOD_MENU_TIMEOUT = 60 * SECONDS; +const CASES_PER_PAGE = 10; + +export const ModMenuCmd = guildPluginUserContextMenuCommand({ + name: "Mod Menu", + defaultMemberPermissions: PermissionFlagsBits.ViewAuditLog.toString(), + async run({ pluginData, interaction }) { + await interaction.deferReply({ ephemeral: true }); + + // Run permission checks for executing user. + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const userCfg = await pluginData.config.getMatchingConfig({ + channelId: interaction.channelId, + member: executingMember, + }); + if (!userCfg.can_use || !userCfg.can_open_mod_menu) { + await interaction.followUp({ content: "Error: Insufficient Permissions" }); + return; + } + + const user = await resolveUser(pluginData.client, interaction.targetId); + if (!user.id) { + await interaction.followUp("Error: User not found"); + return; + } + + // Load cases and display mod menu + const cases: Case[] = await pluginData.state.cases.with("notes").getByUserId(user.id); + const userName = + user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUserUsername(user); + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const totalCases = cases.length; + const totalPages: number = Math.max(Math.ceil(totalCases / CASES_PER_PAGE), 1); + const prefix = getGuildPrefix(pluginData); + const infoEmbed = await getUserInfoEmbed(pluginData, user.id, false); + displayModMenu( + pluginData, + interaction, + totalPages, + async (page) => { + const pageCases: Case[] = await pluginData.state.cases + .with("notes") + .getRecentByUserId(user.id, CASES_PER_PAGE, (page - 1) * CASES_PER_PAGE); + const lines = await asyncMap(pageCases, (c) => casesPlugin.getCaseSummary(c, true, interaction.targetId)); + + const firstCaseNum = (page - 1) * CASES_PER_PAGE + 1; + const lastCaseNum = Math.min(page * CASES_PER_PAGE, totalCases); + const title = + lines.length == 0 + ? `${userName}` + : `Most recent cases for ${userName} | ${firstCaseNum}-${lastCaseNum} of ${totalCases}`; + + const embed = { + author: { + name: title, + icon_url: user instanceof User ? user.displayAvatarURL() : undefined, + }, + fields: [ + ...getChunkedEmbedFields( + emptyEmbedValue, + lines.length == 0 ? `No cases found for **${userName}**` : lines.join("\n"), + ), + { + name: emptyEmbedValue, + value: trimLines( + lines.length == 0 ? "" : `Use \`${prefix}case \` to see more information about an individual case`, + ), + }, + ], + footer: { text: `Page ${page}/${totalPages}` }, + } satisfies APIEmbed; + + return embed; + }, + infoEmbed, + executingMember, + ); + }, +}); + +async function displayModMenu( + pluginData: GuildPluginData, + interaction: ContextMenuCommandInteraction, + totalPages: number, + loadPage: LoadModMenuPageFn, + infoEmbed: APIEmbed | null, + executingMember: GuildMember, +) { + if (interaction.deferred == false) { + await interaction.deferReply().catch((err) => logger.error(`Mod menu interaction defer failed: ${err}`)); + } + + const firstButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("⏪") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.FIRST })) + .setDisabled(true); + const prevButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("⬅") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.PREV })) + .setDisabled(true); + const infoButton = new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Info") + .setEmoji("ℹ") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })) + .setDisabled(infoEmbed != null ? false : true); + const nextButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("➡") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.NEXT })) + .setDisabled(totalPages > 1 ? false : true); + const lastButton = new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setEmoji("⏩") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.LAST })) + .setDisabled(totalPages > 1 ? false : true); + const navigationButtons = [firstButton, prevButton, infoButton, nextButton, lastButton] satisfies ButtonBuilder[]; + + const modactions = pluginData.getPlugin(ModActionsPlugin); + const moderationButtons = [ + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Note") + .setEmoji("📝") + .setDisabled(!(await modactions.hasNotePermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.NOTE, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Warn") + .setEmoji("⚠️") + .setDisabled(!(await modactions.hasWarnPermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.WARN, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Mute") + .setEmoji("🔇") + .setDisabled(!(await modactions.hasMutePermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.MUTE, target: interaction.targetId })), + new ButtonBuilder() + .setStyle(ButtonStyle.Primary) + .setLabel("Ban") + .setEmoji("🚫") + .setDisabled(!(await modactions.hasBanPermission(executingMember, interaction.channelId))) + .setCustomId(serializeCustomId({ action: ModMenuActionType.BAN, target: interaction.targetId })), + ] satisfies ButtonBuilder[]; + + const navigationRow = new ActionRowBuilder().addComponents(navigationButtons); + const moderationRow = new ActionRowBuilder().addComponents(moderationButtons); + + let page = 1; + await interaction + .editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }) + .then(async (currentPage) => { + const collector = await currentPage.createMessageComponentCollector({ + time: MOD_MENU_TIMEOUT, + }); + + collector.on("collect", async (i) => { + const opts = deserializeCustomId(i.customId); + if (opts.action == ModMenuActionType.PAGE) { + await i.deferUpdate().catch((err) => logger.error(`Mod menu defer failed: ${err}`)); + } + + // Update displayed embed if any navigation buttons were used + if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.INFO && infoEmbed != null) { + infoButton + .setLabel("Cases") + .setEmoji("📋") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.CASES })); + firstButton.setDisabled(true); + prevButton.setDisabled(true); + nextButton.setDisabled(true); + lastButton.setDisabled(true); + + await i + .editReply({ + embeds: [infoEmbed], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu info view failed: ${err}`)); + } else if (opts.action == ModMenuActionType.PAGE && opts.target == ModMenuNavigationType.CASES) { + infoButton + .setLabel("Info") + .setEmoji("ℹ") + .setCustomId(serializeCustomId({ action: ModMenuActionType.PAGE, target: ModMenuNavigationType.INFO })); + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, page, totalPages); + + await i + .editReply({ + embeds: [await loadPage(page)], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu cases view failed: ${err}`)); + } else if (opts.action == ModMenuActionType.PAGE) { + let pageDelta = 0; + switch (opts.target) { + case ModMenuNavigationType.PREV: + pageDelta = -1; + break; + case ModMenuNavigationType.NEXT: + pageDelta = 1; + break; + } + + let newPage = 1; + if (opts.target == ModMenuNavigationType.PREV || opts.target == ModMenuNavigationType.NEXT) { + newPage = Math.max(Math.min(page + pageDelta, totalPages), 1); + } else if (opts.target == ModMenuNavigationType.FIRST) { + newPage = 1; + } else if (opts.target == ModMenuNavigationType.LAST) { + newPage = totalPages; + } + + if (newPage != page) { + updateNavButtonState(firstButton, prevButton, nextButton, lastButton, newPage, totalPages); + + await i + .editReply({ + embeds: [await loadPage(newPage)], + components: [navigationRow, moderationRow], + }) + .catch((err) => logger.error(`Mod menu navigation failed: ${err}`)); + + page = newPage; + } + } else if (opts.action == ModMenuActionType.NOTE) { + await launchNoteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.WARN) { + await launchWarnActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.MUTE) { + await launchMuteActionModal(pluginData, i as ButtonInteraction, opts.target); + } else if (opts.action == ModMenuActionType.BAN) { + await launchBanActionModal(pluginData, i as ButtonInteraction, opts.target); + } + + collector.resetTimer(); + }); + + // Remove components on timeout. + collector.on("end", async (_, reason) => { + if (reason !== "messageDelete") { + await interaction + .editReply({ + components: [], + }) + .catch((err) => logger.error(`Mod menu timeout failed: ${err}`)); + } + }); + }) + .catch((err) => logger.error(`Mod menu setup failed: ${err}`)); +} + +function serializeCustomId(opts: ModMenuActionOpts) { + return `${opts.action}:${opts.target}`; +} + +function deserializeCustomId(customId: string): ModMenuActionOpts { + const opts: ModMenuActionOpts = { + action: customId.split(":")[0] as ModMenuActionType, + target: customId.split(":")[1], + }; + + return opts; +} + +function updateNavButtonState( + firstButton: ButtonBuilder, + prevButton: ButtonBuilder, + nextButton: ButtonBuilder, + lastButton: ButtonBuilder, + currentPage: number, + totalPages: number, +) { + if (currentPage > 1) { + firstButton.setDisabled(false); + prevButton.setDisabled(false); + } else { + firstButton.setDisabled(true); + prevButton.setDisabled(true); + } + + if (currentPage == totalPages) { + nextButton.setDisabled(true); + lastButton.setDisabled(true); + } else { + nextButton.setDisabled(false); + lastButton.setDisabled(false); + } +} diff --git a/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts new file mode 100644 index 00000000..559f6e70 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/MuteUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchMuteActionModal } from "../actions/mute.js"; + +export const MuteCmd = guildPluginUserContextMenuCommand({ + name: "Mute", + defaultMemberPermissions: PermissionFlagsBits.ModerateMembers.toString(), + async run({ pluginData, interaction }) { + await launchMuteActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts new file mode 100644 index 00000000..0e3807f3 --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/NoteUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchNoteActionModal } from "../actions/note.js"; + +export const NoteCmd = guildPluginUserContextMenuCommand({ + name: "Note", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), + async run({ pluginData, interaction }) { + await launchNoteActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts new file mode 100644 index 00000000..4721544a --- /dev/null +++ b/backend/src/plugins/ContextMenus/commands/WarnUserCtxCmd.ts @@ -0,0 +1,11 @@ +import { PermissionFlagsBits } from "discord.js"; +import { guildPluginUserContextMenuCommand } from "knub"; +import { launchWarnActionModal } from "../actions/warn.js"; + +export const WarnCmd = guildPluginUserContextMenuCommand({ + name: "Warn", + defaultMemberPermissions: PermissionFlagsBits.ManageMessages.toString(), + async run({ pluginData, interaction }) { + await launchWarnActionModal(pluginData, interaction, interaction.targetId); + }, +}); diff --git a/backend/src/plugins/ContextMenus/docs.ts b/backend/src/plugins/ContextMenus/docs.ts new file mode 100644 index 00000000..7d64055a --- /dev/null +++ b/backend/src/plugins/ContextMenus/docs.ts @@ -0,0 +1,9 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zContextMenusConfig } from "./types.js"; + +export const contextMenuPluginDocs: ZeppelinPluginDocs = { + type: "stable", + configSchema: zContextMenusConfig, + + prettyName: "Context menu", +}; diff --git a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts b/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts deleted file mode 100644 index dcfdaab0..00000000 --- a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { contextMenuEvt } from "../types.js"; -import { routeContextAction } from "../utils/contextRouter.js"; - -export const ContextClickedEvt = contextMenuEvt({ - event: "interactionCreate", - - async listener(meta) { - if (!meta.args.interaction.isContextMenuCommand()) return; - const inter = meta.args.interaction; - await routeContextAction(meta.pluginData, inter); - }, -}); diff --git a/backend/src/plugins/ContextMenus/info.ts b/backend/src/plugins/ContextMenus/info.ts deleted file mode 100644 index 508ca811..00000000 --- a/backend/src/plugins/ContextMenus/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zContextMenusConfig } from "./types.js"; - -export const contextMenuPluginInfo: ZeppelinPluginInfo = { - showInDocs: false, - prettyName: "Context menu", - configSchema: zContextMenusConfig, -}; diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts index f6ff126a..80c56dd1 100644 --- a/backend/src/plugins/ContextMenus/types.ts +++ b/backend/src/plugins/ContextMenus/types.ts @@ -1,23 +1,41 @@ -import { BasePluginType, guildPluginEventListener } from "knub"; -import z from "zod"; -import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks.js"; +import { APIEmbed, Awaitable } from "discord.js"; +import { BasePluginType } from "knub"; +import z from "zod/v4"; +import { GuildCases } from "../../data/GuildCases.js"; export const zContextMenusConfig = z.strictObject({ - can_use: z.boolean(), - user_muteindef: z.boolean(), - user_mute1d: z.boolean(), - user_mute1h: z.boolean(), - user_info: z.boolean(), - message_clean10: z.boolean(), - message_clean25: z.boolean(), - message_clean50: z.boolean(), + can_use: z.boolean().default(false), + can_open_mod_menu: z.boolean().default(false), }); export interface ContextMenuPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zContextMenusConfig; state: { - contextMenuLinks: GuildContextMenuLinks; + cases: GuildCases; }; } -export const contextMenuEvt = guildPluginEventListener(); +export const enum ModMenuActionType { + PAGE = "page", + NOTE = "note", + WARN = "warn", + CLEAN = "clean", + MUTE = "mute", + BAN = "ban", +} + +export const enum ModMenuNavigationType { + FIRST = "first", + PREV = "prev", + NEXT = "next", + LAST = "last", + INFO = "info", + CASES = "cases", +} + +export interface ModMenuActionOpts { + action: ModMenuActionType; + target: string; +} + +export type LoadModMenuPageFn = (page: number) => Awaitable; diff --git a/backend/src/plugins/ContextMenus/utils/contextRouter.ts b/backend/src/plugins/ContextMenus/utils/contextRouter.ts deleted file mode 100644 index 6a868479..00000000 --- a/backend/src/plugins/ContextMenus/utils/contextRouter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ContextMenuCommandInteraction } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ContextMenuPluginType } from "../types.js"; -import { hardcodedActions } from "./hardcodedContextOptions.js"; - -export async function routeContextAction( - pluginData: GuildPluginData, - interaction: ContextMenuCommandInteraction, -) { - const contextLink = await pluginData.state.contextMenuLinks.get(interaction.commandId); - if (!contextLink) 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 deleted file mode 100644 index 81dd48f8..00000000 --- a/backend/src/plugins/ContextMenus/utils/hardcodedContextOptions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { cleanAction } from "../actions/clean.js"; -import { muteAction } from "../actions/mute.js"; -import { userInfoAction } from "../actions/userInfo.js"; - -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 deleted file mode 100644 index e2c146d5..00000000 --- a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ApplicationCommandData, ApplicationCommandType } from "discord.js"; -import { GuildPluginData } from "knub"; -import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin.js"; -import { ContextMenuPluginType } from "../types.js"; -import { hardcodedContext } from "./hardcodedContextOptions.js"; - -export async function loadAllCommands(pluginData: GuildPluginData) { - const comms = await pluginData.client.application!.commands; - const cfg = pluginData.config.get(); - const newCommands: ApplicationCommandData[] = []; - const addedNames: string[] = []; - - for (const [name, label] of Object.entries(hardcodedContext)) { - if (!cfg[name]) continue; - - const type = name.startsWith("user") ? ApplicationCommandType.User : ApplicationCommandType.Message; - const data: ApplicationCommandData = { - type, - name: label, - }; - - addedNames.push(name); - newCommands.push(data); - } - - 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; - - const setCommandsArray = [...setCommands.values()]; - await pluginData.state.contextMenuLinks.deleteAll(); - - for (let i = 0; i < setCommandsArray.length; i++) { - const command = setCommandsArray[i]; - pluginData.state.contextMenuLinks.create(command.id, addedNames[i]); - } -} diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts index df321198..63c967e6 100644 --- a/backend/src/plugins/Counters/CountersPlugin.ts +++ b/backend/src/plugins/Counters/CountersPlugin.ts @@ -1,9 +1,15 @@ import { EventEmitter } from "events"; -import { PluginOptions, guildPlugin } from "knub"; +import { PluginOverride, guildPlugin } from "knub"; import { GuildCounters } from "../../data/GuildCounters.js"; -import { CounterTrigger, parseCounterConditionString } from "../../data/entities/CounterTrigger.js"; +import { + CounterTrigger, + buildCounterConditionString, + getReverseCounterComparisonOp, + parseCounterConditionString, +} from "../../data/entities/CounterTrigger.js"; import { makePublicFn } from "../../pluginUtils.js"; -import { MINUTES, convertDelayStringToMS, values } from "../../utils.js"; +import { MINUTES, convertDelayStringToMS } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { AddCounterCmd } from "./commands/AddCounterCmd.js"; import { CountersListCmd } from "./commands/CountersListCmd.js"; import { ResetAllCounterValuesCmd } from "./commands/ResetAllCounterValuesCmd.js"; @@ -22,28 +28,20 @@ import { CountersPluginType, zCountersConfig } from "./types.js"; const DECAY_APPLY_INTERVAL = 5 * MINUTES; -const defaultOptions: PluginOptions = { - config: { - counters: {}, - can_view: false, - can_edit: false, - can_reset_all: false, +const defaultOverrides: Array> = [ + { + level: ">=50", + config: { + can_view: true, + }, }, - overrides: [ - { - level: ">=50", - config: { - can_view: true, - }, + { + level: ">=100", + config: { + can_edit: true, }, - { - level: ">=100", - config: { - can_edit: true, - }, - }, - ], -}; + }, +]; /** * The Counters plugin keeps track of simple integer values that are tied to a user, channel, both, or neither — "counters". @@ -58,9 +56,8 @@ const defaultOptions: PluginOptions = { export const CountersPlugin = guildPlugin()({ name: "counters", - defaultOptions, - // TODO: Separate input and output types - configParser: (input) => zCountersConfig.parse(input), + configSchema: zCountersConfig, + defaultOverrides, public(pluginData) { return { @@ -88,7 +85,7 @@ export const CountersPlugin = guildPlugin()({ const { state, guild } = pluginData; state.counters = new GuildCounters(guild.id); - state.events = new EventEmitter(); + state.events = new EventEmitter() as any; state.counterTriggersByCounterId = new Map(); const activeTriggerIds: number[] = []; @@ -96,20 +93,23 @@ export const CountersPlugin = guildPlugin()({ // Initialize and store the IDs of each of the counters internally state.counterIds = {}; const config = pluginData.config.get(); - for (const counter of Object.values(config.counters)) { - const dbCounter = await state.counters.findOrCreateCounter(counter.name, counter.per_channel, counter.per_user); - state.counterIds[counter.name] = dbCounter.id; + for (const [counterName, counter] of Object.entries(config.counters)) { + const dbCounter = await state.counters.findOrCreateCounter(counterName, counter.per_channel, counter.per_user); + state.counterIds[counterName] = dbCounter.id; const thisCounterTriggers: CounterTrigger[] = []; state.counterTriggersByCounterId.set(dbCounter.id, thisCounterTriggers); // Initialize triggers - for (const trigger of values(counter.triggers)) { + for (const [triggerName, trigger] of Object.entries(counter.triggers)) { const parsedCondition = parseCounterConditionString(trigger.condition)!; - const parsedReverseCondition = parseCounterConditionString(trigger.reverse_condition)!; + const rawReverseCondition = + trigger.reverse_condition || + buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]); + const parsedReverseCondition = parseCounterConditionString(rawReverseCondition)!; const counterTrigger = await state.counters.initCounterTrigger( dbCounter.id, - trigger.name, + triggerName, parsedCondition[0], parsedCondition[1], parsedReverseCondition[0], @@ -127,6 +127,10 @@ export const CountersPlugin = guildPlugin()({ await state.counters.markUnusedTriggersToBeDeleted(activeTriggerIds); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + async afterLoad(pluginData) { const { state } = pluginData; @@ -162,6 +166,6 @@ export const CountersPlugin = guildPlugin()({ } } - state.events.removeAllListeners(); + (state.events as any).removeAllListeners(); }, }); diff --git a/backend/src/plugins/Counters/commands/AddCounterCmd.ts b/backend/src/plugins/Counters/commands/AddCounterCmd.ts index c1ff37e7..6ef3ec57 100644 --- a/backend/src/plugins/Counters/commands/AddCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/AddCounterCmd.ts @@ -2,7 +2,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "knub"; import { waitForReply } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { changeCounterValue } from "../functions/changeCounterValue.js"; import { CountersPluginType } from "../types.js"; @@ -45,22 +44,22 @@ export const AddCounterCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -69,13 +68,13 @@ export const AddCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to add to?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -87,13 +86,13 @@ export const AddCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to add to?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } @@ -105,13 +104,13 @@ export const AddCounterCmd = guildPluginMessageCommand()({ message.channel.send("How much would you like to add to the counter's value?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialAmount = parseInt(reply.content, 10); if (!potentialAmount) { - sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Not a number, cancelling"); return; } @@ -120,18 +119,21 @@ export const AddCounterCmd = guildPluginMessageCommand()({ await changeCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, amount); const newValue = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null); - const counterName = counter.name || args.counterName; if (channel && user) { message.channel.send( - `Added ${amount} to **${counterName}** for <@!${user.id}> in <#${channel.id}>. The value is now ${newValue}.`, + `Added ${amount} to **${args.counterName}** for <@!${user.id}> in <#${channel.id}>. The value is now ${newValue}.`, ); } else if (channel) { - message.channel.send(`Added ${amount} to **${counterName}** in <#${channel.id}>. The value is now ${newValue}.`); + message.channel.send( + `Added ${amount} to **${args.counterName}** in <#${channel.id}>. The value is now ${newValue}.`, + ); } else if (user) { - message.channel.send(`Added ${amount} to **${counterName}** for <@!${user.id}>. The value is now ${newValue}.`); + message.channel.send( + `Added ${amount} to **${args.counterName}** for <@!${user.id}>. The value is now ${newValue}.`, + ); } else { - message.channel.send(`Added ${amount} to **${counterName}**. The value is now ${newValue}.`); + message.channel.send(`Added ${amount} to **${args.counterName}**. The value is now ${newValue}.`); } }, }); diff --git a/backend/src/plugins/Counters/commands/CountersListCmd.ts b/backend/src/plugins/Counters/commands/CountersListCmd.ts index 396efae5..ece20727 100644 --- a/backend/src/plugins/Counters/commands/CountersListCmd.ts +++ b/backend/src/plugins/Counters/commands/CountersListCmd.ts @@ -1,5 +1,4 @@ import { guildPluginMessageCommand } from "knub"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { trimMultilineString, ucfirst } from "../../../utils.js"; import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; import { CountersPluginType } from "../types.js"; @@ -13,14 +12,14 @@ export const CountersListCmd = guildPluginMessageCommand()({ async run({ pluginData, message }) { const config = await pluginData.config.getForMessage(message); - const countersToShow = Array.from(Object.values(config.counters)).filter((c) => c.can_view !== false); + const countersToShow = Object.entries(config.counters).filter(([, c]) => c.can_view !== false); if (!countersToShow.length) { - sendErrorMessage(pluginData, message.channel, "No counters are configured for this server"); + void pluginData.state.common.sendErrorMessage(message, "No counters are configured for this server"); return; } - const counterLines = countersToShow.map((counter) => { - const title = counter.pretty_name ? `**${counter.pretty_name}** (\`${counter.name}\`)` : `\`${counter.name}\``; + const counterLines = countersToShow.map(([counterName, counter]) => { + const title = counter.pretty_name ? `**${counter.pretty_name}** (\`${counterName}\`)` : `\`${counterName}\``; const types: string[] = []; if (counter.per_user) types.push("per user"); diff --git a/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts b/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts index 8b5a4520..732b41bb 100644 --- a/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts +++ b/backend/src/plugins/Counters/commands/ResetAllCounterValuesCmd.ts @@ -1,6 +1,5 @@ import { guildPluginMessageCommand } from "knub"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { confirm, noop, trimMultilineString } from "../../../utils.js"; import { resetAllCounterValues } from "../functions/resetAllCounterValues.js"; import { CountersPluginType } from "../types.js"; @@ -18,36 +17,41 @@ export const ResetAllCounterValuesCmd = guildPluginMessageCommand null); await resetAllCounterValues(pluginData, args.counterName); loadingMessage?.delete().catch(noop); - sendSuccessMessage(pluginData, message.channel, `All counter values for **${counterName}** have been reset`); + void pluginData.state.common.sendSuccessMessage( + message, + `All counter values for **${args.counterName}** have been reset`, + ); pluginData.getKnubInstance().reloadGuild(pluginData.guild.id); }, diff --git a/backend/src/plugins/Counters/commands/ResetCounterCmd.ts b/backend/src/plugins/Counters/commands/ResetCounterCmd.ts index 1d982973..83c690ef 100644 --- a/backend/src/plugins/Counters/commands/ResetCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/ResetCounterCmd.ts @@ -2,7 +2,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "knub"; import { waitForReply } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { setCounterValue } from "../functions/setCounterValue.js"; import { CountersPluginType } from "../types.js"; @@ -40,22 +39,22 @@ export const ResetCounterCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to reset this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to reset this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -64,13 +63,13 @@ export const ResetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to reset?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -82,13 +81,13 @@ export const ResetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to reset?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } @@ -96,16 +95,15 @@ export const ResetCounterCmd = guildPluginMessageCommand()({ } await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, counter.initial_value); - const counterName = counter.name || args.counterName; if (channel && user) { - message.channel.send(`Reset **${counterName}** for <@!${user.id}> in <#${channel.id}>`); + message.channel.send(`Reset **${args.counterName}** for <@!${user.id}> in <#${channel.id}>`); } else if (channel) { - message.channel.send(`Reset **${counterName}** in <#${channel.id}>`); + message.channel.send(`Reset **${args.counterName}** in <#${channel.id}>`); } else if (user) { - message.channel.send(`Reset **${counterName}** for <@!${user.id}>`); + message.channel.send(`Reset **${args.counterName}** for <@!${user.id}>`); } else { - message.channel.send(`Reset **${counterName}**`); + message.channel.send(`Reset **${args.counterName}**`); } }, }); diff --git a/backend/src/plugins/Counters/commands/SetCounterCmd.ts b/backend/src/plugins/Counters/commands/SetCounterCmd.ts index a126e32f..21243341 100644 --- a/backend/src/plugins/Counters/commands/SetCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/SetCounterCmd.ts @@ -2,7 +2,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { guildPluginMessageCommand } from "knub"; import { waitForReply } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { UnknownUser, resolveUser } from "../../../utils.js"; import { setCounterValue } from "../functions/setCounterValue.js"; import { CountersPluginType } from "../types.js"; @@ -45,22 +44,22 @@ export const SetCounterCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_edit === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to edit this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -69,13 +68,13 @@ export const SetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel || !(potentialChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -87,13 +86,13 @@ export const SetCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to change?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } @@ -105,13 +104,13 @@ export const SetCounterCmd = guildPluginMessageCommand()({ message.channel.send("What would you like to set the counter's value to?"); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialValue = parseInt(reply.content, 10); if (Number.isNaN(potentialValue)) { - sendErrorMessage(pluginData, message.channel, "Not a number, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Not a number, cancelling"); return; } @@ -119,21 +118,20 @@ export const SetCounterCmd = guildPluginMessageCommand()({ } if (value < 0) { - sendErrorMessage(pluginData, message.channel, "Cannot set counter value below 0"); + void pluginData.state.common.sendErrorMessage(message, "Cannot set counter value below 0"); return; } await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, value); - const counterName = counter.name || args.counterName; if (channel && user) { - message.channel.send(`Set **${counterName}** for <@!${user.id}> in <#${channel.id}> to ${value}`); + message.channel.send(`Set **${args.counterName}** for <@!${user.id}> in <#${channel.id}> to ${value}`); } else if (channel) { - message.channel.send(`Set **${counterName}** in <#${channel.id}> to ${value}`); + message.channel.send(`Set **${args.counterName}** in <#${channel.id}> to ${value}`); } else if (user) { - message.channel.send(`Set **${counterName}** for <@!${user.id}> to ${value}`); + message.channel.send(`Set **${args.counterName}** for <@!${user.id}> to ${value}`); } else { - message.channel.send(`Set **${counterName}** to ${value}`); + message.channel.send(`Set **${args.counterName}** to ${value}`); } }, }); diff --git a/backend/src/plugins/Counters/commands/ViewCounterCmd.ts b/backend/src/plugins/Counters/commands/ViewCounterCmd.ts index 1a26c04a..d49e5ff8 100644 --- a/backend/src/plugins/Counters/commands/ViewCounterCmd.ts +++ b/backend/src/plugins/Counters/commands/ViewCounterCmd.ts @@ -2,7 +2,6 @@ import { Snowflake } from "discord.js"; import { guildPluginMessageCommand } from "knub"; import { waitForReply } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { resolveUser, UnknownUser } from "../../../utils.js"; import { CountersPluginType } from "../types.js"; @@ -39,22 +38,22 @@ export const ViewCounterCmd = guildPluginMessageCommand()({ const counter = config.counters[args.counterName]; const counterId = pluginData.state.counterIds[args.counterName]; if (!counter || !counterId) { - sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`); + void pluginData.state.common.sendErrorMessage(message, `Unknown counter: ${args.counterName}`); return; } if (counter.can_view === false) { - sendErrorMessage(pluginData, message.channel, `Missing permissions to view this counter's value`); + void pluginData.state.common.sendErrorMessage(message, `Missing permissions to view this counter's value`); return; } if (args.channel && !counter.per_channel) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-channel`); return; } if (args.user && !counter.per_user) { - sendErrorMessage(pluginData, message.channel, `This counter is not per-user`); + void pluginData.state.common.sendErrorMessage(message, `This counter is not per-user`); return; } @@ -63,13 +62,13 @@ export const ViewCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which channel's counter value would you like to view?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialChannel = pluginData.guild.channels.resolve(reply.content as Snowflake); if (!potentialChannel?.isTextBased()) { - sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Channel is not a text channel, cancelling"); return; } @@ -81,13 +80,13 @@ export const ViewCounterCmd = guildPluginMessageCommand()({ message.channel.send(`Which user's counter value would you like to view?`); const reply = await waitForReply(pluginData.client, message.channel, message.author.id); if (!reply || !reply.content) { - sendErrorMessage(pluginData, message.channel, "Cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Cancelling"); return; } const potentialUser = await resolveUser(pluginData.client, reply.content); if (!potentialUser || potentialUser instanceof UnknownUser) { - sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling"); + void pluginData.state.common.sendErrorMessage(message, "Unknown user, cancelling"); return; } @@ -96,16 +95,15 @@ export const ViewCounterCmd = guildPluginMessageCommand()({ const value = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null); const finalValue = value ?? counter.initial_value; - const counterName = counter.name || args.counterName; if (channel && user) { - message.channel.send(`**${counterName}** for <@!${user.id}> in <#${channel.id}> is ${finalValue}`); + message.channel.send(`**${args.counterName}** for <@!${user.id}> in <#${channel.id}> is ${finalValue}`); } else if (channel) { - message.channel.send(`**${counterName}** in <#${channel.id}> is ${finalValue}`); + message.channel.send(`**${args.counterName}** in <#${channel.id}> is ${finalValue}`); } else if (user) { - message.channel.send(`**${counterName}** for <@!${user.id}> is ${finalValue}`); + message.channel.send(`**${args.counterName}** for <@!${user.id}> is ${finalValue}`); } else { - message.channel.send(`**${counterName}** is ${finalValue}`); + message.channel.send(`**${args.counterName}** is ${finalValue}`); } }, }); diff --git a/backend/src/plugins/Counters/info.ts b/backend/src/plugins/Counters/docs.ts similarity index 71% rename from backend/src/plugins/Counters/info.ts rename to backend/src/plugins/Counters/docs.ts index abf07c70..7c8e3ec4 100644 --- a/backend/src/plugins/Counters/info.ts +++ b/backend/src/plugins/Counters/docs.ts @@ -1,11 +1,12 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { zCountersConfig } from "./types.js"; -export const countersPluginInfo: ZeppelinPluginInfo = { +export const countersPluginDocs: ZeppelinPluginDocs = { + type: "stable", + configSchema: zCountersConfig, + prettyName: "Counters", - showInDocs: true, description: "Keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number", configurationGuide: "See Counters setup guide", - configSchema: zCountersConfig, }; diff --git a/backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts b/backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts index 1da14282..3b4a290d 100644 --- a/backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts +++ b/backend/src/plugins/Counters/functions/getPrettyNameForCounter.ts @@ -4,5 +4,5 @@ import { CountersPluginType } from "../types.js"; export function getPrettyNameForCounter(pluginData: GuildPluginData, counterName: string) { const config = pluginData.config.get(); const counter = config.counters[counterName]; - return counter ? counter.pretty_name || counter.name : "Unknown Counter"; + return counter ? counter.pretty_name || counterName : "Unknown Counter"; } diff --git a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts index 00a2be7d..097634e9 100644 --- a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts +++ b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts @@ -13,5 +13,5 @@ export function getPrettyNameForCounterTrigger( } const trigger = counter.triggers[triggerName]; - return trigger ? trigger.pretty_name || trigger.name : "Unknown Counter Trigger"; + return trigger ? trigger.pretty_name || triggerName : "Unknown Counter Trigger"; } diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts index 289d0612..82b5eebc 100644 --- a/backend/src/plugins/Counters/types.ts +++ b/backend/src/plugins/Counters/types.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "events"; -import { BasePluginType } from "knub"; -import z from "zod"; +import { BasePluginType, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildCounters, MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../data/GuildCounters.js"; import { CounterTrigger, @@ -9,49 +9,26 @@ import { parseCounterConditionString, } from "../../data/entities/CounterTrigger.js"; import { zBoundedCharacters, zBoundedRecord, zDelayString } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import Timeout = NodeJS.Timeout; const MAX_COUNTERS = 5; const MAX_TRIGGERS_PER_COUNTER = 5; -export const zTrigger = z - .strictObject({ - // Dummy type because name gets replaced by the property key in transform() - name: z - .never() - .optional() - .transform(() => ""), - pretty_name: zBoundedCharacters(0, 100).nullable().default(null), - condition: zBoundedCharacters(1, 64).refine((str) => parseCounterConditionString(str) !== null, { - message: "Invalid counter trigger condition", - }), - reverse_condition: zBoundedCharacters(1, 64) - .refine((str) => parseCounterConditionString(str) !== null, { - message: "Invalid counter trigger reverse condition", - }) - .optional(), - }) - .transform((val, ctx) => { - const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); - - let reverseCondition = val.reverse_condition; - if (!reverseCondition) { - const parsedCondition = parseCounterConditionString(val.condition)!; - reverseCondition = buildCounterConditionString( - getReverseCounterComparisonOp(parsedCondition[0]), - parsedCondition[1], - ); - } - - return { - ...val, - name: ruleName, - reverse_condition: reverseCondition, - }; - }); +export const zTrigger = z.strictObject({ + // Dummy type because name gets replaced by the property key in transform() + pretty_name: zBoundedCharacters(0, 100).nullable().default(null), + condition: zBoundedCharacters(1, 64).refine((str) => parseCounterConditionString(str) !== null, { + message: "Invalid counter trigger condition", + }), + reverse_condition: zBoundedCharacters(1, 64) + .refine((str) => parseCounterConditionString(str) !== null, { + message: "Invalid counter trigger reverse condition", + }) + .optional(), +}); const zTriggerFromString = zBoundedCharacters(0, 100).transform((val, ctx) => { - const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); const parsedCondition = parseCounterConditionString(val); if (!parsedCondition) { ctx.addIssue({ @@ -61,7 +38,6 @@ const zTriggerFromString = zBoundedCharacters(0, 100).transform((val, ctx) => { return z.NEVER; } return { - name: ruleName, pretty_name: null, condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]), reverse_condition: buildCounterConditionString( @@ -74,22 +50,6 @@ const zTriggerFromString = zBoundedCharacters(0, 100).transform((val, ctx) => { const zTriggerInput = z.union([zTrigger, zTriggerFromString]); export const zCounter = z.strictObject({ - // Typed as "never" because you are not expected to supply this directly. - // The transform instead picks it up from the property key and the output type is a string. - name: z - .never() - .optional() - .transform((_, ctx) => { - const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); - if (!ruleName) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Counters must have names", - }); - return z.NEVER; - } - return ruleName; - }), pretty_name: zBoundedCharacters(0, 100).nullable().default(null), per_channel: z.boolean().default(false), per_user: z.boolean().default(false), @@ -102,16 +62,16 @@ export const zCounter = z.strictObject({ }) .nullable() .default(null), - can_view: z.boolean().default(false), - can_edit: z.boolean().default(false), - can_reset_all: z.boolean().default(false), + can_view: z.boolean().nullable().default(null), + can_edit: z.boolean().nullable().default(null), + can_reset_all: z.boolean().nullable().default(null), }); export const zCountersConfig = z.strictObject({ - counters: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCounter), 0, MAX_COUNTERS), - can_view: z.boolean(), - can_edit: z.boolean(), - can_reset_all: z.boolean(), + counters: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCounter), 0, MAX_COUNTERS).default({}), + can_view: z.boolean().default(false), + can_edit: z.boolean().default(false), + can_reset_all: z.boolean().default(false), }); export interface CounterEvents { @@ -125,12 +85,13 @@ export interface CounterEventEmitter extends EventEmitter { } export interface CountersPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zCountersConfig; state: { counters: GuildCounters; counterIds: Record; decayTimers: Timeout[]; events: CounterEventEmitter; counterTriggersByCounterId: Map; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts index 6c0b9bf3..7115ea9d 100644 --- a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts +++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts @@ -11,22 +11,20 @@ import { messageToTemplateSafeMessage, userToTemplateSafeUser, } from "../../utils/templateSafeObjects.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { runEvent } from "./functions/runEvent.js"; import { CustomEventsPluginType, zCustomEventsConfig } from "./types.js"; -const defaultOptions = { - config: { - events: {}, - }, -}; - export const CustomEventsPlugin = guildPlugin()({ name: "custom_events", dependencies: () => [LogsPlugin], - configParser: (input) => zCustomEventsConfig.parse(input), - defaultOptions, + configSchema: zCustomEventsConfig, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, afterLoad(pluginData) { const config = pluginData.config.get(); diff --git a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts index 8a3fa477..0992cc33 100644 --- a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts +++ b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts @@ -1,15 +1,15 @@ import { GuildPluginData } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { canActOn } from "../../../pluginUtils.js"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter.js"; -import { resolveMember, zSnowflake } from "../../../utils.js"; +import { resolveMember, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zAddRoleAction = z.strictObject({ type: z.literal("add_role"), - target: zSnowflake, + target: zBoundedCharacters(0, 100), role: z.union([zSnowflake, z.array(zSnowflake)]), }); export type TAddRoleAction = z.infer; diff --git a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts index 29c4bc4d..3b00e7d7 100644 --- a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts +++ b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts @@ -1,8 +1,8 @@ import { GuildPluginData } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter.js"; -import { zBoundedCharacters, zSnowflake } from "../../../utils.js"; +import { zBoundedCharacters } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; @@ -11,8 +11,8 @@ import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zCreateCaseAction = z.strictObject({ type: z.literal("create_case"), case_type: zBoundedCharacters(0, 32), - mod: zSnowflake, - target: zSnowflake, + mod: zBoundedCharacters(0, 100), + target: zBoundedCharacters(0, 100), reason: zBoundedCharacters(0, 4000), }); export type TCreateCaseAction = z.infer; diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts index 39984bf2..ac5f8632 100644 --- a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts +++ b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts @@ -1,14 +1,14 @@ import { Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; -import { convertDelayStringToMS, noop, zDelayString, zSnowflake } from "../../../utils.js"; +import { convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zMakeRoleMentionableAction = z.strictObject({ type: z.literal("make_role_mentionable"), - role: zSnowflake, + role: zBoundedCharacters(0, 100), timeout: zDelayString, }); export type TMakeRoleMentionableAction = z.infer; diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts index d1fefd11..01b4d648 100644 --- a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts +++ b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { zSnowflake } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; diff --git a/backend/src/plugins/CustomEvents/actions/messageAction.ts b/backend/src/plugins/CustomEvents/actions/messageAction.ts index 0e17c19f..4f46a512 100644 --- a/backend/src/plugins/CustomEvents/actions/messageAction.ts +++ b/backend/src/plugins/CustomEvents/actions/messageAction.ts @@ -1,15 +1,15 @@ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; -import { zBoundedCharacters, zSnowflake } from "../../../utils.js"; +import { zBoundedCharacters } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; import { CustomEventsPluginType } from "../types.js"; export const zMessageAction = z.strictObject({ type: z.literal("message"), - channel: zSnowflake, + channel: zBoundedCharacters(0, 100), content: zBoundedCharacters(0, 4000), }); export type TMessageAction = z.infer; diff --git a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts index c7d25bca..deac4517 100644 --- a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts +++ b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts @@ -1,17 +1,17 @@ import { Snowflake, VoiceChannel } from "discord.js"; import { GuildPluginData } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { canActOn } from "../../../pluginUtils.js"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; -import { resolveMember, zSnowflake } from "../../../utils.js"; +import { resolveMember, zBoundedCharacters } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { catchTemplateError } from "../catchTemplateError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zMoveToVoiceChannelAction = z.strictObject({ type: z.literal("move_to_vc"), - target: zSnowflake, - channel: zSnowflake, + target: zBoundedCharacters(0, 100), + channel: zBoundedCharacters(0, 100), }); export type TMoveToVoiceChannelAction = z.infer; diff --git a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts index 4c6af376..aa57d2eb 100644 --- a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts +++ b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts @@ -1,14 +1,14 @@ import { PermissionsBitField, PermissionsString, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; -import { zSnowflake } from "../../../utils.js"; +import { zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { ActionError } from "../ActionError.js"; import { CustomEventsPluginType, TCustomEvent } from "../types.js"; export const zSetChannelPermissionOverridesAction = z.strictObject({ type: z.literal("set_channel_permission_overrides"), - channel: zSnowflake, + channel: zBoundedCharacters(0, 100), overrides: z .array( z.strictObject({ diff --git a/backend/src/plugins/CustomEvents/docs.ts b/backend/src/plugins/CustomEvents/docs.ts new file mode 100644 index 00000000..d879909f --- /dev/null +++ b/backend/src/plugins/CustomEvents/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zCustomEventsConfig } from "./types.js"; + +export const customEventsPluginDocs: ZeppelinPluginDocs = { + prettyName: "Custom events", + type: "internal", + configSchema: zCustomEventsConfig, +}; diff --git a/backend/src/plugins/CustomEvents/functions/runEvent.ts b/backend/src/plugins/CustomEvents/functions/runEvent.ts index 12c0d777..33a0f9c3 100644 --- a/backend/src/plugins/CustomEvents/functions/runEvent.ts +++ b/backend/src/plugins/CustomEvents/functions/runEvent.ts @@ -1,6 +1,4 @@ -import { Message } from "discord.js"; import { GuildPluginData } from "knub"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { TemplateSafeValueContainer } from "../../../templateFormatter.js"; import { ActionError } from "../ActionError.js"; import { addRoleAction } from "../actions/addRoleAction.js"; @@ -39,7 +37,7 @@ export async function runEvent( } catch (e) { if (e instanceof ActionError) { if (event.trigger.type === "command") { - sendErrorMessage(pluginData, (eventData.msg as Message).channel, e.message); + void pluginData.state.common.sendErrorMessage(eventData.msg, e.message); } else { // TODO: Where to log action errors from other kinds of triggers? } diff --git a/backend/src/plugins/CustomEvents/info.ts b/backend/src/plugins/CustomEvents/info.ts deleted file mode 100644 index f9a8cec0..00000000 --- a/backend/src/plugins/CustomEvents/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zCustomEventsConfig } from "./types.js"; - -export const customEventsPluginInfo: ZeppelinPluginInfo = { - prettyName: "Custom events", - showInDocs: false, - configSchema: zCustomEventsConfig, -}; diff --git a/backend/src/plugins/CustomEvents/types.ts b/backend/src/plugins/CustomEvents/types.ts index 3c71b77f..877fe3fe 100644 --- a/backend/src/plugins/CustomEvents/types.ts +++ b/backend/src/plugins/CustomEvents/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType } from "knub"; -import z from "zod"; +import { BasePluginType, pluginUtils } from "knub"; +import z from "zod/v4"; import { zBoundedCharacters, zBoundedRecord } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { zAddRoleAction } from "./actions/addRoleAction.js"; import { zCreateCaseAction } from "./actions/createCaseAction.js"; import { zMakeRoleMentionableAction } from "./actions/makeRoleMentionableAction.js"; @@ -36,12 +37,13 @@ export const zCustomEvent = z.strictObject({ export type TCustomEvent = z.infer; export const zCustomEventsConfig = z.strictObject({ - events: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCustomEvent), 0, 100), + events: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCustomEvent), 0, 100).default({}), }); export interface CustomEventsPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zCustomEventsConfig; state: { clearTriggers: () => void; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts index 7be3ca96..ad2c4dfe 100644 --- a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts +++ b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts @@ -1,15 +1,9 @@ import { Guild } from "discord.js"; -import { BasePluginType, GlobalPluginData, globalPlugin, globalPluginEventListener } from "knub"; -import z from "zod"; +import { GlobalPluginData, globalPlugin, globalPluginEventListener } from "knub"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { Configs } from "../../data/Configs.js"; import { env } from "../../env.js"; - -interface GuildAccessMonitorPluginType extends BasePluginType { - state: { - allowedGuilds: AllowedGuilds; - }; -} +import { GuildAccessMonitorPluginType, zGuildAccessMonitorConfig } from "./types.js"; async function checkGuild(pluginData: GlobalPluginData, guild: Guild) { if (!(await pluginData.state.allowedGuilds.isAllowed(guild.id))) { @@ -24,7 +18,7 @@ async function checkGuild(pluginData: GlobalPluginData()({ name: "guild_access_monitor", - configParser: (input) => z.strictObject({}).parse(input), + configSchema: zGuildAccessMonitorConfig, events: [ globalPluginEventListener()({ diff --git a/backend/src/plugins/GuildAccessMonitor/docs.ts b/backend/src/plugins/GuildAccessMonitor/docs.ts new file mode 100644 index 00000000..71d558d9 --- /dev/null +++ b/backend/src/plugins/GuildAccessMonitor/docs.ts @@ -0,0 +1,13 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { trimPluginDescription } from "../../utils.js"; +import { zGuildAccessMonitorConfig } from "./types.js"; + +export const guildAccessMonitorPluginDocs: ZeppelinPluginDocs = { + type: "stable", + configSchema: zGuildAccessMonitorConfig, + + prettyName: "Bot control", + description: trimPluginDescription(` + Automatically leaves servers that are not on the list of allowed servers + `), +}; diff --git a/backend/src/plugins/GuildAccessMonitor/types.ts b/backend/src/plugins/GuildAccessMonitor/types.ts new file mode 100644 index 00000000..5e6b1eb2 --- /dev/null +++ b/backend/src/plugins/GuildAccessMonitor/types.ts @@ -0,0 +1,12 @@ +import { BasePluginType } from "knub"; +import { z } from "zod/v4"; +import { AllowedGuilds } from "../../data/AllowedGuilds.js"; + +export const zGuildAccessMonitorConfig = z.strictObject({}); + +export interface GuildAccessMonitorPluginType extends BasePluginType { + configSchema: typeof zGuildAccessMonitorConfig; + state: { + allowedGuilds: AllowedGuilds; + }; +} diff --git a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts index f1f69d59..e90cb48c 100644 --- a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts +++ b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts @@ -1,13 +1,12 @@ import { globalPlugin } from "knub"; -import z from "zod"; import { Configs } from "../../data/Configs.js"; import { reloadChangedGuilds } from "./functions/reloadChangedGuilds.js"; -import { GuildConfigReloaderPluginType } from "./types.js"; +import { GuildConfigReloaderPluginType, zGuildConfigReloaderPluginConfig } from "./types.js"; export const GuildConfigReloaderPlugin = globalPlugin()({ name: "guild_config_reloader", - configParser: (input) => z.strictObject({}).parse(input), + configSchema: zGuildConfigReloaderPluginConfig, async beforeLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/GuildConfigReloader/docs.ts b/backend/src/plugins/GuildConfigReloader/docs.ts new file mode 100644 index 00000000..93ce6693 --- /dev/null +++ b/backend/src/plugins/GuildConfigReloader/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zGuildConfigReloaderPluginConfig } from "./types.js"; + +export const guildConfigReloaderPluginDocs: ZeppelinPluginDocs = { + prettyName: "Guild config reloader", + type: "internal", + configSchema: zGuildConfigReloaderPluginConfig, +}; diff --git a/backend/src/plugins/GuildConfigReloader/info.ts b/backend/src/plugins/GuildConfigReloader/info.ts deleted file mode 100644 index 00aaa653..00000000 --- a/backend/src/plugins/GuildConfigReloader/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zGuildConfigReloaderPlugin } from "./types.js"; - -export const guildConfigReloaderPluginInfo: ZeppelinPluginInfo = { - prettyName: "Guild config reloader", - showInDocs: false, - configSchema: zGuildConfigReloaderPlugin, -}; diff --git a/backend/src/plugins/GuildConfigReloader/types.ts b/backend/src/plugins/GuildConfigReloader/types.ts index f9c0ad79..ae054856 100644 --- a/backend/src/plugins/GuildConfigReloader/types.ts +++ b/backend/src/plugins/GuildConfigReloader/types.ts @@ -1,12 +1,12 @@ import { BasePluginType } from "knub"; -import { z } from "zod"; +import { z } from "zod/v4"; import { Configs } from "../../data/Configs.js"; import Timeout = NodeJS.Timeout; -export const zGuildConfigReloaderPlugin = z.strictObject({}); +export const zGuildConfigReloaderPluginConfig = z.strictObject({}); export interface GuildConfigReloaderPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zGuildConfigReloaderPluginConfig; state: { guildConfigs: Configs; unloaded: boolean; diff --git a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts index e6d2b54d..753801ac 100644 --- a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts +++ b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts @@ -1,15 +1,14 @@ import { Guild } from "discord.js"; import { guildPlugin, guildPluginEventListener } from "knub"; -import z from "zod"; import { AllowedGuilds } from "../../data/AllowedGuilds.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { MINUTES } from "../../utils.js"; -import { GuildInfoSaverPluginType } from "./types.js"; +import { GuildInfoSaverPluginType, zGuildInfoSaverConfig } from "./types.js"; export const GuildInfoSaverPlugin = guildPlugin()({ name: "guild_info_saver", - configParser: (input) => z.strictObject({}).parse(input), + configSchema: zGuildInfoSaverConfig, events: [ guildPluginEventListener({ diff --git a/backend/src/plugins/GuildInfoSaver/docs.ts b/backend/src/plugins/GuildInfoSaver/docs.ts new file mode 100644 index 00000000..9ad10337 --- /dev/null +++ b/backend/src/plugins/GuildInfoSaver/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zGuildInfoSaverConfig } from "./types.js"; + +export const guildInfoSaverPluginDocs: ZeppelinPluginDocs = { + prettyName: "Guild info saver", + type: "internal", + configSchema: zGuildInfoSaverConfig, +}; diff --git a/backend/src/plugins/GuildInfoSaver/info.ts b/backend/src/plugins/GuildInfoSaver/info.ts deleted file mode 100644 index 7256e8cc..00000000 --- a/backend/src/plugins/GuildInfoSaver/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zGuildInfoSaverConfig } from "./types.js"; - -export const guildInfoSaverPluginInfo: ZeppelinPluginInfo = { - prettyName: "Guild info saver", - showInDocs: false, - configSchema: zGuildInfoSaverConfig, -}; diff --git a/backend/src/plugins/GuildInfoSaver/types.ts b/backend/src/plugins/GuildInfoSaver/types.ts index 7b40ee2a..3172856d 100644 --- a/backend/src/plugins/GuildInfoSaver/types.ts +++ b/backend/src/plugins/GuildInfoSaver/types.ts @@ -1,10 +1,10 @@ import { BasePluginType } from "knub"; -import { z } from "zod"; +import { z } from "zod/v4"; export const zGuildInfoSaverConfig = z.strictObject({}); export interface GuildInfoSaverPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zGuildInfoSaverConfig; state: { updateInterval: NodeJS.Timeout; }; diff --git a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts index cf521083..33fea080 100644 --- a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts +++ b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts @@ -1,5 +1,4 @@ import { guildPlugin } from "knub"; -import z from "zod"; import { GuildMemberCache } from "../../data/GuildMemberCache.js"; import { makePublicFn } from "../../pluginUtils.js"; import { SECONDS } from "../../utils.js"; @@ -10,14 +9,14 @@ import { updateMemberCacheOnMessage } from "./events/updateMemberCacheOnMessage. import { updateMemberCacheOnRoleChange } from "./events/updateMemberCacheOnRoleChange.js"; import { updateMemberCacheOnVoiceStateUpdate } from "./events/updateMemberCacheOnVoiceStateUpdate.js"; import { getCachedMemberData } from "./functions/getCachedMemberData.js"; -import { GuildMemberCachePluginType } from "./types.js"; +import { GuildMemberCachePluginType, zGuildMemberCacheConfig } from "./types.js"; const PENDING_SAVE_INTERVAL = 30 * SECONDS; export const GuildMemberCachePlugin = guildPlugin()({ name: "guild_member_cache", - configParser: (input) => z.strictObject({}).parse(input), + configSchema: zGuildMemberCacheConfig, events: [ updateMemberCacheOnMemberUpdate, diff --git a/backend/src/plugins/GuildMemberCache/docs.ts b/backend/src/plugins/GuildMemberCache/docs.ts new file mode 100644 index 00000000..c0a47527 --- /dev/null +++ b/backend/src/plugins/GuildMemberCache/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zGuildMemberCacheConfig } from "./types.js"; + +export const guildMemberCachePluginDocs: ZeppelinPluginDocs = { + prettyName: "Guild member cache", + type: "internal", + configSchema: zGuildMemberCacheConfig, +}; diff --git a/backend/src/plugins/GuildMemberCache/info.ts b/backend/src/plugins/GuildMemberCache/info.ts deleted file mode 100644 index 8b2da421..00000000 --- a/backend/src/plugins/GuildMemberCache/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zGuildMemberCacheConfig } from "./types.js"; - -export const guildMemberCachePluginInfo: ZeppelinPluginInfo = { - prettyName: "Guild member cache", - showInDocs: false, - configSchema: zGuildMemberCacheConfig, -}; diff --git a/backend/src/plugins/GuildMemberCache/types.ts b/backend/src/plugins/GuildMemberCache/types.ts index 3ad89f53..32be0f6d 100644 --- a/backend/src/plugins/GuildMemberCache/types.ts +++ b/backend/src/plugins/GuildMemberCache/types.ts @@ -1,11 +1,11 @@ import { BasePluginType } from "knub"; -import { z } from "zod"; +import { z } from "zod/v4"; import { GuildMemberCache } from "../../data/GuildMemberCache.js"; export const zGuildMemberCacheConfig = z.strictObject({}); export interface GuildMemberCachePluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zGuildMemberCacheConfig; state: { memberCache: GuildMemberCache; saveInterval: NodeJS.Timeout; diff --git a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts index 985608c6..82592ec6 100644 --- a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts +++ b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts @@ -1,22 +1,15 @@ -import { PluginOptions, guildPlugin } from "knub"; -import z from "zod"; +import { guildPlugin } from "knub"; import { Queue } from "../../Queue.js"; import { Webhooks } from "../../data/Webhooks.js"; import { makePublicFn } from "../../pluginUtils.js"; import { editMessage } from "./functions/editMessage.js"; import { sendMessage } from "./functions/sendMessage.js"; -import { InternalPosterPluginType } from "./types.js"; - -const defaultOptions: PluginOptions = { - config: {}, - overrides: [], -}; +import { InternalPosterPluginType, zInternalPosterConfig } from "./types.js"; export const InternalPosterPlugin = guildPlugin()({ name: "internal_poster", - configParser: (input) => z.strictObject({}).parse(input), - defaultOptions, + configSchema: zInternalPosterConfig, public(pluginData) { return { diff --git a/backend/src/plugins/InternalPoster/docs.ts b/backend/src/plugins/InternalPoster/docs.ts new file mode 100644 index 00000000..f1183943 --- /dev/null +++ b/backend/src/plugins/InternalPoster/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zInternalPosterConfig } from "./types.js"; + +export const internalPosterPluginDocs: ZeppelinPluginDocs = { + prettyName: "Internal poster", + type: "internal", + configSchema: zInternalPosterConfig, +}; diff --git a/backend/src/plugins/InternalPoster/info.ts b/backend/src/plugins/InternalPoster/info.ts deleted file mode 100644 index 35870b23..00000000 --- a/backend/src/plugins/InternalPoster/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zInternalPosterConfig } from "./types.js"; - -export const internalPosterPluginInfo: ZeppelinPluginInfo = { - prettyName: "Internal poster", - showInDocs: false, - configSchema: zInternalPosterConfig, -}; diff --git a/backend/src/plugins/InternalPoster/types.ts b/backend/src/plugins/InternalPoster/types.ts index c0c99c55..13d4b214 100644 --- a/backend/src/plugins/InternalPoster/types.ts +++ b/backend/src/plugins/InternalPoster/types.ts @@ -1,13 +1,13 @@ import { WebhookClient } from "discord.js"; import { BasePluginType } from "knub"; -import { z } from "zod"; +import { z } from "zod/v4"; import { Queue } from "../../Queue.js"; import { Webhooks } from "../../data/Webhooks.js"; -export const zInternalPosterConfig = z.strictObject({}); +export const zInternalPosterConfig = z.strictObject({}).default({}); export interface InternalPosterPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zInternalPosterConfig; state: { queue: Queue; webhooks: Webhooks; diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts index ebfe611f..d9650618 100644 --- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts +++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts @@ -1,6 +1,7 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildVCAlerts } from "../../data/GuildVCAlerts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { FollowCmd } from "./commands/FollowCmd.js"; import { DeleteFollowCmd, ListFollowCmd } from "./commands/ListFollowCmd.js"; import { WhereCmd } from "./commands/WhereCmd.js"; @@ -10,12 +11,11 @@ import { LocateUserPluginType, zLocateUserConfig } from "./types.js"; import { clearExpiredAlert } from "./utils/clearExpiredAlert.js"; import { fillActiveAlertsList } from "./utils/fillAlertsList.js"; -const defaultOptions: PluginOptions = { - config: { - can_where: false, - can_alert: false, - }, - overrides: [ +export const LocateUserPlugin = guildPlugin()({ + name: "locate_user", + + configSchema: zLocateUserConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -24,13 +24,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const LocateUserPlugin = guildPlugin()({ - name: "locate_user", - - configParser: (input) => zLocateUserConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -53,6 +46,10 @@ export const LocateUserPlugin = guildPlugin()({ state.usersWithAlerts = []; }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/LocateUser/commands/FollowCmd.ts b/backend/src/plugins/LocateUser/commands/FollowCmd.ts index 874cbcfa..a0c72d89 100644 --- a/backend/src/plugins/LocateUser/commands/FollowCmd.ts +++ b/backend/src/plugins/LocateUser/commands/FollowCmd.ts @@ -1,8 +1,7 @@ -import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { registerExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { MINUTES, SECONDS } from "../../../utils.js"; import { locateUserCmd } from "../types.js"; @@ -27,7 +26,7 @@ export const FollowCmd = locateUserCmd({ const active = args.active || false; if (time < 30 * SECONDS) { - sendErrorMessage(pluginData, msg.channel, "Sorry, but the minimum duration for an alert is 30 seconds!"); + void pluginData.state.common.sendErrorMessage(msg, "Sorry, but the minimum duration for an alert is 30 seconds!"); return; } @@ -46,17 +45,15 @@ export const FollowCmd = locateUserCmd({ } if (active) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration( time, )} i will notify and move you.\nPlease make sure to be in a voice channel, otherwise i cannot move you!`, ); } else { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Every time <@${args.member.id}> joins or switches VC in the next ${humanizeDuration(time)} i will notify you`, ); } diff --git a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts index a308dcd2..fa2e4edc 100644 --- a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts +++ b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts @@ -1,6 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { createChunkedMessage, sorter } from "../../../utils.js"; import { locateUserCmd } from "../types.js"; @@ -11,9 +10,9 @@ export const ListFollowCmd = locateUserCmd({ permission: "can_alert", async run({ message: msg, pluginData }) { - const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.member.id); + const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.author.id); if (alerts.length === 0) { - sendErrorMessage(pluginData, msg.channel, "You have no active alerts!"); + void pluginData.state.common.sendErrorMessage(msg, "You have no active alerts!"); return; } @@ -42,11 +41,11 @@ export const DeleteFollowCmd = locateUserCmd({ }, async run({ message: msg, args, pluginData }) { - const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.member.id); + const alerts = await pluginData.state.alerts.getAlertsByRequestorId(msg.author.id); alerts.sort(sorter("expires_at")); if (args.num > alerts.length || args.num <= 0) { - sendErrorMessage(pluginData, msg.channel, "Unknown alert!"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown alert!"); return; } @@ -54,6 +53,6 @@ export const DeleteFollowCmd = locateUserCmd({ clearExpiringVCAlert(toDelete); await pluginData.state.alerts.delete(toDelete.id); - sendSuccessMessage(pluginData, msg.channel, "Alert deleted"); + void pluginData.state.common.sendSuccessMessage(msg, "Alert deleted"); }, }); diff --git a/backend/src/plugins/LocateUser/commands/WhereCmd.ts b/backend/src/plugins/LocateUser/commands/WhereCmd.ts index 245a128a..500f9bfe 100644 --- a/backend/src/plugins/LocateUser/commands/WhereCmd.ts +++ b/backend/src/plugins/LocateUser/commands/WhereCmd.ts @@ -13,6 +13,6 @@ export const WhereCmd = locateUserCmd({ }, async run({ message: msg, args, pluginData }) { - sendWhere(pluginData, args.member, msg.channel, `<@${msg.member.id}> | `); + sendWhere(pluginData, args.member, msg.channel, `<@${msg.author.id}> | `); }, }); diff --git a/backend/src/plugins/LocateUser/info.ts b/backend/src/plugins/LocateUser/docs.ts similarity index 76% rename from backend/src/plugins/LocateUser/info.ts rename to backend/src/plugins/LocateUser/docs.ts index ee7cc3f7..2704d6f3 100644 --- a/backend/src/plugins/LocateUser/info.ts +++ b/backend/src/plugins/LocateUser/docs.ts @@ -1,14 +1,14 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zLocateUserConfig } from "./types.js"; -export const locateUserPluginInfo: ZeppelinPluginInfo = { +export const locateUserPluginDocs: ZeppelinPluginDocs = { prettyName: "Locate user", + type: "stable", description: trimPluginDescription(` This plugin allows users with access to the commands the following: * Instantly receive an invite to the voice channel of a user * Be notified as soon as a user switches or joins a voice channel `), configSchema: zLocateUserConfig, - showInDocs: true, }; diff --git a/backend/src/plugins/LocateUser/types.ts b/backend/src/plugins/LocateUser/types.ts index 568249cd..e84c673c 100644 --- a/backend/src/plugins/LocateUser/types.ts +++ b/backend/src/plugins/LocateUser/types.ts @@ -1,18 +1,20 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildVCAlerts } from "../../data/GuildVCAlerts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zLocateUserConfig = z.strictObject({ - can_where: z.boolean(), - can_alert: z.boolean(), + can_where: z.boolean().default(false), + can_alert: z.boolean().default(false), }); export interface LocateUserPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zLocateUserConfig; state: { alerts: GuildVCAlerts; usersWithAlerts: string[]; unregisterGuildEventListener: () => void; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/LocateUser/utils/moveMember.ts b/backend/src/plugins/LocateUser/utils/moveMember.ts index 1f6ed792..85b068c6 100644 --- a/backend/src/plugins/LocateUser/utils/moveMember.ts +++ b/backend/src/plugins/LocateUser/utils/moveMember.ts @@ -1,6 +1,5 @@ import { GuildMember, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { LocateUserPluginType } from "../types.js"; export async function moveMember( @@ -16,10 +15,10 @@ export async function moveMember( channel: target.voice.channelId, }); } catch { - sendErrorMessage(pluginData, errorChannel, "Failed to move you. Are you in a voice channel?"); + void pluginData.state.common.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); return; } } else { - sendErrorMessage(pluginData, errorChannel, "Failed to move you. Are you in a voice channel?"); + void pluginData.state.common.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); } } diff --git a/backend/src/plugins/LocateUser/utils/sendWhere.ts b/backend/src/plugins/LocateUser/utils/sendWhere.ts index 1c12636b..ef74cbcd 100644 --- a/backend/src/plugins/LocateUser/utils/sendWhere.ts +++ b/backend/src/plugins/LocateUser/utils/sendWhere.ts @@ -1,7 +1,6 @@ import { GuildMember, GuildTextBasedChannel, Invite, VoiceChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { getInviteLink } from "knub/helpers"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { LocateUserPluginType } from "../types.js"; import { createOrReuseInvite } from "./createOrReuseInvite.js"; @@ -22,7 +21,7 @@ export async function sendWhere( try { invite = await createOrReuseInvite(voice); } catch { - sendErrorMessage(pluginData, channel, "Cannot create an invite to that channel!"); + void pluginData.state.common.sendErrorMessage(channel, "Cannot create an invite to that channel!"); return; } channel.send({ diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts index d45b86dc..0485351c 100644 --- a/backend/src/plugins/Logs/LogsPlugin.ts +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -1,5 +1,4 @@ -import { CooldownManager, PluginOptions, guildPlugin } from "knub"; -import DefaultLogMessages from "../../data/DefaultLogMessages.json" assert { type: "json" }; +import { CooldownManager, guildPlugin } from "knub"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; @@ -115,17 +114,12 @@ function getCasesPlugin(): Promise { return import("../Cases/CasesPlugin.js") as Promise; } -const defaultOptions: PluginOptions = { - config: { - channels: {}, - format: DefaultLogMessages, - ping_user: true, - allow_user_mentions: false, - timestamp_format: "[]", - include_embed_timestamp: true, - }, +export const LogsPlugin = guildPlugin()({ + name: "logs", - overrides: [ + dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getCasesPlugin()).CasesPlugin], + configSchema: zLogsConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -134,14 +128,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const LogsPlugin = guildPlugin()({ - name: "logs", - - dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getCasesPlugin()).CasesPlugin], - configParser: (input) => zLogsConfig.parse(input), - defaultOptions, events: [ LogsGuildMemberAddEvt, diff --git a/backend/src/plugins/Logs/docs.ts b/backend/src/plugins/Logs/docs.ts new file mode 100644 index 00000000..abe2f66e --- /dev/null +++ b/backend/src/plugins/Logs/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zLogsConfig } from "./types.js"; + +export const logsPluginDocs: ZeppelinPluginDocs = { + prettyName: "Logs", + configSchema: zLogsConfig, + type: "stable", +}; diff --git a/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts b/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts index 0b6c2558..75c50de8 100644 --- a/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts +++ b/backend/src/plugins/Logs/events/LogsGuildBanEvts.ts @@ -49,7 +49,6 @@ export const LogsGuildBanRemoveEvt = logsEvt({ user.id, ); const mod = relevantAuditLogEntry?.executor ?? null; - logMemberUnban(pluginData, { mod, userId: user.id, diff --git a/backend/src/plugins/Logs/info.ts b/backend/src/plugins/Logs/info.ts deleted file mode 100644 index 6d9f200a..00000000 --- a/backend/src/plugins/Logs/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zLogsConfig } from "./types.js"; - -export const logsPluginInfo: ZeppelinPluginInfo = { - prettyName: "Logs", - configSchema: zLogsConfig, - showInDocs: true, -}; diff --git a/backend/src/plugins/Logs/logFunctions/logMemberBan.ts b/backend/src/plugins/Logs/logFunctions/logMemberBan.ts index d87fd9df..001e7615 100644 --- a/backend/src/plugins/Logs/logFunctions/logMemberBan.ts +++ b/backend/src/plugins/Logs/logFunctions/logMemberBan.ts @@ -1,4 +1,4 @@ -import { User } from "discord.js"; +import { PartialUser, User } from "discord.js"; import { GuildPluginData } from "knub"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; @@ -8,7 +8,7 @@ import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberBanData { - mod: User | UnknownUser | null; + mod: User | UnknownUser | PartialUser | null; user: User | UnknownUser; caseNumber: number; reason: string; diff --git a/backend/src/plugins/Logs/logFunctions/logMemberJoin.ts b/backend/src/plugins/Logs/logFunctions/logMemberJoin.ts index b73dcd7a..f4ea29a7 100644 --- a/backend/src/plugins/Logs/logFunctions/logMemberJoin.ts +++ b/backend/src/plugins/Logs/logFunctions/logMemberJoin.ts @@ -1,8 +1,8 @@ import { GuildMember } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { LogType } from "../../../data/LogType.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects.js"; import { LogsPluginType } from "../types.js"; diff --git a/backend/src/plugins/Logs/logFunctions/logMemberUnban.ts b/backend/src/plugins/Logs/logFunctions/logMemberUnban.ts index 7d763192..be6c8aa3 100644 --- a/backend/src/plugins/Logs/logFunctions/logMemberUnban.ts +++ b/backend/src/plugins/Logs/logFunctions/logMemberUnban.ts @@ -1,4 +1,4 @@ -import { Snowflake, User } from "discord.js"; +import { PartialUser, Snowflake, User } from "discord.js"; import { GuildPluginData } from "knub"; import { LogType } from "../../../data/LogType.js"; import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; @@ -8,7 +8,7 @@ import { LogsPluginType } from "../types.js"; import { log } from "../util/log.js"; export interface LogMemberUnbanData { - mod: User | UnknownUser | null; + mod: User | UnknownUser | PartialUser | null; userId: Snowflake; caseNumber: number; reason: string; diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts index 11eadbd1..1efbbc62 100644 --- a/backend/src/plugins/Logs/types.ts +++ b/backend/src/plugins/Logs/types.ts @@ -1,12 +1,12 @@ import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub"; -import { z } from "zod"; +import { z } from "zod/v4"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { LogType } from "../../data/LogType.js"; -import { zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils.js"; +import { keys, zBoundedCharacters, zEmbedInput, zMessageContent, zRegex, zSnowflake, zStrictMessageContent } from "../../utils.js"; import { MessageBuffer } from "../../utils/MessageBuffer.js"; import { TemplateSafeCase, @@ -21,14 +21,20 @@ import { TemplateSafeUnknownUser, TemplateSafeUser, } from "../../utils/templateSafeObjects.js"; +import DefaultLogMessages from "../../data/DefaultLogMessages.json" with { type: "json" }; const DEFAULT_BATCH_TIME = 1000; const MIN_BATCH_TIME = 250; const MAX_BATCH_TIME = 5000; // A bit of a workaround so we can pass LogType keys to z.enum() -const logTypes = Object.keys(LogType) as [keyof typeof LogType, ...Array]; -const zLogFormats = z.record(z.enum(logTypes), zMessageContent); +const zMessageContentWithDefault = zMessageContent.default(""); +const logTypes = keys(LogType); +const logTypeProps = logTypes.reduce((map, type) => { + map[type] = zMessageContent.default(DefaultLogMessages[type] || ""); + return map; +}, {} as Record); +const zLogFormats = z.strictObject(logTypeProps); const zLogChannel = z.strictObject({ include: z.array(zBoundedCharacters(1, 255)).default([]), @@ -42,7 +48,7 @@ const zLogChannel = z.strictObject({ excluded_threads: z.array(zSnowflake).nullable().default(null), exclude_bots: z.boolean().default(false), excluded_roles: z.array(zSnowflake).nullable().default(null), - format: zLogFormats.default({}), + format: zLogFormats.partial().default({}), timestamp_format: z.string().nullable().default(null), include_embed_timestamp: z.boolean().nullable().default(null), }); @@ -52,20 +58,20 @@ const zLogChannelMap = z.record(zSnowflake, zLogChannel); export type TLogChannelMap = z.infer; export const zLogsConfig = z.strictObject({ - channels: zLogChannelMap, - format: zLogFormats, + channels: zLogChannelMap.default({}), + format: zLogFormats.prefault({}), // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true - ping_user: z.boolean(), - allow_user_mentions: z.boolean(), - timestamp_format: z.string().nullable(), - include_embed_timestamp: z.boolean(), + ping_user: z.boolean().default(true), + allow_user_mentions: z.boolean().default(false), + timestamp_format: z.string().nullable().default("[]"), + include_embed_timestamp: z.boolean().default(true), }); // Hacky way of allowing a """null""" default value for config.format.timestamp due to legacy io-ts reasons export const FORMAT_NO_TIMESTAMP = "__NO_TIMESTAMP__"; export interface LogsPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zLogsConfig; state: { guildLogs: GuildLogs; savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/Logs/util/log.ts b/backend/src/plugins/Logs/util/log.ts index 914dfeaa..94d399c7 100644 --- a/backend/src/plugins/Logs/util/log.ts +++ b/backend/src/plugins/Logs/util/log.ts @@ -3,7 +3,7 @@ import { GuildPluginData } from "knub"; import { allowTimeout } from "../../../RegExpRunner.js"; import { LogType } from "../../../data/LogType.js"; import { TypedTemplateSafeValueContainer } from "../../../templateFormatter.js"; -import { MINUTES, isDiscordAPIError } from "../../../utils.js"; +import { MINUTES, inputPatternToRegExp, isDiscordAPIError } from "../../../utils.js"; import { MessageBuffer } from "../../../utils/MessageBuffer.js"; import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin.js"; import { ILogTypeData, LogsPluginType, TLogChannel, TLogChannelMap } from "../types.js"; @@ -57,7 +57,8 @@ async function shouldExclude( } if (opts.excluded_message_regexes && exclusionData.messageTextContent) { - for (const regex of opts.excluded_message_regexes) { + for (const pattern of opts.excluded_message_regexes) { + const regex = inputPatternToRegExp(pattern); const matches = await pluginData.state.regexRunner .exec(regex, exclusionData.messageTextContent) .catch(allowTimeout); diff --git a/backend/src/plugins/Logs/util/onMessageUpdate.ts b/backend/src/plugins/Logs/util/onMessageUpdate.ts index 24b88aad..ecc9a5f1 100644 --- a/backend/src/plugins/Logs/util/onMessageUpdate.ts +++ b/backend/src/plugins/Logs/util/onMessageUpdate.ts @@ -1,6 +1,5 @@ import { EmbedData, GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import cloneDeep from "lodash/cloneDeep.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { resolveUser } from "../../../utils.js"; import { logMessageEdit } from "../logFunctions/logMessageEdit.js"; @@ -15,12 +14,12 @@ export async function onMessageUpdate( let logUpdate = false; const oldEmbedsToCompare = ((oldSavedMessage.data.embeds || []) as EmbedData[]) - .map((e) => cloneDeep(e)) - .filter((e) => (e as EmbedData).type === "rich"); + .map((e) => structuredClone(e)) + .filter((e) => e.type === "rich"); const newEmbedsToCompare = ((savedMessage.data.embeds || []) as EmbedData[]) - .map((e) => cloneDeep(e)) - .filter((e) => (e as EmbedData).type === "rich"); + .map((e) => structuredClone(e)) + .filter((e) => e.type === "rich"); for (const embed of [...oldEmbedsToCompare, ...newEmbedsToCompare]) { if (embed.thumbnail) { diff --git a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts index ead63597..fcdf5301 100644 --- a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts +++ b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts @@ -1,5 +1,6 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { SaveMessagesToDBCmd } from "./commands/SaveMessagesToDB.js"; import { SavePinsToDBCmd } from "./commands/SavePinsToDB.js"; import { @@ -10,11 +11,11 @@ import { } from "./events/SaveMessagesEvts.js"; import { MessageSaverPluginType, zMessageSaverConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - can_manage: false, - }, - overrides: [ +export const MessageSaverPlugin = guildPlugin()({ + name: "message_saver", + + configSchema: zMessageSaverConfig, + defaultOverrides: [ { level: ">=100", config: { @@ -22,13 +23,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const MessageSaverPlugin = guildPlugin()({ - name: "message_saver", - - configParser: (input) => zMessageSaverConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -48,4 +42,8 @@ export const MessageSaverPlugin = guildPlugin()({ const { state, guild } = pluginData; state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts b/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts index 120d4c80..87543b02 100644 --- a/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts +++ b/backend/src/plugins/MessageSaver/commands/SaveMessagesToDB.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { saveMessagesToDB } from "../saveMessagesToDB.js"; import { messageSaverCmd } from "../types.js"; @@ -18,13 +17,12 @@ export const SaveMessagesToDBCmd = messageSaverCmd({ const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, args.ids.trim().split(" ")); if (failed.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, ); } else { - sendSuccessMessage(pluginData, msg.channel, `Saved ${savedCount} messages!`); + void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`); } }, }); diff --git a/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts b/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts index 67be1bb8..ab903581 100644 --- a/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts +++ b/backend/src/plugins/MessageSaver/commands/SavePinsToDB.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { saveMessagesToDB } from "../saveMessagesToDB.js"; import { messageSaverCmd } from "../types.js"; @@ -19,13 +18,12 @@ export const SavePinsToDBCmd = messageSaverCmd({ const { savedCount, failed } = await saveMessagesToDB(pluginData, args.channel, [...pins.keys()]); if (failed.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, ); } else { - sendSuccessMessage(pluginData, msg.channel, `Saved ${savedCount} messages!`); + void pluginData.state.common.sendSuccessMessage(msg, `Saved ${savedCount} messages!`); } }, }); diff --git a/backend/src/plugins/MessageSaver/docs.ts b/backend/src/plugins/MessageSaver/docs.ts new file mode 100644 index 00000000..3ee79b96 --- /dev/null +++ b/backend/src/plugins/MessageSaver/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zMessageSaverConfig } from "./types.js"; + +export const messageSaverPluginDocs: ZeppelinPluginDocs = { + prettyName: "Message saver", + type: "internal", + configSchema: zMessageSaverConfig, +}; diff --git a/backend/src/plugins/MessageSaver/info.ts b/backend/src/plugins/MessageSaver/info.ts deleted file mode 100644 index 7d62a72a..00000000 --- a/backend/src/plugins/MessageSaver/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zMessageSaverConfig } from "./types.js"; - -export const messageSaverPluginInfo: ZeppelinPluginInfo = { - prettyName: "Message saver", - showInDocs: false, - configSchema: zMessageSaverConfig, -}; diff --git a/backend/src/plugins/MessageSaver/types.ts b/backend/src/plugins/MessageSaver/types.ts index 52d7e86b..d1f0e77e 100644 --- a/backend/src/plugins/MessageSaver/types.ts +++ b/backend/src/plugins/MessageSaver/types.ts @@ -1,15 +1,17 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zMessageSaverConfig = z.strictObject({ - can_manage: z.boolean(), + can_manage: z.boolean().default(false), }); export interface MessageSaverPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zMessageSaverConfig; state: { savedMessages: GuildSavedMessages; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index aa379acb..80c75680 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -10,83 +10,76 @@ import { GuildTempbans } from "../../data/GuildTempbans.js"; import { makePublicFn, mapToPublicFn } from "../../pluginUtils.js"; import { MINUTES } from "../../utils.js"; import { CasesPlugin } from "../Cases/CasesPlugin.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { MutesPlugin } from "../Mutes/MutesPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; -import { AddCaseCmd } from "./commands/AddCaseCmd.js"; -import { BanCmd } from "./commands/BanCmd.js"; -import { CaseCmd } from "./commands/CaseCmd.js"; -import { CasesModCmd } from "./commands/CasesModCmd.js"; -import { CasesUserCmd } from "./commands/CasesUserCmd.js"; -import { DeleteCaseCmd } from "./commands/DeleteCaseCmd.js"; -import { ForcebanCmd } from "./commands/ForcebanCmd.js"; -import { ForcemuteCmd } from "./commands/ForcemuteCmd.js"; -import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd.js"; -import { HideCaseCmd } from "./commands/HideCaseCmd.js"; -import { KickCmd } from "./commands/KickCmd.js"; -import { MassbanCmd } from "./commands/MassBanCmd.js"; -import { MassunbanCmd } from "./commands/MassUnbanCmd.js"; -import { MassmuteCmd } from "./commands/MassmuteCmd.js"; -import { MuteCmd } from "./commands/MuteCmd.js"; -import { NoteCmd } from "./commands/NoteCmd.js"; -import { SoftbanCmd } from "./commands/SoftbanCommand.js"; -import { UnbanCmd } from "./commands/UnbanCmd.js"; -import { UnhideCaseCmd } from "./commands/UnhideCaseCmd.js"; -import { UnmuteCmd } from "./commands/UnmuteCmd.js"; -import { UpdateCmd } from "./commands/UpdateCmd.js"; -import { WarnCmd } from "./commands/WarnCmd.js"; +import { AddCaseMsgCmd } from "./commands/addcase/AddCaseMsgCmd.js"; +import { AddCaseSlashCmd } from "./commands/addcase/AddCaseSlashCmd.js"; +import { BanMsgCmd } from "./commands/ban/BanMsgCmd.js"; +import { BanSlashCmd } from "./commands/ban/BanSlashCmd.js"; +import { CaseMsgCmd } from "./commands/case/CaseMsgCmd.js"; +import { CaseSlashCmd } from "./commands/case/CaseSlashCmd.js"; +import { CasesModMsgCmd } from "./commands/cases/CasesModMsgCmd.js"; +import { CasesSlashCmd } from "./commands/cases/CasesSlashCmd.js"; +import { CasesUserMsgCmd } from "./commands/cases/CasesUserMsgCmd.js"; +import { DeleteCaseMsgCmd } from "./commands/deletecase/DeleteCaseMsgCmd.js"; +import { DeleteCaseSlashCmd } from "./commands/deletecase/DeleteCaseSlashCmd.js"; +import { ForceBanMsgCmd } from "./commands/forceban/ForceBanMsgCmd.js"; +import { ForceBanSlashCmd } from "./commands/forceban/ForceBanSlashCmd.js"; +import { ForceMuteMsgCmd } from "./commands/forcemute/ForceMuteMsgCmd.js"; +import { ForceMuteSlashCmd } from "./commands/forcemute/ForceMuteSlashCmd.js"; +import { ForceUnmuteMsgCmd } from "./commands/forceunmute/ForceUnmuteMsgCmd.js"; +import { ForceUnmuteSlashCmd } from "./commands/forceunmute/ForceUnmuteSlashCmd.js"; +import { HideCaseMsgCmd } from "./commands/hidecase/HideCaseMsgCmd.js"; +import { HideCaseSlashCmd } from "./commands/hidecase/HideCaseSlashCmd.js"; +import { KickMsgCmd } from "./commands/kick/KickMsgCmd.js"; +import { KickSlashCmd } from "./commands/kick/KickSlashCmd.js"; +import { MassBanMsgCmd } from "./commands/massban/MassBanMsgCmd.js"; +import { MassBanSlashCmd } from "./commands/massban/MassBanSlashCmd.js"; +import { MassMuteMsgCmd } from "./commands/massmute/MassMuteMsgCmd.js"; +import { MassMuteSlashSlashCmd } from "./commands/massmute/MassMuteSlashCmd.js"; +import { MassUnbanMsgCmd } from "./commands/massunban/MassUnbanMsgCmd.js"; +import { MassUnbanSlashCmd } from "./commands/massunban/MassUnbanSlashCmd.js"; +import { MuteMsgCmd } from "./commands/mute/MuteMsgCmd.js"; +import { MuteSlashCmd } from "./commands/mute/MuteSlashCmd.js"; +import { NoteMsgCmd } from "./commands/note/NoteMsgCmd.js"; +import { NoteSlashCmd } from "./commands/note/NoteSlashCmd.js"; +import { UnbanMsgCmd } from "./commands/unban/UnbanMsgCmd.js"; +import { UnbanSlashCmd } from "./commands/unban/UnbanSlashCmd.js"; +import { UnhideCaseMsgCmd } from "./commands/unhidecase/UnhideCaseMsgCmd.js"; +import { UnhideCaseSlashCmd } from "./commands/unhidecase/UnhideCaseSlashCmd.js"; +import { UnmuteMsgCmd } from "./commands/unmute/UnmuteMsgCmd.js"; +import { UnmuteSlashCmd } from "./commands/unmute/UnmuteSlashCmd.js"; +import { UpdateMsgCmd } from "./commands/update/UpdateMsgCmd.js"; +import { UpdateSlashCmd } from "./commands/update/UpdateSlashCmd.js"; +import { WarnMsgCmd } from "./commands/warn/WarnMsgCmd.js"; +import { WarnSlashCmd } from "./commands/warn/WarnSlashCmd.js"; import { AuditLogEvents } from "./events/AuditLogEvents.js"; import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt.js"; import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt.js"; import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt.js"; import { banUserId } from "./functions/banUserId.js"; import { clearTempban } from "./functions/clearTempban.js"; -import { hasMutePermission } from "./functions/hasMutePerm.js"; +import { + hasBanPermission, + hasMutePermission, + hasNotePermission, + hasWarnPermission, +} from "./functions/hasModActionPerm.js"; import { kickMember } from "./functions/kickMember.js"; import { offModActionsEvent } from "./functions/offModActionsEvent.js"; import { onModActionsEvent } from "./functions/onModActionsEvent.js"; import { updateCase } from "./functions/updateCase.js"; import { warnMember } from "./functions/warnMember.js"; -import { ModActionsPluginType, zModActionsConfig } from "./types.js"; +import { ModActionsPluginType, modActionsSlashGroup, zModActionsConfig } from "./types.js"; -const defaultOptions = { - config: { - dm_on_warn: true, - dm_on_kick: false, - dm_on_ban: false, - message_on_warn: false, - message_on_kick: false, - message_on_ban: false, - message_channel: null, - warn_message: "You have received a warning on the {guildName} server: {reason}", - kick_message: "You have been kicked from the {guildName} server. Reason given: {reason}", - ban_message: "You have been banned from the {guildName} server. Reason given: {reason}", - tempban_message: "You have been banned from the {guildName} server for {banTime}. Reason given: {reason}", - alert_on_rejoin: false, - alert_channel: null, - warn_notify_enabled: false, - warn_notify_threshold: 5, - warn_notify_message: - "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", - ban_delete_message_days: 1, +export const ModActionsPlugin = guildPlugin()({ + name: "mod_actions", - can_note: false, - can_warn: false, - can_mute: false, - can_kick: false, - can_ban: false, - can_unban: false, - can_view: false, - can_addcase: false, - can_massunban: false, - can_massban: false, - can_massmute: false, - can_hidecase: false, - can_deletecase: false, - can_act_as_other: false, - create_cases_for_manual_actions: true, - }, - overrides: [ + dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin], + configSchema: zModActionsConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -111,40 +104,61 @@ const defaultOptions = { }, }, ], -}; - -export const ModActionsPlugin = guildPlugin()({ - name: "mod_actions", - - dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin], - configParser: (input) => zModActionsConfig.parse(input), - defaultOptions, events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents], + slashCommands: [ + modActionsSlashGroup({ + name: "mod", + description: "Moderation actions", + defaultMemberPermissions: "0", + subcommands: [ + AddCaseSlashCmd, + BanSlashCmd, + CaseSlashCmd, + CasesSlashCmd, + DeleteCaseSlashCmd, + ForceBanSlashCmd, + ForceMuteSlashCmd, + ForceUnmuteSlashCmd, + HideCaseSlashCmd, + KickSlashCmd, + MassBanSlashCmd, + MassMuteSlashSlashCmd, + MassUnbanSlashCmd, + MuteSlashCmd, + NoteSlashCmd, + UnbanSlashCmd, + UnhideCaseSlashCmd, + UnmuteSlashCmd, + UpdateSlashCmd, + WarnSlashCmd, + ], + }), + ], + messageCommands: [ - UpdateCmd, - NoteCmd, - WarnCmd, - MuteCmd, - ForcemuteCmd, - UnmuteCmd, - ForceUnmuteCmd, - KickCmd, - SoftbanCmd, - BanCmd, - UnbanCmd, - ForcebanCmd, - MassbanCmd, - MassmuteCmd, - MassunbanCmd, - AddCaseCmd, - CaseCmd, - CasesUserCmd, - CasesModCmd, - HideCaseCmd, - UnhideCaseCmd, - DeleteCaseCmd, + UpdateMsgCmd, + NoteMsgCmd, + WarnMsgCmd, + MuteMsgCmd, + ForceMuteMsgCmd, + UnmuteMsgCmd, + ForceUnmuteMsgCmd, + KickMsgCmd, + BanMsgCmd, + UnbanMsgCmd, + ForceBanMsgCmd, + MassBanMsgCmd, + MassMuteMsgCmd, + MassUnbanMsgCmd, + AddCaseMsgCmd, + CaseMsgCmd, + CasesUserMsgCmd, + CasesModMsgCmd, + HideCaseMsgCmd, + UnhideCaseMsgCmd, + DeleteCaseMsgCmd, ], public(pluginData) { @@ -153,8 +167,11 @@ export const ModActionsPlugin = guildPlugin()({ kickMember: makePublicFn(pluginData, kickMember), banUserId: makePublicFn(pluginData, banUserId), updateCase: (msg: Message, caseNumber: number | null, note: string) => - updateCase(pluginData, msg, { caseNumber, note }), + updateCase(pluginData, msg, msg.author, caseNumber ?? undefined, note, [...msg.attachments.values()]), + hasNotePermission: makePublicFn(pluginData, hasNotePermission), + hasWarnPermission: makePublicFn(pluginData, hasWarnPermission), hasMutePermission: makePublicFn(pluginData, hasMutePermission), + hasBanPermission: makePublicFn(pluginData, hasBanPermission), on: mapToPublicFn(onModActionsEvent), off: mapToPublicFn(offModActionsEvent), getEventEmitter: () => pluginData.state.events, @@ -178,6 +195,10 @@ export const ModActionsPlugin = guildPlugin()({ state.events = new EventEmitter(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts deleted file mode 100644 index cba64f78..00000000 --- a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { Case } from "../../../data/entities/Case.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const AddCaseCmd = modActionsCmd({ - trigger: "addcase", - permission: "can_addcase", - description: "Add an arbitrary case to the specified user without taking any action", - - signature: [ - { - type: ct.string(), - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - // If the user exists as a guild member, make sure we can act on them first - const member = await resolveMember(pluginData.client, pluginData.guild, user.id); - if (member && !canActOn(pluginData, msg.member, member)) { - sendErrorMessage(pluginData, msg.channel, "Cannot add case on this user: insufficient permissions"); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - // Verify the case type is valid - const type: string = args.type[0].toUpperCase() + args.type.slice(1).toLowerCase(); - if (!CaseTypes[type]) { - sendErrorMessage(pluginData, msg.channel, "Cannot add case: invalid case type"); - return; - } - - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - // Create the case - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const theCase: Case = await casesPlugin.createCase({ - userId: user.id, - modId: mod.id, - type: CaseTypes[type], - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, - }); - - if (user) { - sendSuccessMessage( - pluginData, - msg.channel, - `Case #${theCase.case_number} created for **${renderUsername(user)}**`, - ); - } else { - sendSuccessMessage(pluginData, msg.channel, `Case #${theCase.case_number} created`); - } - - // Log the action - pluginData.getPlugin(LogsPlugin).logCaseCreate({ - mod: mod.user, - userId: user.id, - caseNum: theCase.case_number, - caseType: type.toUpperCase(), - reason, - }); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts deleted file mode 100644 index d3c5c745..00000000 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ /dev/null @@ -1,219 +0,0 @@ -import humanizeDuration from "humanize-duration"; -import { getMemberLevel } from "knub/helpers"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { clearExpiringTempban, registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { banLock } from "../../../utils/lockNameHelpers.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { banUserId } from "../functions/banUserId.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { isBanned } from "../functions/isBanned.js"; -import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), - "delete-days": ct.number({ option: true, shortcut: "d" }), -}; - -export const BanCmd = modActionsCmd({ - trigger: "ban", - permission: "can_ban", - description: "Ban or Tempban the specified member", - - signature: [ - { - user: ct.string(), - time: ct.delay(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - const time = args["time"] ? args["time"] : null; - - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - // acquire a lock because of the needed user-inputs below (if banned/not on server) - const lock = await pluginData.locks.acquire(banLock(user)); - let forceban = false; - const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); - if (!memberToBan) { - const banned = await isBanned(pluginData, user.id); - if (banned) { - // Abort if trying to ban user indefinitely if they are already banned indefinitely - if (!existingTempban && !time) { - sendErrorMessage(pluginData, msg.channel, `User is already banned indefinitely.`); - return; - } - - // Ask the mod if we should update the existing ban - const reply = await waitForButtonConfirm( - msg.channel, - { content: "Failed to message the user. Log the warning anyway?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User already banned, update cancelled by moderator"); - lock.unlock(); - return; - } else { - // Update or add new tempban / remove old tempban - if (time && time > 0) { - if (existingTempban) { - await pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); - } else { - await pluginData.state.tempbans.addTempban(user.id, time, mod.id); - } - const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!; - registerExpiringTempban(tempban); - } else if (existingTempban) { - clearExpiringTempban(existingTempban); - pluginData.state.tempbans.clear(user.id); - } - - // Create a new case for the updated ban since we never stored the old case id and log the action - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - modId: mod.id, - type: CaseTypes.Ban, - userId: user.id, - reason, - noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`], - }); - 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, - msg.channel, - `Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`, - ); - lock.unlock(); - return; - } - } else { - // Ask the mod if we should upgrade to a forceban as the user is not on the server - const reply = await waitForButtonConfirm( - msg.channel, - { content: "User not on server, forceban instead?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User not on server, ban cancelled by moderator"); - lock.unlock(); - return; - } else { - forceban = true; - } - } - } - - // Make sure we're allowed to ban this member if they are on the server - if (!forceban && !canActOn(pluginData, msg.member, memberToBan!)) { - const ourLevel = getMemberLevel(pluginData, msg.member); - const targetLevel = getMemberLevel(pluginData, memberToBan!); - sendErrorMessage( - pluginData, - msg.channel, - `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`, - ); - lock.unlock(); - return; - } - - let contactMethods; - try { - contactMethods = readContactMethodsFromArgs(args); - } catch (e) { - sendErrorMessage(pluginData, msg.channel, e.message); - lock.unlock(); - return; - } - - const deleteMessageDays = - args["delete-days"] ?? (await pluginData.config.getForMessage(msg)).ban_delete_message_days; - const banResult = await banUserId( - pluginData, - user.id, - reason, - { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, - }, - deleteMessageDays, - modId: mod.id, - }, - time, - ); - - if (banResult.status === "failed") { - sendErrorMessage(pluginData, msg.channel, `Failed to ban member: ${banResult.error}`); - lock.unlock(); - return; - } - - let forTime = ""; - if (time && time > 0) { - forTime = `for ${humanizeDuration(time)} `; - } - - // Confirm the action to the moderator - let response = ""; - if (!forceban) { - response = `Banned **${renderUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`; - if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; - } else { - response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`; - } - - lock.unlock(); - sendSuccessMessage(pluginData, msg.channel, response); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/CaseCmd.ts b/backend/src/plugins/ModActions/commands/CaseCmd.ts deleted file mode 100644 index 3a906df6..00000000 --- a/backend/src/plugins/ModActions/commands/CaseCmd.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; -import { modActionsCmd } from "../types.js"; - -export const CaseCmd = modActionsCmd({ - trigger: "case", - permission: "can_view", - description: "Show information about a specific case", - - signature: [ - { - caseNumber: ct.number(), - }, - ], - - async run({ pluginData, message: msg, args }) { - const theCase = await pluginData.state.cases.findByCaseNumber(args.caseNumber); - - if (!theCase) { - sendErrorMessage(pluginData, msg.channel, "Case not found"); - return; - } - - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const embed = await casesPlugin.getCaseEmbed(theCase.id, msg.author.id); - msg.channel.send(embed); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts deleted file mode 100644 index ab585ef8..00000000 --- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { APIEmbed } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; -import { UnknownUser, emptyEmbedValue, renderUsername, resolveMember, resolveUser, trimLines } from "../../../utils.js"; -import { asyncMap } from "../../../utils/async.js"; -import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js"; -import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.userId({ option: true }), -}; - -const casesPerPage = 5; - -export const CasesModCmd = modActionsCmd({ - trigger: ["cases", "modlogs", "infractions"], - permission: "can_view", - description: "Show the most recent 5 cases by the specified -mod", - - signature: [ - { - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const modId = args.mod || msg.author.id; - const mod = - (await resolveMember(pluginData.client, pluginData.guild, modId)) || - (await resolveUser(pluginData.client, modId)); - const modName = mod instanceof UnknownUser ? modId : renderUsername(mod); - - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const totalCases = await casesPlugin.getTotalCasesByMod(modId); - - if (totalCases === 0) { - sendErrorMessage(pluginData, msg.channel, `No cases by **${modName}**`); - return; - } - - const totalPages = Math.max(Math.ceil(totalCases / casesPerPage), 1); - const prefix = getGuildPrefix(pluginData); - - createPaginatedMessage( - pluginData.client, - msg.channel, - totalPages, - 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 isLastPage = page === totalPages; - const firstCaseNum = (page - 1) * casesPerPage + 1; - const lastCaseNum = isLastPage ? totalCases : page * casesPerPage; - const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`; - - const embed = { - author: { - name: title, - icon_url: mod instanceof UnknownUser ? undefined : mod.displayAvatarURL(), - }, - description: lines.join("\n"), - fields: [ - { - name: emptyEmbedValue, - value: trimLines(` - Use \`${prefix}case \` to see more information about an individual case - Use \`${prefix}cases \` to see a specific user's cases - `), - }, - ], - } satisfies APIEmbed; - - return { embeds: [embed] }; - }, - { - limitToUserId: msg.author.id, - }, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts deleted file mode 100644 index c836c079..00000000 --- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { APIEmbed, User } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { - UnknownUser, - chunkArray, - emptyEmbedValue, - renderUsername, - resolveMember, - resolveUser, -} from "../../../utils.js"; -import { asyncMap } from "../../../utils/async.js"; -import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js"; -import { getGuildPrefix } from "../../../utils/getGuildPrefix.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), - hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), - reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), - notes: ct.switchOption({ def: false, shortcut: "n" }), - warns: ct.switchOption({ def: false, shortcut: "w" }), - mutes: ct.switchOption({ def: false, shortcut: "m" }), - unmutes: ct.switchOption({ def: false, shortcut: "um" }), - bans: ct.switchOption({ def: false, shortcut: "b" }), - unbans: ct.switchOption({ def: false, shortcut: "ub" }), -}; - -const casesPerPage = 5; - -export const CasesUserCmd = modActionsCmd({ - trigger: ["cases", "modlogs"], - permission: "can_view", - description: "Show a list of cases the specified user has", - - signature: [ - { - user: ct.string(), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = - (await resolveMember(pluginData.client, pluginData.guild, args.user)) || - (await resolveUser(pluginData.client, args.user)); - if (user instanceof UnknownUser) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - let cases = await pluginData.state.cases.with("notes").getByUserId(user.id); - - const typesToShow: CaseTypes[] = []; - if (args.notes) typesToShow.push(CaseTypes.Note); - if (args.warns) typesToShow.push(CaseTypes.Warn); - if (args.mutes) typesToShow.push(CaseTypes.Mute); - if (args.unmutes) typesToShow.push(CaseTypes.Unmute); - if (args.bans) typesToShow.push(CaseTypes.Ban); - if (args.unbans) typesToShow.push(CaseTypes.Unban); - - if (typesToShow.length > 0) { - // Reversed: Hide specified types - 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)); - } - - 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 : renderUsername(user); - - if (cases.length === 0) { - msg.channel.send(`No cases found for **${userName}**`); - } else { - const casesToDisplay = args.hidden ? cases : normalCases; - if (!casesToDisplay.length) { - msg.channel.send( - `No normal cases found for **${userName}**. Use "-hidden" to show ${cases.length} hidden cases.`, - ); - return; - } - - if (args.expand) { - if (casesToDisplay.length > 8) { - msg.channel.send("Too many cases for expanded view. Please use compact view instead."); - return; - } - - // Expanded view (= individual case embeds) - const casesPlugin = pluginData.getPlugin(CasesPlugin); - for (const theCase of casesToDisplay) { - const embed = await casesPlugin.getCaseEmbed(theCase.id); - msg.channel.send(embed); - } - } else { - // Compact view (= regular message with a preview of each case) - const casesPlugin = pluginData.getPlugin(CasesPlugin); - - const totalPages = Math.max(Math.ceil(casesToDisplay.length / casesPerPage), 1); - const prefix = getGuildPrefix(pluginData); - - createPaginatedMessage( - pluginData.client, - msg.channel, - totalPages, - async (page) => { - const chunkedCases = chunkArray(casesToDisplay, casesPerPage)[page - 1]; - const lines = await asyncMap(chunkedCases, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id)); - - const isLastPage = page === totalPages; - const firstCaseNum = (page - 1) * casesPerPage + 1; - const lastCaseNum = isLastPage ? casesToDisplay.length : page * casesPerPage; - const title = - totalPages === 1 - ? `Cases for ${userName} (${lines.length} total)` - : `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${casesToDisplay.length} for ${userName}`; - - const embed = { - author: { - name: title, - icon_url: user instanceof User ? user.displayAvatarURL() : undefined, - }, - description: lines.join("\n"), - fields: [ - { - name: emptyEmbedValue, - value: `Use \`${prefix}case \` to see more information about an individual case`, - }, - ], - } satisfies APIEmbed; - - if (isLastPage && !args.hidden && hiddenCases.length) - embed.fields.push({ - name: emptyEmbedValue, - value: - hiddenCases.length === 1 - ? `*+${hiddenCases.length} hidden case, use "-hidden" to show it*` - : `*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`, - }); - - return { embeds: [embed] }; - }, - { - limitToUserId: msg.author.id, - }, - ); - } - } - }, -}); diff --git a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts b/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts deleted file mode 100644 index 82b91d85..00000000 --- a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { helpers } from "knub"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { Case } from "../../../data/entities/Case.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { SECONDS, renderUsername, trimLines } from "../../../utils.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; -import { modActionsCmd } from "../types.js"; - -export const DeleteCaseCmd = modActionsCmd({ - trigger: ["delete_case", "deletecase"], - permission: "can_deletecase", - description: trimLines(` - Delete the specified case. This operation can *not* be reversed. - It is generally recommended to use \`!hidecase\` instead when possible. - `), - - signature: { - caseNumber: ct.number({ rest: true }), - - force: ct.switchOption({ def: false, shortcut: "f" }), - }, - - async run({ pluginData, message, args }) { - const failed: number[] = []; - const validCases: Case[] = []; - let cancelled = 0; - - for (const num of args.caseNumber) { - const theCase = await pluginData.state.cases.findByCaseNumber(num); - if (!theCase) { - failed.push(num); - continue; - } - - validCases.push(theCase); - } - - if (failed.length === args.caseNumber.length) { - sendErrorMessage(pluginData, message.channel, "None of the cases were found!"); - return; - } - - for (const theCase of validCases) { - if (!args.force) { - const cases = pluginData.getPlugin(CasesPlugin); - const embedContent = await cases.getCaseEmbed(theCase); - message.channel.send({ - ...embedContent, - content: "Delete the following case? Answer 'Yes' to continue, 'No' to cancel.", - }); - - const reply = await helpers.waitForReply(pluginData.client, message.channel, message.author.id, 15 * SECONDS); - const normalizedReply = (reply?.content || "").toLowerCase().trim(); - if (normalizedReply !== "yes" && normalizedReply !== "y") { - message.channel.send("Cancelled. Case was not deleted."); - cancelled++; - continue; - } - } - - const deletedByName = renderUsername(message.author); - - const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); - const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime")); - - await pluginData.state.cases.softDelete( - theCase.id, - message.author.id, - deletedByName, - `Case deleted by **${deletedByName}** (\`${message.author.id}\`) on ${deletedAt}`, - ); - - const logs = pluginData.getPlugin(LogsPlugin); - logs.logCaseDelete({ - mod: message.member, - case: theCase, - }); - } - - const failedAddendum = - failed.length > 0 - ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` - : ""; - const amt = validCases.length - cancelled; - if (amt === 0) { - sendErrorMessage(pluginData, message.channel, "All deletions were cancelled, no cases were deleted."); - return; - } - - sendSuccessMessage( - pluginData, - message.channel, - `${amt} case${amt === 1 ? " was" : "s were"} deleted!${failedAddendum}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts b/backend/src/plugins/ModActions/commands/ForcebanCmd.ts deleted file mode 100644 index c51487e4..00000000 --- a/backend/src/plugins/ModActions/commands/ForcebanCmd.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Snowflake } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { DAYS, MINUTES, resolveMember, resolveUser } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { isBanned } from "../functions/isBanned.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const ForcebanCmd = modActionsCmd({ - trigger: "forceban", - permission: "can_ban", - description: "Force-ban the specified user, even if they aren't on the server", - - signature: [ - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - // If the user exists as a guild member, make sure we can act on them first - const member = await resolveMember(pluginData.client, pluginData.guild, user.id); - if (member && !canActOn(pluginData, msg.member, member)) { - sendErrorMessage(pluginData, msg.channel, "Cannot forceban this user: insufficient permissions"); - return; - } - - // Make sure the user isn't already banned - const banned = await isBanned(pluginData, user.id); - if (banned) { - sendErrorMessage(pluginData, msg.channel, `User is already banned`); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - ignoreEvent(pluginData, IgnoredEventType.Ban, user.id); - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); - - try { - // FIXME: Use banUserId()? - await pluginData.guild.bans.create(user.id as Snowflake, { - deleteMessageSeconds: (1 * DAYS) / MINUTES, - reason: reason ?? undefined, - }); - } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to forceban member"); - return; - } - - // Create a case - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - userId: user.id, - modId: mod.id, - type: CaseTypes.Ban, - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, - }); - - // Confirm the action - sendSuccessMessage(pluginData, msg.channel, `Member forcebanned (Case #${createdCase.case_number})`); - - // Log the action - pluginData.getPlugin(LogsPlugin).logMemberForceban({ - mod, - userId: user.id, - caseNumber: createdCase.case_number, - reason, - }); - - pluginData.state.events.emit("ban", user.id, reason); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts b/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts deleted file mode 100644 index c1363fc3..00000000 --- a/backend/src/plugins/ModActions/commands/ForcemuteCmd.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { actualMuteUserCmd } from "../functions/actualMuteUserCmd.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const ForcemuteCmd = modActionsCmd({ - trigger: "forcemute", - permission: "can_mute", - description: "Force-mute the specified user, even if they're not on the server", - - signature: [ - { - user: ct.string(), - time: ct.delay(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); - - // Make sure we're allowed to mute this user - if (memberToMute && !canActOn(pluginData, msg.member, memberToMute)) { - sendErrorMessage(pluginData, msg.channel, "Cannot mute: insufficient permissions"); - return; - } - - actualMuteUserCmd(pluginData, user, msg, { ...args, notify: "none" }); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts b/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts deleted file mode 100644 index 6b60791a..00000000 --- a/backend/src/plugins/ModActions/commands/ForceunmuteCmd.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const ForceUnmuteCmd = modActionsCmd({ - trigger: "forceunmute", - permission: "can_mute", - description: "Force-unmute the specified user, even if they're not on the server", - - signature: [ - { - user: ct.string(), - time: ct.delay(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - // Check if they're muted in the first place - if (!(await pluginData.state.mutes.isMuted(user.id))) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted"); - return; - } - - // Find the server member to unmute - const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); - - // Make sure we're allowed to unmute this member - if (memberToUnmute && !canActOn(pluginData, msg.member, memberToUnmute)) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: insufficient permissions"); - return; - } - - actualUnmuteCmd(pluginData, user, msg, args); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/HideCaseCmd.ts b/backend/src/plugins/ModActions/commands/HideCaseCmd.ts deleted file mode 100644 index 790b1feb..00000000 --- a/backend/src/plugins/ModActions/commands/HideCaseCmd.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { modActionsCmd } from "../types.js"; - -export const HideCaseCmd = modActionsCmd({ - trigger: ["hide", "hidecase", "hide_case"], - permission: "can_hidecase", - description: "Hide the specified case so it doesn't appear in !cases or !info", - - signature: [ - { - caseNum: ct.number({ rest: true }), - }, - ], - - async run({ pluginData, message: msg, args }) { - const failed: number[] = []; - - for (const num of args.caseNum) { - const theCase = await pluginData.state.cases.findByCaseNumber(num); - if (!theCase) { - failed.push(num); - continue; - } - - await pluginData.state.cases.setHidden(theCase.id, true); - } - - if (failed.length === args.caseNum.length) { - sendErrorMessage(pluginData, msg.channel, "None of the cases were found!"); - return; - } - const failedAddendum = - failed.length > 0 - ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` - : ""; - - const amt = args.caseNum.length - failed.length; - sendSuccessMessage( - pluginData, - msg.channel, - `${amt} case${amt === 1 ? " is" : "s are"} now hidden! Use \`unhidecase\` to unhide them.${failedAddendum}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/KickCmd.ts b/backend/src/plugins/ModActions/commands/KickCmd.ts deleted file mode 100644 index 33528844..00000000 --- a/backend/src/plugins/ModActions/commands/KickCmd.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { actualKickMemberCmd } from "../functions/actualKickMemberCmd.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), - clean: ct.bool({ option: true, isSwitch: true }), -}; - -export const KickCmd = modActionsCmd({ - trigger: "kick", - permission: "can_kick", - description: "Kick the specified member", - - signature: [ - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - actualKickMemberCmd(pluginData, msg, args); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/MassBanCmd.ts b/backend/src/plugins/ModActions/commands/MassBanCmd.ts deleted file mode 100644 index 8f4fdccb..00000000 --- a/backend/src/plugins/ModActions/commands/MassBanCmd.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Snowflake } from "discord.js"; -import { waitForReply } from "knub/helpers"; -import { performance } from "perf_hooks"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { humanizeDurationShort } from "../../../humanizeDurationShort.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { DAYS, MINUTES, SECONDS, noop } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -export const MassbanCmd = modActionsCmd({ - trigger: "massban", - permission: "can_massban", - description: "Mass-ban a list of user IDs", - - signature: [ - { - userIds: ct.string({ rest: true }), - }, - ], - - async run({ pluginData, message: msg, args }) { - // Limit to 100 users at once (arbitrary?) - if (args.userIds.length > 100) { - sendErrorMessage(pluginData, msg.channel, `Can only massban max 100 users at once`); - return; - } - - // Ask for ban reason (cleaner this way instead of trying to cram it into the args) - msg.channel.send("Ban reason? `cancel` to cancel"); - const banReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id); - if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") { - sendErrorMessage(pluginData, msg.channel, "Cancelled"); - return; - } - - const banReason = formatReasonWithAttachments(banReasonReply.content, [...msg.attachments.values()]); - - // Verify we can act on each of the users specified - for (const userId of args.userIds) { - const member = pluginData.guild.members.cache.get(userId as Snowflake); // TODO: Get members on demand? - if (member && !canActOn(pluginData, msg.member, member)) { - sendErrorMessage(pluginData, msg.channel, "Cannot massban one or more users: insufficient permissions"); - return; - } - } - - // Show a loading indicator since this can take a while - const maxWaitTime = pluginData.state.massbanQueue.timeout * pluginData.state.massbanQueue.length; - const maxWaitTimeFormatted = humanizeDurationShort(maxWaitTime, { round: true }); - const initialLoadingText = - pluginData.state.massbanQueue.length === 0 - ? "Banning..." - : `Massban queued. Waiting for previous massban to finish (max wait ${maxWaitTimeFormatted}).`; - const loadingMsg = await msg.channel.send(initialLoadingText); - - const waitTimeStart = performance.now(); - const waitingInterval = setInterval(() => { - const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true }); - loadingMsg - .edit(`Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`) - .catch(() => clearInterval(waitingInterval)); - }, 1 * MINUTES); - - pluginData.state.massbanQueue.add(async () => { - clearInterval(waitingInterval); - - if (pluginData.state.unloaded) { - void loadingMsg.delete().catch(noop); - return; - } - - void loadingMsg.edit("Banning...").catch(noop); - - // Ban each user and count failed bans (if any) - const startTime = performance.now(); - const failedBans: string[] = []; - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const deleteDays = (await pluginData.config.getForMessage(msg)).ban_delete_message_days; - for (const [i, userId] of args.userIds.entries()) { - if (pluginData.state.unloaded) { - break; - } - - try { - // Ignore automatic ban cases and logs - // We create our own cases below and post a single "mass banned" log instead - ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 30 * MINUTES); - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 30 * MINUTES); - - await pluginData.guild.bans.create(userId as Snowflake, { - deleteMessageSeconds: (deleteDays * DAYS) / SECONDS, - reason: banReason, - }); - - await casesPlugin.createCase({ - userId, - modId: msg.author.id, - type: CaseTypes.Ban, - reason: `Mass ban: ${banReason}`, - postInCaseLogOverride: false, - }); - - pluginData.state.events.emit("ban", userId, banReason); - } catch { - failedBans.push(userId); - } - - // Send a status update every 10 bans - if ((i + 1) % 10 === 0) { - loadingMsg.edit(`Banning... ${i + 1}/${args.userIds.length}`).catch(noop); - } - } - - const totalTime = performance.now() - startTime; - const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true }); - - // Clear loading indicator - loadingMsg.delete().catch(noop); - - const successfulBanCount = args.userIds.length - failedBans.length; - if (successfulBanCount === 0) { - // All bans failed - don't create a log entry and notify the user - 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.getPlugin(LogsPlugin).logMassBan({ - mod: msg.author, - count: successfulBanCount, - reason: banReason, - }); - - if (failedBans.length) { - sendSuccessMessage( - pluginData, - msg.channel, - `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${ - failedBans.length - } failed: ${failedBans.join(" ")}`, - ); - } else { - sendSuccessMessage( - pluginData, - msg.channel, - `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, - ); - } - } - }); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts b/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts deleted file mode 100644 index dfe19f67..00000000 --- a/backend/src/plugins/ModActions/commands/MassUnbanCmd.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Snowflake } from "discord.js"; -import { waitForReply } from "knub/helpers"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { isBanned } from "../functions/isBanned.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -export const MassunbanCmd = modActionsCmd({ - trigger: "massunban", - permission: "can_massunban", - description: "Mass-unban a list of user IDs", - - signature: [ - { - userIds: ct.string({ rest: true }), - }, - ], - - async run({ pluginData, message: msg, args }) { - // Limit to 100 users at once (arbitrary?) - if (args.userIds.length > 100) { - sendErrorMessage(pluginData, msg.channel, `Can only mass-unban max 100 users at once`); - return; - } - - // Ask for unban reason (cleaner this way instead of trying to cram it into the args) - msg.channel.send("Unban reason? `cancel` to cancel"); - const unbanReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id); - if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") { - sendErrorMessage(pluginData, msg.channel, "Cancelled"); - return; - } - - const unbanReason = formatReasonWithAttachments(unbanReasonReply.content, [...msg.attachments.values()]); - - // 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) => { - // 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); - }); - - // Show a loading indicator since this can take a while - const loadingMsg = await msg.channel.send("Unbanning..."); - - // Unban each user and count failed unbans (if any) - const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = []; - const casesPlugin = pluginData.getPlugin(CasesPlugin); - for (const userId of args.userIds) { - if (!(await isBanned(pluginData, userId))) { - failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED }); - continue; - } - - try { - await pluginData.guild.bans.remove(userId as Snowflake, unbanReason ?? undefined); - - await casesPlugin.createCase({ - userId, - modId: msg.author.id, - type: CaseTypes.Unban, - reason: `Mass unban: ${unbanReason}`, - postInCaseLogOverride: false, - }); - } catch { - failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED }); - } - } - - // Clear loading indicator - loadingMsg.delete(); - - const successfulUnbanCount = args.userIds.length - failedUnbans.length; - if (successfulUnbanCount === 0) { - // All unbans failed - don't create a log entry and notify the user - 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.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); - - let failedMsg = ""; - if (notBanned.length > 0) { - failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`; - notBanned.forEach((fail) => { - failedMsg += " " + fail.userId; - }); - } - if (unbanFailed.length > 0) { - failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`; - unbanFailed.forEach((fail) => { - failedMsg += " " + fail.userId; - }); - } - - sendSuccessMessage( - pluginData, - msg.channel, - `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`, - ); - } else { - sendSuccessMessage(pluginData, msg.channel, `Unbanned ${successfulUnbanCount} users successfully`); - } - } - }, -}); - -enum UnbanFailReasons { - NOT_BANNED = "Not banned", - UNBAN_FAILED = "Unban failed", -} diff --git a/backend/src/plugins/ModActions/commands/MassmuteCmd.ts b/backend/src/plugins/ModActions/commands/MassmuteCmd.ts deleted file mode 100644 index 4cb26485..00000000 --- a/backend/src/plugins/ModActions/commands/MassmuteCmd.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Snowflake } from "discord.js"; -import { waitForReply } from "knub/helpers"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { logger } from "../../../logger.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { modActionsCmd } from "../types.js"; - -export const MassmuteCmd = modActionsCmd({ - trigger: "massmute", - permission: "can_massmute", - description: "Mass-mute a list of user IDs", - - signature: [ - { - userIds: ct.string({ rest: true }), - }, - ], - - async run({ pluginData, message: msg, args }) { - // Limit to 100 users at once (arbitrary?) - if (args.userIds.length > 100) { - sendErrorMessage(pluginData, msg.channel, `Can only massmute max 100 users at once`); - return; - } - - // Ask for mute reason - msg.channel.send("Mute reason? `cancel` to cancel"); - const muteReasonReceived = await waitForReply(pluginData.client, msg.channel, msg.author.id); - if ( - !muteReasonReceived || - !muteReasonReceived.content || - muteReasonReceived.content.toLowerCase().trim() === "cancel" - ) { - sendErrorMessage(pluginData, msg.channel, "Cancelled"); - return; - } - - const muteReason = formatReasonWithAttachments(muteReasonReceived.content, [...msg.attachments.values()]); - - // Verify we can act upon all users - for (const userId of args.userIds) { - const member = pluginData.guild.members.cache.get(userId as Snowflake); - if (member && !canActOn(pluginData, msg.member, member)) { - sendErrorMessage(pluginData, msg.channel, "Cannot massmute one or more users: insufficient permissions"); - return; - } - } - - // 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) => { - // Use longer timeouts since this can take a while - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000); - }); - - // Show loading indicator - const loadingMsg = await msg.channel.send("Muting..."); - - // Mute everyone and count fails - const modId = msg.author.id; - const failedMutes: string[] = []; - const mutesPlugin = pluginData.getPlugin(MutesPlugin); - for (const userId of args.userIds) { - try { - await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, { - caseArgs: { - modId, - }, - }); - } catch (e) { - logger.info(e); - failedMutes.push(userId); - } - } - - // Clear loading indicator - loadingMsg.delete(); - - const successfulMuteCount = args.userIds.length - failedMutes.length; - if (successfulMuteCount === 0) { - // All mutes failed - sendErrorMessage(pluginData, msg.channel, "All mutes failed. Make sure the IDs are valid."); - } else { - // Success on all or some mutes - pluginData.getPlugin(LogsPlugin).logMassMute({ - mod: msg.author, - count: successfulMuteCount, - }); - - if (failedMutes.length) { - sendSuccessMessage( - pluginData, - msg.channel, - `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`, - ); - } else { - sendSuccessMessage(pluginData, msg.channel, `Muted ${successfulMuteCount} users successfully`); - } - } - }, -}); diff --git a/backend/src/plugins/ModActions/commands/MuteCmd.ts b/backend/src/plugins/ModActions/commands/MuteCmd.ts deleted file mode 100644 index bfefcec6..00000000 --- a/backend/src/plugins/ModActions/commands/MuteCmd.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { actualMuteUserCmd } from "../functions/actualMuteUserCmd.js"; -import { isBanned } from "../functions/isBanned.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const MuteCmd = modActionsCmd({ - trigger: "mute", - permission: "can_mute", - description: "Mute the specified member", - - signature: [ - { - user: ct.string(), - time: ct.delay(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); - - if (!memberToMute) { - const _isBanned = await isBanned(pluginData, user.id); - const prefix = pluginData.fullConfig.prefix; - if (_isBanned) { - sendErrorMessage( - pluginData, - msg.channel, - `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, - ); - return; - } else { - // Ask the mod if we should upgrade to a forcemute as the user is not on the server - const reply = await waitForButtonConfirm( - msg.channel, - { content: "User not found on the server, forcemute instead?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - - if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User not on server, mute cancelled by moderator"); - return; - } - } - } - - // Make sure we're allowed to mute this member - if (memberToMute && !canActOn(pluginData, msg.member, memberToMute)) { - sendErrorMessage(pluginData, msg.channel, "Cannot mute: insufficient permissions"); - return; - } - - actualMuteUserCmd(pluginData, user, msg, args); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/NoteCmd.ts b/backend/src/plugins/ModActions/commands/NoteCmd.ts deleted file mode 100644 index 7f3f9219..00000000 --- a/backend/src/plugins/ModActions/commands/NoteCmd.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { renderUsername, resolveUser } from "../../../utils.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { modActionsCmd } from "../types.js"; - -export const NoteCmd = modActionsCmd({ - trigger: "note", - permission: "can_note", - description: "Add a note to the specified user", - - signature: { - user: ct.string(), - note: ct.string({ required: false, catchAll: true }), - }, - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - if (!args.note && msg.attachments.size === 0) { - sendErrorMessage(pluginData, msg.channel, "Text or attachment required"); - return; - } - - const userName = renderUsername(user); - const reason = formatReasonWithAttachments(args.note, [...msg.attachments.values()]); - - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - userId: user.id, - modId: msg.author.id, - type: CaseTypes.Note, - reason, - }); - - pluginData.getPlugin(LogsPlugin).logMemberNote({ - mod: msg.author, - user, - caseNumber: createdCase.case_number, - reason, - }); - - sendSuccessMessage(pluginData, msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`); - - pluginData.state.events.emit("note", user.id, reason); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/SoftbanCommand.ts b/backend/src/plugins/ModActions/commands/SoftbanCommand.ts deleted file mode 100644 index d59c63c1..00000000 --- a/backend/src/plugins/ModActions/commands/SoftbanCommand.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { trimPluginDescription } from "../../../utils.js"; -import { actualKickMemberCmd } from "../functions/actualKickMemberCmd.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), -}; - -export const SoftbanCmd = modActionsCmd({ - trigger: "softban", - permission: "can_kick", - description: trimPluginDescription(` - "Softban" the specified user by banning and immediately unbanning them. Effectively a kick with message deletions. - This command will be removed in the future, please use kick with the \`- clean\` argument instead - `), - - signature: [ - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - await actualKickMemberCmd(pluginData, msg, { clean: true, ...args }); - await msg.channel.send( - "Softban will be removed in the future - please use the kick command with the `-clean` argument instead!", - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts deleted file mode 100644 index 1a5a96b8..00000000 --- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Snowflake } from "discord.js"; -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { clearExpiringTempban } from "../../../data/loops/expiringTempbansLoop.js"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; -import { resolveUser } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { ignoreEvent } from "../functions/ignoreEvent.js"; -import { IgnoredEventType, modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const UnbanCmd = modActionsCmd({ - trigger: "unban", - permission: "can_unban", - description: "Unban the specified member", - - signature: [ - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - try { - ignoreEvent(pluginData, IgnoredEventType.Unban, user.id); - await pluginData.guild.bans.remove(user.id as Snowflake, reason ?? undefined); - } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to unban member; are you sure they're banned?"); - return; - } - - // Create a case - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - userId: user.id, - modId: mod.id, - type: CaseTypes.Unban, - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, - }); - // Delete the tempban, if one exists - const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); - if (tempban) { - clearExpiringTempban(tempban); - await pluginData.state.tempbans.clear(user.id); - } - - // Confirm the action - sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); - - // Log the action - pluginData.getPlugin(LogsPlugin).logMemberUnban({ - mod: mod.user, - userId: user.id, - caseNumber: createdCase.case_number, - reason: reason ?? "", - }); - - pluginData.state.events.emit("unban", user.id); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts b/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts deleted file mode 100644 index 0b47595d..00000000 --- a/backend/src/plugins/ModActions/commands/UnhideCaseCmd.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { modActionsCmd } from "../types.js"; - -export const UnhideCaseCmd = modActionsCmd({ - trigger: ["unhide", "unhidecase", "unhide_case"], - permission: "can_hidecase", - description: "Un-hide the specified case, making it appear in !cases and !info again", - - signature: [ - { - caseNum: ct.number({ rest: true }), - }, - ], - - async run({ pluginData, message: msg, args }) { - const failed: number[] = []; - - for (const num of args.caseNum) { - const theCase = await pluginData.state.cases.findByCaseNumber(num); - if (!theCase) { - failed.push(num); - continue; - } - - await pluginData.state.cases.setHidden(theCase.id, false); - } - - if (failed.length === args.caseNum.length) { - sendErrorMessage(pluginData, msg.channel, "None of the cases were found!"); - return; - } - const failedAddendum = - failed.length > 0 - ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` - : ""; - - const amt = args.caseNum.length - failed.length; - sendSuccessMessage( - pluginData, - msg.channel, - `${amt} case${amt === 1 ? " is" : "s are"} no longer hidden!${failedAddendum}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts deleted file mode 100644 index 06c50003..00000000 --- a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; -import { resolveMember, resolveUser } from "../../../utils.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { actualUnmuteCmd } from "../functions/actualUnmuteUserCmd.js"; -import { isBanned } from "../functions/isBanned.js"; -import { modActionsCmd } from "../types.js"; - -const opts = { - mod: ct.member({ option: true }), -}; - -export const UnmuteCmd = modActionsCmd({ - trigger: "unmute", - permission: "can_mute", - description: "Unmute the specified member", - - signature: [ - { - user: ct.string(), - time: ct.delay(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - { - user: ct.string(), - reason: ct.string({ required: false, catchAll: true }), - - ...opts, - }, - ], - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); - const mutesPlugin = pluginData.getPlugin(MutesPlugin); - const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); - - // Check if they're muted in the first place - if ( - !(await pluginData.state.mutes.isMuted(user.id)) && - !hasMuteRole && - !memberToUnmute?.isCommunicationDisabled() - ) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted"); - return; - } - - if (!memberToUnmute) { - const banned = await isBanned(pluginData, user.id); - const prefix = pluginData.fullConfig.prefix; - if (banned) { - sendErrorMessage( - pluginData, - msg.channel, - `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, - ); - return; - } else { - // Ask the mod if we should upgrade to a forceunmute as the user is not on the server - const reply = await waitForButtonConfirm( - msg.channel, - { content: "User not on server, forceunmute instead?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - - if (!reply) { - sendErrorMessage(pluginData, msg.channel, "User not on server, unmute cancelled by moderator"); - return; - } - } - } - - // Make sure we're allowed to unmute this member - if (memberToUnmute && !canActOn(pluginData, msg.member, memberToUnmute)) { - sendErrorMessage(pluginData, msg.channel, "Cannot unmute: insufficient permissions"); - return; - } - - actualUnmuteCmd(pluginData, user, msg, args); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts deleted file mode 100644 index 1fcad2d9..00000000 --- a/backend/src/plugins/ModActions/commands/WarnCmd.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { CaseTypes } from "../../../data/CaseTypes.js"; -import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { errorMessage, renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { waitForButtonConfirm } from "../../../utils/waitForInteraction.js"; -import { CasesPlugin } from "../../Cases/CasesPlugin.js"; -import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments.js"; -import { isBanned } from "../functions/isBanned.js"; -import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs.js"; -import { warnMember } from "../functions/warnMember.js"; -import { modActionsCmd } from "../types.js"; - -export const WarnCmd = modActionsCmd({ - trigger: "warn", - permission: "can_warn", - description: "Send a warning to the specified user", - - signature: { - user: ct.string(), - reason: ct.string({ catchAll: true }), - - mod: ct.member({ option: true }), - notify: ct.string({ option: true }), - "notify-channel": ct.textChannel({ option: true }), - }, - - async run({ pluginData, message: msg, args }) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); - - if (!memberToWarn) { - const _isBanned = await isBanned(pluginData, user.id); - if (_isBanned) { - sendErrorMessage(pluginData, msg.channel, `User is banned`); - } else { - sendErrorMessage(pluginData, msg.channel, `User not found on the server`); - } - - return; - } - - // Make sure we're allowed to warn this member - if (!canActOn(pluginData, msg.member, memberToWarn)) { - sendErrorMessage(pluginData, msg.channel, "Cannot warn: insufficient permissions"); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - msg.channel.send(errorMessage("You don't have permission to use -mod")); - return; - } - - mod = args.mod; - } - - const config = pluginData.config.get(); - const reason = formatReasonWithAttachments(args.reason, [...msg.attachments.values()]); - - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); - if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { - const reply = await waitForButtonConfirm( - msg.channel, - { content: config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`) }, - { confirmText: "Yes", cancelText: "No", restrictToId: msg.member.id }, - ); - if (!reply) { - msg.channel.send(errorMessage("Warn cancelled by moderator")); - return; - } - } - - let contactMethods; - try { - contactMethods = readContactMethodsFromArgs(args); - } catch (e) { - sendErrorMessage(pluginData, msg.channel, e.message); - return; - } - - const warnResult = await warnMember(pluginData, memberToWarn, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : undefined, - reason, - }, - retryPromptChannel: msg.channel, - }); - - if (warnResult.status === "failed") { - sendErrorMessage(pluginData, msg.channel, "Failed to warn user"); - return; - } - - const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; - - sendSuccessMessage( - pluginData, - msg.channel, - `Warned **${renderUsername(memberToWarn)}** (Case #${warnResult.case.case_number})${messageResultText}`, - ); - }, -}); diff --git a/backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts new file mode 100644 index 00000000..938bc28f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/AddCaseMsgCmd.ts @@ -0,0 +1,65 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualAddCaseCmd } from "./actualAddCaseCmd.js"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const AddCaseMsgCmd = modActionsMsgCmd({ + trigger: "addcase", + permission: "can_addcase", + description: "Add an arbitrary case to the specified user without taking any action", + + signature: [ + { + type: ct.string(), + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const member = msg.member || (await msg.guild.members.fetch(msg.author.id)); + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = member; + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + // Verify the case type is valid + const type: string = args.type[0].toUpperCase() + args.type.slice(1).toLowerCase(); + if (!CaseTypes[type]) { + pluginData.state.common.sendErrorMessage(msg, "Cannot add case: invalid case type"); + return; + } + + actualAddCaseCmd( + pluginData, + msg, + member, + mod, + [...msg.attachments.values()], + user, + type as keyof CaseTypes, + args.reason || "", + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts new file mode 100644 index 00000000..e84f609b --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/AddCaseSlashCmd.ts @@ -0,0 +1,69 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualAddCaseCmd } from "./actualAddCaseCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to add this case as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const AddCaseSlashCmd = modActionsSlashCmd({ + name: "addcase", + configPermission: "can_addcase", + description: "Add an arbitrary case to the specified user without taking any action", + allowDms: false, + + signature: [ + slashOptions.string({ + name: "type", + description: "The type of case to add", + required: true, + choices: Object.keys(CaseTypes).map((type) => ({ name: type, value: type })), + }), + slashOptions.user({ name: "user", description: "The user to add a case to", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + actualAddCaseCmd( + pluginData, + interaction, + interaction.member as GuildMember, + mod, + attachments, + options.user, + options.type as keyof CaseTypes, + options.reason || "", + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts b/backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts new file mode 100644 index 00000000..b6ec6a56 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/addcase/actualAddCaseCmd.ts @@ -0,0 +1,63 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { Case } from "../../../../data/entities/Case.js"; +import { canActOn } from "../../../../pluginUtils.js"; +import { UnknownUser, renderUsername, resolveMember } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualAddCaseCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: GuildMember, + mod: GuildMember, + attachments: Array, + user: User | UnknownUser, + type: keyof CaseTypes, + reason: string, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + // If the user exists as a guild member, make sure we can act on them first + const member = await resolveMember(pluginData.client, pluginData.guild, user.id); + if (member && !canActOn(pluginData, author, member)) { + pluginData.state.common.sendErrorMessage(context, "Cannot add case on this user: insufficient permissions"); + return; + } + + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + + // Create the case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const theCase: Case = await casesPlugin.createCase({ + userId: user.id, + modId: mod.id, + type: CaseTypes[type], + reason: formattedReason, + ppId: mod.id !== author.id ? author.id : undefined, + }); + + if (user) { + pluginData.state.common.sendSuccessMessage( + context, + `Case #${theCase.case_number} created for **${renderUsername(user)}**`, + ); + } else { + pluginData.state.common.sendSuccessMessage(context, `Case #${theCase.case_number} created`); + } + + // Log the action + pluginData.getPlugin(LogsPlugin).logCaseCreate({ + mod: mod.user, + userId: user.id, + caseNum: theCase.case_number, + caseType: type.toUpperCase(), + reason: formattedReason, + }); +} diff --git a/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts b/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts new file mode 100644 index 00000000..184e252b --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/BanMsgCmd.ts @@ -0,0 +1,77 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, resolveUser } from "../../../../utils.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualBanCmd } from "./actualBanCmd.js"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + "delete-days": ct.number({ option: true, shortcut: "d" }), +}; + +export const BanMsgCmd = modActionsMsgCmd({ + trigger: "ban", + permission: "can_ban", + description: "Ban or Tempban the specified member", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const member = msg.member || (await msg.guild.members.fetch(msg.author.id)); + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = member; + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(args) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualBanCmd( + pluginData, + msg, + user, + args["time"] ? args["time"] : null, + args.reason || "", + [...msg.attachments.values()], + member, + mod, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts b/backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts new file mode 100644 index 00000000..0732d774 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/BanSlashCmd.ts @@ -0,0 +1,100 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualBanCmd } from "./actualBanCmd.js"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the ban", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to ban as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + slashOptions.number({ + name: "delete-days", + description: "The number of days of messages to delete", + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const BanSlashCmd = modActionsSlashCmd({ + name: "ban", + configPermission: "can_ban", + description: "Ban or Tempban the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to ban", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualBanCmd( + pluginData, + interaction, + options.user, + convertedTime, + options.reason || "", + attachments, + interaction.member as GuildMember, + mod, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts b/backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts new file mode 100644 index 00000000..4ae8c127 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/ban/actualBanCmd.ts @@ -0,0 +1,190 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { getMemberLevel } from "knub/helpers"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { clearExpiringTempban, registerExpiringTempban } from "../../../../data/loops/expiringTempbansLoop.js"; +import { humanizeDuration } from "../../../../humanizeDuration.js"; +import { canActOn, getContextChannel } from "../../../../pluginUtils.js"; +import { UnknownUser, UserNotificationMethod, renderUsername, resolveMember } from "../../../../utils.js"; +import { banLock } from "../../../../utils/lockNameHelpers.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { banUserId } from "../../functions/banUserId.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualBanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + user: User | UnknownUser, + time: number | null, + reason: string, + attachments: Attachment[], + author: GuildMember, + mod: GuildMember, + contactMethods?: UserNotificationMethod[], + deleteDays?: number, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + // acquire a lock because of the needed user-inputs below (if banned/not on server) + const lock = await pluginData.locks.acquire(banLock(user)); + let forceban = false; + const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); + + if (!memberToBan) { + const banned = await isBanned(pluginData, user.id); + + if (!banned) { + // Ask the mod if we should upgrade to a forceban as the user is not on the server + const reply = await waitForButtonConfirm( + context, + { content: "User not on server, forceban instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: author.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(context, "User not on server, ban cancelled by moderator"); + lock.unlock(); + return; + } else { + forceban = true; + } + } else { + // Abort if trying to ban user indefinitely if they are already banned indefinitely + if (!existingTempban && !time) { + pluginData.state.common.sendErrorMessage(context, `User is already banned indefinitely.`); + return; + } + + // Ask the mod if we should update the existing ban + const reply = await waitForButtonConfirm( + context, + { content: "Failed to message the user. Log the warning anyway?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: author.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(context, "User already banned, update cancelled by moderator"); + lock.unlock(); + return; + } + + // Update or add new tempban / remove old tempban + if (time && time > 0) { + if (existingTempban) { + await pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); + } else { + await pluginData.state.tempbans.addTempban(user.id, time, mod.id); + } + const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!; + registerExpiringTempban(tempban); + } else if (existingTempban) { + clearExpiringTempban(existingTempban); + pluginData.state.tempbans.clear(user.id); + } + + // Create a new case for the updated ban since we never stored the old case id and log the action + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + modId: mod.id, + type: CaseTypes.Ban, + userId: user.id, + reason: formattedReason, + noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`], + }); + if (time) { + pluginData.getPlugin(LogsPlugin).logMemberTimedBan({ + mod: mod.user, + user, + caseNumber: createdCase.case_number, + reason: formattedReason, + banTime: humanizeDuration(time), + }); + } else { + pluginData.getPlugin(LogsPlugin).logMemberBan({ + mod: mod.user, + user, + caseNumber: createdCase.case_number, + reason: formattedReason, + }); + } + + pluginData.state.common.sendSuccessMessage( + context, + `Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`, + ); + lock.unlock(); + return; + } + } + + // Make sure we're allowed to ban this member if they are on the server + if (!forceban && !canActOn(pluginData, author, memberToBan!)) { + const ourLevel = getMemberLevel(pluginData, author); + const targetLevel = getMemberLevel(pluginData, memberToBan!); + pluginData.state.common.sendErrorMessage( + context, + `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`, + ); + lock.unlock(); + return; + } + + const matchingConfig = await pluginData.config.getMatchingConfig({ + member: author, + channel: await getContextChannel(context), + }); + const deleteMessageDays = deleteDays ?? matchingConfig.ban_delete_message_days; + const banResult = await banUserId( + pluginData, + user.id, + formattedReason, + formattedReasonWithAttachments, + { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== author.id ? author.id : undefined, + }, + deleteMessageDays, + modId: mod.id, + }, + time ?? undefined, + ); + + if (banResult.status === "failed") { + pluginData.state.common.sendErrorMessage(context, `Failed to ban member: ${banResult.error}`); + lock.unlock(); + return; + } + + let forTime = ""; + if (time && time > 0) { + forTime = `for ${humanizeDuration(time)} `; + } + + // Confirm the action to the moderator + let response = ""; + if (!forceban) { + response = `Banned **${renderUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`; + if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; + } else { + response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`; + } + + lock.unlock(); + pluginData.state.common.sendSuccessMessage(context, response); +} diff --git a/backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts new file mode 100644 index 00000000..211e529f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/CaseMsgCmd.ts @@ -0,0 +1,25 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualCaseCmd } from "./actualCaseCmd.js"; + +const opts = { + show: ct.switchOption({ def: false, shortcut: "sh" }), +}; + +export const CaseMsgCmd = modActionsMsgCmd({ + trigger: "case", + permission: "can_view", + description: "Show information about a specific case", + + signature: [ + { + caseNumber: ct.number(), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + actualCaseCmd(pluginData, msg, msg.author.id, args.caseNumber, args.show); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts new file mode 100644 index 00000000..c2c7eccc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/CaseSlashCmd.ts @@ -0,0 +1,25 @@ +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualCaseCmd } from "./actualCaseCmd.js"; + +const opts = [ + slashOptions.boolean({ name: "show", description: "To make the result visible to everyone", required: false }), +]; + +export const CaseSlashCmd = modActionsSlashCmd({ + name: "case", + configPermission: "can_view", + description: "Show information about a specific case", + allowDms: false, + + signature: [ + slashOptions.number({ name: "case-number", description: "The number of the case to show", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: options.show !== true }); + actualCaseCmd(pluginData, interaction, interaction.user.id, options["case-number"], options.show); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts b/backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts new file mode 100644 index 00000000..15af3e49 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/case/actualCaseCmd.ts @@ -0,0 +1,25 @@ +import { ChatInputCommandInteraction, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { sendContextResponse } from "../../../../pluginUtils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualCaseCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + caseNumber: number, + show: boolean | null, +) { + const theCase = await pluginData.state.cases.findByCaseNumber(caseNumber); + + if (!theCase) { + void pluginData.state.common.sendErrorMessage(context, "Case not found", undefined, undefined, show !== true); + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const content = await casesPlugin.getCaseEmbed(theCase.id, authorId); + + void sendContextResponse(context, content, show !== true); +} diff --git a/backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts b/backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts new file mode 100644 index 00000000..92fe53f3 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesModMsgCmd.ts @@ -0,0 +1,53 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMessageMember } from "../../../../pluginUtils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualCasesCmd } from "./actualCasesCmd.js"; + +const opts = { + mod: ct.userId({ option: true }), + expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), + hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), + reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), + notes: ct.switchOption({ def: false, shortcut: "n" }), + warns: ct.switchOption({ def: false, shortcut: "w" }), + mutes: ct.switchOption({ def: false, shortcut: "m" }), + unmutes: ct.switchOption({ def: false, shortcut: "um" }), + kicks: ct.switchOption({ def: false, shortcut: "k" }), + bans: ct.switchOption({ def: false, shortcut: "b" }), + unbans: ct.switchOption({ def: false, shortcut: "ub" }), + show: ct.switchOption({ def: false, shortcut: "sh" }), +}; + +export const CasesModMsgCmd = modActionsMsgCmd({ + trigger: ["cases", "modlogs", "infractions"], + permission: "can_view", + description: "Show the most recent 5 cases by the specified -mod", + + signature: [ + { + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const member = await resolveMessageMember(msg); + return actualCasesCmd( + pluginData, + msg, + args.mod, + null, + member, + args.notes, + args.warns, + args.mutes, + args.unmutes, + args.kicks, + args.bans, + args.unbans, + args.reverseFilters, + args.hidden, + args.expand, + args.show, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts b/backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts new file mode 100644 index 00000000..4a9d3e15 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesSlashCmd.ts @@ -0,0 +1,56 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualCasesCmd } from "./actualCasesCmd.js"; + +const opts = [ + slashOptions.user({ name: "user", description: "The user to show cases for", required: false }), + slashOptions.user({ name: "mod", description: "The mod to filter cases by", required: false }), + slashOptions.boolean({ name: "expand", description: "Show each case individually", required: false }), + slashOptions.boolean({ name: "hidden", description: "Whether or not to show hidden cases", required: false }), + slashOptions.boolean({ + name: "reverse-filters", + description: "To treat case type filters as exclusive instead of inclusive", + required: false, + }), + slashOptions.boolean({ name: "notes", description: "To filter notes", required: false }), + slashOptions.boolean({ name: "warns", description: "To filter warns", required: false }), + slashOptions.boolean({ name: "mutes", description: "To filter mutes", required: false }), + slashOptions.boolean({ name: "unmutes", description: "To filter unmutes", required: false }), + slashOptions.boolean({ name: "kicks", description: "To filter kicks", required: false }), + slashOptions.boolean({ name: "bans", description: "To filter bans", required: false }), + slashOptions.boolean({ name: "unbans", description: "To filter unbans", required: false }), + slashOptions.boolean({ name: "show", description: "To make the result visible to everyone", required: false }), +]; + +export const CasesSlashCmd = modActionsSlashCmd({ + name: "cases", + configPermission: "can_view", + description: "Show a list of cases the specified user has or the specified mod made", + allowDms: false, + + signature: [...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: options.show !== true }); + + return actualCasesCmd( + pluginData, + interaction, + options.mod?.id ?? null, + options.user, + interaction.member as GuildMember, + options.notes, + options.warns, + options.mutes, + options.unmutes, + options.kicks, + options.bans, + options.unbans, + options["reverse-filters"], + options.hidden, + options.expand, + options.show, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts b/backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts new file mode 100644 index 00000000..dac00506 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/CasesUserMsgCmd.ts @@ -0,0 +1,66 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser, UnknownUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualCasesCmd } from "./actualCasesCmd.js"; + +const opts = { + mod: ct.userId({ option: true }), + expand: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), + hidden: ct.bool({ option: true, isSwitch: true, shortcut: "h" }), + reverseFilters: ct.switchOption({ def: false, shortcut: "r" }), + notes: ct.switchOption({ def: false, shortcut: "n" }), + warns: ct.switchOption({ def: false, shortcut: "w" }), + mutes: ct.switchOption({ def: false, shortcut: "m" }), + unmutes: ct.switchOption({ def: false, shortcut: "um" }), + kicks: ct.switchOption({ def: false, shortcut: "k" }), + bans: ct.switchOption({ def: false, shortcut: "b" }), + unbans: ct.switchOption({ def: false, shortcut: "ub" }), + show: ct.switchOption({ def: false, shortcut: "sh" }), +}; + +export const CasesUserMsgCmd = modActionsMsgCmd({ + trigger: ["cases", "modlogs", "infractions"], + permission: "can_view", + description: "Show a list of cases the specified user has", + + signature: [ + { + user: ct.string(), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = + (await resolveMember(pluginData.client, pluginData.guild, args.user)) || + (await resolveUser(pluginData.client, args.user)); + + if (user instanceof UnknownUser) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const member = await resolveMessageMember(msg); + + return actualCasesCmd( + pluginData, + msg, + args.mod, + user, + member, + args.notes, + args.warns, + args.mutes, + args.unmutes, + args.kicks, + args.bans, + args.unbans, + args.reverseFilters, + args.hidden, + args.expand, + args.show, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts b/backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts new file mode 100644 index 00000000..1f668c70 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/cases/actualCasesCmd.ts @@ -0,0 +1,294 @@ +import { APIEmbed, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { FindOptionsWhere, In } from "typeorm"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { Case } from "../../../../data/entities/Case.js"; +import { sendContextResponse } from "../../../../pluginUtils.js"; +import { + UnknownUser, + chunkArray, + emptyEmbedValue, + renderUsername, + resolveMember, + resolveUser, + trimLines, +} from "../../../../utils.js"; +import { asyncMap } from "../../../../utils/async.js"; +import { createPaginatedMessage } from "../../../../utils/createPaginatedMessage.js"; +import { getGuildPrefix } from "../../../../utils/getGuildPrefix.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { ModActionsPluginType } from "../../types.js"; + +const casesPerPage = 5; +const maxExpandedCases = 8; + +async function sendExpandedCases( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + casesCount: number, + cases: Case[], + show: boolean | null, +) { + if (casesCount > maxExpandedCases) { + await sendContextResponse(context, { + content: "Too many cases for expanded view. Please use compact view instead.", + ephemeral: true, + }); + + return; + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + + for (const theCase of cases) { + const content = await casesPlugin.getCaseEmbed(theCase.id); + await sendContextResponse(context, content, !show); + } +} + +async function casesUserCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: User, + modId: string | null, + user: GuildMember | User | UnknownUser, + modName: string, + typesToShow: CaseTypes[], + hidden: boolean | null, + expand: boolean | null, + show: boolean | null, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const casesFilters: Omit, "guild_id" | "user_id"> = { type: In(typesToShow) }; + + if (modId) { + casesFilters.mod_id = modId; + } + + const cases = await pluginData.state.cases.with("notes").getByUserId(user.id, casesFilters); + 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 : renderUsername(user); + + if (cases.length === 0) { + await sendContextResponse(context, { + content: `No cases found for **${userName}**${modId ? ` by ${modName}` : ""}.`, + ephemeral: !show, + }); + + return; + } + + const casesToDisplay = hidden ? cases : normalCases; + + if (!casesToDisplay.length) { + await sendContextResponse(context, { + content: `No normal cases found for **${userName}**. Use "-hidden" to show ${cases.length} hidden cases.`, + ephemeral: !show, + }); + + return; + } + + if (expand) { + await sendExpandedCases(pluginData, context, casesToDisplay.length, casesToDisplay, show); + return; + } + + // Compact view (= regular message with a preview of each case) + const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, author.id)); + const prefix = getGuildPrefix(pluginData); + const linesPerChunk = 10; + const lineChunks = chunkArray(lines, linesPerChunk); + + const footerField = { + name: emptyEmbedValue, + value: trimLines(` + Use \`${prefix}case \` to see more information about an individual case + `), + }; + + for (const [i, linesInChunk] of lineChunks.entries()) { + const isLastChunk = i === lineChunks.length - 1; + + if (isLastChunk && !hidden && hiddenCases.length) { + if (hiddenCases.length === 1) { + linesInChunk.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`); + } else { + linesInChunk.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`); + } + } + + const chunkStart = i * linesPerChunk + 1; + const chunkEnd = Math.min((i + 1) * linesPerChunk, lines.length); + + const embed = { + author: { + name: + lineChunks.length === 1 + ? `Cases for ${userName}${modId ? ` by ${modName}` : ""} (${lines.length} total)` + : `Cases ${chunkStart}–${chunkEnd} of ${lines.length} for ${userName}`, + icon_url: user instanceof UnknownUser ? undefined : user.displayAvatarURL(), + }, + description: linesInChunk.join("\n"), + fields: [...(isLastChunk ? [footerField] : [])], + } satisfies APIEmbed; + + await sendContextResponse(context, { embeds: [embed], ephemeral: !show }); + } +} + +async function casesModCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: User, + modId: string | null, + mod: GuildMember | User | UnknownUser, + modName: string, + typesToShow: CaseTypes[], + hidden: boolean | null, + expand: boolean | null, + show: boolean | null, +) { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const casesFilters = { type: In(typesToShow), is_hidden: !!hidden }; + + const totalCases = await casesPlugin.getTotalCasesByMod(modId ?? author.id, casesFilters); + + if (totalCases === 0) { + pluginData.state.common.sendErrorMessage(context, `No cases by **${modName}**`, undefined, undefined, !show); + + return; + } + + const totalPages = Math.max(Math.ceil(totalCases / casesPerPage), 1); + const prefix = getGuildPrefix(pluginData); + + if (expand) { + // Expanded view (= individual case embeds) + const cases = totalCases > 8 ? [] : await casesPlugin.getRecentCasesByMod(modId ?? author.id, 8, 0, casesFilters); + + await sendExpandedCases(pluginData, context, totalCases, cases, show); + return; + } + + await createPaginatedMessage( + pluginData.client, + context, + totalPages, + async (page) => { + const cases = await casesPlugin.getRecentCasesByMod( + modId ?? author.id, + casesPerPage, + (page - 1) * casesPerPage, + casesFilters, + ); + + const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, author.id)); + const firstCaseNum = (page - 1) * casesPerPage + 1; + const lastCaseNum = firstCaseNum - 1 + Math.min(cases.length, casesPerPage); + const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`; + + const embed = { + author: { + name: title, + icon_url: mod instanceof UnknownUser ? undefined : mod.displayAvatarURL(), + }, + description: lines.join("\n"), + fields: [ + { + name: emptyEmbedValue, + value: trimLines(` + Use \`${prefix}case \` to see more information about an individual case + Use \`${prefix}cases \` to see a specific user's cases + `), + }, + ], + } satisfies APIEmbed; + + return { embeds: [embed], ephemeral: !show }; + }, + { + limitToUserId: author.id, + }, + ); +} + +export async function actualCasesCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + modId: string | null, + user: GuildMember | User | UnknownUser | null, + author: GuildMember, + notes: boolean | null, + warns: boolean | null, + mutes: boolean | null, + unmutes: boolean | null, + kicks: boolean | null, + bans: boolean | null, + unbans: boolean | null, + reverseFilters: boolean | null, + hidden: boolean | null, + expand: boolean | null, + show: boolean | null, +) { + const mod = modId + ? (await resolveMember(pluginData.client, pluginData.guild, modId)) || (await resolveUser(pluginData.client, modId)) + : null; + const modName = modId ? (mod instanceof UnknownUser ? modId : renderUsername(mod!)) : renderUsername(author); + + const allTypes = [ + CaseTypes.Note, + CaseTypes.Warn, + CaseTypes.Mute, + CaseTypes.Unmute, + CaseTypes.Kick, + CaseTypes.Ban, + CaseTypes.Unban, + ]; + let typesToShow: CaseTypes[] = []; + + if (notes) typesToShow.push(CaseTypes.Note); + if (warns) typesToShow.push(CaseTypes.Warn); + if (mutes) typesToShow.push(CaseTypes.Mute); + if (unmutes) typesToShow.push(CaseTypes.Unmute); + if (kicks) typesToShow.push(CaseTypes.Kick); + if (bans) typesToShow.push(CaseTypes.Ban); + if (unbans) typesToShow.push(CaseTypes.Unban); + + if (typesToShow.length === 0) { + typesToShow = allTypes; + } else { + if (reverseFilters) { + typesToShow = allTypes.filter((t) => !typesToShow.includes(t)); + } + } + + user + ? await casesUserCmd( + pluginData, + context, + author.user, + modId!, + user, + modName, + typesToShow, + hidden, + expand, + show === true, + ) + : await casesModCmd( + pluginData, + context, + author.user, + modId!, + mod ?? author, + modName, + typesToShow, + hidden, + expand, + show === true, + ); +} diff --git a/backend/src/plugins/ModActions/commands/constants.ts b/backend/src/plugins/ModActions/commands/constants.ts new file mode 100644 index 00000000..624f9de3 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/constants.ts @@ -0,0 +1,2 @@ +export const NUMBER_ATTACHMENTS_CASE_CREATION = 1; +export const NUMBER_ATTACHMENTS_CASE_UPDATE = 3; diff --git a/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts new file mode 100644 index 00000000..5d109553 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseMsgCmd.ts @@ -0,0 +1,25 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMessageMember } from "../../../../pluginUtils.js"; +import { trimLines } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualDeleteCaseCmd } from "./actualDeleteCaseCmd.js"; + +export const DeleteCaseMsgCmd = modActionsMsgCmd({ + trigger: ["delete_case", "deletecase"], + permission: "can_deletecase", + description: trimLines(` + Delete the specified case. This operation can *not* be reversed. + It is generally recommended to use \`!hidecase\` instead when possible. + `), + + signature: { + caseNumber: ct.number({ rest: true }), + + force: ct.switchOption({ def: false, shortcut: "f" }), + }, + + async run({ pluginData, message, args }) { + const member = await resolveMessageMember(message); + actualDeleteCaseCmd(pluginData, message, member, args.caseNumber, args.force); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts new file mode 100644 index 00000000..b324c6e3 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/DeleteCaseSlashCmd.ts @@ -0,0 +1,31 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualDeleteCaseCmd } from "./actualDeleteCaseCmd.js"; + +const opts = [slashOptions.boolean({ name: "force", description: "Whether or not to force delete", required: false })]; + +export const DeleteCaseSlashCmd = modActionsSlashCmd({ + name: "deletecase", + configPermission: "can_deletecase", + description: "Delete the specified case. This operation can *not* be reversed.", + allowDms: false, + + signature: [ + slashOptions.string({ name: "case-number", description: "The number of the case to delete", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + + actualDeleteCaseCmd( + pluginData, + interaction, + interaction.member as GuildMember, + options["case-number"].split(/\D+/).map(Number), + !!options.force, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts b/backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts new file mode 100644 index 00000000..6d01afa9 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/deletecase/actualDeleteCaseCmd.ts @@ -0,0 +1,93 @@ +import { ChatInputCommandInteraction, GuildMember, Message } from "discord.js"; +import { GuildPluginData, helpers } from "knub"; +import { Case } from "../../../../data/entities/Case.js"; +import { getContextChannel, sendContextResponse } from "../../../../pluginUtils.js"; +import { SECONDS, renderUsername } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { TimeAndDatePlugin } from "../../../TimeAndDate/TimeAndDatePlugin.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualDeleteCaseCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: GuildMember, + caseNumbers: number[], + force: boolean, +) { + const failed: number[] = []; + const validCases: Case[] = []; + let cancelled = 0; + + for (const num of caseNumbers) { + const theCase = await pluginData.state.cases.findByCaseNumber(num); + if (!theCase) { + failed.push(num); + continue; + } + + validCases.push(theCase); + } + + if (failed.length === caseNumbers.length) { + pluginData.state.common.sendErrorMessage(context, "None of the cases were found!"); + return; + } + + for (const theCase of validCases) { + if (!force) { + const channel = await getContextChannel(context); + if (!channel) { + return; + } + + const cases = pluginData.getPlugin(CasesPlugin); + const embedContent = await cases.getCaseEmbed(theCase); + sendContextResponse(context, { + ...embedContent, + content: "Delete the following case? Answer 'Yes' to continue, 'No' to cancel.", + }); + + const reply = await helpers.waitForReply(pluginData.client, channel, author.id, 15 * SECONDS); + const normalizedReply = (reply?.content || "").toLowerCase().trim(); + if (normalizedReply !== "yes" && normalizedReply !== "y") { + sendContextResponse(context, "Cancelled. Case was not deleted."); + cancelled++; + continue; + } + } + + const deletedByName = renderUsername(author); + + const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin); + const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime")); + + await pluginData.state.cases.softDelete( + theCase.id, + author.id, + deletedByName, + `Case deleted by **${deletedByName}** (\`${author.id}\`) on ${deletedAt}`, + ); + + const logs = pluginData.getPlugin(LogsPlugin); + logs.logCaseDelete({ + mod: author, + case: theCase, + }); + } + + const failedAddendum = + failed.length > 0 + ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` + : ""; + const amt = validCases.length - cancelled; + if (amt === 0) { + pluginData.state.common.sendErrorMessage(context, "All deletions were cancelled, no cases were deleted."); + return; + } + + pluginData.state.common.sendSuccessMessage( + context, + `${amt} case${amt === 1 ? " was" : "s were"} deleted!${failedAddendum}`, + ); +} diff --git a/backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts b/backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts new file mode 100644 index 00000000..d6323098 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/ForceBanMsgCmd.ts @@ -0,0 +1,61 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualForceBanCmd } from "./actualForceBanCmd.js"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const ForceBanMsgCmd = modActionsMsgCmd({ + trigger: "forceban", + permission: "can_ban", + description: "Force-ban the specified user, even if they aren't on the server", + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + // If the user exists as a guild member, make sure we can act on them first + const authorMember = await resolveMessageMember(msg); + const targetMember = await resolveMember(pluginData.client, pluginData.guild, user.id); + if (targetMember && !canActOn(pluginData, authorMember, targetMember)) { + pluginData.state.common.sendErrorMessage(msg, "Cannot forceban this user: insufficient permissions"); + return; + } + + // Make sure the user isn't already banned + const banned = await isBanned(pluginData, user.id); + if (banned) { + pluginData.state.common.sendErrorMessage(msg, `User is already banned`); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + actualForceBanCmd(pluginData, msg, msg.author.id, user, args.reason, [...msg.attachments.values()], mod); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts b/backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts new file mode 100644 index 00000000..61fc9411 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/ForceBanSlashCmd.ts @@ -0,0 +1,68 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualForceBanCmd } from "./actualForceBanCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to ban as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const ForceBanSlashCmd = modActionsSlashCmd({ + name: "forceban", + configPermission: "can_ban", + description: "Force-ban the specified user, even if they aren't on the server", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to ban", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) : null; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualForceBanCmd( + pluginData, + interaction, + interaction.user.id, + options.user, + options.reason ?? "", + attachments, + mod, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forceban/actualForceBanCmd.ts b/backend/src/plugins/ModActions/commands/forceban/actualForceBanCmd.ts new file mode 100644 index 00000000..63495c62 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceban/actualForceBanCmd.ts @@ -0,0 +1,68 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { DAYS, MINUTES, UnknownUser } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualForceBanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + user: User | UnknownUser, + reason: string, + attachments: Array, + mod: GuildMember, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + ignoreEvent(pluginData, IgnoredEventType.Ban, user.id); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); + + try { + // FIXME: Use banUserId()? + await pluginData.guild.bans.create(user.id as Snowflake, { + deleteMessageSeconds: (1 * DAYS) / MINUTES, + reason: formattedReasonWithAttachments ?? undefined, + }); + } catch { + pluginData.state.common.sendErrorMessage(context, "Failed to forceban member"); + return; + } + + // Create a case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: mod.id, + type: CaseTypes.Ban, + reason: formattedReason, + ppId: mod.id !== authorId ? authorId : undefined, + }); + + // Confirm the action + pluginData.state.common.sendSuccessMessage(context, `Member forcebanned (Case #${createdCase.case_number})`); + + // Log the action + pluginData.getPlugin(LogsPlugin).logMemberForceban({ + mod, + userId: user.id, + caseNumber: createdCase.case_number, + reason: formattedReason, + }); + + pluginData.state.events.emit("ban", user.id, formattedReason); +} diff --git a/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts new file mode 100644 index 00000000..129eb9e5 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteMsgCmd.ts @@ -0,0 +1,85 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMuteCmd } from "../mute/actualMuteCmd.js"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), +}; + +export const ForceMuteMsgCmd = modActionsMsgCmd({ + trigger: "forcemute", + permission: "can_mute", + description: "Force-mute the specified user, even if they're not on the server", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const authorMember = await resolveMessageMember(msg); + const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + // Make sure we're allowed to mute this user + if (memberToMute && !canActOn(pluginData, authorMember, memberToMute)) { + pluginData.state.common.sendErrorMessage(msg, "Cannot mute: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualMuteCmd( + pluginData, + msg, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts new file mode 100644 index 00000000..fabdd2ab --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forcemute/ForceMuteSlashCmd.ts @@ -0,0 +1,97 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMuteCmd } from "../mute/actualMuteCmd.js"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the mute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to mute as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const ForceMuteSlashCmd = modActionsSlashCmd({ + name: "forcemute", + configPermission: "can_mute", + description: "Force-mute the specified user, even if they're not on the server", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to mute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualMuteCmd( + pluginData, + interaction, + options.user, + attachments, + mod, + ppId, + convertedTime, + options.reason ?? "", + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts new file mode 100644 index 00000000..b5a7f3c0 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteMsgCmd.ts @@ -0,0 +1,79 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualUnmuteCmd } from "../unmute/actualUnmuteCmd.js"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const ForceUnmuteMsgCmd = modActionsMsgCmd({ + trigger: "forceunmute", + permission: "can_mute", + description: "Force-unmute the specified user, even if they're not on the server", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + // Check if they're muted in the first place + if (!(await pluginData.state.mutes.isMuted(user.id))) { + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: member is not muted"); + return; + } + + const authorMember = await resolveMessageMember(msg); + const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + // Make sure we're allowed to unmute this member + if (memberToUnmute && !canActOn(pluginData, authorMember, memberToUnmute)) { + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + actualUnmuteCmd( + pluginData, + msg, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts new file mode 100644 index 00000000..1fcd135c --- /dev/null +++ b/backend/src/plugins/ModActions/commands/forceunmute/ForceUnmuteSlashCmd.ts @@ -0,0 +1,63 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualUnmuteCmd } from "../unmute/actualUnmuteCmd.js"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the unmute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to unmute as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const ForceUnmuteSlashCmd = modActionsSlashCmd({ + name: "forceunmute", + configPermission: "can_mute", + description: "Force-unmute the specified user, even if they're not on the server", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to unmute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason ?? ""); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts new file mode 100644 index 00000000..ef5c7e7a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/HideCaseMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualHideCaseCmd } from "./actualHideCaseCmd.js"; + +export const HideCaseMsgCmd = modActionsMsgCmd({ + trigger: ["hide", "hidecase", "hide_case"], + permission: "can_hidecase", + description: "Hide the specified case so it doesn't appear in !cases or !info", + + signature: [ + { + caseNum: ct.number({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + actualHideCaseCmd(pluginData, msg, args.caseNum); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts new file mode 100644 index 00000000..114120dc --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/HideCaseSlashCmd.ts @@ -0,0 +1,19 @@ +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualHideCaseCmd } from "./actualHideCaseCmd.js"; + +export const HideCaseSlashCmd = modActionsSlashCmd({ + name: "hidecase", + configPermission: "can_hidecase", + description: "Hide the specified case so it doesn't appear in !cases or !info", + allowDms: false, + + signature: [ + slashOptions.string({ name: "case-number", description: "The number of the case to hide", required: true }), + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + actualHideCaseCmd(pluginData, interaction, options["case-number"].split(/\D+/).map(Number)); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts b/backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts new file mode 100644 index 00000000..1c33efc1 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/hidecase/actualHideCaseCmd.ts @@ -0,0 +1,36 @@ +import { ChatInputCommandInteraction, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualHideCaseCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + caseNumbers: number[], +) { + const failed: number[] = []; + + for (const num of caseNumbers) { + const theCase = await pluginData.state.cases.findByCaseNumber(num); + if (!theCase) { + failed.push(num); + continue; + } + + await pluginData.state.cases.setHidden(theCase.id, true); + } + + if (failed.length === caseNumbers.length) { + pluginData.state.common.sendErrorMessage(context, "None of the cases were found!"); + return; + } + const failedAddendum = + failed.length > 0 + ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` + : ""; + + const amt = caseNumbers.length - failed.length; + pluginData.state.common.sendSuccessMessage( + context, + `${amt} case${amt === 1 ? " is" : "s are"} now hidden! Use \`unhidecase\` to unhide them.${failedAddendum}`, + ); +} diff --git a/backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts b/backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts new file mode 100644 index 00000000..ec7c692b --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/KickMsgCmd.ts @@ -0,0 +1,70 @@ +import { hasPermission } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveUser } from "../../../../utils.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualKickCmd } from "./actualKickCmd.js"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + clean: ct.bool({ option: true, isSwitch: true }), +}; + +export const KickMsgCmd = modActionsMsgCmd({ + trigger: "kick", + permission: "can_kick", + description: "Kick the specified member", + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const authorMember = await resolveMessageMember(msg); + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + if (args.mod) { + if (!(await hasPermission(await pluginData.config.getForMessage(msg), "can_act_as_other"))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualKickCmd( + pluginData, + msg, + authorMember, + user, + args.reason, + [...msg.attachments.values()], + mod, + contactMethods, + args.clean, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts b/backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts new file mode 100644 index 00000000..a020fb5e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/KickSlashCmd.ts @@ -0,0 +1,93 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualKickCmd } from "./actualKickCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to kick as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + slashOptions.boolean({ + name: "clean", + description: "Whether or not to delete the member's last messages", + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const KickSlashCmd = modActionsSlashCmd({ + name: "kick", + configPermission: "can_kick", + description: "Kick the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to kick", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualKickCmd( + pluginData, + interaction, + interaction.member as GuildMember, + options.user, + options.reason || "", + attachments, + mod, + contactMethods, + options.clean, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts b/backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts new file mode 100644 index 00000000..a51e9a4f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/kick/actualKickCmd.ts @@ -0,0 +1,98 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { LogType } from "../../../../data/LogType.js"; +import { canActOn } from "../../../../pluginUtils.js"; +import { + DAYS, + SECONDS, + UnknownUser, + UserNotificationMethod, + renderUsername, + resolveMember, +} from "../../../../utils.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { kickMember } from "../../functions/kickMember.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualKickCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: GuildMember, + user: User | UnknownUser, + reason: string, + attachments: Attachment[], + mod: GuildMember, + contactMethods?: UserNotificationMethod[], + clean?: boolean | null, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToKick) { + const banned = await isBanned(pluginData, user.id); + if (banned) { + pluginData.state.common.sendErrorMessage(context, `User is banned`); + } else { + pluginData.state.common.sendErrorMessage(context, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to kick this member + if (!canActOn(pluginData, author, memberToKick)) { + pluginData.state.common.sendErrorMessage(context, "Cannot kick: insufficient permissions"); + return; + } + + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + const kickResult = await kickMember(pluginData, memberToKick, formattedReason, formattedReasonWithAttachments, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== author.id ? author.id : undefined, + }, + }); + + if (clean) { + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id); + ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id); + + try { + await memberToKick.ban({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: "kick -clean" }); + } catch { + pluginData.state.common.sendErrorMessage(context, "Failed to ban the user to clean messages (-clean)"); + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id); + ignoreEvent(pluginData, IgnoredEventType.Unban, memberToKick.id); + + try { + await pluginData.guild.bans.remove(memberToKick.id, "kick -clean"); + } catch { + pluginData.state.common.sendErrorMessage(context, "Failed to unban the user after banning them (-clean)"); + } + } + + if (kickResult.status === "failed") { + pluginData.state.common.sendErrorMessage(context, `Failed to kick user`); + return; + } + + // Confirm the action to the moderator + let response = `Kicked **${renderUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`; + + if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; + pluginData.state.common.sendSuccessMessage(context, response); +} diff --git a/backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts b/backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts new file mode 100644 index 00000000..438f088e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/MassBanMsgCmd.ts @@ -0,0 +1,33 @@ +import { waitForReply } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMessageMember } from "../../../../pluginUtils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMassBanCmd } from "./actualMassBanCmd.js"; + +export const MassBanMsgCmd = modActionsMsgCmd({ + trigger: "massban", + permission: "can_massban", + description: "Mass-ban a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + // Ask for ban reason (cleaner this way instead of trying to cram it into the args) + msg.reply("Ban reason? `cancel` to cancel"); + const banReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id); + + if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") { + pluginData.state.common.sendErrorMessage(msg, "Cancelled"); + return; + } + + const authorMember = await resolveMessageMember(msg); + actualMassBanCmd(pluginData, msg, args.userIds, authorMember, banReasonReply.content, [ + ...banReasonReply.attachments.values(), + ]); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts b/backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts new file mode 100644 index 00000000..df89a726 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/MassBanSlashCmd.ts @@ -0,0 +1,47 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMassBanCmd } from "./actualMassBanCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const MassBanSlashCmd = modActionsSlashCmd({ + name: "massban", + configPermission: "can_massban", + description: "Mass-ban a list of user IDs", + allowDms: false, + + signature: [ + slashOptions.string({ name: "user-ids", description: "The list of user IDs to ban", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualMassBanCmd( + pluginData, + interaction, + options["user-ids"].split(/\D+/), + interaction.member as GuildMember, + options.reason || "", + attachments, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts b/backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts new file mode 100644 index 00000000..7ab0d46e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massban/actualMassBanCmd.ts @@ -0,0 +1,165 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { humanizeDurationShort } from "../../../../humanizeDuration.js"; +import { + canActOn, + deleteContextResponse, + editContextResponse, + getConfigForContext, + isContextInteraction, + sendContextResponse, +} from "../../../../pluginUtils.js"; +import { DAYS, MINUTES, SECONDS, noop } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualMassBanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, + reason: string, + attachments: Attachment[], +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + pluginData.state.common.sendErrorMessage(context, `Can only massban max 100 users at once`); + return; + } + + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const banReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const banReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + // Verify we can act on each of the users specified + for (const userId of userIds) { + const member = pluginData.guild.members.cache.get(userId as Snowflake); // TODO: Get members on demand? + if (member && !canActOn(pluginData, author, member)) { + pluginData.state.common.sendErrorMessage(context, "Cannot massban one or more users: insufficient permissions"); + return; + } + } + + // Show a loading indicator since this can take a while + const maxWaitTime = pluginData.state.massbanQueue.timeout * pluginData.state.massbanQueue.length; + const maxWaitTimeFormatted = humanizeDurationShort(maxWaitTime, { round: true }); + const initialLoadingText = + pluginData.state.massbanQueue.length === 0 + ? "Banning..." + : `Massban queued. Waiting for previous massban to finish (max wait ${maxWaitTimeFormatted}).`; + const loadingMsg = await sendContextResponse(context, initialLoadingText, true); + + const waitTimeStart = performance.now(); + const waitingInterval = setInterval(() => { + const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true }); + const waitMessageContent = `Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`; + + editContextResponse(loadingMsg, waitMessageContent).catch(() => clearInterval(waitingInterval)); + }, 1 * MINUTES); + + pluginData.state.massbanQueue.add(async () => { + clearInterval(waitingInterval); + + if (pluginData.state.unloaded) { + await deleteContextResponse(loadingMsg); + return; + } + + editContextResponse(loadingMsg, "Banning...").catch(noop); + + // Ban each user and count failed bans (if any) + const startTime = performance.now(); + const failedBans: string[] = []; + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const messageConfig = await getConfigForContext(pluginData.config, context); + const deleteDays = messageConfig.ban_delete_message_days; + + for (const [i, userId] of userIds.entries()) { + if (pluginData.state.unloaded) { + break; + } + + try { + // Ignore automatic ban cases and logs + // We create our own cases below and post a single "mass banned" log instead + ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 30 * MINUTES); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 30 * MINUTES); + + await pluginData.guild.bans.create(userId as Snowflake, { + deleteMessageSeconds: (deleteDays * DAYS) / SECONDS, + reason: banReasonWithAttachments, + }); + + await casesPlugin.createCase({ + userId, + modId: author.id, + type: CaseTypes.Ban, + reason: `Mass ban: ${banReason}`, + postInCaseLogOverride: false, + }); + + pluginData.state.events.emit("ban", userId, banReason); + } catch { + failedBans.push(userId); + } + + // Send a status update every 10 bans + if ((i + 1) % 10 === 0) { + const newLoadingMessageContent = `Banning... ${i + 1}/${userIds.length}`; + + if (isContextInteraction(context)) { + void context.editReply(newLoadingMessageContent).catch(noop); + } else { + loadingMsg.edit(newLoadingMessageContent).catch(noop); + } + } + } + + const totalTime = performance.now() - startTime; + const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true }); + + if (!isContextInteraction(context)) { + // Clear loading indicator + loadingMsg.delete().catch(noop); + } + + const successfulBanCount = userIds.length - failedBans.length; + if (successfulBanCount === 0) { + // All bans failed - don't create a log entry and notify the user + pluginData.state.common.sendErrorMessage(context, "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.getPlugin(LogsPlugin).logMassBan({ + mod: author.user, + count: successfulBanCount, + reason: banReason, + }); + + if (failedBans.length) { + pluginData.state.common.sendSuccessMessage( + context, + `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${failedBans.length} failed: ${failedBans.join( + " ", + )}`, + ); + } else { + pluginData.state.common.sendSuccessMessage( + context, + `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, + ); + } + } + }); +} diff --git a/backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts new file mode 100644 index 00000000..fe96490a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/MassMuteMsgCmd.ts @@ -0,0 +1,36 @@ +import { waitForReply } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMessageMember } from "../../../../pluginUtils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMassMuteCmd } from "./actualMassMuteCmd.js"; + +export const MassMuteMsgCmd = modActionsMsgCmd({ + trigger: "massmute", + permission: "can_massmute", + description: "Mass-mute a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + // Ask for mute reason + msg.reply("Mute reason? `cancel` to cancel"); + const muteReasonReceived = await waitForReply(pluginData.client, msg.channel, msg.author.id); + if ( + !muteReasonReceived || + !muteReasonReceived.content || + muteReasonReceived.content.toLowerCase().trim() === "cancel" + ) { + pluginData.state.common.sendErrorMessage(msg, "Cancelled"); + return; + } + + const member = await resolveMessageMember(msg); + actualMassMuteCmd(pluginData, msg, args.userIds, member, muteReasonReceived.content, [ + ...muteReasonReceived.attachments.values(), + ]); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts new file mode 100644 index 00000000..eabab03e --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/MassMuteSlashCmd.ts @@ -0,0 +1,47 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMassMuteCmd } from "./actualMassMuteCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const MassMuteSlashSlashCmd = modActionsSlashCmd({ + name: "massmute", + configPermission: "can_massmute", + description: "Mass-mute a list of user IDs", + allowDms: false, + + signature: [ + slashOptions.string({ name: "user-ids", description: "The list of user IDs to mute", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualMassMuteCmd( + pluginData, + interaction, + options["user-ids"].split(/\D+/), + interaction.member as GuildMember, + options.reason || "", + attachments, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts b/backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts new file mode 100644 index 00000000..3c6d109b --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massmute/actualMassMuteCmd.ts @@ -0,0 +1,98 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { LogType } from "../../../../data/LogType.js"; +import { logger } from "../../../../logger.js"; +import { canActOn, deleteContextResponse, isContextInteraction, sendContextResponse } from "../../../../pluginUtils.js"; +import { noop } from "../../../../utils.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualMassMuteCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, + reason: string, + attachments: Attachment[], +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + pluginData.state.common.sendErrorMessage(context, `Can only massmute max 100 users at once`); + return; + } + + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const muteReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const muteReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + // Verify we can act upon all users + for (const userId of userIds) { + const member = pluginData.guild.members.cache.get(userId as Snowflake); + if (member && !canActOn(pluginData, author, member)) { + pluginData.state.common.sendErrorMessage(context, "Cannot massmute one or more users: insufficient permissions"); + return; + } + } + + // Ignore automatic mute cases and logs for these users + // We'll create our own cases below and post a single "mass muted" log instead + userIds.forEach((userId) => { + // Use longer timeouts since this can take a while + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000); + }); + + // Show loading indicator + const loadingMsg = await sendContextResponse(context, "Muting...", true); + + // Mute everyone and count fails + const modId = author.id; + const failedMutes: string[] = []; + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + for (const userId of userIds) { + try { + await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, `Mass mute: ${muteReasonWithAttachments}`, { + caseArgs: { + modId, + }, + }); + } catch (e) { + logger.info(e); + failedMutes.push(userId); + } + } + + if (!isContextInteraction(context)) { + // Clear loading indicator + deleteContextResponse(loadingMsg).catch(noop); + } + + const successfulMuteCount = userIds.length - failedMutes.length; + if (successfulMuteCount === 0) { + // All mutes failed + pluginData.state.common.sendErrorMessage(context, "All mutes failed. Make sure the IDs are valid."); + } else { + // Success on all or some mutes + pluginData.getPlugin(LogsPlugin).logMassMute({ + mod: author.user, + count: successfulMuteCount, + }); + + if (failedMutes.length) { + pluginData.state.common.sendSuccessMessage( + context, + `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`, + ); + } else { + pluginData.state.common.sendSuccessMessage(context, `Muted ${successfulMuteCount} users successfully`); + } + } +} diff --git a/backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts b/backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts new file mode 100644 index 00000000..c7166675 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/MassUnbanMsgCmd.ts @@ -0,0 +1,32 @@ +import { waitForReply } from "knub/helpers"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveMessageMember } from "../../../../pluginUtils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMassUnbanCmd } from "./actualMassUnbanCmd.js"; + +export const MassUnbanMsgCmd = modActionsMsgCmd({ + trigger: "massunban", + permission: "can_massunban", + description: "Mass-unban a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + // Ask for unban reason (cleaner this way instead of trying to cram it into the args) + msg.reply("Unban reason? `cancel` to cancel"); + const unbanReasonReply = await waitForReply(pluginData.client, msg.channel, msg.author.id); + if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") { + pluginData.state.common.sendErrorMessage(msg, "Cancelled"); + return; + } + + const member = await resolveMessageMember(msg); + actualMassUnbanCmd(pluginData, msg, args.userIds, member, unbanReasonReply.content, [ + ...unbanReasonReply.attachments.values(), + ]); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts b/backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts new file mode 100644 index 00000000..57e69fad --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/MassUnbanSlashCmd.ts @@ -0,0 +1,47 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMassUnbanCmd } from "./actualMassUnbanCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const MassUnbanSlashCmd = modActionsSlashCmd({ + name: "massunban", + configPermission: "can_massunban", + description: "Mass-unban a list of user IDs", + allowDms: false, + + signature: [ + slashOptions.string({ name: "user-ids", description: "The list of user IDs to unban", required: true }), + + ...opts, + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualMassUnbanCmd( + pluginData, + interaction, + options["user-ids"].split(/[\s,\r\n]+/), + interaction.member as GuildMember, + options.reason || "", + attachments, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts b/backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts new file mode 100644 index 00000000..9a132bf5 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/massunban/actualMassUnbanCmd.ts @@ -0,0 +1,118 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { deleteContextResponse, isContextInteraction, sendContextResponse } from "../../../../pluginUtils.js"; +import { MINUTES, noop } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualMassUnbanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + userIds: string[], + author: GuildMember, + reason: string, + attachments: Attachment[], +) { + // Limit to 100 users at once (arbitrary?) + if (userIds.length > 100) { + pluginData.state.common.sendErrorMessage(context, `Can only mass-unban max 100 users at once`); + return; + } + + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const unbanReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + + // Ignore automatic unban cases and logs for these users + // We'll create our own cases below and post a single "mass unbanned" log instead + userIds.forEach((userId) => { + // Use longer timeouts since this can take a while + ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 2 * MINUTES); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 2 * MINUTES); + }); + + // Show a loading indicator since this can take a while + const loadingMsg = await sendContextResponse(context, { content: "Unbanning...", ephemeral: true }); + + // Unban each user and count failed unbans (if any) + const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = []; + const casesPlugin = pluginData.getPlugin(CasesPlugin); + for (const userId of userIds) { + if (!(await isBanned(pluginData, userId))) { + failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED }); + continue; + } + + try { + await pluginData.guild.bans.remove(userId as Snowflake, unbanReason ?? undefined); + + await casesPlugin.createCase({ + userId, + modId: author.id, + type: CaseTypes.Unban, + reason: `Mass unban: ${unbanReason}`, + postInCaseLogOverride: false, + }); + } catch { + failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED }); + } + } + + if (!isContextInteraction(context)) { + // Clear loading indicator + await deleteContextResponse(loadingMsg).catch(noop); + } + + const successfulUnbanCount = userIds.length - failedUnbans.length; + if (successfulUnbanCount === 0) { + // All unbans failed - don't create a log entry and notify the user + pluginData.state.common.sendErrorMessage(context, "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.getPlugin(LogsPlugin).logMassUnban({ + mod: author.user, + 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); + + let failedMsg = ""; + if (notBanned.length > 0) { + failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`; + notBanned.forEach((fail) => { + failedMsg += " " + fail.userId; + }); + } + if (unbanFailed.length > 0) { + failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`; + unbanFailed.forEach((fail) => { + failedMsg += " " + fail.userId; + }); + } + + pluginData.state.common.sendSuccessMessage( + context, + `Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`, + ); + } else { + pluginData.state.common.sendSuccessMessage(context, `Unbanned ${successfulUnbanCount} users successfully`); + } + } +} + +enum UnbanFailReasons { + NOT_BANNED = "Not banned", + UNBAN_FAILED = "Unban failed", +} diff --git a/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts new file mode 100644 index 00000000..07b23c62 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/mute/MuteMsgCmd.ts @@ -0,0 +1,111 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualMuteCmd } from "./actualMuteCmd.js"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), +}; + +export const MuteMsgCmd = modActionsMsgCmd({ + trigger: "mute", + permission: "can_mute", + description: "Mute the specified member", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const authorMember = await resolveMessageMember(msg); + const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToMute) { + const _isBanned = await isBanned(pluginData, user.id); + const prefix = pluginData.fullConfig.prefix; + if (_isBanned) { + pluginData.state.common.sendErrorMessage( + msg, + `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, + ); + return; + } else { + // Ask the mod if we should upgrade to a forcemute as the user is not on the server + const reply = await waitForButtonConfirm( + msg, + { content: "User not found on the server, forcemute instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: authorMember.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(msg, "User not on server, mute cancelled by moderator"); + return; + } + } + } + + // Make sure we're allowed to mute this member + if (memberToMute && !canActOn(pluginData, authorMember, memberToMute)) { + pluginData.state.common.sendErrorMessage(msg, "Cannot mute: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualMuteCmd( + pluginData, + msg, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts new file mode 100644 index 00000000..a8378f76 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/mute/MuteSlashCmd.ts @@ -0,0 +1,124 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualMuteCmd } from "./actualMuteCmd.js"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the mute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to mute as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const MuteSlashCmd = modActionsSlashCmd({ + name: "mute", + configPermission: "can_mute", + description: "Mute the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to mute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + const memberToMute = await resolveMember(pluginData.client, pluginData.guild, options.user.id); + + if (!memberToMute) { + const _isBanned = await isBanned(pluginData, options.user.id); + const prefix = pluginData.fullConfig.prefix; + if (_isBanned) { + pluginData.state.common.sendErrorMessage( + interaction, + `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, + ); + return; + } else { + // Ask the mod if we should upgrade to a forcemute as the user is not on the server + const reply = await waitForButtonConfirm( + interaction, + { content: "User not found on the server, forcemute instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: interaction.user.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(interaction, "User not on server, mute cancelled by moderator"); + return; + } + } + } + + // Make sure we're allowed to mute this member + if (memberToMute && !canActOn(pluginData, interaction.member as GuildMember, memberToMute)) { + pluginData.state.common.sendErrorMessage(interaction, "Cannot mute: insufficient permissions"); + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualMuteCmd( + pluginData, + interaction, + options.user, + attachments, + mod, + ppId, + convertedTime, + options.reason, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts b/backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts new file mode 100644 index 00000000..736bd42b --- /dev/null +++ b/backend/src/plugins/ModActions/commands/mute/actualMuteCmd.ts @@ -0,0 +1,108 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ERRORS, RecoverablePluginError } from "../../../../RecoverablePluginError.js"; +import { humanizeDuration } from "../../../../humanizeDuration.js"; +import { logger } from "../../../../logger.js"; +import { + UnknownUser, + UserNotificationMethod, + asSingleLine, + isDiscordAPIError, + renderUsername, +} from "../../../../utils.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { MuteResult } from "../../../Mutes/types.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +/** + * The actual function run by both !mute and !forcemute. + * The only difference between the two commands is in target member validation. + */ +export async function actualMuteCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + user: User | UnknownUser, + attachments: Attachment[], + mod: GuildMember, + ppId?: string, + time?: number, + reason?: string | null, + contactMethods?: UserNotificationMethod[], +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const timeUntilUnmute = time && humanizeDuration(time); + const formattedReason = + reason || attachments.length > 0 + ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? "", context, attachments) + : undefined; + const formattedReasonWithAttachments = + reason || attachments.length > 0 ? formatReasonWithAttachments(reason ?? "", attachments) : undefined; + + let muteResult: MuteResult; + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + + try { + muteResult = await mutesPlugin.muteUser(user.id, time, formattedReason, formattedReasonWithAttachments, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId, + }, + }); + } catch (e) { + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + pluginData.state.common.sendErrorMessage(context, "Could not mute the user: no mute role set in config"); + } else if (isDiscordAPIError(e) && e.code === 10007) { + pluginData.state.common.sendErrorMessage(context, "Could not mute the user: unknown member"); + } else { + logger.error(`Failed to mute user ${user.id}: ${e.stack}`); + if (user.id == null) { + // FIXME: Debug + // tslint:disable-next-line:no-console + console.trace("[DEBUG] Null user.id for mute"); + } + pluginData.state.common.sendErrorMessage(context, "Could not mute the user"); + } + + return; + } + + // Confirm the action to the moderator + let response: string; + if (time) { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${renderUsername(user)}**'s + mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${renderUsername(user)}** + for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } + } else { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${renderUsername(user)}**'s + mute to indefinite (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${renderUsername(user)}** + indefinitely (Case #${muteResult.case.case_number}) + `); + } + } + + if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; + pluginData.state.common.sendSuccessMessage(context, response); +} diff --git a/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts b/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts new file mode 100644 index 00000000..9049f981 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/note/NoteMsgCmd.ts @@ -0,0 +1,30 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualNoteCmd } from "./actualNoteCmd.js"; + +export const NoteMsgCmd = modActionsMsgCmd({ + trigger: "note", + permission: "can_note", + description: "Add a note to the specified user", + + signature: { + user: ct.string(), + note: ct.string({ required: false, catchAll: true }), + }, + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + if (!args.note && msg.attachments.size === 0) { + pluginData.state.common.sendErrorMessage(msg, "Text or attachment required"); + return; + } + + actualNoteCmd(pluginData, msg, msg.author, [...msg.attachments.values()], user, args.note || ""); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts b/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts new file mode 100644 index 00000000..507df72a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/note/NoteSlashCmd.ts @@ -0,0 +1,35 @@ +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualNoteCmd } from "./actualNoteCmd.js"; + +const opts = [ + slashOptions.string({ name: "note", description: "The note to add to the user", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the note", + }), +]; + +export const NoteSlashCmd = modActionsSlashCmd({ + name: "note", + configPermission: "can_note", + description: "Add a note to the specified user", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to add a note to", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.note || options.note.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + actualNoteCmd(pluginData, interaction, interaction.user, attachments, options.user, options.note || ""); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts b/backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts new file mode 100644 index 00000000..8914a524 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/note/actualNoteCmd.ts @@ -0,0 +1,50 @@ +import { Attachment, ChatInputCommandInteraction, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { UnknownUser, renderUsername } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualNoteCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: User, + attachments: Array, + user: User | UnknownUser, + note: string, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) { + return; + } + + const userName = renderUsername(user); + const reason = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, attachments); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: author.id, + type: CaseTypes.Note, + reason, + }); + + pluginData.getPlugin(LogsPlugin).logMemberNote({ + mod: author, + user, + caseNumber: createdCase.case_number, + reason, + }); + + pluginData.state.common.sendSuccessMessage( + context, + `Note added on **${userName}** (Case #${createdCase.case_number})`, + undefined, + undefined, + true, + ); + + pluginData.state.events.emit("note", user.id, reason); +} diff --git a/backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts b/backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts new file mode 100644 index 00000000..053427c5 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/UnbanMsgCmd.ts @@ -0,0 +1,47 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveUser } from "../../../../utils.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualUnbanCmd } from "./actualUnbanCmd.js"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const UnbanMsgCmd = modActionsMsgCmd({ + trigger: "unban", + permission: "can_unban", + description: "Unban the specified member", + + signature: [ + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const authorMember = await resolveMessageMember(msg); + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + } + + actualUnbanCmd(pluginData, msg, msg.author.id, user, args.reason, [...msg.attachments.values()], mod); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts b/backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts new file mode 100644 index 00000000..7c910e53 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/UnbanSlashCmd.ts @@ -0,0 +1,54 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { hasPermission } from "../../../../pluginUtils.js"; +import { resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualUnbanCmd } from "./actualUnbanCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to unban as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const UnbanSlashCmd = modActionsSlashCmd({ + name: "unban", + configPermission: "can_unban", + description: "Unban the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to unban", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + actualUnbanCmd(pluginData, interaction, interaction.user.id, options.user, options.reason ?? "", attachments, mod); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts b/backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts new file mode 100644 index 00000000..e984a944 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unban/actualUnbanCmd.ts @@ -0,0 +1,67 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, Snowflake, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { LogType } from "../../../../data/LogType.js"; +import { clearExpiringTempban } from "../../../../data/loops/expiringTempbansLoop.js"; +import { UnknownUser } from "../../../../utils.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { LogsPlugin } from "../../../Logs/LogsPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ignoreEvent } from "../../functions/ignoreEvent.js"; +import { IgnoredEventType, ModActionsPluginType } from "../../types.js"; + +export async function actualUnbanCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + user: User | UnknownUser, + reason: string, + attachments: Array, + mod: GuildMember, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + + try { + ignoreEvent(pluginData, IgnoredEventType.Unban, user.id); + await pluginData.guild.bans.remove(user.id as Snowflake, formattedReason ?? undefined); + } catch { + pluginData.state.common.sendErrorMessage(context, "Failed to unban member; are you sure they're banned?"); + return; + } + + // Create a case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: mod.id, + type: CaseTypes.Unban, + reason: formattedReason, + ppId: mod.id !== authorId ? authorId : undefined, + }); + + // Delete the tempban, if one exists + const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id); + if (tempban) { + clearExpiringTempban(tempban); + await pluginData.state.tempbans.clear(user.id); + } + + // Confirm the action + pluginData.state.common.sendSuccessMessage(context, `Member unbanned (Case #${createdCase.case_number})`); + + // Log the action + pluginData.getPlugin(LogsPlugin).logMemberUnban({ + mod: mod.user, + userId: user.id, + caseNumber: createdCase.case_number, + reason: formattedReason ?? "", + }); + + pluginData.state.events.emit("unban", user.id); +} diff --git a/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts new file mode 100644 index 00000000..1fddfeef --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseMsgCmd.ts @@ -0,0 +1,19 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualHideCaseCmd } from "../hidecase/actualHideCaseCmd.js"; + +export const UnhideCaseMsgCmd = modActionsMsgCmd({ + trigger: ["unhide", "unhidecase", "unhide_case"], + permission: "can_hidecase", + description: "Un-hide the specified case, making it appear in !cases and !info again", + + signature: [ + { + caseNum: ct.number({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + actualHideCaseCmd(pluginData, msg, args.caseNum); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts new file mode 100644 index 00000000..1b29fac6 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/UnhideCaseSlashCmd.ts @@ -0,0 +1,19 @@ +import { slashOptions } from "knub"; +import { modActionsSlashCmd } from "../../types.js"; +import { actualUnhideCaseCmd } from "./actualUnhideCaseCmd.js"; + +export const UnhideCaseSlashCmd = modActionsSlashCmd({ + name: "unhidecase", + configPermission: "can_hidecase", + description: "Un-hide the specified case", + allowDms: false, + + signature: [ + slashOptions.string({ name: "case-number", description: "The number of the case to unhide", required: true }), + ], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + actualUnhideCaseCmd(pluginData, interaction, options["case-number"].split(/\D+/).map(Number)); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts b/backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts new file mode 100644 index 00000000..d270107a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unhidecase/actualUnhideCaseCmd.ts @@ -0,0 +1,37 @@ +import { ChatInputCommandInteraction, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualUnhideCaseCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + caseNumbers: number[], +) { + const failed: number[] = []; + + for (const num of caseNumbers) { + const theCase = await pluginData.state.cases.findByCaseNumber(num); + if (!theCase) { + failed.push(num); + continue; + } + + await pluginData.state.cases.setHidden(theCase.id, false); + } + + if (failed.length === caseNumbers.length) { + pluginData.state.common.sendErrorMessage(context, "None of the cases were found!"); + return; + } + + const failedAddendum = + failed.length > 0 + ? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}` + : ""; + + const amt = caseNumbers.length - failed.length; + pluginData.state.common.sendSuccessMessage( + context, + `${amt} case${amt === 1 ? " is" : "s are"} no longer hidden!${failedAddendum}`, + ); +} diff --git a/backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts b/backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts new file mode 100644 index 00000000..6c2887dd --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unmute/UnmuteMsgCmd.ts @@ -0,0 +1,112 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; +import { resolveMember, resolveUser } from "../../../../utils.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualUnmuteCmd } from "./actualUnmuteCmd.js"; + +const opts = { + mod: ct.member({ option: true }), +}; + +export const UnmuteMsgCmd = modActionsMsgCmd({ + trigger: "unmute", + permission: "can_mute", + description: "Unmute the specified member", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const authorMember = await resolveMessageMember(msg); + const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); + + // Check if they're muted in the first place + if ( + !(await pluginData.state.mutes.isMuted(user.id)) && + !hasMuteRole && + !memberToUnmute?.isCommunicationDisabled() + ) { + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: member is not muted"); + return; + } + + if (!memberToUnmute) { + const banned = await isBanned(pluginData, user.id); + const prefix = pluginData.fullConfig.prefix; + if (banned) { + pluginData.state.common.sendErrorMessage( + msg, + `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, + ); + return; + } else { + // Ask the mod if we should upgrade to a forceunmute as the user is not on the server + const reply = await waitForButtonConfirm( + msg, + { content: "User not on server, forceunmute instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: authorMember.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(msg, "User not on server, unmute cancelled by moderator"); + return; + } + } + } + + // Make sure we're allowed to unmute this member + if (memberToUnmute && !canActOn(pluginData, authorMember, memberToUnmute)) { + pluginData.state.common.sendErrorMessage(msg, "Cannot unmute: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + let ppId: string | undefined; + + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + pluginData.state.common.sendErrorMessage(msg, "You don't have permission to use -mod"); + return; + } + + mod = args.mod; + ppId = msg.author.id; + } + + actualUnmuteCmd( + pluginData, + msg, + user, + [...msg.attachments.values()], + mod, + ppId, + "time" in args ? args.time ?? undefined : undefined, + args.reason, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts b/backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts new file mode 100644 index 00000000..6d3be623 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unmute/UnmuteSlashCmd.ts @@ -0,0 +1,110 @@ +import { GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { convertDelayStringToMS, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualUnmuteCmd } from "./actualUnmuteCmd.js"; + +const opts = [ + slashOptions.string({ name: "time", description: "The duration of the unmute", required: false }), + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to unmute as", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const UnmuteSlashCmd = modActionsSlashCmd({ + name: "unmute", + configPermission: "can_mute", + description: "Unmute the specified member", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to unmute", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + pluginData.state.common.sendErrorMessage(interaction, "Text or attachment required", undefined, undefined, true); + + return; + } + + const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, options.user.id); + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); + + // Check if they're muted in the first place + if ( + !(await pluginData.state.mutes.isMuted(options.user.id)) && + !hasMuteRole && + !memberToUnmute?.isCommunicationDisabled() + ) { + pluginData.state.common.sendErrorMessage(interaction, "Cannot unmute: member is not muted"); + return; + } + + if (!memberToUnmute) { + const banned = await isBanned(pluginData, options.user.id); + const prefix = pluginData.fullConfig.prefix; + if (banned) { + pluginData.state.common.sendErrorMessage( + interaction, + `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`, + ); + return; + } else { + // Ask the mod if we should upgrade to a forceunmute as the user is not on the server + const reply = await waitForButtonConfirm( + interaction, + { content: "User not on server, forceunmute instead?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: interaction.user.id }, + ); + + if (!reply) { + pluginData.state.common.sendErrorMessage(interaction, "User not on server, unmute cancelled by moderator"); + return; + } + } + } + + // Make sure we're allowed to unmute this member + if (memberToUnmute && !canActOn(pluginData, interaction.member as GuildMember, memberToUnmute)) { + pluginData.state.common.sendErrorMessage(interaction, "Cannot unmute: insufficient permissions"); + return; + } + + let mod = interaction.member as GuildMember; + let ppId: string | undefined; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + pluginData.state.common.sendErrorMessage(interaction, "You don't have permission to act as another moderator"); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + ppId = interaction.user.id; + } + + const convertedTime = options.time ? convertDelayStringToMS(options.time) ?? undefined : undefined; + if (options.time && !convertedTime) { + pluginData.state.common.sendErrorMessage(interaction, `Could not convert ${options.time} to a delay`); + return; + } + + actualUnmuteCmd(pluginData, interaction, options.user, attachments, mod, ppId, convertedTime, options.reason); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts b/backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts new file mode 100644 index 00000000..cc657ebd --- /dev/null +++ b/backend/src/plugins/ModActions/commands/unmute/actualUnmuteCmd.ts @@ -0,0 +1,60 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { humanizeDuration } from "../../../../humanizeDuration.js"; +import { UnknownUser, asSingleLine, renderUsername } from "../../../../utils.js"; +import { MutesPlugin } from "../../../Mutes/MutesPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "../../functions/formatReasonForAttachments.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualUnmuteCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + user: User | UnknownUser, + attachments: Array, + mod: GuildMember, + ppId?: string, + time?: number, + reason?: string | null, +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const formattedReason = + reason || attachments.length > 0 + ? await formatReasonWithMessageLinkForAttachments(pluginData, reason ?? "", context, attachments) + : undefined; + + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const result = await mutesPlugin.unmuteUser(user.id, time, { + modId: mod.id, + ppId: ppId ?? undefined, + reason: formattedReason, + }); + + if (!result) { + pluginData.state.common.sendErrorMessage(context, "User is not muted!"); + return; + } + + // Confirm the action to the moderator + if (time) { + const timeUntilUnmute = time && humanizeDuration(time); + pluginData.state.common.sendSuccessMessage( + context, + asSingleLine(` + Unmuting **${renderUsername(user)}** + in ${timeUntilUnmute} (Case #${result.case.case_number}) + `), + ); + } else { + pluginData.state.common.sendSuccessMessage( + context, + asSingleLine(` + Unmuted **${renderUsername(user)}** + (Case #${result.case.case_number}) + `), + ); + } +} diff --git a/backend/src/plugins/ModActions/commands/UpdateCmd.ts b/backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts similarity index 57% rename from backend/src/plugins/ModActions/commands/UpdateCmd.ts rename to backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts index 72e46fde..9e84b83f 100644 --- a/backend/src/plugins/ModActions/commands/UpdateCmd.ts +++ b/backend/src/plugins/ModActions/commands/update/UpdateMsgCmd.ts @@ -1,8 +1,8 @@ -import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { updateCase } from "../functions/updateCase.js"; -import { modActionsCmd } from "../types.js"; +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { updateCase } from "../../functions/updateCase.js"; +import { modActionsMsgCmd } from "../../types.js"; -export const UpdateCmd = modActionsCmd({ +export const UpdateMsgCmd = modActionsMsgCmd({ trigger: ["update", "reason"], permission: "can_note", description: @@ -19,6 +19,6 @@ export const UpdateCmd = modActionsCmd({ ], async run({ pluginData, message: msg, args }) { - await updateCase(pluginData, msg, args); + await updateCase(pluginData, msg, msg.author, args.caseNumber, args.note, [...msg.attachments.values()]); }, }); diff --git a/backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts b/backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts new file mode 100644 index 00000000..a5a664a8 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/update/UpdateSlashCmd.ts @@ -0,0 +1,36 @@ +import { slashOptions } from "knub"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { updateCase } from "../../functions/updateCase.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_UPDATE } from "../constants.js"; + +const opts = [ + slashOptions.string({ name: "case-number", description: "The number of the case to update", required: false }), + slashOptions.string({ name: "reason", description: "The note to add to the case", required: false }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, { + name: "attachment", + description: "An attachment to add to the update", + }), +]; + +export const UpdateSlashCmd = modActionsSlashCmd({ + name: "update", + configPermission: "can_note", + description: "Update the specified case (or your latest case) by adding more notes to it", + allowDms: false, + + signature: [...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + + await updateCase( + pluginData, + interaction, + interaction.user, + options["case-number"] ? Number(options["case-number"]) : null, + options.reason ?? "", + retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_UPDATE, options, "attachment"), + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts b/backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts new file mode 100644 index 00000000..91ea058a --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/WarnMsgCmd.ts @@ -0,0 +1,80 @@ +import { commandTypeHelpers as ct } from "../../../../commandTypes.js"; +import { canActOn, hasPermission, resolveMessageMember } from "../../../../pluginUtils.js"; +import { errorMessage, resolveMember, resolveUser } from "../../../../utils.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsMsgCmd } from "../../types.js"; +import { actualWarnCmd } from "./actualWarnCmd.js"; + +export const WarnMsgCmd = modActionsMsgCmd({ + trigger: "warn", + permission: "can_warn", + description: "Send a warning to the specified user", + + signature: { + user: ct.string(), + reason: ct.string({ catchAll: true }), + + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + }, + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user.id) { + await pluginData.state.common.sendErrorMessage(msg, `User not found`); + return; + } + + const authorMember = await resolveMessageMember(msg); + const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToWarn) { + const _isBanned = await isBanned(pluginData, user.id); + if (_isBanned) { + await pluginData.state.common.sendErrorMessage(msg, `User is banned`); + } else { + await pluginData.state.common.sendErrorMessage(msg, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to warn this member + if (!canActOn(pluginData, authorMember, memberToWarn)) { + await pluginData.state.common.sendErrorMessage(msg, "Cannot warn: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = authorMember; + if (args.mod) { + if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { + msg.channel.send(errorMessage("You don't have permission to use -mod")); + return; + } + + mod = args.mod; + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + await pluginData.state.common.sendErrorMessage(msg, e.message); + return; + } + + actualWarnCmd( + pluginData, + msg, + msg.author.id, + mod, + memberToWarn, + args.reason, + [...msg.attachments.values()], + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts b/backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts new file mode 100644 index 00000000..f0f8c197 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/WarnSlashCmd.ts @@ -0,0 +1,116 @@ +import { ChannelType, GuildMember } from "discord.js"; +import { slashOptions } from "knub"; +import { canActOn, hasPermission } from "../../../../pluginUtils.js"; +import { UserNotificationMethod, resolveMember } from "../../../../utils.js"; +import { generateAttachmentSlashOptions, retrieveMultipleOptions } from "../../../../utils/multipleSlashOptions.js"; +import { isBanned } from "../../functions/isBanned.js"; +import { readContactMethodsFromArgs } from "../../functions/readContactMethodsFromArgs.js"; +import { modActionsSlashCmd } from "../../types.js"; +import { NUMBER_ATTACHMENTS_CASE_CREATION } from "../constants.js"; +import { actualWarnCmd } from "./actualWarnCmd.js"; + +const opts = [ + slashOptions.string({ name: "reason", description: "The reason", required: false }), + slashOptions.user({ name: "mod", description: "The moderator to warn as", required: false }), + slashOptions.string({ + name: "notify", + description: "How to notify", + required: false, + choices: [ + { name: "DM", value: "dm" }, + { name: "Channel", value: "channel" }, + ], + }), + slashOptions.channel({ + name: "notify-channel", + description: "The channel to notify in", + channelTypes: [ChannelType.GuildText, ChannelType.PrivateThread, ChannelType.PublicThread], + required: false, + }), + ...generateAttachmentSlashOptions(NUMBER_ATTACHMENTS_CASE_CREATION, { + name: "attachment", + description: "An attachment to add to the reason", + }), +]; + +export const WarnSlashCmd = modActionsSlashCmd({ + name: "warn", + configPermission: "can_warn", + description: "Send a warning to the specified user", + allowDms: false, + + signature: [slashOptions.user({ name: "user", description: "The user to warn", required: true }), ...opts], + + async run({ interaction, options, pluginData }) { + await interaction.deferReply({ ephemeral: true }); + const attachments = retrieveMultipleOptions(NUMBER_ATTACHMENTS_CASE_CREATION, options, "attachment"); + + if ((!options.reason || options.reason.trim() === "") && attachments.length < 1) { + await pluginData.state.common.sendErrorMessage( + interaction, + "Text or attachment required", + undefined, + undefined, + true, + ); + + return; + } + + const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, options.user.id); + + if (!memberToWarn) { + const _isBanned = await isBanned(pluginData, options.user.id); + if (_isBanned) { + await pluginData.state.common.sendErrorMessage(interaction, `User is banned`); + } else { + await pluginData.state.common.sendErrorMessage(interaction, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to warn this member + if (!canActOn(pluginData, interaction.member as GuildMember, memberToWarn)) { + await pluginData.state.common.sendErrorMessage(interaction, "Cannot warn: insufficient permissions"); + return; + } + + let mod = interaction.member as GuildMember; + const canActAsOther = await hasPermission(pluginData, "can_act_as_other", { + channel: interaction.channel, + member: interaction.member, + }); + + if (options.mod) { + if (!canActAsOther) { + await pluginData.state.common.sendErrorMessage( + interaction, + "You don't have permission to act as another moderator", + ); + return; + } + + mod = (await resolveMember(pluginData.client, pluginData.guild, options.mod.id))!; + } + + let contactMethods: UserNotificationMethod[] | undefined; + try { + contactMethods = readContactMethodsFromArgs(options) ?? undefined; + } catch (e) { + await pluginData.state.common.sendErrorMessage(interaction, e.message); + return; + } + + actualWarnCmd( + pluginData, + interaction, + interaction.user.id, + mod, + memberToWarn, + options.reason ?? "", + attachments, + contactMethods, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts b/backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts new file mode 100644 index 00000000..2decfd28 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/warn/actualWarnCmd.ts @@ -0,0 +1,71 @@ +import { Attachment, ChatInputCommandInteraction, GuildMember, Message } from "discord.js"; +import { GuildPluginData } from "knub"; +import { CaseTypes } from "../../../../data/CaseTypes.js"; +import { UserNotificationMethod, renderUsername } from "../../../../utils.js"; +import { waitForButtonConfirm } from "../../../../utils/waitForInteraction.js"; +import { CasesPlugin } from "../../../Cases/CasesPlugin.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "../../functions/attachmentLinkReaction.js"; +import { + formatReasonWithAttachments, + formatReasonWithMessageLinkForAttachments, +} from "../../functions/formatReasonForAttachments.js"; +import { warnMember } from "../../functions/warnMember.js"; +import { ModActionsPluginType } from "../../types.js"; + +export async function actualWarnCmd( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + authorId: string, + mod: GuildMember, + memberToWarn: GuildMember, + reason: string, + attachments: Attachment[], + contactMethods?: UserNotificationMethod[], +) { + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, reason)) { + return; + } + + const config = pluginData.config.get(); + const formattedReason = await formatReasonWithMessageLinkForAttachments(pluginData, reason, context, attachments); + const formattedReasonWithAttachments = formatReasonWithAttachments(reason, attachments); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); + if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { + const reply = await waitForButtonConfirm( + context, + { content: config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`) }, + { confirmText: "Yes", cancelText: "No", restrictToId: authorId }, + ); + if (!reply) { + await pluginData.state.common.sendErrorMessage(context, "Warn cancelled by moderator"); + return; + } + } + + const warnResult = await warnMember(pluginData, memberToWarn, formattedReason, formattedReasonWithAttachments, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== authorId ? authorId : undefined, + reason: formattedReason, + }, + retryPromptContext: context, + }); + + if (warnResult.status === "failed") { + const failReason = warnResult.error ? `: ${warnResult.error}` : ""; + + await pluginData.state.common.sendErrorMessage(context, `Failed to warn user${failReason}`); + + return; + } + + const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; + + await pluginData.state.common.sendSuccessMessage( + context, + `Warned **${renderUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`, + ); +} diff --git a/backend/src/plugins/ModActions/info.ts b/backend/src/plugins/ModActions/docs.ts similarity index 70% rename from backend/src/plugins/ModActions/info.ts rename to backend/src/plugins/ModActions/docs.ts index 736de0ce..d4f5d32d 100644 --- a/backend/src/plugins/ModActions/info.ts +++ b/backend/src/plugins/ModActions/docs.ts @@ -1,10 +1,10 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zModActionsConfig } from "./types.js"; -export const modActionsPluginInfo: ZeppelinPluginInfo = { +export const modActionsPluginDocs: ZeppelinPluginDocs = { prettyName: "Mod actions", - showInDocs: true, + type: "stable", description: trimPluginDescription(` This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc. `), diff --git a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts deleted file mode 100644 index c0b9fb53..00000000 --- a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { GuildMember, GuildTextBasedChannel } from "discord.js"; -import { GuildPluginData } from "knub"; -import { hasPermission } from "knub/helpers"; -import { LogType } from "../../../data/LogType.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { DAYS, SECONDS, errorMessage, renderUsername, resolveMember, resolveUser } from "../../../utils.js"; -import { IgnoredEventType, ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; -import { ignoreEvent } from "./ignoreEvent.js"; -import { isBanned } from "./isBanned.js"; -import { kickMember } from "./kickMember.js"; -import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs.js"; - -export async function actualKickMemberCmd( - pluginData: GuildPluginData, - msg, - args: { - user: string; - reason: string; - mod: GuildMember; - notify?: string; - "notify-channel"?: GuildTextBasedChannel; - clean?: boolean; - }, -) { - const user = await resolveUser(pluginData.client, args.user); - if (!user.id) { - sendErrorMessage(pluginData, msg.channel, `User not found`); - return; - } - - const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); - - if (!memberToKick) { - const banned = await isBanned(pluginData, user.id); - if (banned) { - sendErrorMessage(pluginData, msg.channel, `User is banned`); - } else { - sendErrorMessage(pluginData, msg.channel, `User not found on the server`); - } - - return; - } - - // Make sure we're allowed to kick this member - if (!canActOn(pluginData, msg.member, memberToKick)) { - sendErrorMessage(pluginData, msg.channel, "Cannot kick: insufficient permissions"); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!(await hasPermission(await pluginData.config.getForMessage(msg), "can_act_as_other"))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - } - - let contactMethods; - try { - contactMethods = readContactMethodsFromArgs(args); - } catch (e) { - sendErrorMessage(pluginData, msg.channel, e.message); - return; - } - - const reason = formatReasonWithAttachments(args.reason, msg.attachments); - - const kickResult = await kickMember(pluginData, memberToKick, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - }, - }); - - if (args.clean) { - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id); - ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id); - - try { - await memberToKick.ban({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: "kick -clean" }); - } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to ban the user to clean messages (-clean)"); - } - - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id); - ignoreEvent(pluginData, IgnoredEventType.Unban, memberToKick.id); - - try { - await pluginData.guild.bans.remove(memberToKick.id, "kick -clean"); - } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to unban the user after banning them (-clean)"); - } - } - - if (kickResult.status === "failed") { - msg.channel.send(errorMessage(`Failed to kick user`)); - return; - } - - // Confirm the action to the moderator - let response = `Kicked **${renderUsername(memberToKick)}** (Case #${kickResult.case.case_number})`; - - if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; - sendSuccessMessage(pluginData, msg.channel, response); -} diff --git a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts deleted file mode 100644 index 2eac70ef..00000000 --- a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { GuildMember, GuildTextBasedChannel, Message, User } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import { GuildPluginData } from "knub"; -import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; -import { logger } from "../../../logger.js"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { UnknownUser, asSingleLine, isDiscordAPIError, renderUsername } from "../../../utils.js"; -import { MutesPlugin } from "../../Mutes/MutesPlugin.js"; -import { MuteResult } from "../../Mutes/types.js"; -import { ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; -import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs.js"; - -/** - * The actual function run by both !mute and !forcemute. - * The only difference between the two commands is in target member validation. - */ -export async function actualMuteUserCmd( - pluginData: GuildPluginData, - user: User | UnknownUser, - msg: Message, - args: { - time?: number; - reason?: string; - mod: GuildMember; - notify?: string; - "notify-channel"?: GuildTextBasedChannel; - }, -) { - // The moderator who did the action is the message author or, if used, the specified -mod - let mod: GuildMember = msg.member!; - let pp: User | null = null; - - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod; - pp = msg.author; - } - - const timeUntilUnmute = args.time && humanizeDuration(args.time); - const reason = args.reason ? formatReasonWithAttachments(args.reason, [...msg.attachments.values()]) : undefined; - - let muteResult: MuteResult; - const mutesPlugin = pluginData.getPlugin(MutesPlugin); - - let contactMethods; - try { - contactMethods = readContactMethodsFromArgs(args); - } catch (e) { - sendErrorMessage(pluginData, msg.channel, e.message); - return; - } - - try { - muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: pp ? pp.id : undefined, - }, - }); - } catch (e) { - if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - sendErrorMessage(pluginData, msg.channel, "Could not mute the user: no mute role set in config"); - } else if (isDiscordAPIError(e) && e.code === 10007) { - sendErrorMessage(pluginData, msg.channel, "Could not mute the user: unknown member"); - } else { - logger.error(`Failed to mute user ${user.id}: ${e.stack}`); - if (user.id == null) { - // FIXME: Debug - // tslint:disable-next-line:no-console - console.trace("[DEBUG] Null user.id for mute"); - } - sendErrorMessage(pluginData, msg.channel, "Could not mute the user"); - } - - return; - } - - // Confirm the action to the moderator - let response: string; - if (args.time) { - if (muteResult.updatedExistingMute) { - response = asSingleLine(` - Updated **${renderUsername(user)}**'s - mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${renderUsername(user)}** - for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } - } else { - if (muteResult.updatedExistingMute) { - response = asSingleLine(` - Updated **${renderUsername(user)}**'s - mute to indefinite (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${renderUsername(user)}** - indefinitely (Case #${muteResult.case.case_number}) - `); - } - } - - if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; - sendSuccessMessage(pluginData, msg.channel, response); -} diff --git a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts deleted file mode 100644 index d4e93100..00000000 --- a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { GuildMember, Message, User } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import { GuildPluginData } from "knub"; -import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin.js"; -import { UnknownUser, asSingleLine, renderUsername } from "../../../utils.js"; -import { ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; - -export async function actualUnmuteCmd( - pluginData: GuildPluginData, - user: User | UnknownUser, - msg: Message, - args: { time?: number; reason?: string; mod?: GuildMember }, -) { - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.author; - let pp: User | null = null; - - if (args.mod) { - if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) { - sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod"); - return; - } - - mod = args.mod.user; - pp = msg.author; - } - - const reason = args.reason ? formatReasonWithAttachments(args.reason, [...msg.attachments.values()]) : undefined; - - const mutesPlugin = pluginData.getPlugin(MutesPlugin); - const result = await mutesPlugin.unmuteUser(user.id, args.time, { - modId: mod.id, - ppId: pp ? pp.id : undefined, - reason, - }); - - if (!result) { - sendErrorMessage(pluginData, msg.channel, "User is not muted!"); - return; - } - - // Confirm the action to the moderator - if (args.time) { - const timeUntilUnmute = args.time && humanizeDuration(args.time); - sendSuccessMessage( - pluginData, - msg.channel, - asSingleLine(` - Unmuting **${renderUsername(user)}** - in ${timeUntilUnmute} (Case #${result.case.case_number}) - `), - ); - } else { - sendSuccessMessage( - pluginData, - msg.channel, - asSingleLine(` - Unmuted **${renderUsername(user)}** - (Case #${result.case.case_number}) - `), - ); - } -} diff --git a/backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts b/backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts new file mode 100644 index 00000000..97d5c8a5 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/attachmentLinkReaction.ts @@ -0,0 +1,48 @@ +import { ChatInputCommandInteraction, Message, TextBasedChannel } from "discord.js"; +import { AnyPluginData, GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../types.js"; + +export function shouldReactToAttachmentLink(pluginData: GuildPluginData) { + const config = pluginData.config.get(); + + return !config.attachment_link_reaction || config.attachment_link_reaction !== "none"; +} + +export function attachmentLinkShouldRestrict(pluginData: GuildPluginData) { + return pluginData.config.get().attachment_link_reaction === "restrict"; +} + +export function detectAttachmentLink(reason: string | null | undefined) { + return reason && /https:\/\/(cdn|media)\.discordapp\.(com|net)\/(ephemeral-)?attachments/gu.test(reason); +} + +export function sendAttachmentLinkDetectionErrorMessage( + pluginData: AnyPluginData, + context: TextBasedChannel | Message | ChatInputCommandInteraction, + restricted = false, +) { + const emoji = pluginData.state.common.getErrorEmoji(); + + pluginData.state.common.sendErrorMessage( + context, + "You manually added a Discord attachment link to the reason. This link will only work for a limited time.\n" + + "You should instead **re-upload** the attachment with the command, in the same message.\n\n" + + (restricted ? `${emoji} **Command canceled.** ${emoji}` : "").trim(), + ); +} + +export async function handleAttachmentLinkDetectionAndGetRestriction( + pluginData: GuildPluginData, + context: TextBasedChannel | Message | ChatInputCommandInteraction, + reason: string | null | undefined, +) { + if (!shouldReactToAttachmentLink(pluginData) || !detectAttachmentLink(reason)) { + return false; + } + + const restricted = attachmentLinkShouldRestrict(pluginData); + + sendAttachmentLinkDetectionErrorMessage(pluginData, context, restricted); + + return restricted; +} diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 2ac01af3..7250fa1d 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -1,9 +1,9 @@ import { DiscordAPIError, Snowflake } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { LogType } from "../../../data/LogType.js"; import { registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { logger } from "../../../logger.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { @@ -30,6 +30,7 @@ export async function banUserId( pluginData: GuildPluginData, userId: string, reason?: string, + reasonWithAttachments?: string, banOptions: BanOptions = {}, banTime?: number, ): Promise { @@ -45,7 +46,7 @@ export async function banUserId( // Attempt to message the user *before* banning them, as doing it after may not be possible const member = await resolveMember(pluginData.client, pluginData.guild, userId); let notifyResult: UserNotificationResult = { method: null, success: true }; - if (reason && member) { + if (reasonWithAttachments && member) { const contactMethods = banOptions?.contactMethods ? banOptions.contactMethods : getDefaultContactMethods(pluginData, "ban"); @@ -58,7 +59,7 @@ export async function banUserId( config.ban_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: banOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) : null, @@ -82,7 +83,7 @@ export async function banUserId( config.tempban_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: banOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId)) : null, diff --git a/backend/src/plugins/ModActions/functions/clearTempban.ts b/backend/src/plugins/ModActions/functions/clearTempban.ts index e0962e9c..96d60770 100644 --- a/backend/src/plugins/ModActions/functions/clearTempban.ts +++ b/backend/src/plugins/ModActions/functions/clearTempban.ts @@ -1,16 +1,15 @@ import { Snowflake } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { LogType } from "../../../data/LogType.js"; import { Tempban } from "../../../data/entities/Tempban.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { logger } from "../../../logger.js"; import { resolveUser } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { IgnoredEventType, ModActionsPluginType } from "../types.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; import { ignoreEvent } from "./ignoreEvent.js"; import { isBanned } from "./isBanned.js"; @@ -21,11 +20,9 @@ export async function clearTempban(pluginData: GuildPluginData, + reason: string, + context: Message | ChatInputCommandInteraction, + attachments: Attachment[], +) { + if (isContextMessage(context)) { + const allAttachments = [...new Set([...context.attachments.values(), ...attachments])]; + + return allAttachments.length > 0 ? ((reason || "") + " " + context.url).trim() : reason; + } + + if (attachments.length < 1) { + return reason; + } + + const attachmentsMessage = await pluginData.state.common.storeAttachmentsAsMessage(attachments, context.channel); + + return ((reason || "") + " " + attachmentsMessage.url).trim(); +} + +export function formatReasonWithAttachments(reason: string, attachments: Attachment[]) { + const attachmentUrls = attachments.map((a) => a.url); + return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); +} diff --git a/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts b/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts deleted file mode 100644 index 3fd92ee8..00000000 --- a/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Attachment } from "discord.js"; - -export function formatReasonWithAttachments(reason: string, attachments: Attachment[]) { - const attachmentUrls = attachments.map((a) => a.url); - return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); -} diff --git a/backend/src/plugins/ModActions/functions/hasModActionPerm.ts b/backend/src/plugins/ModActions/functions/hasModActionPerm.ts new file mode 100644 index 00000000..501e927d --- /dev/null +++ b/backend/src/plugins/ModActions/functions/hasModActionPerm.ts @@ -0,0 +1,35 @@ +import { GuildMember, Snowflake } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ModActionsPluginType } from "../types.js"; + +export async function hasNotePermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_note; +} + +export async function hasWarnPermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_warn; +} + +export async function hasMutePermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_mute; +} + +export async function hasBanPermission( + pluginData: GuildPluginData, + member: GuildMember, + channelId: Snowflake, +) { + return (await pluginData.config.getMatchingConfig({ member, channelId })).can_ban; +} diff --git a/backend/src/plugins/ModActions/functions/hasMutePerm.ts b/backend/src/plugins/ModActions/functions/hasMutePerm.ts deleted file mode 100644 index be3096ee..00000000 --- a/backend/src/plugins/ModActions/functions/hasMutePerm.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { GuildMember, Snowflake } from "discord.js"; -import { GuildPluginData } from "knub"; -import { ModActionsPluginType } from "../types.js"; - -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/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts index 527f6163..c7be5f70 100644 --- a/backend/src/plugins/ModActions/functions/kickMember.ts +++ b/backend/src/plugins/ModActions/functions/kickMember.ts @@ -24,13 +24,14 @@ export async function kickMember( pluginData: GuildPluginData, member: GuildMember, reason?: string, + reasonWithAttachments?: string, kickOptions: KickOptions = {}, ): Promise { const config = pluginData.config.get(); // Attempt to message the user *before* kicking them, as doing it after may not be possible let notifyResult: UserNotificationResult = { method: null, success: true }; - if (reason && member) { + if (reasonWithAttachments && member) { const contactMethods = kickOptions?.contactMethods ? kickOptions.contactMethods : getDefaultContactMethods(pluginData, "kick"); @@ -43,7 +44,7 @@ export async function kickMember( config.kick_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: kickOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId)) : null, diff --git a/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts b/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts index efa3b8bc..47f207f5 100644 --- a/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts +++ b/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts @@ -2,8 +2,8 @@ import { GuildTextBasedChannel } from "discord.js"; import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils.js"; export function readContactMethodsFromArgs(args: { - notify?: string; - "notify-channel"?: GuildTextBasedChannel; + notify?: string | null; + "notify-channel"?: GuildTextBasedChannel | null; }): null | UserNotificationMethod[] { if (args.notify) { if (args.notify === "dm") { diff --git a/backend/src/plugins/ModActions/functions/updateCase.ts b/backend/src/plugins/ModActions/functions/updateCase.ts index 721c083c..6d78ce8b 100644 --- a/backend/src/plugins/ModActions/functions/updateCase.ts +++ b/backend/src/plugins/ModActions/functions/updateCase.ts @@ -1,44 +1,57 @@ -import { Message } from "discord.js"; +import { Attachment, ChatInputCommandInteraction, Message, User } from "discord.js"; +import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { Case } from "../../../data/entities/Case.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; -import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin.js"; +import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { formatReasonWithAttachments } from "./formatReasonWithAttachments.js"; +import { ModActionsPluginType } from "../types.js"; +import { handleAttachmentLinkDetectionAndGetRestriction } from "./attachmentLinkReaction.js"; +import { formatReasonWithMessageLinkForAttachments } from "./formatReasonForAttachments.js"; -export async function updateCase(pluginData, msg: Message, args) { - let theCase: Case | undefined; - if (args.caseNumber != null) { - theCase = await pluginData.state.cases.findByCaseNumber(args.caseNumber); +export async function updateCase( + pluginData: GuildPluginData, + context: Message | ChatInputCommandInteraction, + author: User, + caseNumber?: number | null, + note = "", + attachments: Attachment[] = [], +) { + let theCase: Case | null; + if (caseNumber != null) { + theCase = await pluginData.state.cases.findByCaseNumber(caseNumber); } else { - theCase = await pluginData.state.cases.findLatestByModId(msg.author.id); + theCase = await pluginData.state.cases.findLatestByModId(author.id); } if (!theCase) { - sendErrorMessage(pluginData, msg.channel, "Case not found"); + pluginData.state.common.sendErrorMessage(context, "Case not found"); return; } - if (!args.note && msg.attachments.size === 0) { - sendErrorMessage(pluginData, msg.channel, "Text or attachment required"); + if (note.length === 0 && attachments.length === 0) { + pluginData.state.common.sendErrorMessage(context, "Text or attachment required"); return; } - const note = formatReasonWithAttachments(args.note, [...msg.attachments.values()]); + if (await handleAttachmentLinkDetectionAndGetRestriction(pluginData, context, note)) { + return; + } + + const formattedNote = await formatReasonWithMessageLinkForAttachments(pluginData, note, context, attachments); const casesPlugin = pluginData.getPlugin(CasesPlugin); await casesPlugin.createCaseNote({ caseId: theCase.id, - modId: msg.author.id, - body: note, + modId: author.id, + body: formattedNote, }); pluginData.getPlugin(LogsPlugin).logCaseUpdate({ - mod: msg.author, + mod: author, caseNumber: theCase.case_number, caseType: CaseTypes[theCase.type], - note, + note: formattedNote, }); - sendSuccessMessage(pluginData, msg.channel, `Case \`#${theCase.case_number}\` updated`); + pluginData.state.common.sendSuccessMessage(context, `Case \`#${theCase.case_number}\` updated`); } diff --git a/backend/src/plugins/ModActions/functions/warnMember.ts b/backend/src/plugins/ModActions/functions/warnMember.ts index a7a8f531..9ac737a6 100644 --- a/backend/src/plugins/ModActions/functions/warnMember.ts +++ b/backend/src/plugins/ModActions/functions/warnMember.ts @@ -20,6 +20,7 @@ export async function warnMember( pluginData: GuildPluginData, member: GuildMember, reason: string, + reasonWithAttachments: string, warnOptions: WarnOptions = {}, ): Promise { const config = pluginData.config.get(); @@ -32,7 +33,7 @@ export async function warnMember( config.warn_message, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason, + reason: reasonWithAttachments, moderator: warnOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId)) : null, @@ -56,20 +57,20 @@ export async function warnMember( } if (!notifyResult.success) { - if (warnOptions.retryPromptChannel && pluginData.guild.channels.resolve(warnOptions.retryPromptChannel.id)) { - const reply = await waitForButtonConfirm( - warnOptions.retryPromptChannel, - { content: "Failed to message the user. Log the warning anyway?" }, - { confirmText: "Yes", cancelText: "No", restrictToId: warnOptions.caseArgs?.modId }, - ); + if (!warnOptions.retryPromptContext) { + return { + status: "failed", + error: "Failed to message user", + }; + } - if (!reply) { - return { - status: "failed", - error: "Failed to message user", - }; - } - } else { + const reply = await waitForButtonConfirm( + warnOptions.retryPromptContext, + { content: "Failed to message the user. Log the warning anyway?" }, + { confirmText: "Yes", cancelText: "No", restrictToId: warnOptions.caseArgs?.modId }, + ); + + if (!reply) { return { status: "failed", error: "Failed to message user", diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index 51044fd3..7f4c0aa6 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -1,7 +1,14 @@ -import { GuildTextBasedChannel } from "discord.js"; +import { ChatInputCommandInteraction, Message } from "discord.js"; import { EventEmitter } from "events"; -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { + BasePluginType, + guildPluginEventListener, + guildPluginMessageCommand, + guildPluginSlashCommand, + guildPluginSlashGroup, + pluginUtils, +} from "knub"; +import z from "zod/v4"; import { Queue } from "../../Queue.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; @@ -10,40 +17,56 @@ import { GuildTempbans } from "../../data/GuildTempbans.js"; import { Case } from "../../data/entities/Case.js"; import { UserNotificationMethod, UserNotificationResult } from "../../utils.js"; import { CaseArgs } from "../Cases/types.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; + +export type AttachmentLinkReactionType = "none" | "warn" | "restrict" | null; export const zModActionsConfig = z.strictObject({ - dm_on_warn: z.boolean(), - dm_on_kick: z.boolean(), - dm_on_ban: z.boolean(), - message_on_warn: z.boolean(), - message_on_kick: z.boolean(), - message_on_ban: z.boolean(), - message_channel: z.nullable(z.string()), - warn_message: z.nullable(z.string()), - kick_message: z.nullable(z.string()), - ban_message: z.nullable(z.string()), - tempban_message: z.nullable(z.string()), - alert_on_rejoin: z.boolean(), - alert_channel: z.nullable(z.string()), - warn_notify_enabled: z.boolean(), - warn_notify_threshold: z.number(), - warn_notify_message: z.string(), - ban_delete_message_days: z.number(), - can_note: z.boolean(), - can_warn: z.boolean(), - can_mute: z.boolean(), - can_kick: z.boolean(), - can_ban: z.boolean(), - can_unban: z.boolean(), - can_view: z.boolean(), - can_addcase: z.boolean(), - can_massunban: z.boolean(), - can_massban: z.boolean(), - can_massmute: z.boolean(), - can_hidecase: z.boolean(), - can_deletecase: z.boolean(), - can_act_as_other: z.boolean(), - create_cases_for_manual_actions: z.boolean(), + dm_on_warn: z.boolean().default(true), + dm_on_kick: z.boolean().default(false), + dm_on_ban: z.boolean().default(false), + message_on_warn: z.boolean().default(false), + message_on_kick: z.boolean().default(false), + message_on_ban: z.boolean().default(false), + message_channel: z.nullable(z.string()).default(null), + warn_message: z.nullable(z.string()).default("You have received a warning on the {guildName} server: {reason}"), + kick_message: z + .nullable(z.string()) + .default("You have been kicked from the {guildName} server. Reason given: {reason}"), + ban_message: z + .nullable(z.string()) + .default("You have been banned from the {guildName} server. Reason given: {reason}"), + tempban_message: z + .nullable(z.string()) + .default("You have been banned from the {guildName} server for {banTime}. Reason given: {reason}"), + alert_on_rejoin: z.boolean().default(false), + alert_channel: z.nullable(z.string()).default(null), + warn_notify_enabled: z.boolean().default(false), + warn_notify_threshold: z.number().default(5), + warn_notify_message: z + .string() + .default( + "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", + ), + ban_delete_message_days: z.number().default(1), + attachment_link_reaction: z + .nullable(z.union([z.literal("none"), z.literal("warn"), z.literal("restrict")])) + .default("warn"), + can_note: z.boolean().default(false), + can_warn: z.boolean().default(false), + can_mute: z.boolean().default(false), + can_kick: z.boolean().default(false), + can_ban: z.boolean().default(false), + can_unban: z.boolean().default(false), + can_view: z.boolean().default(false), + can_addcase: z.boolean().default(false), + can_massunban: z.boolean().default(false), + can_massban: z.boolean().default(false), + can_massmute: z.boolean().default(false), + can_hidecase: z.boolean().default(false), + can_deletecase: z.boolean().default(false), + can_act_as_other: z.boolean().default(false), + create_cases_for_manual_actions: z.boolean().default(true), }); export interface ModActionsEvents { @@ -61,7 +84,7 @@ export interface ModActionsEventEmitter extends EventEmitter { } export interface ModActionsPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zModActionsConfig; state: { mutes: GuildMutes; cases: GuildCases; @@ -74,6 +97,8 @@ export interface ModActionsPluginType extends BasePluginType { massbanQueue: Queue; events: ModActionsEventEmitter; + + common: pluginUtils.PluginPublicInterface; }; } @@ -126,7 +151,7 @@ export type WarnMemberNotifyRetryCallback = () => boolean | Promise; export interface WarnOptions { caseArgs?: Partial | null; contactMethods?: UserNotificationMethod[] | null; - retryPromptChannel?: GuildTextBasedChannel | null; + retryPromptContext?: Message | ChatInputCommandInteraction | null; isAutomodAction?: boolean; } @@ -146,5 +171,7 @@ export interface BanOptions { export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban"; -export const modActionsCmd = guildPluginMessageCommand(); +export const modActionsMsgCmd = guildPluginMessageCommand(); +export const modActionsSlashGroup = guildPluginSlashGroup(); +export const modActionsSlashCmd = guildPluginSlashCommand(); export const modActionsEvt = guildPluginEventListener(); diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index 7bd47d72..0a302ee2 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -8,6 +8,7 @@ import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; import { makePublicFn } from "../../pluginUtils.js"; import { CasesPlugin } from "../Cases/CasesPlugin.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { ClearBannedMutesCmd } from "./commands/ClearBannedMutesCmd.js"; @@ -25,27 +26,12 @@ import { renewTimeoutMute } from "./functions/renewTimeoutMute.js"; import { unmuteUser } from "./functions/unmuteUser.js"; import { MutesPluginType, zMutesConfig } from "./types.js"; -const defaultOptions = { - config: { - mute_role: null, - move_to_voice_channel: null, - kick_from_voice_channel: false, +export const MutesPlugin = guildPlugin()({ + name: "mutes", - dm_on_mute: false, - dm_on_update: false, - message_on_mute: false, - message_on_update: false, - message_channel: null, - mute_message: "You have been muted on the {guildName} server. Reason given: {reason}", - timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}", - update_mute_message: "Your mute on the {guildName} server has been updated to {time}.", - remove_roles_on_mute: false, - restore_roles_on_mute: false, - - can_view_list: false, - can_cleanup: false, - }, - overrides: [ + dependencies: () => [CasesPlugin, LogsPlugin, RoleManagerPlugin], + configSchema: zMutesConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -59,14 +45,6 @@ const defaultOptions = { }, }, ], -}; - -export const MutesPlugin = guildPlugin()({ - name: "mutes", - - dependencies: () => [CasesPlugin, LogsPlugin, RoleManagerPlugin], - configParser: (input) => zMutesConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -109,6 +87,10 @@ export const MutesPlugin = guildPlugin()({ state.events = new EventEmitter(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts index 1c37b368..80a9a49e 100644 --- a/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts +++ b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts @@ -1,5 +1,4 @@ import { Snowflake } from "discord.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { mutesCmd } from "../types.js"; export const ClearBannedMutesCmd = mutesCmd({ @@ -25,6 +24,6 @@ export const ClearBannedMutesCmd = mutesCmd({ } } - sendSuccessMessage(pluginData, msg.channel, `Cleared ${cleared} mutes from banned users!`); + void pluginData.state.common.sendSuccessMessage(msg, `Cleared ${cleared} mutes from banned users!`); }, }); diff --git a/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts b/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts index be5ffa13..773ac06d 100644 --- a/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts +++ b/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { mutesCmd } from "../types.js"; export const ClearMutesCmd = mutesCmd({ @@ -23,13 +22,15 @@ export const ClearMutesCmd = mutesCmd({ } if (failed.length !== args.userIds.length) { - sendSuccessMessage(pluginData, msg.channel, `**${args.userIds.length - failed.length} active mute(s) cleared**`); + void pluginData.state.common.sendSuccessMessage( + msg, + `**${args.userIds.length - failed.length} active mute(s) cleared**`, + ); } if (failed.length) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `**${failed.length}/${args.userIds.length} IDs failed**, they are not muted: ${failed.join(" ")}`, ); } diff --git a/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts b/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts index b65c97ab..c225d407 100644 --- a/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts +++ b/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts @@ -1,5 +1,4 @@ import { Snowflake } from "discord.js"; -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { resolveMember } from "../../../utils.js"; import { mutesCmd } from "../types.js"; @@ -26,6 +25,9 @@ export const ClearMutesWithoutRoleCmd = mutesCmd({ } } - sendSuccessMessage(pluginData, msg.channel, `Cleared ${cleared} mutes from members that don't have the mute role`); + void pluginData.state.common.sendSuccessMessage( + msg, + `Cleared ${cleared} mutes from members that don't have the mute role`, + ); }, }); diff --git a/backend/src/plugins/Mutes/commands/MutesCmd.ts b/backend/src/plugins/Mutes/commands/MutesCmd.ts index d599edd4..eda0de18 100644 --- a/backend/src/plugins/Mutes/commands/MutesCmd.ts +++ b/backend/src/plugins/Mutes/commands/MutesCmd.ts @@ -8,7 +8,7 @@ import { } from "discord.js"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { humanizeDurationShort } from "../../../humanizeDurationShort.js"; +import { humanizeDurationShort } from "../../../humanizeDuration.js"; import { getBaseUrl } from "../../../pluginUtils.js"; import { DBDateFormat, MINUTES, renderUsername, resolveMember } from "../../../utils.js"; import { IMuteWithDetails, mutesCmd } from "../types.js"; diff --git a/backend/src/plugins/Mutes/docs.ts b/backend/src/plugins/Mutes/docs.ts new file mode 100644 index 00000000..071c2d4c --- /dev/null +++ b/backend/src/plugins/Mutes/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zMutesConfig } from "./types.js"; + +export const mutesPluginDocs: ZeppelinPluginDocs = { + prettyName: "Mutes", + type: "stable", + configSchema: zMutesConfig, +}; diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index e57ae762..fbb6497b 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -1,5 +1,4 @@ import { Snowflake } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { CaseTypes } from "../../../data/CaseTypes.js"; @@ -8,6 +7,7 @@ import { MuteTypes } from "../../../data/MuteTypes.js"; import { Case } from "../../../data/entities/Case.js"; import { Mute } from "../../../data/entities/Mute.js"; import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { @@ -35,6 +35,7 @@ export async function muteUser( userId: string, muteTime?: number, reason?: string, + reasonWithAttachments?: string, muteOptions: MuteOptions = {}, removeRolesOnMuteOverride: boolean | string[] | null = null, restoreRolesOnMuteOverride: boolean | string[] | null = null, @@ -196,7 +197,7 @@ export async function muteUser( template, new TemplateSafeValueContainer({ guildName: pluginData.guild.name, - reason: reason || "None", + reason: reasonWithAttachments || "None", time: timeUntilUnmuteStr, moderator: muteOptions.caseArgs?.modId ? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId)) @@ -245,10 +246,12 @@ export async function muteUser( if (theCase) { // Update old case const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmuteStr : "indefinite"}`]; - const reasons = reason ? [reason] : []; + const reasons = reason ? [reason] : [""]; // Empty string so that there is a case update even without reason + if (muteOptions.caseArgs?.extraNotes) { reasons.push(...muteOptions.caseArgs.extraNotes); } + for (const noteReason of reasons) { await casesPlugin.createCaseNote({ caseId: existingMute!.case_id, diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts index a5624f0a..b9d4c2b2 100644 --- a/backend/src/plugins/Mutes/functions/unmuteUser.ts +++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts @@ -1,10 +1,10 @@ import { Snowflake } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import { CaseTypes } from "../../../data/CaseTypes.js"; import { AddMuteParams } from "../../../data/GuildMutes.js"; import { MuteTypes } from "../../../data/MuteTypes.js"; import { Mute } from "../../../data/entities/Mute.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { noop, resolveMember, resolveUser } from "../../../utils.js"; import { CasesPlugin } from "../../Cases/CasesPlugin.js"; import { CaseArgs } from "../../Cases/types.js"; diff --git a/backend/src/plugins/Mutes/info.ts b/backend/src/plugins/Mutes/info.ts deleted file mode 100644 index 6654afed..00000000 --- a/backend/src/plugins/Mutes/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zMutesConfig } from "./types.js"; - -export const mutesPluginInfo: ZeppelinPluginInfo = { - prettyName: "Mutes", - showInDocs: true, - configSchema: zMutesConfig, -}; diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index 3bbc9d99..b133134c 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -1,7 +1,7 @@ import { GuildMember } from "discord.js"; import { EventEmitter } from "events"; -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; @@ -10,25 +10,29 @@ import { Case } from "../../data/entities/Case.js"; import { Mute } from "../../data/entities/Mute.js"; import { UserNotificationMethod, UserNotificationResult, zSnowflake } from "../../utils.js"; import { CaseArgs } from "../Cases/types.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zMutesConfig = z.strictObject({ - mute_role: zSnowflake.nullable(), - move_to_voice_channel: zSnowflake.nullable(), - kick_from_voice_channel: z.boolean(), + mute_role: zSnowflake.nullable().default(null), + move_to_voice_channel: zSnowflake.nullable().default(null), + kick_from_voice_channel: z.boolean().default(false), - dm_on_mute: z.boolean(), - dm_on_update: z.boolean(), - message_on_mute: z.boolean(), - message_on_update: z.boolean(), - message_channel: z.string().nullable(), - mute_message: z.string().nullable(), - timed_mute_message: z.string().nullable(), - update_mute_message: z.string().nullable(), + dm_on_mute: z.boolean().default(false), + dm_on_update: z.boolean().default(false), + message_on_mute: z.boolean().default(false), + message_on_update: z.boolean().default(false), + message_channel: z.string().nullable().default(null), + mute_message: z.string().nullable().default("You have been muted on the {guildName} server. Reason given: {reason}"), + timed_mute_message: z + .string() + .nullable() + .default("You have been muted on the {guildName} server for {time}. Reason given: {reason}"), + update_mute_message: z.string().nullable().default("Your mute on the {guildName} server has been updated to {time}."), remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), - can_view_list: z.boolean(), - can_cleanup: z.boolean(), + can_view_list: z.boolean().default(false), + can_cleanup: z.boolean().default(false), }); export interface MutesEvents { @@ -42,7 +46,7 @@ export interface MutesEventEmitter extends EventEmitter { } export interface MutesPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zMutesConfig; state: { mutes: GuildMutes; cases: GuildCases; @@ -53,6 +57,8 @@ export interface MutesPluginType extends BasePluginType { unregisterTimeoutMuteToRenewListener: () => void; events: MutesEventEmitter; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts index 770ce446..57d72922 100644 --- a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts +++ b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts @@ -1,15 +1,16 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { Queue } from "../../Queue.js"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { NamesCmd } from "./commands/NamesCmd.js"; import { NameHistoryPluginType, zNameHistoryConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - can_view: false, - }, - overrides: [ +export const NameHistoryPlugin = guildPlugin()({ + name: "name_history", + + configSchema: zNameHistoryConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -17,13 +18,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const NameHistoryPlugin = guildPlugin()({ - name: "name_history", - - configParser: (input) => zNameHistoryConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -44,4 +38,8 @@ export const NameHistoryPlugin = guildPlugin()({ state.usernameHistory = new UsernameHistory(); state.updateQueue = new Queue(); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/NameHistory/commands/NamesCmd.ts b/backend/src/plugins/NameHistory/commands/NamesCmd.ts index ce1ccaad..f7e27ce4 100644 --- a/backend/src/plugins/NameHistory/commands/NamesCmd.ts +++ b/backend/src/plugins/NameHistory/commands/NamesCmd.ts @@ -4,7 +4,6 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { MAX_NICKNAME_ENTRIES_PER_USER } from "../../../data/GuildNicknameHistory.js"; import { MAX_USERNAME_ENTRIES_PER_USER } from "../../../data/UsernameHistory.js"; import { NICKNAME_RETENTION_PERIOD } from "../../../data/cleanup/nicknames.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { DAYS, renderUsername } from "../../../utils.js"; import { nameHistoryCmd } from "../types.js"; @@ -21,7 +20,7 @@ export const NamesCmd = nameHistoryCmd({ const usernames = await pluginData.state.usernameHistory.getByUserId(args.userId); if (nicknames.length === 0 && usernames.length === 0) { - sendErrorMessage(pluginData, msg.channel, "No name history found"); + void pluginData.state.common.sendErrorMessage(msg, "No name history found"); return; } diff --git a/backend/src/plugins/NameHistory/docs.ts b/backend/src/plugins/NameHistory/docs.ts new file mode 100644 index 00000000..e1b110dc --- /dev/null +++ b/backend/src/plugins/NameHistory/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zNameHistoryConfig } from "./types.js"; + +export const nameHistoryPluginDocs: ZeppelinPluginDocs = { + prettyName: "Name history", + type: "internal", + configSchema: zNameHistoryConfig, +}; diff --git a/backend/src/plugins/NameHistory/info.ts b/backend/src/plugins/NameHistory/info.ts deleted file mode 100644 index 985652dd..00000000 --- a/backend/src/plugins/NameHistory/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zNameHistoryConfig } from "./types.js"; - -export const nameHistoryPluginInfo: ZeppelinPluginInfo = { - prettyName: "Name history", - showInDocs: false, - configSchema: zNameHistoryConfig, -}; diff --git a/backend/src/plugins/NameHistory/types.ts b/backend/src/plugins/NameHistory/types.ts index 708a15f2..84e9b968 100644 --- a/backend/src/plugins/NameHistory/types.ts +++ b/backend/src/plugins/NameHistory/types.ts @@ -1,19 +1,21 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { Queue } from "../../Queue.js"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zNameHistoryConfig = z.strictObject({ - can_view: z.boolean(), + can_view: z.boolean().default(false), }); export interface NameHistoryPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zNameHistoryConfig; state: { nicknameHistory: GuildNicknameHistory; usernameHistory: UsernameHistory; updateQueue: Queue; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Persist/PersistPlugin.ts b/backend/src/plugins/Persist/PersistPlugin.ts index 7d9a5d6e..40008373 100644 --- a/backend/src/plugins/Persist/PersistPlugin.ts +++ b/backend/src/plugins/Persist/PersistPlugin.ts @@ -1,4 +1,4 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildPersistedData } from "../../data/GuildPersistedData.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; @@ -7,20 +7,11 @@ import { LoadDataEvt } from "./events/LoadDataEvt.js"; import { StoreDataEvt } from "./events/StoreDataEvt.js"; import { PersistPluginType, zPersistConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - persisted_roles: [], - persist_nicknames: false, - persist_voice_mutes: false, - }, -}; - export const PersistPlugin = guildPlugin()({ name: "persist", dependencies: () => [LogsPlugin, RoleManagerPlugin], - configParser: (input) => zPersistConfig.parse(input), - defaultOptions, + configSchema: zPersistConfig, // prettier-ignore events: [ diff --git a/backend/src/plugins/Persist/info.ts b/backend/src/plugins/Persist/docs.ts similarity index 73% rename from backend/src/plugins/Persist/info.ts rename to backend/src/plugins/Persist/docs.ts index 122b3eeb..d90a0b66 100644 --- a/backend/src/plugins/Persist/info.ts +++ b/backend/src/plugins/Persist/docs.ts @@ -1,13 +1,13 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zPersistConfig } from "./types.js"; -export const persistPluginInfo: ZeppelinPluginInfo = { +export const persistPluginDocs: ZeppelinPluginDocs = { prettyName: "Persist", description: trimPluginDescription(` Re-apply roles or nicknames for users when they rejoin the server. Mute roles are re-applied automatically, this plugin is not required for that. `), configSchema: zPersistConfig, - showInDocs: true, + type: "stable", }; diff --git a/backend/src/plugins/Persist/events/LoadDataEvt.ts b/backend/src/plugins/Persist/events/LoadDataEvt.ts index 799483a5..dcde2b97 100644 --- a/backend/src/plugins/Persist/events/LoadDataEvt.ts +++ b/backend/src/plugins/Persist/events/LoadDataEvt.ts @@ -1,6 +1,6 @@ import { GuildMember, PermissionFlagsBits } from "discord.js"; import { GuildPluginData } from "knub"; -import intersection from "lodash/intersection.js"; +import { intersection } from "lodash-es"; import { PersistedData } from "../../../data/entities/PersistedData.js"; import { SECONDS } from "../../../utils.js"; import { canAssignRole } from "../../../utils/canAssignRole.js"; diff --git a/backend/src/plugins/Persist/types.ts b/backend/src/plugins/Persist/types.ts index 6ce5d1df..bb48a1fd 100644 --- a/backend/src/plugins/Persist/types.ts +++ b/backend/src/plugins/Persist/types.ts @@ -1,18 +1,17 @@ import { BasePluginType, guildPluginEventListener } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildPersistedData } from "../../data/GuildPersistedData.js"; import { zSnowflake } from "../../utils.js"; export const zPersistConfig = z.strictObject({ - persisted_roles: z.array(zSnowflake), - persist_nicknames: z.boolean(), - persist_voice_mutes: z.boolean(), + persisted_roles: z.array(zSnowflake).default([]), + persist_nicknames: z.boolean().default(false), + persist_voice_mutes: z.boolean().default(false), }); export interface PersistPluginType extends BasePluginType { - config: z.infer; - + configSchema: typeof zPersistConfig; state: { persistedData: GuildPersistedData; logs: GuildLogs; diff --git a/backend/src/plugins/Phisherman/PhishermanPlugin.ts b/backend/src/plugins/Phisherman/PhishermanPlugin.ts index 3e1b4a50..61078776 100644 --- a/backend/src/plugins/Phisherman/PhishermanPlugin.ts +++ b/backend/src/plugins/Phisherman/PhishermanPlugin.ts @@ -1,49 +1,7 @@ -import { PluginOptions, guildPlugin } from "knub"; -import { hasPhishermanMasterAPIKey, phishermanApiKeyIsValid } from "../../data/Phisherman.js"; -import { makePublicFn } from "../../pluginUtils.js"; -import { getDomainInfo } from "./functions/getDomainInfo.js"; +import { guildPlugin } from "knub"; import { PhishermanPluginType, zPhishermanConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - api_key: null, - }, - overrides: [], -}; - export const PhishermanPlugin = guildPlugin()({ name: "phisherman", - - configParser: (input) => zPhishermanConfig.parse(input), - defaultOptions, - - public(pluginData) { - return { - getDomainInfo: makePublicFn(pluginData, getDomainInfo), - }; - }, - - async beforeLoad(pluginData) { - const { state } = pluginData; - - pluginData.state.validApiKey = null; - - if (!hasPhishermanMasterAPIKey()) { - // tslint:disable-next-line:no-console - console.warn("[PHISHERMAN] Could not load Phisherman plugin: master API key is missing"); - return; - } - - const apiKey = pluginData.config.get().api_key; - if (apiKey) { - const isValid = await phishermanApiKeyIsValid(apiKey).catch((err) => { - // tslint:disable-next-line:no-console - console.warn(`[PHISHERMAN] Error checking user API key validity:\n${err.toString()}`); - return false; - }); - if (isValid) { - state.validApiKey = apiKey; - } - } - }, + configSchema: zPhishermanConfig, }); diff --git a/backend/src/plugins/Phisherman/docs.ts b/backend/src/plugins/Phisherman/docs.ts new file mode 100644 index 00000000..fc429bfa --- /dev/null +++ b/backend/src/plugins/Phisherman/docs.ts @@ -0,0 +1,15 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { trimPluginDescription } from "../../utils.js"; +import { zPhishermanConfig } from "./types.js"; + +export const phishermanPluginDocs: ZeppelinPluginDocs = { + prettyName: "Phisherman", + type: "legacy", + description: trimPluginDescription(` + Match malicious links using Phisherman + `), + configurationGuide: trimPluginDescription(` + This plugin has been deprecated. Please use the \`include_malicious\` option for automod \`match_links\` trigger instead. + `), + configSchema: zPhishermanConfig, +}; diff --git a/backend/src/plugins/Phisherman/functions/getDomainInfo.ts b/backend/src/plugins/Phisherman/functions/getDomainInfo.ts deleted file mode 100644 index b8fabcb8..00000000 --- a/backend/src/plugins/Phisherman/functions/getDomainInfo.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { GuildPluginData } from "knub"; -import { - getPhishermanDomainInfo, - phishermanDomainIsSafe, - trackPhishermanCaughtDomain, -} from "../../../data/Phisherman.js"; -import { PhishermanDomainInfo } from "../../../data/types/phisherman.js"; -import { PhishermanPluginType } from "../types.js"; - -export async function getDomainInfo( - pluginData: GuildPluginData, - domain: string, -): Promise { - if (!pluginData.state.validApiKey) { - return null; - } - - const info = await getPhishermanDomainInfo(domain).catch((err) => { - // tslint:disable-next-line:no-console - console.warn(`[PHISHERMAN] Error in getDomainInfo() for server ${pluginData.guild.id}: ${err.message}`); - if (err.message === "missing permissions") { - pluginData.state.validApiKey = null; - } - return null; - }); - if (info != null && !phishermanDomainIsSafe(info)) { - trackPhishermanCaughtDomain(pluginData.state.validApiKey, domain); - } - - return info; -} diff --git a/backend/src/plugins/Phisherman/info.ts b/backend/src/plugins/Phisherman/info.ts deleted file mode 100644 index 88922112..00000000 --- a/backend/src/plugins/Phisherman/info.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { trimPluginDescription } from "../../utils.js"; -import { zPhishermanConfig } from "./types.js"; - -export const phishermanPluginInfo: ZeppelinPluginInfo = { - prettyName: "Phisherman", - description: trimPluginDescription(` - Match scam/phishing links using the Phisherman API. See https://phisherman.gg/ for more details! - `), - configurationGuide: trimPluginDescription(` - ### Getting started - To get started, request an API key for Phisherman following the instructions at https://docs.phisherman.gg/guide/getting-started.html#requesting-api-access - Then, add the api key to the plugin's config: - - ~~~yml - phisherman: - config: - api_key: "your key here" - ~~~ - - ### Note - When using Phisherman features in Zeppelin, Zeppelin reports statistics about checked links back to Phisherman. This only includes the domain (e.g. zeppelin.gg), not the full link. - - ### Usage with Automod - Once you have configured the Phisherman plugin, you are ready to use it with automod. Currently, Phisherman is available as an option in the \`match_links\` plugin: - - ~~~yml - automod: - config: - rules: - # Clean any scam links detected by Phisherman - filter_scam_links: - triggers: - - match_links: - phisherman: - include_suspected: true # It's recommended to keep this enabled to catch new scam domains quickly - include_verified: true - actions: - clean: true - ~~~ - `), - configSchema: zPhishermanConfig, - showInDocs: true, -}; diff --git a/backend/src/plugins/Phisherman/types.ts b/backend/src/plugins/Phisherman/types.ts index d21eb38e..b483cc92 100644 --- a/backend/src/plugins/Phisherman/types.ts +++ b/backend/src/plugins/Phisherman/types.ts @@ -1,14 +1,11 @@ import { BasePluginType } from "knub"; -import z from "zod"; +import z from "zod/v4"; export const zPhishermanConfig = z.strictObject({ - api_key: z.string().max(255).nullable(), + api_key: z.string().max(255).nullable().default(null), }); export interface PhishermanPluginType extends BasePluginType { - config: z.infer; - - state: { - validApiKey: string | null; - }; + configSchema: typeof zPhishermanConfig; + state: {}; } diff --git a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts index 6f75e747..2790dc5b 100644 --- a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts +++ b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts @@ -1,14 +1,15 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildPingableRoles } from "../../data/GuildPingableRoles.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { PingableRoleDisableCmd } from "./commands/PingableRoleDisableCmd.js"; import { PingableRoleEnableCmd } from "./commands/PingableRoleEnableCmd.js"; import { PingableRolesPluginType, zPingableRolesConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - can_manage: false, - }, - overrides: [ +export const PingableRolesPlugin = guildPlugin()({ + name: "pingable_roles", + + configSchema: zPingableRolesConfig, + defaultOverrides: [ { level: ">=100", config: { @@ -16,13 +17,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const PingableRolesPlugin = guildPlugin()({ - name: "pingable_roles", - - configParser: (input) => zPingableRolesConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -44,4 +38,8 @@ export const PingableRolesPlugin = guildPlugin()({ state.cache = new Map(); state.timeouts = new Map(); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts b/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts index e27cd146..cc0d9887 100644 --- a/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts +++ b/backend/src/plugins/PingableRoles/commands/PingableRoleDisableCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { pingableRolesCmd } from "../types.js"; export const PingableRoleDisableCmd = pingableRolesCmd({ @@ -14,16 +13,18 @@ export const PingableRoleDisableCmd = pingableRolesCmd({ async run({ message: msg, args, pluginData }) { const pingableRole = await pluginData.state.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); if (!pingableRole) { - sendErrorMessage(pluginData, msg.channel, `**${args.role.name}** is not set as pingable in <#${args.channelId}>`); + void pluginData.state.common.sendErrorMessage( + msg, + `**${args.role.name}** is not set as pingable in <#${args.channelId}>`, + ); return; } await pluginData.state.pingableRoles.delete(args.channelId, args.role.id); pluginData.state.cache.delete(args.channelId); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `**${args.role.name}** is no longer set as pingable in <#${args.channelId}>`, ); }, diff --git a/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts b/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts index 6c12d7a5..20b3cc8f 100644 --- a/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts +++ b/backend/src/plugins/PingableRoles/commands/PingableRoleEnableCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { pingableRolesCmd } from "../types.js"; export const PingableRoleEnableCmd = pingableRolesCmd({ @@ -17,9 +16,8 @@ export const PingableRoleEnableCmd = pingableRolesCmd({ args.role.id, ); if (existingPingableRole) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `**${args.role.name}** is already set as pingable in <#${args.channelId}>`, ); return; @@ -28,9 +26,8 @@ export const PingableRoleEnableCmd = pingableRolesCmd({ await pluginData.state.pingableRoles.add(args.channelId, args.role.id); pluginData.state.cache.delete(args.channelId); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `**${args.role.name}** has been set as pingable in <#${args.channelId}>`, ); }, diff --git a/backend/src/plugins/PingableRoles/docs.ts b/backend/src/plugins/PingableRoles/docs.ts new file mode 100644 index 00000000..12d60af9 --- /dev/null +++ b/backend/src/plugins/PingableRoles/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zPingableRolesConfig } from "./types.js"; + +export const pingableRolesPluginDocs: ZeppelinPluginDocs = { + prettyName: "Pingable roles", + configSchema: zPingableRolesConfig, + type: "stable", +}; diff --git a/backend/src/plugins/PingableRoles/info.ts b/backend/src/plugins/PingableRoles/info.ts deleted file mode 100644 index f0823fc8..00000000 --- a/backend/src/plugins/PingableRoles/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zPingableRolesConfig } from "./types.js"; - -export const pingableRolesPluginInfo: ZeppelinPluginInfo = { - prettyName: "Pingable roles", - configSchema: zPingableRolesConfig, - showInDocs: true, -}; diff --git a/backend/src/plugins/PingableRoles/types.ts b/backend/src/plugins/PingableRoles/types.ts index d2e00988..58f3c636 100644 --- a/backend/src/plugins/PingableRoles/types.ts +++ b/backend/src/plugins/PingableRoles/types.ts @@ -1,19 +1,20 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildPingableRoles } from "../../data/GuildPingableRoles.js"; import { PingableRole } from "../../data/entities/PingableRole.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zPingableRolesConfig = z.strictObject({ - can_manage: z.boolean(), + can_manage: z.boolean().default(false), }); export interface PingableRolesPluginType extends BasePluginType { - config: z.infer; - + configSchema: typeof zPingableRolesConfig; state: { pingableRoles: GuildPingableRoles; cache: Map; timeouts: Map; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts index f843179d..47c9e62c 100644 --- a/backend/src/plugins/Post/PostPlugin.ts +++ b/backend/src/plugins/Post/PostPlugin.ts @@ -1,8 +1,9 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { EditCmd } from "./commands/EditCmd.js"; @@ -15,11 +16,12 @@ import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd.js"; import { PostPluginType, zPostConfig } from "./types.js"; import { postScheduledPost } from "./util/postScheduledPost.js"; -const defaultOptions: PluginOptions = { - config: { - can_post: false, - }, - overrides: [ +export const PostPlugin = guildPlugin()({ + name: "post", + + dependencies: () => [TimeAndDatePlugin, LogsPlugin], + configSchema: zPostConfig, + defaultOverrides: [ { level: ">=100", config: { @@ -27,14 +29,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const PostPlugin = guildPlugin()({ - name: "post", - - dependencies: () => [TimeAndDatePlugin, LogsPlugin], - configParser: (input) => zPostConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -55,6 +49,10 @@ export const PostPlugin = guildPlugin()({ state.logs = new GuildLogs(guild.id); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Post/commands/EditCmd.ts b/backend/src/plugins/Post/commands/EditCmd.ts index 9568da19..e3867dbb 100644 --- a/backend/src/plugins/Post/commands/EditCmd.ts +++ b/backend/src/plugins/Post/commands/EditCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { postCmd } from "../types.js"; import { formatContent } from "../util/formatContent.js"; @@ -15,18 +14,18 @@ export const EditCmd = postCmd({ async run({ message: msg, args, pluginData }) { const targetMessage = await args.message.channel.messages.fetch(args.message.messageId); if (!targetMessage) { - sendErrorMessage(pluginData, msg.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown message"); return; } if (targetMessage.author.id !== pluginData.client.user!.id) { - sendErrorMessage(pluginData, msg.channel, "Message wasn't posted by me"); + void pluginData.state.common.sendErrorMessage(msg, "Message wasn't posted by me"); return; } targetMessage.channel.messages.edit(targetMessage.id, { content: formatContent(args.content), }); - sendSuccessMessage(pluginData, msg.channel, "Message edited"); + void pluginData.state.common.sendSuccessMessage(msg, "Message edited"); }, }); diff --git a/backend/src/plugins/Post/commands/EditEmbedCmd.ts b/backend/src/plugins/Post/commands/EditEmbedCmd.ts index 2e69d915..01da2c14 100644 --- a/backend/src/plugins/Post/commands/EditEmbedCmd.ts +++ b/backend/src/plugins/Post/commands/EditEmbedCmd.ts @@ -1,6 +1,5 @@ import { APIEmbed } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { isValidEmbed, trimLines } from "../../../utils.js"; import { parseColor } from "../../../utils/parseColor.js"; import { rgbToInt } from "../../../utils/rgbToInt.js"; @@ -30,14 +29,14 @@ export const EditEmbedCmd = postCmd({ if (colorRgb) { color = rgbToInt(colorRgb); } else { - sendErrorMessage(pluginData, msg.channel, "Invalid color specified"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid color specified"); return; } } const targetMessage = await args.message.channel.messages.fetch(args.message.messageId); if (!targetMessage) { - sendErrorMessage(pluginData, msg.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown message"); return; } @@ -51,12 +50,12 @@ export const EditEmbedCmd = postCmd({ try { parsed = JSON.parse(content); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Syntax error in embed JSON: ${e.message}`); + void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`); return; } if (!isValidEmbed(parsed)) { - sendErrorMessage(pluginData, msg.channel, "Embed is not valid"); + void pluginData.state.common.sendErrorMessage(msg, "Embed is not valid"); return; } @@ -69,7 +68,7 @@ export const EditEmbedCmd = postCmd({ args.message.channel.messages.edit(targetMessage.id, { embeds: [embed], }); - await sendSuccessMessage(pluginData, msg.channel, "Embed edited"); + await pluginData.state.common.sendSuccessMessage(msg, "Embed edited"); if (args.content) { const prefix = pluginData.fullConfig.prefix || "!"; diff --git a/backend/src/plugins/Post/commands/PostEmbedCmd.ts b/backend/src/plugins/Post/commands/PostEmbedCmd.ts index 0b040524..a3a5e60c 100644 --- a/backend/src/plugins/Post/commands/PostEmbedCmd.ts +++ b/backend/src/plugins/Post/commands/PostEmbedCmd.ts @@ -1,6 +1,5 @@ import { APIEmbed } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { isValidEmbed, trimLines } from "../../../utils.js"; import { parseColor } from "../../../utils/parseColor.js"; import { rgbToInt } from "../../../utils/rgbToInt.js"; @@ -31,7 +30,7 @@ export const PostEmbedCmd = postCmd({ const content = args.content || args.maincontent; if (!args.title && !content) { - sendErrorMessage(pluginData, msg.channel, "Title or content required"); + void pluginData.state.common.sendErrorMessage(msg, "Title or content required"); return; } @@ -41,7 +40,7 @@ export const PostEmbedCmd = postCmd({ if (colorRgb) { color = rgbToInt(colorRgb); } else { - sendErrorMessage(pluginData, msg.channel, "Invalid color specified"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid color specified"); return; } } @@ -56,12 +55,12 @@ export const PostEmbedCmd = postCmd({ try { parsed = JSON.parse(content); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Syntax error in embed JSON: ${e.message}`); + void pluginData.state.common.sendErrorMessage(msg, `Syntax error in embed JSON: ${e.message}`); return; } if (!isValidEmbed(parsed)) { - sendErrorMessage(pluginData, msg.channel, "Embed is not valid"); + void pluginData.state.common.sendErrorMessage(msg, "Embed is not valid"); return; } diff --git a/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts index 732ca1f8..3854ca94 100644 --- a/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts @@ -1,6 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { sorter } from "../../../utils.js"; import { postCmd } from "../types.js"; @@ -17,12 +16,12 @@ export const ScheduledPostsDeleteCmd = postCmd({ scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { - sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + void pluginData.state.common.sendErrorMessage(msg, "Scheduled post not found"); return; } clearUpcomingScheduledPost(post); await pluginData.state.scheduledPosts.delete(post.id); - sendSuccessMessage(pluginData, msg.channel, "Scheduled post deleted!"); + void pluginData.state.common.sendSuccessMessage(msg, "Scheduled post deleted!"); }, }); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts index 99f93597..c7902215 100644 --- a/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts @@ -1,6 +1,6 @@ import { escapeCodeBlock } from "discord.js"; -import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { createChunkedMessage, DBDateFormat, deactivateMentions, sorter, trimLines } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { postCmd } from "../types.js"; diff --git a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts index 6fbd17be..ea979f5c 100644 --- a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts +++ b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { sorter } from "../../../utils.js"; import { postCmd } from "../types.js"; import { postMessage } from "../util/postMessage.js"; @@ -17,7 +16,7 @@ export const ScheduledPostsShowCmd = postCmd({ scheduledPosts.sort(sorter("post_at")); const post = scheduledPosts[args.num - 1]; if (!post) { - sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + void pluginData.state.common.sendErrorMessage(msg, "Scheduled post not found"); return; } diff --git a/backend/src/plugins/Post/docs.ts b/backend/src/plugins/Post/docs.ts new file mode 100644 index 00000000..65e41b9d --- /dev/null +++ b/backend/src/plugins/Post/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zPostConfig } from "./types.js"; + +export const postPluginDocs: ZeppelinPluginDocs = { + prettyName: "Post", + configSchema: zPostConfig, + type: "stable", +}; diff --git a/backend/src/plugins/Post/info.ts b/backend/src/plugins/Post/info.ts deleted file mode 100644 index 94b30941..00000000 --- a/backend/src/plugins/Post/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zPostConfig } from "./types.js"; - -export const postPluginInfo: ZeppelinPluginInfo = { - prettyName: "Post", - configSchema: zPostConfig, - showInDocs: true, -}; diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts index 1c758b9c..0f9add61 100644 --- a/backend/src/plugins/Post/types.ts +++ b/backend/src/plugins/Post/types.ts @@ -1,19 +1,21 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zPostConfig = z.strictObject({ - can_post: z.boolean(), + can_post: z.boolean().default(false), }); export interface PostPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zPostConfig; state: { savedMessages: GuildSavedMessages; scheduledPosts: GuildScheduledPosts; logs: GuildLogs; + common: pluginUtils.PluginPublicInterface; unregisterGuildEventListener: () => void; }; diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts index 93f7ac5c..04c6def0 100644 --- a/backend/src/plugins/Post/util/actualPostCmd.ts +++ b/backend/src/plugins/Post/util/actualPostCmd.ts @@ -1,9 +1,8 @@ import { GuildTextBasedChannel, Message } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { DBDateFormat, MINUTES, StrictMessageContent, errorMessage, renderUsername } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; @@ -28,23 +27,29 @@ export async function actualPostCmd( "repeat-times"?: number; } = {}, ) { - if (!targetChannel.isTextBased()) { - msg.channel.send(errorMessage("Specified channel is not a text-based channel")); + if (!targetChannel.isSendable()) { + msg.reply(errorMessage("Specified channel is not a sendable channel")); return; } if (content == null && msg.attachments.size === 0) { - msg.channel.send(errorMessage("Message content or attachment required")); + msg.reply(errorMessage("Message content or attachment required")); return; } if (opts.repeat) { if (opts.repeat < MIN_REPEAT_TIME) { - sendErrorMessage(pluginData, msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`); + void pluginData.state.common.sendErrorMessage( + msg, + `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`, + ); return; } if (opts.repeat > MAX_REPEAT_TIME) { - sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); + void pluginData.state.common.sendErrorMessage( + msg, + `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`, + ); return; } } @@ -55,7 +60,7 @@ export async function actualPostCmd( // Schedule the post to be posted later postAt = await parseScheduleTime(pluginData, msg.author.id, opts.schedule); if (!postAt) { - sendErrorMessage(pluginData, msg.channel, "Invalid schedule time"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid schedule time"); return; } } else if (opts.repeat) { @@ -72,17 +77,16 @@ export async function actualPostCmd( // Invalid time if (!repeatUntil) { - sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid time specified for -repeat-until"); return; } if (repeatUntil.isBefore(moment.utc())) { - sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past"); + void pluginData.state.common.sendErrorMessage(msg, "You can't set -repeat-until in the past"); return; } if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", ); return; @@ -90,18 +94,24 @@ export async function actualPostCmd( } else if (opts["repeat-times"]) { repeatTimes = opts["repeat-times"]; if (repeatTimes <= 0) { - sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more"); + void pluginData.state.common.sendErrorMessage(msg, "-repeat-times must be 1 or more"); return; } } if (repeatUntil && repeatTimes) { - sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); + void pluginData.state.common.sendErrorMessage( + msg, + "You can only use one of -repeat-until or -repeat-times at once", + ); return; } if (opts.repeat && !repeatUntil && !repeatTimes) { - sendErrorMessage(pluginData, msg.channel, "You must specify -repeat-until or -repeat-times for repeated messages"); + void pluginData.state.common.sendErrorMessage( + msg, + "You must specify -repeat-until or -repeat-times for repeated messages", + ); return; } @@ -116,7 +126,7 @@ export async function actualPostCmd( // Save schedule/repeat information in DB if (postAt) { if (postAt < moment.utc()) { - sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past"); + void pluginData.state.common.sendErrorMessage(msg, "Post can't be scheduled to be posted in the past"); return; } @@ -192,6 +202,6 @@ export async function actualPostCmd( } if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) { - sendSuccessMessage(pluginData, msg.channel, successMessage); + void pluginData.state.common.sendSuccessMessage(msg, successMessage); } } diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts index a3fc731c..7c8e2ef7 100644 --- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -1,7 +1,8 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { Queue } from "../../Queue.js"; import { GuildReactionRoles } from "../../data/GuildReactionRoles.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd.js"; import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd.js"; @@ -10,19 +11,12 @@ import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt.js"; import { MessageDeletedEvt } from "./events/MessageDeletedEvt.js"; import { ReactionRolesPluginType, zReactionRolesConfig } from "./types.js"; -const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API +export const ReactionRolesPlugin = guildPlugin()({ + name: "reaction_roles", -const defaultOptions: PluginOptions = { - config: { - auto_refresh_interval: MIN_AUTO_REFRESH, - remove_user_reactions: true, - - can_manage: false, - - button_groups: null, - }, - - overrides: [ + dependencies: () => [LogsPlugin], + configSchema: zReactionRolesConfig, + defaultOverrides: [ { level: ">=100", config: { @@ -30,14 +24,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const ReactionRolesPlugin = guildPlugin()({ - name: "reaction_roles", - - dependencies: () => [LogsPlugin], - configParser: (input) => zReactionRolesConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -63,6 +49,10 @@ export const ReactionRolesPlugin = guildPlugin()({ state.pendingRefreshes = new Set(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const config = pluginData.config.get(); if (config.button_groups) { diff --git a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts index 09401a9d..5bfb5176 100644 --- a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts @@ -1,6 +1,5 @@ import { Message } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { isDiscordAPIError } from "../../../utils.js"; import { reactionRolesCmd } from "../types.js"; @@ -15,7 +14,7 @@ export const ClearReactionRolesCmd = reactionRolesCmd({ async run({ message: msg, args, pluginData }) { const existingReactionRoles = pluginData.state.reactionRoles.getForMessage(args.message.messageId); if (!existingReactionRoles) { - sendErrorMessage(pluginData, msg.channel, "Message doesn't have reaction roles on it"); + void pluginData.state.common.sendErrorMessage(msg, "Message doesn't have reaction roles on it"); return; } @@ -26,7 +25,7 @@ export const ClearReactionRolesCmd = reactionRolesCmd({ 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"); + void pluginData.state.common.sendErrorMessage(msg, "Missing access to the specified message"); return; } @@ -35,6 +34,6 @@ export const ClearReactionRolesCmd = reactionRolesCmd({ await targetMessage.reactions.removeAll(); - sendSuccessMessage(pluginData, msg.channel, "Reaction roles cleared"); + void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles cleared"); }, }); diff --git a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts index 949e773b..9212bf8e 100644 --- a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { resolveMessageMember } from "../../../pluginUtils.js"; import { canUseEmoji, isDiscordAPIError, isValidEmoji, noop, trimPluginDescription } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { TReactionRolePair, reactionRolesCmd } from "../types.js"; @@ -33,8 +33,12 @@ export const InitReactionRolesCmd = reactionRolesCmd({ }, async run({ message: msg, args, pluginData }) { - if (!canReadChannel(args.message.channel, msg.member)) { - sendErrorMessage(pluginData, msg.channel, "You can't add reaction roles to channels you can't see yourself"); + const member = await resolveMessageMember(msg); + if (!canReadChannel(args.message.channel, member)) { + void pluginData.state.common.sendErrorMessage( + msg, + "You can't add reaction roles to channels you can't see yourself", + ); return; } @@ -43,7 +47,7 @@ export const InitReactionRolesCmd = reactionRolesCmd({ 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}`); + void pluginData.state.common.sendErrorMessage(msg, `Error ${e.code} while getting message: ${e.message}`); return; } @@ -71,30 +75,28 @@ export const InitReactionRolesCmd = reactionRolesCmd({ // Verify the specified emojis and roles are valid and usable for (const pair of emojiRolePairs) { if (pair[0] === CLEAR_ROLES_EMOJI) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`, ); return; } if (!isValidEmoji(pair[0])) { - sendErrorMessage(pluginData, msg.channel, `Invalid emoji: ${pair[0]}`); + void pluginData.state.common.sendErrorMessage(msg, `Invalid emoji: ${pair[0]}`); return; } if (!canUseEmoji(pluginData.client, pair[0])) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, "I can only use regular emojis and custom emojis from servers I'm on", ); return; } if (!pluginData.guild.roles.cache.has(pair[1] as Snowflake)) { - sendErrorMessage(pluginData, msg.channel, `Unknown role ${pair[1]}`); + void pluginData.state.common.sendErrorMessage(msg, `Unknown role ${pair[1]}`); return; } } @@ -125,9 +127,9 @@ export const InitReactionRolesCmd = reactionRolesCmd({ ); if (errors?.length) { - sendErrorMessage(pluginData, msg.channel, `Errors while adding reaction roles:\n${errors.join("\n")}`); + void pluginData.state.common.sendErrorMessage(msg, `Errors while adding reaction roles:\n${errors.join("\n")}`); } else { - sendSuccessMessage(pluginData, msg.channel, "Reaction roles added"); + void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles added"); } (await progressMessage).delete().catch(noop); diff --git a/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts index f222e742..3dd6392a 100644 --- a/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts +++ b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { reactionRolesCmd } from "../types.js"; import { refreshReactionRoles } from "../util/refreshReactionRoles.js"; @@ -13,12 +12,12 @@ export const RefreshReactionRolesCmd = reactionRolesCmd({ async run({ message: msg, args, pluginData }) { if (pluginData.state.pendingRefreshes.has(`${args.message.channel.id}-${args.message.messageId}`)) { - sendErrorMessage(pluginData, msg.channel, "Another refresh in progress"); + void pluginData.state.common.sendErrorMessage(msg, "Another refresh in progress"); return; } await refreshReactionRoles(pluginData, args.message.channel.id, args.message.messageId); - sendSuccessMessage(pluginData, msg.channel, "Reaction roles refreshed"); + void pluginData.state.common.sendSuccessMessage(msg, "Reaction roles refreshed"); }, }); diff --git a/backend/src/plugins/ReactionRoles/info.ts b/backend/src/plugins/ReactionRoles/docs.ts similarity index 61% rename from backend/src/plugins/ReactionRoles/info.ts rename to backend/src/plugins/ReactionRoles/docs.ts index 6cc21dd3..964d8ca9 100644 --- a/backend/src/plugins/ReactionRoles/info.ts +++ b/backend/src/plugins/ReactionRoles/docs.ts @@ -1,10 +1,9 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { zReactionRolesConfig } from "./types.js"; -export const reactionRolesPluginInfo: ZeppelinPluginInfo = { +export const reactionRolesPluginDocs: ZeppelinPluginDocs = { prettyName: "Reaction roles", description: "Consider using the [Role buttons](https://zeppelin.gg/docs/plugins/role_buttons) plugin instead.", - legacy: true, + type: "legacy", configSchema: zReactionRolesConfig, - showInDocs: true, }; diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts index 5090d181..b22a4856 100644 --- a/backend/src/plugins/ReactionRoles/types.ts +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -1,14 +1,17 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { Queue } from "../../Queue.js"; import { GuildReactionRoles } from "../../data/GuildReactionRoles.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; + +const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API export const zReactionRolesConfig = z.strictObject({ - auto_refresh_interval: z.number(), - remove_user_reactions: z.boolean(), - can_manage: z.boolean(), - button_groups: z.nullable(z.unknown()), + auto_refresh_interval: z.number().min(MIN_AUTO_REFRESH).default(MIN_AUTO_REFRESH), + remove_user_reactions: z.boolean().default(true), + can_manage: z.boolean().default(false), + button_groups: z.null().default(null), }); export type RoleChangeMode = "+" | "-"; @@ -26,7 +29,7 @@ const zReactionRolePair = z.union([z.tuple([z.string(), z.string(), z.string()]) export type TReactionRolePair = z.infer; export interface ReactionRolesPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zReactionRolesConfig; state: { reactionRoles: GuildReactionRoles; savedMessages: GuildSavedMessages; @@ -37,6 +40,8 @@ export interface ReactionRolesPluginType extends BasePluginType { pendingRefreshes: Set; autoRefreshTimeout: NodeJS.Timeout; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts index dc317bd7..5a779288 100644 --- a/backend/src/plugins/Reminders/RemindersPlugin.ts +++ b/backend/src/plugins/Reminders/RemindersPlugin.ts @@ -1,6 +1,7 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { onGuildEvent } from "../../data/GuildEvents.js"; import { GuildReminders } from "../../data/GuildReminders.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { RemindCmd } from "./commands/RemindCmd.js"; import { RemindersCmd } from "./commands/RemindersCmd.js"; @@ -8,11 +9,12 @@ import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd.js"; import { postReminder } from "./functions/postReminder.js"; import { RemindersPluginType, zRemindersConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - can_use: false, - }, - overrides: [ +export const RemindersPlugin = guildPlugin()({ + name: "reminders", + + dependencies: () => [TimeAndDatePlugin], + configSchema: zRemindersConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -20,14 +22,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const RemindersPlugin = guildPlugin()({ - name: "reminders", - - dependencies: () => [TimeAndDatePlugin], - configParser: (input) => zRemindersConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -44,6 +38,10 @@ export const RemindersPlugin = guildPlugin()({ state.unloaded = false; }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts index 136a5c30..b6b1e04d 100644 --- a/backend/src/plugins/Reminders/commands/RemindCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts @@ -1,8 +1,7 @@ -import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { registerUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { convertDelayStringToMS, messageLink } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { remindersCmd } from "../types.js"; @@ -38,7 +37,7 @@ export const RemindCmd = remindersCmd({ // "Delay string" i.e. e.g. "2h30m" const ms = convertDelayStringToMS(args.time); if (ms === null) { - sendErrorMessage(pluginData, msg.channel, "Invalid reminder time"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid reminder time"); return; } @@ -46,7 +45,7 @@ export const RemindCmd = remindersCmd({ } if (!reminderTime.isValid() || reminderTime.isBefore(now)) { - sendErrorMessage(pluginData, msg.channel, "Invalid reminder time"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid reminder time"); return; } @@ -67,9 +66,8 @@ export const RemindCmd = remindersCmd({ pluginData.getPlugin(TimeAndDatePlugin).getDateFormat("pretty_datetime"), ); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `I will remind you in **${timeUntilReminder}** at **${prettyReminderTime}**`, ); }, diff --git a/backend/src/plugins/Reminders/commands/RemindersCmd.ts b/backend/src/plugins/Reminders/commands/RemindersCmd.ts index 04ef4125..18da87b0 100644 --- a/backend/src/plugins/Reminders/commands/RemindersCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindersCmd.ts @@ -1,6 +1,5 @@ -import humanizeDuration from "humanize-duration"; import moment from "moment-timezone"; -import { sendErrorMessage } from "../../../pluginUtils.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { createChunkedMessage, DBDateFormat, sorter } from "../../../utils.js"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin.js"; import { remindersCmd } from "../types.js"; @@ -12,7 +11,7 @@ export const RemindersCmd = remindersCmd({ async run({ message: msg, pluginData }) { const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id); if (reminders.length === 0) { - sendErrorMessage(pluginData, msg.channel, "No reminders"); + void pluginData.state.common.sendErrorMessage(msg, "No reminders"); return; } diff --git a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts index bd53a8f4..a1eb5533 100644 --- a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts +++ b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts @@ -1,6 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { clearUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { sorter } from "../../../utils.js"; import { remindersCmd } from "../types.js"; @@ -17,7 +16,7 @@ export const RemindersDeleteCmd = remindersCmd({ reminders.sort(sorter("remind_at")); if (args.num > reminders.length || args.num <= 0) { - sendErrorMessage(pluginData, msg.channel, "Unknown reminder"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown reminder"); return; } @@ -25,6 +24,6 @@ export const RemindersDeleteCmd = remindersCmd({ clearUpcomingReminder(toDelete); await pluginData.state.reminders.delete(toDelete.id); - sendSuccessMessage(pluginData, msg.channel, "Reminder deleted"); + void pluginData.state.common.sendSuccessMessage(msg, "Reminder deleted"); }, }); diff --git a/backend/src/plugins/Reminders/docs.ts b/backend/src/plugins/Reminders/docs.ts new file mode 100644 index 00000000..93268534 --- /dev/null +++ b/backend/src/plugins/Reminders/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zRemindersConfig } from "./types.js"; + +export const remindersPluginDocs: ZeppelinPluginDocs = { + prettyName: "Reminders", + configSchema: zRemindersConfig, + type: "stable", +}; diff --git a/backend/src/plugins/Reminders/info.ts b/backend/src/plugins/Reminders/info.ts deleted file mode 100644 index 02f04cac..00000000 --- a/backend/src/plugins/Reminders/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zRemindersConfig } from "./types.js"; - -export const remindersPluginInfo: ZeppelinPluginInfo = { - prettyName: "Reminders", - configSchema: zRemindersConfig, - showInDocs: true, -}; diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts index ae0daf24..66fd8438 100644 --- a/backend/src/plugins/Reminders/types.ts +++ b/backend/src/plugins/Reminders/types.ts @@ -1,17 +1,18 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildReminders } from "../../data/GuildReminders.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zRemindersConfig = z.strictObject({ - can_use: z.boolean(), + can_use: z.boolean().default(false), }); export interface RemindersPluginType extends BasePluginType { - config: z.infer; - + configSchema: typeof zRemindersConfig; state: { reminders: GuildReminders; tries: Map; + common: pluginUtils.PluginPublicInterface; unregisterGuildEventListener: () => void; diff --git a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts index 44b537b0..6826fdcb 100644 --- a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts +++ b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts @@ -1,5 +1,6 @@ import { guildPlugin } from "knub"; import { GuildRoleButtons } from "../../data/GuildRoleButtons.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { resetButtonsCmd } from "./commands/resetButtons.js"; @@ -10,22 +11,15 @@ import { RoleButtonsPluginType, zRoleButtonsConfig } from "./types.js"; export const RoleButtonsPlugin = guildPlugin()({ name: "role_buttons", - defaultOptions: { - config: { - buttons: {}, - can_reset: false, - }, - overrides: [ - { - level: ">=100", - config: { - can_reset: true, - }, + configSchema: zRoleButtonsConfig, + defaultOverrides: [ + { + level: ">=100", + config: { + can_reset: true, }, - ], - }, - - configParser: (input) => zRoleButtonsConfig.parse(input), + }, + ], dependencies: () => [LogsPlugin, RoleManagerPlugin], @@ -37,6 +31,10 @@ export const RoleButtonsPlugin = guildPlugin()({ pluginData.state.roleButtons = GuildRoleButtons.getGuildInstance(pluginData.guild.id); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + async afterLoad(pluginData) { await applyAllRoleButtons(pluginData); }, diff --git a/backend/src/plugins/RoleButtons/commands/resetButtons.ts b/backend/src/plugins/RoleButtons/commands/resetButtons.ts index 122820d4..6213e4d5 100644 --- a/backend/src/plugins/RoleButtons/commands/resetButtons.ts +++ b/backend/src/plugins/RoleButtons/commands/resetButtons.ts @@ -1,6 +1,5 @@ import { guildPluginMessageCommand } from "knub"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { applyAllRoleButtons } from "../functions/applyAllRoleButtons.js"; import { RoleButtonsPluginType } from "../types.js"; @@ -16,12 +15,12 @@ export const resetButtonsCmd = guildPluginMessageCommand( async run({ pluginData, args, message }) { const config = pluginData.config.get(); if (!config.buttons[args.name]) { - sendErrorMessage(pluginData, message.channel, `Can't find role buttons with the name "${args.name}"`); + void pluginData.state.common.sendErrorMessage(message, `Can't find role buttons with the name "${args.name}"`); return; } await pluginData.state.roleButtons.deleteRoleButtonItem(args.name); await applyAllRoleButtons(pluginData); - sendSuccessMessage(pluginData, message.channel, "Done!"); + void pluginData.state.common.sendSuccessMessage(message, "Done!"); }, }); diff --git a/backend/src/plugins/RoleButtons/info.ts b/backend/src/plugins/RoleButtons/docs.ts similarity index 95% rename from backend/src/plugins/RoleButtons/info.ts rename to backend/src/plugins/RoleButtons/docs.ts index 7eaedaba..f874f90a 100644 --- a/backend/src/plugins/RoleButtons/info.ts +++ b/backend/src/plugins/RoleButtons/docs.ts @@ -1,9 +1,9 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zRoleButtonsConfig } from "./types.js"; -export const roleButtonsPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const roleButtonsPluginDocs: ZeppelinPluginDocs = { + type: "stable", prettyName: "Role buttons", description: trimPluginDescription(` Allow users to pick roles by clicking on buttons diff --git a/backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts b/backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts index fb456cd3..8411efbd 100644 --- a/backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts +++ b/backend/src/plugins/RoleButtons/functions/applyAllRoleButtons.ts @@ -6,26 +6,27 @@ import { applyRoleButtons } from "./applyRoleButtons.js"; export async function applyAllRoleButtons(pluginData: GuildPluginData) { const savedRoleButtons = await pluginData.state.roleButtons.getSavedRoleButtons(); const config = pluginData.config.get(); - for (const buttons of Object.values(config.buttons)) { + for (const [configName, configItem] of Object.entries(config.buttons)) { // Use the hash of the config to quickly check if we need to update buttons - const hash = createHash("md5").update(JSON.stringify(buttons)).digest("hex"); - const savedButtonsItem = savedRoleButtons.find((bt) => bt.name === buttons.name); + const configItemToHash = { ...configItem, name: configName }; // Add name property for backwards compatibility + const hash = createHash("md5").update(JSON.stringify(configItemToHash)).digest("hex"); + const savedButtonsItem = savedRoleButtons.find((bt) => bt.name === configName); if (savedButtonsItem?.hash === hash) { // No changes continue; } if (savedButtonsItem) { - await pluginData.state.roleButtons.deleteRoleButtonItem(buttons.name); + await pluginData.state.roleButtons.deleteRoleButtonItem(configName); } - const applyResult = await applyRoleButtons(pluginData, buttons, savedButtonsItem ?? null); + const applyResult = await applyRoleButtons(pluginData, configItem, configName, savedButtonsItem ?? null); if (!applyResult) { return; } await pluginData.state.roleButtons.saveRoleButtonItem( - buttons.name, + configName, applyResult.channel_id, applyResult.message_id, hash, diff --git a/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts b/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts index 12d8bc3d..31bdbda4 100644 --- a/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts +++ b/backend/src/plugins/RoleButtons/functions/applyRoleButtons.ts @@ -8,6 +8,7 @@ import { createButtonComponents } from "./createButtonComponents.js"; export async function applyRoleButtons( pluginData: GuildPluginData, configItem: TRoleButtonsConfigItem, + configName: string, existingSavedButtons: RoleButtonsItem | null, ): Promise<{ channel_id: string; message_id: string } | null> { let message: Message; @@ -32,7 +33,7 @@ export async function applyRoleButtons( channel.messages.fetch(configItem.message.message_id).catch(() => null)); if (!messageCandidate) { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Message not found for role_buttons/${configItem.name}`, + body: `Message not found for role_buttons/${configName}`, }); return null; } @@ -45,7 +46,7 @@ export async function applyRoleButtons( : Boolean(configItem.message.content.content?.trim()) || configItem.message.content.embeds?.length; if (!contentIsValid) { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Invalid message content for role_buttons/${configItem.name}`, + body: `Invalid message content for role_buttons/${configName}`, }); return null; } @@ -58,7 +59,7 @@ export async function applyRoleButtons( } if (!channel || !channel?.isTextBased()) { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Text channel not found for role_buttons/${configItem.name}`, + body: `Text channel not found for role_buttons/${configName}`, }); return null; } @@ -89,7 +90,7 @@ export async function applyRoleButtons( candidateMessage = await channel.send(configItem.message.content as string | MessageCreateOptions); } catch (err) { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Error while posting message for role_buttons/${configItem.name}: ${String(err)}`, + body: `Error while posting message for role_buttons/${configName}: ${String(err)}`, }); return null; } @@ -100,16 +101,16 @@ export async function applyRoleButtons( if (message.author.id !== pluginData.client.user?.id) { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Error applying role buttons for role_buttons/${configItem.name}: target message must be posted by Zeppelin`, + body: `Error applying role buttons for role_buttons/${configName}: target message must be posted by Zeppelin`, }); return null; } // Apply role buttons - const components = createButtonComponents(configItem); + const components = createButtonComponents(configItem, configName); await message.edit({ components }).catch((err) => { pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Error applying role buttons for role_buttons/${configItem.name}: ${String(err)}`, + body: `Error applying role buttons for role_buttons/${configName}: ${String(err)}`, }); return null; }); diff --git a/backend/src/plugins/RoleButtons/functions/createButtonComponents.ts b/backend/src/plugins/RoleButtons/functions/createButtonComponents.ts index 8b56d3a9..c4b13b83 100644 --- a/backend/src/plugins/RoleButtons/functions/createButtonComponents.ts +++ b/backend/src/plugins/RoleButtons/functions/createButtonComponents.ts @@ -4,7 +4,10 @@ import { TRoleButtonsConfigItem } from "../types.js"; import { TooManyComponentsError } from "./TooManyComponentsError.js"; import { convertButtonStyleStringToEnum } from "./convertButtonStyleStringToEnum.js"; -export function createButtonComponents(configItem: TRoleButtonsConfigItem): Array> { +export function createButtonComponents( + configItem: TRoleButtonsConfigItem, + configName: string, +): Array> { const rows: Array> = []; let currentRow = new ActionRowBuilder(); @@ -17,7 +20,7 @@ export function createButtonComponents(configItem: TRoleButtonsConfigItem): Arra const button = new ButtonBuilder() .setLabel(option.label ?? "") .setStyle(convertButtonStyleStringToEnum(option.style) ?? ButtonStyle.Primary) - .setCustomId(buildCustomId("roleButtons", { name: configItem.name, index })); + .setCustomId(buildCustomId("roleButtons", { name: configName, index })); if (option.emoji) { button.setEmoji(option.emoji); diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts index 142b82e7..8abc0bef 100644 --- a/backend/src/plugins/RoleButtons/types.ts +++ b/backend/src/plugins/RoleButtons/types.ts @@ -1,8 +1,9 @@ import { ButtonStyle } from "discord.js"; -import { BasePluginType } from "knub"; -import z from "zod"; +import { BasePluginType, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildRoleButtons } from "../../data/GuildRoleButtons.js"; import { zBoundedCharacters, zBoundedRecord, zMessageContent, zSnowflake } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { TooManyComponentsError } from "./functions/TooManyComponentsError.js"; import { createButtonComponents } from "./functions/createButtonComponents.js"; @@ -33,22 +34,6 @@ export type TRoleButtonOption = z.infer; const zRoleButtonsConfigItem = z .strictObject({ - // Typed as "never" because you are not expected to supply this directly. - // The transform instead picks it up from the property key and the output type is a string. - name: z - .never() - .optional() - .transform((_, ctx) => { - const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); - if (!ruleName) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Role buttons must have names", - }); - return z.NEVER; - } - return ruleName; - }), message: z.union([ z.strictObject({ channel_id: zSnowflake, @@ -65,7 +50,7 @@ const zRoleButtonsConfigItem = z .refine( (parsed) => { try { - createButtonComponents(parsed); + createButtonComponents(parsed, "test"); // We can use any configName here } catch (err) { if (err instanceof TooManyComponentsError) { return false; @@ -82,8 +67,8 @@ export type TRoleButtonsConfigItem = z.infer; export const zRoleButtonsConfig = z .strictObject({ - buttons: zBoundedRecord(z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem), 0, 100), - can_reset: z.boolean(), + buttons: zBoundedRecord(z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem), 0, 100).default({}), + can_reset: z.boolean().default(false), }) .refine( (parsed) => { @@ -106,8 +91,9 @@ export const zRoleButtonsConfig = z ); export interface RoleButtonsPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zRoleButtonsConfig; state: { roleButtons: GuildRoleButtons; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts index 5137326f..ebfa294c 100644 --- a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts +++ b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts @@ -13,7 +13,7 @@ export const RoleManagerPlugin = guildPlugin()({ name: "role_manager", dependencies: () => [LogsPlugin], - configParser: (input) => zRoleManagerConfig.parse(input), + configSchema: zRoleManagerConfig, public(pluginData) { return { diff --git a/backend/src/plugins/RoleManager/docs.ts b/backend/src/plugins/RoleManager/docs.ts new file mode 100644 index 00000000..e142fb7f --- /dev/null +++ b/backend/src/plugins/RoleManager/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zRoleManagerConfig } from "./types.js"; + +export const roleManagerPluginDocs: ZeppelinPluginDocs = { + prettyName: "Role manager", + type: "internal", + configSchema: zRoleManagerConfig, +}; diff --git a/backend/src/plugins/RoleManager/info.ts b/backend/src/plugins/RoleManager/info.ts deleted file mode 100644 index 9b532990..00000000 --- a/backend/src/plugins/RoleManager/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zRoleManagerConfig } from "./types.js"; - -export const roleManagerPluginInfo: ZeppelinPluginInfo = { - prettyName: "Role manager", - showInDocs: false, - configSchema: zRoleManagerConfig, -}; diff --git a/backend/src/plugins/RoleManager/types.ts b/backend/src/plugins/RoleManager/types.ts index 1954d994..ecdcadc2 100644 --- a/backend/src/plugins/RoleManager/types.ts +++ b/backend/src/plugins/RoleManager/types.ts @@ -1,11 +1,11 @@ import { BasePluginType } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { GuildRoleQueue } from "../../data/GuildRoleQueue.js"; export const zRoleManagerConfig = z.strictObject({}); export interface RoleManagerPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zRoleManagerConfig; state: { roleQueue: GuildRoleQueue; roleAssignmentLoopRunning: boolean; diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts index cfd94ee2..03d3ec3d 100644 --- a/backend/src/plugins/Roles/RolesPlugin.ts +++ b/backend/src/plugins/Roles/RolesPlugin.ts @@ -1,5 +1,6 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildLogs } from "../../data/GuildLogs.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin.js"; import { AddRoleCmd } from "./commands/AddRoleCmd.js"; @@ -8,13 +9,12 @@ import { MassRemoveRoleCmd } from "./commands/MassRemoveRoleCmd.js"; import { RemoveRoleCmd } from "./commands/RemoveRoleCmd.js"; import { RolesPluginType, zRolesConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - can_assign: false, - can_mass_assign: false, - assignable_roles: [], - }, - overrides: [ +export const RolesPlugin = guildPlugin()({ + name: "roles", + + dependencies: () => [LogsPlugin, RoleManagerPlugin], + configSchema: zRolesConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -28,14 +28,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const RolesPlugin = guildPlugin()({ - name: "roles", - - dependencies: () => [LogsPlugin, RoleManagerPlugin], - configParser: (input) => zRolesConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -50,4 +42,8 @@ export const RolesPlugin = guildPlugin()({ state.logs = new GuildLogs(guild.id); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/Roles/commands/AddRoleCmd.ts b/backend/src/plugins/Roles/commands/AddRoleCmd.ts index 9581e087..76fcdbd1 100644 --- a/backend/src/plugins/Roles/commands/AddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/AddRoleCmd.ts @@ -1,6 +1,6 @@ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveRoleId, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -17,20 +17,21 @@ export const AddRoleCmd = rolesCmd({ }, async run({ message: msg, args, pluginData }) { - if (!canActOn(pluginData, msg.member, args.member, true)) { - sendErrorMessage(pluginData, msg.channel, "Cannot add roles to this user: insufficient permissions"); + const member = await resolveMessageMember(msg); + if (!canActOn(pluginData, member, args.member, true)) { + void pluginData.state.common.sendErrorMessage(msg, "Cannot add roles to this user: insufficient permissions"); return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } @@ -40,12 +41,12 @@ export const AddRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } if (args.member.roles.cache.has(roleId)) { - sendErrorMessage(pluginData, msg.channel, "Member already has that role"); + void pluginData.state.common.sendErrorMessage(msg, "Member already has that role"); return; } @@ -57,9 +58,8 @@ export const AddRoleCmd = rolesCmd({ roles: [role], }); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`, ); }, diff --git a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts index ae7f8b8f..7ad2432d 100644 --- a/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassAddRoleCmd.ts @@ -1,7 +1,7 @@ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { logger } from "../../../logger.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -19,6 +19,8 @@ export const MassAddRoleCmd = rolesCmd({ async run({ message: msg, args, pluginData }) { msg.channel.send(`Resolving members...`); + const authorMember = await resolveMessageMember(msg); + const members: GuildMember[] = []; const unknownMembers: string[] = []; for (const memberId of args.members) { @@ -28,10 +30,9 @@ export const MassAddRoleCmd = rolesCmd({ } for (const member of members) { - if (!canActOn(pluginData, msg.member, member, true)) { - sendErrorMessage( - pluginData, - msg.channel, + if (!canActOn(pluginData, authorMember, member, true)) { + void pluginData.state.common.sendErrorMessage( + msg, "Cannot add roles to 1 or more specified members: insufficient permissions", ); return; @@ -40,13 +41,13 @@ export const MassAddRoleCmd = rolesCmd({ const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } @@ -55,7 +56,7 @@ export const MassAddRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot assign that role"); return; } diff --git a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts index f00c7813..8b17eecc 100644 --- a/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/MassRemoveRoleCmd.ts @@ -1,6 +1,6 @@ import { GuildMember } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveMember, resolveRoleId, successMessage } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -18,6 +18,8 @@ export const MassRemoveRoleCmd = rolesCmd({ async run({ message: msg, args, pluginData }) { msg.channel.send(`Resolving members...`); + const authorMember = await resolveMessageMember(msg); + const members: GuildMember[] = []; const unknownMembers: string[] = []; for (const memberId of args.members) { @@ -27,10 +29,9 @@ export const MassRemoveRoleCmd = rolesCmd({ } for (const member of members) { - if (!canActOn(pluginData, msg.member, member, true)) { - sendErrorMessage( - pluginData, - msg.channel, + if (!canActOn(pluginData, authorMember, member, true)) { + void pluginData.state.common.sendErrorMessage( + msg, "Cannot add roles to 1 or more specified members: insufficient permissions", ); return; @@ -39,13 +40,13 @@ export const MassRemoveRoleCmd = rolesCmd({ const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } @@ -54,7 +55,7 @@ export const MassRemoveRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } diff --git a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts index 2677be8c..a1367a9e 100644 --- a/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts +++ b/backend/src/plugins/Roles/commands/RemoveRoleCmd.ts @@ -1,6 +1,6 @@ import { GuildChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { resolveRoleId, verboseUserMention } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { RoleManagerPlugin } from "../../RoleManager/RoleManagerPlugin.js"; @@ -17,20 +17,24 @@ export const RemoveRoleCmd = rolesCmd({ }, async run({ message: msg, args, pluginData }) { - if (!canActOn(pluginData, msg.member, args.member, true)) { - sendErrorMessage(pluginData, msg.channel, "Cannot remove roles from this user: insufficient permissions"); + const authorMember = await resolveMessageMember(msg); + if (!canActOn(pluginData, authorMember, args.member, true)) { + void pluginData.state.common.sendErrorMessage( + msg, + "Cannot remove roles from this user: insufficient permissions", + ); return; } const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); if (!roleId) { - sendErrorMessage(pluginData, msg.channel, "Invalid role id"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid role id"); return; } const config = await pluginData.config.getForMessage(msg); if (!config.assignable_roles.includes(roleId)) { - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } @@ -40,12 +44,12 @@ export const RemoveRoleCmd = rolesCmd({ pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Unknown role configured for 'roles' plugin: ${roleId}`, }); - sendErrorMessage(pluginData, msg.channel, "You cannot remove that role"); + void pluginData.state.common.sendErrorMessage(msg, "You cannot remove that role"); return; } if (!args.member.roles.cache.has(roleId)) { - sendErrorMessage(pluginData, msg.channel, "Member doesn't have that role"); + void pluginData.state.common.sendErrorMessage(msg, "Member doesn't have that role"); return; } @@ -56,9 +60,8 @@ export const RemoveRoleCmd = rolesCmd({ roles: [role], }); - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Removed role **${role.name}** from ${verboseUserMention(args.member.user)}!`, ); }, diff --git a/backend/src/plugins/Roles/info.ts b/backend/src/plugins/Roles/docs.ts similarity index 69% rename from backend/src/plugins/Roles/info.ts rename to backend/src/plugins/Roles/docs.ts index d946bf4f..a317db9f 100644 --- a/backend/src/plugins/Roles/info.ts +++ b/backend/src/plugins/Roles/docs.ts @@ -1,9 +1,9 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zRolesConfig } from "./types.js"; -export const rolesPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const rolesPluginDocs: ZeppelinPluginDocs = { + type: "stable", prettyName: "Roles", description: trimPluginDescription(` Enables authorised users to add and remove whitelisted roles with a command. diff --git a/backend/src/plugins/Roles/types.ts b/backend/src/plugins/Roles/types.ts index 2a0d4cc7..09c381f7 100644 --- a/backend/src/plugins/Roles/types.ts +++ b/backend/src/plugins/Roles/types.ts @@ -1,17 +1,19 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildLogs } from "../../data/GuildLogs.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zRolesConfig = z.strictObject({ - can_assign: z.boolean(), - can_mass_assign: z.boolean(), - assignable_roles: z.array(z.string()).max(100), + can_assign: z.boolean().default(false), + can_mass_assign: z.boolean().default(false), + assignable_roles: z.array(z.string()).max(100).default([]), }); export interface RolesPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zRolesConfig; state: { logs: GuildLogs; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts index e01b2f8f..04cc2826 100644 --- a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts +++ b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts @@ -1,21 +1,14 @@ -import { CooldownManager, PluginOptions, guildPlugin } from "knub"; +import { CooldownManager, guildPlugin } from "knub"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { RoleAddCmd } from "./commands/RoleAddCmd.js"; import { RoleHelpCmd } from "./commands/RoleHelpCmd.js"; import { RoleRemoveCmd } from "./commands/RoleRemoveCmd.js"; import { SelfGrantableRolesPluginType, zSelfGrantableRolesConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - entries: {}, - mention_roles: false, - }, -}; - export const SelfGrantableRolesPlugin = guildPlugin()({ name: "self_grantable_roles", - configParser: (input) => zSelfGrantableRolesConfig.parse(input), - defaultOptions, + configSchema: zSelfGrantableRolesConfig, // prettier-ignore messageCommands: [ @@ -27,4 +20,8 @@ export const SelfGrantableRolesPlugin = guildPlugin Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); lock.unlock(); return; } + const authorMember = await resolveMessageMember(msg); + // Grant the roles - const newRoleIds = new Set([...rolesToAdd.keys(), ...msg.member.roles.cache.keys()]); + const newRoleIds = new Set([...rolesToAdd.keys(), ...authorMember.roles.cache.keys()]); // Remove extra roles (max_roles) for each entry const skipped: Set = new Set(); @@ -69,7 +72,7 @@ export const RoleAddCmd = selfGrantableRolesCmd({ newRoleIds.delete(roleId); rolesToAdd.delete(roleId); - if (msg.member.roles.cache.has(roleId as Snowflake)) { + if (authorMember.roles.cache.has(roleId as Snowflake)) { removed.add(pluginData.guild.roles.cache.get(roleId as Snowflake)!); } else { skipped.add(pluginData.guild.roles.cache.get(roleId as Snowflake)!); @@ -80,15 +83,16 @@ export const RoleAddCmd = selfGrantableRolesCmd({ } try { - await msg.member.edit({ + await authorMember.edit({ roles: Array.from(newRoleIds) as Snowflake[], }); } catch { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `<@!${msg.author.id}> Got an error while trying to grant you the roles`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); return; } @@ -120,7 +124,7 @@ export const RoleAddCmd = selfGrantableRolesCmd({ messageParts.push("couldn't recognize some of the roles"); } - sendSuccessMessage(pluginData, msg.channel, `<@!${msg.author.id}> ${messageParts.join("; ")}`, { + void pluginData.state.common.sendSuccessMessage(msg, `<@!${msg.author.id}> ${messageParts.join("; ")}`, { users: [msg.author.id], }); diff --git a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts index aaec79e8..ca921962 100644 --- a/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts +++ b/backend/src/plugins/SelfGrantableRoles/commands/RoleRemoveCmd.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { resolveMessageMember } from "../../../pluginUtils.js"; import { memberRolesLock } from "../../../utils/lockNameHelpers.js"; import { selfGrantableRolesCmd } from "../types.js"; import { findMatchingRoles } from "../util/findMatchingRoles.js"; @@ -33,12 +33,14 @@ export const RoleRemoveCmd = selfGrantableRolesCmd({ ); const roleIdsToRemove = rolesToRemove.map((r) => r.id); + const authorMember = await resolveMessageMember(msg); + // Remove the roles if (rolesToRemove.length) { - const newRoleIds = msg.member.roles.cache.filter((role) => !roleIdsToRemove.includes(role.id)); + const newRoleIds = authorMember.roles.cache.filter((role) => !roleIdsToRemove.includes(role.id)); try { - await msg.member.edit({ + await authorMember.edit({ roles: newRoleIds, }); @@ -46,35 +48,37 @@ export const RoleRemoveCmd = selfGrantableRolesCmd({ const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles"; if (rolesToRemove.length !== roleNames.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` + ` couldn't recognize the other roles you mentioned`, { users: [msg.author.id] }, ); } else { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); } } catch { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `<@!${msg.author.id}> Got an error while trying to remove the roles`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); } } else { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`, - { users: [msg.author.id] }, + { + users: [msg.author.id], + }, ); } diff --git a/backend/src/plugins/SelfGrantableRoles/info.ts b/backend/src/plugins/SelfGrantableRoles/docs.ts similarity index 92% rename from backend/src/plugins/SelfGrantableRoles/info.ts rename to backend/src/plugins/SelfGrantableRoles/docs.ts index 40b6d451..cc5bb355 100644 --- a/backend/src/plugins/SelfGrantableRoles/info.ts +++ b/backend/src/plugins/SelfGrantableRoles/docs.ts @@ -1,9 +1,9 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zSelfGrantableRolesConfig } from "./types.js"; -export const selfGrantableRolesPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const selfGrantableRolesPluginDocs: ZeppelinPluginDocs = { + type: "stable", prettyName: "Self-grantable roles", description: trimPluginDescription(` Allows users to grant themselves roles via a command diff --git a/backend/src/plugins/SelfGrantableRoles/types.ts b/backend/src/plugins/SelfGrantableRoles/types.ts index edfb7e1c..84d95837 100644 --- a/backend/src/plugins/SelfGrantableRoles/types.ts +++ b/backend/src/plugins/SelfGrantableRoles/types.ts @@ -1,6 +1,7 @@ -import { BasePluginType, CooldownManager, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, CooldownManager, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { zBoundedCharacters, zBoundedRecord } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; const zRoleMap = z.record( zBoundedCharacters(1, 100), @@ -19,14 +20,15 @@ const zSelfGrantableRoleEntry = z.strictObject({ export type TSelfGrantableRoleEntry = z.infer; export const zSelfGrantableRolesConfig = z.strictObject({ - entries: zBoundedRecord(z.record(zBoundedCharacters(0, 255), zSelfGrantableRoleEntry), 0, 100), - mention_roles: z.boolean(), + entries: zBoundedRecord(z.record(zBoundedCharacters(0, 255), zSelfGrantableRoleEntry), 0, 100).default({}), + mention_roles: z.boolean().default(false), }); export interface SelfGrantableRolesPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zSelfGrantableRolesConfig; state: { cooldowns: CooldownManager; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts index 34d06699..84d6e523 100644 --- a/backend/src/plugins/Slowmode/SlowmodePlugin.ts +++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts @@ -1,8 +1,9 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSlowmodes } from "../../data/GuildSlowmodes.js"; import { SECONDS } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { SlowmodeClearCmd } from "./commands/SlowmodeClearCmd.js"; import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd.js"; @@ -15,25 +16,6 @@ import { onMessageCreate } from "./util/onMessageCreate.js"; const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * SECONDS; -const defaultOptions: PluginOptions = { - config: { - use_native_slowmode: true, - - can_manage: false, - is_affected: true, - }, - - overrides: [ - { - level: ">=50", - config: { - can_manage: true, - is_affected: false, - }, - }, - ], -}; - export const SlowmodePlugin = guildPlugin()({ name: "slowmode", @@ -42,8 +24,16 @@ export const SlowmodePlugin = guildPlugin()({ LogsPlugin, ], - configParser: (input) => zSlowmodeConfig.parse(input), - defaultOptions, + configSchema: zSlowmodeConfig, + defaultOverrides: [ + { + level: ">=50", + config: { + can_manage: true, + is_affected: false, + }, + }, + ], // prettier-ignore messageCommands: [ @@ -63,6 +53,10 @@ export const SlowmodePlugin = guildPlugin()({ state.channelSlowmodeCache = new Map(); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts index cf2cff3d..e2b4569f 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts @@ -1,6 +1,5 @@ import { ChannelType, escapeInlineCode } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { asSingleLine, renderUsername } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -22,16 +21,15 @@ export const SlowmodeClearCmd = slowmodeCmd({ async run({ message: msg, args, pluginData }) { const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); if (!channelSlowmode) { - sendErrorMessage(pluginData, msg.channel, "Channel doesn't have slowmode!"); + void pluginData.state.common.sendErrorMessage(msg, "Channel doesn't have slowmode!"); return; } const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!; const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_CLEAR_PERMISSIONS); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to clear slowmode. ${missingPermissionError(missingPermissions)}`, ); return; @@ -41,9 +39,8 @@ export const SlowmodeClearCmd = slowmodeCmd({ if (args.channel.type === ChannelType.GuildText) { await clearBotSlowmodeFromUserId(pluginData, args.channel, args.user.id, args.force); } else { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, asSingleLine(` Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>: Threads cannot have Bot Slowmode @@ -52,9 +49,8 @@ export const SlowmodeClearCmd = slowmodeCmd({ return; } } catch (e) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, asSingleLine(` Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>: \`${escapeInlineCode(e.message)}\` @@ -63,9 +59,8 @@ export const SlowmodeClearCmd = slowmodeCmd({ return; } - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Slowmode cleared from **${renderUsername(args.user)}** in <#${args.channel.id}>`, ); }, diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeGetCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeGetCmd.ts index e98160b4..a8f334cd 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeGetCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeGetCmd.ts @@ -1,5 +1,5 @@ -import humanizeDuration from "humanize-duration"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { slowmodeCmd } from "../types.js"; export const SlowmodeGetCmd = slowmodeCmd({ diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts index 043d7d5a..276e4bdf 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts @@ -1,6 +1,6 @@ import { GuildChannel, TextChannel } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { createChunkedMessage } from "knub/helpers"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { errorMessage } from "../../../utils.js"; import { slowmodeCmd } from "../types.js"; diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts index 82ebec82..b1d18804 100644 --- a/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts +++ b/backend/src/plugins/Slowmode/commands/SlowmodeSetCmd.ts @@ -1,7 +1,6 @@ import { escapeInlineCode, PermissionsBitField } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { asSingleLine, DAYS, HOURS, MINUTES } from "../../../utils.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -40,7 +39,7 @@ export const SlowmodeSetCmd = slowmodeCmd({ const channel = args.channel || msg.channel; if (!channel.isTextBased() || channel.isThread()) { - sendErrorMessage(pluginData, msg.channel, "Slowmode can only be set on non-thread text-based channels"); + void pluginData.state.common.sendErrorMessage(msg, "Slowmode can only be set on non-thread text-based channels"); return; } @@ -56,29 +55,27 @@ export const SlowmodeSetCmd = slowmodeCmd({ const mode = (args.mode as TMode) || defaultMode; if (!validModes.includes(mode)) { - sendErrorMessage(pluginData, msg.channel, "--mode must be 'bot' or 'native'"); + void pluginData.state.common.sendErrorMessage(msg, "--mode must be 'bot' or 'native'"); return; } // Validate durations if (mode === "native" && args.time > MAX_NATIVE_SLOWMODE) { - sendErrorMessage(pluginData, msg.channel, "Native slowmode can only be set to 6h or less"); + void pluginData.state.common.sendErrorMessage(msg, "Native slowmode can only be set to 6h or less"); return; } if (mode === "bot" && args.time > MAX_BOT_SLOWMODE) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Sorry, bot managed slowmodes can be at most 100 years long. Maybe 99 would be enough?`, ); return; } if (mode === "bot" && args.time < MIN_BOT_SLOWMODE) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, asSingleLine(` Bot managed slowmode must be 15min or more. Use \`--mode native\` to use native slowmodes for short slowmodes instead. @@ -96,9 +93,8 @@ export const SlowmodeSetCmd = slowmodeCmd({ NATIVE_SLOWMODE_PERMISSIONS, ); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to set native slowmode. ${missingPermissionError(missingPermissions)}`, ); return; @@ -111,9 +107,8 @@ export const SlowmodeSetCmd = slowmodeCmd({ BOT_SLOWMODE_PERMISSIONS, ); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to set bot managed slowmode. ${missingPermissionError(missingPermissions)}`, ); return; @@ -134,7 +129,10 @@ export const SlowmodeSetCmd = slowmodeCmd({ try { await channel.setRateLimitPerUser(rateLimitSeconds); } catch (e) { - sendErrorMessage(pluginData, msg.channel, `Failed to set native slowmode: ${escapeInlineCode(e.message)}`); + void pluginData.state.common.sendErrorMessage( + msg, + `Failed to set native slowmode: ${escapeInlineCode(e.message)}`, + ); return; } } else { @@ -153,9 +151,8 @@ export const SlowmodeSetCmd = slowmodeCmd({ const humanizedSlowmodeTime = humanizeDuration(args.time); const slowmodeType = mode === "native" ? "native slowmode" : "bot-maintained slowmode"; - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`, ); }, diff --git a/backend/src/plugins/Slowmode/docs.ts b/backend/src/plugins/Slowmode/docs.ts new file mode 100644 index 00000000..7ff3d509 --- /dev/null +++ b/backend/src/plugins/Slowmode/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zSlowmodeConfig } from "./types.js"; + +export const slowmodePluginDocs: ZeppelinPluginDocs = { + type: "stable", + prettyName: "Slowmode", + configSchema: zSlowmodeConfig, +}; diff --git a/backend/src/plugins/Slowmode/info.ts b/backend/src/plugins/Slowmode/info.ts deleted file mode 100644 index b223b39c..00000000 --- a/backend/src/plugins/Slowmode/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zSlowmodeConfig } from "./types.js"; - -export const slowmodePluginInfo: ZeppelinPluginInfo = { - showInDocs: true, - prettyName: "Slowmode", - configSchema: zSlowmodeConfig, -}; diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts index acdd611a..8b064673 100644 --- a/backend/src/plugins/Slowmode/types.ts +++ b/backend/src/plugins/Slowmode/types.ts @@ -1,19 +1,20 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSlowmodes } from "../../data/GuildSlowmodes.js"; import { SlowmodeChannel } from "../../data/entities/SlowmodeChannel.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zSlowmodeConfig = z.strictObject({ - use_native_slowmode: z.boolean(), + use_native_slowmode: z.boolean().default(true), - can_manage: z.boolean(), - is_affected: z.boolean(), + can_manage: z.boolean().default(false), + is_affected: z.boolean().default(true), }); export interface SlowmodePluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zSlowmodeConfig; state: { slowmodes: GuildSlowmodes; savedMessages: GuildSavedMessages; @@ -21,6 +22,7 @@ export interface SlowmodePluginType extends BasePluginType { clearInterval: NodeJS.Timeout; serverLogs: GuildLogs; channelSlowmodeCache: Map; + common: pluginUtils.PluginPublicInterface; onMessageCreateFn; }; diff --git a/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts index d669f05a..b7d71c1a 100644 --- a/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts +++ b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts @@ -1,5 +1,4 @@ import { Message } from "discord.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { noop } from "../../../utils.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { missingPermissionError } from "../../../utils/missingPermissionError.js"; @@ -11,22 +10,21 @@ export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) { const hasNativeSlowmode = args.channel.rateLimitPerUser; if (!botSlowmode && hasNativeSlowmode === 0) { - sendErrorMessage(pluginData, msg.channel, "Channel is not on slowmode!"); + void pluginData.state.common.sendErrorMessage(msg, "Channel is not on slowmode!"); return; } const me = pluginData.guild.members.cache.get(pluginData.client.user!.id); const missingPermissions = getMissingChannelPermissions(me, args.channel, BOT_SLOWMODE_DISABLE_PERMISSIONS); if (missingPermissions) { - sendErrorMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendErrorMessage( + msg, `Unable to disable slowmode. ${missingPermissionError(missingPermissions)}`, ); return; } - const initMsg = await msg.channel.send("Disabling slowmode..."); + const initMsg = await msg.reply("Disabling slowmode..."); // Disable bot-maintained slowmode let failedUsers: string[] = []; @@ -41,13 +39,12 @@ export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) { } if (failedUsers.length) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`, ); } else { - sendSuccessMessage(pluginData, msg.channel, "Slowmode disabled!"); + void pluginData.state.common.sendSuccessMessage(msg, "Slowmode disabled!"); initMsg.delete().catch(noop); } } diff --git a/backend/src/plugins/Spam/SpamPlugin.ts b/backend/src/plugins/Spam/SpamPlugin.ts index c4df1eda..d278c4b8 100644 --- a/backend/src/plugins/Spam/SpamPlugin.ts +++ b/backend/src/plugins/Spam/SpamPlugin.ts @@ -1,4 +1,4 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; @@ -9,20 +9,12 @@ import { SpamPluginType, zSpamConfig } from "./types.js"; import { clearOldRecentActions } from "./util/clearOldRecentActions.js"; import { onMessageCreate } from "./util/onMessageCreate.js"; -const defaultOptions: PluginOptions = { - config: { - max_censor: null, - max_messages: null, - max_mentions: null, - max_links: null, - max_attachments: null, - max_emojis: null, - max_newlines: null, - max_duplicates: null, - max_characters: null, - max_voice_moves: null, - }, - overrides: [ +export const SpamPlugin = guildPlugin()({ + name: "spam", + + dependencies: () => [LogsPlugin], + configSchema: zSpamConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -38,14 +30,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const SpamPlugin = guildPlugin()({ - name: "spam", - - dependencies: () => [LogsPlugin], - configParser: (input) => zSpamConfig.parse(input), - defaultOptions, // prettier-ignore events: [ diff --git a/backend/src/plugins/Spam/info.ts b/backend/src/plugins/Spam/docs.ts similarity index 69% rename from backend/src/plugins/Spam/info.ts rename to backend/src/plugins/Spam/docs.ts index 7b3bac6a..5aa19b8a 100644 --- a/backend/src/plugins/Spam/info.ts +++ b/backend/src/plugins/Spam/docs.ts @@ -1,14 +1,13 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zSpamConfig } from "./types.js"; -export const spamPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const spamPluginDocs: ZeppelinPluginDocs = { + type: "legacy", prettyName: "Spam protection", description: trimPluginDescription(` Basic spam detection and auto-muting. For more advanced spam filtering, check out the Automod plugin! `), - legacy: true, configSchema: zSpamConfig, }; diff --git a/backend/src/plugins/Spam/types.ts b/backend/src/plugins/Spam/types.ts index ea7b2406..d7f7aaec 100644 --- a/backend/src/plugins/Spam/types.ts +++ b/backend/src/plugins/Spam/types.ts @@ -1,5 +1,5 @@ import { BasePluginType, guildPluginEventListener } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildMutes } from "../../data/GuildMutes.js"; @@ -18,16 +18,16 @@ const zBaseSingleSpamConfig = z.strictObject({ export type TBaseSingleSpamConfig = z.infer; export const zSpamConfig = z.strictObject({ - max_censor: zBaseSingleSpamConfig.nullable(), - max_messages: zBaseSingleSpamConfig.nullable(), - max_mentions: zBaseSingleSpamConfig.nullable(), - max_links: zBaseSingleSpamConfig.nullable(), - max_attachments: zBaseSingleSpamConfig.nullable(), - max_emojis: zBaseSingleSpamConfig.nullable(), - max_newlines: zBaseSingleSpamConfig.nullable(), - max_duplicates: zBaseSingleSpamConfig.nullable(), - max_characters: zBaseSingleSpamConfig.nullable(), - max_voice_moves: zBaseSingleSpamConfig.nullable(), + max_censor: zBaseSingleSpamConfig.nullable().default(null), + max_messages: zBaseSingleSpamConfig.nullable().default(null), + max_mentions: zBaseSingleSpamConfig.nullable().default(null), + max_links: zBaseSingleSpamConfig.nullable().default(null), + max_attachments: zBaseSingleSpamConfig.nullable().default(null), + max_emojis: zBaseSingleSpamConfig.nullable().default(null), + max_newlines: zBaseSingleSpamConfig.nullable().default(null), + max_duplicates: zBaseSingleSpamConfig.nullable().default(null), + max_characters: zBaseSingleSpamConfig.nullable().default(null), + max_voice_moves: zBaseSingleSpamConfig.nullable().default(null), }); export enum RecentActionType { @@ -52,7 +52,7 @@ export interface IRecentAction { } export interface SpamPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zSpamConfig; state: { logs: GuildLogs; archives: GuildArchives; diff --git a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts index cbdbc8a9..56a06067 100644 --- a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts @@ -76,10 +76,13 @@ export async function logAndDetectMessageSpam( (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { + const reason = "Automatic spam detection"; + muteResult = await mutesPlugin.muteUser( member.id, muteTime, - "Automatic spam detection", + reason, + reason, { caseArgs: { modId: pluginData.client.user!.id, diff --git a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts index bb82af68..6f0a2744 100644 --- a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts +++ b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts @@ -40,10 +40,13 @@ export async function logAndDetectOtherSpam( (spamConfig.mute_time && convertDelayStringToMS(spamConfig.mute_time.toString())) ?? 120 * 1000; try { + const reason = "Automatic spam detection"; + await mutesPlugin.muteUser( member.id, muteTime, - "Automatic spam detection", + reason, + reason, { caseArgs: { modId: pluginData.client.user!.id, diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts index 17d6a051..759b3422 100644 --- a/backend/src/plugins/Starboard/StarboardPlugin.ts +++ b/backend/src/plugins/Starboard/StarboardPlugin.ts @@ -1,20 +1,19 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages.js"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { MigratePinsCmd } from "./commands/MigratePinsCmd.js"; import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt.js"; import { StarboardReactionRemoveAllEvt, StarboardReactionRemoveEvt } from "./events/StarboardReactionRemoveEvts.js"; import { StarboardPluginType, zStarboardConfig } from "./types.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; -const defaultOptions: PluginOptions = { - config: { - can_migrate: false, - boards: {}, - }, +export const StarboardPlugin = guildPlugin()({ + name: "starboard", - overrides: [ + configSchema: zStarboardConfig, + defaultOverrides: [ { level: ">=100", config: { @@ -22,13 +21,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const StarboardPlugin = guildPlugin()({ - name: "starboard", - - configParser: (input) => zStarboardConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -50,6 +42,10 @@ export const StarboardPlugin = guildPlugin()({ state.starboardReactions = GuildStarboardReactions.getGuildInstance(guild.id); }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts index 66e70234..f2971aa0 100644 --- a/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts +++ b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts @@ -1,6 +1,5 @@ import { Snowflake, TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { starboardCmd } from "../types.js"; import { saveMessageToStarboard } from "../util/saveMessageToStarboard.js"; @@ -19,13 +18,13 @@ export const MigratePinsCmd = starboardCmd({ const config = await pluginData.config.get(); const starboard = config.boards[args.starboardName]; if (!starboard) { - sendErrorMessage(pluginData, msg.channel, "Unknown starboard specified"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown starboard specified"); return; } const starboardChannel = pluginData.guild.channels.cache.get(starboard.channel_id as Snowflake); if (!starboardChannel || !(starboardChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, msg.channel, "Starboard has an unknown/invalid channel id"); + void pluginData.state.common.sendErrorMessage(msg, "Starboard has an unknown/invalid channel id"); return; } @@ -43,9 +42,8 @@ export const MigratePinsCmd = starboardCmd({ await saveMessageToStarboard(pluginData, pin, starboard); } - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`, ); }, diff --git a/backend/src/plugins/Starboard/info.ts b/backend/src/plugins/Starboard/docs.ts similarity index 95% rename from backend/src/plugins/Starboard/info.ts rename to backend/src/plugins/Starboard/docs.ts index bc51d961..d8fc50dc 100644 --- a/backend/src/plugins/Starboard/info.ts +++ b/backend/src/plugins/Starboard/docs.ts @@ -1,9 +1,9 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zStarboardConfig } from "./types.js"; -export const starboardPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const starboardPluginDocs: ZeppelinPluginDocs = { + type: "stable", prettyName: "Starboard", description: trimPluginDescription(` This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel. diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts index 7ab61559..20d19834 100644 --- a/backend/src/plugins/Starboard/types.ts +++ b/backend/src/plugins/Starboard/types.ts @@ -1,9 +1,10 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages.js"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions.js"; import { zBoundedRecord, zSnowflake } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; const zStarboardOpts = z.strictObject({ channel_id: zSnowflake, @@ -18,17 +19,17 @@ const zStarboardOpts = z.strictObject({ export type TStarboardOpts = z.infer; export const zStarboardConfig = z.strictObject({ - boards: zBoundedRecord(z.record(z.string(), zStarboardOpts), 0, 100), - can_migrate: z.boolean(), + boards: zBoundedRecord(z.record(z.string(), zStarboardOpts), 0, 100).default({}), + can_migrate: z.boolean().default(false), }); export interface StarboardPluginType extends BasePluginType { - config: z.infer; - + configSchema: typeof zStarboardConfig; state: { savedMessages: GuildSavedMessages; starboardMessages: GuildStarboardMessages; starboardReactions: GuildStarboardReactions; + common: pluginUtils.PluginPublicInterface; onMessageDeleteFn; }; diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts index 303a13dc..71ebd109 100644 --- a/backend/src/plugins/Tags/TagsPlugin.ts +++ b/backend/src/plugins/Tags/TagsPlugin.ts @@ -1,13 +1,14 @@ import { Snowflake } from "discord.js"; -import humanizeDuration from "humanize-duration"; -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import moment from "moment-timezone"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildTags } from "../../data/GuildTags.js"; +import { humanizeDuration } from "../../humanizeDuration.js"; import { makePublicFn } from "../../pluginUtils.js"; import { convertDelayStringToMS } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; import { TagCreateCmd } from "./commands/TagCreateCmd.js"; @@ -21,25 +22,12 @@ import { onMessageCreate } from "./util/onMessageCreate.js"; import { onMessageDelete } from "./util/onMessageDelete.js"; import { renderTagBody } from "./util/renderTagBody.js"; -const defaultOptions: PluginOptions = { - config: { - prefix: "!!", - delete_with_command: true, +export const TagsPlugin = guildPlugin()({ + name: "tags", - user_tag_cooldown: null, - global_tag_cooldown: null, - user_cooldown: null, - allow_mentions: false, - global_cooldown: null, - auto_delete_command: false, - - categories: {}, - - can_create: false, - can_use: false, - can_list: false, - }, - overrides: [ + dependencies: () => [LogsPlugin], + configSchema: zTagsConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -49,13 +37,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const TagsPlugin = guildPlugin()({ - name: "tags", - - dependencies: () => [LogsPlugin], - defaultOptions, // prettier-ignore messageCommands: [ @@ -78,8 +59,6 @@ export const TagsPlugin = guildPlugin()({ }; }, - configParser: (input) => zTagsConfig.parse(input), - beforeLoad(pluginData) { const { state, guild } = pluginData; @@ -91,6 +70,10 @@ export const TagsPlugin = guildPlugin()({ state.tagFunctions = {}; }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/Tags/commands/TagCreateCmd.ts b/backend/src/plugins/Tags/commands/TagCreateCmd.ts index 64ed2486..c1a2d3cc 100644 --- a/backend/src/plugins/Tags/commands/TagCreateCmd.ts +++ b/backend/src/plugins/Tags/commands/TagCreateCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { TemplateParseError, parseTemplate } from "../../../templateFormatter.js"; import { tagsCmd } from "../types.js"; @@ -17,7 +16,7 @@ export const TagCreateCmd = tagsCmd({ parseTemplate(args.body); } catch (e) { if (e instanceof TemplateParseError) { - sendErrorMessage(pluginData, msg.channel, `Invalid tag syntax: ${e.message}`); + void pluginData.state.common.sendErrorMessage(msg, `Invalid tag syntax: ${e.message}`); return; } else { throw e; @@ -27,6 +26,6 @@ export const TagCreateCmd = tagsCmd({ await pluginData.state.tags.createOrUpdate(args.tag, args.body, msg.author.id); const prefix = pluginData.config.get().prefix; - sendSuccessMessage(pluginData, msg.channel, `Tag set! Use it with: \`${prefix}${args.tag}\``); + void pluginData.state.common.sendSuccessMessage(msg, `Tag set! Use it with: \`${prefix}${args.tag}\``); }, }); diff --git a/backend/src/plugins/Tags/commands/TagDeleteCmd.ts b/backend/src/plugins/Tags/commands/TagDeleteCmd.ts index 0a711c76..a0f1f0ea 100644 --- a/backend/src/plugins/Tags/commands/TagDeleteCmd.ts +++ b/backend/src/plugins/Tags/commands/TagDeleteCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { tagsCmd } from "../types.js"; export const TagDeleteCmd = tagsCmd({ @@ -13,11 +12,11 @@ export const TagDeleteCmd = tagsCmd({ async run({ message: msg, args, pluginData }) { const tag = await pluginData.state.tags.find(args.tag); if (!tag) { - sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } await pluginData.state.tags.delete(args.tag); - sendSuccessMessage(pluginData, msg.channel, "Tag deleted!"); + void pluginData.state.common.sendSuccessMessage(msg, "Tag deleted!"); }, }); diff --git a/backend/src/plugins/Tags/commands/TagEvalCmd.ts b/backend/src/plugins/Tags/commands/TagEvalCmd.ts index 4af424a0..61dc7fac 100644 --- a/backend/src/plugins/Tags/commands/TagEvalCmd.ts +++ b/backend/src/plugins/Tags/commands/TagEvalCmd.ts @@ -1,7 +1,7 @@ import { MessageCreateOptions } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { logger } from "../../../logger.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; +import { resolveMessageMember } from "../../../pluginUtils.js"; import { TemplateParseError } from "../../../templateFormatter.js"; import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { tagsCmd } from "../types.js"; @@ -16,20 +16,21 @@ export const TagEvalCmd = tagsCmd({ }, async run({ message: msg, args, pluginData }) { + const authorMember = await resolveMessageMember(msg); try { const rendered = (await renderTagBody( pluginData, args.body, [], { - member: memberToTemplateSafeMember(msg.member), - user: userToTemplateSafeUser(msg.member.user), + member: memberToTemplateSafeMember(authorMember), + user: userToTemplateSafeUser(msg.author), }, { member: msg.member }, )) as MessageCreateOptions; if (!rendered.content && !rendered.embeds?.length) { - sendErrorMessage(pluginData, msg.channel, "Evaluation resulted in an empty text"); + void pluginData.state.common.sendErrorMessage(msg, "Evaluation resulted in an empty text"); return; } @@ -37,7 +38,7 @@ export const TagEvalCmd = tagsCmd({ } catch (e) { const errorMessage = e instanceof TemplateParseError ? e.message : "Internal error"; - sendErrorMessage(pluginData, msg.channel, `Failed to render tag: ${errorMessage}`); + void pluginData.state.common.sendErrorMessage(msg, `Failed to render tag: ${errorMessage}`); if (!(e instanceof TemplateParseError)) { logger.warn(`Internal error evaluating tag in ${pluginData.guild.id}: ${e}`); diff --git a/backend/src/plugins/Tags/commands/TagSourceCmd.ts b/backend/src/plugins/Tags/commands/TagSourceCmd.ts index 58729226..92aaff68 100644 --- a/backend/src/plugins/Tags/commands/TagSourceCmd.ts +++ b/backend/src/plugins/Tags/commands/TagSourceCmd.ts @@ -1,6 +1,6 @@ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { getBaseUrl, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { getBaseUrl } from "../../../pluginUtils.js"; import { tagsCmd } from "../types.js"; export const TagSourceCmd = tagsCmd({ @@ -17,18 +17,18 @@ export const TagSourceCmd = tagsCmd({ if (args.delete) { const actualTag = await pluginData.state.tags.find(args.tag); if (!actualTag) { - sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } await pluginData.state.tags.delete(args.tag); - sendSuccessMessage(pluginData, msg.channel, "Tag deleted!"); + void pluginData.state.common.sendSuccessMessage(msg, "Tag deleted!"); return; } const tag = await pluginData.state.tags.find(args.tag); if (!tag) { - sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + void pluginData.state.common.sendErrorMessage(msg, "No tag with that name"); return; } diff --git a/backend/src/plugins/Tags/info.ts b/backend/src/plugins/Tags/docs.ts similarity index 91% rename from backend/src/plugins/Tags/info.ts rename to backend/src/plugins/Tags/docs.ts index 0b7b7b12..39f9c77a 100644 --- a/backend/src/plugins/Tags/info.ts +++ b/backend/src/plugins/Tags/docs.ts @@ -1,10 +1,10 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { TemplateFunctions } from "./templateFunctions.js"; import { TemplateFunction, zTagsConfig } from "./types.js"; -export const tagsPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const tagsPluginDocs: ZeppelinPluginDocs = { + type: "stable", prettyName: "Tags", description: "Tags are a way to store and reuse information.", configurationGuide: trimPluginDescription(` diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts index 3043c3b7..0b44fff7 100644 --- a/backend/src/plugins/Tags/types.ts +++ b/backend/src/plugins/Tags/types.ts @@ -1,12 +1,13 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildTags } from "../../data/GuildTags.js"; -import { zEmbedInput } from "../../utils.js"; +import { zBoundedCharacters, zStrictMessageContent } from "../../utils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; -export const zTag = z.union([z.string(), zEmbedInput]); +export const zTag = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]); export type TTag = z.infer; export const zTagCategory = z @@ -32,33 +33,34 @@ export type TTagCategory = z.infer; export const zTagsConfig = z .strictObject({ - prefix: z.string(), - delete_with_command: z.boolean(), + prefix: z.string().default("!!"), + delete_with_command: z.boolean().default(true), - user_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user, per tag - global_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Any user, per tag - user_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user - allow_mentions: z.boolean(), // Per user - global_cooldown: z.union([z.string(), z.number()]).nullable(), // Any tag use - auto_delete_command: z.boolean(), // Any tag + user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag + global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag + user_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user + allow_mentions: z.boolean().default(false), // Per user + global_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any tag use + auto_delete_command: z.boolean().default(false), // Any tag - categories: z.record(z.string(), zTagCategory), + categories: z.record(z.string(), zTagCategory).default({}), - can_create: z.boolean(), - can_use: z.boolean(), - can_list: z.boolean(), + can_create: z.boolean().default(false), + can_use: z.boolean().default(false), + can_list: z.boolean().default(false), }) .refine((parsed) => !(parsed.auto_delete_command && parsed.delete_with_command), { message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled", }); export interface TagsPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zTagsConfig; state: { archives: GuildArchives; tags: GuildTags; savedMessages: GuildSavedMessages; logs: GuildLogs; + common: pluginUtils.PluginPublicInterface; onMessageCreateFn; diff --git a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts index 4f83b65d..46a60773 100644 --- a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts +++ b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts @@ -1,10 +1,10 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildMemberTimezones } from "../../data/GuildMemberTimezones.js"; import { makePublicFn } from "../../pluginUtils.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { ResetTimezoneCmd } from "./commands/ResetTimezoneCmd.js"; import { SetTimezoneCmd } from "./commands/SetTimezoneCmd.js"; import { ViewTimezoneCmd } from "./commands/ViewTimezoneCmd.js"; -import { defaultDateFormats } from "./defaultDateFormats.js"; import { getDateFormat } from "./functions/getDateFormat.js"; import { getGuildTz } from "./functions/getGuildTz.js"; import { getMemberTz } from "./functions/getMemberTz.js"; @@ -12,14 +12,11 @@ import { inGuildTz } from "./functions/inGuildTz.js"; import { inMemberTz } from "./functions/inMemberTz.js"; import { TimeAndDatePluginType, zTimeAndDateConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - timezone: "Etc/UTC", - can_set_timezone: false, - date_formats: defaultDateFormats, - }, +export const TimeAndDatePlugin = guildPlugin()({ + name: "time_and_date", - overrides: [ + configSchema: zTimeAndDateConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -27,13 +24,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const TimeAndDatePlugin = guildPlugin()({ - name: "time_and_date", - - configParser: (input) => zTimeAndDateConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -57,4 +47,8 @@ export const TimeAndDatePlugin = guildPlugin()({ state.memberTimezones = GuildMemberTimezones.getGuildInstance(guild.id); }, + + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, }); diff --git a/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts b/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts index 665161a8..fa5a41a0 100644 --- a/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts +++ b/backend/src/plugins/TimeAndDate/commands/ResetTimezoneCmd.ts @@ -1,4 +1,3 @@ -import { sendSuccessMessage } from "../../../pluginUtils.js"; import { getGuildTz } from "../functions/getGuildTz.js"; import { timeAndDateCmd } from "../types.js"; @@ -11,9 +10,8 @@ export const ResetTimezoneCmd = timeAndDateCmd({ async run({ pluginData, message }) { await pluginData.state.memberTimezones.reset(message.author.id); const serverTimezone = getGuildTz(pluginData); - sendSuccessMessage( - pluginData, - message.channel, + void pluginData.state.common.sendSuccessMessage( + message, `Your timezone has been reset to server default, **${serverTimezone}**`, ); }, diff --git a/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts b/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts index ef316fb8..f15546e5 100644 --- a/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts +++ b/backend/src/plugins/TimeAndDate/commands/SetTimezoneCmd.ts @@ -1,6 +1,5 @@ import { escapeInlineCode } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; import { trimLines } from "../../../utils.js"; import { parseFuzzyTimezone } from "../../../utils/parseFuzzyTimezone.js"; import { timeAndDateCmd } from "../types.js"; @@ -16,9 +15,8 @@ export const SetTimezoneCmd = timeAndDateCmd({ async run({ pluginData, message, args }) { const parsedTz = parseFuzzyTimezone(args.timezone); if (!parsedTz) { - sendErrorMessage( - pluginData, - message.channel, + void pluginData.state.common.sendErrorMessage( + message, trimLines(` Invalid timezone: \`${escapeInlineCode(args.timezone)}\` Zeppelin uses timezone locations rather than specific timezone names. @@ -29,6 +27,6 @@ export const SetTimezoneCmd = timeAndDateCmd({ } await pluginData.state.memberTimezones.set(message.author.id, parsedTz); - sendSuccessMessage(pluginData, message.channel, `Your timezone is now set to **${parsedTz}**`); + void pluginData.state.common.sendSuccessMessage(message, `Your timezone is now set to **${parsedTz}**`); }, }); diff --git a/backend/src/plugins/TimeAndDate/info.ts b/backend/src/plugins/TimeAndDate/docs.ts similarity index 68% rename from backend/src/plugins/TimeAndDate/info.ts rename to backend/src/plugins/TimeAndDate/docs.ts index 3ce217e6..7a79f335 100644 --- a/backend/src/plugins/TimeAndDate/info.ts +++ b/backend/src/plugins/TimeAndDate/docs.ts @@ -1,9 +1,9 @@ -import { ZeppelinPluginInfo } from "../../types.js"; +import { ZeppelinPluginDocs } from "../../types.js"; import { trimPluginDescription } from "../../utils.js"; import { zTimeAndDateConfig } from "./types.js"; -export const timeAndDatePluginInfo: ZeppelinPluginInfo = { - showInDocs: true, +export const timeAndDatePluginDocs: ZeppelinPluginDocs = { + type: "stable", prettyName: "Time and date", description: trimPluginDescription(` Allows controlling the displayed time/date formats and timezones diff --git a/backend/src/plugins/TimeAndDate/types.ts b/backend/src/plugins/TimeAndDate/types.ts index e829228b..24e1eb3d 100644 --- a/backend/src/plugins/TimeAndDate/types.ts +++ b/backend/src/plugins/TimeAndDate/types.ts @@ -1,23 +1,30 @@ -import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { BasePluginType, guildPluginMessageCommand, pluginUtils } from "knub"; import { U } from "ts-toolbelt"; -import z from "zod"; +import z from "zod/v4"; import { GuildMemberTimezones } from "../../data/GuildMemberTimezones.js"; import { keys } from "../../utils.js"; import { zValidTimezone } from "../../utils/zValidTimezone.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { defaultDateFormats } from "./defaultDateFormats.js"; const zDateFormatKeys = z.enum(keys(defaultDateFormats) as U.ListOf); +const dateFormatTypeMap = keys(defaultDateFormats).reduce((map, key) => { + map[key] = z.string().default(defaultDateFormats[key]); + return map; +}, {} as Record>); + export const zTimeAndDateConfig = z.strictObject({ - timezone: zValidTimezone(z.string()), - date_formats: z.record(zDateFormatKeys, z.string()).nullable(), - can_set_timezone: z.boolean(), + timezone: zValidTimezone(z.string()).default("Etc/UTC"), + date_formats: z.strictObject(dateFormatTypeMap).default(defaultDateFormats), + can_set_timezone: z.boolean().default(false), }); export interface TimeAndDatePluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zTimeAndDateConfig; state: { memberTimezones: GuildMemberTimezones; + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts index fe38fd13..5008d0a7 100644 --- a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts +++ b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts @@ -7,7 +7,7 @@ import { UsernameSaverPluginType, zUsernameSaverConfig } from "./types.js"; export const UsernameSaverPlugin = guildPlugin()({ name: "username_saver", - configParser: (input) => zUsernameSaverConfig.parse(input), + configSchema: zUsernameSaverConfig, // prettier-ignore events: [ diff --git a/backend/src/plugins/UsernameSaver/docs.ts b/backend/src/plugins/UsernameSaver/docs.ts new file mode 100644 index 00000000..a28dc46e --- /dev/null +++ b/backend/src/plugins/UsernameSaver/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zUsernameSaverConfig } from "./types.js"; + +export const usernameSaverPluginDocs: ZeppelinPluginDocs = { + type: "internal", + prettyName: "Username saver", + configSchema: zUsernameSaverConfig, +}; diff --git a/backend/src/plugins/UsernameSaver/info.ts b/backend/src/plugins/UsernameSaver/info.ts deleted file mode 100644 index 68a65f31..00000000 --- a/backend/src/plugins/UsernameSaver/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zUsernameSaverConfig } from "./types.js"; - -export const usernameSaverPluginInfo: ZeppelinPluginInfo = { - showInDocs: false, - prettyName: "Username saver", - configSchema: zUsernameSaverConfig, -}; diff --git a/backend/src/plugins/UsernameSaver/types.ts b/backend/src/plugins/UsernameSaver/types.ts index 4828db6e..2bbd93bd 100644 --- a/backend/src/plugins/UsernameSaver/types.ts +++ b/backend/src/plugins/UsernameSaver/types.ts @@ -1,12 +1,12 @@ import { BasePluginType, guildPluginEventListener } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { Queue } from "../../Queue.js"; import { UsernameHistory } from "../../data/UsernameHistory.js"; export const zUsernameSaverConfig = z.strictObject({}); export interface UsernameSaverPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zUsernameSaverConfig; state: { usernameHistory: UsernameHistory; updateQueue: Queue; diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 23433f43..0ad9bde0 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -1,12 +1,13 @@ import { Snowflake } from "discord.js"; -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { Supporters } from "../../data/Supporters.js"; -import { makePublicFn, sendSuccessMessage } from "../../pluginUtils.js"; +import { makePublicFn } from "../../pluginUtils.js"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin.js"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin.js"; @@ -14,7 +15,7 @@ import { AboutCmd } from "./commands/AboutCmd.js"; import { AvatarCmd } from "./commands/AvatarCmd.js"; import { BanSearchCmd } from "./commands/BanSearchCmd.js"; import { ChannelInfoCmd } from "./commands/ChannelInfoCmd.js"; -import { CleanCmd, cleanCmd } from "./commands/CleanCmd.js"; +import { CleanCmd } from "./commands/CleanCmd.js"; import { ContextCmd } from "./commands/ContextCmd.js"; import { EmojiInfoCmd } from "./commands/EmojiInfoCmd.js"; import { HelpCmd } from "./commands/HelpCmd.js"; @@ -37,43 +38,21 @@ import { UserInfoCmd } from "./commands/UserInfoCmd.js"; import { VcdisconnectCmd } from "./commands/VcdisconnectCmd.js"; import { VcmoveAllCmd, VcmoveCmd } from "./commands/VcmoveCmd.js"; import { AutoJoinThreadEvt, AutoJoinThreadSyncEvt } from "./events/AutoJoinThreadEvt.js"; +import { cleanMessages } from "./functions/cleanMessages.js"; +import { fetchChannelMessagesToClean } from "./functions/fetchChannelMessagesToClean.js"; import { getUserInfoEmbed } from "./functions/getUserInfoEmbed.js"; import { hasPermission } from "./functions/hasPermission.js"; import { activeReloads } from "./guildReloads.js"; import { refreshMembersIfNeeded } from "./refreshMembers.js"; import { UtilityPluginType, zUtilityConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - can_roles: false, - can_level: false, - can_search: false, - can_clean: false, - can_info: false, - can_server: false, - can_inviteinfo: false, - can_channelinfo: false, - can_messageinfo: false, - can_userinfo: false, - can_roleinfo: false, - can_emojiinfo: false, - can_snowflake: false, - can_reload_guild: false, - can_nickname: false, - can_ping: false, - can_source: false, - can_vcmove: false, - can_vckick: false, - can_help: false, - can_about: false, - can_context: false, - can_jumbo: false, - jumbo_size: 128, - can_avatar: false, - info_on_single_result: true, - autojoin_threads: true, - }, - overrides: [ +export const UtilityPlugin = guildPlugin()({ + name: "utility", + + dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin], + + configSchema: zUtilityConfig, + defaultOverrides: [ { level: ">=50", config: { @@ -109,14 +88,6 @@ const defaultOptions: PluginOptions = { }, }, ], -}; - -export const UtilityPlugin = guildPlugin()({ - name: "utility", - - dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin], - configParser: (input) => zUtilityConfig.parse(input), - defaultOptions, // prettier-ignore messageCommands: [ @@ -157,7 +128,8 @@ export const UtilityPlugin = guildPlugin()({ public(pluginData) { return { - clean: makePublicFn(pluginData, cleanCmd), + fetchChannelMessagesToClean: makePublicFn(pluginData, fetchChannelMessagesToClean), + cleanMessages: makePublicFn(pluginData, cleanMessages), userInfo: (userId: Snowflake) => getUserInfoEmbed(pluginData, userId, false), hasPermission: makePublicFn(pluginData, hasPermission), }; @@ -192,11 +164,15 @@ export const UtilityPlugin = guildPlugin()({ } }, + beforeStart(pluginData) { + pluginData.state.common = pluginData.getPlugin(CommonPlugin); + }, + afterLoad(pluginData) { const { guild } = pluginData; if (activeReloads.has(guild.id)) { - sendSuccessMessage(pluginData, activeReloads.get(guild.id)!, "Reloaded!"); + pluginData.state.common.sendSuccessMessage(activeReloads.get(guild.id)!, "Reloaded!"); activeReloads.delete(guild.id); } }, diff --git a/backend/src/plugins/Utility/commands/AboutCmd.ts b/backend/src/plugins/Utility/commands/AboutCmd.ts index 6de19471..44380ed9 100644 --- a/backend/src/plugins/Utility/commands/AboutCmd.ts +++ b/backend/src/plugins/Utility/commands/AboutCmd.ts @@ -1,8 +1,8 @@ import { APIEmbed, GuildChannel } from "discord.js"; -import humanizeDuration from "humanize-duration"; import LCL from "last-commit-log"; import shuffle from "lodash/shuffle.js"; import moment from "moment-timezone"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { rootDir } from "../../../paths.js"; import { getCurrentUptime } from "../../../uptime.js"; import { resolveMember, sorter } from "../../../utils.js"; diff --git a/backend/src/plugins/Utility/commands/AvatarCmd.ts b/backend/src/plugins/Utility/commands/AvatarCmd.ts index 24628759..a4c13f85 100644 --- a/backend/src/plugins/Utility/commands/AvatarCmd.ts +++ b/backend/src/plugins/Utility/commands/AvatarCmd.ts @@ -1,6 +1,5 @@ import { APIEmbed, ImageFormat } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { UnknownUser, renderUsername } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -24,7 +23,7 @@ export const AvatarCmd = utilityCmd({ }; msg.channel.send({ embeds: [embed] }); } else { - sendErrorMessage(pluginData, msg.channel, "Invalid user ID"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid user ID"); } }, }); diff --git a/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts b/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts index 4a7617a6..f3e718fe 100644 --- a/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/ChannelInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getChannelInfoEmbed } from "../functions/getChannelInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -16,7 +15,7 @@ export const ChannelInfoCmd = utilityCmd({ async run({ message, args, pluginData }) { const embed = await getChannelInfoEmbed(pluginData, args.channel); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Unknown channel"); + void pluginData.state.common.sendErrorMessage(message, "Unknown channel"); return; } diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts index 683f28f2..aeb6126a 100644 --- a/backend/src/plugins/Utility/commands/CleanCmd.ts +++ b/backend/src/plugins/Utility/commands/CleanCmd.ts @@ -1,62 +1,14 @@ -import { Message, Snowflake, TextChannel, User } from "discord.js"; -import { GuildPluginData } from "knub"; -import { allowTimeout } from "../../../RegExpRunner.js"; +import { Message, Snowflake } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { LogType } from "../../../data/LogType.js"; -import { SavedMessage } from "../../../data/entities/SavedMessage.js"; -import { humanizeDurationShort } from "../../../humanizeDurationShort.js"; -import { getBaseUrl, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { ContextResponse, deleteContextResponse } from "../../../pluginUtils.js"; import { ModActionsPlugin } from "../../../plugins/ModActions/ModActionsPlugin.js"; -import { DAYS, SECONDS, chunkArray, getInviteCodesInString, noop } from "../../../utils.js"; -import { LogsPlugin } from "../../Logs/LogsPlugin.js"; -import { UtilityPluginType, utilityCmd } from "../types.js"; +import { SECONDS, noop } from "../../../utils.js"; +import { cleanMessages } from "../functions/cleanMessages.js"; +import { fetchChannelMessagesToClean } from "../functions/fetchChannelMessagesToClean.js"; +import { utilityCmd } from "../types.js"; -const MAX_CLEAN_COUNT = 300; -const MAX_CLEAN_TIME = 1 * DAYS; -const MAX_CLEAN_API_REQUESTS = 20; const CLEAN_COMMAND_DELETE_DELAY = 10 * SECONDS; -export async function cleanMessages( - pluginData: GuildPluginData, - channel: TextChannel, - savedMessages: SavedMessage[], - mod: User, -) { - pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id); - pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id); - - // 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[]; - - // Make sure the deletions aren't double logged - 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 (in chunks of 100) - - const chunks = chunkArray(idsToDelete, 100); - await Promise.all( - chunks.map((chunk) => - Promise.all([channel.bulkDelete(chunk), pluginData.state.savedMessages.markBulkAsDeleted(chunk)]), - ), - ); - - // Create an archive - const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild); - const baseUrl = getBaseUrl(pluginData); - const archiveUrl = pluginData.state.archives.getUrl(baseUrl, archiveId); - - pluginData.getPlugin(LogsPlugin).logClean({ - mod, - channel, - count: savedMessages.length, - archiveUrl, - }); - - return { archiveUrl }; -} - const opts = { user: ct.userId({ option: true, shortcut: "u" }), channel: ct.channelId({ option: true, shortcut: "c" }), @@ -67,159 +19,6 @@ const opts = { "to-id": ct.anyId({ option: true, shortcut: "id" }), }; -export interface CleanArgs { - count: number; - update?: boolean; - user?: string; - channel?: string; - bots?: boolean; - "delete-pins"?: boolean; - "has-invites"?: boolean; - match?: RegExp; - "to-id"?: string; -} - -export async function cleanCmd(pluginData: GuildPluginData, args: CleanArgs | any, msg) { - if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { - sendErrorMessage(pluginData, msg.channel, `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`); - return; - } - - const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel; - if (!targetChannel?.isTextBased()) { - sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`); - return; - } - - if (targetChannel.id !== msg.channel.id) { - const configForTargetChannel = await pluginData.config.getMatchingConfig({ - userId: msg.author.id, - member: msg.member, - channelId: targetChannel.id, - categoryId: targetChannel.parentId, - }); - if (configForTargetChannel.can_clean !== true) { - sendErrorMessage(pluginData, msg.channel, `Missing permissions to use clean on that channel`); - return; - } - } - - const cleaningMessage = msg.channel.send("Cleaning..."); - - 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 pinIds: Set = new Set(); - if (!deletePins) { - 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: 100, - }); - if (potentialMessages.size === 0) break; - - requests++; - - 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 (message.createdTimestamp < timeCutoff) continue; - if (args.match && !(await pluginData.state.regexRunner.exec(args.match, contentString).catch(allowTimeout))) { - continue; - } - - filtered.push(message); - } - const remaining = args.count - messagesToClean.length; - const withoutOverflow = filtered.slice(0, remaining); - messagesToClean.push(...withoutOverflow); - - beforeId = potentialMessages.lastKey()!; - - 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) { - // 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}`; - } - - if (args.update) { - const modActions = pluginData.getPlugin(ModActionsPlugin); - const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id; - const updateMessage = `Cleaned ${messagesToClean.length} ${ - messagesToClean.length === 1 ? "message" : "messages" - } in <#${channelId}>: ${cleanResult.archiveUrl}`; - if (typeof args.update === "number") { - modActions.updateCase(msg, args.update, updateMessage); - } else { - modActions.updateCase(msg, null, updateMessage); - } - } - - responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText); - } else { - const responseText = `Found no messages to clean${note ? ` (${note})` : ""}!`; - responseMsg = await sendErrorMessage(pluginData, msg.channel, responseText); - } - - await (await cleaningMessage).delete(); - - if (targetChannel.id === msg.channel.id) { - // Delete the !clean command and the bot response if a different channel wasn't specified - // (so as not to spam the cleaned channel with the command itself) - setTimeout(() => { - msg.delete().catch(noop); - responseMsg?.delete().catch(noop); - }, CLEAN_COMMAND_DELETE_DELAY); - } -} - export const CleanCmd = utilityCmd({ trigger: ["clean", "clear"], description: "Remove a number of recent messages", @@ -242,6 +41,108 @@ export const CleanCmd = utilityCmd({ ], async run({ message: msg, args, pluginData }) { - cleanCmd(pluginData, args, msg); + const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel; + if (!targetChannel?.isTextBased()) { + void pluginData.state.common.sendErrorMessage( + msg, + `Invalid channel specified`, + undefined, + args["response-interaction"], + ); + return; + } + + if (targetChannel.id !== msg.channel.id) { + const configForTargetChannel = await pluginData.config.getMatchingConfig({ + userId: msg.author.id, + member: msg.member, + channelId: targetChannel.id, + categoryId: targetChannel.parentId, + }); + if (configForTargetChannel.can_clean !== true) { + void pluginData.state.common.sendErrorMessage( + msg, + `Missing permissions to use clean on that channel`, + undefined, + args["response-interaction"], + ); + return; + } + } + + let cleaningMessage: Message | undefined = undefined; + if (!args["response-interaction"]) { + cleaningMessage = await msg.channel.send("Cleaning..."); + } + + const fetchMessagesResult = await fetchChannelMessagesToClean(pluginData, targetChannel, { + beforeId: msg.id, + count: args.count, + authorId: args.user, + includePins: args["delete-pins"], + onlyBotMessages: args.bots, + onlyWithInvites: args["has-invites"], + upToId: args["to-id"], + matchContent: args.match, + }); + if ("error" in fetchMessagesResult) { + void pluginData.state.common.sendErrorMessage(msg, fetchMessagesResult.error); + return; + } + + const { messages: messagesToClean, note } = fetchMessagesResult; + + let responseMsg: ContextResponse | null = null; + if (messagesToClean.length > 0) { + const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, 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}`; + } + + if (args.update) { + const modActions = pluginData.getPlugin(ModActionsPlugin); + const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id; + const updateMessage = `Cleaned ${messagesToClean.length} ${ + messagesToClean.length === 1 ? "message" : "messages" + } in <#${channelId}>: ${cleanResult.archiveUrl}`; + if (typeof args.update === "number") { + modActions.updateCase(msg, args.update, updateMessage); + } else { + modActions.updateCase(msg, null, updateMessage); + } + } + + responseMsg = await pluginData.state.common.sendSuccessMessage( + msg, + responseText, + undefined, + args["response-interaction"], + ); + } else { + const responseText = `Found no messages to clean${note ? ` (${note})` : ""}!`; + responseMsg = await pluginData.state.common.sendErrorMessage( + msg, + responseText, + undefined, + args["response-interaction"], + ); + } + + cleaningMessage?.delete(); + + if (targetChannel.id === msg.channel.id) { + // Delete the !clean command and the bot response if a different channel wasn't specified + // (so as not to spam the cleaned channel with the command itself) + msg.delete().catch(noop); + setTimeout(() => { + deleteContextResponse(responseMsg).catch(noop); + responseMsg?.delete().catch(noop); + }, CLEAN_COMMAND_DELETE_DELAY); + } }, }); diff --git a/backend/src/plugins/Utility/commands/ContextCmd.ts b/backend/src/plugins/Utility/commands/ContextCmd.ts index efc4297d..94f4786a 100644 --- a/backend/src/plugins/Utility/commands/ContextCmd.ts +++ b/backend/src/plugins/Utility/commands/ContextCmd.ts @@ -1,6 +1,6 @@ import { Snowflake, TextChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; +import { resolveMessageMember } from "../../../pluginUtils.js"; import { messageLink } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { utilityCmd } from "../types.js"; @@ -23,15 +23,16 @@ export const ContextCmd = utilityCmd({ async run({ message: msg, args, pluginData }) { if (args.channel && !(args.channel instanceof TextChannel)) { - sendErrorMessage(pluginData, msg.channel, "Channel must be a text channel"); + void pluginData.state.common.sendErrorMessage(msg, "Channel must be a text channel"); return; } const channel = args.channel ?? args.message.channel; const messageId = args.messageId ?? args.message.messageId; - if (!canReadChannel(channel, msg.member)) { - sendErrorMessage(pluginData, msg.channel, "Message context not found"); + const authorMember = await resolveMessageMember(msg); + if (!canReadChannel(channel, authorMember)) { + void pluginData.state.common.sendErrorMessage(msg, "Message context not found"); return; } @@ -42,7 +43,7 @@ export const ContextCmd = utilityCmd({ }) )[0]; if (!previousMessage) { - sendErrorMessage(pluginData, msg.channel, "Message context not found"); + void pluginData.state.common.sendErrorMessage(msg, "Message context not found"); return; } diff --git a/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts b/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts index b4227f06..7c079e3f 100644 --- a/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/EmojiInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getCustomEmojiId } from "../functions/getCustomEmojiId.js"; import { getEmojiInfoEmbed } from "../functions/getEmojiInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -17,13 +16,13 @@ export const EmojiInfoCmd = utilityCmd({ async run({ message, args, pluginData }) { const emojiId = getCustomEmojiId(args.emoji); if (!emojiId) { - sendErrorMessage(pluginData, message.channel, "Emoji not found"); + void pluginData.state.common.sendErrorMessage(message, "Emoji not found"); return; } const embed = await getEmojiInfoEmbed(pluginData, emojiId); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Emoji not found"); + void pluginData.state.common.sendErrorMessage(message, "Emoji not found"); return; } diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts index 519f0f6a..8c92e786 100644 --- a/backend/src/plugins/Utility/commands/InfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InfoCmd.ts @@ -1,7 +1,7 @@ import { Snowflake } from "discord.js"; import { getChannelId, getRoleId } from "knub/helpers"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; +import { resolveMessageMember } from "../../../pluginUtils.js"; import { isValidSnowflake, noop, parseInviteCodeInput, resolveInvite, resolveUser } from "../../../utils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { resolveMessageTarget } from "../../../utils/resolveMessageTarget.js"; @@ -78,7 +78,8 @@ export const InfoCmd = utilityCmd({ if (userCfg.can_messageinfo) { const messageTarget = await resolveMessageTarget(pluginData, value); if (messageTarget) { - if (canReadChannel(messageTarget.channel, message.member)) { + const authorMember = await resolveMessageMember(message); + if (canReadChannel(messageTarget.channel, authorMember)) { const embed = await getMessageInfoEmbed(pluginData, messageTarget.channel.id, messageTarget.messageId); if (embed) { message.channel.send({ embeds: [embed] }); @@ -146,9 +147,8 @@ export const InfoCmd = utilityCmd({ } // 10. No can do - sendErrorMessage( - pluginData, - message.channel, + void pluginData.state.common.sendErrorMessage( + message, "Could not find anything with that value or you are lacking permission for the snowflake type", ); }, diff --git a/backend/src/plugins/Utility/commands/InviteInfoCmd.ts b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts index 53324d0e..e553b6b5 100644 --- a/backend/src/plugins/Utility/commands/InviteInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { parseInviteCodeInput } from "../../../utils.js"; import { getInviteInfoEmbed } from "../functions/getInviteInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -18,7 +17,7 @@ export const InviteInfoCmd = utilityCmd({ const inviteCode = parseInviteCodeInput(args.inviteCode); const embed = await getInviteInfoEmbed(pluginData, inviteCode); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Unknown invite"); + void pluginData.state.common.sendErrorMessage(message, "Unknown invite"); return; } diff --git a/backend/src/plugins/Utility/commands/JumboCmd.ts b/backend/src/plugins/Utility/commands/JumboCmd.ts index 4c29b9a2..6a693d3f 100644 --- a/backend/src/plugins/Utility/commands/JumboCmd.ts +++ b/backend/src/plugins/Utility/commands/JumboCmd.ts @@ -3,7 +3,6 @@ import { AttachmentBuilder } from "discord.js"; import fs from "fs"; import twemoji from "twemoji"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { downloadFile, isEmoji, SECONDS } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -51,7 +50,7 @@ export const JumboCmd = utilityCmd({ let file: AttachmentBuilder | undefined; if (!isEmoji(args.emoji)) { - sendErrorMessage(pluginData, msg.channel, "Invalid emoji"); + void pluginData.state.common.sendErrorMessage(msg, "Invalid emoji"); return; } @@ -87,7 +86,7 @@ export const JumboCmd = utilityCmd({ } } if (!image) { - sendErrorMessage(pluginData, msg.channel, "Error occurred while jumboing default emoji"); + void pluginData.state.common.sendErrorMessage(msg, "Error occurred while jumboing default emoji"); return; } diff --git a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts index 7ca935f4..f519d8ab 100644 --- a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts @@ -1,5 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; +import { resolveMessageMember } from "../../../pluginUtils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { getMessageInfoEmbed } from "../functions/getMessageInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -15,14 +15,15 @@ export const MessageInfoCmd = utilityCmd({ }, async run({ message, args, pluginData }) { - if (!canReadChannel(args.message.channel, message.member)) { - sendErrorMessage(pluginData, message.channel, "Unknown message"); + const messageMember = await resolveMessageMember(message); + if (!canReadChannel(args.message.channel, messageMember)) { + void pluginData.state.common.sendErrorMessage(message, "Unknown message"); return; } const embed = await getMessageInfoEmbed(pluginData, args.message.channel.id, args.message.messageId); if (!embed) { - sendErrorMessage(pluginData, message.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(message, "Unknown message"); return; } diff --git a/backend/src/plugins/Utility/commands/NicknameCmd.ts b/backend/src/plugins/Utility/commands/NicknameCmd.ts index 3a33b277..360eb2c2 100644 --- a/backend/src/plugins/Utility/commands/NicknameCmd.ts +++ b/backend/src/plugins/Utility/commands/NicknameCmd.ts @@ -1,6 +1,6 @@ import { escapeBold } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { errorMessage } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -25,7 +25,8 @@ export const NicknameCmd = utilityCmd({ return; } - if (msg.member.id !== args.member.id && !canActOn(pluginData, msg.member, args.member)) { + const authorMember = await resolveMessageMember(msg); + if (msg.author.id !== args.member.id && !canActOn(pluginData, authorMember, args.member)) { msg.channel.send(errorMessage("Cannot change nickname: insufficient permissions")); return; } @@ -45,9 +46,8 @@ export const NicknameCmd = utilityCmd({ return; } - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `Changed nickname of <@!${args.member.id}> from **${oldNickname}** to **${args.nickname}**`, ); }, diff --git a/backend/src/plugins/Utility/commands/NicknameResetCmd.ts b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts index b43ef78f..b6eb34b0 100644 --- a/backend/src/plugins/Utility/commands/NicknameResetCmd.ts +++ b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts @@ -1,5 +1,5 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { errorMessage } from "../../../utils.js"; import { utilityCmd } from "../types.js"; @@ -14,7 +14,8 @@ export const NicknameResetCmd = utilityCmd({ }, async run({ message: msg, args, pluginData }) { - if (msg.member.id !== args.member.id && !canActOn(pluginData, msg.member, args.member)) { + const authorMember = await resolveMessageMember(msg); + if (msg.author.id !== args.member.id && !canActOn(pluginData, authorMember, args.member)) { msg.channel.send(errorMessage("Cannot reset nickname: insufficient permissions")); return; } @@ -31,6 +32,6 @@ export const NicknameResetCmd = utilityCmd({ return; } - sendSuccessMessage(pluginData, msg.channel, `The nickname of <@!${args.member.id}> has been reset`); + void pluginData.state.common.sendSuccessMessage(msg, `The nickname of <@!${args.member.id}> has been reset`); }, }); diff --git a/backend/src/plugins/Utility/commands/RolesCmd.ts b/backend/src/plugins/Utility/commands/RolesCmd.ts index 82a2e2c1..e9fb5f75 100644 --- a/backend/src/plugins/Utility/commands/RolesCmd.ts +++ b/backend/src/plugins/Utility/commands/RolesCmd.ts @@ -1,6 +1,5 @@ import { Role } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { chunkArray, sorter, trimLines } from "../../../utils.js"; import { refreshMembersIfNeeded } from "../refreshMembers.js"; import { utilityCmd } from "../types.js"; @@ -62,7 +61,7 @@ export const RolesCmd = utilityCmd({ } else if (sort === "name") { roles.sort(sorter((r) => r.name.toLowerCase(), sortDir)); } else { - sendErrorMessage(pluginData, msg.channel, "Unknown sorting method"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown sorting method"); return; } diff --git a/backend/src/plugins/Utility/commands/ServerInfoCmd.ts b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts index bb19a20f..01e6e762 100644 --- a/backend/src/plugins/Utility/commands/ServerInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getServerInfoEmbed } from "../functions/getServerInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -17,7 +16,7 @@ export const ServerInfoCmd = utilityCmd({ const serverId = args.serverId || pluginData.guild.id; const serverInfoEmbed = await getServerInfoEmbed(pluginData, serverId); if (!serverInfoEmbed) { - sendErrorMessage(pluginData, message.channel, "Could not find information for that server"); + void pluginData.state.common.sendErrorMessage(message, "Could not find information for that server"); return; } diff --git a/backend/src/plugins/Utility/commands/SourceCmd.ts b/backend/src/plugins/Utility/commands/SourceCmd.ts index b02f18dd..fcb8724f 100644 --- a/backend/src/plugins/Utility/commands/SourceCmd.ts +++ b/backend/src/plugins/Utility/commands/SourceCmd.ts @@ -1,6 +1,6 @@ import moment from "moment-timezone"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { getBaseUrl, sendErrorMessage } from "../../../pluginUtils.js"; +import { getBaseUrl, resolveMessageMember } from "../../../pluginUtils.js"; import { canReadChannel } from "../../../utils/canReadChannel.js"; import { utilityCmd } from "../types.js"; @@ -15,14 +15,15 @@ export const SourceCmd = utilityCmd({ }, async run({ message: cmdMessage, args, pluginData }) { - if (!canReadChannel(args.message.channel, cmdMessage.member)) { - sendErrorMessage(pluginData, cmdMessage.channel, "Unknown message"); + const cmdAuthorMember = await resolveMessageMember(cmdMessage); + if (!canReadChannel(args.message.channel, cmdAuthorMember)) { + void pluginData.state.common.sendErrorMessage(cmdMessage, "Unknown message"); return; } const message = await args.message.channel.messages.fetch(args.message.messageId); if (!message) { - sendErrorMessage(pluginData, cmdMessage.channel, "Unknown message"); + void pluginData.state.common.sendErrorMessage(cmdMessage, "Unknown message"); return; } diff --git a/backend/src/plugins/Utility/commands/UserInfoCmd.ts b/backend/src/plugins/Utility/commands/UserInfoCmd.ts index f180a9b5..99603f84 100644 --- a/backend/src/plugins/Utility/commands/UserInfoCmd.ts +++ b/backend/src/plugins/Utility/commands/UserInfoCmd.ts @@ -1,5 +1,4 @@ import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { sendErrorMessage } from "../../../pluginUtils.js"; import { getUserInfoEmbed } from "../functions/getUserInfoEmbed.js"; import { utilityCmd } from "../types.js"; @@ -19,7 +18,7 @@ export const UserInfoCmd = utilityCmd({ const userId = args.user?.id || message.author.id; const embed = await getUserInfoEmbed(pluginData, userId, args.compact); if (!embed) { - sendErrorMessage(pluginData, message.channel, "User not found"); + void pluginData.state.common.sendErrorMessage(message, "User not found"); return; } diff --git a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts index c8a5d320..2c9928a4 100644 --- a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts +++ b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts @@ -1,6 +1,6 @@ import { VoiceChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { renderUsername } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { utilityCmd } from "../types.js"; @@ -16,13 +16,14 @@ export const VcdisconnectCmd = utilityCmd({ }, async run({ message: msg, args, pluginData }) { - if (!canActOn(pluginData, msg.member, args.member)) { - sendErrorMessage(pluginData, msg.channel, "Cannot move: insufficient permissions"); + const authorMember = await resolveMessageMember(msg); + if (!canActOn(pluginData, authorMember, args.member)) { + void pluginData.state.common.sendErrorMessage(msg, "Cannot move: insufficient permissions"); return; } if (!args.member.voice?.channelId) { - sendErrorMessage(pluginData, msg.channel, "Member is not in a voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Member is not in a voice channel"); return; } const channel = pluginData.guild.channels.cache.get(args.member.voice.channelId) as VoiceChannel; @@ -30,7 +31,7 @@ export const VcdisconnectCmd = utilityCmd({ try { await args.member.voice.disconnect(); } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to disconnect member"); + void pluginData.state.common.sendErrorMessage(msg, "Failed to disconnect member"); return; } @@ -40,9 +41,8 @@ export const VcdisconnectCmd = utilityCmd({ oldChannel: channel, }); - sendSuccessMessage( - pluginData, - msg.channel, + pluginData.state.common.sendSuccessMessage( + msg, `**${renderUsername(args.member)}** disconnected from **${channel.name}**`, ); }, diff --git a/backend/src/plugins/Utility/commands/VcmoveCmd.ts b/backend/src/plugins/Utility/commands/VcmoveCmd.ts index 0ccc5724..f36ce64e 100644 --- a/backend/src/plugins/Utility/commands/VcmoveCmd.ts +++ b/backend/src/plugins/Utility/commands/VcmoveCmd.ts @@ -1,6 +1,6 @@ import { ChannelType, Snowflake, VoiceChannel } from "discord.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js"; -import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js"; +import { canActOn, resolveMessageMember } from "../../../pluginUtils.js"; import { channelMentionRegex, isSnowflake, renderUsername, simpleClosestStringMatch } from "../../../utils.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { utilityCmd } from "../types.js"; @@ -23,7 +23,7 @@ export const VcmoveCmd = utilityCmd({ // Snowflake -> resolve channel directly const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -33,7 +33,7 @@ export const VcmoveCmd = utilityCmd({ const channelId = args.channel.match(channelMentionRegex)![1]; const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -45,7 +45,7 @@ export const VcmoveCmd = utilityCmd({ ); const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { - sendErrorMessage(pluginData, msg.channel, "No matching voice channels"); + void pluginData.state.common.sendErrorMessage(msg, "No matching voice channels"); return; } @@ -53,12 +53,12 @@ export const VcmoveCmd = utilityCmd({ } if (!args.member.voice?.channelId) { - sendErrorMessage(pluginData, msg.channel, "Member is not in a voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Member is not in a voice channel"); return; } if (args.member.voice.channelId === channel.id) { - sendErrorMessage(pluginData, msg.channel, "Member is already on that channel!"); + void pluginData.state.common.sendErrorMessage(msg, "Member is already on that channel!"); return; } @@ -69,7 +69,7 @@ export const VcmoveCmd = utilityCmd({ channel: channel.id, }); } catch { - sendErrorMessage(pluginData, msg.channel, "Failed to move member"); + void pluginData.state.common.sendErrorMessage(msg, "Failed to move member"); return; } @@ -80,7 +80,10 @@ export const VcmoveCmd = utilityCmd({ newChannel: channel, }); - sendSuccessMessage(pluginData, msg.channel, `**${renderUsername(args.member)}** moved to **${channel.name}**`); + void pluginData.state.common.sendSuccessMessage( + msg, + `**${renderUsername(args.member)}** moved to **${channel.name}**`, + ); }, }); @@ -102,7 +105,7 @@ export const VcmoveAllCmd = utilityCmd({ // Snowflake -> resolve channel directly const potentialChannel = pluginData.guild.channels.cache.get(args.channel as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -112,7 +115,7 @@ export const VcmoveAllCmd = utilityCmd({ const channelId = args.channel.match(channelMentionRegex)![1]; const potentialChannel = pluginData.guild.channels.cache.get(channelId as Snowflake); if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + void pluginData.state.common.sendErrorMessage(msg, "Unknown or non-voice channel"); return; } @@ -124,7 +127,7 @@ export const VcmoveAllCmd = utilityCmd({ ); const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, (ch) => ch.name); if (!closestMatch) { - sendErrorMessage(pluginData, msg.channel, "No matching voice channels"); + void pluginData.state.common.sendErrorMessage(msg, "No matching voice channels"); return; } @@ -132,27 +135,28 @@ export const VcmoveAllCmd = utilityCmd({ } if (args.oldChannel.members.size === 0) { - sendErrorMessage(pluginData, msg.channel, "Voice channel is empty"); + void pluginData.state.common.sendErrorMessage(msg, "Voice channel is empty"); return; } if (args.oldChannel.id === channel.id) { - sendErrorMessage(pluginData, msg.channel, "Cant move from and to the same channel!"); + void pluginData.state.common.sendErrorMessage(msg, "Cant move from and to the same channel!"); return; } + const authorMember = await resolveMessageMember(msg); + // Cant leave null, otherwise we get an assignment error in the catch - let currMember = msg.member; + let currMember = authorMember; const moveAmt = args.oldChannel.members.size; let errAmt = 0; for (const memberWithId of args.oldChannel.members) { currMember = memberWithId[1]; // Check for permissions but allow self-moves - if (currMember.id !== msg.member.id && !canActOn(pluginData, msg.member, currMember)) { - sendErrorMessage( - pluginData, - msg.channel, + if (currMember.id !== authorMember.id && !canActOn(pluginData, authorMember, currMember)) { + void pluginData.state.common.sendErrorMessage( + msg, `Failed to move ${renderUsername(currMember)} (${currMember.id}): You cannot act on this member`, ); errAmt++; @@ -164,11 +168,14 @@ export const VcmoveAllCmd = utilityCmd({ channel: channel.id, }); } catch { - if (msg.member.id === currMember.id) { - sendErrorMessage(pluginData, msg.channel, "Unknown error when trying to move members"); + if (authorMember.id === currMember.id) { + void pluginData.state.common.sendErrorMessage(msg, "Unknown error when trying to move members"); return; } - sendErrorMessage(pluginData, msg.channel, `Failed to move ${renderUsername(currMember)} (${currMember.id})`); + void pluginData.state.common.sendErrorMessage( + msg, + `Failed to move ${renderUsername(currMember)} (${currMember.id})`, + ); errAmt++; continue; } @@ -182,13 +189,12 @@ export const VcmoveAllCmd = utilityCmd({ } if (moveAmt !== errAmt) { - sendSuccessMessage( - pluginData, - msg.channel, + void pluginData.state.common.sendSuccessMessage( + msg, `${moveAmt - errAmt} members from **${args.oldChannel.name}** moved to **${channel.name}**`, ); } else { - sendErrorMessage(pluginData, msg.channel, `Failed to move any members.`); + void pluginData.state.common.sendErrorMessage(msg, `Failed to move any members.`); } }, }); diff --git a/backend/src/plugins/Utility/docs.ts b/backend/src/plugins/Utility/docs.ts new file mode 100644 index 00000000..8e16116c --- /dev/null +++ b/backend/src/plugins/Utility/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zUtilityConfig } from "./types.js"; + +export const utilityPluginDocs: ZeppelinPluginDocs = { + type: "stable", + prettyName: "Utility", + configSchema: zUtilityConfig, +}; diff --git a/backend/src/plugins/Utility/functions/cleanMessages.ts b/backend/src/plugins/Utility/functions/cleanMessages.ts new file mode 100644 index 00000000..be707984 --- /dev/null +++ b/backend/src/plugins/Utility/functions/cleanMessages.ts @@ -0,0 +1,49 @@ +import { GuildBasedChannel, Snowflake, TextBasedChannel, User } from "discord.js"; +import { GuildPluginData } from "knub"; +import { SavedMessage } from "../../../data/entities/SavedMessage.js"; +import { LogType } from "../../../data/LogType.js"; +import { getBaseUrl } from "../../../pluginUtils.js"; +import { chunkArray } from "../../../utils.js"; +import { LogsPlugin } from "../../Logs/LogsPlugin.js"; +import { UtilityPluginType } from "../types.js"; + +export async function cleanMessages( + pluginData: GuildPluginData, + channel: GuildBasedChannel & TextBasedChannel, + savedMessages: SavedMessage[], + mod: User, +) { + pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id); + pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id); + + // 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[]; + + // Make sure the deletions aren't double logged + 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 (in chunks of 100) + + const chunks = chunkArray(idsToDelete, 100); + await Promise.all( + chunks.map((chunk) => + Promise.all([channel.bulkDelete(chunk), pluginData.state.savedMessages.markBulkAsDeleted(chunk)]), + ), + ); + + // Create an archive + const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild); + const baseUrl = getBaseUrl(pluginData); + const archiveUrl = pluginData.state.archives.getUrl(baseUrl, archiveId); + + pluginData.getPlugin(LogsPlugin).logClean({ + mod, + channel, + count: savedMessages.length, + archiveUrl, + }); + + return { archiveUrl }; +} diff --git a/backend/src/plugins/Utility/functions/fetchChannelMessagesToClean.ts b/backend/src/plugins/Utility/functions/fetchChannelMessagesToClean.ts new file mode 100644 index 00000000..6aed5e56 --- /dev/null +++ b/backend/src/plugins/Utility/functions/fetchChannelMessagesToClean.ts @@ -0,0 +1,123 @@ +import { GuildBasedChannel, Message, OmitPartialGroupDMChannel, Snowflake, TextBasedChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { SavedMessage } from "../../../data/entities/SavedMessage.js"; +import { humanizeDurationShort } from "../../../humanizeDuration.js"; +import { allowTimeout } from "../../../RegExpRunner.js"; +import { DAYS, getInviteCodesInString } from "../../../utils.js"; +import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp.js"; +import { UtilityPluginType } from "../types.js"; + +const MAX_CLEAN_COUNT = 300; +const MAX_CLEAN_TIME = 1 * DAYS; +const MAX_CLEAN_API_REQUESTS = 20; + +export interface FetchChannelMessagesToCleanOpts { + count: number; + beforeId: string; + upToId?: string; + authorId?: string; + includePins?: boolean; + onlyBotMessages?: boolean; + onlyWithInvites?: boolean; + matchContent?: RegExp; +} + +export interface SuccessResult { + messages: SavedMessage[]; + note: string; +} + +export interface ErrorResult { + error: string; +} + +export type FetchChannelMessagesToCleanResult = SuccessResult | ErrorResult; + +export async function fetchChannelMessagesToClean( + pluginData: GuildPluginData, + targetChannel: GuildBasedChannel & TextBasedChannel, + opts: FetchChannelMessagesToCleanOpts, +): Promise { + if (opts.count > MAX_CLEAN_COUNT || opts.count <= 0) { + return { error: `Clean count must be between 1 and ${MAX_CLEAN_COUNT}` }; + } + + const result: FetchChannelMessagesToCleanResult = { + messages: [], + note: "", + }; + + const timestampCutoff = snowflakeToTimestamp(opts.beforeId) - MAX_CLEAN_TIME; + let foundId = false; + + let pinIds: Set = new Set(); + if (!opts.includePins) { + pinIds = new Set((await targetChannel.messages.fetchPinned()).keys()); + } + + let rawMessagesToClean: Array>> = []; + let beforeId = opts.beforeId; + let requests = 0; + while (rawMessagesToClean.length < opts.count) { + const potentialMessages = await targetChannel.messages.fetch({ + before: beforeId, + limit: 100, + }); + if (potentialMessages.size === 0) break; + + requests++; + + const filtered: Array>> = []; + for (const message of potentialMessages.values()) { + const contentString = message.content || ""; + if (opts.authorId && message.author.id !== opts.authorId) continue; + if (opts.onlyBotMessages && !message.author.bot) continue; + if (pinIds.has(message.id)) continue; + if (opts.onlyWithInvites && getInviteCodesInString(contentString).length === 0) continue; + if (opts.upToId && message.id < opts.upToId) { + foundId = true; + break; + } + if (message.createdTimestamp < timestampCutoff) continue; + if ( + opts.matchContent && + !(await pluginData.state.regexRunner.exec(opts.matchContent, contentString).catch(allowTimeout)) + ) { + continue; + } + + filtered.push(message); + } + const remaining = opts.count - rawMessagesToClean.length; + const withoutOverflow = filtered.slice(0, remaining); + rawMessagesToClean.push(...withoutOverflow); + + beforeId = potentialMessages.lastKey()!; + + if (foundId) { + break; + } + + if (rawMessagesToClean.length < opts.count) { + if (potentialMessages.last()!.createdTimestamp < timestampCutoff) { + result.note = `stopped looking after reaching ${humanizeDurationShort(MAX_CLEAN_TIME)} old messages`; + break; + } + + if (requests >= MAX_CLEAN_API_REQUESTS) { + result.note = `stopped looking after ${requests * 100} messages`; + break; + } + } + } + + // Discord messages -> SavedMessages + const existingStored = await pluginData.state.savedMessages.getMultiple(rawMessagesToClean.map((m) => m.id)); + const alreadyStored = existingStored.map((stored) => stored.id); + const messagesToStore = rawMessagesToClean.filter((potentialMsg) => !alreadyStored.includes(potentialMsg.id)); + await pluginData.state.savedMessages.createFromMessages(messagesToStore); + + result.messages = await pluginData.state.savedMessages.getMultiple(rawMessagesToClean.map((m) => m.id)); + + return result; +} diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts index 5c0cd334..401ff39f 100644 --- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts @@ -1,6 +1,6 @@ import { APIEmbed, ChannelType, Snowflake, StageChannel, VoiceChannel } from "discord.js"; -import humanizeDuration from "humanize-duration"; import { GuildPluginData } from "knub"; +import { humanizeDuration } from "../../../humanizeDuration.js"; import { EmbedWith, MINUTES, formatNumber, preEmbedPadding, trimLines, verboseUserMention } from "../../../utils.js"; import { UtilityPluginType } from "../types.js"; diff --git a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts index a990dd50..72a14a33 100644 --- a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts @@ -2,7 +2,6 @@ import { APIEmbed, ChannelType } from "discord.js"; import { GuildPluginData } from "knub"; import { EmbedWith, - GroupDMInvite, formatNumber, inviteHasCounts, isGroupDMInvite, @@ -100,9 +99,8 @@ export async function getInviteInfoEmbed( fields: [], }; - invite = invite as GroupDMInvite; embed.author = { - name: invite.channel!.name ? `Group DM invite: ${invite.channel!.name}` : `Group DM invite`, + name: invite.channel.name ? `Group DM invite: ${invite.channel.name}` : `Group DM invite`, url: `https://discord.gg/${invite.code}`, }; // FIXME pending invite re-think diff --git a/backend/src/plugins/Utility/info.ts b/backend/src/plugins/Utility/info.ts deleted file mode 100644 index 9fea0438..00000000 --- a/backend/src/plugins/Utility/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zUtilityConfig } from "./types.js"; - -export const utilityPluginInfo: ZeppelinPluginInfo = { - showInDocs: true, - prettyName: "Utility", - configSchema: zUtilityConfig, -}; diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index f362ee1b..73981b42 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -5,6 +5,7 @@ import { GuildMember, Message, MessageComponentInteraction, + OmitPartialGroupDMChannel, PermissionsBitField, Snowflake, User, @@ -13,7 +14,7 @@ import escapeStringRegexp from "escape-string-regexp"; import { ArgsFromSignatureOrArray, GuildPluginData } from "knub"; import moment from "moment-timezone"; import { RegExpRunner, allowTimeout } from "../../RegExpRunner.js"; -import { getBaseUrl, sendErrorMessage } from "../../pluginUtils.js"; +import { getBaseUrl } from "../../pluginUtils.js"; import { InvalidRegexError, MINUTES, @@ -73,22 +74,22 @@ export async function displaySearch( pluginData: GuildPluginData, args: MemberSearchParams, searchType: SearchType.MemberSearch, - msg: Message, + msg: OmitPartialGroupDMChannel, ); export async function displaySearch( pluginData: GuildPluginData, args: BanSearchParams, searchType: SearchType.BanSearch, - msg: Message, + msg: OmitPartialGroupDMChannel, ); export async function displaySearch( pluginData: GuildPluginData, args: MemberSearchParams | BanSearchParams, searchType: SearchType, - msg: Message, + msg: OmitPartialGroupDMChannel, ) { // If we're not exporting, load 1 page of search results at a time and allow the user to switch pages with reactions - let originalSearchMsg: Message; + let originalSearchMsg: OmitPartialGroupDMChannel; let searching = false; let currentPage = args.page || 1; let stopCollectionFn: () => void; @@ -107,7 +108,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 as OmitPartialGroupDMChannel)); } let searchResult; @@ -122,12 +123,12 @@ export async function displaySearch( } } catch (e) { if (e instanceof SearchError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } if (e instanceof InvalidRegexError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } @@ -135,7 +136,7 @@ export async function displaySearch( } if (searchResult.totalResults === 0) { - sendErrorMessage(pluginData, msg.channel, "No results found"); + void pluginData.state.common.sendErrorMessage(msg, "No results found"); return; } @@ -240,19 +241,19 @@ export async function archiveSearch( pluginData: GuildPluginData, args: MemberSearchParams, searchType: SearchType.MemberSearch, - msg: Message, + msg: OmitPartialGroupDMChannel, ); export async function archiveSearch( pluginData: GuildPluginData, args: BanSearchParams, searchType: SearchType.BanSearch, - msg: Message, + msg: OmitPartialGroupDMChannel, ); export async function archiveSearch( pluginData: GuildPluginData, args: MemberSearchParams | BanSearchParams, searchType: SearchType, - msg: Message, + msg: OmitPartialGroupDMChannel, ) { let results; try { @@ -266,12 +267,12 @@ export async function archiveSearch( } } catch (e) { if (e instanceof SearchError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } if (e instanceof InvalidRegexError) { - sendErrorMessage(pluginData, msg.channel, e.message); + void pluginData.state.common.sendErrorMessage(msg, e.message); return; } @@ -279,7 +280,7 @@ export async function archiveSearch( } if (results.totalResults === 0) { - sendErrorMessage(pluginData, msg.channel, "No results found"); + void pluginData.state.common.sendErrorMessage(msg, "No results found"); return; } diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts index 466eb6f9..4f181b2b 100644 --- a/backend/src/plugins/Utility/types.ts +++ b/backend/src/plugins/Utility/types.ts @@ -1,44 +1,45 @@ -import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; -import z from "zod"; +import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub"; +import z from "zod/v4"; import { RegExpRunner } from "../../RegExpRunner.js"; import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildCases } from "../../data/GuildCases.js"; import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { Supporters } from "../../data/Supporters.js"; +import { CommonPlugin } from "../Common/CommonPlugin.js"; export const zUtilityConfig = z.strictObject({ - can_roles: z.boolean(), - can_level: z.boolean(), - can_search: z.boolean(), - can_clean: z.boolean(), - can_info: z.boolean(), - can_server: z.boolean(), - can_inviteinfo: z.boolean(), - can_channelinfo: z.boolean(), - can_messageinfo: z.boolean(), - can_userinfo: z.boolean(), - can_roleinfo: z.boolean(), - can_emojiinfo: z.boolean(), - can_snowflake: z.boolean(), - can_reload_guild: z.boolean(), - can_nickname: z.boolean(), - can_ping: z.boolean(), - can_source: z.boolean(), - can_vcmove: z.boolean(), - can_vckick: z.boolean(), - can_help: z.boolean(), - can_about: z.boolean(), - can_context: z.boolean(), - can_jumbo: z.boolean(), - jumbo_size: z.number(), - can_avatar: z.boolean(), - info_on_single_result: z.boolean(), - autojoin_threads: z.boolean(), + can_roles: z.boolean().default(false), + can_level: z.boolean().default(false), + can_search: z.boolean().default(false), + can_clean: z.boolean().default(false), + can_info: z.boolean().default(false), + can_server: z.boolean().default(false), + can_inviteinfo: z.boolean().default(false), + can_channelinfo: z.boolean().default(false), + can_messageinfo: z.boolean().default(false), + can_userinfo: z.boolean().default(false), + can_roleinfo: z.boolean().default(false), + can_emojiinfo: z.boolean().default(false), + can_snowflake: z.boolean().default(false), + can_reload_guild: z.boolean().default(false), + can_nickname: z.boolean().default(false), + can_ping: z.boolean().default(false), + can_source: z.boolean().default(false), + can_vcmove: z.boolean().default(false), + can_vckick: z.boolean().default(false), + can_help: z.boolean().default(false), + can_about: z.boolean().default(false), + can_context: z.boolean().default(false), + can_jumbo: z.boolean().default(false), + jumbo_size: z.number().default(128), + can_avatar: z.boolean().default(false), + info_on_single_result: z.boolean().default(true), + autojoin_threads: z.boolean().default(true), }); export interface UtilityPluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zUtilityConfig; state: { logs: GuildLogs; cases: GuildCases; @@ -48,6 +49,8 @@ export interface UtilityPluginType extends BasePluginType { regexRunner: RegExpRunner; lastReload: number; + + common: pluginUtils.PluginPublicInterface; }; } diff --git a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts index 7848393a..b566ce41 100644 --- a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts +++ b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts @@ -1,23 +1,14 @@ -import { PluginOptions, guildPlugin } from "knub"; +import { guildPlugin } from "knub"; import { GuildLogs } from "../../data/GuildLogs.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { SendWelcomeMessageEvt } from "./events/SendWelcomeMessageEvt.js"; import { WelcomeMessagePluginType, zWelcomeMessageConfig } from "./types.js"; -const defaultOptions: PluginOptions = { - config: { - send_dm: false, - send_to_channel: null, - message: null, - }, -}; - export const WelcomeMessagePlugin = guildPlugin()({ name: "welcome_message", dependencies: () => [LogsPlugin], - configParser: (input) => zWelcomeMessageConfig.parse(input), - defaultOptions, + configSchema: zWelcomeMessageConfig, // prettier-ignore events: [ diff --git a/backend/src/plugins/WelcomeMessage/docs.ts b/backend/src/plugins/WelcomeMessage/docs.ts new file mode 100644 index 00000000..680a2a47 --- /dev/null +++ b/backend/src/plugins/WelcomeMessage/docs.ts @@ -0,0 +1,8 @@ +import { ZeppelinPluginDocs } from "../../types.js"; +import { zWelcomeMessageConfig } from "./types.js"; + +export const welcomeMessagePluginDocs: ZeppelinPluginDocs = { + type: "stable", + prettyName: "Welcome message", + configSchema: zWelcomeMessageConfig, +}; diff --git a/backend/src/plugins/WelcomeMessage/info.ts b/backend/src/plugins/WelcomeMessage/info.ts deleted file mode 100644 index a8a4c339..00000000 --- a/backend/src/plugins/WelcomeMessage/info.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ZeppelinPluginInfo } from "../../types.js"; -import { zWelcomeMessageConfig } from "./types.js"; - -export const welcomeMessagePluginInfo: ZeppelinPluginInfo = { - showInDocs: true, - prettyName: "Welcome message", - configSchema: zWelcomeMessageConfig, -}; diff --git a/backend/src/plugins/WelcomeMessage/types.ts b/backend/src/plugins/WelcomeMessage/types.ts index 05bd3354..a75062e5 100644 --- a/backend/src/plugins/WelcomeMessage/types.ts +++ b/backend/src/plugins/WelcomeMessage/types.ts @@ -1,15 +1,15 @@ import { BasePluginType, guildPluginEventListener } from "knub"; -import z from "zod"; +import z from "zod/v4"; import { GuildLogs } from "../../data/GuildLogs.js"; export const zWelcomeMessageConfig = z.strictObject({ - send_dm: z.boolean(), - send_to_channel: z.string().nullable(), - message: z.string().nullable(), + send_dm: z.boolean().default(false), + send_to_channel: z.string().nullable().default(null), + message: z.string().nullable().default(null), }); export interface WelcomeMessagePluginType extends BasePluginType { - config: z.infer; + configSchema: typeof zWelcomeMessageConfig; state: { logs: GuildLogs; sentWelcomeMessages: Set; diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 3a7fd60b..46653ce8 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -1,100 +1,254 @@ -import { GlobalPluginBlueprint, GuildPluginBlueprint } from "knub"; +import { ZeppelinGlobalPluginInfo, ZeppelinGuildPluginInfo } from "../types.js"; import { AutoDeletePlugin } from "./AutoDelete/AutoDeletePlugin.js"; +import { autoDeletePluginDocs } from "./AutoDelete/docs.js"; import { AutoReactionsPlugin } from "./AutoReactions/AutoReactionsPlugin.js"; +import { autoReactionsPluginDocs } from "./AutoReactions/docs.js"; import { AutomodPlugin } from "./Automod/AutomodPlugin.js"; +import { automodPluginDocs } from "./Automod/docs.js"; import { BotControlPlugin } from "./BotControl/BotControlPlugin.js"; +import { botControlPluginDocs } from "./BotControl/docs.js"; import { CasesPlugin } from "./Cases/CasesPlugin.js"; +import { casesPluginDocs } from "./Cases/docs.js"; import { CensorPlugin } from "./Censor/CensorPlugin.js"; -import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin.js"; +import { censorPluginDocs } from "./Censor/docs.js"; +import { CommonPlugin } from "./Common/CommonPlugin.js"; +import { commonPluginDocs } from "./Common/docs.js"; import { CompanionChannelsPlugin } from "./CompanionChannels/CompanionChannelsPlugin.js"; +import { companionChannelsPluginDocs } from "./CompanionChannels/docs.js"; import { ContextMenuPlugin } from "./ContextMenus/ContextMenuPlugin.js"; +import { contextMenuPluginDocs } from "./ContextMenus/docs.js"; import { CountersPlugin } from "./Counters/CountersPlugin.js"; +import { countersPluginDocs } from "./Counters/docs.js"; import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin.js"; +import { customEventsPluginDocs } from "./CustomEvents/docs.js"; import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin.js"; +import { guildAccessMonitorPluginDocs } from "./GuildAccessMonitor/docs.js"; import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigReloaderPlugin.js"; +import { guildConfigReloaderPluginDocs } from "./GuildConfigReloader/docs.js"; import { GuildInfoSaverPlugin } from "./GuildInfoSaver/GuildInfoSaverPlugin.js"; +import { guildInfoSaverPluginDocs } from "./GuildInfoSaver/docs.js"; import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin.js"; +import { internalPosterPluginDocs } from "./InternalPoster/docs.js"; import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin.js"; +import { locateUserPluginDocs } from "./LocateUser/docs.js"; import { LogsPlugin } from "./Logs/LogsPlugin.js"; +import { logsPluginDocs } from "./Logs/docs.js"; import { MessageSaverPlugin } from "./MessageSaver/MessageSaverPlugin.js"; +import { messageSaverPluginDocs } from "./MessageSaver/docs.js"; import { ModActionsPlugin } from "./ModActions/ModActionsPlugin.js"; +import { modActionsPluginDocs } from "./ModActions/docs.js"; import { MutesPlugin } from "./Mutes/MutesPlugin.js"; +import { mutesPluginDocs } from "./Mutes/docs.js"; import { NameHistoryPlugin } from "./NameHistory/NameHistoryPlugin.js"; +import { nameHistoryPluginDocs } from "./NameHistory/docs.js"; import { PersistPlugin } from "./Persist/PersistPlugin.js"; +import { persistPluginDocs } from "./Persist/docs.js"; import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin.js"; +import { phishermanPluginDocs } from "./Phisherman/docs.js"; import { PingableRolesPlugin } from "./PingableRoles/PingableRolesPlugin.js"; +import { pingableRolesPluginDocs } from "./PingableRoles/docs.js"; import { PostPlugin } from "./Post/PostPlugin.js"; +import { postPluginDocs } from "./Post/docs.js"; import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin.js"; +import { reactionRolesPluginDocs } from "./ReactionRoles/docs.js"; import { RemindersPlugin } from "./Reminders/RemindersPlugin.js"; +import { remindersPluginDocs } from "./Reminders/docs.js"; import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin.js"; +import { roleButtonsPluginDocs } from "./RoleButtons/docs.js"; import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin.js"; +import { roleManagerPluginDocs } from "./RoleManager/docs.js"; import { RolesPlugin } from "./Roles/RolesPlugin.js"; +import { rolesPluginDocs } from "./Roles/docs.js"; import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin.js"; +import { selfGrantableRolesPluginDocs } from "./SelfGrantableRoles/docs.js"; import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin.js"; +import { slowmodePluginDocs } from "./Slowmode/docs.js"; import { SpamPlugin } from "./Spam/SpamPlugin.js"; +import { spamPluginDocs } from "./Spam/docs.js"; import { StarboardPlugin } from "./Starboard/StarboardPlugin.js"; +import { starboardPluginDocs } from "./Starboard/docs.js"; import { TagsPlugin } from "./Tags/TagsPlugin.js"; +import { tagsPluginDocs } from "./Tags/docs.js"; import { TimeAndDatePlugin } from "./TimeAndDate/TimeAndDatePlugin.js"; +import { timeAndDatePluginDocs } from "./TimeAndDate/docs.js"; import { UsernameSaverPlugin } from "./UsernameSaver/UsernameSaverPlugin.js"; +import { usernameSaverPluginDocs } from "./UsernameSaver/docs.js"; import { UtilityPlugin } from "./Utility/UtilityPlugin.js"; +import { utilityPluginDocs } from "./Utility/docs.js"; import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin.js"; +import { welcomeMessagePluginDocs } from "./WelcomeMessage/docs.js"; -// prettier-ignore -export const guildPlugins: Array> = [ - AutoDeletePlugin, - AutoReactionsPlugin, - GuildInfoSaverPlugin, - CensorPlugin, - ChannelArchiverPlugin, - LocateUserPlugin, - LogsPlugin, - PersistPlugin, - PingableRolesPlugin, - PostPlugin, - ReactionRolesPlugin, - MessageSaverPlugin, - // GuildMemberCachePlugin, // FIXME: New caching thing, or fix deadlocks with this plugin - ModActionsPlugin, - NameHistoryPlugin, - RemindersPlugin, - RolesPlugin, - SelfGrantableRolesPlugin, - SlowmodePlugin, - SpamPlugin, - StarboardPlugin, - TagsPlugin, - UsernameSaverPlugin, - UtilityPlugin, - WelcomeMessagePlugin, - CasesPlugin, - MutesPlugin, - AutomodPlugin, - CompanionChannelsPlugin, - CustomEventsPlugin, - TimeAndDatePlugin, - CountersPlugin, - ContextMenuPlugin, - PhishermanPlugin, - InternalPosterPlugin, - RoleManagerPlugin, - RoleButtonsPlugin, +export const availableGuildPlugins: ZeppelinGuildPluginInfo[] = [ + { + plugin: AutoDeletePlugin, + docs: autoDeletePluginDocs, + }, + { + plugin: AutomodPlugin, + docs: automodPluginDocs, + }, + { + plugin: AutoReactionsPlugin, + docs: autoReactionsPluginDocs, + }, + { + plugin: CasesPlugin, + docs: casesPluginDocs, + autoload: true, + }, + { + plugin: CensorPlugin, + docs: censorPluginDocs, + }, + { + plugin: CompanionChannelsPlugin, + docs: companionChannelsPluginDocs, + }, + { + plugin: ContextMenuPlugin, + docs: contextMenuPluginDocs, + }, + { + plugin: CountersPlugin, + docs: countersPluginDocs, + }, + { + plugin: CustomEventsPlugin, + docs: customEventsPluginDocs, + }, + { + plugin: GuildInfoSaverPlugin, + docs: guildInfoSaverPluginDocs, + autoload: true, + }, + // FIXME: New caching thing, or fix deadlocks with this plugin + // { + // plugin: GuildMemberCachePlugin, + // docs: guildMemberCachePluginDocs, + // autoload: true, + // }, + { + plugin: InternalPosterPlugin, + docs: internalPosterPluginDocs, + }, + { + plugin: LocateUserPlugin, + docs: locateUserPluginDocs, + }, + { + plugin: LogsPlugin, + docs: logsPluginDocs, + }, + { + plugin: MessageSaverPlugin, + docs: messageSaverPluginDocs, + autoload: true, + }, + { + plugin: ModActionsPlugin, + docs: modActionsPluginDocs, + }, + { + plugin: MutesPlugin, + docs: mutesPluginDocs, + autoload: true, + }, + { + plugin: NameHistoryPlugin, + docs: nameHistoryPluginDocs, + autoload: true, + }, + { + plugin: PersistPlugin, + docs: persistPluginDocs, + }, + { + plugin: PhishermanPlugin, + docs: phishermanPluginDocs, + }, + { + plugin: PingableRolesPlugin, + docs: pingableRolesPluginDocs, + }, + { + plugin: PostPlugin, + docs: postPluginDocs, + }, + { + plugin: ReactionRolesPlugin, + docs: reactionRolesPluginDocs, + }, + { + plugin: RemindersPlugin, + docs: remindersPluginDocs, + }, + { + plugin: RoleButtonsPlugin, + docs: roleButtonsPluginDocs, + }, + { + plugin: RoleManagerPlugin, + docs: roleManagerPluginDocs, + }, + { + plugin: RolesPlugin, + docs: rolesPluginDocs, + }, + { + plugin: SelfGrantableRolesPlugin, + docs: selfGrantableRolesPluginDocs, + }, + { + plugin: SlowmodePlugin, + docs: slowmodePluginDocs, + }, + { + plugin: SpamPlugin, + docs: spamPluginDocs, + }, + { + plugin: StarboardPlugin, + docs: starboardPluginDocs, + }, + { + plugin: TagsPlugin, + docs: tagsPluginDocs, + }, + { + plugin: TimeAndDatePlugin, + docs: timeAndDatePluginDocs, + autoload: true, + }, + { + plugin: UsernameSaverPlugin, + docs: usernameSaverPluginDocs, + }, + { + plugin: UtilityPlugin, + docs: utilityPluginDocs, + }, + { + plugin: WelcomeMessagePlugin, + docs: welcomeMessagePluginDocs, + }, + { + plugin: CommonPlugin, + docs: commonPluginDocs, + autoload: true, + }, ]; -// prettier-ignore -export const globalPlugins: Array> = [ - GuildConfigReloaderPlugin, - BotControlPlugin, - GuildAccessMonitorPlugin, -]; - -// prettier-ignore -export const baseGuildPlugins: Array> = [ - GuildInfoSaverPlugin, - MessageSaverPlugin, - NameHistoryPlugin, - // GuildMemberCachePlugin, // FIXME: New caching thing, or fix deadlocks with this plugin - CasesPlugin, - MutesPlugin, - TimeAndDatePlugin, - // TODO: Replace these with proper dependencies +export const availableGlobalPlugins: ZeppelinGlobalPluginInfo[] = [ + { + plugin: GuildConfigReloaderPlugin, + docs: guildConfigReloaderPluginDocs, + }, + { + plugin: BotControlPlugin, + docs: botControlPluginDocs, + }, + { + plugin: GuildAccessMonitorPlugin, + docs: guildAccessMonitorPluginDocs, + }, ]; diff --git a/backend/src/plugins/pluginInfo.ts b/backend/src/plugins/pluginInfo.ts deleted file mode 100644 index 58dbc34f..00000000 --- a/backend/src/plugins/pluginInfo.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ZeppelinPluginInfo } from "../types.js"; -import { AutoDeletePlugin } from "./AutoDelete/AutoDeletePlugin.js"; -import { autoDeletePluginInfo } from "./AutoDelete/info.js"; -import { AutoReactionsPlugin } from "./AutoReactions/AutoReactionsPlugin.js"; -import { autoReactionsInfo } from "./AutoReactions/info.js"; -import { AutomodPlugin } from "./Automod/AutomodPlugin.js"; -import { automodPluginInfo } from "./Automod/info.js"; -import { CasesPlugin } from "./Cases/CasesPlugin.js"; -import { casesPluginInfo } from "./Cases/info.js"; -import { CensorPlugin } from "./Censor/CensorPlugin.js"; -import { censorPluginInfo } from "./Censor/info.js"; -import { CompanionChannelsPlugin } from "./CompanionChannels/CompanionChannelsPlugin.js"; -import { companionChannelsPluginInfo } from "./CompanionChannels/info.js"; -import { ContextMenuPlugin } from "./ContextMenus/ContextMenuPlugin.js"; -import { contextMenuPluginInfo } from "./ContextMenus/info.js"; -import { CountersPlugin } from "./Counters/CountersPlugin.js"; -import { countersPluginInfo } from "./Counters/info.js"; -import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin.js"; -import { customEventsPluginInfo } from "./CustomEvents/info.js"; -import { GuildInfoSaverPlugin } from "./GuildInfoSaver/GuildInfoSaverPlugin.js"; -import { guildInfoSaverPluginInfo } from "./GuildInfoSaver/info.js"; -import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin.js"; -import { internalPosterPluginInfo } from "./InternalPoster/info.js"; -import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin.js"; -import { locateUserPluginInfo } from "./LocateUser/info.js"; -import { LogsPlugin } from "./Logs/LogsPlugin.js"; -import { logsPluginInfo } from "./Logs/info.js"; -import { MessageSaverPlugin } from "./MessageSaver/MessageSaverPlugin.js"; -import { messageSaverPluginInfo } from "./MessageSaver/info.js"; -import { ModActionsPlugin } from "./ModActions/ModActionsPlugin.js"; -import { modActionsPluginInfo } from "./ModActions/info.js"; -import { MutesPlugin } from "./Mutes/MutesPlugin.js"; -import { mutesPluginInfo } from "./Mutes/info.js"; -import { NameHistoryPlugin } from "./NameHistory/NameHistoryPlugin.js"; -import { nameHistoryPluginInfo } from "./NameHistory/info.js"; -import { PersistPlugin } from "./Persist/PersistPlugin.js"; -import { persistPluginInfo } from "./Persist/info.js"; -import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin.js"; -import { phishermanPluginInfo } from "./Phisherman/info.js"; -import { PingableRolesPlugin } from "./PingableRoles/PingableRolesPlugin.js"; -import { pingableRolesPluginInfo } from "./PingableRoles/info.js"; -import { PostPlugin } from "./Post/PostPlugin.js"; -import { postPluginInfo } from "./Post/info.js"; -import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin.js"; -import { reactionRolesPluginInfo } from "./ReactionRoles/info.js"; -import { RemindersPlugin } from "./Reminders/RemindersPlugin.js"; -import { remindersPluginInfo } from "./Reminders/info.js"; -import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin.js"; -import { roleButtonsPluginInfo } from "./RoleButtons/info.js"; -import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin.js"; -import { roleManagerPluginInfo } from "./RoleManager/info.js"; -import { RolesPlugin } from "./Roles/RolesPlugin.js"; -import { rolesPluginInfo } from "./Roles/info.js"; -import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin.js"; -import { selfGrantableRolesPluginInfo } from "./SelfGrantableRoles/info.js"; -import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin.js"; -import { slowmodePluginInfo } from "./Slowmode/info.js"; -import { SpamPlugin } from "./Spam/SpamPlugin.js"; -import { spamPluginInfo } from "./Spam/info.js"; -import { StarboardPlugin } from "./Starboard/StarboardPlugin.js"; -import { starboardPluginInfo } from "./Starboard/info.js"; -import { TagsPlugin } from "./Tags/TagsPlugin.js"; -import { tagsPluginInfo } from "./Tags/info.js"; -import { TimeAndDatePlugin } from "./TimeAndDate/TimeAndDatePlugin.js"; -import { timeAndDatePluginInfo } from "./TimeAndDate/info.js"; -import { UsernameSaverPlugin } from "./UsernameSaver/UsernameSaverPlugin.js"; -import { usernameSaverPluginInfo } from "./UsernameSaver/info.js"; -import { UtilityPlugin } from "./Utility/UtilityPlugin.js"; -import { utilityPluginInfo } from "./Utility/info.js"; -import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin.js"; -import { welcomeMessagePluginInfo } from "./WelcomeMessage/info.js"; - -export const guildPluginInfo: Record = { - [AutoDeletePlugin.name]: autoDeletePluginInfo, - [AutoReactionsPlugin.name]: autoReactionsInfo, - [GuildInfoSaverPlugin.name]: guildInfoSaverPluginInfo, - [CensorPlugin.name]: censorPluginInfo, - [LocateUserPlugin.name]: locateUserPluginInfo, - [LogsPlugin.name]: logsPluginInfo, - [PersistPlugin.name]: persistPluginInfo, - [PingableRolesPlugin.name]: pingableRolesPluginInfo, - [PostPlugin.name]: postPluginInfo, - [ReactionRolesPlugin.name]: reactionRolesPluginInfo, - [MessageSaverPlugin.name]: messageSaverPluginInfo, - [ModActionsPlugin.name]: modActionsPluginInfo, - [NameHistoryPlugin.name]: nameHistoryPluginInfo, - [RemindersPlugin.name]: remindersPluginInfo, - [RolesPlugin.name]: rolesPluginInfo, - [SelfGrantableRolesPlugin.name]: selfGrantableRolesPluginInfo, - [SlowmodePlugin.name]: slowmodePluginInfo, - [SpamPlugin.name]: spamPluginInfo, - [StarboardPlugin.name]: starboardPluginInfo, - [TagsPlugin.name]: tagsPluginInfo, - [UsernameSaverPlugin.name]: usernameSaverPluginInfo, - [UtilityPlugin.name]: utilityPluginInfo, - [WelcomeMessagePlugin.name]: welcomeMessagePluginInfo, - [CasesPlugin.name]: casesPluginInfo, - [MutesPlugin.name]: mutesPluginInfo, - [AutomodPlugin.name]: automodPluginInfo, - [CompanionChannelsPlugin.name]: companionChannelsPluginInfo, - [CustomEventsPlugin.name]: customEventsPluginInfo, - [TimeAndDatePlugin.name]: timeAndDatePluginInfo, - [CountersPlugin.name]: countersPluginInfo, - [ContextMenuPlugin.name]: contextMenuPluginInfo, - [PhishermanPlugin.name]: phishermanPluginInfo, - [InternalPosterPlugin.name]: internalPosterPluginInfo, - [RoleManagerPlugin.name]: roleManagerPluginInfo, - [RoleButtonsPlugin.name]: roleButtonsPluginInfo, -}; diff --git a/backend/src/profiler.ts b/backend/src/profiler.ts index e0b03f20..088a9644 100644 --- a/backend/src/profiler.ts +++ b/backend/src/profiler.ts @@ -3,7 +3,7 @@ import type { Knub } from "knub"; type Profiler = Knub["profiler"]; let profiler: Profiler | null = null; -export function getProfiler() { +export function getProfiler(): Profiler | null { return profiler; } diff --git a/backend/src/templateFormatter.ts b/backend/src/templateFormatter.ts index db053d3e..431891f7 100644 --- a/backend/src/templateFormatter.ts +++ b/backend/src/templateFormatter.ts @@ -481,7 +481,7 @@ export async function renderTemplate( // If our template cache is full, delete the first item if (templateCache.size >= TEMPLATE_CACHE_SIZE) { - const firstKey = templateCache.keys().next().value; + const firstKey = templateCache.keys().next().value!; templateCache.delete(firstKey); } diff --git a/backend/src/types.ts b/backend/src/types.ts index 25319889..35142f4c 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,29 +1,12 @@ -import { BaseConfig, Knub } from "knub"; -import z, { ZodTypeAny } from "zod"; +import { GlobalPluginBlueprint, GuildPluginBlueprint, Knub } from "knub"; +import z from "zod/v4"; import { zSnowflake } from "./utils.js"; -export interface ZeppelinGuildConfig extends BaseConfig { - success_emoji?: string; - error_emoji?: string; - - // Deprecated - timezone?: string; - date_formats?: any; -} - export const zZeppelinGuildConfig = z.strictObject({ // From BaseConfig prefix: z.string().optional(), levels: z.record(zSnowflake, z.number()).optional(), plugins: z.record(z.string(), z.unknown()).optional(), - - // From ZeppelinGuildConfig - success_emoji: z.string().optional(), - error_emoji: z.string().optional(), - - // Deprecated - timezone: z.string().optional(), - date_formats: z.unknown().optional(), }); export type TZeppelinKnub = Knub; @@ -33,14 +16,27 @@ export type TZeppelinKnub = Knub; */ export type TMarkdown = string; -export interface ZeppelinPluginInfo { - showInDocs: boolean; - prettyName: string; - configSchema: ZodTypeAny; +export interface ZeppelinGuildPluginInfo { + plugin: GuildPluginBlueprint; + docs: ZeppelinPluginDocs; + autoload?: boolean; +} + +export interface ZeppelinGlobalPluginInfo { + plugin: GlobalPluginBlueprint; + docs: ZeppelinPluginDocs; +} + +export type DocsPluginType = "stable" | "legacy" | "internal"; + +export interface ZeppelinPluginDocs { + type: DocsPluginType; + configSchema: z.ZodType; + + prettyName?: string; description?: TMarkdown; usageGuide?: TMarkdown; configurationGuide?: TMarkdown; - legacy?: boolean; } export interface CommandInfo { diff --git a/backend/src/utils.test.ts b/backend/src/utils.test.ts index 2c1627d6..3fd7b054 100644 --- a/backend/src/utils.test.ts +++ b/backend/src/utils.test.ts @@ -1,5 +1,5 @@ import test from "ava"; -import z from "zod"; +import z from "zod/v4"; import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, zAllowedMentions } from "./utils.js"; import { ErisAllowedMentionFormat } from "./utils/erisAllowedMentionsToDjsMentionOptions.js"; diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 214d3f20..a6c949ed 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,6 +1,7 @@ import { APIEmbed, ChannelType, + ChatInputCommandInteraction, Client, DiscordAPIError, EmbedData, @@ -14,13 +15,17 @@ import { GuildTextBasedChannel, Invite, InviteGuild, + InviteType, LimitedCollection, Message, MessageCreateOptions, MessageMentionOptions, PartialChannelData, + PartialGroupDMChannel, PartialMessage, + PartialUser, RoleResolvable, + SendableChannels, Sticker, TextBasedChannel, User, @@ -28,37 +33,31 @@ import { import emojiRegex from "emoji-regex"; import fs from "fs"; import https from "https"; -import humanizeDuration from "humanize-duration"; import isEqual from "lodash/isEqual.js"; import { performance } from "perf_hooks"; -import tlds from "tlds" assert { type: "json" }; +import tlds from "tlds" with { type: "json" }; import tmp from "tmp"; import { URL } from "url"; -import { z, ZodEffects, ZodError, ZodRecord, ZodString } from "zod"; +import { z, ZodError, ZodPipe, ZodRecord, ZodString, ZodTransform } from "zod/v4"; import { ISavedMessageAttachmentData, SavedMessage } from "./data/entities/SavedMessage.js"; +import { delayStringMultipliers, humanizeDuration } from "./humanizeDuration.js"; import { getProfiler } from "./profiler.js"; import { SimpleCache } from "./SimpleCache.js"; import { sendDM } from "./utils/sendDM.js"; import { Brand } from "./utils/typeUtils.js"; import { waitForButtonConfirm } from "./utils/waitForInteraction.js"; +import { GenericCommandSource } from "./pluginUtils.js"; const fsp = fs.promises; -const delayStringMultipliers = { - w: 1000 * 60 * 60 * 24 * 7, - d: 1000 * 60 * 60 * 24, - h: 1000 * 60 * 60, - m: 1000 * 60, - s: 1000, - x: 1, -}; - export const MS = 1; export const SECONDS = 1000 * MS; export const MINUTES = 60 * SECONDS; export const HOURS = 60 * MINUTES; export const DAYS = 24 * HOURS; -export const WEEKS = 7 * 24 * HOURS; +export const WEEKS = 7 * DAYS; +export const YEARS = (365 + 1 / 4 - 1 / 100 + 1 / 400) * DAYS; +export const MONTHS = YEARS / 12; export const EMPTY_CHAR = "\u200b"; @@ -89,7 +88,7 @@ export function isDiscordAPIError(err: Error | string): err is DiscordAPIError { // null | undefined -> undefined export function zNullishToUndefined( type: T, -): ZodEffects> | undefined> { +): ZodPipe> | undefined>> { return type.transform((v) => v ?? undefined); } @@ -148,8 +147,7 @@ export function nonNullish(v: V): v is NonNullable { export type GuildInvite = Invite & { guild: InviteGuild | Guild }; export type GroupDMInvite = Invite & { - channel: PartialChannelData; - type: typeof ChannelType.GroupDM; + channel: PartialGroupDMChannel; }; export function zBoundedCharacters(min: number, max: number) { @@ -186,23 +184,20 @@ export function inputPatternToRegExp(pattern: string) { } export function zRegex(zStr: T) { - return zStr.transform((str, ctx) => { + return zStr.refine((str) => { try { - return inputPatternToRegExp(str); + inputPatternToRegExp(str); + return true; } catch (err) { if (err instanceof InvalidRegexError) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Invalid regex", - }); - return z.NEVER; + return false; } throw err; } }); } -export const zEmbedInput = z.object({ +export const zEmbedInput = z.strictObject({ title: z.string().optional(), description: z.string().optional(), url: z.string().optional(), @@ -267,14 +262,28 @@ export const zEmbedInput = z.object({ }), ) .nullable(), +}).meta({ + id: "embedInput", }); export type EmbedWith = APIEmbed & Pick, T>; -export const zStrictMessageContent = z.object({ +export const zStrictMessageContent = z.strictObject({ content: z.string().optional(), tts: z.boolean().optional(), - embeds: z.array(zEmbedInput).optional(), + embeds: z.union([z.array(zEmbedInput), zEmbedInput]).optional(), + embed: zEmbedInput.optional(), +}).transform((data) => { + if (data.embed) { + data.embeds = [data.embed]; + delete data.embed; + } + if (data.embeds && !Array.isArray(data.embeds)) { + data.embeds = [data.embeds]; + } + return data as StrictMessageContent; +}).meta({ + id: "strictMessageContent", }); export type ZStrictMessageContent = z.infer; @@ -289,7 +298,7 @@ export type MessageContent = string | StrictMessageContent; export const zMessageContent = z.union([ zBoundedCharacters(0, 4000), zStrictMessageContent, -]) as z.ZodType; +]); export function validateAndParseMessageContent(input: unknown): StrictMessageContent { if (input == null) { @@ -380,7 +389,7 @@ export function zBoundedRecord>( record: TRecord, minKeys: number, maxKeys: number, -): ZodEffects { +): TRecord { return record.refine( (data) => { const len = Object.keys(data).length; @@ -407,7 +416,7 @@ const MAX_DELAY_STRING_AMOUNT = 100 * 365 * DAYS; * Turns a "delay string" such as "1h30m" to milliseconds */ export function convertDelayStringToMS(str, defaultUnit = "m"): number | null { - const regex = /^([0-9]+)\s*([wdhms])?[a-z]*\s*/; + const regex = /^([0-9]+)\s*((?:mo?)|[ywdhs])?[a-z]*\s*/; let match; let ms = 0; @@ -838,7 +847,7 @@ export function chunkMessageLines(str: string, maxChunkLength = 1990): string[] } export async function createChunkedMessage( - channel: TextBasedChannel | User, + channel: SendableChannels | User, messageText: string, allowedMentions?: MessageMentionOptions, ) { @@ -1305,11 +1314,11 @@ export async function resolveStickerId(bot: Client, id: Snowflake): Promise { - return waitForButtonConfirm(channel, content, { restrictToId: userId }); + return waitForButtonConfirm(context, content, { restrictToId: userId }); } export function messageSummary(msg: SavedMessage) { @@ -1478,11 +1487,11 @@ export function isFullMessage(msg: Message | PartialMessage): msg is Message { } export function isGuildInvite(invite: Invite): invite is GuildInvite { - return invite.guild != null; + return invite.type === InviteType.Guild; } export function isGroupDMInvite(invite: Invite): invite is GroupDMInvite { - return invite.guild == null && invite.channel?.type === ChannelType.GroupDM; + return invite.type === InviteType.GroupDM; } export function inviteHasCounts(invite: Invite): invite is Invite { diff --git a/backend/src/utils/createPaginatedMessage.ts b/backend/src/utils/createPaginatedMessage.ts index 2f6c6fb3..e7671584 100644 --- a/backend/src/utils/createPaginatedMessage.ts +++ b/backend/src/utils/createPaginatedMessage.ts @@ -1,19 +1,10 @@ -import { - Client, - Message, - MessageCreateOptions, - MessageEditOptions, - MessageReaction, - PartialMessageReaction, - PartialUser, - TextBasedChannel, - User, -} from "discord.js"; +import { Client, Message, MessageReaction, PartialMessageReaction, PartialUser, User } from "discord.js"; +import { ContextResponseOptions, fetchContextChannel, GenericCommandSource } from "../pluginUtils.js"; import { MINUTES, noop } from "../utils.js"; import { Awaitable } from "./typeUtils.js"; import Timeout = NodeJS.Timeout; -export type LoadPageFn = (page: number) => Awaitable; +export type LoadPageFn = (page: number) => Awaitable; export interface PaginateMessageOpts { timeout: number; @@ -27,12 +18,17 @@ const defaultOpts: PaginateMessageOpts = { export async function createPaginatedMessage( client: Client, - channel: TextBasedChannel | User, + context: GenericCommandSource, totalPages: number, loadPageFn: LoadPageFn, opts: Partial = {}, ): Promise { const fullOpts = { ...defaultOpts, ...opts } as PaginateMessageOpts; + const channel = await fetchContextChannel(context); + if (!channel.isSendable()) { + throw new Error("Context channel is not sendable"); + } + const firstPageContent = await loadPageFn(1); const message = await channel.send(firstPageContent); diff --git a/backend/src/utils/formatZodIssue.ts b/backend/src/utils/formatZodIssue.ts index 93932f66..a27a863f 100644 --- a/backend/src/utils/formatZodIssue.ts +++ b/backend/src/utils/formatZodIssue.ts @@ -1,4 +1,4 @@ -import { ZodIssue } from "zod"; +import { ZodIssue } from "zod/v4"; export function formatZodIssue(issue: ZodIssue): string { const path = issue.path.join("/"); diff --git a/backend/src/utils/loadYamlSafely.ts b/backend/src/utils/loadYamlSafely.ts index 9cfb68ae..07fdb66e 100644 --- a/backend/src/utils/loadYamlSafely.ts +++ b/backend/src/utils/loadYamlSafely.ts @@ -5,7 +5,7 @@ import { validateNoObjectAliases } from "./validateNoObjectAliases.js"; * Loads a YAML file safely while removing object anchors/aliases (including arrays) */ export function loadYamlSafely(yamlStr: string): any { - let loaded = yaml.safeLoad(yamlStr); + let loaded = yaml.load(yamlStr); if (loaded == null || typeof loaded !== "object") { loaded = {}; } diff --git a/backend/src/utils/multipleSlashOptions.ts b/backend/src/utils/multipleSlashOptions.ts new file mode 100644 index 00000000..9cdd2eca --- /dev/null +++ b/backend/src/utils/multipleSlashOptions.ts @@ -0,0 +1,20 @@ +import { AttachmentSlashCommandOption, slashOptions } from "knub"; + +type AttachmentSlashOptions = Omit; + +export function generateAttachmentSlashOptions(amount: number, options: AttachmentSlashOptions) { + return new Array(amount).fill(0).map((_, i) => { + return slashOptions.attachment({ + name: amount > 1 ? `${options.name}${i + 1}` : options.name, + description: options.description, + required: options.required ?? false, + }); + }); +} + +export function retrieveMultipleOptions(amount: number, options: any, name: string) { + return new Array(amount) + .fill(0) + .map((_, i) => options[amount > 1 ? `${name}${i + 1}` : name]) + .filter((a) => a); +} diff --git a/backend/src/utils/permissionNames.ts b/backend/src/utils/permissionNames.ts index e888ab3c..801f512f 100644 --- a/backend/src/utils/permissionNames.ts +++ b/backend/src/utils/permissionNames.ts @@ -48,4 +48,8 @@ export const PERMISSION_NAMES = { UseExternalSounds: "Use External Sounds", UseSoundboard: "Use Soundboard", ViewCreatorMonetizationAnalytics: "View Creator Monetization Analytics", + CreateGuildExpressions: "Create Guild Expressions", + CreateEvents: "Create Events", + SendPolls: "Send Polls", + UseExternalApps: "Use External Apps", } as const satisfies Record; diff --git a/backend/src/utils/templateSafeObjects.ts b/backend/src/utils/templateSafeObjects.ts index 3a65aa8d..0c96b0b1 100644 --- a/backend/src/utils/templateSafeObjects.ts +++ b/backend/src/utils/templateSafeObjects.ts @@ -5,6 +5,7 @@ import { GuildMember, Message, PartialGuildMember, + PartialUser, Role, Snowflake, StageInstance, @@ -242,29 +243,29 @@ export function guildToTemplateSafeGuild(guild: Guild): TemplateSafeGuild { }); } -export function userToTemplateSafeUser(user: User | UnknownUser): TemplateSafeUser { - if (user instanceof UnknownUser) { +export function userToTemplateSafeUser(user: User | UnknownUser | PartialUser): TemplateSafeUser { + if (user instanceof User) { return new TemplateSafeUser({ id: user.id, - username: "Unknown", - discriminator: "0000", + username: user.username, + discriminator: user.discriminator, + globalName: user.globalName, mention: `<@${user.id}>`, - tag: "Unknown#0000", + tag: user.tag, + avatarURL: user.displayAvatarURL?.() || "", + bot: user.bot, + createdAt: user.createdTimestamp, renderedUsername: renderUsername(user), }); } return new TemplateSafeUser({ id: user.id, - username: user.username, - discriminator: user.discriminator, - globalName: user.globalName, + username: "Unknown", + discriminator: "0000", mention: `<@${user.id}>`, - tag: user.tag, - avatarURL: user.displayAvatarURL(), - bot: user.bot, - createdAt: user.createdTimestamp, - renderedUsername: renderUsername(user), + tag: "Unknown#0000", + renderedUsername: "Unknown", }); } diff --git a/backend/src/utils/waitForInteraction.ts b/backend/src/utils/waitForInteraction.ts index ec44fb7c..d0eec4d2 100644 --- a/backend/src/utils/waitForInteraction.ts +++ b/backend/src/utils/waitForInteraction.ts @@ -2,22 +2,23 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, - GuildTextBasedChannel, MessageActionRowComponentBuilder, MessageComponentInteraction, MessageCreateOptions, } from "discord.js"; import moment from "moment"; import { v4 as uuidv4 } from "uuid"; +import { GenericCommandSource, isContextInteraction, sendContextResponse } from "../pluginUtils.js"; import { noop } from "../utils.js"; export async function waitForButtonConfirm( - channel: GuildTextBasedChannel, - toPost: MessageCreateOptions, + context: GenericCommandSource, + toPost: Omit, options?: WaitForOptions, ): Promise { return new Promise(async (resolve) => { - const idMod = `${channel.guild.id}-${moment.utc().valueOf()}`; + const contextIsInteraction = isContextInteraction(context); + const idMod = `${context.id}-${moment.utc().valueOf()}`; const row = new ActionRowBuilder().addComponents([ new ButtonBuilder() .setStyle(ButtonStyle.Success) @@ -29,7 +30,7 @@ export async function waitForButtonConfirm( .setLabel(options?.cancelText || "Cancel") .setCustomId(`cancelButton:${idMod}:${uuidv4()}`), ]); - const message = await channel.send({ ...toPost, components: [row] }); + const message = await sendContextResponse(context, { ...toPost, components: [row] }, true); const collector = message.createMessageComponentCollector({ time: 10000 }); @@ -41,16 +42,16 @@ export async function waitForButtonConfirm( .catch((err) => console.trace(err.message)); } else { if (interaction.customId.startsWith(`confirmButton:${idMod}:`)) { - message.delete(); + if (!contextIsInteraction) message.delete(); resolve(true); } else if (interaction.customId.startsWith(`cancelButton:${idMod}:`)) { - message.delete(); + if (!contextIsInteraction) message.delete(); resolve(false); } } }); collector.on("end", () => { - if (message.deletable) message.delete().catch(noop); + if (!contextIsInteraction && message.deletable) message.delete().catch(noop); resolve(false); }); }); diff --git a/backend/src/utils/zColor.ts b/backend/src/utils/zColor.ts index 4961b67d..ab256313 100644 --- a/backend/src/utils/zColor.ts +++ b/backend/src/utils/zColor.ts @@ -1,4 +1,4 @@ -import z from "zod"; +import z from "zod/v4"; import { parseColor } from "./parseColor.js"; import { rgbToInt } from "./rgbToInt.js"; diff --git a/backend/src/utils/zValidTimezone.ts b/backend/src/utils/zValidTimezone.ts index 7757de05..b0b06556 100644 --- a/backend/src/utils/zValidTimezone.ts +++ b/backend/src/utils/zValidTimezone.ts @@ -1,4 +1,4 @@ -import { ZodString } from "zod"; +import { ZodString } from "zod/v4"; import { isValidTimezone } from "./isValidTimezone.js"; export function zValidTimezone(z: Z) { diff --git a/backend/src/utils/zodDeepPartial.ts b/backend/src/utils/zodDeepPartial.ts new file mode 100644 index 00000000..c054be94 --- /dev/null +++ b/backend/src/utils/zodDeepPartial.ts @@ -0,0 +1,165 @@ +/* + Modified version of https://gist.github.com/jaens/7e15ae1984bb338c86eb5e452dee3010 + Original version's license: + + Copyright 2024, Jaen - https://github.com/jaens + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { z } from "zod/v4"; +import { $ZodRecordKey, $ZodType } from "zod/v4/core"; + +const RESOLVING = Symbol("mapOnSchema/resolving"); + +export function mapOnSchema( + schema: T, + fn: (schema: $ZodType) => TResult, +): TResult; + +/** + * Applies {@link fn} to each element of the schema recursively, replacing every schema with its return value. + * The rewriting is applied bottom-up (ie. {@link fn} will get called on "children" first). + */ +export function mapOnSchema(schema: $ZodType, fn: (schema: $ZodType) => $ZodType): $ZodType { + // Cache results to support recursive schemas + const results = new Map<$ZodType, $ZodType | typeof RESOLVING>(); + + function mapElement(s: $ZodType) { + const value = results.get(s); + if (value === RESOLVING) { + throw new Error("Recursive schema access detected"); + } else if (value !== undefined) { + return value; + } + + results.set(s, RESOLVING); + const result = mapOnSchema(s, fn); + results.set(s, result); + return result; + } + + function mapInner() { + if (schema instanceof z.ZodObject) { + const newShape: Record = {}; + for (const [key, value] of Object.entries(schema.shape)) { + newShape[key] = mapElement(value); + } + + return new z.ZodObject({ + ...schema.def, + shape: newShape, + }); + } else if (schema instanceof z.ZodArray) { + return new z.ZodArray({ + ...schema.def, + type: "array", + element: mapElement(schema.def.element), + }); + } else if (schema instanceof z.ZodMap) { + return new z.ZodMap({ + ...schema.def, + keyType: mapElement(schema.def.keyType), + valueType: mapElement(schema.def.valueType), + }); + } else if (schema instanceof z.ZodSet) { + return new z.ZodSet({ + ...schema.def, + valueType: mapElement(schema.def.valueType), + }); + } else if (schema instanceof z.ZodOptional) { + return new z.ZodOptional({ + ...schema.def, + innerType: mapElement(schema.def.innerType), + }); + } else if (schema instanceof z.ZodNullable) { + return new z.ZodNullable({ + ...schema.def, + innerType: mapElement(schema.def.innerType), + }); + } else if (schema instanceof z.ZodDefault) { + return new z.ZodDefault({ + ...schema.def, + innerType: mapElement(schema.def.innerType), + }); + } else if (schema instanceof z.ZodReadonly) { + return new z.ZodReadonly({ + ...schema.def, + innerType: mapElement(schema.def.innerType), + }); + } else if (schema instanceof z.ZodLazy) { + return new z.ZodLazy({ + ...schema.def, + // NB: This leaks `fn` into the schema, but there is no other way to support recursive schemas + getter: () => mapElement(schema._def.getter()), + }); + } else if (schema instanceof z.ZodPromise) { + return new z.ZodPromise({ + ...schema.def, + innerType: mapElement(schema.def.innerType), + }); + } else if (schema instanceof z.ZodCatch) { + return new z.ZodCatch({ + ...schema.def, + innerType: mapElement(schema._def.innerType), + }); + } else if (schema instanceof z.ZodTuple) { + return new z.ZodTuple({ + ...schema.def, + items: schema.def.items.map((item: $ZodType) => mapElement(item)), + rest: schema.def.rest && mapElement(schema.def.rest), + }); + } else if (schema instanceof z.ZodDiscriminatedUnion) { + return new z.ZodDiscriminatedUnion({ + ...schema.def, + options: schema.options.map((option) => mapOnSchema(option, fn)), + }); + } else if (schema instanceof z.ZodUnion) { + return new z.ZodUnion({ + ...schema.def, + options: schema.options.map((option) => mapOnSchema(option, fn)), + }); + } else if (schema instanceof z.ZodIntersection) { + return new z.ZodIntersection({ + ...schema.def, + right: mapElement(schema.def.right), + left: mapElement(schema.def.left), + }); + } else if (schema instanceof z.ZodRecord) { + return new z.ZodRecord({ + ...schema.def, + keyType: mapElement(schema.def.keyType) as $ZodRecordKey, + valueType: mapElement(schema.def.valueType), + }); + } else { + return schema; + } + } + + return fn(mapInner()); +} + +export function deepPartial(schema: T): T { + return mapOnSchema(schema, (s) => (s instanceof z.ZodObject ? s.partial() : s)) as T; +} + +/** Make all object schemas "strict" (ie. fail on unknown keys), except if they are marked as `.passthrough()` */ +export function deepStrict(schema: T): T { + return mapOnSchema(schema, (s) => + s instanceof z.ZodObject /* && s.def.unknownKeys !== "passthrough" */ ? s.strict() : s, + ) as T; +} + +export function deepStrictAll(schema: T): T { + return mapOnSchema(schema, (s) => (s instanceof z.ZodObject ? s.strict() : s)) as T; +} diff --git a/backend/src/validateActiveConfigs.ts b/backend/src/validateActiveConfigs.ts index 327f97db..aff64fd4 100644 --- a/backend/src/validateActiveConfigs.ts +++ b/backend/src/validateActiveConfigs.ts @@ -1,12 +1,10 @@ -import jsYaml from "js-yaml"; +import { YAMLException } from "js-yaml"; import { validateGuildConfig } from "./configValidator.js"; import { Configs } from "./data/Configs.js"; import { connect, disconnect } from "./data/db.js"; import { loadYamlSafely } from "./utils/loadYamlSafely.js"; import { ObjectAliasError } from "./utils/validateNoObjectAliases.js"; -const YAMLException = jsYaml.YAMLException; - function writeError(key: string, error: string) { const indented = error .split("\n") diff --git a/backend/src/yamlParseTest.ts b/backend/src/yamlParseTest.ts deleted file mode 100644 index 5db961ff..00000000 --- a/backend/src/yamlParseTest.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { load } from "js-yaml"; -import YAML from "yawn-yaml/cjs"; - -const src = ` -prefix: '!' - -plugins: - myplugin: - config: - - can_do_thing: true - - # Lol - can_do_other_thing: false -`; - -const json = load(src); -const yaml = new YAML(src); -json.plugins.myplugin.config.can_do_thing = false; -yaml.json = json; - -// tslint:disable-next-line:no-console -console.log(yaml.yaml); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index b012223a..17e10137 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -5,7 +5,8 @@ "module": "NodeNext", "baseUrl": "./src", "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "composite": true }, "include": ["src/**/*.ts", "src/**/*.json"], "references": [ diff --git a/build-image.sh b/build-image.sh new file mode 100755 index 00000000..5b47f6d3 --- /dev/null +++ b/build-image.sh @@ -0,0 +1 @@ +docker build -t dragory/zeppelin --build-arg API_URL=https://zeppelin.gg/api . diff --git a/config-checker/.gitignore b/config-checker/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/config-checker/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/config-checker/index.html b/config-checker/index.html new file mode 100644 index 00000000..74c8d52f --- /dev/null +++ b/config-checker/index.html @@ -0,0 +1,37 @@ + + + + + + + + Zeppelin config checker + + +
+
+
+

Config

+
+
+
+
+
+
+
+ +
+
+

Errors

+
+
+
+
+
+
+
+
+ + + + diff --git a/config-checker/package-lock.json b/config-checker/package-lock.json new file mode 100644 index 00000000..75e1b81d --- /dev/null +++ b/config-checker/package-lock.json @@ -0,0 +1,1179 @@ +{ + "name": "config-checker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "config-checker", + "dependencies": { + "monaco-editor": "^0.52.2", + "monaco-yaml": "^5.4.0" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^6.3.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz", + "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz", + "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz", + "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz", + "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz", + "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz", + "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz", + "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz", + "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz", + "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz", + "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz", + "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz", + "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz", + "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz", + "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz", + "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz", + "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz", + "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz", + "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz", + "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz", + "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, + "node_modules/monaco-languageserver-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz", + "integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==", + "license": "MIT", + "dependencies": { + "monaco-types": "^0.1.0", + "vscode-languageserver-protocol": "^3.0.0", + "vscode-uri": "^3.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-marker-data-provider": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.4.tgz", + "integrity": "sha512-4DsPgsAqpTyUDs3humXRBPUJoihTv+L6v9aupQWD80X2YXaCXUd11mWYeSCYHuPgdUmjFaNWCEOjQ6ewf/QA1Q==", + "license": "MIT", + "dependencies": { + "monaco-types": "^0.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-types": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz", + "integrity": "sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-worker-manager": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz", + "integrity": "sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==", + "license": "MIT", + "peerDependencies": { + "monaco-editor": ">=0.30.0" + } + }, + "node_modules/monaco-yaml": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.4.0.tgz", + "integrity": "sha512-tuBVDy1KAPrgO905GHTItu8AaA5bIzF5S4X0JVRAE/D66FpRhkDUk7tKi5bwKMVTTugtpMLsXN4ewh4CgE/FtQ==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "jsonc-parser": "^3.0.0", + "monaco-languageserver-types": "^0.4.0", + "monaco-marker-data-provider": "^1.0.0", + "monaco-types": "^0.1.0", + "monaco-worker-manager": "^2.0.0", + "path-browserify": "^1.0.0", + "prettier": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.0", + "vscode-languageserver-types": "^3.0.0", + "vscode-uri": "^3.0.0", + "yaml": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "monaco-editor": ">=0.36" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz", + "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.41.0", + "@rollup/rollup-android-arm64": "4.41.0", + "@rollup/rollup-darwin-arm64": "4.41.0", + "@rollup/rollup-darwin-x64": "4.41.0", + "@rollup/rollup-freebsd-arm64": "4.41.0", + "@rollup/rollup-freebsd-x64": "4.41.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.41.0", + "@rollup/rollup-linux-arm-musleabihf": "4.41.0", + "@rollup/rollup-linux-arm64-gnu": "4.41.0", + "@rollup/rollup-linux-arm64-musl": "4.41.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.41.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0", + "@rollup/rollup-linux-riscv64-gnu": "4.41.0", + "@rollup/rollup-linux-riscv64-musl": "4.41.0", + "@rollup/rollup-linux-s390x-gnu": "4.41.0", + "@rollup/rollup-linux-x64-gnu": "4.41.0", + "@rollup/rollup-linux-x64-musl": "4.41.0", + "@rollup/rollup-win32-arm64-msvc": "4.41.0", + "@rollup/rollup-win32-ia32-msvc": "4.41.0", + "@rollup/rollup-win32-x64-msvc": "4.41.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + } + } +} diff --git a/config-checker/package.json b/config-checker/package.json new file mode 100644 index 00000000..076811ae --- /dev/null +++ b/config-checker/package.json @@ -0,0 +1,18 @@ +{ + "name": "config-checker", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^6.3.5" + }, + "dependencies": { + "monaco-editor": "^0.52.2", + "monaco-yaml": "^5.4.0" + } +} diff --git a/config-checker/public/config-schema.json b/config-checker/public/config-schema.json new file mode 100644 index 00000000..4214b9f3 --- /dev/null +++ b/config-checker/public/config-schema.json @@ -0,0 +1,20853 @@ +{ + "type": "object", + "properties": { + "prefix": { + "type": "string" + }, + "levels": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number" + } + }, + "plugins": { + "type": "object", + "properties": { + "auto_delete": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "delay": { + "default": "5s", + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "delay": { + "default": "5s", + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "automod": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "rules": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 100 + }, + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "pretty_name": { + "type": "string" + }, + "presets": { + "default": [], + "maxItems": 25, + "type": "array", + "items": { + "type": "string", + "maxLength": 100 + } + }, + "affects_bots": { + "default": false, + "type": "boolean" + }, + "affects_self": { + "default": false, + "type": "boolean" + }, + "cooldown": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "allow_further_rules": { + "default": false, + "type": "boolean" + }, + "triggers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "any_message": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "match_words": { + "type": "object", + "properties": { + "words": { + "maxItems": 1024, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "case_sensitive": { + "default": false, + "type": "boolean" + }, + "only_full_words": { + "default": true, + "type": "boolean" + }, + "normalize": { + "default": false, + "type": "boolean" + }, + "loose_matching": { + "default": false, + "type": "boolean" + }, + "loose_matching_threshold": { + "default": 4, + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "strip_markdown": { + "default": false, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": false, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "words" + ], + "additionalProperties": false + }, + "match_regex": { + "type": "object", + "properties": { + "patterns": { + "maxItems": 512, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "case_sensitive": { + "default": false, + "type": "boolean" + }, + "normalize": { + "default": false, + "type": "boolean" + }, + "strip_markdown": { + "default": false, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": false, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "patterns" + ], + "additionalProperties": false + }, + "match_invites": { + "type": "object", + "properties": { + "include_guilds": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + }, + "exclude_guilds": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + }, + "include_invite_codes": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "exclude_invite_codes": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "allow_group_dm_invites": { + "default": false, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": false, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "match_links": { + "type": "object", + "properties": { + "include_domains": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 255 + } + }, + "exclude_domains": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 255 + } + }, + "include_subdomains": { + "default": true, + "type": "boolean" + }, + "include_words": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "exclude_words": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "include_regex": { + "maxItems": 512, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "exclude_regex": { + "maxItems": 512, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "phisherman": { + "type": "object", + "properties": { + "include_suspected": { + "type": "boolean" + }, + "include_verified": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "include_malicious": { + "default": false, + "type": "boolean" + }, + "only_real_links": { + "default": true, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": true, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "match_attachment_type": { + "type": "object", + "properties": { + "whitelist_enabled": { + "default": false, + "type": "boolean" + }, + "filetype_whitelist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "blacklist_enabled": { + "default": false, + "type": "boolean" + }, + "filetype_blacklist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + } + }, + "required": [], + "additionalProperties": false + }, + "match_mime_type": { + "type": "object", + "properties": { + "whitelist_enabled": { + "default": false, + "type": "boolean" + }, + "mime_type_whitelist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "blacklist_enabled": { + "default": false, + "type": "boolean" + }, + "mime_type_blacklist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + } + }, + "required": [], + "additionalProperties": false + }, + "member_join": { + "type": "object", + "properties": { + "only_new": { + "default": false, + "type": "boolean" + }, + "new_threshold": { + "default": "1h", + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + }, + "member_leave": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "role_added": { + "default": [], + "anyOf": [ + { + "type": "string" + }, + { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "role_removed": { + "default": [], + "anyOf": [ + { + "type": "string" + }, + { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "message_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "mention_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "link_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "attachment_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "emoji_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "line_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "character_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "member_join_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "sticker_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "thread_create_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + } + }, + "required": [ + "amount", + "within" + ], + "additionalProperties": false + }, + "counter_trigger": { + "type": "object", + "properties": { + "counter": { + "type": "string", + "maxLength": 100 + }, + "trigger": { + "type": "string", + "maxLength": 100 + }, + "reverse": { + "type": "boolean" + } + }, + "required": [ + "counter", + "trigger" + ], + "additionalProperties": false + }, + "note": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "warn": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "mute": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "unmute": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "kick": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "ban": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "unban": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "antiraid_level": { + "type": "object", + "properties": { + "level": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ] + }, + "only_on_change": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "level", + "only_on_change" + ], + "additionalProperties": false + }, + "thread_create": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "thread_delete": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "thread_archive": { + "type": "object", + "properties": { + "locked": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "thread_unarchive": { + "type": "object", + "properties": { + "locked": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "actions": { + "type": "object", + "properties": { + "clean": { + "default": false, + "type": "boolean" + }, + "warn": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "mute": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "duration": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "restore_roles_on_mute": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "kick": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "ban": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "duration": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deleteMessageDays": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "alert": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "text": { + "type": "string" + }, + "allowed_mentions": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "everyone": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "users": { + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "roles": { + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "replied_user": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "channel", + "text" + ] + }, + "change_nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + ] + }, + "log": { + "default": true, + "type": "boolean" + }, + "add_roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "remove_roles": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "set_antiraid_level": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reply": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "auto_delete": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "inline": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + ] + }, + "add_to_counter": { + "type": "object", + "properties": { + "counter": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "counter", + "amount" + ] + }, + "set_counter": { + "type": "object", + "properties": { + "counter": { + "type": "string" + }, + "value": { + "type": "number", + "minimum": 0, + "maximum": 2147483647 + } + }, + "required": [ + "counter", + "value" + ], + "additionalProperties": false + }, + "set_slowmode": { + "type": "object", + "properties": { + "channels": { + "default": [], + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "duration": { + "default": "10s", + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "start_thread": { + "type": "object", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "auto_archive": { + "type": "string", + "maxLength": 32 + }, + "private": { + "default": false, + "type": "boolean" + }, + "slowmode": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "limit_per_channel": { + "default": 5, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "auto_archive" + ], + "additionalProperties": false + }, + "archive_thread": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "change_perms": { + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "perms": { + "type": "object", + "properties": { + "CreateInstantInvite": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "KickMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "BanMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Administrator": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageChannels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageGuild": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "AddReactions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewAuditLog": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "PrioritySpeaker": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Stream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewChannel": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendTTSMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "EmbedLinks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "AttachFiles": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ReadMessageHistory": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MentionEveryone": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalEmojis": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewGuildInsights": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Connect": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Speak": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MuteMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "DeafenMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MoveMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseVAD": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ChangeNickname": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageNicknames": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageRoles": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageWebhooks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageEmojisAndStickers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageGuildExpressions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseApplicationCommands": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "RequestToSpeak": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageEvents": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreatePublicThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreatePrivateThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalStickers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendMessagesInThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseEmbeddedActivities": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ModerateMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewCreatorMonetizationAnalytics": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseSoundboard": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreateGuildExpressions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreateEvents": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalSounds": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendVoiceMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendPolls": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalApps": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CREATE_INSTANT_INVITE": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "KICK_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "BAN_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ADMINISTRATOR": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_CHANNELS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_GUILD": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ADD_REACTIONS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "VIEW_AUDIT_LOG": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "PRIORITY_SPEAKER": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "STREAM": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "VIEW_CHANNEL": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SEND_MESSAGES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SEND_TTSMESSAGES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_MESSAGES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "EMBED_LINKS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ATTACH_FILES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "READ_MESSAGE_HISTORY": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MENTION_EVERYONE": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_EXTERNAL_EMOJIS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "VIEW_GUILD_INSIGHTS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CONNECT": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SPEAK": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MUTE_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "DEAFEN_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MOVE_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_VAD": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CHANGE_NICKNAME": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_NICKNAMES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_ROLES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_WEBHOOKS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_EMOJIS_AND_STICKERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_APPLICATION_COMMANDS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "REQUEST_TO_SPEAK": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_EVENTS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CREATE_PUBLIC_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CREATE_PRIVATE_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_EXTERNAL_STICKERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SEND_MESSAGES_IN_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_EMBEDDED_ACTIVITIES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MODERATE_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "target", + "perms" + ], + "additionalProperties": false + }, + "pause_invites": { + "type": "object", + "properties": { + "paused": { + "type": "boolean" + } + }, + "required": [ + "paused" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "triggers", + "actions" + ], + "additionalProperties": false + } + }, + "antiraid_levels": { + "default": [ + "low", + "medium", + "high" + ], + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "maxLength": 100 + } + }, + "can_set_antiraid": { + "default": false, + "type": "boolean" + }, + "can_view_antiraid": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "rules": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 100 + }, + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "pretty_name": { + "type": "string" + }, + "presets": { + "default": [], + "maxItems": 25, + "type": "array", + "items": { + "type": "string", + "maxLength": 100 + } + }, + "affects_bots": { + "default": false, + "type": "boolean" + }, + "affects_self": { + "default": false, + "type": "boolean" + }, + "cooldown": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "allow_further_rules": { + "default": false, + "type": "boolean" + }, + "triggers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "any_message": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "match_words": { + "type": "object", + "properties": { + "words": { + "maxItems": 1024, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "case_sensitive": { + "default": false, + "type": "boolean" + }, + "only_full_words": { + "default": true, + "type": "boolean" + }, + "normalize": { + "default": false, + "type": "boolean" + }, + "loose_matching": { + "default": false, + "type": "boolean" + }, + "loose_matching_threshold": { + "default": 4, + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "strip_markdown": { + "default": false, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": false, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "match_regex": { + "type": "object", + "properties": { + "patterns": { + "maxItems": 512, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "case_sensitive": { + "default": false, + "type": "boolean" + }, + "normalize": { + "default": false, + "type": "boolean" + }, + "strip_markdown": { + "default": false, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": false, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "match_invites": { + "type": "object", + "properties": { + "include_guilds": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + }, + "exclude_guilds": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + }, + "include_invite_codes": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "exclude_invite_codes": { + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "allow_group_dm_invites": { + "default": false, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": false, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "match_links": { + "type": "object", + "properties": { + "include_domains": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 255 + } + }, + "exclude_domains": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 255 + } + }, + "include_subdomains": { + "default": true, + "type": "boolean" + }, + "include_words": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "exclude_words": { + "maxItems": 700, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "include_regex": { + "maxItems": 512, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "exclude_regex": { + "maxItems": 512, + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "phisherman": { + "type": "object", + "properties": { + "include_suspected": { + "type": "boolean" + }, + "include_verified": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "include_malicious": { + "default": false, + "type": "boolean" + }, + "only_real_links": { + "default": true, + "type": "boolean" + }, + "match_messages": { + "default": true, + "type": "boolean" + }, + "match_embeds": { + "default": true, + "type": "boolean" + }, + "match_visible_names": { + "default": false, + "type": "boolean" + }, + "match_usernames": { + "default": false, + "type": "boolean" + }, + "match_nicknames": { + "default": false, + "type": "boolean" + }, + "match_custom_status": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "match_attachment_type": { + "type": "object", + "properties": { + "whitelist_enabled": { + "default": false, + "type": "boolean" + }, + "filetype_whitelist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "blacklist_enabled": { + "default": false, + "type": "boolean" + }, + "filetype_blacklist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + } + }, + "required": [], + "additionalProperties": false + }, + "match_mime_type": { + "type": "object", + "properties": { + "whitelist_enabled": { + "default": false, + "type": "boolean" + }, + "mime_type_whitelist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + }, + "blacklist_enabled": { + "default": false, + "type": "boolean" + }, + "mime_type_blacklist": { + "default": [], + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "maxLength": 32 + } + } + }, + "required": [], + "additionalProperties": false + }, + "member_join": { + "type": "object", + "properties": { + "only_new": { + "default": false, + "type": "boolean" + }, + "new_threshold": { + "default": "1h", + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + }, + "member_leave": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "role_added": { + "default": [], + "anyOf": [ + { + "type": "string" + }, + { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "role_removed": { + "default": [], + "anyOf": [ + { + "type": "string" + }, + { + "maxItems": 255, + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "message_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "mention_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "link_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "attachment_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "emoji_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "line_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "character_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "member_join_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + }, + "sticker_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + }, + "per_channel": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "thread_create_spam": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "within": { + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + }, + "counter_trigger": { + "type": "object", + "properties": { + "counter": { + "type": "string", + "maxLength": 100 + }, + "trigger": { + "type": "string", + "maxLength": 100 + }, + "reverse": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "note": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "warn": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "mute": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "unmute": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "kick": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "ban": { + "type": "object", + "properties": { + "manual": { + "default": true, + "type": "boolean" + }, + "automatic": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "unban": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "antiraid_level": { + "type": "object", + "properties": { + "level": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ] + }, + "only_on_change": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "thread_create": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "thread_delete": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "thread_archive": { + "type": "object", + "properties": { + "locked": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "thread_unarchive": { + "type": "object", + "properties": { + "locked": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "actions": { + "type": "object", + "properties": { + "clean": { + "default": false, + "type": "boolean" + }, + "warn": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "mute": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "duration": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "restore_roles_on_mute": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "kick": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "ban": { + "type": "object", + "properties": { + "reason": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "duration": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "notify": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": "dm" + }, + { + "const": "channel" + } + ] + }, + { + "type": "null" + } + ] + }, + "notifyChannel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deleteMessageDays": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "postInCaseLog": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hide_case": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "alert": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "text": { + "type": "string" + }, + "allowed_mentions": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "everyone": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "users": { + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "roles": { + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "replied_user": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "change_nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + } + ] + }, + "log": { + "default": true, + "type": "boolean" + }, + "add_roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "remove_roles": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "set_antiraid_level": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reply": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "auto_delete": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "inline": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + ] + }, + "add_to_counter": { + "type": "object", + "properties": { + "counter": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [] + }, + "set_counter": { + "type": "object", + "properties": { + "counter": { + "type": "string" + }, + "value": { + "type": "number", + "minimum": 0, + "maximum": 2147483647 + } + }, + "required": [], + "additionalProperties": false + }, + "set_slowmode": { + "type": "object", + "properties": { + "channels": { + "default": [], + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "duration": { + "default": "10s", + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "start_thread": { + "type": "object", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "auto_archive": { + "type": "string", + "maxLength": 32 + }, + "private": { + "default": false, + "type": "boolean" + }, + "slowmode": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 32 + }, + { + "type": "null" + } + ] + }, + "limit_per_channel": { + "default": 5, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "archive_thread": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "change_perms": { + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "perms": { + "type": "object", + "properties": { + "CreateInstantInvite": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "KickMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "BanMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Administrator": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageChannels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageGuild": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "AddReactions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewAuditLog": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "PrioritySpeaker": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Stream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewChannel": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendTTSMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "EmbedLinks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "AttachFiles": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ReadMessageHistory": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MentionEveryone": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalEmojis": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewGuildInsights": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Connect": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "Speak": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MuteMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "DeafenMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MoveMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseVAD": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ChangeNickname": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageNicknames": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageRoles": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageWebhooks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageEmojisAndStickers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageGuildExpressions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseApplicationCommands": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "RequestToSpeak": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageEvents": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ManageThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreatePublicThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreatePrivateThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalStickers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendMessagesInThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseEmbeddedActivities": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ModerateMembers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ViewCreatorMonetizationAnalytics": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseSoundboard": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreateGuildExpressions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CreateEvents": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalSounds": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendVoiceMessages": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SendPolls": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "UseExternalApps": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CREATE_INSTANT_INVITE": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "KICK_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "BAN_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ADMINISTRATOR": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_CHANNELS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_GUILD": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ADD_REACTIONS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "VIEW_AUDIT_LOG": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "PRIORITY_SPEAKER": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "STREAM": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "VIEW_CHANNEL": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SEND_MESSAGES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SEND_TTSMESSAGES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_MESSAGES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "EMBED_LINKS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ATTACH_FILES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "READ_MESSAGE_HISTORY": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MENTION_EVERYONE": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_EXTERNAL_EMOJIS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "VIEW_GUILD_INSIGHTS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CONNECT": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SPEAK": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MUTE_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "DEAFEN_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MOVE_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_VAD": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CHANGE_NICKNAME": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_NICKNAMES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_ROLES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_WEBHOOKS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_EMOJIS_AND_STICKERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_APPLICATION_COMMANDS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "REQUEST_TO_SPEAK": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_EVENTS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MANAGE_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CREATE_PUBLIC_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "CREATE_PRIVATE_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_EXTERNAL_STICKERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "SEND_MESSAGES_IN_THREADS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "USE_EMBEDDED_ACTIVITIES": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "MODERATE_MEMBERS": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "pause_invites": { + "type": "object", + "properties": { + "paused": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "antiraid_levels": { + "default": [ + "low", + "medium", + "high" + ], + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "maxLength": 100 + } + }, + "can_set_antiraid": { + "default": false, + "type": "boolean" + }, + "can_view_antiraid": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "auto_reactions": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_manage": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_manage": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "cases": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "log_automatic_actions": { + "default": true, + "type": "boolean" + }, + "case_log_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "show_relative_times": { + "default": true, + "type": "boolean" + }, + "relative_time_cutoff": { + "default": "1w", + "type": "string", + "maxLength": 32 + }, + "case_colors": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "ban": { + "type": "string" + }, + "unban": { + "type": "string" + }, + "note": { + "type": "string" + }, + "warn": { + "type": "string" + }, + "kick": { + "type": "string" + }, + "mute": { + "type": "string" + }, + "unmute": { + "type": "string" + }, + "deleted": { + "type": "string" + }, + "softban": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "case_icons": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "ban": { + "type": "string" + }, + "unban": { + "type": "string" + }, + "note": { + "type": "string" + }, + "warn": { + "type": "string" + }, + "kick": { + "type": "string" + }, + "mute": { + "type": "string" + }, + "unmute": { + "type": "string" + }, + "deleted": { + "type": "string" + }, + "softban": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "log_automatic_actions": { + "default": true, + "type": "boolean" + }, + "case_log_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "show_relative_times": { + "default": true, + "type": "boolean" + }, + "relative_time_cutoff": { + "default": "1w", + "type": "string", + "maxLength": 32 + }, + "case_colors": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "ban": { + "type": "string" + }, + "unban": { + "type": "string" + }, + "note": { + "type": "string" + }, + "warn": { + "type": "string" + }, + "kick": { + "type": "string" + }, + "mute": { + "type": "string" + }, + "unmute": { + "type": "string" + }, + "deleted": { + "type": "string" + }, + "softban": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "case_icons": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "ban": { + "type": "string" + }, + "unban": { + "type": "string" + }, + "note": { + "type": "string" + }, + "warn": { + "type": "string" + }, + "kick": { + "type": "string" + }, + "mute": { + "type": "string" + }, + "unmute": { + "type": "string" + }, + "deleted": { + "type": "string" + }, + "softban": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "censor": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "filter_zalgo": { + "default": false, + "type": "boolean" + }, + "filter_invites": { + "default": false, + "type": "boolean" + }, + "invite_guild_whitelist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "invite_guild_blacklist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "invite_code_whitelist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "invite_code_blacklist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "allow_group_dm_invites": { + "default": false, + "type": "boolean" + }, + "filter_domains": { + "default": false, + "type": "boolean" + }, + "domain_whitelist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "domain_blacklist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "blocked_tokens": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "blocked_words": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "blocked_regex": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "maxLength": 1000 + } + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "filter_zalgo": { + "default": false, + "type": "boolean" + }, + "filter_invites": { + "default": false, + "type": "boolean" + }, + "invite_guild_whitelist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "invite_guild_blacklist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "invite_code_whitelist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "invite_code_blacklist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "allow_group_dm_invites": { + "default": false, + "type": "boolean" + }, + "filter_domains": { + "default": false, + "type": "boolean" + }, + "domain_whitelist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "domain_blacklist": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "blocked_tokens": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "blocked_words": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "blocked_regex": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "maxLength": 1000 + } + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "companion_channels": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "entries": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "voice_channel_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "text_channel_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "type": "number" + }, + "enabled": { + "default": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "voice_channel_ids", + "text_channel_ids", + "permissions" + ], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "entries": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "voice_channel_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "text_channel_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "type": "number" + }, + "enabled": { + "default": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "context_menu": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_use": { + "default": false, + "type": "boolean" + }, + "can_open_mod_menu": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_use": { + "default": false, + "type": "boolean" + }, + "can_open_mod_menu": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "counters": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "counters": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "pretty_name": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "per_channel": { + "default": false, + "type": "boolean" + }, + "per_user": { + "default": false, + "type": "boolean" + }, + "initial_value": { + "default": 0, + "type": "number", + "minimum": 0, + "maximum": 2147483647 + }, + "triggers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "pretty_name": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "condition": { + "type": "string" + }, + "reverse_condition": { + "type": "string" + } + }, + "required": [ + "condition" + ], + "additionalProperties": false + }, + { + "type": "string" + } + ] + } + }, + "decay": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "every": { + "type": "string", + "maxLength": 32 + } + }, + "required": [ + "amount", + "every" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "can_view": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "can_edit": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "can_reset_all": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "triggers" + ], + "additionalProperties": false + } + }, + "can_view": { + "default": false, + "type": "boolean" + }, + "can_edit": { + "default": false, + "type": "boolean" + }, + "can_reset_all": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "counters": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "pretty_name": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "per_channel": { + "default": false, + "type": "boolean" + }, + "per_user": { + "default": false, + "type": "boolean" + }, + "initial_value": { + "default": 0, + "type": "number", + "minimum": 0, + "maximum": 2147483647 + }, + "triggers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "pretty_name": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "condition": { + "type": "string" + }, + "reverse_condition": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "string" + } + ] + } + }, + "decay": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "every": { + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "can_view": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "can_edit": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "can_reset_all": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "can_view": { + "default": false, + "type": "boolean" + }, + "can_edit": { + "default": false, + "type": "boolean" + }, + "can_reset_all": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "custom_events": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "events": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "trigger": { + "type": "object", + "properties": { + "type": { + "const": "command" + }, + "name": { + "type": "string" + }, + "params": { + "type": "string" + }, + "can_use": { + "type": "boolean" + } + }, + "required": [ + "type", + "name", + "params", + "can_use" + ], + "additionalProperties": false + }, + "actions": { + "maxItems": 10, + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "add_role" + }, + "target": { + "type": "string" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type", + "target", + "role" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "create_case" + }, + "case_type": { + "type": "string" + }, + "mod": { + "type": "string" + }, + "target": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "type", + "case_type", + "mod", + "target", + "reason" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "move_to_vc" + }, + "target": { + "type": "string" + }, + "channel": { + "type": "string" + } + }, + "required": [ + "type", + "target", + "channel" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "message" + }, + "channel": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [ + "type", + "channel", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "make_role_mentionable" + }, + "role": { + "type": "string" + }, + "timeout": { + "type": "string", + "maxLength": 32 + } + }, + "required": [ + "type", + "role", + "timeout" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "make_role_unmentionable" + }, + "role": { + "type": "string" + } + }, + "required": [ + "type", + "role" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "set_channel_permission_overrides" + }, + "channel": { + "type": "string" + }, + "overrides": { + "maxItems": 15, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "const": "member" + }, + { + "const": "role" + } + ] + }, + "id": { + "type": "string" + }, + "allow": { + "type": "number" + }, + "deny": { + "type": "number" + } + }, + "required": [ + "type", + "id", + "allow", + "deny" + ], + "additionalProperties": false + } + } + }, + "required": [ + "type", + "channel", + "overrides" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "name", + "trigger", + "actions" + ], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "events": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "trigger": { + "type": "object", + "properties": { + "type": { + "const": "command" + }, + "name": { + "type": "string" + }, + "params": { + "type": "string" + }, + "can_use": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "actions": { + "maxItems": 10, + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "add_role" + }, + "target": { + "type": "string" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "create_case" + }, + "case_type": { + "type": "string" + }, + "mod": { + "type": "string" + }, + "target": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "move_to_vc" + }, + "target": { + "type": "string" + }, + "channel": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "message" + }, + "channel": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "make_role_mentionable" + }, + "role": { + "type": "string" + }, + "timeout": { + "type": "string", + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "make_role_unmentionable" + }, + "role": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "set_channel_permission_overrides" + }, + "channel": { + "type": "string" + }, + "overrides": { + "maxItems": 15, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "const": "member" + }, + { + "const": "role" + } + ] + }, + "id": { + "type": "string" + }, + "allow": { + "type": "number" + }, + "deny": { + "type": "number" + } + }, + "required": [], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + } + ] + } + } + }, + "required": [], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "guild_info_saver": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "internal_poster": { + "type": "object", + "properties": { + "config": { + "default": {}, + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "default": {}, + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + } + }, + "required": [] + }, + "locate_user": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_where": { + "default": false, + "type": "boolean" + }, + "can_alert": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_where": { + "default": false, + "type": "boolean" + }, + "can_alert": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "logs": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "channels": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "include": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "exclude": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "batched": { + "default": true, + "type": "boolean" + }, + "batch_time": { + "default": 1000, + "type": "number", + "minimum": 250, + "maximum": 5000 + }, + "excluded_users": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_message_regexes": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_channels": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_categories": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_threads": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "exclude_bots": { + "default": false, + "type": "boolean" + }, + "excluded_roles": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "format": { + "default": {}, + "type": "object", + "properties": { + "MEMBER_WARN": { + "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_EXPIRED": { + "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_KICK": { + "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_FORCEBAN": { + "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_SOFTBAN": { + "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN": { + "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_LEAVE": { + "default": "{timestamp} 📤 {userMention(member)} left the server", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_ADD": { + "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_REMOVE": { + "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NICK_CHANGE": { + "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_USERNAME_CHANGE": { + "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_RESTORE": { + "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_CREATE": { + "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_DELETE": { + "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_UPDATE": { + "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_CREATE": { + "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_DELETE": { + "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_UPDATE": { + "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_CREATE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_DELETE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_UPDATE": { + "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_EDIT": { + "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE": { + "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BULK": { + "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BARE": { + "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_JOIN": { + "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_LEAVE": { + "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_MOVE": { + "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_CREATE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_DELETE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_UPDATE": { + "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_CREATE": { + "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_DELETE": { + "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_UPDATE": { + "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_CREATE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_DELETE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_UPDATE": { + "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "COMMAND": { + "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CENSOR": { + "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CLEAN": { + "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_CREATE": { + "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSUNBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSMUTE": { + "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN_WITH_PRIOR_RECORDS": { + "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "OTHER_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_CHANGES": { + "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_MOVE": { + "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_DISCONNECT": { + "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_UPDATE": { + "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_REJOIN": { + "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "POSTED_SCHEDULED_MESSAGE": { + "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "BOT_ALERT": { + "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "AUTOMOD_ACTION": { + "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_AUTO": { + "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_USER": { + "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_AUTO": { + "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NOTE": { + "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_DELETE": { + "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "DM_FAILED": { + "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "timestamp_format": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "include_embed_timestamp": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "format": { + "default": {}, + "type": "object", + "properties": { + "MEMBER_WARN": { + "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_EXPIRED": { + "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_KICK": { + "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_FORCEBAN": { + "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_SOFTBAN": { + "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN": { + "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_LEAVE": { + "default": "{timestamp} 📤 {userMention(member)} left the server", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_ADD": { + "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_REMOVE": { + "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NICK_CHANGE": { + "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_USERNAME_CHANGE": { + "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_RESTORE": { + "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_CREATE": { + "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_DELETE": { + "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_UPDATE": { + "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_CREATE": { + "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_DELETE": { + "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_UPDATE": { + "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_CREATE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_DELETE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_UPDATE": { + "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_EDIT": { + "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE": { + "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BULK": { + "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BARE": { + "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_JOIN": { + "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_LEAVE": { + "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_MOVE": { + "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_CREATE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_DELETE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_UPDATE": { + "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_CREATE": { + "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_DELETE": { + "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_UPDATE": { + "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_CREATE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_DELETE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_UPDATE": { + "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "COMMAND": { + "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CENSOR": { + "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CLEAN": { + "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_CREATE": { + "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSUNBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSMUTE": { + "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN_WITH_PRIOR_RECORDS": { + "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "OTHER_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_CHANGES": { + "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_MOVE": { + "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_DISCONNECT": { + "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_UPDATE": { + "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_REJOIN": { + "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "POSTED_SCHEDULED_MESSAGE": { + "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "BOT_ALERT": { + "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "AUTOMOD_ACTION": { + "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_AUTO": { + "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_USER": { + "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_AUTO": { + "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NOTE": { + "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_DELETE": { + "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "DM_FAILED": { + "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "ping_user": { + "default": true, + "type": "boolean" + }, + "allow_user_mentions": { + "default": false, + "type": "boolean" + }, + "timestamp_format": { + "default": "[]", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "include_embed_timestamp": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "channels": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "include": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "exclude": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "batched": { + "default": true, + "type": "boolean" + }, + "batch_time": { + "default": 1000, + "type": "number", + "minimum": 250, + "maximum": 5000 + }, + "excluded_users": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_message_regexes": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_channels": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_categories": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "excluded_threads": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "exclude_bots": { + "default": false, + "type": "boolean" + }, + "excluded_roles": { + "default": null, + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "format": { + "default": {}, + "type": "object", + "properties": { + "MEMBER_WARN": { + "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_EXPIRED": { + "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_KICK": { + "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_FORCEBAN": { + "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_SOFTBAN": { + "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN": { + "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_LEAVE": { + "default": "{timestamp} 📤 {userMention(member)} left the server", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_ADD": { + "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_REMOVE": { + "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NICK_CHANGE": { + "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_USERNAME_CHANGE": { + "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_RESTORE": { + "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_CREATE": { + "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_DELETE": { + "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_UPDATE": { + "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_CREATE": { + "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_DELETE": { + "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_UPDATE": { + "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_CREATE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_DELETE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_UPDATE": { + "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_EDIT": { + "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE": { + "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BULK": { + "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BARE": { + "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_JOIN": { + "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_LEAVE": { + "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_MOVE": { + "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_CREATE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_DELETE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_UPDATE": { + "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_CREATE": { + "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_DELETE": { + "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_UPDATE": { + "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_CREATE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_DELETE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_UPDATE": { + "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "COMMAND": { + "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CENSOR": { + "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CLEAN": { + "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_CREATE": { + "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSUNBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSMUTE": { + "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN_WITH_PRIOR_RECORDS": { + "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "OTHER_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_CHANGES": { + "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_MOVE": { + "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_DISCONNECT": { + "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_UPDATE": { + "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_REJOIN": { + "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "POSTED_SCHEDULED_MESSAGE": { + "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "BOT_ALERT": { + "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "AUTOMOD_ACTION": { + "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_AUTO": { + "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_USER": { + "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_AUTO": { + "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NOTE": { + "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_DELETE": { + "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "DM_FAILED": { + "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "timestamp_format": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "include_embed_timestamp": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "format": { + "default": {}, + "type": "object", + "properties": { + "MEMBER_WARN": { + "default": "{timestamp} ⚠️ {userMention(member)} was warned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted indefinitely by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was unmuted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_EXPIRED": { + "default": "{timestamp} 🔊 {userMention(member)}'s mute expired", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_KICK": { + "default": "{timestamp} 👢 {userMention(user)} was kicked by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was banned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was unbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_FORCEBAN": { + "default": "{timestamp} 🔨 User (`{userId}`) was forcebanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_SOFTBAN": { + "default": "{timestamp} 🔨 {userMention(member)} was softbanned by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN": { + "default": "{timestamp} 📥 {new} {userMention(member)} joined (created )", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_LEAVE": { + "default": "{timestamp} 📤 {userMention(member)} left the server", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_ADD": { + "default": "{timestamp} 🔑 {userMention(mod)} added roles for {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_REMOVE": { + "default": "{timestamp} 🔑 {userMention(mod)} removed roles from {userMention(member)}: **{roles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NICK_CHANGE": { + "default": "{timestamp} ✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_USERNAME_CHANGE": { + "default": "{timestamp} ✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_RESTORE": { + "default": "{timestamp} 💿 Restored {restoredData} for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_CREATE": { + "default": "{timestamp} 🖊 Channel {channelMention(channel)} was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_DELETE": { + "default": "{timestamp} 🗑 Channel {channelMention(channel)} was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CHANNEL_UPDATE": { + "default": "{timestamp} ✏ Channel {channelMention(newChannel)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_CREATE": { + "default": "{timestamp} 🖊 Thread {channelMention(thread)} was created in channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_DELETE": { + "default": "{timestamp} 🗑 Thread {channelMention(thread)} was deleted/archived from channel <#{thread.parentId}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "THREAD_UPDATE": { + "default": "{timestamp} ✏ Thread {channelMention(newThread)} was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_CREATE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_DELETE": { + "default": "{timestamp} 🖊 Role **{role.name}** (`{role.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "ROLE_UPDATE": { + "default": "{timestamp} 🖊 Role **{newRole.name}** (`{newRole.id}`) was edited. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_EDIT": { + "default": "{timestamp} ✏ {userMention(user)} edited their message (`{after.id}`) in {channelMention(channel)}:\n**Before:**{messageSummary(before)}**After:**{messageSummary(after)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE": { + "default": "{timestamp} 🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BULK": { + "default": "{timestamp} 🗑 **{count}** messages by {authorIds} deleted in {channelMention(channel)} ({archiveUrl})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_BARE": { + "default": "{timestamp} 🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_JOIN": { + "default": "{timestamp} 🎙 🔵 {userMention(member)} joined {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_LEAVE": { + "default": "{timestamp} 🎙 🔴 {userMention(member)} left {channelMention(channel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_MOVE": { + "default": "{timestamp} 🎙 ↔ {userMention(member)} moved from {channelMention(oldChannel)} to {channelMention(newChannel)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_CREATE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was created in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_DELETE": { + "default": "{timestamp} 📣 Stage Instance `{stageInstance.topic}` was deleted in Stage Channel <#{stageChannel.id}>", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STAGE_INSTANCE_UPDATE": { + "default": "{timestamp} 📣 Stage Instance `{newStageInstance.topic}` was edited in Stage Channel <#{stageChannel.id}>. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_CREATE": { + "default": "{timestamp} {emoji.mention} Emoji **{emoji.name}** (`{emoji.id}`) was created", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_DELETE": { + "default": "{timestamp} 👋 Emoji **{emoji.name}** (`{emoji.id}`) was deleted", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "EMOJI_UPDATE": { + "default": "{timestamp} {newEmoji.mention} Emoji **{newEmoji.name}** (`{newEmoji.id}`) was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_CREATE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was created. Description: `{sticker.description}` Format: {emoji.format}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_DELETE": { + "default": "{timestamp} 🖼️ Sticker `{sticker.name} ({sticker.id})` was deleted.", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "STICKER_UPDATE": { + "default": "{timestamp} 🖼️ Sticker `{newSticker.name} ({sticker.id})` was updated. Changes:\n{differenceString}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "COMMAND": { + "default": "{timestamp} 🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected in {channelMention(channel)}: {description} (more than {limit} in {interval}s)\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CENSOR": { + "default": "{timestamp} 🛑 Censored message (`{message.id}`) from {userMention(user)} in {channelMention(channel)}: {reason}:\n```{messageText}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CLEAN": { + "default": "{timestamp} 🚿 {userMention(mod)} cleaned **{count}** message(s) in {channelMention(channel)}\n{archiveUrl}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_CREATE": { + "default": "{timestamp} ✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSUNBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} mass-unbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSBAN": { + "default": "{timestamp} ⚒ {userMention(mod)} massbanned {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MASSMUTE": { + "default": "{timestamp} 📢🚫 {userMention(mod)} massmuted {count} users", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_MUTE": { + "default": "{timestamp} 🔇 {userMention(user)} was muted for **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNMUTE": { + "default": "{timestamp} 🔊 {userMention(user)} was scheduled to be unmuted in **{time}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_BAN": { + "default": "{timestamp} 🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_TIMED_UNBAN": { + "default": "{timestamp} 🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_JOIN_WITH_PRIOR_RECORDS": { + "default": "{timestamp} ⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "OTHER_SPAM_DETECTED": { + "default": "{timestamp} 🛑 {userMention(member)} spam detected: {description} (more than {limit} in {interval}s)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_ROLE_CHANGES": { + "default": "{timestamp} 🔑 {userMention(member)} had role changes: received **{addedRoles}**, lost **{removedRoles}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_MOVE": { + "default": "{timestamp} 🎙 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "VOICE_CHANNEL_FORCE_DISCONNECT": { + "default": "{timestamp} 🎙 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_UPDATE": { + "default": "{timestamp} ✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_MUTE_REJOIN": { + "default": "{timestamp} ⚠ Reapplied active mute for {userMention(member)} on rejoin", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "POSTED_SCHEDULED_MESSAGE": { + "default": "{timestamp} 📨 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "BOT_ALERT": { + "default": "{timestamp} ⚠ **BOT ALERT:** {tmplEval(body)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "AUTOMOD_ACTION": { + "default": "{timestamp} 🤖 Automod rule **{if(not(prettyName), rule, prettyName)}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SCHEDULED_REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {datetime}, repeated {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "REPEATED_MESSAGE": { + "default": "{timestamp} ⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} {repeatDetails}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MESSAGE_DELETE_AUTO": { + "default": "{timestamp} 🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_USER": { + "default": "{timestamp} ⚔ {userMention(user)} set anti-raid to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "SET_ANTIRAID_AUTO": { + "default": "{timestamp} ⚔ Anti-raid automatically set to **{level}**", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "MEMBER_NOTE": { + "default": "{timestamp} 🖊 Note added on {userMention(user)} by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "CASE_DELETE": { + "default": "{timestamp} ✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + }, + "DM_FAILED": { + "default": "{timestamp} 🚧 Failed to send DM ({source}) to {userMention(user)}", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "ping_user": { + "default": true, + "type": "boolean" + }, + "allow_user_mentions": { + "default": false, + "type": "boolean" + }, + "timestamp_format": { + "default": "[]", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "include_embed_timestamp": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "message_saver": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_manage": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_manage": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "mod_actions": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "dm_on_warn": { + "default": true, + "type": "boolean" + }, + "dm_on_kick": { + "default": false, + "type": "boolean" + }, + "dm_on_ban": { + "default": false, + "type": "boolean" + }, + "message_on_warn": { + "default": false, + "type": "boolean" + }, + "message_on_kick": { + "default": false, + "type": "boolean" + }, + "message_on_ban": { + "default": false, + "type": "boolean" + }, + "message_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "warn_message": { + "default": "You have received a warning on the {guildName} server: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kick_message": { + "default": "You have been kicked from the {guildName} server. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ban_message": { + "default": "You have been banned from the {guildName} server. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tempban_message": { + "default": "You have been banned from the {guildName} server for {banTime}. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "alert_on_rejoin": { + "default": false, + "type": "boolean" + }, + "alert_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "warn_notify_enabled": { + "default": false, + "type": "boolean" + }, + "warn_notify_threshold": { + "default": 5, + "type": "number" + }, + "warn_notify_message": { + "default": "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", + "type": "string" + }, + "ban_delete_message_days": { + "default": 1, + "type": "number" + }, + "attachment_link_reaction": { + "default": "warn", + "anyOf": [ + { + "anyOf": [ + { + "const": "none" + }, + { + "const": "warn" + }, + { + "const": "restrict" + } + ] + }, + { + "type": "null" + } + ] + }, + "can_note": { + "default": false, + "type": "boolean" + }, + "can_warn": { + "default": false, + "type": "boolean" + }, + "can_mute": { + "default": false, + "type": "boolean" + }, + "can_kick": { + "default": false, + "type": "boolean" + }, + "can_ban": { + "default": false, + "type": "boolean" + }, + "can_unban": { + "default": false, + "type": "boolean" + }, + "can_view": { + "default": false, + "type": "boolean" + }, + "can_addcase": { + "default": false, + "type": "boolean" + }, + "can_massunban": { + "default": false, + "type": "boolean" + }, + "can_massban": { + "default": false, + "type": "boolean" + }, + "can_massmute": { + "default": false, + "type": "boolean" + }, + "can_hidecase": { + "default": false, + "type": "boolean" + }, + "can_deletecase": { + "default": false, + "type": "boolean" + }, + "can_act_as_other": { + "default": false, + "type": "boolean" + }, + "create_cases_for_manual_actions": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "dm_on_warn": { + "default": true, + "type": "boolean" + }, + "dm_on_kick": { + "default": false, + "type": "boolean" + }, + "dm_on_ban": { + "default": false, + "type": "boolean" + }, + "message_on_warn": { + "default": false, + "type": "boolean" + }, + "message_on_kick": { + "default": false, + "type": "boolean" + }, + "message_on_ban": { + "default": false, + "type": "boolean" + }, + "message_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "warn_message": { + "default": "You have received a warning on the {guildName} server: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kick_message": { + "default": "You have been kicked from the {guildName} server. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ban_message": { + "default": "You have been banned from the {guildName} server. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tempban_message": { + "default": "You have been banned from the {guildName} server for {banTime}. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "alert_on_rejoin": { + "default": false, + "type": "boolean" + }, + "alert_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "warn_notify_enabled": { + "default": false, + "type": "boolean" + }, + "warn_notify_threshold": { + "default": 5, + "type": "number" + }, + "warn_notify_message": { + "default": "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", + "type": "string" + }, + "ban_delete_message_days": { + "default": 1, + "type": "number" + }, + "attachment_link_reaction": { + "default": "warn", + "anyOf": [ + { + "anyOf": [ + { + "const": "none" + }, + { + "const": "warn" + }, + { + "const": "restrict" + } + ] + }, + { + "type": "null" + } + ] + }, + "can_note": { + "default": false, + "type": "boolean" + }, + "can_warn": { + "default": false, + "type": "boolean" + }, + "can_mute": { + "default": false, + "type": "boolean" + }, + "can_kick": { + "default": false, + "type": "boolean" + }, + "can_ban": { + "default": false, + "type": "boolean" + }, + "can_unban": { + "default": false, + "type": "boolean" + }, + "can_view": { + "default": false, + "type": "boolean" + }, + "can_addcase": { + "default": false, + "type": "boolean" + }, + "can_massunban": { + "default": false, + "type": "boolean" + }, + "can_massban": { + "default": false, + "type": "boolean" + }, + "can_massmute": { + "default": false, + "type": "boolean" + }, + "can_hidecase": { + "default": false, + "type": "boolean" + }, + "can_deletecase": { + "default": false, + "type": "boolean" + }, + "can_act_as_other": { + "default": false, + "type": "boolean" + }, + "create_cases_for_manual_actions": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "mutes": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "mute_role": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "move_to_voice_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kick_from_voice_channel": { + "default": false, + "type": "boolean" + }, + "dm_on_mute": { + "default": false, + "type": "boolean" + }, + "dm_on_update": { + "default": false, + "type": "boolean" + }, + "message_on_mute": { + "default": false, + "type": "boolean" + }, + "message_on_update": { + "default": false, + "type": "boolean" + }, + "message_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mute_message": { + "default": "You have been muted on the {guildName} server. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "timed_mute_message": { + "default": "You have been muted on the {guildName} server for {time}. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "update_mute_message": { + "default": "Your mute on the {guildName} server has been updated to {time}.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "can_view_list": { + "default": false, + "type": "boolean" + }, + "can_cleanup": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "mute_role": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "move_to_voice_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kick_from_voice_channel": { + "default": false, + "type": "boolean" + }, + "dm_on_mute": { + "default": false, + "type": "boolean" + }, + "dm_on_update": { + "default": false, + "type": "boolean" + }, + "message_on_mute": { + "default": false, + "type": "boolean" + }, + "message_on_update": { + "default": false, + "type": "boolean" + }, + "message_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mute_message": { + "default": "You have been muted on the {guildName} server. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "timed_mute_message": { + "default": "You have been muted on the {guildName} server for {time}. Reason given: {reason}", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "update_mute_message": { + "default": "Your mute on the {guildName} server has been updated to {time}.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "can_view_list": { + "default": false, + "type": "boolean" + }, + "can_cleanup": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "name_history": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_view": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_view": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "persist": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "persisted_roles": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "persist_nicknames": { + "default": false, + "type": "boolean" + }, + "persist_voice_mutes": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "persisted_roles": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "persist_nicknames": { + "default": false, + "type": "boolean" + }, + "persist_voice_mutes": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "phisherman": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "api_key": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "api_key": { + "default": null, + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "pingable_roles": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_manage": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_manage": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "post": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_post": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_post": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "reaction_roles": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "auto_refresh_interval": { + "default": 900000, + "type": "number", + "minimum": 900000 + }, + "remove_user_reactions": { + "default": true, + "type": "boolean" + }, + "can_manage": { + "default": false, + "type": "boolean" + }, + "button_groups": { + "default": null, + "type": "null" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "auto_refresh_interval": { + "default": 900000, + "type": "number", + "minimum": 900000 + }, + "remove_user_reactions": { + "default": true, + "type": "boolean" + }, + "can_manage": { + "default": false, + "type": "boolean" + }, + "button_groups": { + "default": null, + "type": "null" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "reminders": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_use": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_use": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "role_buttons": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "buttons": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "message": { + "anyOf": [ + { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "message_id": { + "type": "string" + } + }, + "required": [ + "channel_id", + "message_id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "required": [ + "channel_id", + "content" + ], + "additionalProperties": false + } + ] + }, + "options": { + "maxItems": 25, + "type": "array", + "items": { + "type": "object", + "properties": { + "role_id": { + "type": "string" + }, + "label": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "emoji": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "style": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": 1 + }, + { + "const": 2 + }, + { + "const": 3 + }, + { + "const": 4 + }, + { + "const": "PRIMARY" + }, + { + "const": "SECONDARY" + }, + { + "const": "SUCCESS" + }, + { + "const": "DANGER" + } + ] + }, + { + "type": "null" + } + ] + }, + "start_new_row": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "role_id" + ], + "additionalProperties": false + } + }, + "exclusive": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "message", + "options" + ], + "additionalProperties": false + } + }, + "can_reset": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "buttons": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "message": { + "anyOf": [ + { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "message_id": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + ] + }, + "options": { + "maxItems": 25, + "type": "array", + "items": { + "type": "object", + "properties": { + "role_id": { + "type": "string" + }, + "label": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "emoji": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "style": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "const": 1 + }, + { + "const": 2 + }, + { + "const": 3 + }, + { + "const": 4 + }, + { + "const": "PRIMARY" + }, + { + "const": "SECONDARY" + }, + { + "const": "SUCCESS" + }, + { + "const": "DANGER" + } + ] + }, + { + "type": "null" + } + ] + }, + "start_new_row": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "exclusive": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "can_reset": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "role_manager": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "roles": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_assign": { + "default": false, + "type": "boolean" + }, + "can_mass_assign": { + "default": false, + "type": "boolean" + }, + "assignable_roles": { + "default": [], + "maxItems": 100, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_assign": { + "default": false, + "type": "boolean" + }, + "can_mass_assign": { + "default": false, + "type": "boolean" + }, + "assignable_roles": { + "default": [], + "maxItems": 100, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "self_grantable_roles": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "entries": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "roles": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "maxItems": 100, + "type": "array", + "items": { + "type": "string" + } + } + }, + "can_use": { + "default": false, + "type": "boolean" + }, + "can_ignore_cooldown": { + "default": false, + "type": "boolean" + }, + "max_roles": { + "default": 0, + "type": "number" + } + }, + "required": [ + "roles" + ], + "additionalProperties": false + } + }, + "mention_roles": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "entries": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "roles": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "maxItems": 100, + "type": "array", + "items": { + "type": "string" + } + } + }, + "can_use": { + "default": false, + "type": "boolean" + }, + "can_ignore_cooldown": { + "default": false, + "type": "boolean" + }, + "max_roles": { + "default": 0, + "type": "number" + } + }, + "required": [], + "additionalProperties": false + } + }, + "mention_roles": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "slowmode": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "use_native_slowmode": { + "default": true, + "type": "boolean" + }, + "can_manage": { + "default": false, + "type": "boolean" + }, + "is_affected": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "use_native_slowmode": { + "default": true, + "type": "boolean" + }, + "can_manage": { + "default": false, + "type": "boolean" + }, + "is_affected": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "spam": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "max_censor": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_messages": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_mentions": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_links": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_attachments": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_emojis": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_newlines": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_duplicates": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_characters": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_voice_moves": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "interval", + "count" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "max_censor": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_messages": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_mentions": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_links": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_attachments": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_emojis": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_newlines": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_duplicates": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_characters": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "max_voice_moves": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "interval": { + "type": "number" + }, + "count": { + "type": "number" + }, + "mute": { + "default": false, + "type": "boolean" + }, + "mute_time": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "remove_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "restore_roles_on_mute": { + "default": false, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "clean": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "starboard": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "boards": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "stars_required": { + "type": "number" + }, + "star_emoji": { + "default": [ + "⭐" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "allow_selfstars": { + "default": false, + "type": "boolean" + }, + "copy_full_embed": { + "default": false, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "show_star_count": { + "default": true, + "type": "boolean" + }, + "color": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "channel_id", + "stars_required" + ], + "additionalProperties": false + } + }, + "can_migrate": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "boards": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "stars_required": { + "type": "number" + }, + "star_emoji": { + "default": [ + "⭐" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "allow_selfstars": { + "default": false, + "type": "boolean" + }, + "copy_full_embed": { + "default": false, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "show_star_count": { + "default": true, + "type": "boolean" + }, + "color": { + "default": null, + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "can_migrate": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "tags": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "prefix": { + "default": "!!", + "type": "string" + }, + "delete_with_command": { + "default": true, + "type": "boolean" + }, + "user_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "global_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "user_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "allow_mentions": { + "default": false, + "type": "boolean" + }, + "global_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "auto_delete_command": { + "default": false, + "type": "boolean" + }, + "categories": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "prefix": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "delete_with_command": { + "default": false, + "type": "boolean" + }, + "user_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "user_category_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "global_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "allow_mentions": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "global_category_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "auto_delete_command": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "tags": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "can_use": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "tags" + ], + "additionalProperties": false + } + }, + "can_create": { + "default": false, + "type": "boolean" + }, + "can_use": { + "default": false, + "type": "boolean" + }, + "can_list": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "prefix": { + "default": "!!", + "type": "string" + }, + "delete_with_command": { + "default": true, + "type": "boolean" + }, + "user_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "global_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "user_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "allow_mentions": { + "default": false, + "type": "boolean" + }, + "global_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "auto_delete_command": { + "default": false, + "type": "boolean" + }, + "categories": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "prefix": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "delete_with_command": { + "default": false, + "type": "boolean" + }, + "user_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "user_category_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "global_tag_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "allow_mentions": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "global_category_cooldown": { + "default": null, + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "null" + } + ] + }, + "auto_delete_command": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "tags": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/strictMessageContent" + } + ] + } + }, + "can_use": { + "default": null, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "can_create": { + "default": false, + "type": "boolean" + }, + "can_use": { + "default": false, + "type": "boolean" + }, + "can_list": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "time_and_date": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "timezone": { + "default": "Etc/UTC", + "type": "string" + }, + "date_formats": { + "default": { + "date": "MMM D, YYYY", + "time": "H:mm", + "pretty_datetime": "MMM D, YYYY [at] H:mm z" + }, + "type": "object", + "properties": { + "date": { + "default": "MMM D, YYYY", + "type": "string" + }, + "time": { + "default": "H:mm", + "type": "string" + }, + "pretty_datetime": { + "default": "MMM D, YYYY [at] H:mm z", + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + "can_set_timezone": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "timezone": { + "default": "Etc/UTC", + "type": "string" + }, + "date_formats": { + "default": { + "date": "MMM D, YYYY", + "time": "H:mm", + "pretty_datetime": "MMM D, YYYY [at] H:mm z" + }, + "type": "object", + "properties": { + "date": { + "default": "MMM D, YYYY", + "type": "string" + }, + "time": { + "default": "H:mm", + "type": "string" + }, + "pretty_datetime": { + "default": "MMM D, YYYY [at] H:mm z", + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + "can_set_timezone": { + "default": false, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "username_saver": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "utility": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "can_roles": { + "default": false, + "type": "boolean" + }, + "can_level": { + "default": false, + "type": "boolean" + }, + "can_search": { + "default": false, + "type": "boolean" + }, + "can_clean": { + "default": false, + "type": "boolean" + }, + "can_info": { + "default": false, + "type": "boolean" + }, + "can_server": { + "default": false, + "type": "boolean" + }, + "can_inviteinfo": { + "default": false, + "type": "boolean" + }, + "can_channelinfo": { + "default": false, + "type": "boolean" + }, + "can_messageinfo": { + "default": false, + "type": "boolean" + }, + "can_userinfo": { + "default": false, + "type": "boolean" + }, + "can_roleinfo": { + "default": false, + "type": "boolean" + }, + "can_emojiinfo": { + "default": false, + "type": "boolean" + }, + "can_snowflake": { + "default": false, + "type": "boolean" + }, + "can_reload_guild": { + "default": false, + "type": "boolean" + }, + "can_nickname": { + "default": false, + "type": "boolean" + }, + "can_ping": { + "default": false, + "type": "boolean" + }, + "can_source": { + "default": false, + "type": "boolean" + }, + "can_vcmove": { + "default": false, + "type": "boolean" + }, + "can_vckick": { + "default": false, + "type": "boolean" + }, + "can_help": { + "default": false, + "type": "boolean" + }, + "can_about": { + "default": false, + "type": "boolean" + }, + "can_context": { + "default": false, + "type": "boolean" + }, + "can_jumbo": { + "default": false, + "type": "boolean" + }, + "jumbo_size": { + "default": 128, + "type": "number" + }, + "can_avatar": { + "default": false, + "type": "boolean" + }, + "info_on_single_result": { + "default": true, + "type": "boolean" + }, + "autojoin_threads": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "can_roles": { + "default": false, + "type": "boolean" + }, + "can_level": { + "default": false, + "type": "boolean" + }, + "can_search": { + "default": false, + "type": "boolean" + }, + "can_clean": { + "default": false, + "type": "boolean" + }, + "can_info": { + "default": false, + "type": "boolean" + }, + "can_server": { + "default": false, + "type": "boolean" + }, + "can_inviteinfo": { + "default": false, + "type": "boolean" + }, + "can_channelinfo": { + "default": false, + "type": "boolean" + }, + "can_messageinfo": { + "default": false, + "type": "boolean" + }, + "can_userinfo": { + "default": false, + "type": "boolean" + }, + "can_roleinfo": { + "default": false, + "type": "boolean" + }, + "can_emojiinfo": { + "default": false, + "type": "boolean" + }, + "can_snowflake": { + "default": false, + "type": "boolean" + }, + "can_reload_guild": { + "default": false, + "type": "boolean" + }, + "can_nickname": { + "default": false, + "type": "boolean" + }, + "can_ping": { + "default": false, + "type": "boolean" + }, + "can_source": { + "default": false, + "type": "boolean" + }, + "can_vcmove": { + "default": false, + "type": "boolean" + }, + "can_vckick": { + "default": false, + "type": "boolean" + }, + "can_help": { + "default": false, + "type": "boolean" + }, + "can_about": { + "default": false, + "type": "boolean" + }, + "can_context": { + "default": false, + "type": "boolean" + }, + "can_jumbo": { + "default": false, + "type": "boolean" + }, + "jumbo_size": { + "default": 128, + "type": "number" + }, + "can_avatar": { + "default": false, + "type": "boolean" + }, + "info_on_single_result": { + "default": true, + "type": "boolean" + }, + "autojoin_threads": { + "default": true, + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "welcome_message": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "send_dm": { + "default": false, + "type": "boolean" + }, + "send_to_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "send_dm": { + "default": false, + "type": "boolean" + }, + "send_to_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + }, + "common": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "success_emoji": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_emoji": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attachment_storing_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + }, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + }, + "config": { + "type": "object", + "properties": { + "success_emoji": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_emoji": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attachment_storing_channel": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "config" + ], + "additionalProperties": false + } + } + }, + "required": [] + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false, + "$defs": { + "__schema0": { + "$ref": "#/$defs/overrideCriteria" + }, + "overrideCriteria": { + "id": "overrideCriteria", + "type": "object", + "properties": { + "channel": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "category": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "user": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "thread": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "type": "null" + } + ] + }, + "is_thread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "thread_type": { + "anyOf": [ + { + "enum": [ + "public", + "private" + ] + }, + { + "type": "null" + } + ] + }, + "extra": {}, + "zzz_dummy_property_do_not_use": {}, + "all": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "any": { + "type": "array", + "items": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "not": { + "$ref": "#/$defs/overrideCriteria" + } + }, + "required": [], + "additionalProperties": false + }, + "strictMessageContent": { + "id": "strictMessageContent", + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "tts": { + "type": "boolean" + }, + "embeds": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/embedInput" + } + }, + { + "$ref": "#/$defs/embedInput" + } + ] + }, + "embed": { + "$ref": "#/$defs/embedInput" + } + }, + "required": [], + "additionalProperties": false + }, + "embedInput": { + "id": "embedInput", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "url": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "color": { + "type": "number" + }, + "footer": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "icon_url": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "image": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [] + }, + "thumbnail": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [] + }, + "video": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [] + }, + "provider": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "inline": { + "type": "boolean" + } + }, + "required": [] + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "additionalProperties": false + } + }, + "$schema": "https://json-schema.org/draft-2020-12/schema" +} \ No newline at end of file diff --git a/config-checker/src/main.ts b/config-checker/src/main.ts new file mode 100644 index 00000000..d5445122 --- /dev/null +++ b/config-checker/src/main.ts @@ -0,0 +1,88 @@ +import * as monaco from "monaco-editor"; +import { configureMonacoYaml } from "monaco-yaml"; +import schemaUri from "/config-schema.json?url"; + +window.MonacoEnvironment = { + getWorker(_, label) { + switch (label) { + case "editorWorkerService": + return new Worker(new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url), { type: "module" }); + case "yaml": + return new Worker(new URL("./yaml.worker.js", import.meta.url), { type: "module" }) + default: + throw new Error(`Unknown label ${label}`); + } + }, +}; + +configureMonacoYaml(monaco, { + enableSchemaRequest: true, + schemas: [{ + fileMatch: ["**/config.yaml"], + uri: schemaUri, + }], +}); + +const initialModel = monaco.editor.createModel("# Paste your config here to check it\n", undefined, monaco.Uri.parse("file:///config.yaml")); +initialModel.updateOptions({ tabSize: 2 }); + +const editorRoot = document.getElementById("editor")!; +const errorsRoot = document.getElementById("errors")!; + +monaco.editor.defineTheme("zeppelin", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#00000000", + "editor.focusBorder": "#00000000", + "list.focusOutline": "#00000000", + "editorStickyScroll.background": "#070c11", + }, +}); +monaco.editor.create(editorRoot, { + automaticLayout: true, + model: initialModel, + quickSuggestions: { + other: true, + comments: true, + strings: true, + }, + theme: "zeppelin", + minimap: { + enabled: false, + }, +}); + +function showErrors(markers: monaco.editor.IMarker[]) { + if (markers.length) { + markers.sort((a, b) => a.startLineNumber - b.startLineNumber); + const frag = document.createDocumentFragment(); + for (const marker of markers) { + const error = document.createElement("div"); + error.classList.add("error"); + + const lineMarker = document.createElement("strong"); + lineMarker.innerText = `Line ${marker.startLineNumber}: `; + + const errorText = document.createElement("span"); + errorText.innerText = marker.message; + + error.append(lineMarker, errorText); + frag.append(error); + } + errorsRoot.replaceChildren(frag); + } else { + const success = document.createElement("div"); + success.classList.add("noErrors"); + success.innerText = "No errors!"; + errorsRoot.replaceChildren(success); + } +} + +monaco.editor.onDidChangeMarkers(([uri]) => { + const markers = monaco.editor.getModelMarkers({ resource: uri }); + showErrors(markers); +}); + +showErrors([]); diff --git a/config-checker/src/style.css b/config-checker/src/style.css new file mode 100644 index 00000000..451e21c9 --- /dev/null +++ b/config-checker/src/style.css @@ -0,0 +1,89 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: 14px; + background-color: black; + background: linear-gradient(45deg, #040a0e, #27699e); + color: #f8f8f8; + margin: 0; +} + +.wrap { + height: 100vh; + display: flex; + flex-direction: column; + padding: 16px; + gap: 16px; +} + +.section { + background-color: #000000b8; + display: flex; + flex-direction: column; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 0 12px rgba(0, 0, 0, 0.397); +} + +.title { + flex: 0 0 32px; + background-color: #ffffff11; + display: flex; + align-items: center; + padding-left: 10px; +} + +.title h1 { + margin: 0; + font-size: 12px; + line-height: 1; + text-transform: uppercase; +} + +.content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor-wrap { + flex: 0 0 100%; + + display: flex; + flex-direction: column; +} + +#editor { + flex: 0 0 100%; +} + +.monaco-editor { + outline: 0 !important; +} + +.errors-wrap { + flex: 0 0 100%; + + display: flex; + flex-direction: column; + + padding: 10px; + + overflow-y: auto; +} + +#errors { +} + +.error { + color: hsl(10.7deg 58.76% 57.09%); +} + +.noErrors { + color: hsl(93.81deg 56.52% 52.07%); + font-weight: 700; +} diff --git a/config-checker/src/vite-env.d.ts b/config-checker/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/config-checker/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/config-checker/src/yaml.worker.js b/config-checker/src/yaml.worker.js new file mode 100644 index 00000000..566e2df6 --- /dev/null +++ b/config-checker/src/yaml.worker.js @@ -0,0 +1 @@ +import "monaco-yaml/yaml.worker.js"; diff --git a/config-checker/tsconfig.json b/config-checker/tsconfig.json new file mode 100644 index 00000000..a22caba9 --- /dev/null +++ b/config-checker/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/dashboard/package.json b/dashboard/package.json index 49680956..41fc4926 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -27,7 +27,7 @@ "source-map-loader": "^4.0.1", "tailwindcss": "^1.9.6", "ts-loader": "^9.4.3", - "vue-loader": "^15.10.1", + "vue-loader": "^17.4.2", "vue-style-loader": "^4.1.3", "vue-template-compiler": "^2.7.14", "webpack": "^5.94.0", @@ -37,7 +37,6 @@ }, "dependencies": { "@fastify/static": "^7.0.1", - "@highlightjs/vue-plugin": "^1.0.2", "fastify": "^4.26.2", "highlight.js": "^11.8.0", "humanize-duration": "^3.27.0", @@ -45,11 +44,12 @@ "marked": "^5.1.0", "modern-css-reset": "^1.4.0", "moment": "^2.29.4", - "vue": "^2.7.14", - "vue-material-design-icons": "^4.1.0", - "vue-router": "^3.6.5", - "vue2-ace-editor": "^0.0.15", - "vuex": "^3.6.2" + "vue": "^3.5.13", + "vue-material-design-icons": "^5.3.1", + "vue-router": "^4.5.0", + "vue3-ace-editor": "^2.2.4", + "vue3-highlightjs": "^1.0.5", + "vuex": "^4.1.0" }, "browserslist": [ "last 2 Chrome versions" diff --git a/dashboard/src/auth.ts b/dashboard/src/auth.ts index bb5c1b9e..92fcd936 100644 --- a/dashboard/src/auth.ts +++ b/dashboard/src/auth.ts @@ -21,9 +21,11 @@ export const loginCallbackGuard: NavigationGuard = async (to, from, next) => { } else { window.location.href = `/?error=noAccess`; } + return next(); }; export const authRedirectGuard: NavigationGuard = async (to, form, next) => { if (await isAuthenticated()) return next("/dashboard"); window.location.href = `${process.env.API_URL}/auth/login`; + return next(); }; diff --git a/dashboard/src/components/Expandable.vue b/dashboard/src/components/Expandable.vue index 4f0a39f4..fb699924 100644 --- a/dashboard/src/components/Expandable.vue +++ b/dashboard/src/components/Expandable.vue @@ -72,11 +72,11 @@ .inline-code, code:not([class]), - >>> code:not([class]) { + :deep(code:not([class])) { @apply bg-gray-900; } - .codeblock { + :deep(.codeblock) { box-shadow: none; } diff --git a/dashboard/src/components/Tab.vue b/dashboard/src/components/Tab.vue index e583bdc6..347db6ee 100644 --- a/dashboard/src/components/Tab.vue +++ b/dashboard/src/components/Tab.vue @@ -15,7 +15,7 @@ } } - a { + :deep(a) { @apply block; @apply py-2; @apply px-4; diff --git a/dashboard/src/components/dashboard/GuildAccess.vue b/dashboard/src/components/dashboard/GuildAccess.vue index a28ddd1b..860666df 100644 --- a/dashboard/src/components/dashboard/GuildAccess.vue +++ b/dashboard/src/components/dashboard/GuildAccess.vue @@ -100,7 +100,7 @@