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": [
"BanTheNons",
"CleverSource",
"DarkView",
"DenverCoder1",
"Jernik",
"Rstar284",
"almeidx",
"axisiscool",
"BanTheNons",
"Benricheson101",
"brawaru",
"CleverSource",
"Dalkskkskk",
"DarkView",
"DenverCoder1",
"dexbiobot",
"greenbigfrog",
"hawkeye7662",
"iamshoXy",
"Jernik",
"k200-1",
"LilyBergonzat",
"martinbndr",
"metal0",
"Obliie",
"paolojpa",
"roflmaoqwerty",
"Rstar284",
"rubyowo",
"rukogit",
"Scraayp",
"TheKodeToad",
"thewilloftheshadow",
"usoka",
"vcokltfre",
"Dragory",
"rubyowo",
"Dalkskkskk",
"iamshoXy",
"Scraayp",
"app/dependabot",
"dependabot[bot]",
"WeebHiroyuki",
"zayKenyon",
"rukogit",
"Obliie",
"brawaru",
"Benricheson101",
"hawkeye7662",
"LilyBergonzat",
"martinbndr"
"Dragory",
"app/dependabot",
"dependabot[bot]"
],
"message": "Thank you for contributing to Zeppelin! We require contributors to sign our Contributor License Agreement (CLA). To let us review and merge your code, please visit https://github.com/ZeppelinBot/CLA to sign the CLA!"
}

View file

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

View file

@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
node-version: [18.16]
node-version: [22]
steps:
- 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 chown node:node /zeppelin
@ -32,3 +32,8 @@ RUN npm run build
# Prune dev dependencies
WORKDIR /zeppelin
RUN npm prune --omit=dev
FROM node:22-alpine AS main
USER node
COPY --from=build --chown=node:node /zeppelin /zeppelin

View file

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

View file

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

View file

@ -46,7 +46,7 @@ export class SimpleCache<T = any> {
});
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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

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 { chunkArray } from "../utils.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({
relations: this.getRelations(),
where: {
guild_id: this.guildId,
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({
where: {
guild_id: this.guildId,
mod_id: modId,
is_hidden: false,
...filters,
},
});
}
async getRecentByModId(modId: string, count: number, skip = 0): Promise<Case[]> {
return this.cases.find({
relations: this.getRelations(),
where: {
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({
relations: this.getRelations(),
where,
skip,
take: count,
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}`;
}
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);
if (!mute) {
// Mute was already cleared
@ -27,7 +27,7 @@ async function broadcastExpiredMute(guildId: string, userId: string, tries = 0)
return;
}
console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`);
// console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`);
if (!hasGuildEventListener(mute.guild_id, "expiredMute")) {
// If there are no listeners registered for the server yet, try again in a bit
if (tries < MAX_TRIES_PER_SERVER) {
@ -42,7 +42,7 @@ async function broadcastExpiredMute(guildId: string, userId: string, tries = 0)
}
function broadcastTimeoutMuteToRenew(mute: Mute, tries = 0) {
console.log(`[EXPIRING MUTES LOOP] Broadcasting timeout mute to renew: ${mute.guild_id}/${mute.user_id}`);
// console.log(`[EXPIRING MUTES LOOP] Broadcasting timeout mute to renew: ${mute.guild_id}/${mute.user_id}`);
if (!hasGuildEventListener(mute.guild_id, "timeoutMuteToRenew")) {
// If there are no listeners registered for the server yet, try again in a bit
if (tries < MAX_TRIES_PER_SERVER) {

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

View file

@ -1,23 +1,91 @@
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { guildPluginInfo } from "./plugins/pluginInfo.js";
import fs from "node:fs";
import { z } from "zod/v4";
import { availableGuildPlugins } from "./plugins/availablePlugins.js";
import { zZeppelinGuildConfig } from "./types.js";
import { deepPartial } from "./utils/zodDeepPartial.js";
const pluginSchemaMap = Object.entries(guildPluginInfo).reduce((map, [pluginName, pluginInfo]) => {
if (pluginInfo.configSchema) {
map[pluginName] = pluginInfo.configSchema;
const basePluginOverrideCriteriaSchema = z.strictObject({
channel: z
.union([z.string(), z.array(z.string())])
.nullable()
.optional(),
category: z
.union([z.string(), z.array(z.string())])
.nullable()
.optional(),
level: z
.union([z.string(), z.array(z.string())])
.nullable()
.optional(),
user: z
.union([z.string(), z.array(z.string())])
.nullable()
.optional(),
role: z
.union([z.string(), z.array(z.string())])
.nullable()
.optional(),
thread: z
.union([z.string(), z.array(z.string())])
.nullable()
.optional(),
is_thread: z.boolean().nullable().optional(),
thread_type: z.literal(["public", "private"]).nullable().optional(),
extra: z.any().optional(),
});
const pluginOverrideCriteriaSchema = basePluginOverrideCriteriaSchema.extend({
get zzz_dummy_property_do_not_use() {
return pluginOverrideCriteriaSchema.optional();
},
get all() {
return z.array(pluginOverrideCriteriaSchema).optional();
},
get any() {
return z.array(pluginOverrideCriteriaSchema).optional();
},
get not() {
return pluginOverrideCriteriaSchema.optional();
},
}).meta({
id: "overrideCriteria",
});
const outputPath = process.argv[2];
if (!outputPath) {
console.error("Output path required");
process.exit(1);
}
const partialConfigs = new Map<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;
}, {});
const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).merge(
z.strictObject({
plugins: z.strictObject(pluginSchemaMap).partial(),
}),
);
const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).extend({
plugins: z.strictObject(pluginSchemaMap).partial().optional(),
});
const jsonSchema = zodToJsonSchema(fullSchema);
const jsonSchema = z.toJSONSchema(fullSchema, { io: "input", cycles: "ref" });
console.log(JSON.stringify(jsonSchema, null, 2));
fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2), { encoding: "utf8" });
process.exit(0);

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,
ThreadChannel,
} from "discord.js";
import { EventEmitter } from "events";
import { Knub, PluginError, PluginLoadError, PluginNotLoadedError } from "knub";
import moment from "moment-timezone";
import { performance } from "perf_hooks";
@ -22,9 +21,9 @@ import { RecoverablePluginError } from "./RecoverablePluginError.js";
import { SimpleError } from "./SimpleError.js";
import { AllowedGuilds } from "./data/AllowedGuilds.js";
import { Configs } from "./data/Configs.js";
import { FishFishError, initFishFish } from "./data/FishFish.js";
import { GuildLogs } from "./data/GuildLogs.js";
import { LogType } from "./data/LogType.js";
import { hasPhishermanMasterAPIKey } from "./data/Phisherman.js";
import { dataSource } from "./data/dataSource.js";
import { connect } from "./data/db.js";
import { runExpiredArchiveDeletionLoop } from "./data/loops/expiredArchiveDeletionLoop.js";
@ -33,14 +32,13 @@ import { runExpiringMutesLoop } from "./data/loops/expiringMutesLoop.js";
import { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop.js";
import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop.js";
import { runMemberCacheDeletionLoop } from "./data/loops/memberCacheDeletionLoop.js";
import { runPhishermanCacheCleanupLoop, runPhishermanReportingLoop } from "./data/loops/phishermanLoops.js";
import { runSavedMessageCleanupLoop } from "./data/loops/savedMessageCleanupLoop.js";
import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop.js";
import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop.js";
import { consumeQueryStats } from "./data/queryLogger.js";
import { env } from "./env.js";
import { logger } from "./logger.js";
import { baseGuildPlugins, globalPlugins, guildPlugins } from "./plugins/availablePlugins.js";
import { availableGlobalPlugins, availableGuildPlugins } from "./plugins/availablePlugins.js";
import { setProfiler } from "./profiler.js";
import { logRateLimit } from "./rateLimitStats.js";
import { startUptimeCounter } from "./uptime.js";
@ -143,6 +141,12 @@ function errorHandler(err) {
return;
}
if (err instanceof FishFishError) {
// FishFish errors are not critical, so we just log them
console.error(`[FISHFISH] ${err.message}`);
return;
}
// tslint:disable:no-console
console.error(err);
@ -166,10 +170,8 @@ function errorHandler(err) {
// tslint:enable:no-console
}
if (process.env.NODE_ENV === "production") {
process.on("uncaughtException", errorHandler);
process.on("unhandledRejection", errorHandler);
}
// Verify required Node.js version
const REQUIRED_NODE_VERSION = "16.9.0";
@ -252,8 +254,8 @@ connect().then(async () => {
GatewayIntentBits.GuildVoiceStates,
],
});
// FIXME: TS doesn't see Client as a child of EventEmitter for some reason
(client as unknown as EventEmitter).setMaxListeners(200);
client.setMaxListeners(200);
const safe429DecayInterval = 5 * SECONDS;
const safe429MaxCount = 5;
@ -275,6 +277,10 @@ connect().then(async () => {
});
client.on("error", (err) => {
if (err instanceof PluginLoadError) {
errorHandler(err);
return;
}
errorHandler(new DiscordJSError(err.message, (err as any).code, 0));
});
@ -282,8 +288,8 @@ connect().then(async () => {
const guildConfigs = new Configs();
const bot = new Knub(client, {
guildPlugins,
globalPlugins,
guildPlugins: availableGuildPlugins.map((obj) => obj.plugin),
globalPlugins: availableGlobalPlugins.map((obj) => obj.plugin),
options: {
canLoadGuild(guildId): Promise<boolean> {
@ -292,7 +298,7 @@ connect().then(async () => {
/**
* Plugins are enabled if they...
* - are base plugins, i.e. always enabled, or
* - are marked to be autoloaded, or
* - are explicitly enabled in the guild config
* Dependencies are also automatically loaded by Knub.
*/
@ -302,10 +308,10 @@ connect().then(async () => {
}
const configuredPlugins = ctx.config.plugins;
const basePluginNames = baseGuildPlugins.map((p) => p.name);
const autoloadPluginNames = availableGuildPlugins.filter((obj) => obj.autoload).map((obj) => obj.plugin.name);
return Array.from(plugins.keys()).filter((pluginName) => {
if (basePluginNames.includes(pluginName)) return true;
if (autoloadPluginNames.includes(pluginName)) return true;
return configuredPlugins[pluginName] && (configuredPlugins[pluginName] as any).enabled !== false;
});
},
@ -323,12 +329,30 @@ connect().then(async () => {
if (row) {
try {
const loaded = loadYamlSafely(row.config);
if (loaded.success_emoji || loaded.error_emoji) {
const deprecatedKeys = [] as string[];
const exampleConfig = `plugins:\n common:\n config:\n success_emoji: "👍"\n error_emoji: "👎"`;
if (loaded.success_emoji) {
deprecatedKeys.push("success_emoji");
}
if (loaded.error_emoji) {
deprecatedKeys.push("error_emoji");
}
// logger.warn(`Deprecated config properties found in "${key}": ${deprecatedKeys.join(", ")}`);
// logger.warn(`You can now configure those emojis in the "common" plugin config\n${exampleConfig}`);
}
// Remove deprecated properties some may still have in their config
delete loaded.success_emoji;
delete loaded.error_emoji;
return loaded;
} catch (err) {
logger.error(`Error while loading config "${key}": ${err.message}`);
logger.error(`Error while loading config "${key}"`);
return {};
}
}
@ -385,6 +409,8 @@ connect().then(async () => {
enableProfiling();
}
initFishFish();
runExpiringMutesLoop();
await sleep(10 * SECONDS);
runExpiringTempbansLoop();
@ -402,13 +428,6 @@ connect().then(async () => {
runExpiredMemberCacheDeletionLoop();
await sleep(10 * SECONDS);
runMemberCacheDeletionLoop();
if (hasPhishermanMasterAPIKey()) {
await sleep(10 * SECONDS);
runPhishermanCacheCleanupLoop();
await sleep(10 * SECONDS);
runPhishermanReportingLoop();
}
});
let lowestGlobalRemaining = Infinity;

View file

@ -9,7 +9,7 @@ export class MigrateUsernamesToNewHistoryTable1556909512501 implements Migration
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");
stream.on("data", (row: any) => {
migratedUsernames.add(row.key);

View file

@ -3,18 +3,35 @@
*/
import {
BitField,
BitFieldResolvable,
ChatInputCommandInteraction,
CommandInteraction,
GuildMember,
InteractionEditReplyOptions,
InteractionReplyOptions,
InteractionResponse,
Message,
MessageCreateOptions,
MessageMentionOptions,
MessageEditOptions,
MessageFlags,
MessageFlagsString,
ModalSubmitInteraction,
PermissionsBitField,
TextBasedChannel,
} from "discord.js";
import { AnyPluginData, BasePluginData, CommandContext, ExtendedMatchParams, GuildPluginData, helpers } from "knub";
import { logger } from "./logger.js";
import {
AnyPluginData,
BasePluginData,
CommandContext,
ExtendedMatchParams,
GuildPluginData,
helpers,
PluginConfigManager,
} from "knub";
import z from "zod/v4";
import { isStaff } from "./staff.js";
import { TZeppelinKnub } from "./types.js";
import { errorMessage, successMessage } from "./utils.js";
import { Tail } from "./utils/typeUtils.js";
const { getMemberLevel } = helpers;
@ -49,45 +66,117 @@ export async function hasPermission(
return helpers.hasPermission(config, permission);
}
export async function sendSuccessMessage(
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 };
export type GenericCommandSource = Message | CommandInteraction | ModalSubmitInteraction;
return channel
.send({ ...content }) // Force line break
.catch((err) => {
const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id;
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined;
});
export function isContextInteraction(
context: GenericCommandSource,
): context is CommandInteraction | ModalSubmitInteraction {
return context instanceof CommandInteraction || context instanceof ModalSubmitInteraction;
}
export async function sendErrorMessage(
pluginData: AnyPluginData<any>,
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 };
export function isContextMessage(context: GenericCommandSource): context is Message {
return context instanceof Message;
}
return channel
.send({ ...content }) // Force line break
.catch((err) => {
const channelInfo = "guild" in channel ? `${channel.id} (${channel.guild.id})` : channel.id;
logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined;
export async function getContextChannel(context: GenericCommandSource): Promise<TextBasedChannel | null> {
if (isContextInteraction(context)) {
return context.channel;
}
if (context instanceof Message) {
return context.channel;
}
throw new Error("Unknown context type");
}
export function getContextChannelId(context: GenericCommandSource): string | null {
return context.channelId;
}
export async function fetchContextChannel(context: GenericCommandSource) {
if (!context.guild) {
throw new Error("Missing context guild");
}
const channelId = getContextChannelId(context);
if (!channelId) {
throw new Error("Missing context channel ID");
}
return (await context.guild.channels.fetch(channelId))!;
}
function flagsWithEphemeral<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,
});
}
@ -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 { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { LogsPlugin } from "../Logs/LogsPlugin.js";
@ -8,19 +8,11 @@ import { onMessageCreate } from "./util/onMessageCreate.js";
import { onMessageDelete } from "./util/onMessageDelete.js";
import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk.js";
const defaultOptions: PluginOptions<AutoDeletePluginType> = {
config: {
enabled: false,
delay: "5s",
},
};
export const AutoDeletePlugin = guildPlugin<AutoDeletePluginType>()({
name: "auto_delete",
dependencies: () => [TimeAndDatePlugin, LogsPlugin],
configParser: (input) => zAutoDeleteConfig.parse(input),
defaultOptions,
configSchema: zAutoDeleteConfig,
beforeLoad(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";
export const autoDeletePluginInfo: ZeppelinPluginInfo = {
showInDocs: true,
export const autoDeletePluginDocs: ZeppelinPluginDocs = {
type: "stable",
configSchema: zAutoDeleteConfig,
prettyName: "Auto-delete",
description: "Allows Zeppelin to auto-delete messages from a channel after a delay",
configurationGuide: "Maximum deletion delay is currently 5 minutes",
configSchema: zAutoDeleteConfig,
};

View file

@ -1,5 +1,5 @@
import { BasePluginType } from "knub";
import z from "zod";
import z from "zod/v4";
import { GuildLogs } from "../../data/GuildLogs.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { SavedMessage } from "../../data/entities/SavedMessage.js";
@ -14,12 +14,12 @@ export interface IDeletionQueueItem {
}
export const zAutoDeleteConfig = z.strictObject({
enabled: z.boolean(),
delay: zDelayString,
enabled: z.boolean().default(false),
delay: zDelayString.default("5s"),
});
export interface AutoDeletePluginType extends BasePluginType {
config: z.output<typeof zAutoDeleteConfig>;
configSchema: typeof zAutoDeleteConfig;
state: {
guildSavedMessages: GuildSavedMessages;
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 moment from "moment-timezone";
import { LogType } from "../../../data/LogType.js";
@ -17,8 +17,8 @@ export async function deleteNextItem(pluginData: GuildPluginData<AutoDeletePlugi
scheduleNextDeletion(pluginData);
const channel = pluginData.guild.channels.cache.get(itemToDelete.message.channel_id as Snowflake);
if (!channel || channel.type === ChannelType.GuildCategory) {
// Channel was deleted, ignore
if (!channel || !("messages" in channel)) {
// Channel does not exist or does not support messages, ignore
return;
}

View file

@ -1,25 +1,21 @@
import { PluginOptions, guildPlugin } from "knub";
import { PluginOverride, guildPlugin } from "knub";
import { GuildAutoReactions } from "../../data/GuildAutoReactions.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
import { LogsPlugin } from "../Logs/LogsPlugin.js";
import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd.js";
import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd.js";
import { AddReactionsEvt } from "./events/AddReactionsEvt.js";
import { AutoReactionsPluginType, zAutoReactionsConfig } from "./types.js";
const defaultOptions: PluginOptions<AutoReactionsPluginType> = {
config: {
can_manage: false,
},
overrides: [
const defaultOverrides: Array<PluginOverride<AutoReactionsPluginType>> = [
{
level: ">=100",
config: {
can_manage: true,
},
},
],
};
];
export const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({
name: "auto_reactions",
@ -29,8 +25,8 @@ export const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({
LogsPlugin,
],
configParser: (input) => zAutoReactionsConfig.parse(input),
defaultOptions,
configSchema: zAutoReactionsConfig,
defaultOverrides,
// prettier-ignore
messageCommands: [
@ -50,4 +46,8 @@ export const AutoReactionsPlugin = guildPlugin<AutoReactionsPluginType>()({
state.autoReactions = GuildAutoReactions.getGuildInstance(guild.id);
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 { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { autoReactionsCmd } from "../types.js";
export const DisableAutoReactionsCmd = autoReactionsCmd({
@ -14,12 +13,12 @@ export const DisableAutoReactionsCmd = autoReactionsCmd({
async run({ message: msg, args, pluginData }) {
const autoReaction = await pluginData.state.autoReactions.getForChannel(args.channelId);
if (!autoReaction) {
sendErrorMessage(pluginData, msg.channel, `Auto-reactions aren't enabled in <#${args.channelId}>`);
void pluginData.state.common.sendErrorMessage(msg, `Auto-reactions aren't enabled in <#${args.channelId}>`);
return;
}
await pluginData.state.autoReactions.removeFromChannel(args.channelId);
pluginData.state.cache.delete(args.channelId);
sendSuccessMessage(pluginData, msg.channel, `Auto-reactions disabled in <#${args.channelId}>`);
void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions disabled in <#${args.channelId}>`);
},
});

View file

@ -1,6 +1,5 @@
import { PermissionsBitField } from "discord.js";
import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { canUseEmoji, customEmojiRegex, isEmoji } from "../../../utils.js";
import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions.js";
import { missingPermissionError } from "../../../utils/missingPermissionError.js";
@ -25,9 +24,8 @@ export const NewAutoReactionsCmd = autoReactionsCmd({
const me = pluginData.guild.members.cache.get(pluginData.client.user!.id)!;
const missingPermissions = getMissingChannelPermissions(me, args.channel, requiredPermissions);
if (missingPermissions) {
sendErrorMessage(
pluginData,
msg.channel,
pluginData.state.common.sendErrorMessage(
msg,
`Cannot set auto-reactions for that channel. ${missingPermissionError(missingPermissions)}`,
);
return;
@ -35,7 +33,7 @@ export const NewAutoReactionsCmd = autoReactionsCmd({
for (const reaction of args.reactions) {
if (!isEmoji(reaction)) {
sendErrorMessage(pluginData, msg.channel, "One or more of the specified reactions were invalid!");
void pluginData.state.common.sendErrorMessage(msg, "One or more of the specified reactions were invalid!");
return;
}
@ -45,7 +43,10 @@ export const NewAutoReactionsCmd = autoReactionsCmd({
if (customEmojiMatch) {
// Custom emoji
if (!canUseEmoji(pluginData.client, customEmojiMatch[2])) {
sendErrorMessage(pluginData, msg.channel, "I can only use regular emojis and custom emojis from this server");
pluginData.state.common.sendErrorMessage(
msg,
"I can only use regular emojis and custom emojis from this server",
);
return;
}
@ -60,6 +61,6 @@ export const NewAutoReactionsCmd = autoReactionsCmd({
await pluginData.state.autoReactions.set(args.channel.id, finalReactions);
pluginData.state.cache.delete(args.channel.id);
sendSuccessMessage(pluginData, msg.channel, `Auto-reactions set for <#${args.channel.id}>`);
void pluginData.state.common.sendSuccessMessage(msg, `Auto-reactions set for <#${args.channel.id}>`);
},
});

View file

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

View file

@ -1,21 +1,23 @@
import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
import z from "zod";
import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand, pluginUtils } from "knub";
import z from "zod/v4";
import { GuildAutoReactions } from "../../data/GuildAutoReactions.js";
import { GuildLogs } from "../../data/GuildLogs.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { AutoReaction } from "../../data/entities/AutoReaction.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
export const zAutoReactionsConfig = z.strictObject({
can_manage: z.boolean(),
can_manage: z.boolean().default(false),
});
export interface AutoReactionsPluginType extends BasePluginType {
config: z.output<typeof zAutoReactionsConfig>;
configSchema: typeof zAutoReactionsConfig;
state: {
logs: GuildLogs;
savedMessages: GuildSavedMessages;
autoReactions: GuildAutoReactions;
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 { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap.js";
import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
import { CountersPlugin } from "../Counters/CountersPlugin.js";
import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin.js";
import { LogsPlugin } from "../Logs/LogsPlugin.js";
@ -33,29 +34,6 @@ import { clearOldRecentActions } from "./functions/clearOldRecentActions.js";
import { clearOldRecentSpam } from "./functions/clearOldRecentSpam.js";
import { AutomodPluginType, zAutomodConfig } from "./types.js";
const defaultOptions = {
config: {
rules: {},
antiraid_levels: ["low", "medium", "high"],
can_set_antiraid: false,
can_view_antiraid: false,
},
overrides: [
{
level: ">=50",
config: {
can_view_antiraid: true,
},
},
{
level: ">=100",
config: {
can_set_antiraid: true,
},
},
],
};
export const AutomodPlugin = guildPlugin<AutomodPluginType>()({
name: "automod",
@ -70,8 +48,7 @@ export const AutomodPlugin = guildPlugin<AutomodPluginType>()({
RoleManagerPlugin,
],
defaultOptions,
configParser: (input) => zAutomodConfig.parse(input),
configSchema: zAutomodConfig,
customOverrideCriteriaFunctions: {
antiraid_level: (pluginData, matchParams, value) => {
@ -117,6 +94,10 @@ export const AutomodPlugin = guildPlugin<AutomodPluginType>()({
state.cachedAntiraidLevel = await state.antiraidLevels.get();
},
beforeStart(pluginData) {
pluginData.state.common = pluginData.getPlugin(CommonPlugin);
},
async afterLoad(pluginData) {
const { state } = pluginData;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js";
import z from "zod";
import z from "zod/v4";
import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js";
import {
convertDelayStringToMS,

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 { zBoundedCharacters } from "../../../utils.js";
import { CountersPlugin } from "../../Counters/CountersPlugin.js";

View file

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

View file

@ -1,5 +1,5 @@
import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from "discord.js";
import z from "zod";
import z from "zod/v4";
import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter.js";
import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils.js";
import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects.js";

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 { CaseArgs } from "../../Cases/types.js";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin.js";
@ -33,7 +33,7 @@ export const WarnAction = automodAction({
const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToWarn) {
if (!member) continue;
await modActions.warnMember(member, reason, { contactMethods, caseArgs, isAutomodAction: true });
await modActions.warnMember(member, reason, reason, { contactMethods, caseArgs, isAutomodAction: true });
}
},
});

View file

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

View file

@ -1,6 +1,5 @@
import { guildPluginMessageCommand } from "knub";
import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { setAntiraidLevel } from "../functions/setAntiraidLevel.js";
import { AutomodPluginType } from "../types.js";
@ -15,11 +14,11 @@ export const SetAntiraidCmd = guildPluginMessageCommand<AutomodPluginType>()({
async run({ pluginData, message, args }) {
const config = pluginData.config.get();
if (!config.antiraid_levels.includes(args.level)) {
sendErrorMessage(pluginData, message.channel, "Unknown anti-raid level");
pluginData.state.common.sendErrorMessage(message, "Unknown anti-raid level");
return;
}
await setAntiraidLevel(pluginData, args.level, message.author);
sendSuccessMessage(pluginData, message.channel, `Anti-raid level set to **${args.level}**`);
pluginData.state.common.sendSuccessMessage(message, `Anti-raid level set to **${args.level}**`);
},
});

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,13 @@
import { GuildPluginData } from "knub";
import { AutomodContext, AutomodPluginType, TRule } from "../types.js";
export function checkCooldown(pluginData: GuildPluginData<AutomodPluginType>, rule: TRule, context: AutomodContext) {
const cooldownKey = `${rule.name}-${context.user?.id}`;
export function checkCooldown(
pluginData: GuildPluginData<AutomodPluginType>,
rule: TRule,
ruleName: string,
context: AutomodContext,
) {
const cooldownKey = `${ruleName}-${context.user?.id}`;
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 { humanizeDurationShort } from "../../../humanizeDurationShort.js";
import { humanizeDurationShort } from "../../../humanizeDuration.js";
import { getBaseUrl } from "../../../pluginUtils.js";
import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils.js";
import { RecentActionType } from "../constants.js";

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary.js";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage.js";

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
import { convertDelayStringToMS, zDelayString } from "../../../utils.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 { RecentActionType } from "../constants.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";
const configSchema = z.strictObject({});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js";
import { BasePluginType, CooldownManager } from "knub";
import z from "zod";
import { BasePluginType, CooldownManager, pluginUtils } from "knub";
import z from "zod/v4";
import { Queue } from "../../Queue.js";
import { RegExpRunner } from "../../RegExpRunner.js";
import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels.js";
@ -9,6 +9,7 @@ import { GuildLogs } from "../../data/GuildLogs.js";
import { GuildSavedMessages } from "../../data/GuildSavedMessages.js";
import { SavedMessage } from "../../data/entities/SavedMessage.js";
import { entries, zBoundedRecord, zDelayString } from "../../utils.js";
import { CommonPlugin } from "../Common/CommonPlugin.js";
import { CounterEvents } from "../Counters/types.js";
import { ModActionType, ModActionsEvents } from "../ModActions/types.js";
import { MutesEvents } from "../Mutes/types.js";
@ -45,22 +46,6 @@ const zActionsMap = z
const zRule = z.strictObject({
enabled: z.boolean().default(true),
// Typed as "never" because you are not expected to supply this directly.
// The transform instead picks it up from the property key and the output type is a string.
name: z
.never()
.optional()
.transform((_, ctx) => {
const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
if (!ruleName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Automod rules must have names",
});
return z.NEVER;
}
return ruleName;
}),
pretty_name: z.string().optional(),
presets: z.array(z.string().max(100)).max(25).default([]),
affects_bots: z.boolean().default(false),
@ -68,21 +53,19 @@ const zRule = z.strictObject({
cooldown: zDelayString.nullable().default(null),
allow_further_rules: z.boolean().default(false),
triggers: z.array(zTriggersMap),
actions: zActionsMap.refine((v) => !(v.clean && v.start_thread), {
message: "Cannot have both clean and start_thread active at the same time",
}),
actions: zActionsMap,
});
export type TRule = z.infer<typeof zRule>;
export const zAutomodConfig = z.strictObject({
rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255),
antiraid_levels: z.array(z.string().max(100)).max(10),
can_set_antiraid: z.boolean(),
can_view_antiraid: z.boolean(),
rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255).default({}),
antiraid_levels: z.array(z.string().max(100)).max(10).default(["low", "medium", "high"]),
can_set_antiraid: z.boolean().default(false),
can_view_antiraid: z.boolean().default(false),
});
export interface AutomodPluginType extends BasePluginType {
config: z.output<typeof zAutomodConfig>;
configSchema: typeof zAutomodConfig;
customOverrideCriteria: {
antiraid_level?: string;
@ -140,6 +123,8 @@ export interface AutomodPluginType extends BasePluginType {
modActionsListeners: Map<keyof ModActionsEvents, 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 { Configs } from "../../data/Configs.js";
import { GuildArchives } from "../../data/GuildArchives.js";
import { sendSuccessMessage } from "../../pluginUtils.js";
import { getActiveReload, resetActiveReload } from "./activeReload.js";
import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd.js";
import { AddServerFromInviteCmd } from "./commands/AddServerFromInviteCmd.js";
@ -24,21 +23,9 @@ import { RestPerformanceCmd } from "./commands/RestPerformanceCmd.js";
import { ServersCmd } from "./commands/ServersCmd.js";
import { BotControlPluginType, zBotControlConfig } from "./types.js";
const defaultOptions = {
config: {
can_use: false,
can_eligible: false,
can_performance: false,
can_add_server_from_invite: false,
can_list_dashboard_perms: false,
update_cmd: null,
},
};
export const BotControlPlugin = globalPlugin<BotControlPluginType>()({
name: "bot_control",
configParser: (input) => zBotControlConfig.parse(input),
defaultOptions,
configSchema: zBotControlConfig,
// prettier-ignore
messageCommands: [
@ -77,7 +64,7 @@ export const BotControlPlugin = globalPlugin<BotControlPluginType>()({
if (guild) {
const channel = guild.channels.cache.get(channelId as Snowflake);
if (channel instanceof TextChannel) {
sendSuccessMessage(pluginData, channel, "Global plugins reloaded!");
void channel.send("Global plugins reloaded!");
}
}
}

View file

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

View file

@ -1,7 +1,6 @@
import { ApiPermissions } from "@zeppelinbot/shared/apiPermissions.js";
import moment from "moment-timezone";
import { commandTypeHelpers as ct } from "../../../commandTypes.js";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils.js";
import { DBDateFormat, isGuildInvite, resolveInvite } from "../../../utils.js";
import { isEligible } from "../functions/isEligible.js";
import { botControlCmd } from "../types.js";
@ -18,19 +17,19 @@ export const AddServerFromInviteCmd = botControlCmd({
async run({ pluginData, message: msg, args }) {
const invite = await resolveInvite(pluginData.client, args.inviteCode, true);
if (!invite || !isGuildInvite(invite)) {
sendErrorMessage(pluginData, msg.channel, "Could not resolve invite"); // :D
void msg.channel.send("Could not resolve invite"); // :D
return;
}
const existing = await pluginData.state.allowedGuilds.find(invite.guild.id);
if (existing) {
sendErrorMessage(pluginData, msg.channel, "Server is already allowed!");
void msg.channel.send("Server is already allowed!");
return;
}
const { result, explanation } = await isEligible(pluginData, args.user, invite);
if (!result) {
sendErrorMessage(pluginData, msg.channel, `Could not add server because it's not eligible: ${explanation}`);
msg.channel.send(`Could not add server because it's not eligible: ${explanation}`);
return;
}
@ -51,6 +50,6 @@ export const AddServerFromInviteCmd = botControlCmd({
);
}
sendSuccessMessage(pluginData, msg.channel, "Server was eligible and is now allowed to use Zeppelin!");
msg.channel.send("Server was eligible and is now allowed to use Zeppelin!");
},
});

View file

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

View file

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

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