3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-06-15 10:45:01 +00:00

Merge branch 'master' into chore/dependabot

This commit is contained in:
Almeida 2025-06-01 16:01:27 +01:00 committed by GitHub
commit e0f06844db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
516 changed files with 33887 additions and 10626 deletions

43
.clabot
View file

@ -1,37 +1,40 @@
{ {
"contributors": [ "contributors": [
"BanTheNons",
"CleverSource",
"DarkView",
"DenverCoder1",
"Jernik",
"Rstar284",
"almeidx", "almeidx",
"axisiscool", "axisiscool",
"BanTheNons",
"Benricheson101",
"brawaru",
"CleverSource",
"Dalkskkskk",
"DarkView",
"DenverCoder1",
"dexbiobot", "dexbiobot",
"greenbigfrog", "greenbigfrog",
"hawkeye7662",
"iamshoXy",
"Jernik",
"k200-1", "k200-1",
"LilyBergonzat",
"martinbndr",
"metal0", "metal0",
"Obliie",
"paolojpa", "paolojpa",
"roflmaoqwerty", "roflmaoqwerty",
"Rstar284",
"rubyowo",
"rukogit",
"Scraayp",
"TheKodeToad",
"thewilloftheshadow", "thewilloftheshadow",
"usoka", "usoka",
"vcokltfre", "vcokltfre",
"Dragory", "WeebHiroyuki",
"rubyowo",
"Dalkskkskk",
"iamshoXy",
"Scraayp",
"app/dependabot",
"dependabot[bot]",
"zayKenyon", "zayKenyon",
"rukogit",
"Obliie", "Dragory",
"brawaru", "app/dependabot",
"Benricheson101", "dependabot[bot]"
"hawkeye7662",
"LilyBergonzat",
"martinbndr"
], ],
"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!" "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!"
} }

View file

@ -22,8 +22,10 @@ STAFF=
DEFAULT_ALLOWED_SERVERS= DEFAULT_ALLOWED_SERVERS=
# Only required if relevant feature is used # Only required if relevant feature is used
#PHISHERMAN_API_KEY= #FISHFISH_API_KEY=
#DEFAULT_SUCCESS_EMOJI=
#DEFAULT_ERROR_EMOJI=
# ========================== # ==========================
# DEVELOPMENT # DEVELOPMENT

View file

@ -8,7 +8,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [18.16] node-version: [22]
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

2
.nvmrc
View file

@ -1 +1 @@
18 22

View file

@ -1,4 +1,4 @@
FROM node:20 FROM node:22 AS build
RUN mkdir /zeppelin RUN mkdir /zeppelin
RUN chown node:node /zeppelin RUN chown node:node /zeppelin
@ -32,3 +32,8 @@ RUN npm run build
# Prune dev dependencies # Prune dev dependencies
WORKDIR /zeppelin WORKDIR /zeppelin
RUN npm prune --omit=dev RUN npm prune --omit=dev
FROM node:22-alpine AS main
USER node
COPY --from=build --chown=node:node /zeppelin /zeppelin

View file

@ -4,6 +4,9 @@
"description": "", "description": "",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": {
"./*": "./dist/*"
},
"scripts": { "scripts": {
"watch": "tsc-watch --build --onSuccess \"node start-dev.js\"", "watch": "tsc-watch --build --onSuccess \"node start-dev.js\"",
"watch-yaml-parse-test": "tsc-watch --build --onSuccess \"node dist/yamlParseTest.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-prod": "npm run migrate-rollback",
"migrate-rollback-dev": "npm run build && 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", "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", "test": "npm run build && npm run run-tests",
"run-tests": "ava", "run-tests": "ava",
"test-watch": "tsc-watch --build --onSuccess \"npx ava\"" "test-watch": "tsc-watch --build --onSuccess \"npx ava\""
@ -38,18 +41,18 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"deep-diff": "^1.0.2", "deep-diff": "^1.0.2",
"discord.js": "^14.14.1", "discord.js": "^14.19.3",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
"express": "^4.20.0", "express": "^4.20.0",
"fp-ts": "^2.0.1", "fp-ts": "^2.0.1",
"humanize-duration": "^3.15.0", "humanize-duration": "^3.15.0",
"js-yaml": "^3.13.1", "js-yaml": "^4.1.0",
"knub": "^32.0.0-next.21", "knub": "^32.0.0-next.25",
"knub-command-manager": "^9.1.0", "knub-command-manager": "^9.1.0",
"last-commit-log": "^2.1.0", "last-commit-log": "^2.1.0",
"lodash": "^4.17.21", "lodash-es": "^4.17.21",
"moment-timezone": "^0.5.21", "moment-timezone": "^0.5.21",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.9.8", "mysql2": "^3.9.8",
@ -72,15 +75,14 @@
"utf-8-validate": "^5.0.5", "utf-8-validate": "^5.0.5",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
"zlib-sync": "^0.1.7", "zod": "^3.25.17"
"zod": "^3.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.5", "@types/cors": "^2.8.5",
"@types/express": "^4.16.1", "@types/express": "^4.16.1",
"@types/jest": "^24.0.15", "@types/jest": "^24.0.15",
"@types/js-yaml": "^3.12.1", "@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/moment-timezone": "^0.5.6",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/passport": "^1.0.0", "@types/passport": "^1.0.0",

View file

@ -1,5 +1,5 @@
import { EventEmitter } from "events";
import { CooldownManager } from "knub"; import { CooldownManager } from "knub";
import { EventEmitter } from "node:events";
import { RegExpWorker, TimeoutError } from "regexp-worker"; import { RegExpWorker, TimeoutError } from "regexp-worker";
import { MINUTES, SECONDS } from "./utils.js"; import { MINUTES, SECONDS } from "./utils.js";
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;

View file

@ -46,7 +46,7 @@ export class SimpleCache<T = any> {
}); });
if (this.maxItems && this.store.size > this.maxItems) { 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); this.store.delete(keyToDelete);
} }
} }

View file

@ -1,130 +1,135 @@
import express from "express"; import express from "express";
import z from "zod"; import z from "zod/v4";
import { guildPlugins } from "../plugins/availablePlugins.js"; import { availableGuildPlugins } from "../plugins/availablePlugins.js";
import { guildPluginInfo } from "../plugins/pluginInfo.js"; import { ZeppelinGuildPluginInfo } from "../types.js";
import { indentLines } from "../utils.js"; import { indentLines } from "../utils.js";
import { notFound } from "./responses.js"; import { notFound } from "./responses.js";
import { $ZodPipeDef } from "zod/v4/core";
function isZodObject(schema: z.ZodTypeAny): schema is z.ZodObject<any> { function isZodObject(schema: z.ZodType): schema is z.ZodObject<any> {
return schema._def.typeName === "ZodObject"; return schema.def.type === "object";
} }
function isZodRecord(schema: z.ZodTypeAny): schema is z.ZodRecord<any> { function isZodRecord(schema: z.ZodType): schema is z.ZodRecord<any> {
return schema._def.typeName === "ZodRecord"; return schema.def.type === "record";
} }
function isZodEffects(schema: z.ZodTypeAny): schema is z.ZodEffects<any, any> { function isZodOptional(schema: z.ZodType): schema is z.ZodOptional<any> {
return schema._def.typeName === "ZodEffects"; return schema.def.type === "optional";
} }
function isZodOptional(schema: z.ZodTypeAny): schema is z.ZodOptional<any> { function isZodArray(schema: z.ZodType): schema is z.ZodArray<any> {
return schema._def.typeName === "ZodOptional"; return schema.def.type === "array";
} }
function isZodArray(schema: z.ZodTypeAny): schema is z.ZodArray<any> { function isZodUnion(schema: z.ZodType): schema is z.ZodUnion<any> {
return schema._def.typeName === "ZodArray"; return schema.def.type === "union";
} }
function isZodUnion(schema: z.ZodTypeAny): schema is z.ZodUnion<any> { function isZodNullable(schema: z.ZodType): schema is z.ZodNullable<any> {
return schema._def.typeName === "ZodUnion"; return schema.def.type === "nullable";
} }
function isZodNullable(schema: z.ZodTypeAny): schema is z.ZodNullable<any> { function isZodDefault(schema: z.ZodType): schema is z.ZodDefault<any> {
return schema._def.typeName === "ZodNullable"; return schema.def.type === "default";
} }
function isZodDefault(schema: z.ZodTypeAny): schema is z.ZodDefault<any> { function isZodLiteral(schema: z.ZodType): schema is z.ZodLiteral<any> {
return schema._def.typeName === "ZodDefault"; return schema.def.type === "literal";
} }
function isZodLiteral(schema: z.ZodTypeAny): schema is z.ZodLiteral<any> { function isZodIntersection(schema: z.ZodType): schema is z.ZodIntersection<any, any> {
return schema._def.typeName === "ZodLiteral"; return schema.def.type === "intersection";
} }
function isZodIntersection(schema: z.ZodTypeAny): schema is z.ZodIntersection<any, any> { function formatZodConfigSchema(schema: z.ZodType) {
return schema._def.typeName === "ZodIntersection";
}
function formatZodConfigSchema(schema: z.ZodTypeAny) {
if (isZodObject(schema)) { if (isZodObject(schema)) {
return ( return (
`{\n` + `{\n` +
Object.entries(schema._def.shape()) Object.entries(schema.def.shape)
.map(([k, value]) => indentLines(`${k}: ${formatZodConfigSchema(value as z.ZodTypeAny)}`, 2)) .map(([k, value]) => indentLines(`${k}: ${formatZodConfigSchema(value as z.ZodType)}`, 2))
.join("\n") + .join("\n") +
"\n}" "\n}"
); );
} }
if (isZodRecord(schema)) { if (isZodRecord(schema)) {
return "{\n" + indentLines(`[string]: ${formatZodConfigSchema(schema._def.valueType)}`, 2) + "\n}"; return "{\n" + indentLines(`[string]: ${formatZodConfigSchema(schema.valueType as z.ZodType)}`, 2) + "\n}";
}
if (isZodEffects(schema)) {
return formatZodConfigSchema(schema._def.schema);
} }
if (isZodOptional(schema)) { if (isZodOptional(schema)) {
return `Optional<${formatZodConfigSchema(schema._def.innerType)}>`; return `Optional<${formatZodConfigSchema(schema.def.innerType)}>`;
} }
if (isZodArray(schema)) { if (isZodArray(schema)) {
return `Array<${formatZodConfigSchema(schema._def.type)}>`; return `Array<${formatZodConfigSchema(schema.def.element)}>`;
} }
if (isZodUnion(schema)) { if (isZodUnion(schema)) {
return schema._def.options.map((t) => formatZodConfigSchema(t)).join(" | "); return schema.def.options.map((t) => formatZodConfigSchema(t)).join(" | ");
} }
if (isZodNullable(schema)) { if (isZodNullable(schema)) {
return `Nullable<${formatZodConfigSchema(schema._def.innerType)}>`; return `Nullable<${formatZodConfigSchema(schema.def.innerType)}>`;
} }
if (isZodDefault(schema)) { if (isZodDefault(schema)) {
return formatZodConfigSchema(schema._def.innerType); return formatZodConfigSchema(schema.def.innerType);
} }
if (isZodLiteral(schema)) { if (isZodLiteral(schema)) {
return schema._def.value; return schema.def.values;
} }
if (isZodIntersection(schema)) { 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"; return "string";
} }
if (schema._def.typeName === "ZodNumber") { if (schema.def.type === "number") {
return "number"; return "number";
} }
if (schema._def.typeName === "ZodBoolean") { if (schema.def.type === "boolean") {
return "boolean"; return "boolean";
} }
if (schema._def.typeName === "ZodNever") { if (schema.def.type === "never") {
return "never"; return "never";
} }
if (schema.def.type === "pipe") {
return formatZodConfigSchema((schema.def as $ZodPipeDef).in as z.ZodType);
}
return "unknown"; return "unknown";
} }
const availableGuildPluginsByName = availableGuildPlugins.reduce<Record<string, ZeppelinGuildPluginInfo>>(
(map, obj) => {
map[obj.plugin.name] = obj;
return map;
},
{},
);
export function initDocs(router: express.Router) { 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) => { router.get("/docs/plugins", (req: express.Request, res: express.Response) => {
res.json( res.json(
docsPluginNames.map((pluginName) => { docsPlugins.map((obj) => ({
const info = guildPluginInfo[pluginName]; name: obj.plugin.name,
const thinInfo = info ? { prettyName: info.prettyName, legacy: info.legacy ?? false } : {}; info: {
return { prettyName: obj.docs.prettyName,
name: pluginName, type: obj.docs.type,
info: thinInfo, },
}; })),
}),
); );
}); });
router.get("/docs/plugins/:pluginName", (req: express.Request, res: express.Response) => { router.get("/docs/plugins/:pluginName", (req: express.Request, res: express.Response) => {
const name = req.params.pluginName; const pluginInfo = availableGuildPluginsByName[req.params.pluginName];
const baseInfo = guildPluginInfo[name]; if (!pluginInfo) {
if (!baseInfo) {
return notFound(res); return notFound(res);
} }
const plugin = guildPlugins.find((p) => p.name === name)!; const { configSchema, ...info } = pluginInfo.docs;
const { configSchema, ...info } = baseInfo;
const formattedConfigSchema = formatZodConfigSchema(configSchema); const formattedConfigSchema = formatZodConfigSchema(configSchema);
const messageCommands = (plugin.messageCommands || []).map((cmd) => ({ const messageCommands = (pluginInfo.plugin.messageCommands || []).map((cmd) => ({
trigger: cmd.trigger, trigger: cmd.trigger,
permission: cmd.permission, permission: cmd.permission,
signature: cmd.signature, signature: cmd.signature,
@ -133,10 +138,10 @@ export function initDocs(router: express.Router) {
config: cmd.config, config: cmd.config,
})); }));
const defaultOptions = plugin.defaultOptions || {}; const defaultOptions = pluginInfo.docs.configSchema.safeParse({}).data ?? {};
res.json({ res.json({
name, name: pluginInfo.plugin.name,
info, info,
configSchema: formattedConfigSchema, configSchema: formattedConfigSchema,
defaultOptions, defaultOptions,

View file

@ -1,7 +1,7 @@
import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js";
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { z } from "zod"; import { z } from "zod/v4";
import { GuildCases } from "../../data/GuildCases.js"; import { GuildCases } from "../../data/GuildCases.js";
import { Case } from "../../data/entities/Case.js"; import { Case } from "../../data/entities/Case.js";
import { MINUTES } from "../../utils.js"; import { MINUTES } from "../../utils.js";

View file

@ -1,6 +1,6 @@
import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js";
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import jsYaml from "js-yaml"; import { YAMLException } from "js-yaml";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { Queue } from "../../Queue.js"; import { Queue } from "../../Queue.js";
import { validateGuildConfig } from "../../configValidator.js"; import { validateGuildConfig } from "../../configValidator.js";
@ -15,8 +15,6 @@ import { ObjectAliasError } from "../../utils/validateNoObjectAliases.js";
import { hasGuildPermission, requireGuildPermission } from "../permissions.js"; import { hasGuildPermission, requireGuildPermission } from "../permissions.js";
import { clientError, ok, serverError, unauthorized } from "../responses.js"; import { clientError, ok, serverError, unauthorized } from "../responses.js";
const YAMLException = jsYaml.YAMLException;
const apiPermissionAssignments = new ApiPermissionAssignments(); const apiPermissionAssignments = new ApiPermissionAssignments();
const auditLog = new ApiAuditLog(); const auditLog = new ApiAuditLog();

View file

@ -28,10 +28,10 @@ app.use(multer().none());
const rootRouter = express.Router(); const rootRouter = express.Router();
initAuth(app); initAuth(rootRouter);
initGuildsAPI(app); initGuildsAPI(rootRouter);
initArchives(app); initArchives(rootRouter);
initDocs(app); initDocs(rootRouter);
// Default route // Default route
rootRouter.get("/", (req, res) => { rootRouter.get("/", (req, res) => {

View file

@ -1,13 +1,12 @@
import { ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from "knub"; import { BaseConfig, ConfigValidationError, GuildPluginBlueprint, PluginConfigManager } from "knub";
import moment from "moment-timezone"; import { z, ZodError } from "zod/v4";
import { ZodError } from "zod"; import { availableGuildPlugins } from "./plugins/availablePlugins.js";
import { guildPlugins } from "./plugins/availablePlugins.js"; import { zZeppelinGuildConfig } from "./types.js";
import { ZeppelinGuildConfig, zZeppelinGuildConfig } from "./types.js";
import { formatZodIssue } from "./utils/formatZodIssue.js"; import { formatZodIssue } from "./utils/formatZodIssue.js";
const pluginNameToPlugin = new Map<string, GuildPluginBlueprint<any, any>>(); const pluginNameToPlugin = new Map<string, GuildPluginBlueprint<any, any>>();
for (const plugin of guildPlugins) { for (const pluginInfo of availableGuildPlugins) {
pluginNameToPlugin.set(plugin.name, plugin); pluginNameToPlugin.set(pluginInfo.plugin.name, pluginInfo.plugin);
} }
export async function validateGuildConfig(config: any): Promise<string | null> { export async function validateGuildConfig(config: any): Promise<string | null> {
@ -16,14 +15,7 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
return validationResult.error.issues.map(formatZodIssue).join("\n"); return validationResult.error.issues.map(formatZodIssue).join("\n");
} }
const guildConfig = config as ZeppelinGuildConfig; const guildConfig = config as BaseConfig;
if (guildConfig.timezone) {
const validTimezones = moment.tz.names();
if (!validTimezones.includes(guildConfig.timezone)) {
return `Invalid timezone: ${guildConfig.timezone}`;
}
}
if (guildConfig.plugins) { if (guildConfig.plugins) {
for (const [pluginName, pluginOptions] of Object.entries(guildConfig.plugins)) { for (const [pluginName, pluginOptions] of Object.entries(guildConfig.plugins)) {
@ -36,15 +28,21 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
} }
const plugin = pluginNameToPlugin.get(pluginName)!; const plugin = pluginNameToPlugin.get(pluginName)!;
const configManager = new PluginConfigManager(plugin.defaultOptions || { config: {} }, pluginOptions, { const configManager = new PluginConfigManager(
levels: {}, pluginOptions,
parser: plugin.configParser, {
}); configSchema: plugin.configSchema,
defaultOverrides: plugin.defaultOverrides ?? [],
levels: {},
customOverrideCriteriaFunctions: plugin.customOverrideCriteriaFunctions,
},
);
try { try {
await configManager.init(); await configManager.init();
} catch (err) { } catch (err) {
if (err instanceof ZodError) { if (err instanceof ZodError) {
return `${pluginName}: ${err.issues.map(formatZodIssue).join("\n")}`; return `${pluginName}:\n${z.prettifyError(err)}`;
} }
if (err instanceof ConfigValidationError) { if (err instanceof ConfigValidationError) {
return `${pluginName}: ${err.message}`; return `${pluginName}: ${err.message}`;

View file

@ -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<typeof zDomain>;
const FULL_REFRESH_INTERVAL = 6 * HOURS;
const domains = new Map<string, FishFishDomain>();
let sessionTokenPromise: Promise<string> | 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<string> {
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<string, string> = {}): Promise<unknown> {
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<void> {
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());
}

View file

@ -1,4 +1,4 @@
import { In, InsertResult, Repository } from "typeorm"; import { FindOptionsWhere, In, InsertResult, Repository } from "typeorm";
import { Queue } from "../Queue.js"; import { Queue } from "../Queue.js";
import { chunkArray } from "../utils.js"; import { chunkArray } from "../utils.js";
import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js";
@ -73,34 +73,69 @@ export class GuildCases extends BaseGuildRepository {
}); });
} }
async getByUserId(userId: string): Promise<Case[]> { async getByUserId(
userId: string,
filters: Omit<FindOptionsWhere<Case>, "guild_id" | "user_id"> = {},
): Promise<Case[]> {
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<Case[]> {
return this.cases.find({ return this.cases.find({
relations: this.getRelations(), relations: this.getRelations(),
where: { where: {
guild_id: this.guildId, guild_id: this.guildId,
user_id: userId, user_id: userId,
}, },
skip,
take: count,
order: {
case_number: "DESC",
},
}); });
} }
async getTotalCasesByModId(modId: string): Promise<number> { async getTotalCasesByModId(
modId: string,
filters: Omit<FindOptionsWhere<Case>, "guild_id" | "mod_id" | "is_hidden"> = {},
): Promise<number> {
return this.cases.count({ return this.cases.count({
where: { where: {
guild_id: this.guildId, guild_id: this.guildId,
mod_id: modId, mod_id: modId,
is_hidden: false, is_hidden: false,
...filters,
}, },
}); });
} }
async getRecentByModId(modId: string, count: number, skip = 0): Promise<Case[]> { async getRecentByModId(
modId: string,
count: number,
skip = 0,
filters: Omit<FindOptionsWhere<Case>, "guild_id" | "mod_id"> = {},
): Promise<Case[]> {
const where: FindOptionsWhere<Case> = {
guild_id: this.guildId,
mod_id: modId,
is_hidden: false,
...filters,
};
if (where.is_hidden === true) {
delete where.is_hidden;
}
return this.cases.find({ return this.cases.find({
relations: this.getRelations(), relations: this.getRelations(),
where: { where,
guild_id: this.guildId,
mod_id: modId,
is_hidden: false,
},
skip, skip,
take: count, take: count,
order: { order: {

View file

@ -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<string, Map<string, number[]>> = new Map();
const pendingApiRequests: Map<string, Promise<unknown>> = new Map();
const pendingDomainInfoChecks: Map<string, Promise<PhishermanDomainInfo | null>> = new Map();
type MemoryCacheEntry = {
info: PhishermanDomainInfo | null;
expires: number;
};
const memoryCache: Map<string, MemoryCacheEntry> = 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<PhishermanCacheEntry> | null = null;
function getCacheRepository(): Repository<PhishermanCacheEntry> {
if (cacheRepository == null) {
cacheRepository = dataSource.getRepository(PhishermanCacheEntry);
}
return cacheRepository;
}
let keyCacheRepository: Repository<PhishermanKeyCacheEntry> | null = null;
function getKeyCacheRepository(): Repository<PhishermanKeyCacheEntry> {
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<T>(
method: "GET" | "POST",
resource: string,
payload?: Record<string, unknown> | null,
): Promise<T> {
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<T>;
}
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<T>;
}
type DomainInfoApiCallResult = PhishermanUnknownDomain | PhishermanDomainInfo;
async function fetchDomainInfo(domain: string): Promise<PhishermanDomainInfo | null> {
// tslint:disable-next-line:no-console
console.log(`[PHISHERMAN] Requesting domain information: ${domain}`);
const result = await apiCall<Record<string, DomainInfoApiCallResult>>("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<PhishermanDomainInfo | null> {
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<boolean> {
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();
}

View file

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

View file

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

View file

@ -16,7 +16,7 @@ function muteToKey(mute: Mute) {
return `${mute.guild_id}/${mute.user_id}`; 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<void> {
const mute = await getMutesRepository().findMute(guildId, userId); const mute = await getMutesRepository().findMute(guildId, userId);
if (!mute) { if (!mute) {
// Mute was already cleared // Mute was already cleared
@ -27,7 +27,7 @@ async function broadcastExpiredMute(guildId: string, userId: string, tries = 0)
return; 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 (!hasGuildEventListener(mute.guild_id, "expiredMute")) {
// If there are no listeners registered for the server yet, try again in a bit // If there are no listeners registered for the server yet, try again in a bit
if (tries < MAX_TRIES_PER_SERVER) { 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) { 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 (!hasGuildEventListener(mute.guild_id, "timeoutMuteToRenew")) {
// If there are no listeners registered for the server yet, try again in a bit // If there are no listeners registered for the server yet, try again in a bit
if (tries < MAX_TRIES_PER_SERVER) { if (tries < MAX_TRIES_PER_SERVER) {

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod/v4";
import { rootDir } from "./paths.js"; import { rootDir } from "./paths.js";
const envType = z.object({ const envType = z.object({
@ -37,6 +37,10 @@ const envType = z.object({
.optional(), .optional(),
PHISHERMAN_API_KEY: z.string().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_HOST: z.string().optional(),
DB_PORT: z.preprocess((v) => Number(v), z.number()).optional(), DB_PORT: z.preprocess((v) => Number(v), z.number()).optional(),

View file

@ -1,23 +1,91 @@
import { z } from "zod"; import fs from "node:fs";
import { zodToJsonSchema } from "zod-to-json-schema"; import { z } from "zod/v4";
import { guildPluginInfo } from "./plugins/pluginInfo.js"; import { availableGuildPlugins } from "./plugins/availablePlugins.js";
import { zZeppelinGuildConfig } from "./types.js"; import { zZeppelinGuildConfig } from "./types.js";
import { deepPartial } from "./utils/zodDeepPartial.js";
const pluginSchemaMap = Object.entries(guildPluginInfo).reduce((map, [pluginName, pluginInfo]) => { const basePluginOverrideCriteriaSchema = z.strictObject({
if (pluginInfo.configSchema) { channel: z
map[pluginName] = pluginInfo.configSchema; .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<any, z.ZodType>();
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; return map;
}, {}); }, {});
const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).merge( const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).extend({
z.strictObject({ plugins: z.strictObject(pluginSchemaMap).partial().optional(),
plugins: z.strictObject(pluginSchemaMap).partial(), });
}),
);
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); process.exit(0);

View file

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

View file

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

View file

@ -12,7 +12,6 @@ import {
TextChannel, TextChannel,
ThreadChannel, ThreadChannel,
} from "discord.js"; } from "discord.js";
import { EventEmitter } from "events";
import { Knub, PluginError, PluginLoadError, PluginNotLoadedError } from "knub"; import { Knub, PluginError, PluginLoadError, PluginNotLoadedError } from "knub";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { performance } from "perf_hooks"; import { performance } from "perf_hooks";
@ -22,9 +21,9 @@ import { RecoverablePluginError } from "./RecoverablePluginError.js";
import { SimpleError } from "./SimpleError.js"; import { SimpleError } from "./SimpleError.js";
import { AllowedGuilds } from "./data/AllowedGuilds.js"; import { AllowedGuilds } from "./data/AllowedGuilds.js";
import { Configs } from "./data/Configs.js"; import { Configs } from "./data/Configs.js";
import { FishFishError, initFishFish } from "./data/FishFish.js";
import { GuildLogs } from "./data/GuildLogs.js"; import { GuildLogs } from "./data/GuildLogs.js";
import { LogType } from "./data/LogType.js"; import { LogType } from "./data/LogType.js";
import { hasPhishermanMasterAPIKey } from "./data/Phisherman.js";
import { dataSource } from "./data/dataSource.js"; import { dataSource } from "./data/dataSource.js";
import { connect } from "./data/db.js"; import { connect } from "./data/db.js";
import { runExpiredArchiveDeletionLoop } from "./data/loops/expiredArchiveDeletionLoop.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 { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop.js";
import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop.js"; import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop.js";
import { runMemberCacheDeletionLoop } from "./data/loops/memberCacheDeletionLoop.js"; import { runMemberCacheDeletionLoop } from "./data/loops/memberCacheDeletionLoop.js";
import { runPhishermanCacheCleanupLoop, runPhishermanReportingLoop } from "./data/loops/phishermanLoops.js";
import { runSavedMessageCleanupLoop } from "./data/loops/savedMessageCleanupLoop.js"; import { runSavedMessageCleanupLoop } from "./data/loops/savedMessageCleanupLoop.js";
import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop.js"; import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop.js";
import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop.js"; import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop.js";
import { consumeQueryStats } from "./data/queryLogger.js"; import { consumeQueryStats } from "./data/queryLogger.js";
import { env } from "./env.js"; import { env } from "./env.js";
import { logger } from "./logger.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 { setProfiler } from "./profiler.js";
import { logRateLimit } from "./rateLimitStats.js"; import { logRateLimit } from "./rateLimitStats.js";
import { startUptimeCounter } from "./uptime.js"; import { startUptimeCounter } from "./uptime.js";
@ -143,6 +141,12 @@ function errorHandler(err) {
return; 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 // tslint:disable:no-console
console.error(err); console.error(err);
@ -166,10 +170,8 @@ function errorHandler(err) {
// tslint:enable:no-console // tslint:enable:no-console
} }
if (process.env.NODE_ENV === "production") { process.on("uncaughtException", errorHandler);
process.on("uncaughtException", errorHandler); process.on("unhandledRejection", errorHandler);
process.on("unhandledRejection", errorHandler);
}
// Verify required Node.js version // Verify required Node.js version
const REQUIRED_NODE_VERSION = "16.9.0"; const REQUIRED_NODE_VERSION = "16.9.0";
@ -252,8 +254,8 @@ connect().then(async () => {
GatewayIntentBits.GuildVoiceStates, 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 safe429DecayInterval = 5 * SECONDS;
const safe429MaxCount = 5; const safe429MaxCount = 5;
@ -275,6 +277,10 @@ connect().then(async () => {
}); });
client.on("error", (err) => { client.on("error", (err) => {
if (err instanceof PluginLoadError) {
errorHandler(err);
return;
}
errorHandler(new DiscordJSError(err.message, (err as any).code, 0)); errorHandler(new DiscordJSError(err.message, (err as any).code, 0));
}); });
@ -282,8 +288,8 @@ connect().then(async () => {
const guildConfigs = new Configs(); const guildConfigs = new Configs();
const bot = new Knub(client, { const bot = new Knub(client, {
guildPlugins, guildPlugins: availableGuildPlugins.map((obj) => obj.plugin),
globalPlugins, globalPlugins: availableGlobalPlugins.map((obj) => obj.plugin),
options: { options: {
canLoadGuild(guildId): Promise<boolean> { canLoadGuild(guildId): Promise<boolean> {
@ -292,7 +298,7 @@ connect().then(async () => {
/** /**
* Plugins are enabled if they... * 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 * - are explicitly enabled in the guild config
* Dependencies are also automatically loaded by Knub. * Dependencies are also automatically loaded by Knub.
*/ */
@ -302,10 +308,10 @@ connect().then(async () => {
} }
const configuredPlugins = ctx.config.plugins; 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) => { 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; return configuredPlugins[pluginName] && (configuredPlugins[pluginName] as any).enabled !== false;
}); });
}, },
@ -323,12 +329,30 @@ connect().then(async () => {
if (row) { if (row) {
try { try {
const loaded = loadYamlSafely(row.config); 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 // Remove deprecated properties some may still have in their config
delete loaded.success_emoji; delete loaded.success_emoji;
delete loaded.error_emoji; delete loaded.error_emoji;
return loaded; return loaded;
} catch (err) { } catch (err) {
logger.error(`Error while loading config "${key}": ${err.message}`); logger.error(`Error while loading config "${key}"`);
return {}; return {};
} }
} }
@ -385,6 +409,8 @@ connect().then(async () => {
enableProfiling(); enableProfiling();
} }
initFishFish();
runExpiringMutesLoop(); runExpiringMutesLoop();
await sleep(10 * SECONDS); await sleep(10 * SECONDS);
runExpiringTempbansLoop(); runExpiringTempbansLoop();
@ -402,13 +428,6 @@ connect().then(async () => {
runExpiredMemberCacheDeletionLoop(); runExpiredMemberCacheDeletionLoop();
await sleep(10 * SECONDS); await sleep(10 * SECONDS);
runMemberCacheDeletionLoop(); runMemberCacheDeletionLoop();
if (hasPhishermanMasterAPIKey()) {
await sleep(10 * SECONDS);
runPhishermanCacheCleanupLoop();
await sleep(10 * SECONDS);
runPhishermanReportingLoop();
}
}); });
let lowestGlobalRemaining = Infinity; let lowestGlobalRemaining = Infinity;

View file

@ -9,7 +9,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
const migratedUsernames = new Set(); const migratedUsernames = new Set();
await new Promise(async (resolve) => { await new Promise<void>(async (resolve) => {
const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history"); const stream = await queryRunner.stream("SELECT CONCAT(user_id, '-', username) AS `key` FROM username_history");
stream.on("data", (row: any) => { stream.on("data", (row: any) => {
migratedUsernames.add(row.key); migratedUsernames.add(row.key);

View file

@ -3,18 +3,35 @@
*/ */
import { import {
BitField,
BitFieldResolvable,
ChatInputCommandInteraction,
CommandInteraction,
GuildMember, GuildMember,
InteractionEditReplyOptions,
InteractionReplyOptions,
InteractionResponse,
Message, Message,
MessageCreateOptions, MessageCreateOptions,
MessageMentionOptions, MessageEditOptions,
MessageFlags,
MessageFlagsString,
ModalSubmitInteraction,
PermissionsBitField, PermissionsBitField,
TextBasedChannel, TextBasedChannel,
} from "discord.js"; } from "discord.js";
import { AnyPluginData, BasePluginData, CommandContext, ExtendedMatchParams, GuildPluginData, helpers } from "knub"; import {
import { logger } from "./logger.js"; AnyPluginData,
BasePluginData,
CommandContext,
ExtendedMatchParams,
GuildPluginData,
helpers,
PluginConfigManager,
} from "knub";
import z from "zod/v4";
import { isStaff } from "./staff.js"; import { isStaff } from "./staff.js";
import { TZeppelinKnub } from "./types.js"; import { TZeppelinKnub } from "./types.js";
import { errorMessage, successMessage } from "./utils.js";
import { Tail } from "./utils/typeUtils.js"; import { Tail } from "./utils/typeUtils.js";
const { getMemberLevel } = helpers; const { getMemberLevel } = helpers;
@ -49,46 +66,118 @@ export async function hasPermission(
return helpers.hasPermission(config, permission); return helpers.hasPermission(config, permission);
} }
export async function sendSuccessMessage( export type GenericCommandSource = Message | CommandInteraction | ModalSubmitInteraction;
pluginData: AnyPluginData<any>,
channel: TextBasedChannel,
body: string,
allowedMentions?: MessageMentionOptions,
): Promise<Message | undefined> {
const emoji = pluginData.fullConfig.success_emoji || undefined;
const formattedBody = successMessage(body, emoji);
const content: MessageCreateOptions = allowedMentions
? { content: formattedBody, allowedMentions }
: { content: formattedBody };
return channel export function isContextInteraction(
.send({ ...content }) // Force line break context: GenericCommandSource,
.catch((err) => { ): context is CommandInteraction | ModalSubmitInteraction {
const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id; return context instanceof CommandInteraction || context instanceof ModalSubmitInteraction;
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined;
});
} }
export async function sendErrorMessage( export function isContextMessage(context: GenericCommandSource): context is Message {
pluginData: AnyPluginData<any>, return context instanceof Message;
channel: TextBasedChannel, }
body: string,
allowedMentions?: MessageMentionOptions,
): Promise<Message | undefined> {
const emoji = pluginData.fullConfig.error_emoji || undefined;
const formattedBody = errorMessage(body, emoji);
const content: MessageCreateOptions = allowedMentions
? { content: formattedBody, allowedMentions }
: { content: formattedBody };
return channel export async function getContextChannel(context: GenericCommandSource): Promise<TextBasedChannel | null> {
.send({ ...content }) // Force line break if (isContextInteraction(context)) {
.catch((err) => { return context.channel;
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}`); if (context instanceof Message) {
return undefined; 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<TFlags extends string, TType extends number | bigint>(
flags: BitFieldResolvable<TFlags, any>,
ephemeral: boolean,
): BitFieldResolvable<TFlags | Extract<MessageFlagsString, "Ephemeral">, 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<Message> {
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<ContextResponse> {
return response.edit(content);
}
export async function deleteContextResponse(response: ContextResponse): Promise<void> {
await response.delete();
}
export async function getConfigForContext<TPluginData extends BasePluginData<any>>(
config: PluginConfigManager<TPluginData>,
context: GenericCommandSource,
): Promise<z.output<TPluginData["_pluginType"]["configSchema"]>> {
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<any>) { export function getBaseUrl(pluginData: AnyPluginData<any>) {
@ -136,4 +225,6 @@ export function makePublicFn<TPluginData extends BasePluginData<any>, T extends
}; };
} }
// ??? export function resolveMessageMember(message: Message<true>) {
return Promise.resolve(message.member || message.guild.members.fetch(message.author.id));
}

View file

@ -1,4 +1,4 @@
import { PluginOptions, guildPlugin } from "knub"; import { guildPlugin } from "knub";
import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildLogs } from "../../data/GuildLogs.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js";
@ -8,19 +8,11 @@ import { onMessageCreate } from "./util/onMessageCreate.js";
import { onMessageDelete } from "./util/onMessageDelete.js"; import { onMessageDelete } from "./util/onMessageDelete.js";
import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk.js"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk.js";
const defaultOptions: PluginOptions<AutoDeletePluginType> = {
config: {
enabled: false,
delay: "5s",
},
};
export const AutoDeletePlugin = guildPlugin<AutoDeletePluginType>()({ export const AutoDeletePlugin = guildPlugin<AutoDeletePluginType>()({
name: "auto_delete", name: "auto_delete",
dependencies: () => [TimeAndDatePlugin, LogsPlugin], dependencies: () => [TimeAndDatePlugin, LogsPlugin],
configParser: (input) => zAutoDeleteConfig.parse(input), configSchema: zAutoDeleteConfig,
defaultOptions,
beforeLoad(pluginData) { beforeLoad(pluginData) {
const { state, guild } = pluginData; const { state, guild } = pluginData;

View file

@ -1,10 +1,11 @@
import { ZeppelinPluginInfo } from "../../types.js"; import { ZeppelinPluginDocs } from "../../types.js";
import { zAutoDeleteConfig } from "./types.js"; import { zAutoDeleteConfig } from "./types.js";
export const autoDeletePluginInfo: ZeppelinPluginInfo = { export const autoDeletePluginDocs: ZeppelinPluginDocs = {
showInDocs: true, type: "stable",
configSchema: zAutoDeleteConfig,
prettyName: "Auto-delete", prettyName: "Auto-delete",
description: "Allows Zeppelin to auto-delete messages from a channel after a delay", description: "Allows Zeppelin to auto-delete messages from a channel after a delay",
configurationGuide: "Maximum deletion delay is currently 5 minutes", configurationGuide: "Maximum deletion delay is currently 5 minutes",
configSchema: zAutoDeleteConfig,
}; };

View file

@ -1,5 +1,5 @@
import { BasePluginType } from "knub"; import { BasePluginType } from "knub";
import z from "zod"; import z from "zod/v4";
import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildLogs } from "../../data/GuildLogs.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { SavedMessage } from "../../data/entities/SavedMessage.js"; import { SavedMessage } from "../../data/entities/SavedMessage.js";
@ -14,12 +14,12 @@ export interface IDeletionQueueItem {
} }
export const zAutoDeleteConfig = z.strictObject({ export const zAutoDeleteConfig = z.strictObject({
enabled: z.boolean(), enabled: z.boolean().default(false),
delay: zDelayString, delay: zDelayString.default("5s"),
}); });
export interface AutoDeletePluginType extends BasePluginType { export interface AutoDeletePluginType extends BasePluginType {
config: z.output<typeof zAutoDeleteConfig>; configSchema: typeof zAutoDeleteConfig;
state: { state: {
guildSavedMessages: GuildSavedMessages; guildSavedMessages: GuildSavedMessages;
guildLogs: GuildLogs; guildLogs: GuildLogs;

View file

@ -1,4 +1,4 @@
import { ChannelType, PermissionsBitField, Snowflake } from "discord.js"; import { PermissionsBitField, Snowflake } from "discord.js";
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { LogType } from "../../../data/LogType.js"; import { LogType } from "../../../data/LogType.js";
@ -17,8 +17,8 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
scheduleNextDeletion(pluginData); scheduleNextDeletion(pluginData);
const channel = pluginData.guild.channels.cache.get(itemToDelete.message.channel_id as Snowflake); const channel = pluginData.guild.channels.cache.get(itemToDelete.message.channel_id as Snowflake);
if (!channel || channel.type === ChannelType.GuildCategory) { if (!channel || !("messages" in channel)) {
// Channel was deleted, ignore // Channel does not exist or does not support messages, ignore
return; return;
} }

View file

@ -1,25 +1,21 @@
import { PluginOptions, guildPlugin } from "knub"; import { PluginOverride, guildPlugin } from "knub";
import { GuildAutoReactions } from "../../data/GuildAutoReactions.js"; import { GuildAutoReactions } from "../../data/GuildAutoReactions.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js";
import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd.js"; import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd.js";
import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd.js"; import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd.js";
import { AddReactionsEvt } from "./events/AddReactionsEvt.js"; import { AddReactionsEvt } from "./events/AddReactionsEvt.js";
import { AutoReactionsPluginType, zAutoReactionsConfig } from "./types.js"; import { AutoReactionsPluginType, zAutoReactionsConfig } from "./types.js";
const defaultOptions: PluginOptions<AutoReactionsPluginType> = { const defaultOverrides: Array<PluginOverride<AutoReactionsPluginType>> = [
config: { {
can_manage: false, level: ">=100",
}, config: {
overrides: [ can_manage: true,
{
level: ">=100",
config: {
can_manage: true,
},
}, },
], },
}; ];
export const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({ export const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({
name: "auto_reactions", name: "auto_reactions",
@ -29,8 +25,8 @@ export const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({
LogsPlugin, LogsPlugin,
], ],
configParser: (input) => zAutoReactionsConfig.parse(input), configSchema: zAutoReactionsConfig,
defaultOptions, defaultOverrides,
// prettier-ignore // prettier-ignore
messageCommands: [ messageCommands: [
@ -50,4 +46,8 @@ export const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({
state.autoReactions = GuildAutoReactions.getGuildInstance(guild.id); state.autoReactions = GuildAutoReactions.getGuildInstance(guild.id);
state.cache = new Map(); state.cache = new Map();
}, },
beforeStart(pluginData) {
pluginData.state.common = pluginData.getPlugin(CommonPlugin);
},
}); });

View file

@ -1,5 +1,4 @@
import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { autoReactionsCmd } from "../types.js"; import { autoReactionsCmd } from "../types.js";
export const DisableAutoReactionsCmd = autoReactionsCmd({ export const DisableAutoReactionsCmd = autoReactionsCmd({
@ -14,12 +13,12 @@ export const DisableAutoReactionsCmd = autoReactionsCmd({
async run({ message: msg, args, pluginData }) { async run({ message: msg, args, pluginData }) {
const autoReaction = await pluginData.state.autoReactions.getForChannel(args.channelId); const autoReaction = await pluginData.state.autoReactions.getForChannel(args.channelId);
if (!autoReaction) { 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; return;
} }
await pluginData.state.autoReactions.removeFromChannel(args.channelId); await pluginData.state.autoReactions.removeFromChannel(args.channelId);
pluginData.state.cache.delete(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}>`);
}, },
}); });

View file

@ -1,6 +1,5 @@
import { PermissionsBitField } from "discord.js"; import { PermissionsBitField } from "discord.js";
import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { canUseEmoji, customEmojiRegex, isEmoji } from "../../../utils.js"; import { canUseEmoji, customEmojiRegex, isEmoji } from "../../../utils.js";
import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js"; import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js";
import { missingPermissionError } from "../../../utils/missingPermissionError.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 me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
const missingPermissions = getMissingChannelPermissions(me, args.channel, requiredPermissions); const missingPermissions = getMissingChannelPermissions(me, args.channel, requiredPermissions);
if (missingPermissions) { if (missingPermissions) {
sendErrorMessage( pluginData.state.common.sendErrorMessage(
pluginData, msg,
msg.channel,
`Cannot set auto-reactions for that channel. ${missingPermissionError(missingPermissions)}`, `Cannot set auto-reactions for that channel. ${missingPermissionError(missingPermissions)}`,
); );
return; return;
@ -35,7 +33,7 @@ export const NewAutoReactionsCmd = autoReactionsCmd({
for (const reaction of args.reactions) { for (const reaction of args.reactions) {
if (!isEmoji(reaction)) { 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; return;
} }
@ -45,7 +43,10 @@ export const NewAutoReactionsCmd = autoReactionsCmd({
if (customEmojiMatch) { if (customEmojiMatch) {
// Custom emoji // Custom emoji
if (!canUseEmoji(pluginData.client, customEmojiMatch[2])) { 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; return;
} }
@ -60,6 +61,6 @@ export const NewAutoReactionsCmd = autoReactionsCmd({
await pluginData.state.autoReactions.set(args.channel.id, finalReactions); await pluginData.state.autoReactions.set(args.channel.id, finalReactions);
pluginData.state.cache.delete(args.channel.id); 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}>`);
}, },
}); });

View file

@ -1,12 +1,13 @@
import { ZeppelinPluginInfo } from "../../types.js"; import { ZeppelinPluginDocs } from "../../types.js";
import { trimPluginDescription } from "../../utils.js"; import { trimPluginDescription } from "../../utils.js";
import { zAutoReactionsConfig } from "./types.js"; import { zAutoReactionsConfig } from "./types.js";
export const autoReactionsInfo: ZeppelinPluginInfo = { export const autoReactionsPluginDocs: ZeppelinPluginDocs = {
showInDocs: true, type: "stable",
configSchema: zAutoReactionsConfig,
prettyName: "Auto-reactions", prettyName: "Auto-reactions",
description: trimPluginDescription(` description: trimPluginDescription(`
Allows setting up automatic reactions to all new messages on a channel Allows setting up automatic reactions to all new messages on a channel
`), `),
configSchema: zAutoReactionsConfig,
}; };

View file

@ -1,21 +1,23 @@
import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub";
import z from "zod"; import z from "zod/v4";
import { GuildAutoReactions } from "../../data/GuildAutoReactions.js"; import { GuildAutoReactions } from "../../data/GuildAutoReactions.js";
import { GuildLogs } from "../../data/GuildLogs.js"; import { GuildLogs } from "../../data/GuildLogs.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { AutoReaction } from "../../data/entities/AutoReaction.js"; import { AutoReaction } from "../../data/entities/AutoReaction.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
export const zAutoReactionsConfig = z.strictObject({ export const zAutoReactionsConfig = z.strictObject({
can_manage: z.boolean(), can_manage: z.boolean().default(false),
}); });
export interface AutoReactionsPluginType extends BasePluginType { export interface AutoReactionsPluginType extends BasePluginType {
config: z.output<typeof zAutoReactionsConfig>; configSchema: typeof zAutoReactionsConfig;
state: { state: {
logs: GuildLogs; logs: GuildLogs;
savedMessages: GuildSavedMessages; savedMessages: GuildSavedMessages;
autoReactions: GuildAutoReactions; autoReactions: GuildAutoReactions;
cache: Map<string, AutoReaction | null>; cache: Map<string, AutoReaction | null>;
common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;
}; };
} }

View file

@ -8,6 +8,7 @@ import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners.js";
import { MINUTES, SECONDS } from "../../utils.js"; import { MINUTES, SECONDS } from "../../utils.js";
import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap.js"; import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap.js";
import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap.js"; import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
import { CountersPlugin } from "../Counters/CountersPlugin.js"; import { CountersPlugin } from "../Counters/CountersPlugin.js";
import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js";
import { LogsPlugin } from "../Logs/LogsPlugin.js"; import { LogsPlugin } from "../Logs/LogsPlugin.js";
@ -33,29 +34,6 @@ import { clearOldRecentActions } from "./functions/clearOldRecentActions.js";
import { clearOldRecentSpam } from "./functions/clearOldRecentSpam.js"; import { clearOldRecentSpam } from "./functions/clearOldRecentSpam.js";
import { AutomodPluginType, zAutomodConfig } from "./types.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<AutomodPluginType>()({ export const AutomodPlugin = guildPlugin<AutomodPluginType>()({
name: "automod", name: "automod",
@ -70,8 +48,7 @@ export const AutomodPlugin = guildPlugin<AutomodPluginType>()({
RoleManagerPlugin, RoleManagerPlugin,
], ],
defaultOptions, configSchema: zAutomodConfig,
configParser: (input) => zAutomodConfig.parse(input),
customOverrideCriteriaFunctions: { customOverrideCriteriaFunctions: {
antiraid_level: (pluginData, matchParams, value) => { antiraid_level: (pluginData, matchParams, value) => {
@ -117,6 +94,10 @@ export const AutomodPlugin = guildPlugin<AutomodPluginType>()({
state.cachedAntiraidLevel = await state.antiraidLevels.get(); state.cachedAntiraidLevel = await state.antiraidLevels.get();
}, },
beforeStart(pluginData) {
pluginData.state.common = pluginData.getPlugin(CommonPlugin);
},
async afterLoad(pluginData) { async afterLoad(pluginData) {
const { state } = pluginData; const { state } = pluginData;

View file

@ -1,5 +1,5 @@
import { PermissionFlagsBits, Snowflake } from "discord.js"; import { PermissionFlagsBits, Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { nonNullish, unique, zSnowflake } from "../../../utils.js"; import { nonNullish, unique, zSnowflake } from "../../../utils.js";
import { canAssignRole } from "../../../utils/canAssignRole.js"; import { canAssignRole } from "../../../utils/canAssignRole.js";
import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { zBoundedCharacters } from "../../../utils.js"; import { zBoundedCharacters } from "../../../utils.js";
import { CountersPlugin } from "../../Counters/CountersPlugin.js"; import { CountersPlugin } from "../../Counters/CountersPlugin.js";
import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js";

View file

@ -1,5 +1,5 @@
import { Snowflake } from "discord.js"; import { Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { LogType } from "../../../data/LogType.js"; import { LogType } from "../../../data/LogType.js";
import { import {
createTypedTemplateSafeValueContainer, createTypedTemplateSafeValueContainer,

View file

@ -1,5 +1,5 @@
import { AnyThreadChannel } from "discord.js"; import { AnyThreadChannel } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { noop } from "../../../utils.js"; import { noop } from "../../../utils.js";
import { automodAction } from "../helpers.js"; import { automodAction } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { import {
convertDelayStringToMS, convertDelayStringToMS,
nonNullish, nonNullish,
@ -47,6 +47,7 @@ export const BanAction = automodAction({
await modActions.banUserId( await modActions.banUserId(
userId, userId,
reason, reason,
reason,
{ {
contactMethods, contactMethods,
caseArgs, caseArgs,

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { nonNullish, unique, zBoundedCharacters } from "../../../utils.js"; import { nonNullish, unique, zBoundedCharacters } from "../../../utils.js";
import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js";
import { automodAction } from "../helpers.js"; import { automodAction } from "../helpers.js";

View file

@ -1,6 +1,6 @@
import { PermissionsBitField, PermissionsString } from "discord.js"; import { PermissionsBitField, PermissionsString } from "discord.js";
import { U } from "ts-toolbelt"; import { U } from "ts-toolbelt";
import z from "zod"; import z from "zod/v4";
import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js"; import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js";
import { isValidSnowflake, keys, noop, zBoundedCharacters } from "../../../utils.js"; import { isValidSnowflake, keys, noop, zBoundedCharacters } from "../../../utils.js";
import { import {
@ -65,11 +65,17 @@ const permissionNames = keys(PermissionsBitField.Flags) as U.ListOf<keyof typeof
const legacyPermissionNames = keys(legacyPermMap) as U.ListOf<keyof typeof legacyPermMap>; const legacyPermissionNames = keys(legacyPermMap) as U.ListOf<keyof typeof legacyPermMap>;
const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const; const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const;
const permissionTypeMap = allPermissionNames.reduce((map, permName) => {
map[permName] = z.boolean().nullable();
return map;
}, {} as Record<typeof allPermissionNames[number], z.ZodNullable<z.ZodBoolean>>);
const zPermissionsMap = z.strictObject(permissionTypeMap);
export const ChangePermsAction = automodAction({ export const ChangePermsAction = automodAction({
configSchema: z.strictObject({ configSchema: z.strictObject({
target: zBoundedCharacters(1, 2000), target: zBoundedCharacters(1, 2000),
channel: zBoundedCharacters(1, 2000).nullable().default(null), 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 }) { async apply({ pluginData, contexts, actionConfig, ruleName }) {

View file

@ -1,5 +1,5 @@
import { GuildTextBasedChannel, Snowflake } from "discord.js"; import { GuildTextBasedChannel, Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { LogType } from "../../../data/LogType.js"; import { LogType } from "../../../data/LogType.js";
import { noop } from "../../../utils.js"; import { noop } from "../../../utils.js";
import { automodAction } from "../helpers.js"; import { automodAction } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { zBoundedCharacters } from "../../../utils.js"; import { zBoundedCharacters } from "../../../utils.js";
import { automodAction } from "../helpers.js"; import { automodAction } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js";
import { CaseArgs } from "../../Cases/types.js"; import { CaseArgs } from "../../Cases/types.js";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js";
@ -33,7 +33,7 @@ export const KickAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToKick) { for (const member of membersToKick) {
if (!member) continue; if (!member) continue;
await modActions.kickMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); await modActions.kickMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true });
} }
}, },
}); });

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { isTruthy, unique } from "../../../utils.js"; import { isTruthy, unique } from "../../../utils.js";
import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js";
import { automodAction } from "../helpers.js"; import { automodAction } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError.js";
import { import {
convertDelayStringToMS, convertDelayStringToMS,
@ -57,6 +57,7 @@ export const MuteAction = automodAction({
userId, userId,
duration, duration,
reason, reason,
reason,
{ contactMethods, caseArgs, isAutomodAction: true }, { contactMethods, caseArgs, isAutomodAction: true },
rolesToRemove, rolesToRemove,
rolesToRestore, rolesToRestore,

View file

@ -1,5 +1,5 @@
import { GuildFeature } from "discord.js"; import { GuildFeature } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { automodAction } from "../helpers.js"; import { automodAction } from "../helpers.js";
export const PauseInvitesAction = automodAction({ export const PauseInvitesAction = automodAction({

View file

@ -1,5 +1,5 @@
import { PermissionFlagsBits, Snowflake } from "discord.js"; import { PermissionFlagsBits, Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { nonNullish, unique, zSnowflake } from "../../../utils.js"; import { nonNullish, unique, zSnowflake } from "../../../utils.js";
import { canAssignRole } from "../../../utils/canAssignRole.js"; import { canAssignRole } from "../../../utils/canAssignRole.js";
import { getMissingPermissions } from "../../../utils/getMissingPermissions.js"; import { getMissingPermissions } from "../../../utils/getMissingPermissions.js";

View file

@ -1,5 +1,5 @@
import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js"; 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 { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js";
import { import {
convertDelayStringToMS, convertDelayStringToMS,

View file

@ -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 { MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../../data/GuildCounters.js";
import { zBoundedCharacters } from "../../../utils.js"; import { zBoundedCharacters } from "../../../utils.js";
import { CountersPlugin } from "../../Counters/CountersPlugin.js"; import { CountersPlugin } from "../../Counters/CountersPlugin.js";

View file

@ -1,12 +1,12 @@
import { ChannelType, GuildTextBasedChannel, Snowflake } from "discord.js"; 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 { convertDelayStringToMS, isDiscordAPIError, zDelayString, zSnowflake } from "../../../utils.js";
import { LogsPlugin } from "../../Logs/LogsPlugin.js"; import { LogsPlugin } from "../../Logs/LogsPlugin.js";
import { automodAction } from "../helpers.js"; import { automodAction } from "../helpers.js";
export const SetSlowmodeAction = automodAction({ export const SetSlowmodeAction = automodAction({
configSchema: z.strictObject({ configSchema: z.strictObject({
channels: z.array(zSnowflake), channels: z.array(zSnowflake).nullable().default([]),
duration: zDelayString.nullable().default("10s"), duration: zDelayString.nullable().default("10s"),
}), }),

View file

@ -1,5 +1,5 @@
import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from "discord.js"; 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 { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js";
import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils.js"; import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils.js";
import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js"; import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js"; import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils.js";
import { CaseArgs } from "../../Cases/types.js"; import { CaseArgs } from "../../Cases/types.js";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js";
@ -33,7 +33,7 @@ export const WarnAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin); const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToWarn) { for (const member of membersToWarn) {
if (!member) continue; if (!member) continue;
await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true }); await modActions.warnMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true });
} }
}, },
}); });

View file

@ -1,5 +1,4 @@
import { guildPluginMessageCommand } from "knub"; import { guildPluginMessageCommand } from "knub";
import { sendSuccessMessage } from "../../../pluginUtils.js";
import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js";
import { AutomodPluginType } from "../types.js"; import { AutomodPluginType } from "../types.js";
@ -9,6 +8,6 @@ export const AntiraidClearCmd = guildPluginMessageCommand<AutomodPluginType>()({
async run({ pluginData, message }) { async run({ pluginData, message }) {
await setAntiraidLevel(pluginData, null, message.author); await setAntiraidLevel(pluginData, null, message.author);
sendSuccessMessage(pluginData, message.channel, "Anti-raid turned **off**"); void pluginData.state.common.sendSuccessMessage(message, "Anti-raid turned **off**");
}, },
}); });

View file

@ -1,6 +1,5 @@
import { guildPluginMessageCommand } from "knub"; import { guildPluginMessageCommand } from "knub";
import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { setAntiraidLevel } from "../functions/setAntiraidLevel.js"; import { setAntiraidLevel } from "../functions/setAntiraidLevel.js";
import { AutomodPluginType } from "../types.js"; import { AutomodPluginType } from "../types.js";
@ -15,11 +14,11 @@ export const SetAntiraidCmd = guildPluginMessageCommand<AutomodPluginType>()({
async run({ pluginData, message, args }) { async run({ pluginData, message, args }) {
const config = pluginData.config.get(); const config = pluginData.config.get();
if (!config.antiraid_levels.includes(args.level)) { 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; return;
} }
await setAntiraidLevel(pluginData, args.level, message.author); 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}**`);
}, },
}); });

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { MINUTES, SECONDS } from "../../utils.js"; import { MINUTES, SECONDS } from "../../utils.js";
export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS; export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS;

View file

@ -1,11 +1,12 @@
import { ZeppelinPluginInfo } from "../../types.js"; import { ZeppelinPluginDocs } from "../../types.js";
import { trimPluginDescription } from "../../utils.js"; import { trimPluginDescription } from "../../utils.js";
import { zAutomodConfig } from "./types.js"; import { zAutomodConfig } from "./types.js";
export const automodPluginInfo: ZeppelinPluginInfo = { export const automodPluginDocs: ZeppelinPluginDocs = {
showInDocs: true, type: "stable",
prettyName: "Automod",
configSchema: zAutomodConfig, configSchema: zAutomodConfig,
prettyName: "Automod",
description: trimPluginDescription(` description: trimPluginDescription(`
Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention. Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention.
`), `),

View file

@ -1,6 +1,5 @@
import { guildPluginEventListener } from "knub"; import { guildPluginEventListener } from "knub";
import diff from "lodash/difference.js"; import { difference, isEqual } from "lodash-es";
import isEqual from "lodash/isEqual.js";
import { runAutomod } from "../functions/runAutomod.js"; import { runAutomod } from "../functions/runAutomod.js";
import { AutomodContext, AutomodPluginType } from "../types.js"; import { AutomodContext, AutomodPluginType } from "../types.js";
@ -15,8 +14,8 @@ export const RunAutomodOnMemberUpdate = guildPluginEventListener<AutomodPluginTy
if (isEqual(oldRoles, newRoles)) return; if (isEqual(oldRoles, newRoles)) return;
const addedRoles = diff(newRoles, oldRoles); const addedRoles = difference(newRoles, oldRoles);
const removedRoles = diff(oldRoles, newRoles); const removedRoles = difference(oldRoles, newRoles);
if (addedRoles.length || removedRoles.length) { if (addedRoles.length || removedRoles.length) {
const context: AutomodContext = { const context: AutomodContext = {

View file

@ -2,8 +2,13 @@ import { GuildPluginData } from "knub";
import { convertDelayStringToMS } from "../../../utils.js"; import { convertDelayStringToMS } from "../../../utils.js";
import { AutomodContext, AutomodPluginType, TRule } from "../types.js"; import { AutomodContext, AutomodPluginType, TRule } from "../types.js";
export function applyCooldown(pluginData: GuildPluginData<AutomodPluginType>, rule: TRule, context: AutomodContext) { export function applyCooldown(
const cooldownKey = `${rule.name}-${context.user?.id}`; pluginData: GuildPluginData<AutomodPluginType>,
rule: TRule,
ruleName: string,
context: AutomodContext,
) {
const cooldownKey = `${ruleName}-${context.user?.id}`;
const cooldownTime = convertDelayStringToMS(rule.cooldown, "s"); const cooldownTime = convertDelayStringToMS(rule.cooldown, "s");
if (cooldownTime) pluginData.state.cooldownManager.setCooldown(cooldownKey, cooldownTime); if (cooldownTime) pluginData.state.cooldownManager.setCooldown(cooldownKey, cooldownTime);

View file

@ -1,8 +1,13 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { AutomodContext, AutomodPluginType, TRule } from "../types.js"; import { AutomodContext, AutomodPluginType, TRule } from "../types.js";
export function checkCooldown(pluginData: GuildPluginData<AutomodPluginType>, rule: TRule, context: AutomodContext) { export function checkCooldown(
const cooldownKey = `${rule.name}-${context.user?.id}`; pluginData: GuildPluginData<AutomodPluginType>,
rule: TRule,
ruleName: string,
context: AutomodContext,
) {
const cooldownKey = `${ruleName}-${context.user?.id}`;
return pluginData.state.cooldownManager.isOnCooldown(cooldownKey); return pluginData.state.cooldownManager.isOnCooldown(cooldownKey);
} }

View file

@ -1,6 +1,6 @@
import z from "zod"; import z from "zod/v4";
import { SavedMessage } from "../../../data/entities/SavedMessage.js"; import { SavedMessage } from "../../../data/entities/SavedMessage.js";
import { humanizeDurationShort } from "../../../humanizeDurationShort.js"; import { humanizeDurationShort } from "../../../humanizeDuration.js";
import { getBaseUrl } from "../../../pluginUtils.js"; import { getBaseUrl } from "../../../pluginUtils.js";
import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils.js"; import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils.js";
import { RecentActionType } from "../constants.js"; import { RecentActionType } from "../constants.js";

View file

@ -49,7 +49,7 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
} }
if (!rule.affects_self && userId && userId === pluginData.client.user?.id) continue; 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; continue;
} }
@ -87,7 +87,7 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
} }
if (matchResult) { if (matchResult) {
if (rule.cooldown) applyCooldown(pluginData, rule, context); if (rule.cooldown) applyCooldown(pluginData, rule, ruleName, context);
contexts = [context, ...(matchResult.extraContexts || [])]; contexts = [context, ...(matchResult.extraContexts || [])];
@ -164,6 +164,18 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
); );
} }
} }
// Log all automod rules by default
if (rule.actions.log == null) {
availableActions.log.apply({
ruleName,
pluginData,
contexts,
actionConfig: true,
matchResult,
prettyName,
});
}
} }
if (profilingEnabled()) { if (profilingEnabled()) {

View file

@ -1,5 +1,5 @@
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import z, { ZodTypeAny } from "zod"; import z, { ZodTypeAny } from "zod/v4";
import { Awaitable } from "../../utils/typeUtils.js"; import { Awaitable } from "../../utils/typeUtils.js";
import { AutomodContext, AutomodPluginType } from "./types.js"; import { AutomodContext, AutomodPluginType } from "./types.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
interface AntiraidLevelTriggerResult {} interface AntiraidLevelTriggerResult {}

View file

@ -1,5 +1,5 @@
import { Snowflake } from "discord.js"; import { Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { verboseChannelMention } from "../../../utils.js"; import { verboseChannelMention } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line:no-empty-interface // tslint:disable-next-line:no-empty-interface

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line // tslint:disable-next-line

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
interface ExampleMatchResultType { interface ExampleMatchResultType {

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line:no-empty-interface // tslint:disable-next-line:no-empty-interface

View file

@ -1,6 +1,6 @@
import { escapeInlineCode, Snowflake } from "discord.js"; import { escapeInlineCode, Snowflake } from "discord.js";
import { extname } from "path"; import { extname } from "path";
import z from "zod"; import z from "zod/v4";
import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
@ -9,30 +9,12 @@ interface MatchResultType {
mode: "blacklist" | "whitelist"; mode: "blacklist" | "whitelist";
} }
const configSchema = z const configSchema = z.strictObject({
.strictObject({ whitelist_enabled: z.boolean().default(false),
filetype_blacklist: z.array(z.string().max(32)).max(255).default([]), filetype_whitelist: z.array(z.string().max(32)).max(255).default([]),
blacklist_enabled: z.boolean().default(false), blacklist_enabled: z.boolean().default(false),
filetype_whitelist: z.array(z.string().max(32)).max(255).default([]), filetype_blacklist: 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;
});
export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({ export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
configSchema, configSchema,

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from "../../../utils.js"; import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from "../../../utils.js";
import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js";

View file

@ -1,11 +1,10 @@
import { escapeInlineCode } from "discord.js"; import { escapeInlineCode } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { allowTimeout } from "../../../RegExpRunner.js"; import { allowTimeout } from "../../../RegExpRunner.js";
import { phishermanDomainIsSafe } from "../../../data/Phisherman.js"; import { getFishFishDomain } from "../../../data/FishFish.js";
import { getUrlsInString, zRegex } from "../../../utils.js"; import { getUrlsInString, inputPatternToRegExp, zRegex } from "../../../utils.js";
import { mergeRegexes } from "../../../utils/mergeRegexes.js"; import { mergeRegexes } from "../../../utils/mergeRegexes.js";
import { mergeWordsIntoRegex } from "../../../utils/mergeWordsIntoRegex.js"; import { mergeWordsIntoRegex } from "../../../utils/mergeWordsIntoRegex.js";
import { PhishermanPlugin } from "../../Phisherman/PhishermanPlugin.js";
import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
@ -40,6 +39,7 @@ const configSchema = z.strictObject({
include_verified: z.boolean().optional(), include_verified: z.boolean().optional(),
}) })
.optional(), .optional(),
include_malicious: z.boolean().default(false),
only_real_links: z.boolean().default(true), only_real_links: z.boolean().default(true),
match_messages: z.boolean().default(true), match_messages: z.boolean().default(true),
match_embeds: z.boolean().default(true), match_embeds: z.boolean().default(true),
@ -73,7 +73,7 @@ export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
if (trigger.exclude_regex) { if (trigger.exclude_regex) {
if (!regexCache.has(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); regexCache.set(trigger.exclude_regex, toCache);
} }
const regexes = regexCache.get(trigger.exclude_regex)!; const regexes = regexCache.get(trigger.exclude_regex)!;
@ -88,7 +88,7 @@ export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
if (trigger.include_regex) { if (trigger.include_regex) {
if (!regexCache.has(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); regexCache.set(trigger.include_regex, toCache);
} }
const regexes = regexCache.get(trigger.include_regex)!; const regexes = regexCache.get(trigger.include_regex)!;
@ -155,22 +155,18 @@ export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
} }
} }
if (trigger.phisherman) { const includeMalicious =
const phishermanResult = await pluginData.getPlugin(PhishermanPlugin).getDomainInfo(normalizedHostname); trigger.include_malicious || trigger.phisherman?.include_suspected || trigger.phisherman?.include_verified;
if (phishermanResult != null && !phishermanDomainIsSafe(phishermanResult)) { if (includeMalicious) {
if ( const domainInfo = getFishFishDomain(normalizedHostname);
(trigger.phisherman.include_suspected && !phishermanResult.verifiedPhish) || if (domainInfo && domainInfo.category !== "safe") {
(trigger.phisherman.include_verified && phishermanResult.verifiedPhish) return {
) { extra: {
const suspectedVerified = phishermanResult.verifiedPhish ? "verified" : "suspected"; type,
return { link: link.input,
extra: { details: `(known ${domainInfo.category} domain)`,
type, },
link: link.input, };
details: `using Phisherman (${suspectedVerified})`,
},
};
}
} }
} }
} }

View file

@ -1,5 +1,5 @@
import { escapeInlineCode } from "discord.js"; import { escapeInlineCode } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
@ -8,30 +8,12 @@ interface MatchResultType {
mode: "blacklist" | "whitelist"; mode: "blacklist" | "whitelist";
} }
const configSchema = z const configSchema = z.strictObject({
.strictObject({ whitelist_enabled: z.boolean().default(false),
mime_type_blacklist: z.array(z.string().max(255)).max(255).default([]), mime_type_whitelist: z.array(z.string().max(32)).max(255).default([]),
blacklist_enabled: z.boolean().default(false), blacklist_enabled: z.boolean().default(false),
mime_type_whitelist: z.array(z.string().max(255)).max(255).default([]), mime_type_blacklist: 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;
});
export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({ export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
configSchema, configSchema,

View file

@ -1,6 +1,6 @@
import z from "zod"; import z from "zod/v4";
import { allowTimeout } from "../../../RegExpRunner.js"; import { allowTimeout } from "../../../RegExpRunner.js";
import { zRegex } from "../../../utils.js"; import { inputPatternToRegExp, zRegex } from "../../../utils.js";
import { mergeRegexes } from "../../../utils/mergeRegexes.js"; import { mergeRegexes } from "../../../utils/mergeRegexes.js";
import { normalizeText } from "../../../utils/normalizeText.js"; import { normalizeText } from "../../../utils/normalizeText.js";
import { stripMarkdown } from "../../../utils/stripMarkdown.js"; import { stripMarkdown } from "../../../utils/stripMarkdown.js";
@ -38,7 +38,7 @@ export const MatchRegexTrigger = automodTrigger<MatchResultType>()({
if (!regexCache.has(trigger)) { if (!regexCache.has(trigger)) {
const flags = trigger.case_sensitive ? "" : "i"; 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); regexCache.set(trigger, toCache);
} }
const regexes = regexCache.get(trigger)!; const regexes = regexCache.get(trigger)!;

View file

@ -1,5 +1,5 @@
import escapeStringRegexp from "escape-string-regexp"; import escapeStringRegexp from "escape-string-regexp";
import z from "zod"; import z from "zod/v4";
import { normalizeText } from "../../../utils/normalizeText.js"; import { normalizeText } from "../../../utils/normalizeText.js";
import { stripMarkdown } from "../../../utils/stripMarkdown.js"; import { stripMarkdown } from "../../../utils/stripMarkdown.js";
import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js";
@ -19,7 +19,7 @@ const configSchema = z.strictObject({
only_full_words: z.boolean().default(true), only_full_words: z.boolean().default(true),
normalize: z.boolean().default(false), normalize: z.boolean().default(false),
loose_matching: 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), strip_markdown: z.boolean().default(false),
match_messages: z.boolean().default(true), match_messages: z.boolean().default(true),
match_embeds: z.boolean().default(false), match_embeds: z.boolean().default(false),

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js";
import { RecentActionType } from "../constants.js"; import { RecentActionType } from "../constants.js";
import { findRecentSpam } from "../functions/findRecentSpam.js"; import { findRecentSpam } from "../functions/findRecentSpam.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
const configSchema = z.strictObject({}); const configSchema = z.strictObject({});

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line:no-empty-interface // tslint:disable-next-line:no-empty-interface

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line:no-empty-interface // tslint:disable-next-line:no-empty-interface

View file

@ -1,5 +1,5 @@
import { Snowflake } from "discord.js"; import { Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { renderUsername, zSnowflake } from "../../../utils.js"; import { renderUsername, zSnowflake } from "../../../utils.js";
import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,5 +1,5 @@
import { Snowflake } from "discord.js"; import { Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { renderUsername, zSnowflake } from "../../../utils.js"; import { renderUsername, zSnowflake } from "../../../utils.js";
import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,5 +1,5 @@
import { User, escapeBold, type Snowflake } from "discord.js"; import { User, escapeBold, type Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { renderUsername } from "../../../utils.js"; import { renderUsername } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,5 +1,5 @@
import { User, escapeBold, type Snowflake } from "discord.js"; import { User, escapeBold, type Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { renderUsername } from "../../../utils.js"; import { renderUsername } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { convertDelayStringToMS, zDelayString } from "../../../utils.js"; import { convertDelayStringToMS, zDelayString } from "../../../utils.js";
import { RecentActionType } from "../constants.js"; import { RecentActionType } from "../constants.js";
import { findRecentSpam } from "../functions/findRecentSpam.js"; import { findRecentSpam } from "../functions/findRecentSpam.js";

View file

@ -1,5 +1,5 @@
import { User, escapeBold, type Snowflake } from "discord.js"; import { User, escapeBold, type Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { renderUsername } from "../../../utils.js"; import { renderUsername } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,5 +1,5 @@
import { User, escapeBold, type Snowflake } from "discord.js"; import { User, escapeBold, type Snowflake } from "discord.js";
import z from "zod"; import z from "zod/v4";
import { renderUsername } from "../../../utils.js"; import { renderUsername } from "../../../utils.js";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line:no-empty-interface // tslint:disable-next-line:no-empty-interface

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line:no-empty-interface // tslint:disable-next-line:no-empty-interface

View file

@ -1,4 +1,4 @@
import z from "zod"; import z from "zod/v4";
import { automodTrigger } from "../helpers.js"; import { automodTrigger } from "../helpers.js";
// tslint:disable-next-line:no-empty-interface // tslint:disable-next-line:no-empty-interface

View file

@ -1,6 +1,6 @@
import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js"; import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js";
import { BasePluginType, CooldownManager } from "knub"; import { BasePluginType, CooldownManager, pluginUtils } from "knub";
import z from "zod"; import z from "zod/v4";
import { Queue } from "../../Queue.js"; import { Queue } from "../../Queue.js";
import { RegExpRunner } from "../../RegExpRunner.js"; import { RegExpRunner } from "../../RegExpRunner.js";
import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels.js"; import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels.js";
@ -9,6 +9,7 @@ import { GuildLogs } from "../../data/GuildLogs.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js"; import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { SavedMessage } from "../../data/entities/SavedMessage.js"; import { SavedMessage } from "../../data/entities/SavedMessage.js";
import { entries, zBoundedRecord, zDelayString } from "../../utils.js"; import { entries, zBoundedRecord, zDelayString } from "../../utils.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
import { CounterEvents } from "../Counters/types.js"; import { CounterEvents } from "../Counters/types.js";
import { ModActionType, ModActionsEvents } from "../ModActions/types.js"; import { ModActionType, ModActionsEvents } from "../ModActions/types.js";
import { MutesEvents } from "../Mutes/types.js"; import { MutesEvents } from "../Mutes/types.js";
@ -45,22 +46,6 @@ const zActionsMap = z
const zRule = z.strictObject({ const zRule = z.strictObject({
enabled: z.boolean().default(true), 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(), pretty_name: z.string().optional(),
presets: z.array(z.string().max(100)).max(25).default([]), presets: z.array(z.string().max(100)).max(25).default([]),
affects_bots: z.boolean().default(false), affects_bots: z.boolean().default(false),
@ -68,21 +53,19 @@ const zRule = z.strictObject({
cooldown: zDelayString.nullable().default(null), cooldown: zDelayString.nullable().default(null),
allow_further_rules: z.boolean().default(false), allow_further_rules: z.boolean().default(false),
triggers: z.array(zTriggersMap), triggers: z.array(zTriggersMap),
actions: zActionsMap.refine((v) => !(v.clean && v.start_thread), { actions: zActionsMap,
message: "Cannot have both clean and start_thread active at the same time",
}),
}); });
export type TRule = z.infer<typeof zRule>; export type TRule = z.infer<typeof zRule>;
export const zAutomodConfig = z.strictObject({ export const zAutomodConfig = z.strictObject({
rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255), rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255).default({}),
antiraid_levels: z.array(z.string().max(100)).max(10), antiraid_levels: z.array(z.string().max(100)).max(10).default(["low", "medium", "high"]),
can_set_antiraid: z.boolean(), can_set_antiraid: z.boolean().default(false),
can_view_antiraid: z.boolean(), can_view_antiraid: z.boolean().default(false),
}); });
export interface AutomodPluginType extends BasePluginType { export interface AutomodPluginType extends BasePluginType {
config: z.output<typeof zAutomodConfig>; configSchema: typeof zAutomodConfig;
customOverrideCriteria: { customOverrideCriteria: {
antiraid_level?: string; antiraid_level?: string;
@ -140,6 +123,8 @@ export interface AutomodPluginType extends BasePluginType {
modActionsListeners: Map<keyof ModActionsEvents, any>; modActionsListeners: Map<keyof ModActionsEvents, any>;
mutesListeners: Map<keyof MutesEvents, any>; mutesListeners: Map<keyof MutesEvents, any>;
common: pluginUtils.PluginPublicInterface<typeof CommonPlugin>;
}; };
} }

View file

@ -4,7 +4,6 @@ import { AllowedGuilds } from "../../data/AllowedGuilds.js";
import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments.js";
import { Configs } from "../../data/Configs.js"; import { Configs } from "../../data/Configs.js";
import { GuildArchives } from "../../data/GuildArchives.js"; import { GuildArchives } from "../../data/GuildArchives.js";
import { sendSuccessMessage } from "../../pluginUtils.js";
import { getActiveReload, resetActiveReload } from "./activeReload.js"; import { getActiveReload, resetActiveReload } from "./activeReload.js";
import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd.js"; import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd.js";
import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd.js"; import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd.js";
@ -24,21 +23,9 @@ import { RestPerformanceCmd } from "./commands/RestPerformanceCmd.js";
import { ServersCmd } from "./commands/ServersCmd.js"; import { ServersCmd } from "./commands/ServersCmd.js";
import { BotControlPluginType, zBotControlConfig } from "./types.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<BotControlPluginType>()({ export const BotControlPlugin = globalPlugin<BotControlPluginType>()({
name: "bot_control", name: "bot_control",
configParser: (input) => zBotControlConfig.parse(input), configSchema: zBotControlConfig,
defaultOptions,
// prettier-ignore // prettier-ignore
messageCommands: [ messageCommands: [
@ -77,7 +64,7 @@ export const BotControlPlugin = globalPlugin<BotControlPluginType>()({
if (guild) { if (guild) {
const channel = guild.channels.cache.get(channelId as Snowflake); const channel = guild.channels.cache.get(channelId as Snowflake);
if (channel instanceof TextChannel) { if (channel instanceof TextChannel) {
sendSuccessMessage(pluginData, channel, "Global plugins reloaded!"); void channel.send("Global plugins reloaded!");
} }
} }
} }

View file

@ -1,6 +1,6 @@
import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js";
import { commandTypeHelpers as ct } from "../../../commandTypes.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 { renderUsername } from "../../../utils.js";
import { botControlCmd } from "../types.js"; import { botControlCmd } from "../types.js";
@ -19,7 +19,7 @@ export const AddDashboardUserCmd = botControlCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const guild = await pluginData.state.allowedGuilds.find(args.guildId); const guild = await pluginData.state.allowedGuilds.find(args.guildId);
if (!guild) { if (!guild) {
sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin"); void msg.channel.send("Server is not using Zeppelin");
return; return;
} }
@ -36,10 +36,7 @@ export const AddDashboardUserCmd = botControlCmd({
} }
const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`); const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`);
sendSuccessMessage(
pluginData, msg.channel.send(`The following users were given dashboard access for **${guild.name}**:\n\n${userNameList}`);
msg.channel,
`The following users were given dashboard access for **${guild.name}**:\n\n${userNameList}`,
);
}, },
}); });

View file

@ -1,7 +1,6 @@
import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { DBDateFormat, isGuildInvite, resolveInvite } from "../../../utils.js"; import { DBDateFormat, isGuildInvite, resolveInvite } from "../../../utils.js";
import { isEligible } from "../functions/isEligible.js"; import { isEligible } from "../functions/isEligible.js";
import { botControlCmd } from "../types.js"; import { botControlCmd } from "../types.js";
@ -18,19 +17,19 @@ export const AddServerFromInviteCmd = botControlCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const invite = await resolveInvite(pluginData.client, args.inviteCode, true); const invite = await resolveInvite(pluginData.client, args.inviteCode, true);
if (!invite || !isGuildInvite(invite)) { if (!invite || !isGuildInvite(invite)) {
sendErrorMessage(pluginData, msg.channel, "Could not resolve invite"); // :D void msg.channel.send("Could not resolve invite"); // :D
return; return;
} }
const existing = await pluginData.state.allowedGuilds.find(invite.guild.id); const existing = await pluginData.state.allowedGuilds.find(invite.guild.id);
if (existing) { if (existing) {
sendErrorMessage(pluginData, msg.channel, "Server is already allowed!"); void msg.channel.send("Server is already allowed!");
return; return;
} }
const { result, explanation } = await isEligible(pluginData, args.user, invite); const { result, explanation } = await isEligible(pluginData, args.user, invite);
if (!result) { 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; 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!");
}, },
}); });

View file

@ -1,7 +1,7 @@
import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js"; import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { commandTypeHelpers as ct } from "../../../commandTypes.js"; 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 { DBDateFormat, isSnowflake } from "../../../utils.js";
import { botControlCmd } from "../types.js"; import { botControlCmd } from "../types.js";
@ -20,17 +20,17 @@ export const AllowServerCmd = botControlCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const existing = await pluginData.state.allowedGuilds.find(args.guildId); const existing = await pluginData.state.allowedGuilds.find(args.guildId);
if (existing) { if (existing) {
sendErrorMessage(pluginData, msg.channel, "Server is already allowed!"); void msg.channel.send("Server is already allowed!");
return; return;
} }
if (!isSnowflake(args.guildId)) { if (!isSnowflake(args.guildId)) {
sendErrorMessage(pluginData, msg.channel, "Invalid server ID!"); void msg.channel.send("Invalid server ID!");
return; return;
} }
if (args.userId && !isSnowflake(args.userId)) { if (args.userId && !isSnowflake(args.userId)) {
sendErrorMessage(pluginData, msg.channel, "Invalid user ID!"); void msg.channel.send("Invalid user ID!");
return; 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!");
}, },
}); });

View file

@ -1,5 +1,5 @@
import { commandTypeHelpers as ct } from "../../../commandTypes.js"; import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { isStaffPreFilter, sendErrorMessage } from "../../../pluginUtils.js"; import { isStaffPreFilter } from "../../../pluginUtils.js";
import { botControlCmd } from "../types.js"; import { botControlCmd } from "../types.js";
export const ChannelToServerCmd = botControlCmd({ export const ChannelToServerCmd = botControlCmd({
@ -16,7 +16,7 @@ export const ChannelToServerCmd = botControlCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const channel = pluginData.client.channels.cache.get(args.channelId); const channel = pluginData.client.channels.cache.get(args.channelId);
if (!channel) { if (!channel) {
sendErrorMessage(pluginData, msg.channel, "Channel not found in cache!"); void msg.channel.send("Channel not found in cache!");
return; return;
} }

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