From 51e148df3000a1a11c777b2994eb33475ac83b01 Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 22:27:07 +0000 Subject: [PATCH 01/42] Add image-spam automod with moderator console Introduces a detection engine for the image-spam pattern slipping past our filters: clustered image posts from both fresh accounts and compromised veterans. Detection uses Discord attachment metadata only (no downloads): - Image burst: N image messages from one user in a short window (low confidence -> alert moderators only). - Cross-channel fan-out: same image across multiple channels in a window (high confidence) -> catches compromised veterans where account age is useless. - Known-spam blocklist: moderator-confirmed image fingerprints are auto-actioned on sight. Tiered enforcement: high-confidence hits auto-delete and timeout, then alert; bursts alert only. Alerts post to MOD_LOG_CHANNEL_ID with action buttons (confirm / timeout / ban / delete / dismiss) keeping a human in the loop. Members with Manage Messages or a configured immune role are skipped. Persistence is optional: the hot path is in-memory, with a Postgres-backed blocklist that gracefully no-ops when no database is configured. Adds a docker-compose stack (bot + Postgres), an initial Prisma migration, and .env.example. Also fixes the pre-existing compile errors that were blocking `npm run build` (ping.ts async/typo, tags.ts typing, unused listener params) so the project once again builds to a deployable state. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- .env.example | 30 ++++ README.md | 49 ++++++ docker-compose.yml | 43 +++++ package-lock.json | 4 +- package.json | 3 +- prisma/migrations/0_init/migration.sql | 51 ++++++ prisma/migrations/migration_lock.toml | 1 + prisma/schema.prisma | 19 +++ src/commands/ping.ts | 4 +- src/interaction-handlers/spamModeration.ts | 173 ++++++++++++++++++++ src/listeners/memberAdd.ts | 2 +- src/listeners/memberRemove.ts | 2 +- src/listeners/messageCreate.ts | 122 +++++++++++++- src/listeners/ready.ts | 6 +- src/utils/automod/blocklist.ts | 59 +++++++ src/utils/automod/console.ts | 177 +++++++++++++++++++++ src/utils/automod/incidents.ts | 53 ++++++ src/utils/automod/signature.ts | 38 +++++ src/utils/automod/tracker.ts | 134 ++++++++++++++++ src/utils/config.ts | 43 ++++- src/utils/db.ts | 45 ++++-- src/utils/tags.ts | 16 +- 22 files changed, 1047 insertions(+), 27 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100644 prisma/migrations/0_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/interaction-handlers/spamModeration.ts create mode 100644 src/utils/automod/blocklist.ts create mode 100644 src/utils/automod/console.ts create mode 100644 src/utils/automod/incidents.ts create mode 100644 src/utils/automod/signature.ts create mode 100644 src/utils/automod/tracker.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..883cf6e --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# --- Required --- +# Discord bot token (https://discord.com/developers/applications) +BOT_TOKEN= + +# --- Core IDs (defaults target the official Arduino server) --- +# SERVER_ID=420594746990526466 +# BOT_COMMANDS_CHANNEL_ID=451158319361556491 + +# Channel where the automod posts image-spam alerts for moderators. +# REQUIRED to enable the anti-spam console; leave empty to disable automod. +MOD_LOG_CHANNEL_ID= + +# --- Persistence (optional) --- +# When unset, the bot runs fully in-memory and the spam-image blocklist +# resets on restart. With docker-compose this is wired automatically. +# DATABASE_URL=postgresql://arduino:arduino@db:5432/arduino + +# Postgres credentials used by docker-compose +# POSTGRES_USER=arduino +# POSTGRES_PASSWORD=arduino +# POSTGRES_DB=arduino + +# --- Automod tunables (optional; defaults in src/utils/config.ts) --- +# AUTOMOD_BURST_THRESHOLD=3 # image messages to flag a same-channel burst +# AUTOMOD_BURST_WINDOW_MS=60000 # window for the burst count +# AUTOMOD_FANOUT_CHANNELS=2 # distinct channels for a cross-channel fan-out +# AUTOMOD_FANOUT_WINDOW_MS=120000 # window for fan-out detection +# AUTOMOD_TIMEOUT_MS=3600000 # auto/console timeout duration (1 hour) +# AUTOMOD_ALERT_COOLDOWN_MS=30000 # min gap between alerts per user +# AUTOMOD_IMMUNE_ROLE_IDS= # comma-separated role ids never inspected diff --git a/README.md b/README.md index 9288e22..22a75c0 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,63 @@ All commands are used as Discord slash commands (type `/` in Discord): `/tag name:power` — Sends information about powering Arduino boards to the bot-commands channel. `/tag name:avrdude user:@someuser` — Sends AVRDUDE troubleshooting info to the bot-commands channel and pings `@someuser`. +## Image-spam automod + +The bot watches for the image-spam pattern that has been slipping past our other +filters: accounts (both freshly-joined and compromised long-time members) +posting **clusters of images** to advertise. It complements YAGPDB rather than +replacing it, and never disables image sharing for the server. + +**How it detects spam (no images are downloaded — it uses Discord's attachment +metadata only):** + +- **Image burst** — several image messages from one user in a short window + (default: 3 in 60s). *Lower confidence → alerts moderators only.* +- **Cross-channel fan-out** — the same image posted across multiple channels in + a short window (default: 2+ channels). This is the strongest signal and catches + compromised veterans, where account age is useless. *High confidence.* +- **Known-spam blocklist** — once a moderator confirms an alert, that image's + fingerprint is blocklisted so repeat campaigns are caught instantly. *High + confidence.* + +**Tiered response:** high-confidence hits auto-delete the messages and timeout +the user, then post an alert; bursts only post an alert. Every alert lands in the +mod-log channel with action buttons — **Confirm spam / Timeout / Ban / Delete +msgs / Not spam** — so a human stays in the loop. Members with Manage Messages +(or a configured immune role) are never inspected. + +**Required bot permissions:** Manage Messages (delete), Moderate Members +(timeout), Ban Members (ban), plus the **Message Content** privileged intent +(already enabled in `index.ts`). Set `MOD_LOG_CHANNEL_ID` to enable the console; +leaving it unset disables the automod entirely. + ## Environment Variables & Configuration This bot requires the following environment variables to be set: - `BOT_TOKEN`: Your Discord bot token. +- `MOD_LOG_CHANNEL_ID`: Channel for image-spam alerts. Required to enable the + automod; leave unset to disable it. + +Optional: + +- `DATABASE_URL`: Postgres connection string. Without it the bot runs fully + in-memory and the spam-image blocklist resets on restart. +- Automod thresholds (`AUTOMOD_BURST_THRESHOLD`, `AUTOMOD_FANOUT_CHANNELS`, + `AUTOMOD_IMMUNE_ROLE_IDS`, …) — see [`.env.example`](.env.example). > Additional configuration options can be set in `config.ts`. +### Running with Docker + +A [`docker-compose.yml`](docker-compose.yml) bundles the bot with a Postgres +instance for blocklist persistence: + +```bash +cp .env.example .env # fill in BOT_TOKEN and MOD_LOG_CHANNEL_ID +docker compose up -d # applies DB migrations, then starts the bot +``` + ## Contributing diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..828af7a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + bot: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + # Pulled from the .env file (see .env.example). DATABASE_URL points at + # the bundled Postgres service below. + BOT_TOKEN: ${BOT_TOKEN} + SERVER_ID: ${SERVER_ID:-} + BOT_COMMANDS_CHANNEL_ID: ${BOT_COMMANDS_CHANNEL_ID:-} + MOD_LOG_CHANNEL_ID: ${MOD_LOG_CHANNEL_ID:-} + DATABASE_URL: postgresql://${POSTGRES_USER:-arduino}:${POSTGRES_PASSWORD:-arduino}@db:5432/${POSTGRES_DB:-arduino} + # Optional automod tunables (defaults live in src/utils/config.ts) + AUTOMOD_BURST_THRESHOLD: ${AUTOMOD_BURST_THRESHOLD:-} + AUTOMOD_BURST_WINDOW_MS: ${AUTOMOD_BURST_WINDOW_MS:-} + AUTOMOD_FANOUT_CHANNELS: ${AUTOMOD_FANOUT_CHANNELS:-} + AUTOMOD_FANOUT_WINDOW_MS: ${AUTOMOD_FANOUT_WINDOW_MS:-} + AUTOMOD_TIMEOUT_MS: ${AUTOMOD_TIMEOUT_MS:-} + AUTOMOD_ALERT_COOLDOWN_MS: ${AUTOMOD_ALERT_COOLDOWN_MS:-} + AUTOMOD_IMMUNE_ROLE_IDS: ${AUTOMOD_IMMUNE_ROLE_IDS:-} + # Apply any pending Prisma migrations, then start the bot. + command: sh -c "npx prisma migrate deploy && npm start" + + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-arduino} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-arduino} + POSTGRES_DB: ${POSTGRES_DB:-arduino} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-arduino}'] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/package-lock.json b/package-lock.json index da3bc31..8876885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arduino-bot", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arduino-bot", - "version": "0.2.0", + "version": "0.3.0", "license": "GPL-3.0-or-later", "dependencies": { "@prisma/client": "^4.16.2", diff --git a/package.json b/package.json index c5c2cb4..b68c1ce 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "start": "node ./dist/src/index.js", "build": "tsc -p .", - "dev": "tsnd --respawn --transpile-only --exit-child ." + "dev": "tsnd --respawn --transpile-only --exit-child .", + "postinstall": "prisma generate" }, "repository": { "type": "git", diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..bfdd704 --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,51 @@ +-- CreateEnum +CREATE TYPE "MemberAnalyticsEvent" AS ENUM ('join', 'leave'); + +-- CreateTable +CREATE TABLE "MemberAnalytics" ( + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "event" "MemberAnalyticsEvent" NOT NULL, + "memberId" VARCHAR NOT NULL, + + CONSTRAINT "MemberAnalytics_pkey" PRIMARY KEY ("time") +); + +-- CreateTable +CREATE TABLE "MessageAnalytics" ( + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "memberId" VARCHAR, + "channelId" VARCHAR, + + CONSTRAINT "MessageAnalytics_pk" PRIMARY KEY ("time") +); + +-- CreateTable +CREATE TABLE "CommandAnalytics" ( + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "command" VARCHAR, + + CONSTRAINT "CommandAnalytics_pk" PRIMARY KEY ("time") +); + +-- CreateTable +CREATE TABLE "SpamSignature" ( + "signature" VARCHAR NOT NULL, + "addedBy" VARCHAR NOT NULL, + "reason" VARCHAR, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SpamSignature_pkey" PRIMARY KEY ("signature") +); + +-- CreateTable +CREATE TABLE "ModerationAction" ( + "id" TEXT NOT NULL, + "time" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "moderatorId" VARCHAR NOT NULL, + "targetId" VARCHAR NOT NULL, + "action" VARCHAR NOT NULL, + "reason" VARCHAR, + + CONSTRAINT "ModerationAction_pkey" PRIMARY KEY ("id") +); + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d14cc32..85a677c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,3 +28,22 @@ enum MemberAnalyticsEvent { join leave } + +/// Blocklist of image fingerprints confirmed to be spam by a moderator. +/// New uploads matching one of these are auto-actioned. +model SpamSignature { + signature String @id @db.VarChar + addedBy String @db.VarChar + reason String? @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) +} + +/// Audit trail of automod-related moderation actions. +model ModerationAction { + id String @id @default(uuid()) + time DateTime @default(now()) @db.Timestamptz(6) + moderatorId String @db.VarChar + targetId String @db.VarChar + action String @db.VarChar + reason String? @db.VarChar +} diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 3d184ba..884bf1f 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -19,7 +19,7 @@ export class PingCommand extends Command { }); } - public override chatInputRun(interaction: Command.ChatInputCommandInteraction) { + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { const sent = await interaction.deferReply({ fetchReply: true }); const latency = sent.createdTimestamp - interaction.createdTimestamp; @@ -41,7 +41,7 @@ export class PingCommand extends Command { } ]) .setFooter({ text: 'Arduino server' }) - .setTimestap(); + .setTimestamp(); return interaction.editReply({ embeds: [embed] }); diff --git a/src/interaction-handlers/spamModeration.ts b/src/interaction-handlers/spamModeration.ts new file mode 100644 index 0000000..b841dc3 --- /dev/null +++ b/src/interaction-handlers/spamModeration.ts @@ -0,0 +1,173 @@ +import { + InteractionHandler, + InteractionHandlerTypes, + container, +} from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ComponentType, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, + type ButtonComponent, + type ButtonInteraction, +} from 'discord.js'; +import { deleteIncident, getIncident } from '../utils/automod/incidents'; +import { addToBlocklist } from '../utils/automod/blocklist'; +import { + banMember, + deleteIncidentMessages, + logModerationAction, + timeoutMember, +} from '../utils/automod/console'; +import { clearUser } from '../utils/automod/tracker'; + +interface ParsedButton { + action: string; + incidentId: string; +} + +export class SpamModerationHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + if (!interaction.customId.startsWith('automod:')) return this.none(); + const [, action, incidentId] = interaction.customId.split(':'); + return this.some({ action, incidentId }); + } + + public async run(interaction: ButtonInteraction, parsed: ParsedButton) { + // Only staff (anyone who can delete messages) may action alerts. + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) + return interaction.reply({ + content: 'You need the Manage Messages permission to action spam alerts.', + flags: MessageFlags.Ephemeral, + }); + + const incident = getIncident(parsed.incidentId); + if (!incident || !interaction.guild) + return interaction.reply({ + content: + 'This alert has expired (the incident is no longer tracked, likely due to a bot restart). Please action the user manually.', + flags: MessageFlags.Ephemeral, + }); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const { guild } = interaction; + const moderator = interaction.user; + let summary: string; + + switch (parsed.action) { + case 'confirm': { + const deleted = await deleteIncidentMessages( + container.client, + incident + ); + const timedOut = await timeoutMember( + guild, + incident.userId, + `Confirmed image spam by ${moderator.tag}` + ); + await addToBlocklist( + incident.signatures, + moderator.id, + 'confirmed image spam' + ); + clearUser(incident.userId); + summary = `✅ Confirmed spam — deleted ${deleted} message(s), ${ + timedOut ? 'timed out the user' : '**could not** time out the user' + }, and blocklisted ${incident.signatures.length} image signature(s).`; + break; + } + case 'timeout': { + const ok = await timeoutMember( + guild, + incident.userId, + `Automod review by ${moderator.tag}` + ); + summary = ok + ? '⏳ User timed out.' + : '⚠️ Could not time out the user (check role hierarchy and permissions).'; + break; + } + case 'ban': { + const ok = await banMember( + guild, + incident.userId, + `Automod review by ${moderator.tag}` + ); + clearUser(incident.userId); + summary = ok + ? '🔨 User banned and recent messages purged.' + : '⚠️ Could not ban the user (check role hierarchy and permissions).'; + break; + } + case 'delete': { + const deleted = await deleteIncidentMessages( + container.client, + incident + ); + summary = `🗑️ Deleted ${deleted} message(s).`; + break; + } + case 'dismiss': { + clearUser(incident.userId); + summary = + '👌 Marked as not spam. Cleared tracking for this user; no action taken.'; + break; + } + default: + summary = 'Unknown action.'; + } + + await logModerationAction( + moderator.id, + incident.userId, + parsed.action, + incident.reason + ); + deleteIncident(parsed.incidentId); + await this.resolveAlert(interaction, summary); + return interaction.editReply({ content: summary }); + } + + /** Disable the alert's buttons and stamp it with the resolution. */ + private async resolveAlert( + interaction: ButtonInteraction, + summary: string + ): Promise { + const disabledRows = interaction.message.components + .filter((row) => row.type === ComponentType.ActionRow) + .map((row) => { + const rebuilt = new ActionRowBuilder(); + for (const component of row.components) + if (component.type === ComponentType.Button) + rebuilt.addComponents( + ButtonBuilder.from(component as ButtonComponent).setDisabled(true) + ); + return rebuilt; + }); + + const original = interaction.message.embeds[0]; + const embed = (original ? EmbedBuilder.from(original) : new EmbedBuilder()) + .setColor(0x868e96) + .addFields({ + name: 'Resolution', + value: `${summary}\nActioned by <@${interaction.user.id}>`, + }); + + await interaction.message + .edit({ embeds: [embed], components: disabledRows }) + .catch(() => null); + } +} diff --git a/src/listeners/memberAdd.ts b/src/listeners/memberAdd.ts index 399e0e2..a5b52b3 100644 --- a/src/listeners/memberAdd.ts +++ b/src/listeners/memberAdd.ts @@ -7,7 +7,7 @@ export class MemberAddListener extends Listener { super(context, { ...options, event: Events.GuildMemberAdd }); } - public async run(member: GuildMember) { + public async run(_member: GuildMember) { // await prisma.memberAnalytics.create({ // data: { event: 'join', memberId: member.user.id }, // }); diff --git a/src/listeners/memberRemove.ts b/src/listeners/memberRemove.ts index 0c9e6a9..d2a9393 100644 --- a/src/listeners/memberRemove.ts +++ b/src/listeners/memberRemove.ts @@ -7,7 +7,7 @@ export class MemberRemoveListener extends Listener { super(context, { ...options, event: Events.GuildMemberRemove }); } - public async run(member: GuildMember) { + public async run(_member: GuildMember) { // await prisma.memberAnalytics.create({ // data: { event: 'leave', memberId: member.user.id }, // }); diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 2f3dfac..122abcc 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -1,6 +1,23 @@ -import { Events, Listener } from '@sapphire/framework'; -import type { Message } from 'discord.js'; -// import { prisma } from '../utils/db'; +import { Events, Listener, container } from '@sapphire/framework'; +import { PermissionFlagsBits, type GuildMember, type Message } from 'discord.js'; +import { SERVER_ID, MOD_LOG_CHANNEL_ID, automodConfig } from '../utils/config'; +import { imageSignatures } from '../utils/automod/signature'; +import { + recordImageMessage, + shouldAlert, + markAlerted, +} from '../utils/automod/tracker'; +import { isBlocklisted } from '../utils/automod/blocklist'; +import { + createIncident, + type Incident, + type IncidentLevel, +} from '../utils/automod/incidents'; +import { + buildAlertPayload, + deleteIncidentMessages, + timeoutMember, +} from '../utils/automod/console'; export class MessageCreateListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { @@ -8,8 +25,101 @@ export class MessageCreateListener extends Listener { } public async run(message: Message) { - // await prisma.messageAnalytics.create({ - // data: { memberId: message.author.id, channelId: message.channel.id }, - // }); + if (!message.inGuild() || message.author.bot) return; + if (message.guildId !== SERVER_ID) return; + // Without a console channel there is nowhere to surface alerts. + if (!MOD_LOG_CHANNEL_ID) return; + if (message.member && this.isImmune(message.member)) return; + + const signatures = imageSignatures(message); + if (signatures.length === 0) return; + + const now = Date.now(); + const detection = recordImageMessage(message.author.id, { + at: now, + channelId: message.channelId, + messageId: message.id, + signatures, + }); + + const { blocked, matched } = await isBlocklisted(signatures); + + let level: IncidentLevel | null = null; + if (blocked) level = 'blocklist'; + else if (detection.level !== 'none') level = detection.level; + if (!level) return; + + if (!shouldAlert(message.author.id, now)) return; + markAlerted(message.author.id, now); + + // For blocklist-only hits the detector found no cluster, so the incident is + // just the current message; otherwise use the contributing events. + const messages = + detection.level === 'none' + ? [{ channelId: message.channelId, messageId: message.id }] + : detection.events.map((e) => ({ + channelId: e.channelId, + messageId: e.messageId, + })); + + const incident = createIncident({ + userId: message.author.id, + guildId: message.guildId, + level, + reason: + level === 'blocklist' + ? `Matched ${matched.length} known spam image${matched.length === 1 ? '' : 's'}` + : detection.reason, + messages, + signatures: level === 'blocklist' ? matched : detection.signatures, + }); + + // Tiered enforcement: high-confidence signals act immediately; a + // same-channel burst only alerts and waits for a human. + const highConfidence = level === 'fanout' || level === 'blocklist'; + let autoActed = false; + if (highConfidence) { + const deleted = await deleteIncidentMessages( + container.client, + incident + ).catch(() => 0); + const timedOut = await timeoutMember( + message.guild, + message.author.id, + `Automod: ${incident.reason}` + ).catch(() => false); + autoActed = deleted > 0 || timedOut; + } + + await this.postAlert(message, incident, autoActed); + } + + private isImmune(member: GuildMember): boolean { + if (member.permissions.has(PermissionFlagsBits.ManageMessages)) return true; + return automodConfig.immuneRoleIds.some((roleId) => + member.roles.cache.has(roleId) + ); + } + + private async postAlert( + message: Message, + incident: Incident, + autoActed: boolean + ): Promise { + const channel = await container.client.channels + .fetch(MOD_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel || !channel.isSendable()) { + container.logger.warn( + `Automod: MOD_LOG_CHANNEL_ID ${MOD_LOG_CHANNEL_ID} is not a sendable channel; cannot post alert.` + ); + return; + } + + const member = + message.member ?? + (await message.guild.members.fetch(message.author.id).catch(() => null)); + + await channel.send(buildAlertPayload(incident, member, autoActed)); } } diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index 2408e55..5680ab8 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -1,15 +1,19 @@ import { Events, Listener } from '@sapphire/framework'; import type { Client } from 'discord.js'; +import { initDatabase } from '../utils/db'; export class ReadyListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { super(context, { ...options, once: true, event: Events.ClientReady }); } - public run({ user }: Client) { + public async run({ user }: Client) { const { username, id, discriminator } = user!; this.container.logger.info( `Logged in as ${username}#${discriminator} (${id})` ); + + // Connect persistence if configured; the bot runs in-memory otherwise. + await initDatabase(); } } diff --git a/src/utils/automod/blocklist.ts b/src/utils/automod/blocklist.ts new file mode 100644 index 0000000..b859b68 --- /dev/null +++ b/src/utils/automod/blocklist.ts @@ -0,0 +1,59 @@ +import { container } from '@sapphire/framework'; +import { getPrisma } from '../db'; + +/** + * In-memory mirror of the blocklist. Always consulted first so the feature + * works even with no database, and so confirmed-spam signatures take effect + * immediately within the running process. + */ +const memory = new Set(); + +export interface BlocklistMatch { + blocked: boolean; + matched: string[]; +} + +/** Check whether any of the given signatures is a known spam image. */ +export async function isBlocklisted( + signatures: string[] +): Promise { + const matched = new Set(signatures.filter((s) => memory.has(s))); + + const prisma = getPrisma(); + if (prisma) { + try { + const rows = await prisma.spamSignature.findMany({ + where: { signature: { in: signatures } }, + select: { signature: true }, + }); + for (const row of rows) { + matched.add(row.signature); + memory.add(row.signature); // warm the in-memory cache + } + } catch (error) { + container.logger.error('Blocklist lookup failed:', error); + } + } + + return { blocked: matched.size > 0, matched: [...matched] }; +} + +/** Add signatures to the blocklist (in-memory always; persisted if possible). */ +export async function addToBlocklist( + signatures: string[], + addedBy: string, + reason: string +): Promise { + for (const signature of signatures) memory.add(signature); + + const prisma = getPrisma(); + if (!prisma) return; + try { + await prisma.spamSignature.createMany({ + data: signatures.map((signature) => ({ signature, addedBy, reason })), + skipDuplicates: true, + }); + } catch (error) { + container.logger.error('Persisting blocklist signatures failed:', error); + } +} diff --git a/src/utils/automod/console.ts b/src/utils/automod/console.ts new file mode 100644 index 0000000..6ecb40b --- /dev/null +++ b/src/utils/automod/console.ts @@ -0,0 +1,177 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + TimestampStyles, + time, + type Client, + type Guild, + type GuildMember, + type MessageCreateOptions, +} from 'discord.js'; +import { container } from '@sapphire/framework'; +import { automodConfig } from '../config'; +import { getPrisma } from '../db'; +import type { Incident, IncidentLevel } from './incidents'; + +const LEVEL_COLOR: Record = { + fanout: 0xe03131, // high confidence — red + blocklist: 0xe03131, + burst: 0xf08c00, // needs review — amber +}; + +const LEVEL_LABEL: Record = { + fanout: 'Cross-channel fan-out', + blocklist: 'Known spam image', + burst: 'Image burst', +}; + +/** One moderation action button bound to an incident id. */ +function actionButton( + action: string, + label: string, + style: ButtonStyle, + incidentId: string +): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(`automod:${action}:${incidentId}`) + .setLabel(label) + .setStyle(style); +} + +/** Build the alert message moderators see in the console channel. */ +export function buildAlertPayload( + incident: Incident, + member: GuildMember | null, + autoActed: boolean +): MessageCreateOptions { + const channelMentions = + [...new Set(incident.messages.map((m) => `<#${m.channelId}>`))].join(' ') || + '—'; + + const jumpLinks = + incident.messages + .slice(0, 5) + .map( + (m, i) => + `[#${i + 1}](https://discord.com/channels/${incident.guildId}/${m.channelId}/${m.messageId})` + ) + .join(' • ') || '—'; + + const embed = new EmbedBuilder() + .setColor(LEVEL_COLOR[incident.level]) + .setTitle('🚨 Possible image spam') + .setDescription(`<@${incident.userId}> \`${incident.userId}\``) + .addFields( + { + name: 'Signal', + value: `**${LEVEL_LABEL[incident.level]}** — ${incident.reason}`, + }, + { name: 'Channels', value: channelMentions, inline: true }, + { name: 'Messages', value: String(incident.messages.length), inline: true } + ); + + if (member) { + embed + .setThumbnail(member.displayAvatarURL()) + .setFooter({ text: member.user.tag }) + .addFields( + { + name: 'Account created', + value: time(member.user.createdAt, TimestampStyles.RelativeTime), + inline: true, + }, + { + name: 'Joined server', + value: member.joinedAt + ? time(member.joinedAt, TimestampStyles.RelativeTime) + : 'unknown', + inline: true, + } + ); + } + + embed.addFields({ name: 'Jump to messages', value: jumpLinks }); + + if (autoActed) + embed.addFields({ + name: '🔒 Auto-action taken', + value: + 'High-confidence signal: the messages were deleted and the user was timed out automatically. Review and escalate or reverse below.', + }); + + const row1 = new ActionRowBuilder().addComponents( + actionButton('confirm', '✅ Confirm spam', ButtonStyle.Danger, incident.id), + actionButton('timeout', '⏳ Timeout', ButtonStyle.Secondary, incident.id), + actionButton('ban', '🔨 Ban', ButtonStyle.Danger, incident.id) + ); + const row2 = new ActionRowBuilder().addComponents( + actionButton('delete', '🗑️ Delete msgs', ButtonStyle.Secondary, incident.id), + actionButton('dismiss', '👌 Not spam', ButtonStyle.Success, incident.id) + ); + + return { embeds: [embed], components: [row1, row2] }; +} + +/** Apply a timeout to a member. Returns false if blocked by hierarchy/perms. */ +export async function timeoutMember( + guild: Guild, + userId: string, + reason: string +): Promise { + const member = await guild.members.fetch(userId).catch(() => null); + if (!member || !member.moderatable) return false; + return member + .timeout(automodConfig.timeoutMs, reason) + .then(() => true) + .catch(() => false); +} + +/** Ban a member and scrub their last day of messages. */ +export async function banMember( + guild: Guild, + userId: string, + reason: string +): Promise { + return guild.members + .ban(userId, { reason, deleteMessageSeconds: 24 * 60 * 60 }) + .then(() => true) + .catch(() => false); +} + +/** Delete every message recorded on an incident. Returns the count deleted. */ +export async function deleteIncidentMessages( + client: Client, + incident: Incident +): Promise { + let deleted = 0; + for (const { channelId, messageId } of incident.messages) { + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel || !channel.isTextBased() || channel.isDMBased()) continue; + const ok = await channel.messages + .delete(messageId) + .then(() => true) + .catch(() => false); + if (ok) deleted++; + } + return deleted; +} + +/** Best-effort audit log of a moderation action (no-op without a database). */ +export async function logModerationAction( + moderatorId: string, + targetId: string, + action: string, + reason: string +): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + await prisma.moderationAction.create({ + data: { moderatorId, targetId, action, reason }, + }); + } catch (error) { + container.logger.error('Logging moderation action failed:', error); + } +} diff --git a/src/utils/automod/incidents.ts b/src/utils/automod/incidents.ts new file mode 100644 index 0000000..4562f0d --- /dev/null +++ b/src/utils/automod/incidents.ts @@ -0,0 +1,53 @@ +import { randomUUID } from 'node:crypto'; +import type { DetectionLevel } from './tracker'; + +export type IncidentLevel = Exclude | 'blocklist'; + +export interface IncidentMessage { + channelId: string; + messageId: string; +} + +export interface Incident { + id: string; + userId: string; + guildId: string; + level: IncidentLevel; + reason: string; + messages: IncidentMessage[]; + /** Image signatures to blocklist if a moderator confirms this is spam. */ + signatures: string[]; + createdAt: number; +} + +/** + * In-memory store of open incidents, keyed by a short id embedded in the + * moderation buttons' custom ids. Incidents are intentionally ephemeral: if + * the bot restarts, open alerts simply expire (their buttons report this). + */ +const incidents = new Map(); +const TTL_MS = 60 * 60 * 1000; + +export function createIncident( + data: Omit +): Incident { + const incident: Incident = { + ...data, + id: randomUUID().slice(0, 8), + createdAt: Date.now(), + }; + incidents.set(incident.id, incident); + return incident; +} + +export const getIncident = (id: string): Incident | undefined => + incidents.get(id); + +export const deleteIncident = (id: string): boolean => incidents.delete(id); + +const sweep = setInterval(() => { + const horizon = Date.now() - TTL_MS; + for (const [id, incident] of incidents) + if (incident.createdAt < horizon) incidents.delete(id); +}, 10 * 60_000); +sweep.unref(); diff --git a/src/utils/automod/signature.ts b/src/utils/automod/signature.ts new file mode 100644 index 0000000..389f5cb --- /dev/null +++ b/src/utils/automod/signature.ts @@ -0,0 +1,38 @@ +import type { Attachment, Message } from 'discord.js'; + +/** + * A cheap, download-free fingerprint of an image attachment built from + * metadata Discord already gives us (content type, byte size, dimensions). + * + * Re-uploads of the *same* image file produce an identical signature, which is + * enough to spot the "same advert fanned across several channels" pattern + * without ever fetching image bytes. It is intentionally conservative: it can + * miss re-encoded/resized variants (a future perceptual-hash upgrade would + * catch those), but it never decodes untrusted media and costs nothing on the + * hot path. + */ +export function attachmentSignature(attachment: Attachment): string { + const type = attachment.contentType ?? 'image/unknown'; + const dimensions = + attachment.width && attachment.height + ? `${attachment.width}x${attachment.height}` + : 'na'; + return `${type}|${attachment.size}|${dimensions}`; +} + +const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|avif)$/i; + +/** Whether an attachment is an image we should inspect. */ +export function isImageAttachment(attachment: Attachment): boolean { + if (attachment.contentType) + return attachment.contentType.startsWith('image/'); + // Fall back to the file extension when Discord omits the content type. + return IMAGE_EXTENSION.test(attachment.name ?? ''); +} + +/** Signatures for every image attachment on a message (empty if none). */ +export function imageSignatures(message: Message): string[] { + return [...message.attachments.values()] + .filter(isImageAttachment) + .map(attachmentSignature); +} diff --git a/src/utils/automod/tracker.ts b/src/utils/automod/tracker.ts new file mode 100644 index 0000000..9a5315b --- /dev/null +++ b/src/utils/automod/tracker.ts @@ -0,0 +1,134 @@ +import { automodConfig } from '../config'; + +export interface ImageEvent { + at: number; + channelId: string; + messageId: string; + signatures: string[]; +} + +export type DetectionLevel = 'none' | 'burst' | 'fanout'; + +export interface Detection { + level: DetectionLevel; + reason: string; + /** The image events that contributed to this verdict. */ + events: ImageEvent[]; + /** Distinct channels involved. */ + channels: string[]; + /** Distinct image signatures involved. */ + signatures: string[]; +} + +const NONE: Detection = { + level: 'none', + reason: '', + events: [], + channels: [], + signatures: [], +}; + +/** Per-user recent image events, pruned to the longest detection window. */ +const userEvents = new Map(); +/** Last time we raised an alert for a user, for cooldown suppression. */ +const lastAlertAt = new Map(); + +const retentionMs = () => + Math.max(automodConfig.burstWindowMs, automodConfig.fanoutWindowMs); + +const unique = (values: T[]): T[] => [...new Set(values)]; + +/** + * Record an image-bearing message and evaluate whether the user's recent + * activity now constitutes spam. Fan-out (same image across channels) takes + * precedence over a same-channel burst because it is the higher-confidence + * signal. + */ +export function recordImageMessage( + userId: string, + event: ImageEvent +): Detection { + const horizon = event.at - retentionMs(); + const events = (userEvents.get(userId) ?? []).filter((e) => e.at >= horizon); + events.push(event); + userEvents.set(userId, events); + + // --- Fan-out: one signature seen across N+ distinct channels --- + const fanoutFrom = event.at - automodConfig.fanoutWindowMs; + const fanoutEvents = events.filter((e) => e.at >= fanoutFrom); + const channelsBySignature = new Map>(); + for (const e of fanoutEvents) + for (const signature of e.signatures) { + const channels = channelsBySignature.get(signature) ?? new Set(); + channels.add(e.channelId); + channelsBySignature.set(signature, channels); + } + + const fannedSignatures = [...channelsBySignature.entries()].filter( + ([, channels]) => channels.size >= automodConfig.fanoutChannels + ); + + if (fannedSignatures.length > 0) { + const signatures = fannedSignatures.map(([signature]) => signature); + const contributing = fanoutEvents.filter((e) => + e.signatures.some((s) => signatures.includes(s)) + ); + const channels = unique(contributing.map((e) => e.channelId)); + return { + level: 'fanout', + reason: `Identical image posted across ${channels.length} channels`, + events: contributing, + channels, + signatures, + }; + } + + // --- Burst: N+ image messages within the burst window --- + const burstFrom = event.at - automodConfig.burstWindowMs; + const burstEvents = events.filter((e) => e.at >= burstFrom); + if (burstEvents.length >= automodConfig.burstThreshold) { + return { + level: 'burst', + reason: `${burstEvents.length} image messages in ${Math.round( + automodConfig.burstWindowMs / 1000 + )}s`, + events: burstEvents, + channels: unique(burstEvents.map((e) => e.channelId)), + signatures: unique(burstEvents.flatMap((e) => e.signatures)), + }; + } + + return NONE; +} + +/** Whether enough time has passed since the last alert for this user. */ +export function shouldAlert(userId: string, now: number): boolean { + const last = lastAlertAt.get(userId); + return !last || now - last >= automodConfig.alertCooldownMs; +} + +export function markAlerted(userId: string, now: number): void { + lastAlertAt.set(userId, now); +} + +/** Forget a user's tracked state (e.g. after a moderator resolves an alert). */ +export function clearUser(userId: string): void { + userEvents.delete(userId); + lastAlertAt.delete(userId); +} + +// Periodically drop stale state so the maps don't grow unbounded for users +// who post one image and never return. unref() keeps this from holding the +// process open. +const sweep = setInterval(() => { + const horizon = Date.now() - retentionMs(); + for (const [userId, events] of userEvents) { + const fresh = events.filter((e) => e.at >= horizon); + if (fresh.length === 0) userEvents.delete(userId); + else userEvents.set(userId, fresh); + } + const cooldownHorizon = Date.now() - automodConfig.alertCooldownMs; + for (const [userId, at] of lastAlertAt) + if (at < cooldownHorizon) lastAlertAt.delete(userId); +}, 5 * 60_000); +sweep.unref(); diff --git a/src/utils/config.ts b/src/utils/config.ts index 56f3383..03a656a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -9,12 +9,51 @@ const missingEnv = requiredEnvironment.filter((req) => !process.env[req]); if (missingEnv.length > 0) throw new Error( - 'Missing required environment variables: \n- ' + - missingEnv.join('\n- ') + 'Missing required environment variables: \n- ' + missingEnv.join('\n- ') ); // This should be the server and the bot commands channel id's. export const { BOT_TOKEN = '', SERVER_ID = '420594746990526466', // Arduino Official Server BOT_COMMANDS_CHANNEL_ID = '451158319361556491', // Arduino Official Bot Channel + // Channel where automod posts spam alerts for moderators to action. + // Leave unset to disable the automod console entirely. + MOD_LOG_CHANNEL_ID = '', + // Optional: persistence for the spam-image blocklist. When unset the bot + // runs fully in-memory and the blocklist resets on restart. + DATABASE_URL = '', } = process.env; + +/** Parse a positive integer from the environment, falling back to a default. */ +const posInt = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}; + +/** Parse a comma-separated list of ids from the environment. */ +const idList = (value: string | undefined): string[] => + (value ?? '') + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0); + +/** + * Tunables for the image-spam detector. Every value is overridable via the + * environment so moderators can adjust thresholds without a redeploy. + */ +export const automodConfig = { + /** Number of image messages from one user within `burstWindowMs` to flag a burst. */ + burstThreshold: posInt(process.env.AUTOMOD_BURST_THRESHOLD, 3), + /** Sliding window (ms) for counting an image burst. */ + burstWindowMs: posInt(process.env.AUTOMOD_BURST_WINDOW_MS, 60_000), + /** Distinct channels the same image must appear in to flag a fan-out. */ + fanoutChannels: posInt(process.env.AUTOMOD_FANOUT_CHANNELS, 2), + /** Sliding window (ms) for detecting cross-channel fan-out. */ + fanoutWindowMs: posInt(process.env.AUTOMOD_FANOUT_WINDOW_MS, 120_000), + /** How long (ms) auto-applied/console timeouts last. Default 1 hour. */ + timeoutMs: posInt(process.env.AUTOMOD_TIMEOUT_MS, 60 * 60 * 1000), + /** Minimum gap (ms) between alerts for the same user, to avoid alert spam. */ + alertCooldownMs: posInt(process.env.AUTOMOD_ALERT_COOLDOWN_MS, 30_000), + /** Roles whose members are never inspected or actioned by the automod. */ + immuneRoleIds: idList(process.env.AUTOMOD_IMMUNE_ROLE_IDS), +}; diff --git a/src/utils/db.ts b/src/utils/db.ts index 702e179..2eae321 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,14 +1,39 @@ import { PrismaClient } from '@prisma/client'; -import { Logger, LogLevel } from '@sapphire/framework'; +import { container } from '@sapphire/framework'; +import { DATABASE_URL } from './config'; -const logger = new Logger(LogLevel.Info); +/** + * The Prisma client, or `null` when no database is configured. The bot is + * designed to run fully in-memory when persistence is unavailable, so every + * consumer must treat a `null` client as "persistence disabled" rather than + * an error. + */ +let prisma: PrismaClient | null = null; -// export const prisma = new PrismaClient(); +export const getPrisma = (): PrismaClient | null => prisma; -// prisma -// .$connect() -// .catch((error: Error) => { -// logger.fatal(error.message); -// process.exit(1); -// }) -// .then(() => logger.info('Database connection success')); +/** + * Attempt to connect to the database. Safe to call when `DATABASE_URL` is + * unset (logs a notice and leaves the bot in in-memory mode) and when the + * connection fails (logs the error and continues without persistence). + */ +export async function initDatabase(): Promise { + if (!DATABASE_URL) { + container.logger.warn( + 'DATABASE_URL not set — running in-memory only. The spam-image blocklist will reset on restart.' + ); + return; + } + + const client = new PrismaClient(); + try { + await client.$connect(); + prisma = client; + container.logger.info('Database connection success'); + } catch (error) { + container.logger.error( + 'Database connection failed — continuing without persistence.', + error + ); + } +} diff --git a/src/utils/tags.ts b/src/utils/tags.ts index 766dd3e..578b38c 100644 --- a/src/utils/tags.ts +++ b/src/utils/tags.ts @@ -6,7 +6,19 @@ import { } from 'discord.js'; import universalEmbed from '../index'; -export default { +/** + * Shape of a single tag. Every field is optional because tags vary: most are + * rich embeds, a few are plain (or templated) text, and some opt out of the + * bot-commands-channel-only behaviour. + */ +export interface Tag { + embeds?: EmbedBuilder[]; + components?: ActionRowBuilder[]; + content?: string | ((user?: string) => string); + botCommandsOnly?: boolean; +} + +const tags: Record = { ai: { embeds: [ new EmbedBuilder(universalEmbed) @@ -610,3 +622,5 @@ export default { // requiredRoles: ['Admin', 'Moderator'], // Role names or IDs // }, }; + +export default tags; From 83e2cba8c01f60c78c881df476d23d1e0f6bad6d Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:26:05 +0000 Subject: [PATCH 02/42] Bump discord.js to 14.26 and Sapphire framework to 5.5 Low-risk ecosystem update: discord.js 14.19 -> 14.26, @sapphire/framework 5.3 -> 5.5, @sapphire/plugin-logger 4.0 -> 4.1, @sapphire/ts-config 5.0.1 -> 5.0.3. Build passes unchanged. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- package-lock.json | 203 ++++++++++++++++++++++++++++------------------ package.json | 8 +- 2 files changed, 126 insertions(+), 85 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8876885..3a3d7c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,21 +7,22 @@ "": { "name": "arduino-bot", "version": "0.3.0", + "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { "@prisma/client": "^4.16.2", "@sapphire/discord.js-utilities": "^7.3.3", - "@sapphire/framework": "^5.3.6", + "@sapphire/framework": "^5.5.0", "@sapphire/plugin-hmr": "^3.0.2", - "@sapphire/plugin-logger": "^4.0.2", + "@sapphire/plugin-logger": "^4.1.0", "@sapphire/plugin-subcommands": "^7.0.1", "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", - "discord.js": "^14.19.3", + "discord.js": "^14.26.4", "dotenv": "^16.5.0" }, "devDependencies": { - "@sapphire/ts-config": "^5.0.1", + "@sapphire/ts-config": "^5.0.3", "@types/node": "^20.14.2", "prisma": "^4.16.2", "ts-node-dev": "^2.0.0", @@ -42,15 +43,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.2.tgz", - "integrity": "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -72,12 +73,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.38.1" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -107,20 +108,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.5.0.tgz", - "integrity": "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.1", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -141,11 +142,24 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -154,13 +168,13 @@ } }, "node_modules/@discordjs/ws": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.2.tgz", - "integrity": "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.0", + "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", @@ -301,18 +315,18 @@ } }, "node_modules/@sapphire/framework": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/@sapphire/framework/-/framework-5.3.6.tgz", - "integrity": "sha512-VDNsW6S8uMTVXUGSu9fwOYZ3zaMIQbgVvrglnPpjKSmW4GA6M3iewPZgtH/PDtqOXQe6khj1gY1ouhZnyYitNg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@sapphire/framework/-/framework-5.5.0.tgz", + "integrity": "sha512-jC+zS8yR/MtJaTFEQutBBWj7Scuvny4d0adtQ3s0OCFWe3rUqNfENomBG3P4tsQ/kRF7L3WCqucyLV8ywG24hw==", "license": "MIT", "dependencies": { - "@discordjs/builders": "^1.11.2", - "@sapphire/discord-utilities": "^3.5.0", + "@discordjs/builders": "^1.13.0", + "@sapphire/discord-utilities": "^4.0.0", "@sapphire/discord.js-utilities": "^7.3.3", - "@sapphire/lexure": "^1.1.10", + "@sapphire/lexure": "^1.1.12", "@sapphire/pieces": "^4.4.1", "@sapphire/ratelimits": "^2.4.11", - "@sapphire/result": "^2.7.2", + "@sapphire/result": "^2.8.0", "@sapphire/stopwatch": "^1.5.4", "@sapphire/utilities": "^3.18.2" }, @@ -321,13 +335,26 @@ "npm": ">=7" } }, + "node_modules/@sapphire/framework/node_modules/@sapphire/discord-utilities": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/discord-utilities/-/discord-utilities-4.0.0.tgz", + "integrity": "sha512-QAvrKNHgswz+ZX48WqSYpRiRzQcugNXXB1C3fR1qbpTJGd7Ckr2OWyFK88TyOksi3U2isrk8sMriTcAgaIe7Qg==", + "license": "MIT", + "dependencies": { + "discord-api-types": "^0.38.30" + }, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@sapphire/lexure": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@sapphire/lexure/-/lexure-1.1.10.tgz", - "integrity": "sha512-odE4FD0SkCxkwEOhzAOqEnCJ/oJlPUuyFEw2KJacIuGiwY86WRTPIHLg1rt6XmfSYLxGXiqRf74req43+wRV9g==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@sapphire/lexure/-/lexure-1.1.12.tgz", + "integrity": "sha512-F7Z3QzRnAZGunRl24/qQMhzRogZU/foumu2EBBunRnQi/o/DLTCwdAbLgJATyPlvJa8N6FrJq0JJwvzM/vXoXg==", "license": "MIT", "dependencies": { - "@sapphire/result": "^2.7.2" + "@sapphire/result": "^2.8.0" }, "engines": { "node": ">=v14.0.0", @@ -403,12 +430,12 @@ } }, "node_modules/@sapphire/plugin-logger": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@sapphire/plugin-logger/-/plugin-logger-4.0.2.tgz", - "integrity": "sha512-5Nr++u+fA3/jZwj1aL9Z16RgyJZRE1gyUftfWjrzdndE5FkcbnLiVCKvnI8WzSupVhdn6kMaCWAteOSgAaq3lQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sapphire/plugin-logger/-/plugin-logger-4.1.0.tgz", + "integrity": "sha512-+vLTITQw3DI8+kfDPNJ0yhIK/LrfM/Gro/UFcxgYWLLm+lwNmSpLZxdWe9v7ESXprody7rTp8JXPNBK7UWyE8w==", "license": "MIT", "dependencies": { - "@sapphire/timestamp": "^1.0.3", + "@sapphire/timestamp": "^1.0.5", "colorette": "^2.0.20" }, "engines": { @@ -440,9 +467,9 @@ } }, "node_modules/@sapphire/result": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@sapphire/result/-/result-2.7.2.tgz", - "integrity": "sha512-DJbCGmvi8UZAu/hh85auQL8bODFlpcS3cWjRJZ5/cXTLekmGvs/CrRxrIzwbA6+poyYojo5rK4qu8trmjfneog==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@sapphire/result/-/result-2.8.0.tgz", + "integrity": "sha512-693yWouX+hR9uJm1Jgq0uSSjbSD3UrblMaxiuGbHPjSwzLCSZTcm0h3kvdVhq3o/yl4+oeAWW3hiaJ0TELuRJQ==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -496,20 +523,34 @@ } }, "node_modules/@sapphire/ts-config": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sapphire/ts-config/-/ts-config-5.0.1.tgz", - "integrity": "sha512-86YBYNBDNs6/bCrTsv274553v43Bz8YljfrrIQ4N8ll2npUxbf6cpC0gjfJY+FMa1HwKUgoMF4lvhzY0Ph0smw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@sapphire/ts-config/-/ts-config-5.0.3.tgz", + "integrity": "sha512-bFyGYHFT3TpOf5Sg2P+zY2ad0t5IA2epc5HtewlghhL7MYvbZvxtKsdaNaMwAdNObBx7hpiQm5OcOhyzEwQvbQ==", "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.6.2", - "typescript": "^5.4.2" + "tslib": "^2.8.1", + "typescript": "~5.4.5" }, "engines": { "node": ">=v16.0.0", "npm": ">=8.0.0" } }, + "node_modules/@sapphire/ts-config/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@sapphire/type": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@sapphire/type/-/type-2.6.0.tgz", @@ -596,9 +637,9 @@ } }, "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", - "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -859,33 +900,33 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.11", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.11.tgz", - "integrity": "sha512-XN0qhcQpetkyb/49hcDHuoeUPsQqOkb17wbV/t48gUkoEDi4ajhsxqugGcxvcN17BBtI9FPPWEgzv6IhQmCwyw==", + "version": "0.38.48", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.48.tgz", + "integrity": "sha512-WFUE/2o0lBlLeCQonQ+Pu2RqHAqbytBJ2RlXR91gzk05InSS6k9ShzzLYoymrA4c2oRgRKGE7/VqQJNNdGWSxQ==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, "node_modules/discord.js": { - "version": "14.19.3", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.19.3.tgz", - "integrity": "sha512-lncTRk0k+8Q5D3nThnODBR8fR8x2fM798o8Vsr40Krx0DjPwpZCuxxTcFMrXMQVOqM1QB9wqWgaXPg3TbmlHqA==", + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.11.2", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", - "@discordjs/rest": "^2.5.0", - "@discordjs/util": "^1.1.1", - "@discordjs/ws": "^1.2.2", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -1172,9 +1213,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -1184,9 +1225,9 @@ "license": "MIT" }, "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, "node_modules/make-dir": { @@ -1799,9 +1840,9 @@ } }, "node_modules/undici": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", - "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1858,9 +1899,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index b68c1ce..c6822bb 100644 --- a/package.json +++ b/package.json @@ -22,17 +22,17 @@ "dependencies": { "@prisma/client": "^4.16.2", "@sapphire/discord.js-utilities": "^7.3.3", - "@sapphire/framework": "^5.3.6", + "@sapphire/framework": "^5.5.0", "@sapphire/plugin-hmr": "^3.0.2", - "@sapphire/plugin-logger": "^4.0.2", + "@sapphire/plugin-logger": "^4.1.0", "@sapphire/plugin-subcommands": "^7.0.1", "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", - "discord.js": "^14.19.3", + "discord.js": "^14.26.4", "dotenv": "^16.5.0" }, "devDependencies": { - "@sapphire/ts-config": "^5.0.1", + "@sapphire/ts-config": "^5.0.3", "@types/node": "^20.14.2", "prisma": "^4.16.2", "ts-node-dev": "^2.0.0", From f2886d65ffd5cc711acc26256b5e3213a1ac05fc Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:28:52 +0000 Subject: [PATCH 03/42] Migrate Prisma 4 to 7 with the pg driver adapter Prisma 7 removes the embedded query engine in favour of driver adapters and no longer accepts the connection `url` in the schema datasource: - Drop `url` from prisma/schema.prisma's datasource block. - Add prisma.config.ts holding the migration/introspection connection URL, guarded so offline `prisma generate` (postinstall) still works when DATABASE_URL is unset. - Connect the runtime client through @prisma/adapter-pg (new deps: @prisma/adapter-pg, pg), keeping the graceful in-memory fallback. Build and offline/online client generation both pass. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- package-lock.json | 1336 +++++++++++++++++++++++++++++++++++++++++- package.json | 9 +- prisma.config.ts | 14 + prisma/schema.prisma | 1 - src/utils/db.ts | 7 +- 5 files changed, 1336 insertions(+), 31 deletions(-) create mode 100644 prisma.config.ts diff --git a/package-lock.json b/package-lock.json index 3a3d7c8..2a09705 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { - "@prisma/client": "^4.16.2", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@sapphire/discord.js-utilities": "^7.3.3", "@sapphire/framework": "^5.5.0", "@sapphire/plugin-hmr": "^3.0.2", @@ -19,12 +20,14 @@ "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", "discord.js": "^14.26.4", - "dotenv": "^16.5.0" + "dotenv": "^16.5.0", + "pg": "^8.21.0" }, "devDependencies": { "@sapphire/ts-config": "^5.0.3", "@types/node": "^20.14.2", - "prisma": "^4.16.2", + "@types/pg": "^8.20.0", + "prisma": "^7.8.0", "ts-node-dev": "^2.0.0", "typescript": "^5.4.5" } @@ -202,6 +205,49 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -230,41 +276,370 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.16.2.tgz", - "integrity": "sha512-qCoEyxv1ZrQ4bKy39GnylE8Zq31IRmm8bNhNbZx7bF2cU5aiCCnSa93J2imF88MBjn7J9eUQneNxUQVJdl/rPQ==", - "hasInstallScript": true, + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", "license": "Apache-2.0", "dependencies": { - "@prisma/engines-version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81" + "@prisma/client-runtime-utils": "7.8.0" }, "engines": { - "node": ">=14.17" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { - "prisma": "*" + "prisma": "*", + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "prisma": { "optional": true + }, + "typescript": { + "optional": true } } }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, "node_modules/@prisma/engines": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.16.2.tgz", - "integrity": "sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", "devOptional": true, "hasInstallScript": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } }, "node_modules/@prisma/engines-version": { - "version": "4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz", - "integrity": "sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==", + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -576,6 +951,13 @@ "node": ">=v14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -613,6 +995,28 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", @@ -690,6 +1094,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -740,12 +1161,29 @@ "dev": true, "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -789,6 +1227,91 @@ "dev": true, "license": "MIT" }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -844,6 +1367,13 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -857,6 +1387,29 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -874,12 +1427,46 @@ } } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -957,18 +1544,99 @@ "xtend": "^4.0.0" } }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -982,6 +1650,36 @@ "node": ">=8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -1058,6 +1756,33 @@ "node": ">=10" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1092,6 +1817,27 @@ "node": ">= 6" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1111,6 +1857,23 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1124,6 +1887,23 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1212,6 +1992,37 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1224,6 +2035,29 @@ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-bytes.js": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", @@ -1335,6 +2169,40 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nan": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", @@ -1408,6 +2276,13 @@ "node": ">=0.10.0" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1426,6 +2301,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1433,6 +2318,118 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1446,22 +2443,168 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prisma": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.16.2.tgz", - "integrity": "sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/engines": "4.16.2" + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" }, "bin": { - "prisma": "build/index.js", - "prisma2": "build/index.js" + "prisma": "build/index.js" }, "engines": { - "node": ">=14.17" + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" } }, "node_modules/readable-stream": { @@ -1491,6 +2634,26 @@ "node": ">=8.10.0" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -1512,6 +2675,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -1548,6 +2721,21 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -1560,12 +2748,41 @@ "node": ">=10" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1593,6 +2810,32 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1829,7 +3072,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1867,6 +3110,21 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -1883,6 +3141,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -1923,7 +3197,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -1944,6 +3217,17 @@ "engines": { "node": ">=6" } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } } } } diff --git a/package.json b/package.json index c6822bb..e142f5a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "author": "Max Bromberg and Contributors", "license": "GPL-3.0-or-later", "dependencies": { - "@prisma/client": "^4.16.2", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@sapphire/discord.js-utilities": "^7.3.3", "@sapphire/framework": "^5.5.0", "@sapphire/plugin-hmr": "^3.0.2", @@ -29,12 +30,14 @@ "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", "discord.js": "^14.26.4", - "dotenv": "^16.5.0" + "dotenv": "^16.5.0", + "pg": "^8.21.0" }, "devDependencies": { "@sapphire/ts-config": "^5.0.3", "@types/node": "^20.14.2", - "prisma": "^4.16.2", + "@types/pg": "^8.20.0", + "prisma": "^7.8.0", "ts-node-dev": "^2.0.0", "typescript": "^5.4.5" } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..4bcdbf8 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; + +// Prisma 7 moved the migration/introspection connection URL out of the schema +// and into this config file. The datasource is only required for CLI commands +// that touch the database (migrate, db pull); `prisma generate` does not need +// it, so we omit it entirely when DATABASE_URL is unset to keep offline +// generation (e.g. the postinstall hook) working. +const url = process.env.DATABASE_URL; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + ...(url ? { datasource: { url } } : {}), +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85a677c..255c77d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,7 +4,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } model MemberAnalytics { diff --git a/src/utils/db.ts b/src/utils/db.ts index 2eae321..241123e 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,4 +1,5 @@ import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; import { container } from '@sapphire/framework'; import { DATABASE_URL } from './config'; @@ -16,6 +17,9 @@ export const getPrisma = (): PrismaClient | null => prisma; * Attempt to connect to the database. Safe to call when `DATABASE_URL` is * unset (logs a notice and leaves the bot in in-memory mode) and when the * connection fails (logs the error and continues without persistence). + * + * Prisma 7 connects through a driver adapter rather than an embedded engine, + * so we hand it a `pg` connection backed by `DATABASE_URL`. */ export async function initDatabase(): Promise { if (!DATABASE_URL) { @@ -25,7 +29,8 @@ export async function initDatabase(): Promise { return; } - const client = new PrismaClient(); + const adapter = new PrismaPg(DATABASE_URL); + const client = new PrismaClient({ adapter }); try { await client.$connect(); prisma = client; From 0dbcdbc94d5c399b733507b60df2c63b4aaf340d Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:29:33 +0000 Subject: [PATCH 04/42] Bump TypeScript to 6, dotenv to 17, @types/node to 25 TypeScript 6 deprecates the (here unused) `baseUrl` compiler option, so it is removed; all imports are relative and no `paths` mapping existed. Build passes on the new toolchain. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- package-lock.json | 45 ++++++++++++++++----------------------------- package.json | 6 +++--- tsconfig.json | 1 - 3 files changed, 19 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2a09705..a6a5df9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,16 +20,16 @@ "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", "discord.js": "^14.26.4", - "dotenv": "^16.5.0", + "dotenv": "^17.4.2", "pg": "^8.21.0" }, "devDependencies": { "@sapphire/ts-config": "^5.0.3", - "@types/node": "^20.14.2", + "@types/node": "^25.9.3", "@types/pg": "^8.20.0", "prisma": "^7.8.0", "ts-node-dev": "^2.0.0", - "typescript": "^5.4.5" + "typescript": "^6.0.3" } }, "node_modules/@cspotcode/source-map-support": { @@ -987,12 +987,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", - "integrity": "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/pg": { @@ -1272,19 +1272,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/c12/node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "devOptional": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/c12/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -1523,9 +1510,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -3069,9 +3056,9 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -3092,9 +3079,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/util-deprecate": { diff --git a/package.json b/package.json index e142f5a..41f969c 100644 --- a/package.json +++ b/package.json @@ -30,15 +30,15 @@ "@sapphire/type": "^2.6.0", "@sapphire/utilities": "^3.18.2", "discord.js": "^14.26.4", - "dotenv": "^16.5.0", + "dotenv": "^17.4.2", "pg": "^8.21.0" }, "devDependencies": { "@sapphire/ts-config": "^5.0.3", - "@types/node": "^20.14.2", + "@types/node": "^25.9.3", "@types/pg": "^8.20.0", "prisma": "^7.8.0", "ts-node-dev": "^2.0.0", - "typescript": "^5.4.5" + "typescript": "^6.0.3" } } diff --git a/tsconfig.json b/tsconfig.json index 9a932b5..3e3203e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,6 @@ // --- Module Configuration --- "module": "Node16", "moduleResolution": "Node16", - "baseUrl": "./", // Allows for absolute paths from the root "target": "ES2022", // Updated to a more modern target "lib": ["ESNext"], From 3f5fb0ce9220cc4253348f82fee0a183ffe8ca33 Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:31:08 +0000 Subject: [PATCH 05/42] Add tenure-aware detection for new members (automod prong 2) New members (joined within AUTOMOD_NEW_MEMBER_WINDOW_MS, default 72h) get a stricter burst threshold (AUTOMOD_NEW_MEMBER_BURST_THRESHOLD, default 2) so join-then-spam trips faster. Stays "monitor + flag" with no hard image gate. Compromised veterans are unaffected by tenure and remain covered by the fan-out and blocklist signals. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- src/listeners/messageCreate.ts | 25 +++++++++++++++++++------ src/utils/automod/tracker.ts | 14 ++++++++++++-- src/utils/config.ts | 10 ++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 122abcc..dba2143 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -35,12 +35,25 @@ export class MessageCreateListener extends Listener { if (signatures.length === 0) return; const now = Date.now(); - const detection = recordImageMessage(message.author.id, { - at: now, - channelId: message.channelId, - messageId: message.id, - signatures, - }); + // New members get a stricter burst threshold so join-then-spam trips + // faster; tenure is useless for compromised veterans, who are instead + // caught by the fan-out/blocklist signals below. + const isNewMember = Boolean( + message.member?.joinedTimestamp && + now - message.member.joinedTimestamp < automodConfig.newMemberWindowMs + ); + const detection = recordImageMessage( + message.author.id, + { + at: now, + channelId: message.channelId, + messageId: message.id, + signatures, + }, + isNewMember + ? { burstThreshold: automodConfig.newMemberBurstThreshold } + : {} + ); const { blocked, matched } = await isBlocklisted(signatures); diff --git a/src/utils/automod/tracker.ts b/src/utils/automod/tracker.ts index 9a5315b..4fee945 100644 --- a/src/utils/automod/tracker.ts +++ b/src/utils/automod/tracker.ts @@ -38,6 +38,14 @@ const retentionMs = () => const unique = (values: T[]): T[] => [...new Set(values)]; +export interface DetectionOptions { + /** + * Override for the same-channel burst threshold. Used to apply a stricter + * threshold to new members. Falls back to the configured default. + */ + burstThreshold?: number; +} + /** * Record an image-bearing message and evaluate whether the user's recent * activity now constitutes spam. Fan-out (same image across channels) takes @@ -46,8 +54,10 @@ const unique = (values: T[]): T[] => [...new Set(values)]; */ export function recordImageMessage( userId: string, - event: ImageEvent + event: ImageEvent, + options: DetectionOptions = {} ): Detection { + const burstThreshold = options.burstThreshold ?? automodConfig.burstThreshold; const horizon = event.at - retentionMs(); const events = (userEvents.get(userId) ?? []).filter((e) => e.at >= horizon); events.push(event); @@ -86,7 +96,7 @@ export function recordImageMessage( // --- Burst: N+ image messages within the burst window --- const burstFrom = event.at - automodConfig.burstWindowMs; const burstEvents = events.filter((e) => e.at >= burstFrom); - if (burstEvents.length >= automodConfig.burstThreshold) { + if (burstEvents.length >= burstThreshold) { return { level: 'burst', reason: `${burstEvents.length} image messages in ${Math.round( diff --git a/src/utils/config.ts b/src/utils/config.ts index 03a656a..ed67593 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -56,4 +56,14 @@ export const automodConfig = { alertCooldownMs: posInt(process.env.AUTOMOD_ALERT_COOLDOWN_MS, 30_000), /** Roles whose members are never inspected or actioned by the automod. */ immuneRoleIds: idList(process.env.AUTOMOD_IMMUNE_ROLE_IDS), + /** + * How long after joining (ms) a member is treated as "new", so their image + * posts are scrutinised harder. Default 72 hours. + */ + newMemberWindowMs: posInt(process.env.AUTOMOD_NEW_MEMBER_WINDOW_MS, 72 * 60 * 60 * 1000), + /** + * Stricter burst threshold applied to new members (catches join-then-spam + * faster). Should be <= burstThreshold. Default 2. + */ + newMemberBurstThreshold: posInt(process.env.AUTOMOD_NEW_MEMBER_BURST_THRESHOLD, 2), }; From 5c044d1f0f147ee870cf89879d87123963a15f0c Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:37:14 +0000 Subject: [PATCH 06/42] Add perceptual (dHash) image hashing to the automod Each image now carries two fingerprints: the existing download-free metadata signature (catches byte-identical re-uploads) and a perceptual dHash computed from a tiny media-proxy thumbnail (catches re-encoded/resized copies via Hamming-distance matching). - New phash.ts: dHash via jimp + Hamming distance + bounded thumbnail fetch. - Fan-out detection gains a perceptual pass alongside the exact-signature pass. - Blocklist stores both fingerprint kinds (SpamSignature.kind) with a warm in-memory cache loaded on ready; perceptual matches use Hamming distance. - AUTOMOD_PHASH_THRESHOLD tunable; docs updated. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- .env.example | 3 + README.md | 14 +- package-lock.json | 777 ++++++++++++++++++++- package.json | 1 + prisma/migrations/0_init/migration.sql | 4 + prisma/schema.prisma | 5 +- src/interaction-handlers/spamModeration.ts | 4 +- src/listeners/messageCreate.ts | 12 +- src/listeners/ready.ts | 3 + src/utils/automod/blocklist.ts | 99 ++- src/utils/automod/incidents.ts | 4 +- src/utils/automod/phash.ts | 98 +++ src/utils/automod/tracker.ts | 102 ++- src/utils/config.ts | 5 + 14 files changed, 1062 insertions(+), 69 deletions(-) create mode 100644 src/utils/automod/phash.ts diff --git a/.env.example b/.env.example index 883cf6e..e89d91f 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ MOD_LOG_CHANNEL_ID= # AUTOMOD_BURST_WINDOW_MS=60000 # window for the burst count # AUTOMOD_FANOUT_CHANNELS=2 # distinct channels for a cross-channel fan-out # AUTOMOD_FANOUT_WINDOW_MS=120000 # window for fan-out detection +# AUTOMOD_PHASH_THRESHOLD=6 # max Hamming distance (of 64) for near-dupe images # AUTOMOD_TIMEOUT_MS=3600000 # auto/console timeout duration (1 hour) # AUTOMOD_ALERT_COOLDOWN_MS=30000 # min gap between alerts per user # AUTOMOD_IMMUNE_ROLE_IDS= # comma-separated role ids never inspected +# AUTOMOD_NEW_MEMBER_WINDOW_MS=259200000 # how long a member counts as "new" (72h) +# AUTOMOD_NEW_MEMBER_BURST_THRESHOLD=2 # stricter burst threshold for new members diff --git a/README.md b/README.md index 22a75c0..1f0cc7b 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,24 @@ filters: accounts (both freshly-joined and compromised long-time members) posting **clusters of images** to advertise. It complements YAGPDB rather than replacing it, and never disables image sharing for the server. -**How it detects spam (no images are downloaded — it uses Discord's attachment -metadata only):** +**How it detects spam:** - **Image burst** — several image messages from one user in a short window - (default: 3 in 60s). *Lower confidence → alerts moderators only.* + (default: 3 in 60s; stricter for new members). *Lower confidence → alerts + moderators only.* - **Cross-channel fan-out** — the same image posted across multiple channels in a short window (default: 2+ channels). This is the strongest signal and catches compromised veterans, where account age is useless. *High confidence.* - **Known-spam blocklist** — once a moderator confirms an alert, that image's - fingerprint is blocklisted so repeat campaigns are caught instantly. *High + fingerprints are blocklisted so repeat campaigns are caught instantly. *High confidence.* +Each image gets two fingerprints: a cheap, download-free **metadata signature** +(content type + size + dimensions) that catches byte-identical re-uploads, and a +**perceptual hash** (dHash, computed from a tiny media-proxy thumbnail) that +catches re-encoded or resized copies via Hamming-distance matching. New members +(joined within the last 72h by default) are held to a stricter burst threshold. + **Tiered response:** high-confidence hits auto-delete the messages and timeout the user, then post an alert; bursts only post an alert. Every alert lands in the mod-log channel with action buttons — **Confirm spam / Timeout / Ban / Delete diff --git a/package-lock.json b/package-lock.json index a6a5df9..c729b9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@sapphire/utilities": "^3.18.2", "discord.js": "^14.26.4", "dotenv": "^17.4.2", + "jimp": "^1.6.1", "pg": "^8.21.0" }, "devDependencies": { @@ -32,6 +33,16 @@ "typescript": "^6.0.3" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -248,6 +259,418 @@ "hono": "^4" } }, + "node_modules/@jimp/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", + "license": "MIT", + "dependencies": { + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^21.3.3", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", + "license": "MIT", + "dependencies": { + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -958,6 +1381,29 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1120,6 +1566,12 @@ "node": ">=8" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1161,6 +1613,15 @@ "dev": true, "license": "MIT" }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -1197,6 +1658,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1398,9 +1865,9 @@ "peer": true }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1571,6 +2038,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1624,6 +2096,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1760,6 +2250,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, "node_modules/giget": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", @@ -1891,6 +2391,41 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1993,6 +2528,44 @@ "devOptional": true, "license": "ISC" }, + "node_modules/jimp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2003,6 +2576,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2082,6 +2661,18 @@ "dev": true, "license": "ISC" }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2270,6 +2861,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2279,6 +2876,34 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2430,6 +3055,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/pkg-types": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -2442,6 +3088,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -2715,6 +3370,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2776,6 +3440,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-xml-to-json": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", + "license": "MIT", + "engines": { + "node": ">=20.12.2" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2878,6 +3551,22 @@ "node": ">=0.10.0" } }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2908,6 +3597,12 @@ "node": ">=10" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2921,6 +3616,24 @@ "node": ">=8.0" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -3069,6 +3782,18 @@ "node": ">=14.17" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", @@ -3084,6 +3809,15 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3180,6 +3914,34 @@ } } }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -3215,6 +3977,15 @@ "grammex": "^3.1.11", "graphmatch": "^1.1.0" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 41f969c..55f7445 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@sapphire/utilities": "^3.18.2", "discord.js": "^14.26.4", "dotenv": "^17.4.2", + "jimp": "^1.6.1", "pg": "^8.21.0" }, "devDependencies": { diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql index bfdd704..bba1aa5 100644 --- a/prisma/migrations/0_init/migration.sql +++ b/prisma/migrations/0_init/migration.sql @@ -1,3 +1,6 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + -- CreateEnum CREATE TYPE "MemberAnalyticsEvent" AS ENUM ('join', 'leave'); @@ -30,6 +33,7 @@ CREATE TABLE "CommandAnalytics" ( -- CreateTable CREATE TABLE "SpamSignature" ( "signature" VARCHAR NOT NULL, + "kind" VARCHAR NOT NULL DEFAULT 'meta', "addedBy" VARCHAR NOT NULL, "reason" VARCHAR, "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 255c77d..25beb74 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,9 +29,12 @@ enum MemberAnalyticsEvent { } /// Blocklist of image fingerprints confirmed to be spam by a moderator. -/// New uploads matching one of these are auto-actioned. +/// New uploads matching one of these are auto-actioned. `kind` is "meta" for +/// exact metadata signatures or "phash" for perceptual hashes (matched by +/// Hamming distance). model SpamSignature { signature String @id @db.VarChar + kind String @default("meta") @db.VarChar addedBy String @db.VarChar reason String? @db.VarChar createdAt DateTime @default(now()) @db.Timestamptz(6) diff --git a/src/interaction-handlers/spamModeration.ts b/src/interaction-handlers/spamModeration.ts index b841dc3..38bf78a 100644 --- a/src/interaction-handlers/spamModeration.ts +++ b/src/interaction-handlers/spamModeration.ts @@ -80,13 +80,15 @@ export class SpamModerationHandler extends InteractionHandler { ); await addToBlocklist( incident.signatures, + incident.hashes, moderator.id, 'confirmed image spam' ); clearUser(incident.userId); + const fingerprints = incident.signatures.length + incident.hashes.length; summary = `✅ Confirmed spam — deleted ${deleted} message(s), ${ timedOut ? 'timed out the user' : '**could not** time out the user' - }, and blocklisted ${incident.signatures.length} image signature(s).`; + }, and blocklisted ${fingerprints} image fingerprint(s).`; break; } case 'timeout': { diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index dba2143..a9c32c1 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -2,6 +2,7 @@ import { Events, Listener, container } from '@sapphire/framework'; import { PermissionFlagsBits, type GuildMember, type Message } from 'discord.js'; import { SERVER_ID, MOD_LOG_CHANNEL_ID, automodConfig } from '../utils/config'; import { imageSignatures } from '../utils/automod/signature'; +import { perceptualHashes } from '../utils/automod/phash'; import { recordImageMessage, shouldAlert, @@ -35,6 +36,9 @@ export class MessageCreateListener extends Listener { if (signatures.length === 0) return; const now = Date.now(); + // Perceptual hashes are best-effort (network fetch + decode); the metadata + // signatures still cover anything that fails to hash. + const hashes = await perceptualHashes(message); // New members get a stricter burst threshold so join-then-spam trips // faster; tenure is useless for compromised veterans, who are instead // caught by the fan-out/blocklist signals below. @@ -49,13 +53,14 @@ export class MessageCreateListener extends Listener { channelId: message.channelId, messageId: message.id, signatures, + hashes, }, isNewMember ? { burstThreshold: automodConfig.newMemberBurstThreshold } : {} ); - const { blocked, matched } = await isBlocklisted(signatures); + const { blocked, matched } = isBlocklisted(signatures, hashes); let level: IncidentLevel | null = null; if (blocked) level = 'blocklist'; @@ -84,7 +89,10 @@ export class MessageCreateListener extends Listener { ? `Matched ${matched.length} known spam image${matched.length === 1 ? '' : 's'}` : detection.reason, messages, - signatures: level === 'blocklist' ? matched : detection.signatures, + // For a fresh detection, blocklist the fingerprints that triggered it. + // For a blocklist hit, reinforce with this message's fingerprints. + signatures: level === 'blocklist' ? signatures : detection.signatures, + hashes: level === 'blocklist' ? hashes : detection.hashes, }); // Tiered enforcement: high-confidence signals act immediately; a diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index 5680ab8..b21b07b 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -1,6 +1,7 @@ import { Events, Listener } from '@sapphire/framework'; import type { Client } from 'discord.js'; import { initDatabase } from '../utils/db'; +import { loadBlocklist } from '../utils/automod/blocklist'; export class ReadyListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { @@ -15,5 +16,7 @@ export class ReadyListener extends Listener { // Connect persistence if configured; the bot runs in-memory otherwise. await initDatabase(); + // Warm the automod blocklist from the database (no-op without one). + await loadBlocklist(); } } diff --git a/src/utils/automod/blocklist.ts b/src/utils/automod/blocklist.ts index b859b68..1495168 100644 --- a/src/utils/automod/blocklist.ts +++ b/src/utils/automod/blocklist.ts @@ -1,59 +1,98 @@ import { container } from '@sapphire/framework'; +import { automodConfig } from '../config'; import { getPrisma } from '../db'; +import { hammingDistance } from './phash'; /** - * In-memory mirror of the blocklist. Always consulted first so the feature - * works even with no database, and so confirmed-spam signatures take effect - * immediately within the running process. + * In-memory mirror of the blocklist, kept warm so lookups never touch the + * database on the hot path. Populated from the database on startup (when one is + * configured) and updated immediately whenever a moderator confirms spam, so + * the feature works fully in-memory when no database is present. */ -const memory = new Set(); +const exactSignatures = new Set(); +const perceptualHashes = new Set(); + +export type FingerprintKind = 'meta' | 'phash'; export interface BlocklistMatch { blocked: boolean; + /** Fingerprints from the input that matched the blocklist. */ matched: string[]; } -/** Check whether any of the given signatures is a known spam image. */ -export async function isBlocklisted( - signatures: string[] -): Promise { - const matched = new Set(signatures.filter((s) => memory.has(s))); - +/** Load the persisted blocklist into memory. Safe to call with no database. */ +export async function loadBlocklist(): Promise { const prisma = getPrisma(); - if (prisma) { - try { - const rows = await prisma.spamSignature.findMany({ - where: { signature: { in: signatures } }, - select: { signature: true }, - }); - for (const row of rows) { - matched.add(row.signature); - memory.add(row.signature); // warm the in-memory cache - } - } catch (error) { - container.logger.error('Blocklist lookup failed:', error); - } + if (!prisma) return; + try { + const rows = await prisma.spamSignature.findMany({ + select: { signature: true, kind: true }, + }); + for (const { signature, kind } of rows) + (kind === 'phash' ? perceptualHashes : exactSignatures).add(signature); + container.logger.info( + `Automod: loaded ${exactSignatures.size} signature(s) and ${perceptualHashes.size} perceptual hash(es) from the blocklist.` + ); + } catch (error) { + container.logger.error('Loading blocklist failed:', error); } +} + +/** + * Check incoming fingerprints against the blocklist. Metadata signatures match + * exactly; perceptual hashes match within the configured Hamming distance. + */ +export function isBlocklisted( + signatures: string[], + hashes: string[] +): BlocklistMatch { + const matched = new Set(); + + for (const signature of signatures) + if (exactSignatures.has(signature)) matched.add(signature); + + for (const hash of hashes) + for (const known of perceptualHashes) + if (hammingDistance(hash, known) <= automodConfig.phashThreshold) { + matched.add(hash); + break; + } return { blocked: matched.size > 0, matched: [...matched] }; } -/** Add signatures to the blocklist (in-memory always; persisted if possible). */ +/** Add fingerprints to the blocklist (in-memory always; persisted if possible). */ export async function addToBlocklist( signatures: string[], + hashes: string[], addedBy: string, reason: string ): Promise { - for (const signature of signatures) memory.add(signature); + for (const signature of signatures) exactSignatures.add(signature); + for (const hash of hashes) perceptualHashes.add(hash); const prisma = getPrisma(); if (!prisma) return; + + const rows = [ + ...signatures.map((signature) => ({ + signature, + kind: 'meta' as FingerprintKind, + addedBy, + reason, + })), + ...hashes.map((signature) => ({ + signature, + kind: 'phash' as FingerprintKind, + addedBy, + reason, + })), + ]; + if (rows.length === 0) return; + try { - await prisma.spamSignature.createMany({ - data: signatures.map((signature) => ({ signature, addedBy, reason })), - skipDuplicates: true, - }); + await prisma.spamSignature.createMany({ data: rows, skipDuplicates: true }); } catch (error) { - container.logger.error('Persisting blocklist signatures failed:', error); + container.logger.error('Persisting blocklist fingerprints failed:', error); } } diff --git a/src/utils/automod/incidents.ts b/src/utils/automod/incidents.ts index 4562f0d..ffc2d0a 100644 --- a/src/utils/automod/incidents.ts +++ b/src/utils/automod/incidents.ts @@ -15,8 +15,10 @@ export interface Incident { level: IncidentLevel; reason: string; messages: IncidentMessage[]; - /** Image signatures to blocklist if a moderator confirms this is spam. */ + /** Metadata signatures to blocklist if a moderator confirms this is spam. */ signatures: string[]; + /** Perceptual hashes to blocklist if a moderator confirms this is spam. */ + hashes: string[]; createdAt: number; } diff --git a/src/utils/automod/phash.ts b/src/utils/automod/phash.ts new file mode 100644 index 0000000..1275110 --- /dev/null +++ b/src/utils/automod/phash.ts @@ -0,0 +1,98 @@ +import { Jimp, intToRGBA } from 'jimp'; +import type { Attachment, Message } from 'discord.js'; +import { container } from '@sapphire/framework'; +import { isImageAttachment } from './signature'; + +// dHash works on a (W+1) x H greyscale image, comparing each pixel to its right +// neighbour to produce W*H = 64 bits. +const HASH_W = 9; +const HASH_H = 8; + +/** Max image attachments per message we will fetch + hash. */ +const MAX_ATTACHMENTS = 4; +/** Skip attachments larger than this; the thumbnail proxy handles the rest. */ +const MAX_BYTES = 12 * 1024 * 1024; +/** Per-fetch timeout. */ +const FETCH_TIMEOUT_MS = 4000; + +/** + * Difference hash (dHash) of an image, as 16 hex chars (64 bits). Near-identical + * images — including re-encoded, recompressed, or lightly resized copies — + * produce hashes a small Hamming distance apart, which exact byte/metadata + * signatures cannot detect. + */ +export async function dHashFromBuffer(buffer: Buffer): Promise { + const image = await Jimp.read(buffer); + image.resize({ w: HASH_W, h: HASH_H }).greyscale(); + + let bits = ''; + for (let y = 0; y < HASH_H; y++) + for (let x = 0; x < HASH_W - 1; x++) { + const left = intToRGBA(image.getPixelColor(x, y)).r; + const right = intToRGBA(image.getPixelColor(x + 1, y)).r; + bits += left < right ? '1' : '0'; + } + + // 64-bit string -> 16 hex chars + let hex = ''; + for (let i = 0; i < bits.length; i += 4) + hex += parseInt(bits.slice(i, i + 4), 2).toString(16); + return hex; +} + +/** Hamming distance between two equal-length hex hashes (lower = more similar). */ +export function hammingDistance(a: string, b: string): number { + if (a.length !== b.length) return Number.MAX_SAFE_INTEGER; + let distance = 0; + for (let i = 0; i < a.length; i++) { + let nibble = parseInt(a[i], 16) ^ parseInt(b[i], 16); + while (nibble) { + distance += nibble & 1; + nibble >>= 1; + } + } + return distance; +} + +/** + * A small thumbnail of the attachment via Discord's media proxy, so we transfer + * and decode a few hundred bytes instead of the full image. + */ +function thumbnailUrl(attachment: Attachment): string { + const base = attachment.proxyURL || attachment.url; + const separator = base.includes('?') ? '&' : '?'; + return `${base}${separator}width=32&height=32`; +} + +/** + * Perceptual hashes for every image attachment on a message. Best-effort: any + * attachment that fails to fetch or decode is skipped (the metadata signature + * still covers it). Never throws. + */ +export async function perceptualHashes(message: Message): Promise { + const images = [...message.attachments.values()] + .filter(isImageAttachment) + .filter((attachment) => attachment.size <= MAX_BYTES) + .slice(0, MAX_ATTACHMENTS); + + const hashes = await Promise.all( + images.map(async (attachment) => { + try { + const response = await fetch(thumbnailUrl(attachment), { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + const buffer = Buffer.from(await response.arrayBuffer()); + return await dHashFromBuffer(buffer); + } catch (error) { + container.logger.debug( + `Automod: could not perceptual-hash attachment ${attachment.id}:`, + error + ); + return null; + } + }) + ); + + return hashes.filter((hash): hash is string => hash !== null); +} diff --git a/src/utils/automod/tracker.ts b/src/utils/automod/tracker.ts index 4fee945..cd2d74f 100644 --- a/src/utils/automod/tracker.ts +++ b/src/utils/automod/tracker.ts @@ -1,10 +1,14 @@ import { automodConfig } from '../config'; +import { hammingDistance } from './phash'; export interface ImageEvent { at: number; channelId: string; messageId: string; + /** Cheap metadata fingerprints (one per image attachment). */ signatures: string[]; + /** Perceptual hashes (one per image attachment, best-effort). */ + hashes: string[]; } export type DetectionLevel = 'none' | 'burst' | 'fanout'; @@ -16,8 +20,10 @@ export interface Detection { events: ImageEvent[]; /** Distinct channels involved. */ channels: string[]; - /** Distinct image signatures involved. */ + /** Distinct metadata signatures involved. */ signatures: string[]; + /** Distinct perceptual hashes involved. */ + hashes: string[]; } const NONE: Detection = { @@ -26,6 +32,7 @@ const NONE: Detection = { events: [], channels: [], signatures: [], + hashes: [], }; /** Per-user recent image events, pruned to the longest detection window. */ @@ -63,35 +70,13 @@ export function recordImageMessage( events.push(event); userEvents.set(userId, events); - // --- Fan-out: one signature seen across N+ distinct channels --- + // --- Fan-out: the same image seen across N+ distinct channels --- const fanoutFrom = event.at - automodConfig.fanoutWindowMs; const fanoutEvents = events.filter((e) => e.at >= fanoutFrom); - const channelsBySignature = new Map>(); - for (const e of fanoutEvents) - for (const signature of e.signatures) { - const channels = channelsBySignature.get(signature) ?? new Set(); - channels.add(e.channelId); - channelsBySignature.set(signature, channels); - } - - const fannedSignatures = [...channelsBySignature.entries()].filter( - ([, channels]) => channels.size >= automodConfig.fanoutChannels - ); - if (fannedSignatures.length > 0) { - const signatures = fannedSignatures.map(([signature]) => signature); - const contributing = fanoutEvents.filter((e) => - e.signatures.some((s) => signatures.includes(s)) - ); - const channels = unique(contributing.map((e) => e.channelId)); - return { - level: 'fanout', - reason: `Identical image posted across ${channels.length} channels`, - events: contributing, - channels, - signatures, - }; - } + const fanout = + detectExactFanout(fanoutEvents) ?? detectPerceptualFanout(fanoutEvents); + if (fanout) return fanout; // --- Burst: N+ image messages within the burst window --- const burstFrom = event.at - automodConfig.burstWindowMs; @@ -105,12 +90,75 @@ export function recordImageMessage( events: burstEvents, channels: unique(burstEvents.map((e) => e.channelId)), signatures: unique(burstEvents.flatMap((e) => e.signatures)), + hashes: unique(burstEvents.flatMap((e) => e.hashes)), }; } return NONE; } +/** Fan-out by exact metadata signature (catches byte-identical re-uploads). */ +function detectExactFanout(events: ImageEvent[]): Detection | null { + const channelsBySignature = new Map>(); + for (const e of events) + for (const signature of e.signatures) { + const channels = channelsBySignature.get(signature) ?? new Set(); + channels.add(e.channelId); + channelsBySignature.set(signature, channels); + } + + const fanned = [...channelsBySignature.entries()].filter( + ([, channels]) => channels.size >= automodConfig.fanoutChannels + ); + if (fanned.length === 0) return null; + + const signatures = fanned.map(([signature]) => signature); + const contributing = events.filter((e) => + e.signatures.some((s) => signatures.includes(s)) + ); + const channels = unique(contributing.map((e) => e.channelId)); + return { + level: 'fanout', + reason: `Identical image posted across ${channels.length} channels`, + events: contributing, + channels, + signatures, + hashes: unique(contributing.flatMap((e) => e.hashes)), + }; +} + +/** + * Fan-out by perceptual hash (catches re-encoded/resized copies of the same + * image that have different metadata signatures per channel). For each hash we + * cluster all near-duplicates within the configured Hamming distance and check + * whether that cluster spans enough distinct channels. + */ +function detectPerceptualFanout(events: ImageEvent[]): Detection | null { + const hashed = events.flatMap((e) => + e.hashes.map((hash) => ({ hash, event: e })) + ); + + for (const anchor of hashed) { + const cluster = hashed.filter( + ({ hash }) => + hammingDistance(hash, anchor.hash) <= automodConfig.phashThreshold + ); + const channels = unique(cluster.map(({ event }) => event.channelId)); + if (channels.length >= automodConfig.fanoutChannels) { + const contributing = unique(cluster.map(({ event }) => event)); + return { + level: 'fanout', + reason: `Near-identical image posted across ${channels.length} channels`, + events: contributing, + channels, + signatures: unique(contributing.flatMap((e) => e.signatures)), + hashes: unique(cluster.map(({ hash }) => hash)), + }; + } + } + return null; +} + /** Whether enough time has passed since the last alert for this user. */ export function shouldAlert(userId: string, now: number): boolean { const last = lastAlertAt.get(userId); diff --git a/src/utils/config.ts b/src/utils/config.ts index ed67593..e147e0a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -48,6 +48,11 @@ export const automodConfig = { burstWindowMs: posInt(process.env.AUTOMOD_BURST_WINDOW_MS, 60_000), /** Distinct channels the same image must appear in to flag a fan-out. */ fanoutChannels: posInt(process.env.AUTOMOD_FANOUT_CHANNELS, 2), + /** + * Max Hamming distance (out of 64) for two perceptual hashes to count as the + * same image. Higher = more lenient/near-duplicate matching. Default 6. + */ + phashThreshold: posInt(process.env.AUTOMOD_PHASH_THRESHOLD, 6), /** Sliding window (ms) for detecting cross-channel fan-out. */ fanoutWindowMs: posInt(process.env.AUTOMOD_FANOUT_WINDOW_MS, 120_000), /** How long (ms) auto-applied/console timeouts last. Default 1 hour. */ From f162ea1baa7687f689dc5e68ad8db12d620c0a3d Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:40:41 +0000 Subject: [PATCH 07/42] Port legacy server-management listeners (parity) Modernizes the legacy bot's ambient listeners to Sapphire/discord.js v14: - Auto-crosspost configured announcement channels, with result logging (crosspost.ts; enabled via CROSSPOST_CHANNEL_IDS). - Message-link flattening: inline quote embeds for same-guild message links (messageLinkEmbed.ts). - Join logging with invite-source attribution via an in-memory invite-use cache seeded on ready and kept current by an inviteCreate listener (memberAdd.ts, inviteCreate.ts, ready.ts, utils/inviteCache.ts). Adds the GuildInvites intent. - Leave logging (an improvement over legacy, which logged joins only). - Best-effort join/leave analytics via the existing MemberAnalytics model. Each feature self-disables when its channel/role id is unconfigured. The legacy file-extension filter is intentionally dropped in favour of Discord's native AutoMod. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- .env.example | 8 +++ src/index.ts | 1 + src/listeners/crosspost.ts | 50 +++++++++++++++++ src/listeners/inviteCreate.ts | 14 +++++ src/listeners/memberAdd.ts | 89 ++++++++++++++++++++++++++++--- src/listeners/memberRemove.ts | 63 +++++++++++++++++++--- src/listeners/messageLinkEmbed.ts | 50 +++++++++++++++++ src/listeners/ready.ts | 26 ++++++++- src/utils/config.ts | 9 ++++ src/utils/inviteCache.ts | 20 +++++++ 10 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 src/listeners/crosspost.ts create mode 100644 src/listeners/inviteCreate.ts create mode 100644 src/listeners/messageLinkEmbed.ts create mode 100644 src/utils/inviteCache.ts diff --git a/.env.example b/.env.example index e89d91f..d67e297 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,14 @@ BOT_TOKEN= # REQUIRED to enable the anti-spam console; leave empty to disable automod. MOD_LOG_CHANNEL_ID= +# --- Server-management features (optional; each self-disables when unset) --- +# JOIN_LEAVE_LOG_CHANNEL_ID= # member join/leave + invite-source logging +# CROSSPOST_LOG_CHANNEL_ID= # where auto-crosspost results are logged +# CROSSPOST_CHANNEL_IDS= # comma-separated announcement channels to auto-publish +# ROLE_SELECT_MESSAGE_ID= # message whose buttons toggle opt-in roles +# EVENT_NOTIFS_ROLE_ID= # role toggled by the "events" button +# SERVER_UPDATE_NOTIFS_ROLE_ID= # role toggled by the "server_updates" button + # --- Persistence (optional) --- # When unset, the bot runs fully in-memory and the spam-image blocklist # resets on restart. With docker-compose this is wired automatically. diff --git a/src/index.ts b/src/index.ts index 8951206..8201035 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ const client = new SapphireClient({ GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildInvites, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], diff --git a/src/listeners/crosspost.ts b/src/listeners/crosspost.ts new file mode 100644 index 0000000..dba71b2 --- /dev/null +++ b/src/listeners/crosspost.ts @@ -0,0 +1,50 @@ +import { Events, Listener, container } from '@sapphire/framework'; +import { EmbedBuilder, type Message } from 'discord.js'; +import { CROSSPOST_LOG_CHANNEL_ID, crosspostChannelIds } from '../utils/config'; +import universalEmbed from '../index'; + +/** + * Auto-publishes (crossposts) messages in configured announcement/feed channels + * and logs the result, mirroring the legacy bot's behaviour. Disabled unless + * CROSSPOST_CHANNEL_IDS is set. + */ +export class CrosspostListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.MessageCreate }); + } + + public async run(message: Message) { + if (crosspostChannelIds.length === 0) return; + if (!message.inGuild()) return; + if (!crosspostChannelIds.includes(message.channelId)) return; + if (!message.crosspostable) return; + + const channelName = + 'name' in message.channel ? message.channel.name : message.channelId; + + try { + await message.crosspost(); + await this.log(`Auto-crossposted in #${channelName}`); + } catch (error) { + container.logger.error(`Auto-crosspost failed in #${channelName}:`, error); + await this.log( + `Failed to auto-crosspost in #${channelName}`, + 'Likely rate-limiting — check the logs for details.' + ); + } + } + + private async log(title: string, description?: string): Promise { + if (!CROSSPOST_LOG_CHANNEL_ID) return; + const channel = await container.client.channels + .fetch(CROSSPOST_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel?.isSendable()) return; + + const embed = new EmbedBuilder(universalEmbed) + .setTitle(title) + .setTimestamp(); + if (description) embed.setDescription(description); + await channel.send({ embeds: [embed] }).catch(() => null); + } +} diff --git a/src/listeners/inviteCreate.ts b/src/listeners/inviteCreate.ts new file mode 100644 index 0000000..e6bbcfd --- /dev/null +++ b/src/listeners/inviteCreate.ts @@ -0,0 +1,14 @@ +import { Events, Listener } from '@sapphire/framework'; +import type { Invite } from 'discord.js'; +import { setInviteUses } from '../utils/inviteCache'; + +/** Seed newly-created invites into the cache so join attribution stays accurate. */ +export class InviteCreateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.InviteCreate }); + } + + public run(invite: Invite) { + setInviteUses(invite.code, invite.uses ?? 0); + } +} diff --git a/src/listeners/memberAdd.ts b/src/listeners/memberAdd.ts index a5b52b3..e552d89 100644 --- a/src/listeners/memberAdd.ts +++ b/src/listeners/memberAdd.ts @@ -1,15 +1,90 @@ -import { Events, Listener } from '@sapphire/framework'; -import type { GuildMember } from 'discord.js'; -// import { prisma } from '../utils/db'; +import { Events, Listener, container } from '@sapphire/framework'; +import { + EmbedBuilder, + TimestampStyles, + time, + type GuildMember, +} from 'discord.js'; +import { JOIN_LEAVE_LOG_CHANNEL_ID } from '../utils/config'; +import { getInviteUses, setInviteUses } from '../utils/inviteCache'; +import { getPrisma } from '../utils/db'; +import universalEmbed from '../index'; export class MemberAddListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { super(context, { ...options, event: Events.GuildMemberAdd }); } - public async run(_member: GuildMember) { - // await prisma.memberAnalytics.create({ - // data: { event: 'join', memberId: member.user.id }, - // }); + public async run(member: GuildMember) { + await this.recordAnalytics(member.id); + await this.logJoin(member); + } + + private async recordAnalytics(memberId: string): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + await prisma.memberAnalytics.create({ + data: { event: 'join', memberId }, + }); + } catch (error) { + container.logger.error('Recording join analytics failed:', error); + } + } + + private async logJoin(member: GuildMember): Promise { + if (!JOIN_LEAVE_LOG_CHANNEL_ID) return; + const channel = await container.client.channels + .fetch(JOIN_LEAVE_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel?.isSendable()) return; + + const source = await this.resolveInviteSource(member); + const embed = new EmbedBuilder(universalEmbed) + .setTitle('📥 Member joined') + .setAuthor({ + name: member.user.tag, + iconURL: member.displayAvatarURL(), + }) + .setThumbnail(member.displayAvatarURL()) + .setDescription(`<@${member.id}> — member #${member.guild.memberCount}`) + .addFields( + { + name: 'Account created', + value: time(member.user.createdAt, TimestampStyles.RelativeTime), + inline: true, + }, + { + name: 'Invite', + value: source ?? 'Unknown (Server Discovery or vanity URL)', + inline: true, + } + ) + .setFooter({ text: `ID: ${member.id}` }) + .setTimestamp(); + + await channel.send({ embeds: [embed] }).catch(() => null); + } + + /** + * Work out which invite was used by finding the one whose use count grew + * since the cached snapshot, then refresh the cache for every invite. + */ + private async resolveInviteSource( + member: GuildMember + ): Promise { + let source: string | null = null; + try { + const invites = await member.guild.invites.fetch(); + for (const invite of invites.values()) { + const previous = getInviteUses(invite.code) ?? 0; + if (!source && invite.uses !== null && invite.uses > previous) + source = `\`${invite.code}\` by ${invite.inviter?.tag ?? 'unknown'}`; + setInviteUses(invite.code, invite.uses ?? 0); + } + } catch (error) { + container.logger.warn('Could not resolve invite source on join:', error); + } + return source; } } diff --git a/src/listeners/memberRemove.ts b/src/listeners/memberRemove.ts index d2a9393..199521e 100644 --- a/src/listeners/memberRemove.ts +++ b/src/listeners/memberRemove.ts @@ -1,15 +1,64 @@ -import { Events, Listener } from '@sapphire/framework'; -import type { GuildMember } from 'discord.js'; -// import { prisma } from '../utils/db'; +import { Events, Listener, container } from '@sapphire/framework'; +import { + EmbedBuilder, + TimestampStyles, + time, + type GuildMember, + type PartialGuildMember, +} from 'discord.js'; +import { JOIN_LEAVE_LOG_CHANNEL_ID } from '../utils/config'; +import { getPrisma } from '../utils/db'; +import universalEmbed from '../index'; export class MemberRemoveListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { super(context, { ...options, event: Events.GuildMemberRemove }); } - public async run(_member: GuildMember) { - // await prisma.memberAnalytics.create({ - // data: { event: 'leave', memberId: member.user.id }, - // }); + public async run(member: GuildMember | PartialGuildMember) { + await this.recordAnalytics(member.id); + await this.logLeave(member); + } + + private async recordAnalytics(memberId: string): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + await prisma.memberAnalytics.create({ + data: { event: 'leave', memberId }, + }); + } catch (error) { + container.logger.error('Recording leave analytics failed:', error); + } + } + + private async logLeave( + member: GuildMember | PartialGuildMember + ): Promise { + if (!JOIN_LEAVE_LOG_CHANNEL_ID) return; + const channel = await container.client.channels + .fetch(JOIN_LEAVE_LOG_CHANNEL_ID) + .catch(() => null); + if (!channel?.isSendable()) return; + + const embed = new EmbedBuilder(universalEmbed) + .setTitle('📤 Member left') + .setAuthor({ + name: member.user.tag, + iconURL: member.displayAvatarURL(), + }) + .setDescription(`<@${member.id}>`) + .setFooter({ text: `ID: ${member.id}` }) + .setTimestamp(); + + // joinedAt is null for partials/uncached members. + if (member.joinedAt) + embed.addFields({ + name: 'Joined', + value: time(member.joinedAt, TimestampStyles.RelativeTime), + inline: true, + }); + + await channel.send({ embeds: [embed] }).catch(() => null); } } diff --git a/src/listeners/messageLinkEmbed.ts b/src/listeners/messageLinkEmbed.ts new file mode 100644 index 0000000..fde1ab1 --- /dev/null +++ b/src/listeners/messageLinkEmbed.ts @@ -0,0 +1,50 @@ +import { Events, Listener } from '@sapphire/framework'; +import { EmbedBuilder, type Message } from 'discord.js'; +import universalEmbed from '../index'; + +// Matches https://discord.com/channels/// (and the +// canary/ptb subdomains). IDs are 17-20 digits to be future-proof. +const MESSAGE_LINK = + /https?:\/\/(?:canary\.|ptb\.)?discord\.com\/channels\/(\d{17,20})\/(\d{17,20})\/(\d{17,20})/g; + +/** + * When a user posts a link to another message in this server, re-posts that + * message's content inline as a quote embed for context. Same-guild only. + */ +export class MessageLinkEmbedListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.MessageCreate }); + } + + public async run(message: Message) { + if (!message.inGuild() || message.author.bot) return; + + const matches = [...message.content.matchAll(MESSAGE_LINK)]; + if (matches.length === 0) return; + + for (const [, guildId, channelId, messageId] of matches) { + if (guildId !== message.guildId) continue; + + const channel = await message.client.channels + .fetch(channelId) + .catch(() => null); + if (!channel?.isTextBased() || channel.isDMBased()) continue; + + const linked = await channel.messages.fetch(messageId).catch(() => null); + if (!linked || (!linked.content && linked.embeds.length === 0)) continue; + + const embed = new EmbedBuilder(universalEmbed) + .setAuthor({ + name: `${linked.author.tag} said:`, + iconURL: linked.author.displayAvatarURL(), + }) + .setDescription(linked.content || '*[no text content]*') + .setFooter({ + text: `Quoted by ${message.author.tag} • click the link for full context`, + }) + .setTimestamp(linked.createdAt); + + await message.channel.send({ embeds: [embed] }).catch(() => null); + } + } +} diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index b21b07b..193750d 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -2,14 +2,16 @@ import { Events, Listener } from '@sapphire/framework'; import type { Client } from 'discord.js'; import { initDatabase } from '../utils/db'; import { loadBlocklist } from '../utils/automod/blocklist'; +import { seedInviteCache } from '../utils/inviteCache'; +import { JOIN_LEAVE_LOG_CHANNEL_ID, SERVER_ID } from '../utils/config'; export class ReadyListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { super(context, { ...options, once: true, event: Events.ClientReady }); } - public async run({ user }: Client) { - const { username, id, discriminator } = user!; + public async run(client: Client) { + const { username, id, discriminator } = client.user; this.container.logger.info( `Logged in as ${username}#${discriminator} (${id})` ); @@ -18,5 +20,25 @@ export class ReadyListener extends Listener { await initDatabase(); // Warm the automod blocklist from the database (no-op without one). await loadBlocklist(); + // Seed the invite-use cache so join logging can attribute the source. + await this.fillInviteCache(client); + } + + private async fillInviteCache(client: Client): Promise { + // Only needed when join logging is enabled; fetching invites requires the + // Manage Server permission, so failures are non-fatal. + if (!JOIN_LEAVE_LOG_CHANNEL_ID) return; + const guild = await client.guilds.fetch(SERVER_ID).catch(() => null); + if (!guild) return; + try { + const invites = await guild.invites.fetch(); + seedInviteCache(invites.values()); + this.container.logger.info(`Invite cache filled (${invites.size} invites).`); + } catch (error) { + this.container.logger.warn( + 'Could not fetch invites for join tracking (missing Manage Server permission?):', + error + ); + } } } diff --git a/src/utils/config.ts b/src/utils/config.ts index e147e0a..73c3126 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -22,6 +22,12 @@ export const { // Optional: persistence for the spam-image blocklist. When unset the bot // runs fully in-memory and the blocklist resets on restart. DATABASE_URL = '', + // Server-management features. Each self-disables when its id is unset. + JOIN_LEAVE_LOG_CHANNEL_ID = '', // member join/leave + invite-source logging + CROSSPOST_LOG_CHANNEL_ID = '', // where auto-crosspost results are logged + ROLE_SELECT_MESSAGE_ID = '', // message whose buttons toggle opt-in roles + EVENT_NOTIFS_ROLE_ID = '', // role toggled by the "events" button + SERVER_UPDATE_NOTIFS_ROLE_ID = '', // role toggled by the "server_updates" button } = process.env; /** Parse a positive integer from the environment, falling back to a default. */ @@ -37,6 +43,9 @@ const idList = (value: string | undefined): string[] => .map((id) => id.trim()) .filter((id) => id.length > 0); +/** Announcement/feed channels whose messages are auto-published (crossposted). */ +export const crosspostChannelIds = idList(process.env.CROSSPOST_CHANNEL_IDS); + /** * Tunables for the image-spam detector. Every value is overridable via the * environment so moderators can adjust thresholds without a redeploy. diff --git a/src/utils/inviteCache.ts b/src/utils/inviteCache.ts new file mode 100644 index 0000000..4e887d8 --- /dev/null +++ b/src/utils/inviteCache.ts @@ -0,0 +1,20 @@ +/** + * In-memory snapshot of each invite's use count, used to work out which invite + * a new member joined with (by diffing against the live counts on join). + * Filled on ready, kept current by the inviteCreate and guildMemberAdd + * listeners. Not persisted — it is rebuilt from Discord on every startup. + */ +const inviteUses = new Map(); + +export const setInviteUses = (code: string, uses: number): void => { + inviteUses.set(code, uses); +}; + +export const getInviteUses = (code: string): number | undefined => + inviteUses.get(code); + +export const seedInviteCache = ( + invites: Iterable<{ code: string; uses: number | null }> +): void => { + for (const invite of invites) inviteUses.set(invite.code, invite.uses ?? 0); +}; From c7ccb7159866286dad2360f3935c6c348fd0b248 Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:42:14 +0000 Subject: [PATCH 08/42] Port role-select buttons, tag buttons, and a modern /say (parity) - Role-select interaction handler toggles the event / server-update opt-in roles when their buttons are clicked on the configured role-select message (roleSelect.ts; gated by ROLE_SELECT_MESSAGE_ID + role ids). - Per-tag buttons: a `tag:` button replies ephemerally with that tag, restoring the legacy "codeblock" button on the `ask` tag. Adds a shared resolveTag() helper (tagButton.ts, utils/resolveTag.ts). - /say: a Manage-Server-gated slash command that sends a custom embed (title, description, optional newline-delimited fields and thumbnail) to a chosen channel, replacing the legacy multi-prompt say flow. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- src/commands/say.ts | 132 +++++++++++++++++++++++++ src/interaction-handlers/roleSelect.ts | 85 ++++++++++++++++ src/interaction-handlers/tagButton.ts | 39 ++++++++ src/utils/resolveTag.ts | 26 +++++ src/utils/tags.ts | 2 +- 5 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 src/commands/say.ts create mode 100644 src/interaction-handlers/roleSelect.ts create mode 100644 src/interaction-handlers/tagButton.ts create mode 100644 src/utils/resolveTag.ts diff --git a/src/commands/say.ts b/src/commands/say.ts new file mode 100644 index 0000000..04dc0f3 --- /dev/null +++ b/src/commands/say.ts @@ -0,0 +1,132 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { + ChannelType, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, +} from 'discord.js'; +import universalEmbed from '../index'; + +const MAX_FIELDS = 25; + +export class SayCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + name: 'say', + description: 'Send a custom embed to a channel as the bot (staff only).', + }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerChatInputCommand((builder) => + builder + .setName(this.name) + .setDescription(this.description) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Channel to send the embed in') + .addChannelTypes( + ChannelType.GuildText, + ChannelType.GuildAnnouncement + ) + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('title') + .setDescription('Embed title') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('description') + .setDescription('Embed description') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('fields') + .setDescription('Optional fields, one per line as: name | value') + ) + .addStringOption((option) => + option + .setName('thumbnail') + .setDescription('Optional thumbnail image URL') + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + // Belt-and-braces: the command is also gated by default member permissions. + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) + return interaction.reply({ + content: 'You need the Manage Server permission to use this.', + flags: MessageFlags.Ephemeral, + }); + + const picked = interaction.options.getChannel('channel', true); + const channel = await interaction.client.channels + .fetch(picked.id) + .catch(() => null); + if (!channel?.isSendable()) + return interaction.reply({ + content: 'I can\'t send messages in that channel.', + flags: MessageFlags.Ephemeral, + }); + + const embed = new EmbedBuilder(universalEmbed) + .setTitle(interaction.options.getString('title', true)) + .setDescription(interaction.options.getString('description', true)) + .setTimestamp(); + + const thumbnail = interaction.options.getString('thumbnail'); + if (thumbnail) { + if (!URL.canParse(thumbnail)) + return interaction.reply({ + content: 'The thumbnail must be a valid URL.', + flags: MessageFlags.Ephemeral, + }); + embed.setThumbnail(thumbnail); + } + + const fields = this.parseFields(interaction.options.getString('fields')); + if (fields.length > 0) embed.addFields(fields); + + try { + await channel.send({ embeds: [embed] }); + } catch (error) { + this.container.logger.error('/say failed to send:', error); + return interaction.reply({ + content: 'Something went wrong sending the embed.', + flags: MessageFlags.Ephemeral, + }); + } + + return interaction.reply({ + content: `✅ Sent to <#${picked.id}>.`, + flags: MessageFlags.Ephemeral, + }); + } + + /** Parse newline-separated `name | value` pairs into embed fields. */ + private parseFields(raw: string | null): { name: string; value: string }[] { + if (!raw) return []; + return raw + .split('\n') + .map((line) => line.split('|')) + .filter((parts) => parts.length >= 2 && parts[0].trim() && parts[1].trim()) + .slice(0, MAX_FIELDS) + .map((parts) => ({ + name: parts[0].trim(), + value: parts.slice(1).join('|').trim(), + })); + } +} diff --git a/src/interaction-handlers/roleSelect.ts b/src/interaction-handlers/roleSelect.ts new file mode 100644 index 0000000..43d6a07 --- /dev/null +++ b/src/interaction-handlers/roleSelect.ts @@ -0,0 +1,85 @@ +import { + InteractionHandler, + InteractionHandlerTypes, +} from '@sapphire/framework'; +import { + EmbedBuilder, + MessageFlags, + type ButtonInteraction, + type GuildMember, +} from 'discord.js'; +import { + EVENT_NOTIFS_ROLE_ID, + ROLE_SELECT_MESSAGE_ID, + SERVER_UPDATE_NOTIFS_ROLE_ID, +} from '../utils/config'; +import universalEmbed from '../index'; + +interface RoleToggle { + roleId: string; + label: string; +} + +/** Self-assignable opt-in roles, keyed by the button's custom id. */ +function roleForButton(customId: string): RoleToggle | null { + if (customId === 'events' && EVENT_NOTIFS_ROLE_ID) + return { roleId: EVENT_NOTIFS_ROLE_ID, label: 'event notification' }; + if (customId === 'server_updates' && SERVER_UPDATE_NOTIFS_ROLE_ID) + return { + roleId: SERVER_UPDATE_NOTIFS_ROLE_ID, + label: 'server update notification', + }; + return null; +} + +/** + * Toggles opt-in notification roles when a member clicks a button on the + * configured role-select message. Disabled unless ROLE_SELECT_MESSAGE_ID and + * the corresponding role id are set. + */ +export class RoleSelectHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + if (!ROLE_SELECT_MESSAGE_ID) return this.none(); + if (interaction.message.id !== ROLE_SELECT_MESSAGE_ID) return this.none(); + const toggle = roleForButton(interaction.customId); + return toggle ? this.some(toggle) : this.none(); + } + + public async run(interaction: ButtonInteraction, toggle: RoleToggle) { + const member = interaction.member as GuildMember | null; + if (!member) + return interaction.reply({ + content: 'This only works inside the server.', + flags: MessageFlags.Ephemeral, + }); + + const had = member.roles.cache.has(toggle.roleId); + try { + if (had) await member.roles.remove(toggle.roleId); + else await member.roles.add(toggle.roleId); + } catch { + return interaction.reply({ + content: `I couldn't update your roles — please check my permissions or ask a moderator.`, + flags: MessageFlags.Ephemeral, + }); + } + + const embed = new EmbedBuilder(universalEmbed).setTitle( + `✅ The ${toggle.label} role was ${had ? 'removed' : 'added'}.` + ); + return interaction.reply({ + embeds: [embed], + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/src/interaction-handlers/tagButton.ts b/src/interaction-handlers/tagButton.ts new file mode 100644 index 0000000..4df143c --- /dev/null +++ b/src/interaction-handlers/tagButton.ts @@ -0,0 +1,39 @@ +import { + InteractionHandler, + InteractionHandlerTypes, +} from '@sapphire/framework'; +import { MessageFlags, type ButtonInteraction } from 'discord.js'; +import { resolveTag } from '../utils/resolveTag'; + +/** + * Replies (ephemerally) with a tag when a `tag:` button is clicked — e.g. + * the "Learn How to Share Code" button on the `ask` tag opens the `codeblock` + * tag. This restores the legacy per-tag button-reply behaviour. + */ +export class TagButtonHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + if (!interaction.customId.startsWith('tag:')) return this.none(); + return this.some(interaction.customId.slice('tag:'.length)); + } + + public async run(interaction: ButtonInteraction, tagName: string) { + const payload = resolveTag(tagName); + if (!payload) + return interaction.reply({ + content: 'That tag no longer exists.', + flags: MessageFlags.Ephemeral, + }); + + return interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); + } +} diff --git a/src/utils/resolveTag.ts b/src/utils/resolveTag.ts new file mode 100644 index 0000000..b3a95d6 --- /dev/null +++ b/src/utils/resolveTag.ts @@ -0,0 +1,26 @@ +import type { ActionRowBuilder, ButtonBuilder, EmbedBuilder } from 'discord.js'; +import tags from './tags'; + +export interface TagPayload { + content?: string; + embeds?: EmbedBuilder[]; + components?: ActionRowBuilder[]; +} + +/** + * Resolve a tag name into a sendable message payload, evaluating templated + * (function) content with the optional user id. Returns null for unknown tags. + * Shared by the `/tag` command and the tag-button interaction handler. + */ +export function resolveTag(name: string, userId?: string): TagPayload | null { + const tag = tags[name]; + if (!tag) return null; + + const payload: TagPayload = {}; + if (tag.embeds) payload.embeds = tag.embeds; + if (tag.components) payload.components = tag.components; + if (tag.content) + payload.content = + typeof tag.content === 'function' ? tag.content(userId) : tag.content; + return payload; +} diff --git a/src/utils/tags.ts b/src/utils/tags.ts index 578b38c..eed864a 100644 --- a/src/utils/tags.ts +++ b/src/utils/tags.ts @@ -67,7 +67,7 @@ const tags: Record = { components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId('codeblock') + .setCustomId('tag:codeblock') .setLabel('Learn How to Share Code') .setStyle(ButtonStyle.Primary), ), From 2c3b69dc3842afee48c53650c0b05316d0193711 Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:45:14 +0000 Subject: [PATCH 09/42] Cleanliness pass: tag refactor, Dockerfile fix, ping + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix the Dockerfile: drop `apk add openssl1.1-compat` (removed from current Alpine and unneeded with Prisma 7's driver adapter — it was breaking the image build), and restructure COPY/install for proper layer caching. - Refactor /tag to use the shared resolveTag() helper: drops the `any` payload, modernizes `ephemeral` -> MessageFlags, uses isSendable(), and fixes a gap where the in-channel path ignored embeds. - Modernize /ping to the non-deprecated deferReply({ withResponse }) API and tidy formatting. - README: document /ping, /say, the full tag list, and the ported server-management features. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- Dockerfile | 14 +++--- README.md | 29 +++++++++++ src/commands/ping.ts | 40 +++++++-------- src/commands/tag.ts | 115 +++++++++++-------------------------------- 4 files changed, 83 insertions(+), 115 deletions(-) diff --git a/Dockerfile b/Dockerfile index d72fd64..b1bd54c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,15 +4,15 @@ LABEL org.opencontainers.image.source https://github.com/arduinodiscord/bot WORKDIR /srv -COPY ./package*.json . -COPY ./prisma ./prisma - -COPY . . - -RUN apk add --update --no-cache openssl1.1-compat - +# Install dependencies first for better layer caching. The postinstall hook runs +# `prisma generate`, which needs the schema, so copy prisma/ before installing. +COPY package*.json ./ +COPY prisma ./prisma +COPY prisma.config.ts ./ RUN npm install +# Then copy the rest of the source and build. +COPY . . RUN npm run build CMD ["npm", "start"] diff --git a/README.md b/README.md index 1f0cc7b..f61bc7d 100644 --- a/README.md +++ b/README.md @@ -17,20 +17,29 @@ All commands are used as Discord slash commands (type `/` in Discord): | Command | Usage Example | Description | |-----------|---------------------------|--------------------------------------------------------------------| | `/about` | `/about` | Shows information about the bot. | +| `/ping` | `/ping` | Bot/API latency and uptime. | | `/tag` | `/tag name: [user:@username]` | Sends an informational tag to the bot-commands channel, optionally pinging a user. | +| `/say` | `/say channel:<#channel> title: description:` | Staff-only (Manage Server): send a custom embed to a channel. Optional `fields` (one `name \| value` per line) and `thumbnail`. | ### `/tag` options (alphabetical) +- `ai` — The server's no-AI policy. - `ask` — Guidance on how to ask good questions. - `avrdude` — AVRDUDE error troubleshooting. - `codeblock` — How to format code in Discord. +- `debounce` — Debouncing bouncy buttons/switches. - `espcomm` — ESP board communication troubleshooting. +- `help` — How to use the bot and list of tags. - `hid` — Info about Arduino HID (keyboard/mouse) support. +- `lab` — Recommended electronics lab equipment. - `language` — What language Arduino uses. - `levelShifter` — Logic level shifter explanation. - `libmissing` — Fixing missing library errors. +- `needinfo` — Ask a user for the details needed to help them. +- `ninevolt` — Why 9V batteries are a poor choice. - `power` — Powering Arduino safely. - `pullup` — Pull-up/pull-down resistor explanation. +- `reinstall` — How to cleanly reinstall the Arduino IDE. - `wiki` — Link to the Arduino Discord community wiki. **Example:** @@ -73,6 +82,26 @@ msgs / Not spam** — so a human stays in the loop. Members with Manage Messages (already enabled in `index.ts`). Set `MOD_LOG_CHANNEL_ID` to enable the console; leaving it unset disables the automod entirely. +## Server management + +These ambient features were ported from the legacy bot and modernized. Each +**self-disables** until its channel/role id is configured (see +[`.env.example`](.env.example)): + +- **Auto-crosspost** — automatically publishes messages in configured + announcement channels and logs the result (`CROSSPOST_CHANNEL_IDS`). +- **Join/leave logging** — posts member join (with invite-source attribution) + and leave embeds to a log channel, and records best-effort join/leave + analytics when a database is configured (`JOIN_LEAVE_LOG_CHANNEL_ID`). +- **Role-select buttons** — buttons on a configured message let members toggle + the event / server-update opt-in roles (`ROLE_SELECT_MESSAGE_ID`). +- **Message-link flattening** — when someone posts a link to another message in + the server, the bot quotes that message inline for context. + +> The legacy file-extension attachment filter and `maintenance` mode were +> intentionally dropped in favour of Discord-native AutoMod and modern +> redeploy/hosting workflows. + ## Environment Variables & Configuration This bot requires the following environment variables to be set: diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 884bf1f..eb08183 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -19,31 +19,29 @@ export class PingCommand extends Command { }); } - public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { - const sent = await interaction.deferReply({ fetchReply: true }); - - const latency = sent.createdTimestamp - interaction.createdTimestamp; + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const sent = await interaction.deferReply({ withResponse: true }); + const createdTimestamp = + sent.resource?.message?.createdTimestamp ?? Date.now(); + + const latency = createdTimestamp - interaction.createdTimestamp; const apiLatency = Math.round(this.container.client.ws.ping); - + const embed = new EmbedBuilder(universalEmbed) - .setDescription(`My latency is ${latency}ms `) + .setDescription(`My latency is ${latency}ms`) .addFields([ - - { - name: 'API Latency', - value: `${apiLatency}ms`, - inline: true - }, - { - name: 'Uptime', + { name: 'API Latency', value: `${apiLatency}ms`, inline: true }, + { + name: 'Uptime', value: this.formatUptime(process.uptime()), - inline: false - } + inline: false, + }, ]) - .setFooter({ text: 'Arduino server' }) + .setFooter({ text: 'Arduino server' }) .setTimestamp(); - return interaction.editReply({ embeds: [embed] }); } @@ -52,13 +50,13 @@ export class PingCommand extends Command { const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); - - const parts = []; + + const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); parts.push(`${secs}s`); - + return parts.join(' '); } } diff --git a/src/commands/tag.ts b/src/commands/tag.ts index df633ec..05c6c5a 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -1,7 +1,8 @@ import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; -import { TextChannel, EmbedBuilder } from 'discord.js'; +import { EmbedBuilder, MessageFlags } from 'discord.js'; import { BOT_COMMANDS_CHANNEL_ID } from '../utils/config'; import tags from '../utils/tags'; +import { resolveTag } from '../utils/resolveTag'; import universalEmbed from '../index'; export class TagCommand extends Command { @@ -56,108 +57,48 @@ export class TagCommand extends Command { } public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction, + interaction: Command.ChatInputCommandInteraction ) { - const option = interaction.options.get('name'); - const tagName = option?.value as keyof typeof tags; + const tagName = interaction.options.getString('name', true); const user = interaction.options.getUser('user'); - const tag = tags[tagName]; - const botCommandsOnly = tag.botCommandsOnly !== false; // Defaults to true if missing - - // Role restriction check (uncomment and use if needed) - // if (tag.requiredRoles) { - // const member = await interaction.guild?.members.fetch(interaction.user.id); - // const hasRole = member?.roles.cache.some(role => - // tag.requiredRoles.includes(role.name) || tag.requiredRoles.includes(role.id) - // ); - // if (!hasRole) { - // return interaction.reply({ - // content: "You do not have permission to use this tag.", - // ephemeral: true, - // }); - // } - // } - - // If tag is allowed in any channel, reply there - if (!botCommandsOnly) { - if (typeof tag === 'object' && tag.content) { - return interaction.reply({ - content: - typeof tag.content === 'function' - ? tag.content(user?.id) - : tag.content, - ephemeral: false, - }); - } else if (typeof tag === 'string') { - return interaction.reply({ content: tag, ephemeral: false }); - } - } - // Tag not found - if (!tag) { + const tag = tags[tagName]; + const payload = resolveTag(tagName, user?.id); + if (!tag || !payload) return interaction.reply({ content: 'That tag does not exist.', - ephemeral: true, + flags: MessageFlags.Ephemeral, }); - } - // Fetch bot-commands channel + // Ping the requested user when the tag didn't already include a mention. + if (user && !payload.content) payload.content = `<@${user.id}>`; - const botCommandsChannel = await interaction.guild?.channels.fetch( - BOT_COMMANDS_CHANNEL_ID, - ); + // Tags default to bot-commands-channel-only unless they opt out. + if (tag.botCommandsOnly === false) return interaction.reply(payload); - if (!botCommandsChannel || !(botCommandsChannel instanceof TextChannel)) { + const botCommandsChannel = await interaction.client.channels + .fetch(BOT_COMMANDS_CHANNEL_ID) + .catch(() => null); + if (!botCommandsChannel?.isSendable()) return interaction.reply({ - content: 'Bot commands channel not found or is not a text channel.', - ephemeral: true, + content: 'The bot-commands channel is unavailable.', + flags: MessageFlags.Ephemeral, }); - } - - let messagePayload: any = {}; - if (typeof tag === 'object') { - messagePayload = { ...tag }; - // If tag.content is a function, call it with user id - if (tag.content) { - messagePayload.content = - typeof tag.content === 'function' - ? tag.content(user?.id) - : tag.content; - } - if (user && !messagePayload.content) { - messagePayload.content = `<@${user.id}>`; - } - } else { - messagePayload.content = user ? `<@${user.id}> ${tag}` : tag; - } + await botCommandsChannel.send(payload); - await botCommandsChannel.send(messagePayload); + const pointer = new EmbedBuilder(universalEmbed) + .setTitle('Requested info was sent in the Bot-Commands Channel') + .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`); - if (user) { - // Notify the user in the original channel (not ephemeral) + if (user) return interaction.reply({ content: `<@${user.id}> you've been tagged with standard helpful info.`, - ephemeral: false, - embeds: [ - new EmbedBuilder(universalEmbed) - .setTitle('Your answer is in the Bot-Commands Channel...') - .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`), //todo: 'info about <@$tagName}>!' would be better - ], + embeds: [pointer], }); - } else { - // Ephemeral reply for normal tag - return interaction.reply({ - content: ``, - ephemeral: true, - embeds: [ - new EmbedBuilder(universalEmbed) - .setTitle('Requested info was sent in the Bot-Commands Channel') - .setDescription( - `See <#${BOT_COMMANDS_CHANNEL_ID}>... only you can see this message...`, - ), - ], - }); - } + return interaction.reply({ + embeds: [pointer], + flags: MessageFlags.Ephemeral, + }); } } From b75c70a2af092bb66d3fc5817277283f8ccbf2e8 Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 23:57:37 +0000 Subject: [PATCH 10/42] Resolve PR #22 feedback and fix tag grammar/spelling Incorporates the language work from PR #22 (already on staging) plus the contributor's follow-up: per real-world feedback that the old bootloader can work on non-Nano 328P "franken-boards", the avrdude step-8 advice no longer restricts the old-bootloader suggestion to Nanos only. Also restores the differentiated /tag confirmation titles and sweeps the tag content for obvious spelling/grammar errors (useing, untill, compileing, secconds, backwords, Attemppting, reconized, repibably, commonunicate, consistantly, reagulator, usefull, there->their, "ai"->"AI", etc.). Co-authored-by: tuulikauri <170147490+tuulikauri@users.noreply.github.com> Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- src/commands/tag.ts | 16 ++++++++++------ src/utils/tags.ts | 24 ++++++++++++------------ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/commands/tag.ts b/src/commands/tag.ts index 05c6c5a..585f42f 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -87,17 +87,21 @@ export class TagCommand extends Command { await botCommandsChannel.send(payload); - const pointer = new EmbedBuilder(universalEmbed) - .setTitle('Requested info was sent in the Bot-Commands Channel') - .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`); - if (user) return interaction.reply({ content: `<@${user.id}> you've been tagged with standard helpful info.`, - embeds: [pointer], + embeds: [ + new EmbedBuilder(universalEmbed) + .setTitle('Your answer is in the Bot-Commands Channel...') + .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`), + ], }); return interaction.reply({ - embeds: [pointer], + embeds: [ + new EmbedBuilder(universalEmbed) + .setTitle('Requested info was sent in the Bot-Commands Channel') + .setDescription(`See <#${BOT_COMMANDS_CHANNEL_ID}> for your info!`), + ], flags: MessageFlags.Ephemeral, }); } diff --git a/src/utils/tags.ts b/src/utils/tags.ts index eed864a..9624b5d 100644 --- a/src/utils/tags.ts +++ b/src/utils/tags.ts @@ -27,10 +27,10 @@ const tags: Record = { ) .setTitle('🚫 NO AI ZONE 🚫') .setDescription( - "This server is a **NO AI** zone. We do not allow the use of AI-generated content, including but not limited to ChatGPT, Midjourney, DALL-E, and other similar tools. This includes asking for help with code, schematics, or any other content that can be generated by AI.\n\nWe believe in the value of human creativity and problem-solving skills. We encourage you to learn and grow by engaging with the community and seeking help from fellow members.\n\nIf you have questions or need assistance, please ask the community for help. We're here to support each other and learn together! \n\nThank you for respecting this rule and helping to maintain the integrity of our community by NOT useing ai to reply to users, and if users are useing AI for there project, do not assist!", + "This server is a **NO AI** zone. We do not allow the use of AI-generated content, including but not limited to ChatGPT, Midjourney, DALL-E, and other similar tools. This includes asking for help with code, schematics, or any other content that can be generated by AI.\n\nWe believe in the value of human creativity and problem-solving skills. We encourage you to learn and grow by engaging with the community and seeking help from fellow members.\n\nIf you have questions or need assistance, please ask the community for help. We're here to support each other and learn together! \n\nThank you for respecting this rule and helping to maintain the integrity of our community by NOT using AI to reply to users, and if users are using AI for their project, do not assist!", ) .setFooter({ - text: 'Help us keep this a NO AI zone!\n\nYou do them no favors by using AI, as it will stunt there growth and learning.', + text: 'Help us keep this a NO AI zone!\n\nYou do them no favors by using AI, as it will stunt their growth and learning.', }), ], }, @@ -115,7 +115,7 @@ const tags: Record = { name: '6. Is your cable faulty or capable of sending data?', value: "Some USB cables aren't capable of transferring data, and some may be faulty, so make sure to try a different one to see if it works! You should try plugging another device into the cable to see if data can pass through it.\n" + - 'APPLE computers sometimes have issues with there usb adapters. You need to try other adaptors or buy an official one for your MAC.', + 'APPLE computers sometimes have issues with their USB adapters. You need to try other adaptors or buy an official one for your MAC.', }, { name: '7. Is the power LED lit on your board?', @@ -123,15 +123,15 @@ const tags: Record = { 'If it is, unplug and re-plug your board, then check for blinking LEDs. If only the Power LED or no LEDs light up, ask for further assistance (not for all boards).', }, { - name: '8. Do you have a Nano or similar Atmega 328p-based board?', + name: '8. Do you have a Nano or other ATmega328P-based board?', value: - 'If so, try using the old bootloader. In the Arduino IDE, go to Tools -> Processor and select 328p (old bootloader). This only applies to Nanos and Nano variants. *Boards this applies to will show the Processor option in the Tools menu. Otherwise, you can skip this step.*', + 'If so, try using the old bootloader. In the Arduino IDE, go to Tools → Processor and select **ATmega328P (Old Bootloader)**. This is most common on Nanos and Nano-style clones, but some other 328P-based boards expose it too. *If the Processor option appears in your Tools menu, it is worth a try; if it does not, you can skip this step.*', }, { name: '9. Does your onboard LED blink when you press the reset button?', value: "Try pressing the reset button on your Arduino. If the onboard LED doesn't blink when you reset, you probably have a broken bootloader. You can check out [this tutorial](https://www.arduino.cc/en/Hacking/Bootloader?from=Tutorial.Bootloader) on how to burn the bootloader.\n\n" + - 'It it does, try unplugging the board, holding down the reset button, then plugging it back in while still holding the reset button. After a few seconds, release the reset button and try uploading your sketch again. This is called the "manual reset" method and can sometimes help with communication issues. You can also hold down the button untill its done compileing and tries to UPLOAD, then release it. This is called the "manual reset" method and can sometimes help with communication issues.', + 'If it does, try unplugging the board, holding down the reset button, then plugging it back in while still holding the reset button. After a few seconds, release the reset button and try uploading your sketch again. This is called the "manual reset" method and can sometimes help with communication issues. You can also hold down the button until its done compiling and tries to UPLOAD, then release it. This is called the "manual reset" method and can sometimes help with communication issues.', }, { name: "10. Is this a problem on your computer's side?", @@ -251,7 +251,7 @@ const tags: Record = { { name: '6. Have you tried holding down the BOOT/IO0/FLASH button?', value: - 'Unhook the board, wait 10 secconds, then try holding down the BOOT/IO0/FLASH button. Keep holding it down untill **AFTER** its done compileing, and the IDE says "uploading".\n\nThen release the button. This puts the board into flash mode, and can sometimes help with communication issues.\n\nThe boards are so bad sometimes the buttons are labeled backwords, so try again with the other button if it has one.', + 'Unhook the board, wait 10 seconds, then try holding down the BOOT/IO0/FLASH button. Keep holding it down until **AFTER** its done compiling, and the IDE says "uploading".\n\nThen release the button. This puts the board into flash mode, and can sometimes help with communication issues.\n\nThe boards are so bad sometimes the buttons are labeled backwards, so try again with the other button if it has one.', }, { name: '7. Are there any problems with your wiring?', @@ -310,7 +310,7 @@ const tags: Record = { { name: 'Boards that are __NOT__ HID compliant', value: - 'Uno (R3 or older), Mega, Nano (328), and Pro Mini cannot be used as HID devices. Attemppting to do so will result in a bricked board.', + 'Uno (R3 or older), Mega, Nano (328), and Pro Mini cannot be used as HID devices. Attempting to do so will result in a bricked board.', }, { name: 'Boards that __ARE__ HID compliant', @@ -371,7 +371,7 @@ const tags: Record = { { name: 'The Problem: Voltage Mismatch', value: - "Many popular Arduino boards, like the Uno and Mega operate at **5 Volts (5V)**. This means their digital pins operate at 5V for a 'HIGH' signal, and they expect 5v in return **3.3v will not be reconized**.\n\nHowever, a lot of modern modules and sensors (like the NRF24L01, ESP8266, SD cards) are designed to operate at **3.3 Volts (3.3V)**. Their **input or GPIO** pins are often **NOT 5V tolerant** and they can **NOT repibably send 5v to devices** either.\n\n Its like some one whispering softly to someone that is hard of hearing. They can not commonunicate consistantly. https://wiki.arduinodiscord.cc/hardwareGuides/logiclevel", + "Many popular Arduino boards, like the Uno and Mega operate at **5 Volts (5V)**. This means their digital pins operate at 5V for a 'HIGH' signal, and they expect 5v in return **3.3v will not be recognized**.\n\nHowever, a lot of modern modules and sensors (like the NRF24L01, ESP8266, SD cards) are designed to operate at **3.3 Volts (3.3V)**. Their **input or GPIO** pins are often **NOT 5V tolerant** and they can **NOT reliably send 5v to devices** either.\n\n It's like someone whispering softly to someone who is hard of hearing. They cannot communicate consistently. https://wiki.arduinodiscord.cc/hardwareGuides/logiclevel", }, { name: 'What Happens if You Connect 5V to a 3.3V Pin?', @@ -471,12 +471,12 @@ const tags: Record = { ninevolt: { embeds: [ new EmbedBuilder({ ...universalEmbed }) - .setTitle('Nine Volt usefullness') + .setTitle('Nine Volt usefulness') .setDescription('Not very useful') .addFields({ name: 'Dies quickly', value: - 'Nine-volt batteries are not very useful for powering Arduinos or other electronics. They have a low capacity and will die quickly 15 minutes or less, especially if you are using them to power an Arduino. They do not have enough power to drive motors or servos. They rarely have a usefull purpose in the Arduino world, and are not recommended for use with Arduinos, even though many kits come with them. https://odysee.com/@Maderdash:2/9vBattery:0', + 'Nine-volt batteries are not very useful for powering Arduinos or other electronics. They have a low capacity and will die quickly 15 minutes or less, especially if you are using them to power an Arduino. They do not have enough power to drive motors or servos. They rarely have a useful purpose in the Arduino world, and are not recommended for use with Arduinos, even though many kits come with them. https://odysee.com/@Maderdash:2/9vBattery:0', }), ], }, @@ -499,7 +499,7 @@ const tags: Record = { { name: '3. Powering the Arduino properly', value: - 'On most Arduino boards, there will be a pin marked **VIN**. This stands for Voltage Input. You can provide the maximum power rating for your board on this pin. An UNO will accept 7-12V. The voltage reagulator requires around 2v above what is trying to be supplied out. 5v plus 2v for the overhead = 7v+. Also the more voltage you supply to this pin, the less current you can get out of the board. Example: 7v you can get 400ma out of the reagulator. 12v you can get 100ma. 24v might over heat the reagulator useing just the board its self. If you have a regulated 5-volt power supply, you can sometimes use the 5V pin to power the Arduino. You should **NOT** connect batteries to the 5V or 3.3V pins. You can also power Arduinos via USB, this is conected directly to the 5v PIN on the board, or the barrel jack, that is conected directly to the VIN pin on some boards.', + 'On most Arduino boards, there will be a pin marked **VIN**. This stands for Voltage Input. You can provide the maximum power rating for your board on this pin. An UNO will accept 7-12V. The voltage regulator requires around 2v above what is trying to be supplied out. 5v plus 2v for the overhead = 7v+. Also the more voltage you supply to this pin, the less current you can get out of the board. Example: 7v you can get 400ma out of the regulator. 12v you can get 100ma. 24v might overheat the regulator using just the board itself. If you have a regulated 5-volt power supply, you can sometimes use the 5V pin to power the Arduino. You should **NOT** connect batteries to the 5V or 3.3V pins. You can also power Arduinos via USB, this is conected directly to the 5v PIN on the board, or the barrel jack, that is conected directly to the VIN pin on some boards.', }, { name: '4. The 3.3V pin', From 8bb885e48002300a3b7b7b924138f721a0b9edbc Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Fri, 12 Jun 2026 00:00:09 +0000 Subject: [PATCH 11/42] Refactor: extract shared embed out of the entry point The shared `universalEmbed` was exported from src/index.ts, forcing every command and listener to import the bootstrap module just to reuse an embed. Move it to its own utils/embed.ts and repoint all importers. Also modernize index.ts: use the ActivityType enum instead of the magic number 3, drop the dead db import comment, and remove the now-unnecessary EmbedBuilder import. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- src/commands/about.ts | 2 +- src/commands/ping.ts | 2 +- src/commands/say.ts | 2 +- src/commands/tag.ts | 2 +- src/index.ts | 18 ++++++------------ src/interaction-handlers/roleSelect.ts | 2 +- src/listeners/crosspost.ts | 2 +- src/listeners/memberAdd.ts | 2 +- src/listeners/memberRemove.ts | 2 +- src/listeners/messageLinkEmbed.ts | 2 +- src/utils/embed.ts | 16 ++++++++++++++++ src/utils/tags.ts | 2 +- 12 files changed, 32 insertions(+), 22 deletions(-) create mode 100644 src/utils/embed.ts diff --git a/src/commands/about.ts b/src/commands/about.ts index 672911b..b605fe0 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,7 +1,7 @@ import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; import { version } from './../../package.json'; -import universalEmbed from '../index' +import universalEmbed from '../utils/embed'; export class AboutCommand extends Command { public constructor(context: Command.Context, options: Command.Options) { diff --git a/src/commands/ping.ts b/src/commands/ping.ts index eb08183..90844b0 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,6 +1,6 @@ import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; export class PingCommand extends Command { public constructor(context: Command.Context, options: Command.Options) { diff --git a/src/commands/say.ts b/src/commands/say.ts index 04dc0f3..b810b07 100644 --- a/src/commands/say.ts +++ b/src/commands/say.ts @@ -5,7 +5,7 @@ import { MessageFlags, PermissionFlagsBits, } from 'discord.js'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; const MAX_FIELDS = 25; diff --git a/src/commands/tag.ts b/src/commands/tag.ts index 585f42f..923183f 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -3,7 +3,7 @@ import { EmbedBuilder, MessageFlags } from 'discord.js'; import { BOT_COMMANDS_CHANNEL_ID } from '../utils/config'; import tags from '../utils/tags'; import { resolveTag } from '../utils/resolveTag'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; export class TagCommand extends Command { public constructor(context: Command.Context, options: Command.Options) { diff --git a/src/index.ts b/src/index.ts index 8201035..9a6693d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,7 @@ import { SapphireClient, Logger, LogLevel } from '@sapphire/framework'; -import { EmbedBuilder, GatewayIntentBits } from 'discord.js'; +import { ActivityType, GatewayIntentBits } from 'discord.js'; import { BOT_TOKEN } from './utils/config'; import { version } from '../package.json'; -// import './utils/db'; const logger = new Logger(LogLevel.Info); @@ -17,14 +16,9 @@ const client = new SapphireClient({ GatewayIntentBits.DirectMessages, ], presence: { - activities: [{ - name: `/help | v${version}`, - type: 3 - }] - } -}) + activities: [{ name: `/tag • v${version}`, type: ActivityType.Watching }], + }, +}); -logger.info('Attempting to connect to discord client...') -client.login(BOT_TOKEN) - -export default (new EmbedBuilder().setFooter({ text: 'Arduino Bot • GPL-3.0 • /tag' }).setColor('#dc5b05').toJSON()) \ No newline at end of file +logger.info('Attempting to connect to discord client...'); +void client.login(BOT_TOKEN); diff --git a/src/interaction-handlers/roleSelect.ts b/src/interaction-handlers/roleSelect.ts index 43d6a07..a16bef4 100644 --- a/src/interaction-handlers/roleSelect.ts +++ b/src/interaction-handlers/roleSelect.ts @@ -13,7 +13,7 @@ import { ROLE_SELECT_MESSAGE_ID, SERVER_UPDATE_NOTIFS_ROLE_ID, } from '../utils/config'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; interface RoleToggle { roleId: string; diff --git a/src/listeners/crosspost.ts b/src/listeners/crosspost.ts index dba71b2..fba86d1 100644 --- a/src/listeners/crosspost.ts +++ b/src/listeners/crosspost.ts @@ -1,7 +1,7 @@ import { Events, Listener, container } from '@sapphire/framework'; import { EmbedBuilder, type Message } from 'discord.js'; import { CROSSPOST_LOG_CHANNEL_ID, crosspostChannelIds } from '../utils/config'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; /** * Auto-publishes (crossposts) messages in configured announcement/feed channels diff --git a/src/listeners/memberAdd.ts b/src/listeners/memberAdd.ts index e552d89..e86f713 100644 --- a/src/listeners/memberAdd.ts +++ b/src/listeners/memberAdd.ts @@ -8,7 +8,7 @@ import { import { JOIN_LEAVE_LOG_CHANNEL_ID } from '../utils/config'; import { getInviteUses, setInviteUses } from '../utils/inviteCache'; import { getPrisma } from '../utils/db'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; export class MemberAddListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { diff --git a/src/listeners/memberRemove.ts b/src/listeners/memberRemove.ts index 199521e..f7172eb 100644 --- a/src/listeners/memberRemove.ts +++ b/src/listeners/memberRemove.ts @@ -8,7 +8,7 @@ import { } from 'discord.js'; import { JOIN_LEAVE_LOG_CHANNEL_ID } from '../utils/config'; import { getPrisma } from '../utils/db'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; export class MemberRemoveListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { diff --git a/src/listeners/messageLinkEmbed.ts b/src/listeners/messageLinkEmbed.ts index fde1ab1..15a7301 100644 --- a/src/listeners/messageLinkEmbed.ts +++ b/src/listeners/messageLinkEmbed.ts @@ -1,6 +1,6 @@ import { Events, Listener } from '@sapphire/framework'; import { EmbedBuilder, type Message } from 'discord.js'; -import universalEmbed from '../index'; +import universalEmbed from '../utils/embed'; // Matches https://discord.com/channels/// (and the // canary/ptb subdomains). IDs are 17-20 digits to be future-proof. diff --git a/src/utils/embed.ts b/src/utils/embed.ts new file mode 100644 index 0000000..96ec819 --- /dev/null +++ b/src/utils/embed.ts @@ -0,0 +1,16 @@ +import { EmbedBuilder } from 'discord.js'; + +/** + * Shared base embed (brand colour + footer) reused across the bot. Spread it + * into a fresh builder rather than mutating it: + * + * ```ts + * new EmbedBuilder(universalEmbed).setTitle('…') + * ``` + */ +export const universalEmbed = new EmbedBuilder() + .setFooter({ text: 'Arduino Bot • GPL-3.0 • /tag' }) + .setColor('#dc5b05') + .toJSON(); + +export default universalEmbed; diff --git a/src/utils/tags.ts b/src/utils/tags.ts index 9624b5d..87ba6d6 100644 --- a/src/utils/tags.ts +++ b/src/utils/tags.ts @@ -4,7 +4,7 @@ import { ButtonBuilder, ButtonStyle, } from 'discord.js'; -import universalEmbed from '../index'; +import universalEmbed from './embed'; /** * Shape of a single tag. Every field is optional because tags vary: most are From 1e79dffaa79adce17b269a35643f0de8b06ad98a Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Fri, 12 Jun 2026 00:01:30 +0000 Subject: [PATCH 12/42] Release v1.0.0: new README, fix repo URLs - Bump the bot to its first major version (0.3.0 -> 1.0.0). - Replace the README with a reorganized, badged overview: highlights, one-command Docker quick start, a configuration table, commands, the automod and server-management features, and a project-structure map. - Point repository/bugs/homepage in package.json at arduinodiscord/bot (they still referenced a personal fork). Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- README.md | 297 ++++++++++++++++++++++++---------------------- package-lock.json | 4 +- package.json | 8 +- 3 files changed, 164 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index f61bc7d..595b06a 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,172 @@ -# Arduino Discord Bot +
-> Now with slash commands! +# 🤖 Arduino Discord Bot + +### The community bot powering the official **[Arduino Discord](https://arduino.cc/discord)** + +Helpful tags, image-spam moderation, and server automation — built for the people who keep the server running. + +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](LICENSE) +[![discord.js](https://img.shields.io/badge/discord.js-14-5865F2?logo=discord&logoColor=white)](https://discord.js.org) +[![Sapphire](https://img.shields.io/badge/framework-Sapphire-1e88e5)](https://www.sapphirejs.dev) +[![TypeScript](https://img.shields.io/badge/TypeScript-6-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org) +[![Node](https://img.shields.io/badge/Node-LTS-339933?logo=node.js&logoColor=white)](https://nodejs.org) + +
--- -This is the custom Discord bot powering the official Arduino Discord server at [https://arduino.cc/discord](https://arduino.cc/discord). - -## What does this bot do? - -The Arduino Bot provides helpful information, troubleshooting steps, and community resources for Arduino users. It responds to slash commands with detailed guides, tips, and links, making it easier for users to get help and learn about Arduino topics. - -## Current Slash Commands - -All commands are used as Discord slash commands (type `/` in Discord): - -| Command | Usage Example | Description | -|-----------|---------------------------|--------------------------------------------------------------------| -| `/about` | `/about` | Shows information about the bot. | -| `/ping` | `/ping` | Bot/API latency and uptime. | -| `/tag` | `/tag name: [user:@username]` | Sends an informational tag to the bot-commands channel, optionally pinging a user. | -| `/say` | `/say channel:<#channel> title: description:` | Staff-only (Manage Server): send a custom embed to a channel. Optional `fields` (one `name \| value` per line) and `thumbnail`. | - -### `/tag` options (alphabetical) - -- `ai` — The server's no-AI policy. -- `ask` — Guidance on how to ask good questions. -- `avrdude` — AVRDUDE error troubleshooting. -- `codeblock` — How to format code in Discord. -- `debounce` — Debouncing bouncy buttons/switches. -- `espcomm` — ESP board communication troubleshooting. -- `help` — How to use the bot and list of tags. -- `hid` — Info about Arduino HID (keyboard/mouse) support. -- `lab` — Recommended electronics lab equipment. -- `language` — What language Arduino uses. -- `levelShifter` — Logic level shifter explanation. -- `libmissing` — Fixing missing library errors. -- `needinfo` — Ask a user for the details needed to help them. -- `ninevolt` — Why 9V batteries are a poor choice. -- `power` — Powering Arduino safely. -- `pullup` — Pull-up/pull-down resistor explanation. -- `reinstall` — How to cleanly reinstall the Arduino IDE. -- `wiki` — Link to the Arduino Discord community wiki. - -**Example:** -`/tag name:power` — Sends information about powering Arduino boards to the bot-commands channel. -`/tag name:avrdude user:@someuser` — Sends AVRDUDE troubleshooting info to the bot-commands channel and pings `@someuser`. - -## Image-spam automod - -The bot watches for the image-spam pattern that has been slipping past our other -filters: accounts (both freshly-joined and compromised long-time members) -posting **clusters of images** to advertise. It complements YAGPDB rather than -replacing it, and never disables image sharing for the server. - -**How it detects spam:** - -- **Image burst** — several image messages from one user in a short window - (default: 3 in 60s; stricter for new members). *Lower confidence → alerts - moderators only.* -- **Cross-channel fan-out** — the same image posted across multiple channels in - a short window (default: 2+ channels). This is the strongest signal and catches - compromised veterans, where account age is useless. *High confidence.* -- **Known-spam blocklist** — once a moderator confirms an alert, that image's - fingerprints are blocklisted so repeat campaigns are caught instantly. *High - confidence.* - -Each image gets two fingerprints: a cheap, download-free **metadata signature** -(content type + size + dimensions) that catches byte-identical re-uploads, and a -**perceptual hash** (dHash, computed from a tiny media-proxy thumbnail) that -catches re-encoded or resized copies via Hamming-distance matching. New members -(joined within the last 72h by default) are held to a stricter burst threshold. - -**Tiered response:** high-confidence hits auto-delete the messages and timeout -the user, then post an alert; bursts only post an alert. Every alert lands in the -mod-log channel with action buttons — **Confirm spam / Timeout / Ban / Delete -msgs / Not spam** — so a human stays in the loop. Members with Manage Messages -(or a configured immune role) are never inspected. - -**Required bot permissions:** Manage Messages (delete), Moderate Members -(timeout), Ban Members (ban), plus the **Message Content** privileged intent -(already enabled in `index.ts`). Set `MOD_LOG_CHANNEL_ID` to enable the console; -leaving it unset disables the automod entirely. - -## Server management - -These ambient features were ported from the legacy bot and modernized. Each -**self-disables** until its channel/role id is configured (see -[`.env.example`](.env.example)): - -- **Auto-crosspost** — automatically publishes messages in configured - announcement channels and logs the result (`CROSSPOST_CHANNEL_IDS`). -- **Join/leave logging** — posts member join (with invite-source attribution) - and leave embeds to a log channel, and records best-effort join/leave - analytics when a database is configured (`JOIN_LEAVE_LOG_CHANNEL_ID`). -- **Role-select buttons** — buttons on a configured message let members toggle - the event / server-update opt-in roles (`ROLE_SELECT_MESSAGE_ID`). -- **Message-link flattening** — when someone posts a link to another message in - the server, the bot quotes that message inline for context. - -> The legacy file-extension attachment filter and `maintenance` mode were -> intentionally dropped in favour of Discord-native AutoMod and modern -> redeploy/hosting workflows. - -## Environment Variables & Configuration - -This bot requires the following environment variables to be set: - -- `BOT_TOKEN`: Your Discord bot token. -- `MOD_LOG_CHANNEL_ID`: Channel for image-spam alerts. Required to enable the - automod; leave unset to disable it. - -Optional: - -- `DATABASE_URL`: Postgres connection string. Without it the bot runs fully - in-memory and the spam-image blocklist resets on restart. -- Automod thresholds (`AUTOMOD_BURST_THRESHOLD`, `AUTOMOD_FANOUT_CHANNELS`, - `AUTOMOD_IMMUNE_ROLE_IDS`, …) — see [`.env.example`](.env.example). - -> Additional configuration options can be set in `config.ts`. - -### Running with Docker - -A [`docker-compose.yml`](docker-compose.yml) bundles the bot with a Postgres -instance for blocklist persistence: +## ✨ Highlights + +| | | +|---|---| +| 🏷️ **Slash-command tags** | Curated troubleshooting guides (`/tag`) — AVRDUDE errors, level shifters, powering boards, and more | +| 🛡️ **Image-spam automod** | Catches the image-cluster spam wave with burst + cross-channel fan-out + perceptual-hash detection, and a one-click moderator console | +| 🧰 **Server automation** | Auto-crosspost, join/leave + invite-source logging, role-select buttons, message-link quoting | +| 🗣️ **Staff tooling** | `/say` to broadcast rich embeds as the bot | +| 🐳 **One-command deploy** | `docker compose up` — bot + Postgres, migrations applied automatically | + +--- + +## 🚀 Quick start + +### With Docker (recommended) + +```bash +git clone https://github.com/arduinodiscord/bot.git +cd bot +cp .env.example .env # add your BOT_TOKEN (+ any feature channel IDs) +docker compose up -d # builds the bot, starts Postgres, applies migrations +``` + +That's it — the bot connects, registers its slash commands, and is ready. + +### Local development ```bash -cp .env.example .env # fill in BOT_TOKEN and MOD_LOG_CHANNEL_ID -docker compose up -d # applies DB migrations, then starts the bot +npm install +cp .env.example .env # set BOT_TOKEN +npm run dev # hot-reloading dev server (ts-node-dev) +``` + +> **Privileged intents:** enable **Message Content** and **Server Members** for +> your application in the [Discord Developer Portal](https://discord.com/developers/applications). +> For invite-source logging the bot also needs the **Manage Server** permission. + +--- + +## ⚙️ Configuration + +Everything is driven by environment variables (see [`.env.example`](.env.example)). +Only `BOT_TOKEN` is required; **every other feature self-disables** until you +give it a channel or role id, so you can adopt them one at a time. + +| Variable | Purpose | +|---|---| +| `BOT_TOKEN` | **Required.** Your Discord bot token | +| `MOD_LOG_CHANNEL_ID` | Enables the image-spam automod console | +| `DATABASE_URL` | Postgres connection (optional — runs in-memory without it; wired automatically by Docker) | +| `JOIN_LEAVE_LOG_CHANNEL_ID` | Member join/leave + invite-source logging | +| `CROSSPOST_CHANNEL_IDS` · `CROSSPOST_LOG_CHANNEL_ID` | Auto-publish announcement channels | +| `ROLE_SELECT_MESSAGE_ID` · `EVENT_NOTIFS_ROLE_ID` · `SERVER_UPDATE_NOTIFS_ROLE_ID` | Button-based opt-in roles | +| `AUTOMOD_*` | Detector thresholds & timeouts (sensible defaults; see `.env.example`) | + +--- + +## 💬 Commands + +All commands are Discord **slash commands** — type `/` in the server. + +| Command | Description | +|---|---| +| `/tag name: [user:@user]` | Post a curated troubleshooting guide, optionally pinging someone | +| `/about` | Bot, Node, and version info | +| `/ping` | Latency & uptime | +| `/say channel:<#ch> title:… description:…` | **Staff only** — send a custom embed (optional `fields` as `name \| value` per line, and `thumbnail`) | + +
+📚 Available /tag topics + +`ai` · `ask` · `avrdude` · `codeblock` · `debounce` · `espcomm` · `help` · +`hid` · `lab` · `language` · `levelShifter` · `libmissing` · `needinfo` · +`ninevolt` · `power` · `pullup` · `reinstall` · `wiki` + +
+ +--- + +## 🛡️ Image-spam automod + +A wave of spam — both freshly-joined accounts and **compromised long-time +members** — posts *clusters of images* to advertise. This bot targets exactly +that pattern without ever disabling image sharing server-wide. + +**Detection** +- **Burst** — several image messages from one user in a short window (stricter for new members) → *alerts mods*. +- **Cross-channel fan-out** — the same image across multiple channels → *high confidence* (this is what catches compromised veterans, where account age tells you nothing). +- **Known-spam blocklist** — once a mod confirms an alert, that image is fingerprinted and future copies are caught instantly. + +Each image gets a cheap **metadata signature** (catches identical re-uploads) +*and* a **perceptual hash** (dHash from a tiny thumbnail — catches re-encoded / +resized copies via Hamming distance). + +**Response (tiered):** high-confidence hits auto-delete + timeout and then +alert; bursts only alert. Every alert lands in the mod-log channel with +**Confirm / Timeout / Ban / Delete / Not spam** buttons — a human stays in the +loop. Members with *Manage Messages* (or a configured immune role) are never +inspected. + +> Complements your existing YAGPDB AutoMod rather than replacing them. + +--- + +## 🧰 Server management + +Ambient helpers, each self-disabling until configured: + +- **Auto-crosspost** announcement channels, with logging. +- **Join/leave logging** with invite-source attribution (+ optional analytics when a DB is present). +- **Role-select buttons** to toggle event / server-update opt-in roles. +- **Message-link flattening** — quote a linked message inline for context. + +--- + +## 🏗️ Project structure + ``` +src/ +├── index.ts # client bootstrap (intents, presence, login) +├── commands/ # slash commands (about, ping, tag, say) +├── listeners/ # gateway events (ready, messages, members, invites…) +├── interaction-handlers/ # button handlers (spam console, role-select, tag buttons) +└── utils/ + ├── automod/ # spam detection: tracker, phash, blocklist, console, incidents + ├── config.ts # env-driven configuration + ├── db.ts # optional Prisma (pg driver adapter) with in-memory fallback + ├── tags.ts # tag content + schema + └── embed.ts # shared base embed +prisma/ # schema + migrations +``` + +**Stack:** [discord.js v14](https://discord.js.org) · [Sapphire framework](https://www.sapphirejs.dev) · TypeScript 6 · Prisma 7 (+ Postgres, optional). +--- -## Contributing +## 🤝 Contributing -Want to add a new tag or feature? It’s easy! +Want to add a tag or a feature? PRs welcome! -1. **Clone the repo** and create a new branch. -2. **Add your tag:** - - Edit [`src/utils/tags.ts`](src/utils/tags.ts) and add your tag object to the exported object. - - If adding a new command, create a new file in [`src/commands/`](src/commands/). -3. **Register your tag:** - - For `/tag`, add your tag to the `.addChoices()` list in [`src/commands/tag.ts`](src/commands/tag.ts). -4. **Test your changes** locally. -5. **Make a Pull Request:** - - Push your branch and open a PR on GitHub. - - Clearly describe your changes. +1. Fork & branch. +2. **Add a tag:** edit [`src/utils/tags.ts`](src/utils/tags.ts) and add it to the `.addChoices()` list in [`src/commands/tag.ts`](src/commands/tag.ts). +3. `npm run build` to type-check. +4. Open a PR against `staging` with a clear description. -If you find a bug or want to request a feature, please [open an issue](https://github.com/max-bromberg/arduino-bot/issues). +Found a bug or have an idea? [Open an issue](https://github.com/arduinodiscord/bot/issues). --- -06-13-2025 Update -**License:** GPL-3.0-or-later -See [LICENSE](LICENSE) for details. + +
+ +**License:** [GPL-3.0-or-later](LICENSE) · Made with ❤️ by the Arduino Discord community + +
diff --git a/package-lock.json b/package-lock.json index c729b9a..099cea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arduino-bot", - "version": "0.3.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arduino-bot", - "version": "0.3.0", + "version": "1.0.0", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 55f7445..7ef8c01 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arduino-bot", "description": "The new and improved custom discord bot to power the official Arduino discord server @ https://arduino.cc/discord", - "version": "0.3.0", + "version": "1.0.0", "main": "./dist/src/index.js", "scripts": { "start": "node ./dist/src/index.js", @@ -11,12 +11,12 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/max-bromberg/arduino-bot.git" + "url": "git+https://github.com/arduinodiscord/bot.git" }, "bugs": { - "url": "https://github.com/max-bromberg/arduino-bot/issues" + "url": "https://github.com/arduinodiscord/bot/issues" }, - "homepage": "https://github.com/max-bromberg/arduino-bot#readme", + "homepage": "https://github.com/arduinodiscord/bot#readme", "author": "Max Bromberg and Contributors", "license": "GPL-3.0-or-later", "dependencies": { From 9fd044f416b5d16d07b8310bb7f19bf7272b272e Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Fri, 12 Jun 2026 00:10:23 +0000 Subject: [PATCH 13/42] Add Phase 1 helper-assist features Three features aimed at reducing repetitive load on community helpers: - Keyword -> tag auto-suggest (tagSuggest.ts): high-precision signatures for the most-repeated questions (AVRDUDE, missing library, ESP upload) surface the matching tag via a single button, with a per-user cooldown. Toggle with TAG_SUGGEST_ENABLED. - "Request more info" message context-menu command (requestInfo.ts): one-click posting of the needinfo checklist to an asker. - Solve workflow: /solved [helper] command, a "Mark Solved" button on new help-forum posts (threadCreate.ts + solvedButton.ts, gated by HELP_FORUM_CHANNEL_IDS), and shared solveThread.ts logic that titles the post with a checkmark, credits the helper, and archives it. OP or staff only. Docs and .env.example updated. Co-authored-by: Claude https://claude.ai/code/session_01SgtVVoWBx7FRzWdGoUUE1K --- .env.example | 4 ++ README.md | 19 +++++ src/commands/requestInfo.ts | 61 ++++++++++++++++ src/commands/solved.ts | 64 +++++++++++++++++ src/interaction-handlers/solvedButton.ts | 70 +++++++++++++++++++ src/listeners/tagSuggest.ts | 89 ++++++++++++++++++++++++ src/listeners/threadCreate.ts | 40 +++++++++++ src/utils/config.ts | 9 +++ src/utils/solveThread.ts | 36 ++++++++++ 9 files changed, 392 insertions(+) create mode 100644 src/commands/requestInfo.ts create mode 100644 src/commands/solved.ts create mode 100644 src/interaction-handlers/solvedButton.ts create mode 100644 src/listeners/tagSuggest.ts create mode 100644 src/listeners/threadCreate.ts create mode 100644 src/utils/solveThread.ts diff --git a/.env.example b/.env.example index d67e297..0b43857 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ MOD_LOG_CHANNEL_ID= # EVENT_NOTIFS_ROLE_ID= # role toggled by the "events" button # SERVER_UPDATE_NOTIFS_ROLE_ID= # role toggled by the "server_updates" button +# --- Helper-assist features --- +# HELP_FORUM_CHANNEL_IDS= # forum channels that get a "Mark Solved" button on new posts +# TAG_SUGGEST_ENABLED=true # auto-suggest a relevant tag on common error messages (set false to disable) + # --- Persistence (optional) --- # When unset, the bot runs fully in-memory and the spam-image blocklist # resets on restart. With docker-compose this is wired automatically. diff --git a/README.md b/README.md index 595b06a..dc9364b 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,14 @@ All commands are Discord **slash commands** — type `/` in the server. | Command | Description | |---|---| | `/tag name: [user:@user]` | Post a curated troubleshooting guide, optionally pinging someone | +| `/solved [helper:@user]` | Mark the current help post solved (and thank a helper); closes the thread | | `/about` | Bot, Node, and version info | | `/ping` | Latency & uptime | | `/say channel:<#ch> title:… description:…` | **Staff only** — send a custom embed (optional `fields` as `name \| value` per line, and `thumbnail`) | +Plus a **"Request more info"** right-click (message context-menu) action that +posts the `needinfo` checklist to an asker in one click. +
📚 Available /tag topics @@ -131,6 +135,21 @@ Ambient helpers, each self-disabling until configured: --- +## 🙌 Helper assist + +Features aimed at taking repetitive load off the community members who answer +the most questions: + +- **Keyword → tag suggestions** — when a message contains a known error + signature (AVRDUDE, missing-library, ESP upload…), the bot offers the matching + tag via a single button, so askers self-serve before a helper has to repeat a + canned answer. Per-user cooldown; toggle with `TAG_SUGGEST_ENABLED`. +- **"Request more info" context-menu** — right-click any message → *Request more + info* to post the `needinfo` checklist to the asker in one click. +- **Solve workflow** — a **Mark Solved** button on new help-forum posts (set + `HELP_FORUM_CHANNEL_IDS`) plus `/solved [helper:@user]`, which closes the post + and credits whoever helped. + ## 🏗️ Project structure ``` diff --git a/src/commands/requestInfo.ts b/src/commands/requestInfo.ts new file mode 100644 index 0000000..4e1b8bd --- /dev/null +++ b/src/commands/requestInfo.ts @@ -0,0 +1,61 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { ApplicationCommandType, MessageFlags } from 'discord.js'; +import { resolveTag } from '../utils/resolveTag'; + +/** + * Right-click a message → "Request more info" posts the `needinfo` checklist as + * a reply to that message, pinging its author. Saves helpers from typing out + * (or remembering) `/tag needinfo` for the most common ask. + */ +export class RequestInfoCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { ...options, name: 'Request more info' }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerContextMenuCommand((builder) => + builder + .setName('Request more info') + .setType(ApplicationCommandType.Message) + .setDMPermission(false) + ); + } + + public override async contextMenuRun( + interaction: Command.ContextMenuCommandInteraction + ) { + if (!interaction.isMessageContextMenuCommand()) return; + + const target = interaction.targetMessage; + const payload = resolveTag('needinfo', target.author.id); + if (!payload?.content) + return interaction.reply({ + content: 'The needinfo tag is unavailable.', + flags: MessageFlags.Ephemeral, + }); + + const sent = await target + .reply({ + content: payload.content, + allowedMentions: { users: [target.author.id] }, + }) + .catch(() => null); + + // If replying to the original message failed (e.g. it was deleted), fall + // back to a normal channel message. + if (!sent && interaction.channel?.isSendable()) + await interaction.channel + .send({ + content: payload.content, + allowedMentions: { users: [target.author.id] }, + }) + .catch(() => null); + + return interaction.reply({ + content: '✅ Requested more info from the user.', + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/src/commands/solved.ts b/src/commands/solved.ts new file mode 100644 index 0000000..e9d596b --- /dev/null +++ b/src/commands/solved.ts @@ -0,0 +1,64 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { EmbedBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import universalEmbed from '../utils/embed'; +import { applySolved, canMarkSolved } from '../utils/solveThread'; + +export class SolvedCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + name: 'solved', + description: 'Mark the current help post/thread as solved.', + }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerChatInputCommand((builder) => + builder + .setName(this.name) + .setDescription(this.description) + .setDMPermission(false) + .addUserOption((option) => + option + .setName('helper') + .setDescription('Credit the member who helped you') + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const channel = interaction.channel; + if (!channel?.isThread()) + return interaction.reply({ + content: 'Use this inside a help post or thread.', + flags: MessageFlags.Ephemeral, + }); + + const isStaff = + interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages) ?? + false; + const check = canMarkSolved(channel, interaction.user.id, isStaff); + if (!check.ok) + return interaction.reply({ + content: check.reason, + flags: MessageFlags.Ephemeral, + }); + + const helper = interaction.options.getUser('helper'); + const embed = new EmbedBuilder(universalEmbed) + .setTitle('✅ Marked solved') + .setDescription( + helper + ? `Thanks for the help, <@${helper.id}>! 🎉` + : 'Glad it’s sorted! Closing this post.' + ); + + await interaction.reply({ embeds: [embed] }); + await applySolved(channel); + return undefined; + } +} diff --git a/src/interaction-handlers/solvedButton.ts b/src/interaction-handlers/solvedButton.ts new file mode 100644 index 0000000..6d4a4a2 --- /dev/null +++ b/src/interaction-handlers/solvedButton.ts @@ -0,0 +1,70 @@ +import { + InteractionHandler, + InteractionHandlerTypes, +} from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, + type ButtonInteraction, +} from 'discord.js'; +import universalEmbed from '../utils/embed'; +import { applySolved, canMarkSolved } from '../utils/solveThread'; + +/** Handles the "Mark Solved" button posted in help-forum threads. */ +export class SolvedButtonHandler extends InteractionHandler { + public constructor( + context: InteractionHandler.LoaderContext, + options: InteractionHandler.Options + ) { + super(context, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + return interaction.customId === 'solved' ? this.some() : this.none(); + } + + public async run(interaction: ButtonInteraction) { + const channel = interaction.channel; + if (!channel?.isThread()) + return interaction.reply({ + content: 'This button only works inside a help post.', + flags: MessageFlags.Ephemeral, + }); + + const isStaff = + interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages) ?? + false; + const check = canMarkSolved(channel, interaction.user.id, isStaff); + if (!check.ok) + return interaction.reply({ + content: check.reason, + flags: MessageFlags.Ephemeral, + }); + + const embed = new EmbedBuilder(universalEmbed) + .setTitle('✅ Marked solved') + .setDescription(`Closed by <@${interaction.user.id}>.`); + await interaction.reply({ embeds: [embed] }); + + // Disable the button so it can't be clicked again, then close the thread. + const disabledRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('solved') + .setLabel('✅ Solved') + .setStyle(ButtonStyle.Success) + .setDisabled(true) + ); + await interaction.message + .edit({ components: [disabledRow] }) + .catch(() => null); + await applySolved(channel); + return undefined; + } +} diff --git a/src/listeners/tagSuggest.ts b/src/listeners/tagSuggest.ts new file mode 100644 index 0000000..fa78da1 --- /dev/null +++ b/src/listeners/tagSuggest.ts @@ -0,0 +1,89 @@ +import { Events, Listener } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type Message, +} from 'discord.js'; +import { SERVER_ID, tagSuggestEnabled } from '../utils/config'; +import universalEmbed from '../utils/embed'; + +interface Suggestion { + pattern: RegExp; + tag: string; + prompt: string; +} + +// High-precision signatures for the most-repeated questions. Kept conservative +// to avoid false positives; the suggestion is a single button, never the full +// answer dumped into the channel. +const SUGGESTIONS: Suggestion[] = [ + { + pattern: /stk500|avrdude[:\s]|not in sync/i, + tag: 'avrdude', + prompt: 'Looks like an **AVRDUDE upload error**.', + }, + { + pattern: + /no such file or directory|fatal error:.*\.h|\.h: No such file|library.*(not found|is not installed|missing)/i, + tag: 'libmissing', + prompt: 'Looks like a **missing library / header** error.', + }, + { + pattern: + /espcomm|esptool|failed to connect to esp|wrong boot mode|a fatal error occurred.*(packet|connect|timed out)/i, + tag: 'espcomm', + prompt: 'Looks like an **ESP upload / connection** problem.', + }, +]; + +const COOLDOWN_MS = 5 * 60_000; +const lastSuggested = new Map(); + +export class TagSuggestListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.MessageCreate }); + } + + public async run(message: Message) { + if (!tagSuggestEnabled) return; + if (!message.inGuild() || message.author.bot) return; + if (message.guildId !== SERVER_ID) return; + if (message.content.length < 10) return; + + const match = SUGGESTIONS.find((s) => s.pattern.test(message.content)); + if (!match) return; + + const now = Date.now(); + const last = lastSuggested.get(message.author.id); + if (last && now - last < COOLDOWN_MS) return; + lastSuggested.set(message.author.id, now); + + const embed = new EmbedBuilder(universalEmbed).setDescription( + `💡 ${match.prompt} Tap below for troubleshooting steps.` + ); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`tag:${match.tag}`) + .setLabel('Show steps') + .setStyle(ButtonStyle.Primary) + ); + + await message + .reply({ + embeds: [embed], + components: [row], + allowedMentions: { repliedUser: false }, + }) + .catch(() => null); + } +} + +// Drop stale cooldown entries so the map can't grow unbounded. +const sweep = setInterval(() => { + const horizon = Date.now() - COOLDOWN_MS; + for (const [userId, at] of lastSuggested) + if (at < horizon) lastSuggested.delete(userId); +}, 10 * 60_000); +sweep.unref(); diff --git a/src/listeners/threadCreate.ts b/src/listeners/threadCreate.ts new file mode 100644 index 0000000..3766592 --- /dev/null +++ b/src/listeners/threadCreate.ts @@ -0,0 +1,40 @@ +import { Events, Listener } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type AnyThreadChannel, +} from 'discord.js'; +import { helpForumChannelIds } from '../utils/config'; +import universalEmbed from '../utils/embed'; + +/** + * Posts a "Mark Solved" button when a new post is created in a configured help + * forum, so the asker can close it (and credit a helper) in one click. Disabled + * unless HELP_FORUM_CHANNEL_IDS is set. + */ +export class ThreadCreateListener extends Listener { + public constructor(context: Listener.Context, options: Listener.Options) { + super(context, { ...options, event: Events.ThreadCreate }); + } + + public async run(thread: AnyThreadChannel, newlyCreated: boolean) { + if (!newlyCreated) return; + if (helpForumChannelIds.length === 0) return; + if (!thread.parentId || !helpForumChannelIds.includes(thread.parentId)) + return; + + const embed = new EmbedBuilder(universalEmbed).setDescription( + 'When your question is answered, the original poster or a moderator can click **Mark Solved** to close this post. Use `/solved helper:@user` to also thank whoever helped. 🛠️' + ); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('solved') + .setLabel('✅ Mark Solved') + .setStyle(ButtonStyle.Success) + ); + + await thread.send({ embeds: [embed], components: [row] }).catch(() => null); + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 73c3126..80ef26b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -46,6 +46,15 @@ const idList = (value: string | undefined): string[] => /** Announcement/feed channels whose messages are auto-published (crossposted). */ export const crosspostChannelIds = idList(process.env.CROSSPOST_CHANNEL_IDS); +/** + * Forum channels treated as "help" forums: new posts get a "Mark Solved" button. + * The /solved command works in any thread regardless of this list. + */ +export const helpForumChannelIds = idList(process.env.HELP_FORUM_CHANNEL_IDS); + +/** Whether the keyword -> tag auto-suggester is active (on unless "false"). */ +export const tagSuggestEnabled = process.env.TAG_SUGGEST_ENABLED !== 'false'; + /** * Tunables for the image-spam detector. Every value is overridable via the * environment so moderators can adjust thresholds without a redeploy. diff --git a/src/utils/solveThread.ts b/src/utils/solveThread.ts new file mode 100644 index 0000000..ac168df --- /dev/null +++ b/src/utils/solveThread.ts @@ -0,0 +1,36 @@ +import type { ThreadChannel } from 'discord.js'; + +export const SOLVED_PREFIX = '✅ '; + +export interface SolveCheck { + ok: boolean; + reason?: string; +} + +/** + * Whether `userId` may mark `thread` solved: the original poster always can, + * and so can staff (Manage Messages). Already-solved threads are rejected. + */ +export function canMarkSolved( + thread: ThreadChannel, + userId: string, + isStaff: boolean +): SolveCheck { + if (thread.name.startsWith(SOLVED_PREFIX)) + return { ok: false, reason: 'This post is already marked solved.' }; + if (thread.ownerId !== userId && !isStaff) + return { + ok: false, + reason: 'Only the person who opened this post (or a moderator) can mark it solved.', + }; + return { ok: true }; +} + +/** Prefix the thread title with ✅ and archive it. Idempotent and non-throwing. */ +export async function applySolved(thread: ThreadChannel): Promise { + const name = thread.name.startsWith(SOLVED_PREFIX) + ? thread.name + : (SOLVED_PREFIX + thread.name).slice(0, 100); + await thread.setName(name).catch(() => null); + await thread.setArchived(true).catch(() => null); +} From acc7ab49ccc44aba3492b16d910ca3b4d949eafb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 17:16:13 +0000 Subject: [PATCH 14/42] Add text-flooding automod detector Flag users who fragment one thought across many tiny messages in quick succession, which buries conversation. Counts short messages from a user within a sliding window and raises a "Message flooding" incident in the existing mod-log console, reusing the same action buttons. - New flood tracker (utils/automod/flood.ts) with per-user windowed counts, alert cooldown, and a self-pruning sweep, mirroring the image tracker. - Generalise IncidentLevel to include 'flood'; console renders a level-aware title and auto-action note. - Wire detection into the message listener, independent of the image automod; optional auto-timeout via AUTOMOD_FLOOD_AUTO_TIMEOUT (alert-only by default). - Clear flood state on confirm/ban/dismiss; tidy the confirm summary when an incident carries no image fingerprints. - Config tunables (AUTOMOD_FLOOD_*), .env.example, and README docs. --- .env.example | 7 ++ README.md | 16 +++- src/interaction-handlers/spamModeration.ts | 9 +- src/listeners/messageCreate.ts | 58 +++++++++++++ src/utils/automod/console.ts | 16 +++- src/utils/automod/flood.ts | 97 ++++++++++++++++++++++ src/utils/automod/incidents.ts | 8 +- src/utils/config.ts | 22 +++++ 8 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 src/utils/automod/flood.ts diff --git a/.env.example b/.env.example index 0b43857..88966c1 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,10 @@ MOD_LOG_CHANNEL_ID= # AUTOMOD_IMMUNE_ROLE_IDS= # comma-separated role ids never inspected # AUTOMOD_NEW_MEMBER_WINDOW_MS=259200000 # how long a member counts as "new" (72h) # AUTOMOD_NEW_MEMBER_BURST_THRESHOLD=2 # stricter burst threshold for new members + +# --- Text-flooding detector (needs MOD_LOG_CHANNEL_ID to post alerts) --- +# AUTOMOD_FLOOD_ENABLED=true # set false to disable text-flooding detection +# AUTOMOD_FLOOD_THRESHOLD=5 # short messages within the window to flag flooding +# AUTOMOD_FLOOD_WINDOW_MS=15000 # sliding window for the flood count +# AUTOMOD_FLOOD_MAX_CHARS=25 # a message counts as "short" at or below this length +# AUTOMOD_FLOOD_AUTO_TIMEOUT=false # also auto-timeout on a flood hit (default: alert only) diff --git a/README.md b/README.md index dc9364b..6c4a827 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,20 @@ inspected. --- +## 🌊 Text-flooding automod + +Some users fragment a single thought across a stream of one-word messages +instead of sending it as one message — which buries ongoing conversation and +leaves no room to reply. The bot flags this pattern: **N short messages from +one user inside a short window** (`AUTOMOD_FLOOD_*`, defaults: 5 messages ≤ 25 +chars in 15 s) lands a **Message flooding** alert in the same mod-log console as +the image automod, with the same Confirm / Timeout / Ban / Delete / Not-spam +buttons. By default it only alerts (flooding is usually a habit, not an attack); +set `AUTOMOD_FLOOD_AUTO_TIMEOUT=true` to also time the user out automatically. +Members with *Manage Messages* (or a configured immune role) are never flagged. + +--- + ## 🧰 Server management Ambient helpers, each self-disabling until configured: @@ -159,7 +173,7 @@ src/ ├── listeners/ # gateway events (ready, messages, members, invites…) ├── interaction-handlers/ # button handlers (spam console, role-select, tag buttons) └── utils/ - ├── automod/ # spam detection: tracker, phash, blocklist, console, incidents + ├── automod/ # spam detection: tracker, flood, phash, blocklist, console, incidents ├── config.ts # env-driven configuration ├── db.ts # optional Prisma (pg driver adapter) with in-memory fallback ├── tags.ts # tag content + schema diff --git a/src/interaction-handlers/spamModeration.ts b/src/interaction-handlers/spamModeration.ts index 38bf78a..1f59015 100644 --- a/src/interaction-handlers/spamModeration.ts +++ b/src/interaction-handlers/spamModeration.ts @@ -22,6 +22,7 @@ import { timeoutMember, } from '../utils/automod/console'; import { clearUser } from '../utils/automod/tracker'; +import { clearFloodUser } from '../utils/automod/flood'; interface ParsedButton { action: string; @@ -85,10 +86,14 @@ export class SpamModerationHandler extends InteractionHandler { 'confirmed image spam' ); clearUser(incident.userId); + clearFloodUser(incident.userId); const fingerprints = incident.signatures.length + incident.hashes.length; + const blocklisted = fingerprints + ? `, and blocklisted ${fingerprints} image fingerprint(s)` + : ''; summary = `✅ Confirmed spam — deleted ${deleted} message(s), ${ timedOut ? 'timed out the user' : '**could not** time out the user' - }, and blocklisted ${fingerprints} image fingerprint(s).`; + }${blocklisted}.`; break; } case 'timeout': { @@ -109,6 +114,7 @@ export class SpamModerationHandler extends InteractionHandler { `Automod review by ${moderator.tag}` ); clearUser(incident.userId); + clearFloodUser(incident.userId); summary = ok ? '🔨 User banned and recent messages purged.' : '⚠️ Could not ban the user (check role hierarchy and permissions).'; @@ -124,6 +130,7 @@ export class SpamModerationHandler extends InteractionHandler { } case 'dismiss': { clearUser(incident.userId); + clearFloodUser(incident.userId); summary = '👌 Marked as not spam. Cleared tracking for this user; no action taken.'; break; diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index a9c32c1..615b298 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -8,6 +8,11 @@ import { shouldAlert, markAlerted, } from '../utils/automod/tracker'; +import { + recordShortMessage, + shouldAlertFlood, + markFloodAlerted, +} from '../utils/automod/flood'; import { isBlocklisted } from '../utils/automod/blocklist'; import { createIncident, @@ -32,6 +37,10 @@ export class MessageCreateListener extends Listener { if (!MOD_LOG_CHANNEL_ID) return; if (message.member && this.isImmune(message.member)) return; + // Text-flooding runs independently of the image automod below: a user + // fragmenting one thought across many tiny messages rarely posts images. + await this.runFlood(message); + const signatures = imageSignatures(message); if (signatures.length === 0) return; @@ -115,6 +124,55 @@ export class MessageCreateListener extends Listener { await this.postAlert(message, incident, autoActed); } + /** + * Detect text flooding: many short messages from one user in quick + * succession. Only short, non-empty text counts — pure-image messages are + * left to the image automod above so the two detectors don't double up. + */ + private async runFlood(message: Message): Promise { + if (!automodConfig.floodEnabled) return; + + const content = message.content.trim(); + if (content.length === 0 || content.length > automodConfig.floodMaxChars) + return; + + const now = Date.now(); + const detection = recordShortMessage(message.author.id, { + at: now, + channelId: message.channelId, + messageId: message.id, + }); + if (!detection.flooded) return; + if (!shouldAlertFlood(message.author.id, now)) return; + markFloodAlerted(message.author.id, now); + + const incident = createIncident({ + userId: message.author.id, + guildId: message.guildId, + level: 'flood', + reason: detection.reason, + messages: detection.messages.map((m) => ({ + channelId: m.channelId, + messageId: m.messageId, + })), + // Flooding leaves no image fingerprints to blocklist. + signatures: [], + hashes: [], + }); + + // Flooding is usually a habit, not an attack, so we only auto-act when a + // server opts in; otherwise a human decides from the console. + let autoActed = false; + if (automodConfig.floodAutoTimeout) + autoActed = await timeoutMember( + message.guild, + message.author.id, + `Automod: ${incident.reason}` + ).catch(() => false); + + await this.postAlert(message, incident, autoActed); + } + private isImmune(member: GuildMember): boolean { if (member.permissions.has(PermissionFlagsBits.ManageMessages)) return true; return automodConfig.immuneRoleIds.some((roleId) => diff --git a/src/utils/automod/console.ts b/src/utils/automod/console.ts index 6ecb40b..1ef2db3 100644 --- a/src/utils/automod/console.ts +++ b/src/utils/automod/console.ts @@ -19,12 +19,22 @@ const LEVEL_COLOR: Record = { fanout: 0xe03131, // high confidence — red blocklist: 0xe03131, burst: 0xf08c00, // needs review — amber + flood: 0xf08c00, // needs review — amber }; const LEVEL_LABEL: Record = { fanout: 'Cross-channel fan-out', blocklist: 'Known spam image', burst: 'Image burst', + flood: 'Message flooding', +}; + +/** Headline shown at the top of an alert, by incident kind. */ +const LEVEL_TITLE: Record = { + fanout: '🚨 Possible image spam', + blocklist: '🚨 Possible image spam', + burst: '🚨 Possible image spam', + flood: '🚨 Possible message flooding', }; /** One moderation action button bound to an incident id. */ @@ -61,7 +71,7 @@ export function buildAlertPayload( const embed = new EmbedBuilder() .setColor(LEVEL_COLOR[incident.level]) - .setTitle('🚨 Possible image spam') + .setTitle(LEVEL_TITLE[incident.level]) .setDescription(`<@${incident.userId}> \`${incident.userId}\``) .addFields( { @@ -98,7 +108,9 @@ export function buildAlertPayload( embed.addFields({ name: '🔒 Auto-action taken', value: - 'High-confidence signal: the messages were deleted and the user was timed out automatically. Review and escalate or reverse below.', + incident.level === 'flood' + ? 'The user was timed out automatically. Review and escalate or reverse below.' + : 'High-confidence signal: the messages were deleted and the user was timed out automatically. Review and escalate or reverse below.', }); const row1 = new ActionRowBuilder().addComponents( diff --git a/src/utils/automod/flood.ts b/src/utils/automod/flood.ts new file mode 100644 index 0000000..47e2e38 --- /dev/null +++ b/src/utils/automod/flood.ts @@ -0,0 +1,97 @@ +import { automodConfig } from '../config'; + +/** A short message recorded for flood detection. */ +export interface FloodMessage { + at: number; + channelId: string; + messageId: string; +} + +export interface FloodDetection { + flooded: boolean; + reason: string; + /** The short messages that contributed to the verdict. */ + messages: FloodMessage[]; + /** Distinct channels involved. */ + channels: string[]; +} + +const NONE: FloodDetection = { + flooded: false, + reason: '', + messages: [], + channels: [], +}; + +/** Per-user recent short messages, pruned to the flood window. */ +const userMessages = new Map(); +/** Last time we raised a flood alert for a user, for cooldown suppression. */ +const lastAlertAt = new Map(); + +const unique = (values: T[]): T[] => [...new Set(values)]; + +/** + * Record a short message and evaluate whether the user is now flooding — i.e. + * sending too many tiny messages in quick succession instead of consolidating + * them into one. Only the caller's notion of "short" reaches here; this just + * counts how many landed inside the sliding window. + * + * On a positive verdict the user's buffer is cleared so the next flood has to + * build up from scratch (the alert cooldown still guards against re-alerting). + */ +export function recordShortMessage( + userId: string, + message: FloodMessage +): FloodDetection { + const horizon = message.at - automodConfig.floodWindowMs; + const messages = (userMessages.get(userId) ?? []).filter( + (m) => m.at >= horizon + ); + messages.push(message); + + if (messages.length >= automodConfig.floodThreshold) { + userMessages.delete(userId); + return { + flooded: true, + reason: `${messages.length} short messages in ${Math.round( + automodConfig.floodWindowMs / 1000 + )}s`, + messages, + channels: unique(messages.map((m) => m.channelId)), + }; + } + + userMessages.set(userId, messages); + return NONE; +} + +/** Whether enough time has passed since the last flood alert for this user. */ +export function shouldAlertFlood(userId: string, now: number): boolean { + const last = lastAlertAt.get(userId); + return !last || now - last >= automodConfig.alertCooldownMs; +} + +export function markFloodAlerted(userId: string, now: number): void { + lastAlertAt.set(userId, now); +} + +/** Forget a user's flood state (e.g. after a moderator resolves an alert). */ +export function clearFloodUser(userId: string): void { + userMessages.delete(userId); + lastAlertAt.delete(userId); +} + +// Periodically drop stale state so the maps don't grow unbounded. unref() +// keeps this timer from holding the process open. +const sweep = setInterval(() => { + const horizon = Date.now() - automodConfig.floodWindowMs; + for (const [userId, messages] of userMessages) { + const fresh = messages.filter((m) => m.at >= horizon); + if (fresh.length === 0) userMessages.delete(userId); + else userMessages.set(userId, fresh); + } + const cooldownHorizon = Date.now() - automodConfig.alertCooldownMs; + for (const [userId, at] of lastAlertAt) + if (at < cooldownHorizon) lastAlertAt.delete(userId); +}, 5 * 60_000); +sweep.unref(); diff --git a/src/utils/automod/incidents.ts b/src/utils/automod/incidents.ts index ffc2d0a..b9aded2 100644 --- a/src/utils/automod/incidents.ts +++ b/src/utils/automod/incidents.ts @@ -1,7 +1,11 @@ import { randomUUID } from 'node:crypto'; -import type { DetectionLevel } from './tracker'; -export type IncidentLevel = Exclude | 'blocklist'; +/** + * The kinds of incident the moderation console can surface. `burst`, `fanout` + * and `blocklist` come from the image-spam detector; `flood` from the + * text-flooding detector. + */ +export type IncidentLevel = 'burst' | 'fanout' | 'blocklist' | 'flood'; export interface IncidentMessage { channelId: string; diff --git a/src/utils/config.ts b/src/utils/config.ts index 80ef26b..40cad4d 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -89,4 +89,26 @@ export const automodConfig = { * faster). Should be <= burstThreshold. Default 2. */ newMemberBurstThreshold: posInt(process.env.AUTOMOD_NEW_MEMBER_BURST_THRESHOLD, 2), + + // --- Text-flooding detector --- + /** Whether the text-flooding detector is active (on unless "false"). */ + floodEnabled: process.env.AUTOMOD_FLOOD_ENABLED !== 'false', + /** + * Number of short messages from one user within `floodWindowMs` to flag + * flooding (a user fragmenting one thought across many tiny messages). + */ + floodThreshold: posInt(process.env.AUTOMOD_FLOOD_THRESHOLD, 5), + /** Sliding window (ms) for counting flood messages. Default 15s. */ + floodWindowMs: posInt(process.env.AUTOMOD_FLOOD_WINDOW_MS, 15_000), + /** + * A message counts toward flooding only if its trimmed content length is at + * most this. Longer messages are treated as normal conversation. Default 25. + */ + floodMaxChars: posInt(process.env.AUTOMOD_FLOOD_MAX_CHARS, 25), + /** + * When true, the bot also times the user out automatically on a flood hit. + * Off by default: flooding is usually a habit, not an attack, so by default + * we only alert moderators and let them decide. + */ + floodAutoTimeout: process.env.AUTOMOD_FLOOD_AUTO_TIMEOUT === 'true', }; From 8edd57a9b7dd03522df12259900b05598207dd6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 17:29:39 +0000 Subject: [PATCH 15/42] Add cross-channel question-spam automod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catch new users fanning the same question across many channels at once, which is noise and bypasses the right help channel. Two tenure-aware signals feed the existing mod-log console: - Near-identical fan-out (any tenure): the same question (token-overlap Jaccard >= threshold) across N+ distinct channels in the window. High confidence, so the duplicate copies are auto-deleted (first kept), then mods are alerted. - New-member spread: a recent joiner posting substantive messages across N+ channels even when reworded past the similarity check. Alert-only, since these aren't strict duplicates. - New crosspost tracker (utils/automod/crosspost.ts): per-user windowed buffer, token-set similarity, clustering, alert cooldown, self-pruning sweep — mirrors the flood/image trackers. - Extend IncidentLevel with 'crosspost'; console renders a level-aware title, color, and auto-action note. - Wire into the message listener; factor out a shared isNewMember helper reused by the image path. Clear crosspost state on resolve. - Config tunables (AUTOMOD_CROSSPOST_*), .env.example, and README docs. --- .env.example | 9 ++ README.md | 21 ++- src/interaction-handlers/spamModeration.ts | 4 + src/listeners/messageCreate.ts | 82 ++++++++++- src/utils/automod/console.ts | 20 ++- src/utils/automod/crosspost.ts | 155 +++++++++++++++++++++ src/utils/automod/incidents.ts | 7 +- src/utils/config.ts | 28 ++++ 8 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 src/utils/automod/crosspost.ts diff --git a/.env.example b/.env.example index 88966c1..619369c 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,12 @@ MOD_LOG_CHANNEL_ID= # AUTOMOD_FLOOD_WINDOW_MS=15000 # sliding window for the flood count # AUTOMOD_FLOOD_MAX_CHARS=25 # a message counts as "short" at or below this length # AUTOMOD_FLOOD_AUTO_TIMEOUT=false # also auto-timeout on a flood hit (default: alert only) + +# --- Cross-channel question-spam detector (needs MOD_LOG_CHANNEL_ID) --- +# Catches new users fanning the same question across many channels at once. +# AUTOMOD_CROSSPOST_ENABLED=true # set false to disable +# AUTOMOD_CROSSPOST_CHANNELS=2 # distinct channels for a near-identical repeat +# AUTOMOD_CROSSPOST_SPREAD_CHANNELS=3 # distinct channels for new-member shotgunning +# AUTOMOD_CROSSPOST_WINDOW_MS=120000 # window for cross-channel detection (2 min) +# AUTOMOD_CROSSPOST_MIN_CHARS=12 # ignore messages shorter than this (greetings/reactions) +# AUTOMOD_CROSSPOST_SIMILARITY_PCT=80 # token-overlap % at which two messages are the "same" question diff --git a/README.md b/README.md index 6c4a827..019aa00 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,25 @@ Members with *Manage Messages* (or a configured immune role) are never flagged. --- +## 📡 Cross-channel question-spam automod + +New members often fire the *same* question into every channel at once instead +of the one that fits. Two tenure-aware signals catch this (`AUTOMOD_CROSSPOST_*`): + +- **Near-identical fan-out** (any tenure) — the same question (token-overlap ≥ + `SIMILARITY_PCT`, default 80%) across `CHANNELS`+ channels (default 2) inside + the window. High confidence, so the bot **auto-deletes the duplicate copies, + keeping the first**, then alerts. +- **New-member spread** (recent joiners only) — posting substantive messages in + `SPREAD_CHANNELS`+ channels (default 3) at once, even when reworded enough to + dodge the similarity check. Alerts only — these aren't strict duplicates. + +Both surface in the same mod-log console with the usual action buttons, reuse +the existing new-member window, and respect the immune-role/permission checks. +Short greetings and reactions (below `MIN_CHARS`) are ignored. + +--- + ## 🧰 Server management Ambient helpers, each self-disabling until configured: @@ -173,7 +192,7 @@ src/ ├── listeners/ # gateway events (ready, messages, members, invites…) ├── interaction-handlers/ # button handlers (spam console, role-select, tag buttons) └── utils/ - ├── automod/ # spam detection: tracker, flood, phash, blocklist, console, incidents + ├── automod/ # spam detection: tracker, flood, crosspost, phash, blocklist, console, incidents ├── config.ts # env-driven configuration ├── db.ts # optional Prisma (pg driver adapter) with in-memory fallback ├── tags.ts # tag content + schema diff --git a/src/interaction-handlers/spamModeration.ts b/src/interaction-handlers/spamModeration.ts index 1f59015..e3a1130 100644 --- a/src/interaction-handlers/spamModeration.ts +++ b/src/interaction-handlers/spamModeration.ts @@ -23,6 +23,7 @@ import { } from '../utils/automod/console'; import { clearUser } from '../utils/automod/tracker'; import { clearFloodUser } from '../utils/automod/flood'; +import { clearCrosspostUser } from '../utils/automod/crosspost'; interface ParsedButton { action: string; @@ -87,6 +88,7 @@ export class SpamModerationHandler extends InteractionHandler { ); clearUser(incident.userId); clearFloodUser(incident.userId); + clearCrosspostUser(incident.userId); const fingerprints = incident.signatures.length + incident.hashes.length; const blocklisted = fingerprints ? `, and blocklisted ${fingerprints} image fingerprint(s)` @@ -115,6 +117,7 @@ export class SpamModerationHandler extends InteractionHandler { ); clearUser(incident.userId); clearFloodUser(incident.userId); + clearCrosspostUser(incident.userId); summary = ok ? '🔨 User banned and recent messages purged.' : '⚠️ Could not ban the user (check role hierarchy and permissions).'; @@ -131,6 +134,7 @@ export class SpamModerationHandler extends InteractionHandler { case 'dismiss': { clearUser(incident.userId); clearFloodUser(incident.userId); + clearCrosspostUser(incident.userId); summary = '👌 Marked as not spam. Cleared tracking for this user; no action taken.'; break; diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 615b298..fe40d71 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -13,6 +13,12 @@ import { shouldAlertFlood, markFloodAlerted, } from '../utils/automod/flood'; +import { + recordTextMessage, + shouldAlertCrosspost, + markCrosspostAlerted, + tokenize, +} from '../utils/automod/crosspost'; import { isBlocklisted } from '../utils/automod/blocklist'; import { createIncident, @@ -37,9 +43,10 @@ export class MessageCreateListener extends Listener { if (!MOD_LOG_CHANNEL_ID) return; if (message.member && this.isImmune(message.member)) return; - // Text-flooding runs independently of the image automod below: a user - // fragmenting one thought across many tiny messages rarely posts images. + // Text-flooding and cross-channel question spam run independently of the + // image automod below: they key off message text, not attachments. await this.runFlood(message); + await this.runCrosspost(message); const signatures = imageSignatures(message); if (signatures.length === 0) return; @@ -51,10 +58,7 @@ export class MessageCreateListener extends Listener { // New members get a stricter burst threshold so join-then-spam trips // faster; tenure is useless for compromised veterans, who are instead // caught by the fan-out/blocklist signals below. - const isNewMember = Boolean( - message.member?.joinedTimestamp && - now - message.member.joinedTimestamp < automodConfig.newMemberWindowMs - ); + const isNewMember = this.isNewMember(message.member, now); const detection = recordImageMessage( message.author.id, { @@ -173,6 +177,64 @@ export class MessageCreateListener extends Listener { await this.postAlert(message, incident, autoActed); } + /** + * Detect a user fanning the same question across many channels — the classic + * new-joiner "ask everywhere at once" pattern. High-confidence near-identical + * repeats auto-delete the duplicate copies (keeping the first); the broader + * new-member spread signal only alerts. Tenure is read the same way as the + * image automod: new members are scrutinised harder. + */ + private async runCrosspost(message: Message): Promise { + if (!automodConfig.crosspostEnabled) return; + + const content = message.content.trim(); + if (content.length < automodConfig.crosspostMinChars) return; + + const now = Date.now(); + const detection = recordTextMessage( + message.author.id, + { + at: now, + channelId: message.channelId, + messageId: message.id, + tokens: tokenize(content), + }, + { isNewMember: this.isNewMember(message.member, now) } + ); + if (!detection) return; + if (!shouldAlertCrosspost(message.author.id, now)) return; + markCrosspostAlerted(message.author.id, now); + + const messages = detection.messages.map((m) => ({ + channelId: m.channelId, + messageId: m.messageId, + })); + const incident = createIncident({ + userId: message.author.id, + guildId: message.guildId, + level: 'crosspost', + reason: detection.reason, + messages, + // Cross-posting leaves no image fingerprints to blocklist. + signatures: [], + hashes: [], + }); + + // Auto-delete only genuine duplicates: for a near-identical fan-out, drop + // every copy but the first. The content-agnostic spread signal isn't a set + // of duplicates, so it only alerts and waits for a human. + let autoActed = false; + if (detection.kind === 'similar' && messages.length > 1) { + const deleted = await deleteIncidentMessages(container.client, { + ...incident, + messages: messages.slice(1), + }).catch(() => 0); + autoActed = deleted > 0; + } + + await this.postAlert(message, incident, autoActed); + } + private isImmune(member: GuildMember): boolean { if (member.permissions.has(PermissionFlagsBits.ManageMessages)) return true; return automodConfig.immuneRoleIds.some((roleId) => @@ -180,6 +242,14 @@ export class MessageCreateListener extends Listener { ); } + /** Whether a member is still within the configured "new member" window. */ + private isNewMember(member: GuildMember | null, now: number): boolean { + return Boolean( + member?.joinedTimestamp && + now - member.joinedTimestamp < automodConfig.newMemberWindowMs + ); + } + private async postAlert( message: Message, incident: Incident, diff --git a/src/utils/automod/console.ts b/src/utils/automod/console.ts index 1ef2db3..cde8b8a 100644 --- a/src/utils/automod/console.ts +++ b/src/utils/automod/console.ts @@ -20,6 +20,7 @@ const LEVEL_COLOR: Record = { blocklist: 0xe03131, burst: 0xf08c00, // needs review — amber flood: 0xf08c00, // needs review — amber + crosspost: 0xe03131, // cross-channel — red }; const LEVEL_LABEL: Record = { @@ -27,6 +28,7 @@ const LEVEL_LABEL: Record = { blocklist: 'Known spam image', burst: 'Image burst', flood: 'Message flooding', + crosspost: 'Cross-channel question spam', }; /** Headline shown at the top of an alert, by incident kind. */ @@ -35,6 +37,19 @@ const LEVEL_TITLE: Record = { blocklist: '🚨 Possible image spam', burst: '🚨 Possible image spam', flood: '🚨 Possible message flooding', + crosspost: '🚨 Possible cross-channel question spam', +}; + +/** What an automatic action did, shown when the bot acted before a human. */ +const HIGH_CONFIDENCE_NOTE = + 'High-confidence signal: the messages were deleted and the user was timed out automatically. Review and escalate or reverse below.'; +const AUTO_ACTION_NOTE: Record = { + fanout: HIGH_CONFIDENCE_NOTE, + blocklist: HIGH_CONFIDENCE_NOTE, + burst: HIGH_CONFIDENCE_NOTE, + flood: 'The user was timed out automatically. Review and escalate or reverse below.', + crosspost: + 'The duplicate crossposts were deleted automatically (the first copy was kept). Review and escalate or reverse below.', }; /** One moderation action button bound to an incident id. */ @@ -107,10 +122,7 @@ export function buildAlertPayload( if (autoActed) embed.addFields({ name: '🔒 Auto-action taken', - value: - incident.level === 'flood' - ? 'The user was timed out automatically. Review and escalate or reverse below.' - : 'High-confidence signal: the messages were deleted and the user was timed out automatically. Review and escalate or reverse below.', + value: AUTO_ACTION_NOTE[incident.level], }); const row1 = new ActionRowBuilder().addComponents( diff --git a/src/utils/automod/crosspost.ts b/src/utils/automod/crosspost.ts new file mode 100644 index 0000000..a0299ca --- /dev/null +++ b/src/utils/automod/crosspost.ts @@ -0,0 +1,155 @@ +import { automodConfig } from '../config'; + +/** A substantive text message recorded for cross-channel detection. */ +export interface TextMessage { + at: number; + channelId: string; + messageId: string; + /** Tokenised, normalised words of the message, for similarity comparison. */ + tokens: Set; +} + +/** Which signal tripped: a near-identical repeat, or a new-member shotgun. */ +export type CrosspostKind = 'similar' | 'spread'; + +export interface CrosspostDetection { + kind: CrosspostKind; + reason: string; + /** Contributing messages, oldest first (so the first is the one to keep). */ + messages: TextMessage[]; + /** Distinct channels involved. */ + channels: string[]; +} + +/** Per-user recent substantive messages, pruned to the crosspost window. */ +const userMessages = new Map(); +/** Last time we raised a crosspost alert for a user, for cooldown suppression. */ +const lastAlertAt = new Map(); + +const unique = (values: T[]): T[] => [...new Set(values)]; + +/** Lowercase, strip punctuation, collapse whitespace — then split into words. */ +export function tokenize(text: string): Set { + const normalized = text + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + return new Set(normalized.split(' ').filter(Boolean)); +} + +/** Jaccard overlap of two token sets, in [0, 1]. */ +function similarity(a: Set, b: Set): number { + if (a.size === 0 || b.size === 0) return 0; + let intersection = 0; + for (const token of a) if (b.has(token)) intersection++; + return intersection / (a.size + b.size - intersection); +} + +/** Whether two messages are "the same question" under the configured threshold. */ +export function isSimilar(a: TextMessage, b: TextMessage): boolean { + return similarity(a.tokens, b.tokens) >= automodConfig.crosspostSimilarityPct / 100; +} + +export interface CrosspostOptions { + /** Whether the author currently counts as a new member (enables `spread`). */ + isNewMember?: boolean; +} + +/** + * Record a substantive message and decide whether the user is fanning the same + * question across channels. The high-confidence `similar` signal (near-identical + * text in N+ channels) wins over the content-agnostic new-member `spread` + * signal. On a hit the buffer is cleared so the next incident builds afresh. + */ +export function recordTextMessage( + userId: string, + message: TextMessage, + options: CrosspostOptions = {} +): CrosspostDetection | null { + const horizon = message.at - automodConfig.crosspostWindowMs; + const messages = (userMessages.get(userId) ?? []).filter( + (m) => m.at >= horizon + ); + messages.push(message); + + const similar = detectSimilarFanout(messages); + const detection = similar ?? detectNewMemberSpread(messages, options); + + if (detection) { + userMessages.delete(userId); + return detection; + } + + userMessages.set(userId, messages); + return null; +} + +/** Near-identical text across `crosspostChannels`+ distinct channels. */ +function detectSimilarFanout(messages: TextMessage[]): CrosspostDetection | null { + for (const anchor of messages) { + const cluster = messages.filter((m) => isSimilar(m, anchor)); + const channels = unique(cluster.map((m) => m.channelId)); + if (channels.length >= automodConfig.crosspostChannels) { + const ordered = [...cluster].sort((a, b) => a.at - b.at); + return { + kind: 'similar', + reason: `Same question posted across ${channels.length} channels`, + messages: ordered, + channels, + }; + } + } + return null; +} + +/** + * A new member posting in `crosspostSpreadChannels`+ distinct channels, even + * when the wording differs enough to dodge the similarity check. + */ +function detectNewMemberSpread( + messages: TextMessage[], + options: CrosspostOptions +): CrosspostDetection | null { + if (!options.isNewMember) return null; + const channels = unique(messages.map((m) => m.channelId)); + if (channels.length < automodConfig.crosspostSpreadChannels) return null; + const ordered = [...messages].sort((a, b) => a.at - b.at); + return { + kind: 'spread', + reason: `New member posting across ${channels.length} channels at once`, + messages: ordered, + channels, + }; +} + +/** Whether enough time has passed since the last crosspost alert for this user. */ +export function shouldAlertCrosspost(userId: string, now: number): boolean { + const last = lastAlertAt.get(userId); + return !last || now - last >= automodConfig.alertCooldownMs; +} + +export function markCrosspostAlerted(userId: string, now: number): void { + lastAlertAt.set(userId, now); +} + +/** Forget a user's crosspost state (e.g. after a moderator resolves an alert). */ +export function clearCrosspostUser(userId: string): void { + userMessages.delete(userId); + lastAlertAt.delete(userId); +} + +// Periodically drop stale state so the maps don't grow unbounded. unref() +// keeps this timer from holding the process open. +const sweep = setInterval(() => { + const horizon = Date.now() - automodConfig.crosspostWindowMs; + for (const [userId, messages] of userMessages) { + const fresh = messages.filter((m) => m.at >= horizon); + if (fresh.length === 0) userMessages.delete(userId); + else userMessages.set(userId, fresh); + } + const cooldownHorizon = Date.now() - automodConfig.alertCooldownMs; + for (const [userId, at] of lastAlertAt) + if (at < cooldownHorizon) lastAlertAt.delete(userId); +}, 5 * 60_000); +sweep.unref(); diff --git a/src/utils/automod/incidents.ts b/src/utils/automod/incidents.ts index b9aded2..1566ca0 100644 --- a/src/utils/automod/incidents.ts +++ b/src/utils/automod/incidents.ts @@ -5,7 +5,12 @@ import { randomUUID } from 'node:crypto'; * and `blocklist` come from the image-spam detector; `flood` from the * text-flooding detector. */ -export type IncidentLevel = 'burst' | 'fanout' | 'blocklist' | 'flood'; +export type IncidentLevel = + | 'burst' + | 'fanout' + | 'blocklist' + | 'flood' + | 'crosspost'; export interface IncidentMessage { channelId: string; diff --git a/src/utils/config.ts b/src/utils/config.ts index 40cad4d..f19e548 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -111,4 +111,32 @@ export const automodConfig = { * we only alert moderators and let them decide. */ floodAutoTimeout: process.env.AUTOMOD_FLOOD_AUTO_TIMEOUT === 'true', + + // --- Cross-channel question-spam detector --- + /** Whether the cross-channel question-spam detector is active (on unless "false"). */ + crosspostEnabled: process.env.AUTOMOD_CROSSPOST_ENABLED !== 'false', + /** + * Distinct channels the same (or near-identical) message must span within + * `crosspostWindowMs` to flag a cross-channel repeat. Applies at any tenure. + */ + crosspostChannels: posInt(process.env.AUTOMOD_CROSSPOST_CHANNELS, 2), + /** + * Distinct channels a *new* member must post substantive messages in within + * the window to flag content-agnostic "shotgunning" (they reworded enough to + * dodge the similarity check but are clearly asking everywhere at once). + */ + crosspostSpreadChannels: posInt(process.env.AUTOMOD_CROSSPOST_SPREAD_CHANNELS, 3), + /** Sliding window (ms) for cross-channel detection. Default 2 minutes. */ + crosspostWindowMs: posInt(process.env.AUTOMOD_CROSSPOST_WINDOW_MS, 120_000), + /** + * Minimum trimmed length for a message to be considered a "question" worth + * tracking — keeps greetings/reactions ("hi", "ok") from tripping the + * detector. Default 12. + */ + crosspostMinChars: posInt(process.env.AUTOMOD_CROSSPOST_MIN_CHARS, 12), + /** + * Token-overlap (Jaccard) percentage at which two messages count as the + * "same" question. Higher = stricter. Default 80%. + */ + crosspostSimilarityPct: posInt(process.env.AUTOMOD_CROSSPOST_SIMILARITY_PCT, 80), }; From 00b48aad344ca9263ed8a6b5bba4c3ff9f21e2dc Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Fri, 12 Jun 2026 17:46:36 +0000 Subject: [PATCH 16/42] Add Phase 2 helper-assist features Builds on the Phase 1 helper-assist set to cut the most-repeated asks and close the loop on help posts. Repeated-ask deflectors (one unified suggestion engine, one reply, one per-user cooldown, each individually toggleable): - Co-locate keyword->tag triggers in tags.ts via a new `suggest` field and broaden coverage (level shifter, power, pull-up, 9V, HID, debounce, plus the original AVRDUDE/libmissing/ESP). tagSuggest now builds its signatures from the tags themselves. - Detect code pasted as plain text and offer the codeblock tag (CODE_FORMAT_SUGGEST_ENABLED). - Nudge low-effort "can I ask?"/"anyone here?" pings toward the ask tag (ASK_SUGGEST_ENABLED), with conservative anchored patterns. Close-the-loop on help forums: - Auto-post the needinfo checklist when a new help post is too thin (no code/image, short), via threadCreate (HELP_AUTO_NEEDINFO). - Stale-post sweep: nudge open posts idle past a threshold, then auto-archive if no human replies after the nudge; re-engagement resets the state. Runs from the ready listener on an unref'd interval. - /openposts: ephemeral digest of open help posts, oldest activity first. Shared open-post scan (utils/helpPosts.ts) backs both the sweep and the digest. Config knobs (HELP_*), .env.example, and README updated. Co-authored-by: Claude https://claude.ai/code/session_01G6Q9Af9u4QrPS93f4kgsRJ --- .env.example | 9 ++- README.md | 23 +++++-- src/commands/openPosts.ts | 74 ++++++++++++++++++++++ src/listeners/ready.ts | 3 + src/listeners/tagSuggest.ts | 116 +++++++++++++++++++++++++--------- src/listeners/threadCreate.ts | 42 ++++++++++-- src/utils/config.ts | 24 +++++++ src/utils/helpPosts.ts | 51 +++++++++++++++ src/utils/staleHelpSweep.ts | 82 ++++++++++++++++++++++++ src/utils/tags.ts | 55 ++++++++++++++++ 10 files changed, 439 insertions(+), 40 deletions(-) create mode 100644 src/commands/openPosts.ts create mode 100644 src/utils/helpPosts.ts create mode 100644 src/utils/staleHelpSweep.ts diff --git a/.env.example b/.env.example index 619369c..6f2b53c 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,15 @@ MOD_LOG_CHANNEL_ID= # SERVER_UPDATE_NOTIFS_ROLE_ID= # role toggled by the "server_updates" button # --- Helper-assist features --- -# HELP_FORUM_CHANNEL_IDS= # forum channels that get a "Mark Solved" button on new posts +# HELP_FORUM_CHANNEL_IDS= # forum channels that get a "Mark Solved" button on new posts (enables A/E/H below) # TAG_SUGGEST_ENABLED=true # auto-suggest a relevant tag on common error messages (set false to disable) +# CODE_FORMAT_SUGGEST_ENABLED=true # nudge users who paste unformatted code toward the codeblock tag +# ASK_SUGGEST_ENABLED=true # nudge "can I ask?" / "anyone here?" non-questions toward the ask tag +# HELP_AUTO_NEEDINFO=true # auto-post the needinfo checklist when a new help post is too thin +# HELP_NEEDINFO_MIN_CHARS=60 # opening post shorter than this (and without code/image) counts as thin +# HELP_STALE_SWEEP_ENABLED=true # nudge + auto-archive abandoned help posts +# HELP_STALE_NUDGE_HOURS=24 # idle hours before a "still need help?" nudge +# HELP_STALE_ARCHIVE_HOURS=72 # idle hours after a nudge (no human reply) before auto-archiving # --- Persistence (optional) --- # When unset, the bot runs fully in-memory and the spam-image blocklist diff --git a/README.md b/README.md index 019aa00..849f841 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ All commands are Discord **slash commands** — type `/` in the server. |---|---| | `/tag name: [user:@user]` | Post a curated troubleshooting guide, optionally pinging someone | | `/solved [helper:@user]` | Mark the current help post solved (and thank a helper); closes the thread | +| `/openposts` | List open help posts waiting for an answer, oldest first (ephemeral) | | `/about` | Bot, Node, and version info | | `/ping` | Latency & uptime | | `/say channel:<#ch> title:… description:…` | **Staff only** — send a custom embed (optional `fields` as `name \| value` per line, and `thumbnail`) | @@ -173,22 +174,34 @@ Ambient helpers, each self-disabling until configured: Features aimed at taking repetitive load off the community members who answer the most questions: -- **Keyword → tag suggestions** — when a message contains a known error - signature (AVRDUDE, missing-library, ESP upload…), the bot offers the matching - tag via a single button, so askers self-serve before a helper has to repeat a - canned answer. Per-user cooldown; toggle with `TAG_SUGGEST_ENABLED`. +- **Keyword → tag suggestions** — when a message matches a known signature + (AVRDUDE, missing-library, ESP upload, level shifters, power, pull-ups, 9V, + HID, debounce…), the bot offers the matching tag via a single button, so + askers self-serve before a helper repeats a canned answer. Each trigger lives + next to its tag in `tags.ts`. Per-user cooldown; toggle with `TAG_SUGGEST_ENABLED`. +- **Unformatted-code nudge** — detects code pasted as plain text and offers the + `codeblock` tag, the single most-repeated ask. Toggle `CODE_FORMAT_SUGGEST_ENABLED`. +- **"Just ask" nudge** — replies to low-effort pings ("can I ask?", "anyone + here?") with the `ask` tag. Conservative patterns; toggle `ASK_SUGGEST_ENABLED`. - **"Request more info" context-menu** — right-click any message → *Request more info* to post the `needinfo` checklist to the asker in one click. +- **Auto-needinfo on thin posts** — a new help-forum post with no code, image, + or detail auto-gets the `needinfo` checklist (`HELP_AUTO_NEEDINFO`). - **Solve workflow** — a **Mark Solved** button on new help-forum posts (set `HELP_FORUM_CHANNEL_IDS`) plus `/solved [helper:@user]`, which closes the post and credits whoever helped. +- **Stale-post nudge & auto-archive** — abandoned help posts get a "still need + help?" nudge after a day, then auto-archive if no one replies + (`HELP_STALE_NUDGE_HOURS` / `HELP_STALE_ARCHIVE_HOURS`). +- **`/openposts`** — an ephemeral digest of open help posts, oldest-waiting + first, so helpers can pick up whatever's been waiting longest. ## 🏗️ Project structure ``` src/ ├── index.ts # client bootstrap (intents, presence, login) -├── commands/ # slash commands (about, ping, tag, say) +├── commands/ # slash commands (about, ping, tag, say, solved, openposts) ├── listeners/ # gateway events (ready, messages, members, invites…) ├── interaction-handlers/ # button handlers (spam console, role-select, tag buttons) └── utils/ diff --git a/src/commands/openPosts.ts b/src/commands/openPosts.ts new file mode 100644 index 0000000..ab057a8 --- /dev/null +++ b/src/commands/openPosts.ts @@ -0,0 +1,74 @@ +import { ApplicationCommandRegistry, Command } from '@sapphire/framework'; +import { + EmbedBuilder, + MessageFlags, + TimestampStyles, + time, +} from 'discord.js'; +import { helpForumChannelIds } from '../utils/config'; +import { fetchOpenHelpPosts } from '../utils/helpPosts'; +import universalEmbed from '../utils/embed'; + +const MAX_LISTED = 15; + +/** + * `/openposts` — an ephemeral digest of currently-open (active, unsolved) help + * posts, oldest activity first, so helpers can pick up whatever's been waiting + * longest. Reuses the same open-post scan as the stale-post sweep. + */ +export class OpenPostsCommand extends Command { + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + name: 'openposts', + description: 'List open help posts waiting for an answer.', + }); + } + + public override registerApplicationCommands( + registry: ApplicationCommandRegistry + ) { + registry.registerChatInputCommand((builder) => + builder + .setName(this.name) + .setDescription(this.description) + .setDMPermission(false) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + if (helpForumChannelIds.length === 0) + return interaction.reply({ + content: 'No help forums are configured.', + flags: MessageFlags.Ephemeral, + }); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const posts = (await fetchOpenHelpPosts(interaction.client)).sort( + (a, b) => a.lastActivityAt - b.lastActivityAt + ); + + if (posts.length === 0) + return interaction.editReply({ + content: '🎉 No open help posts right now — all caught up!', + }); + + const lines = posts.slice(0, MAX_LISTED).map(({ thread, lastActivityAt }) => { + const url = `https://discord.com/channels/${thread.guildId}/${thread.id}`; + const when = time(Math.floor(lastActivityAt / 1000), TimestampStyles.RelativeTime); + return `• [${thread.name}](${url}) — last activity ${when}`; + }); + + if (posts.length > MAX_LISTED) + lines.push(`…and ${posts.length - MAX_LISTED} more.`); + + const embed = new EmbedBuilder(universalEmbed) + .setTitle(`🗂️ Open help posts (${posts.length})`) + .setDescription(lines.join('\n')); + + return interaction.editReply({ embeds: [embed] }); + } +} diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index 193750d..f1b0807 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -3,6 +3,7 @@ import type { Client } from 'discord.js'; import { initDatabase } from '../utils/db'; import { loadBlocklist } from '../utils/automod/blocklist'; import { seedInviteCache } from '../utils/inviteCache'; +import { startStaleHelpSweep } from '../utils/staleHelpSweep'; import { JOIN_LEAVE_LOG_CHANNEL_ID, SERVER_ID } from '../utils/config'; export class ReadyListener extends Listener { @@ -22,6 +23,8 @@ export class ReadyListener extends Listener { await loadBlocklist(); // Seed the invite-use cache so join logging can attribute the source. await this.fillInviteCache(client); + // Begin nudging/auto-archiving stale help posts (no-op unless configured). + startStaleHelpSweep(client); } private async fillInviteCache(client: Client): Promise { diff --git a/src/listeners/tagSuggest.ts b/src/listeners/tagSuggest.ts index fa78da1..f613e7d 100644 --- a/src/listeners/tagSuggest.ts +++ b/src/listeners/tagSuggest.ts @@ -6,54 +6,110 @@ import { EmbedBuilder, type Message, } from 'discord.js'; -import { SERVER_ID, tagSuggestEnabled } from '../utils/config'; +import { + SERVER_ID, + tagSuggestEnabled, + codeFormatSuggestEnabled, + askSuggestEnabled, +} from '../utils/config'; +import tags, { type Tag, type TagSuggestion } from '../utils/tags'; import universalEmbed from '../utils/embed'; interface Suggestion { - pattern: RegExp; tag: string; prompt: string; + /** Label for the button that reveals the tag. */ + label: string; } -// High-precision signatures for the most-repeated questions. Kept conservative -// to avoid false positives; the suggestion is a single button, never the full -// answer dumped into the channel. -const SUGGESTIONS: Suggestion[] = [ - { - pattern: /stk500|avrdude[:\s]|not in sync/i, - tag: 'avrdude', - prompt: 'Looks like an **AVRDUDE upload error**.', - }, - { - pattern: - /no such file or directory|fatal error:.*\.h|\.h: No such file|library.*(not found|is not installed|missing)/i, - tag: 'libmissing', - prompt: 'Looks like a **missing library / header** error.', - }, - { - pattern: - /espcomm|esptool|failed to connect to esp|wrong boot mode|a fatal error occurred.*(packet|connect|timed out)/i, - tag: 'espcomm', - prompt: 'Looks like an **ESP upload / connection** problem.', - }, -]; +// Keyword -> tag signatures, collected from the tags that declare a `suggest` +// rule. Co-locating the trigger with the tag keeps adding one a single edit. +const keywordSuggestions = Object.entries(tags) + .filter( + (entry): entry is [string, Tag & { suggest: TagSuggestion }] => + Boolean(entry[1].suggest) + ) + .map(([tag, t]) => ({ tag, pattern: t.suggest.pattern, prompt: t.suggest.prompt })); const COOLDOWN_MS = 5 * 60_000; const lastSuggested = new Map(); +const ARDUINO_CODE = + /\b(void\s+setup\s*\(|void\s+loop\s*\(|#include\s*[<"]|pinMode\s*\(|digital(Write|Read)\s*\(|analog(Write|Read)\s*\(|Serial\.(begin|print))/; + +/** + * Heuristic for code pasted as plain text. Conservative: an unmistakable + * Arduino signature in a multi-line paste, or a sizeable multi-line blob dense + * with code punctuation. Anything already in a code fence is left alone. + */ +function looksLikeUnformattedCode(content: string): boolean { + if (content.includes('```')) return false; + const lines = content.split('\n').length; + const semicolons = (content.match(/;/g) ?? []).length; + const braces = (content.match(/[{}]/g) ?? []).length; + if (ARDUINO_CODE.test(content) && (lines >= 4 || semicolons >= 2)) return true; + return lines >= 5 && semicolons >= 3 && braces >= 2; +} + +// Short messages that are a request to ask / a ping for attention rather than +// an actual question. Anchored and length-bounded to limit false positives. +const LOW_EFFORT_ASK = [ + /^(can|could|may) (i|someone|anyone|u|you)\b.{0,20}\b(help|ask)\b/i, + /^(can|may) i ask( a)?( quick)?( question)?\s*\??$/i, + /^(is\s+)?(any\s?(one|body)|some\s?(one|body))\s+(here|around|online|there|available)\s*\??$/i, + /\b(any\s?(one|body)|some\s?(one|body))\b.{0,30}\b(good with|know about|help with)\b/i, + /^(help|help me|need help|i need help|pls help|please help)\b[!.\s]*$/i, +]; + +function looksLikeLowEffortAsk(content: string): boolean { + const text = content.trim(); + if (text.length > 80) return false; + return LOW_EFFORT_ASK.some((p) => p.test(text)); +} + +/** Pick at most one suggestion, in priority order, honouring per-type toggles. */ +function detect(content: string): Suggestion | null { + if (tagSuggestEnabled) { + const match = keywordSuggestions.find((s) => s.pattern.test(content)); + if (match) + return { tag: match.tag, prompt: match.prompt, label: 'Show steps' }; + } + if (codeFormatSuggestEnabled && looksLikeUnformattedCode(content)) + return { + tag: 'codeblock', + prompt: 'That looks like **unformatted code**.', + label: 'How to format code', + }; + if (askSuggestEnabled && looksLikeLowEffortAsk(content)) + return { + tag: 'ask', + prompt: + 'No need to ask to ask — just **post your question with details** and someone will help.', + label: 'How to ask', + }; + return null; +} + +/** + * Watches messages and, when one matches a high-precision signature, offers the + * relevant tag via a single button (never the full answer). Three detectors — + * keyword/error signatures, unformatted code, and low-effort "can I ask" pings — + * share one reply, one priority order, and one per-user cooldown. + */ export class TagSuggestListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { super(context, { ...options, event: Events.MessageCreate }); } public async run(message: Message) { - if (!tagSuggestEnabled) return; + if (!tagSuggestEnabled && !codeFormatSuggestEnabled && !askSuggestEnabled) + return; if (!message.inGuild() || message.author.bot) return; if (message.guildId !== SERVER_ID) return; if (message.content.length < 10) return; - const match = SUGGESTIONS.find((s) => s.pattern.test(message.content)); - if (!match) return; + const suggestion = detect(message.content); + if (!suggestion) return; const now = Date.now(); const last = lastSuggested.get(message.author.id); @@ -61,12 +117,12 @@ export class TagSuggestListener extends Listener { lastSuggested.set(message.author.id, now); const embed = new EmbedBuilder(universalEmbed).setDescription( - `💡 ${match.prompt} Tap below for troubleshooting steps.` + `💡 ${suggestion.prompt} Tap below for the details.` ); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId(`tag:${match.tag}`) - .setLabel('Show steps') + .setCustomId(`tag:${suggestion.tag}`) + .setLabel(suggestion.label) .setStyle(ButtonStyle.Primary) ); diff --git a/src/listeners/threadCreate.ts b/src/listeners/threadCreate.ts index 3766592..8d83947 100644 --- a/src/listeners/threadCreate.ts +++ b/src/listeners/threadCreate.ts @@ -6,13 +6,18 @@ import { EmbedBuilder, type AnyThreadChannel, } from 'discord.js'; -import { helpForumChannelIds } from '../utils/config'; +import { helpForumChannelIds, helpAssistConfig } from '../utils/config'; +import { resolveTag } from '../utils/resolveTag'; import universalEmbed from '../utils/embed'; +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + /** - * Posts a "Mark Solved" button when a new post is created in a configured help - * forum, so the asker can close it (and credit a helper) in one click. Disabled - * unless HELP_FORUM_CHANNEL_IDS is set. + * On a new post in a configured help forum: + * - posts a "Mark Solved" button so the asker can close it in one click, and + * - if the opening post is too thin (short, no code, no image), auto-posts the + * `needinfo` checklist so helpers don't have to ask for the basics. + * Disabled unless HELP_FORUM_CHANNEL_IDS is set. */ export class ThreadCreateListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { @@ -36,5 +41,34 @@ export class ThreadCreateListener extends Listener { ); await thread.send({ embeds: [embed], components: [row] }).catch(() => null); + + if (helpAssistConfig.autoNeedinfo) await this.maybeRequestInfo(thread); + } + + /** Post the needinfo checklist when the opening message lacks substance. */ + private async maybeRequestInfo(thread: AnyThreadChannel): Promise { + // The starter message can lag a moment behind ThreadCreate for forum posts. + let starter = await thread.fetchStarterMessage().catch(() => null); + if (!starter) { + await delay(1500); + starter = await thread.fetchStarterMessage().catch(() => null); + } + if (!starter) return; // can't judge it — leave it alone + + const hasImage = starter.attachments.size > 0; + const hasCode = starter.content.includes('```'); + const tooShort = + starter.content.trim().length < helpAssistConfig.needinfoMinChars; + if (hasImage || hasCode || !tooShort) return; + + const payload = resolveTag('needinfo', starter.author.id); + if (!payload?.content) return; + + await thread + .send({ + content: payload.content, + allowedMentions: { users: [starter.author.id] }, + }) + .catch(() => null); } } diff --git a/src/utils/config.ts b/src/utils/config.ts index f19e548..6b02048 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -55,6 +55,30 @@ export const helpForumChannelIds = idList(process.env.HELP_FORUM_CHANNEL_IDS); /** Whether the keyword -> tag auto-suggester is active (on unless "false"). */ export const tagSuggestEnabled = process.env.TAG_SUGGEST_ENABLED !== 'false'; +/** Whether to nudge users who paste unformatted code toward the codeblock tag. */ +export const codeFormatSuggestEnabled = + process.env.CODE_FORMAT_SUGGEST_ENABLED !== 'false'; + +/** Whether to nudge "can I ask?" / "anyone here?" non-questions toward the ask tag. */ +export const askSuggestEnabled = process.env.ASK_SUGGEST_ENABLED !== 'false'; + +/** + * Help-forum quality-of-life knobs. The auto-needinfo and stale-post sweep only + * do anything when `helpForumChannelIds` is configured. + */ +export const helpAssistConfig = { + /** Auto-post the needinfo checklist when a new help post is too thin. */ + autoNeedinfo: process.env.HELP_AUTO_NEEDINFO !== 'false', + /** A starter message shorter than this (and without code) counts as "thin". */ + needinfoMinChars: posInt(process.env.HELP_NEEDINFO_MIN_CHARS, 60), + /** Whether the stale-post nudge/auto-archive sweep runs. */ + staleSweepEnabled: process.env.HELP_STALE_SWEEP_ENABLED !== 'false', + /** Idle time (ms) before an open help post gets a "still need help?" nudge. */ + staleNudgeMs: posInt(process.env.HELP_STALE_NUDGE_HOURS, 24) * 60 * 60 * 1000, + /** Idle time (ms) after a nudge, with no human reply, before auto-archiving. */ + staleArchiveMs: posInt(process.env.HELP_STALE_ARCHIVE_HOURS, 72) * 60 * 60 * 1000, +}; + /** * Tunables for the image-spam detector. Every value is overridable via the * environment so moderators can adjust thresholds without a redeploy. diff --git a/src/utils/helpPosts.ts b/src/utils/helpPosts.ts new file mode 100644 index 0000000..332bdcb --- /dev/null +++ b/src/utils/helpPosts.ts @@ -0,0 +1,51 @@ +import type { Client, ThreadChannel } from 'discord.js'; +import { SERVER_ID, helpForumChannelIds } from './config'; +import { SOLVED_PREFIX } from './solveThread'; + +export interface OpenHelpPost { + thread: ThreadChannel; + /** Timestamp (ms) of the last message, falling back to thread creation. */ + lastActivityAt: number; + /** Whether that last message was posted by the bot (e.g. a prompt/nudge). */ + lastFromBot: boolean; +} + +/** Whether a thread has already been marked solved (✅ title prefix). */ +export const isSolved = (thread: ThreadChannel): boolean => + thread.name.startsWith(SOLVED_PREFIX); + +/** + * Find the currently-open (active, unsolved) posts across the configured help + * forums, annotated with last-activity info. Shared by the stale-post sweep and + * the `/openposts` digest. Best-effort: per-thread fetch failures are skipped. + */ +export async function fetchOpenHelpPosts( + client: Client +): Promise { + if (helpForumChannelIds.length === 0) return []; + + const guild = await client.guilds.fetch(SERVER_ID).catch(() => null); + if (!guild) return []; + + const active = await guild.channels.fetchActiveThreads().catch(() => null); + if (!active) return []; + + const posts: OpenHelpPost[] = []; + for (const thread of active.threads.values()) { + if (!thread.parentId || !helpForumChannelIds.includes(thread.parentId)) + continue; + if (thread.archived || isSolved(thread)) continue; + + const last = await thread.messages + .fetch({ limit: 1 }) + .then((messages) => messages.first() ?? null) + .catch(() => null); + + posts.push({ + thread, + lastActivityAt: last?.createdTimestamp ?? thread.createdTimestamp ?? 0, + lastFromBot: last?.author?.id === client.user?.id, + }); + } + return posts; +} diff --git a/src/utils/staleHelpSweep.ts b/src/utils/staleHelpSweep.ts new file mode 100644 index 0000000..5aaed2e --- /dev/null +++ b/src/utils/staleHelpSweep.ts @@ -0,0 +1,82 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type Client, +} from 'discord.js'; +import { container } from '@sapphire/framework'; +import { helpForumChannelIds, helpAssistConfig } from './config'; +import { fetchOpenHelpPosts } from './helpPosts'; +import universalEmbed from './embed'; + +const SWEEP_INTERVAL_MS = 30 * 60_000; + +/** Threads we've nudged, so we can later auto-archive if still abandoned. */ +const nudged = new Map(); + +function nudgePayload() { + const embed = new EmbedBuilder(universalEmbed).setDescription( + "👋 This post has been quiet for a while. If you're sorted, tap **Mark Solved** to close it — otherwise reply with an update (what you've tried, your wiring/code) so a helper can jump back in." + ); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('solved') + .setLabel('✅ Mark Solved') + .setStyle(ButtonStyle.Success) + ); + return { embeds: [embed], components: [row] }; +} + +/** + * One pass: nudge open help posts idle past the nudge threshold, and archive + * those still untouched by a human a while after their nudge. A human reply + * after the nudge clears the state so the post is treated as live again. + */ +async function sweepOnce(client: Client): Promise { + const now = Date.now(); + const posts = await fetchOpenHelpPosts(client); + const live = new Set(posts.map((p) => p.thread.id)); + + for (const { thread, lastActivityAt, lastFromBot } of posts) { + const nudgedAt = nudged.get(thread.id); + + if (nudgedAt) { + const humanReplied = !lastFromBot && lastActivityAt > nudgedAt; + if (humanReplied) { + nudged.delete(thread.id); + } else if (now - nudgedAt >= helpAssistConfig.staleArchiveMs) { + await thread.setArchived(true, 'Auto-archived: no activity after nudge').catch(() => null); + nudged.delete(thread.id); + } + continue; + } + + if (now - lastActivityAt >= helpAssistConfig.staleNudgeMs) { + const sent = await thread.send(nudgePayload()).catch(() => null); + if (sent) nudged.set(thread.id, now); + } + } + + // Forget state for threads that are no longer open (solved/archived/deleted). + for (const id of nudged.keys()) if (!live.has(id)) nudged.delete(id); +} + +/** + * Start the periodic stale-help-post sweep. No-op unless help forums are + * configured and the sweep is enabled. The interval is unref'd so it never + * keeps the process alive on its own. + */ +export function startStaleHelpSweep(client: Client): void { + if (!helpAssistConfig.staleSweepEnabled) return; + if (helpForumChannelIds.length === 0) return; + + const run = () => + sweepOnce(client).catch((error) => + container.logger.error('Stale help-post sweep failed:', error) + ); + + // First pass shortly after startup, then on a fixed interval. + setTimeout(run, 60_000).unref(); + setInterval(run, SWEEP_INTERVAL_MS).unref(); +} diff --git a/src/utils/tags.ts b/src/utils/tags.ts index 87ba6d6..2247887 100644 --- a/src/utils/tags.ts +++ b/src/utils/tags.ts @@ -11,11 +11,23 @@ import universalEmbed from './embed'; * rich embeds, a few are plain (or templated) text, and some opt out of the * bot-commands-channel-only behaviour. */ +/** + * An optional auto-suggest rule for a tag. When a user's message matches + * `pattern`, the suggestion engine offers this tag via a single button with + * `prompt` as the lead-in. Co-locating the trigger with the tag means adding a + * suggestible tag is a one-place change. + */ +export interface TagSuggestion { + pattern: RegExp; + prompt: string; +} + export interface Tag { embeds?: EmbedBuilder[]; components?: ActionRowBuilder[]; content?: string | ((user?: string) => string); botCommandsOnly?: boolean; + suggest?: TagSuggestion; } const tags: Record = { @@ -75,6 +87,10 @@ const tags: Record = { }, avrdude: { + suggest: { + pattern: /stk500|avrdude[:\s]|not in sync/i, + prompt: 'Looks like an **AVRDUDE upload error**.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle('Solving AVRDUDE Communication Errors (Try These in Order)') @@ -158,6 +174,11 @@ const tags: Record = { }, debounce: { + suggest: { + pattern: + /debounc|button.*(bounc|multiple|several times|twice)|reading (multiple|several) (presses|times)/i, + prompt: 'Sounds like a **switch debouncing** problem.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle('🔘 Taming Bouncy Buttons: Understanding Debouncing') @@ -219,6 +240,11 @@ const tags: Record = { }, espcomm: { + suggest: { + pattern: + /espcomm|esptool|failed to connect to esp|wrong boot mode|a fatal error occurred.*(packet|connect|timed out)/i, + prompt: 'Looks like an **ESP upload / connection** problem.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle( @@ -300,6 +326,11 @@ const tags: Record = { }, hid: { + suggest: { + pattern: + /\bhid\b|keyboard\.h|mouse\.h|emulat(e|ing) (a )?(keyboard|mouse)|act as a (keyboard|mouse)/i, + prompt: 'Looks like a **USB HID (keyboard/mouse)** question.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('Can Your Arduino Be Used as a Keyboard or Mouse?') @@ -364,6 +395,11 @@ const tags: Record = { }, levelShifter: { + suggest: { + pattern: + /level\s?shift|logic[- ]?level|3\.3\s?v?\s*(to|->|→)\s*5\s?v|5\s?v?\s*(to|->|→)\s*3\.3\s?v/i, + prompt: 'Sounds like a **logic-level / voltage-level** question.', + }, embeds: [ new EmbedBuilder(universalEmbed) .setTitle('Logic Level Shifters: Protecting Your 3.3V Modules') @@ -411,6 +447,11 @@ const tags: Record = { }, libmissing: { + suggest: { + pattern: + /no such file or directory|fatal error:.*\.h|\.h: No such file|library.*(not found|is not installed|missing)/i, + prompt: 'Looks like a **missing library / header** error.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('Solving Library Errors (Such as "yourlib.h not found")') @@ -469,6 +510,10 @@ const tags: Record = { }, ninevolt: { + suggest: { + pattern: /\b9\s?v(olt)?\b.*batter|batter.*\b9\s?v(olt)?\b|smoke (alarm|detector) batter/i, + prompt: 'Heads up — this looks like the **9V battery** pitfall.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('Nine Volt usefulness') @@ -482,6 +527,11 @@ const tags: Record = { }, power: { + suggest: { + pattern: + /brown\s?out|not enough (power|current)|voltage drop|how (do i|to) power (my|the|a)|powering (my|the|a) (board|arduino|esp|nano|uno|mega)/i, + prompt: 'Looks like a **powering your board** question.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('Powering an Arduino') @@ -516,6 +566,11 @@ const tags: Record = { }, pullup: { + suggest: { + pattern: + /pull[\s-]?up|pull[\s-]?down|floating (pin|input)|button.*(random|float|noisy)|reads? (randomly|high and low)/i, + prompt: 'Sounds like a **pull-up / floating input** issue.', + }, embeds: [ new EmbedBuilder({ ...universalEmbed }) .setTitle('What does pull-up (or pull-down) mean, and how do I use it?') From 6d809b91ba065b69da64aec0bdc98194aca54f5d Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Fri, 12 Jun 2026 17:57:28 +0000 Subject: [PATCH 17/42] Lengthen stale-post timing; support text + forum help channels - Raise the stale-post defaults so slow helper response doesn't archive live posts: nudge after 72h (was 24h), auto-archive after a further 168h of no human reply (was 72h). Both still env-tunable. - Generalise help-channel handling so entries may be forum OR regular text channels. The thread lifecycle (Mark Solved button, auto-needinfo, stale nudge/auto-archive, /openposts) keys on a thread's parent being a configured help channel, so threads opened inside text help channels now get the same treatment as forum posts. - Add HELP_CHANNEL_IDS as the preferred config, merged with (and superseding the naming of) the legacy HELP_FORUM_CHANNEL_IDS, which still works. - Update wording/docs (config, .env.example, README) to reflect both channel types; note that message-level assists already work server-wide and that thread-less text messages have no archivable lifecycle. Co-authored-by: Claude https://claude.ai/code/session_01G6Q9Af9u4QrPS93f4kgsRJ --- .env.example | 9 ++++-- README.md | 22 ++++++++++----- src/commands/openPosts.ts | 6 ++-- src/interaction-handlers/solvedButton.ts | 2 +- src/listeners/threadCreate.ts | 12 ++++---- src/utils/config.ts | 36 ++++++++++++++++++------ src/utils/helpPosts.ts | 11 ++++---- src/utils/staleHelpSweep.ts | 6 ++-- 8 files changed, 67 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index 6f2b53c..52646c8 100644 --- a/.env.example +++ b/.env.example @@ -19,15 +19,18 @@ MOD_LOG_CHANNEL_ID= # SERVER_UPDATE_NOTIFS_ROLE_ID= # role toggled by the "server_updates" button # --- Helper-assist features --- -# HELP_FORUM_CHANNEL_IDS= # forum channels that get a "Mark Solved" button on new posts (enables A/E/H below) +# Help channels may be FORUM channels (each post is a thread) or regular TEXT +# channels (threads opened inside them get the same treatment). Comma-separated. +# HELP_CHANNEL_IDS= # help channels (forum or text) — enables solve button, auto-needinfo, sweep, /openposts +# HELP_FORUM_CHANNEL_IDS= # legacy alias for HELP_CHANNEL_IDS (still honoured; merged in) # TAG_SUGGEST_ENABLED=true # auto-suggest a relevant tag on common error messages (set false to disable) # CODE_FORMAT_SUGGEST_ENABLED=true # nudge users who paste unformatted code toward the codeblock tag # ASK_SUGGEST_ENABLED=true # nudge "can I ask?" / "anyone here?" non-questions toward the ask tag # HELP_AUTO_NEEDINFO=true # auto-post the needinfo checklist when a new help post is too thin # HELP_NEEDINFO_MIN_CHARS=60 # opening post shorter than this (and without code/image) counts as thin # HELP_STALE_SWEEP_ENABLED=true # nudge + auto-archive abandoned help posts -# HELP_STALE_NUDGE_HOURS=24 # idle hours before a "still need help?" nudge -# HELP_STALE_ARCHIVE_HOURS=72 # idle hours after a nudge (no human reply) before auto-archiving +# HELP_STALE_NUDGE_HOURS=72 # idle hours before a "still need help?" nudge (default 3 days) +# HELP_STALE_ARCHIVE_HOURS=168 # idle hours after a nudge (no human reply) before auto-archiving (default 7 days) # --- Persistence (optional) --- # When unset, the bot runs fully in-memory and the spam-image blocklist diff --git a/README.md b/README.md index 849f841..fff2251 100644 --- a/README.md +++ b/README.md @@ -185,17 +185,25 @@ the most questions: here?") with the `ask` tag. Conservative patterns; toggle `ASK_SUGGEST_ENABLED`. - **"Request more info" context-menu** — right-click any message → *Request more info* to post the `needinfo` checklist to the asker in one click. -- **Auto-needinfo on thin posts** — a new help-forum post with no code, image, - or detail auto-gets the `needinfo` checklist (`HELP_AUTO_NEEDINFO`). -- **Solve workflow** — a **Mark Solved** button on new help-forum posts (set - `HELP_FORUM_CHANNEL_IDS`) plus `/solved [helper:@user]`, which closes the post - and credits whoever helped. +- **Auto-needinfo on thin posts** — a new help thread with no code, image, or + detail auto-gets the `needinfo` checklist (`HELP_AUTO_NEEDINFO`). +- **Solve workflow** — a **Mark Solved** button on new help threads (set + `HELP_CHANNEL_IDS`) plus `/solved [helper:@user]`, which closes the post and + credits whoever helped. - **Stale-post nudge & auto-archive** — abandoned help posts get a "still need - help?" nudge after a day, then auto-archive if no one replies - (`HELP_STALE_NUDGE_HOURS` / `HELP_STALE_ARCHIVE_HOURS`). + help?" nudge after a few days, then auto-archive only if no one replies for a + while longer (defaults 3 then 7 days — helpers often take a while; tune with + `HELP_STALE_NUDGE_HOURS` / `HELP_STALE_ARCHIVE_HOURS`). - **`/openposts`** — an ephemeral digest of open help posts, oldest-waiting first, so helpers can pick up whatever's been waiting longest. +> **Help channels can be forum *or* text channels.** List both kinds in +> `HELP_CHANNEL_IDS` — forum posts and threads opened inside text help channels +> get the same Mark-Solved / needinfo / stale-sweep / `/openposts` treatment. +> The keyword/code/ask suggestions and *Request more info* work server-wide +> regardless of channel type. (Plain, thread-less messages in a text channel +> can't be archived, so the thread lifecycle simply doesn't apply to them.) + ## 🏗️ Project structure ``` diff --git a/src/commands/openPosts.ts b/src/commands/openPosts.ts index ab057a8..0ed96ae 100644 --- a/src/commands/openPosts.ts +++ b/src/commands/openPosts.ts @@ -5,7 +5,7 @@ import { TimestampStyles, time, } from 'discord.js'; -import { helpForumChannelIds } from '../utils/config'; +import { helpChannelIds } from '../utils/config'; import { fetchOpenHelpPosts } from '../utils/helpPosts'; import universalEmbed from '../utils/embed'; @@ -39,9 +39,9 @@ export class OpenPostsCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - if (helpForumChannelIds.length === 0) + if (helpChannelIds.length === 0) return interaction.reply({ - content: 'No help forums are configured.', + content: 'No help channels are configured.', flags: MessageFlags.Ephemeral, }); diff --git a/src/interaction-handlers/solvedButton.ts b/src/interaction-handlers/solvedButton.ts index 6d4a4a2..b31522a 100644 --- a/src/interaction-handlers/solvedButton.ts +++ b/src/interaction-handlers/solvedButton.ts @@ -14,7 +14,7 @@ import { import universalEmbed from '../utils/embed'; import { applySolved, canMarkSolved } from '../utils/solveThread'; -/** Handles the "Mark Solved" button posted in help-forum threads. */ +/** Handles the "Mark Solved" button posted in help threads (forum or text). */ export class SolvedButtonHandler extends InteractionHandler { public constructor( context: InteractionHandler.LoaderContext, diff --git a/src/listeners/threadCreate.ts b/src/listeners/threadCreate.ts index 8d83947..d6e6d0a 100644 --- a/src/listeners/threadCreate.ts +++ b/src/listeners/threadCreate.ts @@ -6,18 +6,19 @@ import { EmbedBuilder, type AnyThreadChannel, } from 'discord.js'; -import { helpForumChannelIds, helpAssistConfig } from '../utils/config'; +import { helpChannelIds, helpAssistConfig } from '../utils/config'; import { resolveTag } from '../utils/resolveTag'; import universalEmbed from '../utils/embed'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** - * On a new post in a configured help forum: + * On a new help thread — a forum post, or a thread opened inside a text help + * channel — this: * - posts a "Mark Solved" button so the asker can close it in one click, and * - if the opening post is too thin (short, no code, no image), auto-posts the * `needinfo` checklist so helpers don't have to ask for the basics. - * Disabled unless HELP_FORUM_CHANNEL_IDS is set. + * Disabled unless a help channel is configured (HELP_CHANNEL_IDS). */ export class ThreadCreateListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { @@ -26,9 +27,8 @@ export class ThreadCreateListener extends Listener { public async run(thread: AnyThreadChannel, newlyCreated: boolean) { if (!newlyCreated) return; - if (helpForumChannelIds.length === 0) return; - if (!thread.parentId || !helpForumChannelIds.includes(thread.parentId)) - return; + if (helpChannelIds.length === 0) return; + if (!thread.parentId || !helpChannelIds.includes(thread.parentId)) return; const embed = new EmbedBuilder(universalEmbed).setDescription( 'When your question is answered, the original poster or a moderator can click **Mark Solved** to close this post. Use `/solved helper:@user` to also thank whoever helped. 🛠️' diff --git a/src/utils/config.ts b/src/utils/config.ts index 6b02048..788c784 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -47,10 +47,21 @@ const idList = (value: string | undefined): string[] => export const crosspostChannelIds = idList(process.env.CROSSPOST_CHANNEL_IDS); /** - * Forum channels treated as "help" forums: new posts get a "Mark Solved" button. - * The /solved command works in any thread regardless of this list. + * Channels treated as "help" channels. Entries may be **forum** channels (each + * post is a thread) or **regular text** channels (threads opened inside them get + * the same treatment): new help threads get a "Mark Solved" button and, when + * thin, the needinfo checklist, and the stale-post sweep / `/openposts` track + * them. The /solved command works in any thread regardless of this list. + * + * Reads HELP_CHANNEL_IDS (preferred) and the legacy HELP_FORUM_CHANNEL_IDS, + * merged and de-duplicated, so existing configs keep working. */ -export const helpForumChannelIds = idList(process.env.HELP_FORUM_CHANNEL_IDS); +export const helpChannelIds = [ + ...new Set([ + ...idList(process.env.HELP_CHANNEL_IDS), + ...idList(process.env.HELP_FORUM_CHANNEL_IDS), + ]), +]; /** Whether the keyword -> tag auto-suggester is active (on unless "false"). */ export const tagSuggestEnabled = process.env.TAG_SUGGEST_ENABLED !== 'false'; @@ -63,8 +74,8 @@ export const codeFormatSuggestEnabled = export const askSuggestEnabled = process.env.ASK_SUGGEST_ENABLED !== 'false'; /** - * Help-forum quality-of-life knobs. The auto-needinfo and stale-post sweep only - * do anything when `helpForumChannelIds` is configured. + * Help-channel quality-of-life knobs. The auto-needinfo and stale-post sweep + * only do anything when `helpChannelIds` is configured. */ export const helpAssistConfig = { /** Auto-post the needinfo checklist when a new help post is too thin. */ @@ -73,10 +84,17 @@ export const helpAssistConfig = { needinfoMinChars: posInt(process.env.HELP_NEEDINFO_MIN_CHARS, 60), /** Whether the stale-post nudge/auto-archive sweep runs. */ staleSweepEnabled: process.env.HELP_STALE_SWEEP_ENABLED !== 'false', - /** Idle time (ms) before an open help post gets a "still need help?" nudge. */ - staleNudgeMs: posInt(process.env.HELP_STALE_NUDGE_HOURS, 24) * 60 * 60 * 1000, - /** Idle time (ms) after a nudge, with no human reply, before auto-archiving. */ - staleArchiveMs: posInt(process.env.HELP_STALE_ARCHIVE_HOURS, 72) * 60 * 60 * 1000, + /** + * Idle time (ms) before an open help post gets a "still need help?" nudge. + * Default 72h (3 days) — helpers often take a while to reach a post. + */ + staleNudgeMs: posInt(process.env.HELP_STALE_NUDGE_HOURS, 72) * 60 * 60 * 1000, + /** + * Idle time (ms) after a nudge, with no human reply, before auto-archiving. + * Default 168h (7 days) on top of the nudge wait, so nothing is archived from + * under a slow-but-active conversation. + */ + staleArchiveMs: posInt(process.env.HELP_STALE_ARCHIVE_HOURS, 168) * 60 * 60 * 1000, }; /** diff --git a/src/utils/helpPosts.ts b/src/utils/helpPosts.ts index 332bdcb..09f0543 100644 --- a/src/utils/helpPosts.ts +++ b/src/utils/helpPosts.ts @@ -1,5 +1,5 @@ import type { Client, ThreadChannel } from 'discord.js'; -import { SERVER_ID, helpForumChannelIds } from './config'; +import { SERVER_ID, helpChannelIds } from './config'; import { SOLVED_PREFIX } from './solveThread'; export interface OpenHelpPost { @@ -16,13 +16,14 @@ export const isSolved = (thread: ThreadChannel): boolean => /** * Find the currently-open (active, unsolved) posts across the configured help - * forums, annotated with last-activity info. Shared by the stale-post sweep and - * the `/openposts` digest. Best-effort: per-thread fetch failures are skipped. + * channels, annotated with last-activity info. Works for threads under both + * forum and text help channels. Shared by the stale-post sweep and the + * `/openposts` digest. Best-effort: per-thread fetch failures are skipped. */ export async function fetchOpenHelpPosts( client: Client ): Promise { - if (helpForumChannelIds.length === 0) return []; + if (helpChannelIds.length === 0) return []; const guild = await client.guilds.fetch(SERVER_ID).catch(() => null); if (!guild) return []; @@ -32,7 +33,7 @@ export async function fetchOpenHelpPosts( const posts: OpenHelpPost[] = []; for (const thread of active.threads.values()) { - if (!thread.parentId || !helpForumChannelIds.includes(thread.parentId)) + if (!thread.parentId || !helpChannelIds.includes(thread.parentId)) continue; if (thread.archived || isSolved(thread)) continue; diff --git a/src/utils/staleHelpSweep.ts b/src/utils/staleHelpSweep.ts index 5aaed2e..3a85f54 100644 --- a/src/utils/staleHelpSweep.ts +++ b/src/utils/staleHelpSweep.ts @@ -6,7 +6,7 @@ import { type Client, } from 'discord.js'; import { container } from '@sapphire/framework'; -import { helpForumChannelIds, helpAssistConfig } from './config'; +import { helpChannelIds, helpAssistConfig } from './config'; import { fetchOpenHelpPosts } from './helpPosts'; import universalEmbed from './embed'; @@ -63,13 +63,13 @@ async function sweepOnce(client: Client): Promise { } /** - * Start the periodic stale-help-post sweep. No-op unless help forums are + * Start the periodic stale-help-post sweep. No-op unless help channels are * configured and the sweep is enabled. The interval is unref'd so it never * keeps the process alive on its own. */ export function startStaleHelpSweep(client: Client): void { if (!helpAssistConfig.staleSweepEnabled) return; - if (helpForumChannelIds.length === 0) return; + if (helpChannelIds.length === 0) return; const run = () => sweepOnce(client).catch((error) => From fd89c4043bdc4637ace7100f52f28d2adef4dac0 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Mon, 15 Jun 2026 01:01:04 -0500 Subject: [PATCH 18/42] feat: replace dHash with DCT-based pHash for image spam detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the perceptual hash algorithm from dHash (pixel-brightness differences) to pHash (2-D DCT on a 32×32 greyscale thumbnail, top-left 8×8 low-frequency block compared against its mean). pHash operates in the frequency domain and is significantly more robust against re-encoding, recompression, watermarking, and minor crops — the exact transformations spammers use to evade exact-hash blocklists. The output format (16 hex chars, 64-bit Hamming-comparable hash) and all downstream consumers (tracker, blocklist, incidents, DB schema) are unchanged. Cosine values are precomputed at module load so per-image cost is multiplications only. Thumbnail request bumped to 64×64 so Discord's proxy handles the expensive initial downscale before Jimp resizes to 32×32. Co-Authored-By: Claude Sonnet 4.6 --- src/utils/automod/phash.ts | 111 ++++++++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/src/utils/automod/phash.ts b/src/utils/automod/phash.ts index 1275110..7df62e5 100644 --- a/src/utils/automod/phash.ts +++ b/src/utils/automod/phash.ts @@ -3,37 +3,97 @@ import type { Attachment, Message } from 'discord.js'; import { container } from '@sapphire/framework'; import { isImageAttachment } from './signature'; -// dHash works on a (W+1) x H greyscale image, comparing each pixel to its right -// neighbour to produce W*H = 64 bits. -const HASH_W = 9; -const HASH_H = 8; +// pHash parameters: resize to DCT_INPUT_SIZE×DCT_INPUT_SIZE, then keep the +// top-left DCT_COEFF_SIZE×DCT_COEFF_SIZE low-frequency block (64 bits). +const DCT_INPUT_SIZE = 32; +const DCT_COEFF_SIZE = 8; /** Max image attachments per message we will fetch + hash. */ const MAX_ATTACHMENTS = 4; -/** Skip attachments larger than this; the thumbnail proxy handles the rest. */ -const MAX_BYTES = 12 * 1024 * 1024; /** Per-fetch timeout. */ const FETCH_TIMEOUT_MS = 4000; /** - * Difference hash (dHash) of an image, as 16 hex chars (64 bits). Near-identical - * images — including re-encoded, recompressed, or lightly resized copies — - * produce hashes a small Hamming distance apart, which exact byte/metadata - * signatures cannot detect. + * Pre-computed cosine table for the 1-D DCT: + * cosTable[k][n] = cos(π/N * (n + 0.5) * k) for N = DCT_INPUT_SIZE + * Computed once at module load so per-image cost is multiplications only. */ -export async function dHashFromBuffer(buffer: Buffer): Promise { +const cosTable: number[][] = Array.from({ length: DCT_INPUT_SIZE }, (_, k) => + Array.from({ length: DCT_INPUT_SIZE }, (__, n) => + Math.cos((Math.PI / DCT_INPUT_SIZE) * (n + 0.5) * k) + ) +); + +/** 1-D DCT-II applied to a signal of length DCT_INPUT_SIZE. */ +function dct1d(signal: number[]): number[] { + return Array.from({ length: DCT_INPUT_SIZE }, (_, k) => { + let sum = 0; + for (let n = 0; n < DCT_INPUT_SIZE; n++) sum += signal[n] * cosTable[k][n]; + return sum; + }); +} + +/** + * 2-D DCT via two separable passes of the 1-D DCT: first across rows, then + * down columns. Input/output are row-major flat arrays of DCT_INPUT_SIZE². + */ +function dct2d(pixels: number[]): number[] { + const N = DCT_INPUT_SIZE; + + // Pass 1: DCT each row + const tmp = new Array(N * N); + for (let r = 0; r < N; r++) { + const row = pixels.slice(r * N, r * N + N); + const t = dct1d(row); + for (let c = 0; c < N; c++) tmp[r * N + c] = t[c]; + } + + // Pass 2: DCT each column + const out = new Array(N * N); + for (let c = 0; c < N; c++) { + const col = Array.from({ length: N }, (_, r) => tmp[r * N + c]); + const t = dct1d(col); + for (let r = 0; r < N; r++) out[r * N + c] = t[r]; + } + + return out; +} + +/** + * Perceptual hash (pHash) of an image, as 16 hex chars (64 bits). + * + * 1. Resize to 32×32 and greyscale. + * 2. Compute 2-D DCT. + * 3. Extract the top-left 8×8 low-frequency block (64 coefficients). + * 4. Compute the mean of those values. + * 5. Bit i = 1 if coefficient i > mean, else 0. + * + * Near-identical images — re-encoded, recompressed, watermarked, or lightly + * resized/cropped — produce hashes with a small Hamming distance, which exact + * metadata signatures cannot detect. + */ +export async function pHashFromBuffer(buffer: Buffer): Promise { const image = await Jimp.read(buffer); - image.resize({ w: HASH_W, h: HASH_H }).greyscale(); + image.resize({ w: DCT_INPUT_SIZE, h: DCT_INPUT_SIZE }).greyscale(); - let bits = ''; - for (let y = 0; y < HASH_H; y++) - for (let x = 0; x < HASH_W - 1; x++) { - const left = intToRGBA(image.getPixelColor(x, y)).r; - const right = intToRGBA(image.getPixelColor(x + 1, y)).r; - bits += left < right ? '1' : '0'; - } + const pixels: number[] = []; + for (let y = 0; y < DCT_INPUT_SIZE; y++) + for (let x = 0; x < DCT_INPUT_SIZE; x++) + pixels.push(intToRGBA(image.getPixelColor(x, y)).r); + + const dct = dct2d(pixels); + + // Top-left 8×8 low-frequency block + const low: number[] = []; + for (let r = 0; r < DCT_COEFF_SIZE; r++) + for (let c = 0; c < DCT_COEFF_SIZE; c++) + low.push(dct[r * DCT_INPUT_SIZE + c]); - // 64-bit string -> 16 hex chars + const mean = low.reduce((s, v) => s + v, 0) / low.length; + + // 64 bits → 16 hex chars + let bits = ''; + for (const v of low) bits += v > mean ? '1' : '0'; let hex = ''; for (let i = 0; i < bits.length; i += 4) hex += parseInt(bits.slice(i, i + 4), 2).toString(16); @@ -55,13 +115,15 @@ export function hammingDistance(a: string, b: string): number { } /** - * A small thumbnail of the attachment via Discord's media proxy, so we transfer - * and decode a few hundred bytes instead of the full image. + * Discord thumbnail proxy URL for an attachment. We request 64×64 so Discord + * handles the expensive initial downscale and we receive a small buffer; Jimp + * then resizes it further to 32×32 with better antialiasing than starting from + * a 32×32 proxy directly. */ function thumbnailUrl(attachment: Attachment): string { const base = attachment.proxyURL || attachment.url; const separator = base.includes('?') ? '&' : '?'; - return `${base}${separator}width=32&height=32`; + return `${base}${separator}width=64&height=64`; } /** @@ -72,7 +134,6 @@ function thumbnailUrl(attachment: Attachment): string { export async function perceptualHashes(message: Message): Promise { const images = [...message.attachments.values()] .filter(isImageAttachment) - .filter((attachment) => attachment.size <= MAX_BYTES) .slice(0, MAX_ATTACHMENTS); const hashes = await Promise.all( @@ -83,7 +144,7 @@ export async function perceptualHashes(message: Message): Promise { }); if (!response.ok) return null; const buffer = Buffer.from(await response.arrayBuffer()); - return await dHashFromBuffer(buffer); + return await pHashFromBuffer(buffer); } catch (error) { container.logger.debug( `Automod: could not perceptual-hash attachment ${attachment.id}:`, From e80acb45639bd3b13549575480426f35a4529b77 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Mon, 15 Jun 2026 01:04:39 -0500 Subject: [PATCH 19/42] feat: complete docker-compose env coverage and add Dockge setup guide docker-compose.yml was missing every env var added during the revamp branch (all helper-assist, flood/crosspost automod, phash threshold, server-management IDs). Variables omitted from the compose environment block are silently ignored even when present in .env, so none of the new features could be configured without this fix. Also switches the migration command from `npx prisma` to `node_modules/.bin/prisma` to avoid a network lookup in the container. SETUP.md is a step-by-step Dockge deployment guide covering Discord application setup, privileged intents, ID collection, cloning to /opt/stacks, .env configuration, and a full env-var reference table grouped by feature. Co-Authored-By: Claude Sonnet 4.6 --- SETUP.md | 215 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 57 ++++++++++-- 2 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 SETUP.md diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..927c764 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,215 @@ +# Setup Guide + +Step-by-step deployment on a Dockge server. + +--- + +## 1. Create the Discord application + +1. Go to [discord.com/developers/applications](https://discord.com/developers/applications) → **New Application**. +2. Name it (e.g. *Arduino Bot*) → **Create**. +3. In the left sidebar → **Bot**: + - Click **Reset Token** → copy it. This is your `BOT_TOKEN`. Keep it secret. + - Under **Privileged Gateway Intents**, enable: + - **Server Members Intent** (required for join/leave logging and new-member detection) + - **Message Content Intent** (required for tag suggestions, automod, and help-post detection) +4. In the left sidebar → **OAuth2 → URL Generator**: + - Scopes: `bot`, `applications.commands` + - Bot permissions: `Manage Messages`, `Moderate Members`, `Ban Members`, `Send Messages`, `Embed Links`, `Read Message History`, `View Channels` + - Copy the generated URL, paste it in a browser, and invite the bot to your server. + +> **Manage Server permission** is also needed if you want invite-source logging (`JOIN_LEAVE_LOG_CHANNEL_ID`). Add it to the bot permissions above if you plan to use that feature. + +--- + +## 2. Collect your Discord IDs + +Enable Developer Mode: **User Settings → Advanced → Developer Mode**. You can then right-click any server, channel, role, or message to **Copy ID**. + +Gather the IDs you need for the features you want to use: + +| What to copy | Used for | +|---|---| +| Server (guild) ID | `SERVER_ID` | +| Bot commands channel | `BOT_COMMANDS_CHANNEL_ID` | +| Mod-log channel | `MOD_LOG_CHANNEL_ID` (enables automod console) | +| Help channel(s) | `HELP_CHANNEL_IDS` (comma-separated; forum or text) | +| Join/leave log channel | `JOIN_LEAVE_LOG_CHANNEL_ID` | +| Announcement channels | `CROSSPOST_CHANNEL_IDS` (comma-separated) | +| Crosspost log channel | `CROSSPOST_LOG_CHANNEL_ID` | +| Role-select message | `ROLE_SELECT_MESSAGE_ID` | +| Events role | `EVENT_NOTIFS_ROLE_ID` | +| Server-updates role | `SERVER_UPDATE_NOTIFS_ROLE_ID` | +| Mod / immune roles | `AUTOMOD_IMMUNE_ROLE_IDS` (comma-separated) | + +You only need the IDs for features you intend to use. Features self-disable when their ID is left blank. + +--- + +## 3. Get the code onto your server + +SSH into your Dockge host and clone the repo into Dockge's stacks directory: + +```bash +cd /opt/stacks +git clone https://github.com/arduinodiscord/bot.git arduino-bot +cd arduino-bot +git checkout revamp # active development branch +``` + +> Dockge looks for compose files inside `/opt/stacks//`. Cloning there means Dockge can pick up the stack automatically. + +--- + +## 4. Create your `.env` file + +```bash +cp .env.example .env +nano .env # or use your editor of choice +``` + +Fill in at minimum: + +```dotenv +BOT_TOKEN=your_token_here +``` + +Then add the IDs and enable the features you want (see section 2 and the full reference below). + +--- + +## 5. Deploy in Dockge + +1. Open Dockge in your browser (typically `http://:5001`). +2. Click **+ Compose** (or **Scan** if Dockge has already picked up the stack). +3. If creating manually, paste the contents of `docker-compose.yml` into the editor. +4. Dockge will find the `.env` file in the same directory and use it automatically. +5. Click **Deploy**. Dockge will: + - Build the bot image (takes a minute on first run) + - Start Postgres + - Run `prisma migrate deploy` to create the database schema + - Start the bot + +Watch the log output. You should see: +``` +Logged in as Arduino Bot#0000 (...) +Database connection success +Automod: loaded 0 signature(s) and 0 perceptual hash(es) from the blocklist. +``` + +--- + +## 6. Verify it's working + +**Slash commands registered?** +Type `/` in your server — the bot's commands (`/tag`, `/solved`, `/openposts`, `/ping`, `/about`, `/say`) should appear. + +**Automod console active?** +Confirm `MOD_LOG_CHANNEL_ID` is set. Post a test image in two different channels quickly — you should see an alert appear in the mod-log channel within seconds. + +**Help-channel features active?** +Open a new thread in a configured help channel. You should see a "Mark Solved" button appear. Post a short message with no code/image in a new thread — the needinfo checklist should appear automatically. + +**Tag suggestions active?** +Post a message containing `avrdude` or `stk500` in any channel — the bot should reply with a suggestion button. + +--- + +## 7. Updating + +```bash +cd /opt/stacks/arduino-bot +git pull +``` + +Then in Dockge, click **Rebuild** on the stack. The `prisma migrate deploy` step in the startup command applies any new migrations automatically. + +--- + +## Environment variable reference + +All variables are optional except `BOT_TOKEN`. Leaving a variable blank uses the default shown. + +### Core + +| Variable | Default | Description | +|---|---|---| +| `BOT_TOKEN` | — | **Required.** Your bot token | +| `SERVER_ID` | `420594746990526466` | Your server's ID | +| `BOT_COMMANDS_CHANNEL_ID` | `451158319361556491` | Channel where bot-command-only tags are allowed | +| `DATABASE_URL` | auto-set by compose | Set automatically by docker-compose; do not override | + +### Automod console + +| Variable | Default | Description | +|---|---|---| +| `MOD_LOG_CHANNEL_ID` | *(disabled)* | Channel where automod alerts appear; **required to enable automod** | + +### Image-spam automod + +| Variable | Default | Description | +|---|---|---| +| `AUTOMOD_BURST_THRESHOLD` | `3` | Image messages from one user within the window to flag a burst | +| `AUTOMOD_BURST_WINDOW_MS` | `60000` | Window for burst counting (ms) | +| `AUTOMOD_NEW_MEMBER_BURST_THRESHOLD` | `2` | Stricter burst threshold for recently-joined members | +| `AUTOMOD_NEW_MEMBER_WINDOW_MS` | `259200000` | How long a member counts as "new" (72h) | +| `AUTOMOD_FANOUT_CHANNELS` | `2` | Distinct channels the same image must appear in to flag fan-out | +| `AUTOMOD_FANOUT_WINDOW_MS` | `120000` | Window for fan-out detection (ms) | +| `AUTOMOD_PHASH_THRESHOLD` | `6` | Max Hamming distance (of 64) for two images to count as the same | +| `AUTOMOD_TIMEOUT_MS` | `3600000` | Duration of auto-applied or console timeouts (1h) | +| `AUTOMOD_ALERT_COOLDOWN_MS` | `30000` | Minimum gap between alerts for the same user | +| `AUTOMOD_IMMUNE_ROLE_IDS` | *(none)* | Comma-separated role IDs that are never inspected | + +### Text-flooding automod + +| Variable | Default | Description | +|---|---|---| +| `AUTOMOD_FLOOD_ENABLED` | `true` | Set `false` to disable | +| `AUTOMOD_FLOOD_THRESHOLD` | `5` | Short messages in the window to flag flooding | +| `AUTOMOD_FLOOD_WINDOW_MS` | `15000` | Window for flood counting (ms) | +| `AUTOMOD_FLOOD_MAX_CHARS` | `25` | A message is "short" at or below this character count | +| `AUTOMOD_FLOOD_AUTO_TIMEOUT` | `false` | Set `true` to also timeout automatically (default: alert only) | + +### Cross-channel question-spam automod + +| Variable | Default | Description | +|---|---|---| +| `AUTOMOD_CROSSPOST_ENABLED` | `true` | Set `false` to disable | +| `AUTOMOD_CROSSPOST_CHANNELS` | `2` | Distinct channels for a near-identical message to trigger | +| `AUTOMOD_CROSSPOST_SPREAD_CHANNELS` | `3` | Distinct channels for new-member spread detection | +| `AUTOMOD_CROSSPOST_WINDOW_MS` | `120000` | Detection window (ms) | +| `AUTOMOD_CROSSPOST_MIN_CHARS` | `12` | Messages shorter than this are ignored (greetings, reactions) | +| `AUTOMOD_CROSSPOST_SIMILARITY_PCT` | `80` | Token-overlap % at which two messages count as the same question | + +### Helper-assist + +| Variable | Default | Description | +|---|---|---| +| `HELP_CHANNEL_IDS` | *(disabled)* | Comma-separated forum or text channel IDs — enables solve button, auto-needinfo, stale sweep, and `/openposts` | +| `TAG_SUGGEST_ENABLED` | `true` | Keyword → tag auto-suggestion | +| `CODE_FORMAT_SUGGEST_ENABLED` | `true` | Nudge for unformatted code pastes | +| `ASK_SUGGEST_ENABLED` | `true` | Nudge for "can I ask?" / "anyone here?" messages | +| `HELP_AUTO_NEEDINFO` | `true` | Auto-post needinfo checklist on thin help posts | +| `HELP_NEEDINFO_MIN_CHARS` | `60` | Opening post shorter than this (and with no code/image) counts as thin | +| `HELP_STALE_SWEEP_ENABLED` | `true` | Nudge + auto-archive abandoned help posts | +| `HELP_STALE_NUDGE_HOURS` | `72` | Hours idle before a "still need help?" nudge (3 days) | +| `HELP_STALE_ARCHIVE_HOURS` | `168` | Hours after the nudge with no human reply before auto-archiving (7 days) | + +### Server management + +| Variable | Default | Description | +|---|---|---| +| `JOIN_LEAVE_LOG_CHANNEL_ID` | *(disabled)* | Member join/leave + invite-source logging | +| `CROSSPOST_CHANNEL_IDS` | *(disabled)* | Comma-separated announcement channels to auto-publish | +| `CROSSPOST_LOG_CHANNEL_ID` | *(disabled)* | Where auto-crosspost results are logged | +| `ROLE_SELECT_MESSAGE_ID` | *(disabled)* | Message whose buttons toggle opt-in roles | +| `EVENT_NOTIFS_ROLE_ID` | *(disabled)* | Role toggled by the "events" button | +| `SERVER_UPDATE_NOTIFS_ROLE_ID` | *(disabled)* | Role toggled by the "server_updates" button | + +### Postgres (docker-compose only) + +| Variable | Default | Description | +|---|---|---| +| `POSTGRES_USER` | `arduino` | Database user | +| `POSTGRES_PASSWORD` | `arduino` | Database password — **change this** | +| `POSTGRES_DB` | `arduino` | Database name | diff --git a/docker-compose.yml b/docker-compose.yml index 828af7a..b6bdb28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,23 +6,68 @@ services: db: condition: service_healthy environment: - # Pulled from the .env file (see .env.example). DATABASE_URL points at - # the bundled Postgres service below. + # ── Required ────────────────────────────────────────────────────────── BOT_TOKEN: ${BOT_TOKEN} + + # ── Core IDs (defaults target the official Arduino server) ──────────── SERVER_ID: ${SERVER_ID:-} BOT_COMMANDS_CHANNEL_ID: ${BOT_COMMANDS_CHANNEL_ID:-} - MOD_LOG_CHANNEL_ID: ${MOD_LOG_CHANNEL_ID:-} + + # ── Database (wired to the bundled Postgres service) ────────────────── DATABASE_URL: postgresql://${POSTGRES_USER:-arduino}:${POSTGRES_PASSWORD:-arduino}@db:5432/${POSTGRES_DB:-arduino} - # Optional automod tunables (defaults live in src/utils/config.ts) + + # ── Automod console ─────────────────────────────────────────────────── + MOD_LOG_CHANNEL_ID: ${MOD_LOG_CHANNEL_ID:-} + + # ── Server-management features ──────────────────────────────────────── + JOIN_LEAVE_LOG_CHANNEL_ID: ${JOIN_LEAVE_LOG_CHANNEL_ID:-} + CROSSPOST_CHANNEL_IDS: ${CROSSPOST_CHANNEL_IDS:-} + CROSSPOST_LOG_CHANNEL_ID: ${CROSSPOST_LOG_CHANNEL_ID:-} + ROLE_SELECT_MESSAGE_ID: ${ROLE_SELECT_MESSAGE_ID:-} + EVENT_NOTIFS_ROLE_ID: ${EVENT_NOTIFS_ROLE_ID:-} + SERVER_UPDATE_NOTIFS_ROLE_ID: ${SERVER_UPDATE_NOTIFS_ROLE_ID:-} + + # ── Helper-assist features ──────────────────────────────────────────── + HELP_CHANNEL_IDS: ${HELP_CHANNEL_IDS:-} + HELP_FORUM_CHANNEL_IDS: ${HELP_FORUM_CHANNEL_IDS:-} + TAG_SUGGEST_ENABLED: ${TAG_SUGGEST_ENABLED:-} + CODE_FORMAT_SUGGEST_ENABLED: ${CODE_FORMAT_SUGGEST_ENABLED:-} + ASK_SUGGEST_ENABLED: ${ASK_SUGGEST_ENABLED:-} + HELP_AUTO_NEEDINFO: ${HELP_AUTO_NEEDINFO:-} + HELP_NEEDINFO_MIN_CHARS: ${HELP_NEEDINFO_MIN_CHARS:-} + HELP_STALE_SWEEP_ENABLED: ${HELP_STALE_SWEEP_ENABLED:-} + HELP_STALE_NUDGE_HOURS: ${HELP_STALE_NUDGE_HOURS:-} + HELP_STALE_ARCHIVE_HOURS: ${HELP_STALE_ARCHIVE_HOURS:-} + + # ── Image-spam automod tunables ─────────────────────────────────────── AUTOMOD_BURST_THRESHOLD: ${AUTOMOD_BURST_THRESHOLD:-} AUTOMOD_BURST_WINDOW_MS: ${AUTOMOD_BURST_WINDOW_MS:-} AUTOMOD_FANOUT_CHANNELS: ${AUTOMOD_FANOUT_CHANNELS:-} AUTOMOD_FANOUT_WINDOW_MS: ${AUTOMOD_FANOUT_WINDOW_MS:-} + AUTOMOD_PHASH_THRESHOLD: ${AUTOMOD_PHASH_THRESHOLD:-} AUTOMOD_TIMEOUT_MS: ${AUTOMOD_TIMEOUT_MS:-} AUTOMOD_ALERT_COOLDOWN_MS: ${AUTOMOD_ALERT_COOLDOWN_MS:-} AUTOMOD_IMMUNE_ROLE_IDS: ${AUTOMOD_IMMUNE_ROLE_IDS:-} - # Apply any pending Prisma migrations, then start the bot. - command: sh -c "npx prisma migrate deploy && npm start" + AUTOMOD_NEW_MEMBER_WINDOW_MS: ${AUTOMOD_NEW_MEMBER_WINDOW_MS:-} + AUTOMOD_NEW_MEMBER_BURST_THRESHOLD: ${AUTOMOD_NEW_MEMBER_BURST_THRESHOLD:-} + + # ── Text-flooding automod tunables ──────────────────────────────────── + AUTOMOD_FLOOD_ENABLED: ${AUTOMOD_FLOOD_ENABLED:-} + AUTOMOD_FLOOD_THRESHOLD: ${AUTOMOD_FLOOD_THRESHOLD:-} + AUTOMOD_FLOOD_WINDOW_MS: ${AUTOMOD_FLOOD_WINDOW_MS:-} + AUTOMOD_FLOOD_MAX_CHARS: ${AUTOMOD_FLOOD_MAX_CHARS:-} + AUTOMOD_FLOOD_AUTO_TIMEOUT: ${AUTOMOD_FLOOD_AUTO_TIMEOUT:-} + + # ── Cross-channel question-spam automod tunables ────────────────────── + AUTOMOD_CROSSPOST_ENABLED: ${AUTOMOD_CROSSPOST_ENABLED:-} + AUTOMOD_CROSSPOST_CHANNELS: ${AUTOMOD_CROSSPOST_CHANNELS:-} + AUTOMOD_CROSSPOST_SPREAD_CHANNELS: ${AUTOMOD_CROSSPOST_SPREAD_CHANNELS:-} + AUTOMOD_CROSSPOST_WINDOW_MS: ${AUTOMOD_CROSSPOST_WINDOW_MS:-} + AUTOMOD_CROSSPOST_MIN_CHARS: ${AUTOMOD_CROSSPOST_MIN_CHARS:-} + AUTOMOD_CROSSPOST_SIMILARITY_PCT: ${AUTOMOD_CROSSPOST_SIMILARITY_PCT:-} + + # Migrate the database schema, then start the bot. + command: sh -c "node_modules/.bin/prisma migrate deploy && npm start" db: image: postgres:16-alpine From 74effe818adda864fb0c41f30b34aba34d6ad75b Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Mon, 15 Jun 2026 01:43:47 -0500 Subject: [PATCH 20/42] feat: harden helper-assist for community rollout Tag/code/ask suggestions: - Never fire for members with any server role (Trusted and above). Since all roles are granted manually, role presence reliably identifies recognised community members who don't need suggestions. - Add SUGGEST_IGNORE_CHANNEL_IDS (comma-separated) to suppress suggestions in specific channels or entire forum hierarchies (staff channels, etc.). Auto-needinfo (HELP_AUTO_NEEDINFO): - Default flipped to false. Must be explicitly opted in. - Thin-post check now includes the thread title in the character count, adds URL and inline-code detection as substantive-content signals, and raises the combined threshold to 120 chars. Any of code block, inline code, attachment, or URL bypasses needinfo regardless of length. - When needinfo fires, the checklist and Mark Solved button are sent as a single message (was two). The checklist text is a short inline version; the full /tag needinfo content used by helpers is unchanged. HELP_NEEDINFO_MIN_CHARS default raised from 60 (body only) to 120 (title + body combined) to reflect the broader check. docker-compose, .env.example, README, and SETUP updated. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 ++ README.md | 19 ++++++-- SETUP.md | 11 ++--- docker-compose.yml | 1 + src/listeners/tagSuggest.ts | 15 +++++++ src/listeners/threadCreate.ts | 85 ++++++++++++++++++++++------------- src/utils/config.ts | 18 ++++++-- 7 files changed, 109 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 52646c8..b5228bc 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ MOD_LOG_CHANNEL_ID= # SERVER_UPDATE_NOTIFS_ROLE_ID= # role toggled by the "server_updates" button # --- Helper-assist features --- +# Channels where tag/code/ask suggestions are suppressed (staff channels, +# announcements, etc.). Comma-separated channel IDs. For a forum channel, +# listing the parent channel ID silences all its threads. +# SUGGEST_IGNORE_CHANNEL_IDS= # Help channels may be FORUM channels (each post is a thread) or regular TEXT # channels (threads opened inside them get the same treatment). Comma-separated. # HELP_CHANNEL_IDS= # help channels (forum or text) — enables solve button, auto-needinfo, sweep, /openposts diff --git a/README.md b/README.md index fff2251..e37215c 100644 --- a/README.md +++ b/README.md @@ -179,14 +179,24 @@ the most questions: HID, debounce…), the bot offers the matching tag via a single button, so askers self-serve before a helper repeats a canned answer. Each trigger lives next to its tag in `tags.ts`. Per-user cooldown; toggle with `TAG_SUGGEST_ENABLED`. + **Only fires for members with no server role** — helpers, knowledgeable members, + and any other role-holder are never shown suggestions. - **Unformatted-code nudge** — detects code pasted as plain text and offers the `codeblock` tag, the single most-repeated ask. Toggle `CODE_FORMAT_SUGGEST_ENABLED`. + Subject to the same role exemption as keyword suggestions. - **"Just ask" nudge** — replies to low-effort pings ("can I ask?", "anyone here?") with the `ask` tag. Conservative patterns; toggle `ASK_SUGGEST_ENABLED`. + Subject to the same role exemption as keyword suggestions. +- **Suggestion ignore list** — set `SUGGEST_IGNORE_CHANNEL_IDS` to suppress all + three suggestions in specific channels (e.g. staff channels). Listing a forum + channel's ID silences all its threads. - **"Request more info" context-menu** — right-click any message → *Request more info* to post the `needinfo` checklist to the asker in one click. -- **Auto-needinfo on thin posts** — a new help thread with no code, image, or - detail auto-gets the `needinfo` checklist (`HELP_AUTO_NEEDINFO`). +- **Auto-needinfo on thin posts** — when a new help thread lacks substance (no + code, image, URL, or inline code, and the post title + body together are under + `HELP_NEEDINFO_MIN_CHARS` characters), the bot sends a concise checklist and the + Mark Solved button in a single message. **Off by default** (`HELP_AUTO_NEEDINFO=true` + to enable). The full `/tag needinfo` checklist used by helpers is unaffected. - **Solve workflow** — a **Mark Solved** button on new help threads (set `HELP_CHANNEL_IDS`) plus `/solved [helper:@user]`, which closes the post and credits whoever helped. @@ -201,8 +211,9 @@ the most questions: > `HELP_CHANNEL_IDS` — forum posts and threads opened inside text help channels > get the same Mark-Solved / needinfo / stale-sweep / `/openposts` treatment. > The keyword/code/ask suggestions and *Request more info* work server-wide -> regardless of channel type. (Plain, thread-less messages in a text channel -> can't be archived, so the thread lifecycle simply doesn't apply to them.) +> regardless of channel type (subject to role exemptions and ignore lists). +> Plain, thread-less messages in a text channel can't be archived, so the thread +> lifecycle simply doesn't apply to them. ## 🏗️ Project structure diff --git a/SETUP.md b/SETUP.md index 927c764..ede48a8 100644 --- a/SETUP.md +++ b/SETUP.md @@ -186,11 +186,12 @@ All variables are optional except `BOT_TOKEN`. Leaving a variable blank uses the | Variable | Default | Description | |---|---|---| | `HELP_CHANNEL_IDS` | *(disabled)* | Comma-separated forum or text channel IDs — enables solve button, auto-needinfo, stale sweep, and `/openposts` | -| `TAG_SUGGEST_ENABLED` | `true` | Keyword → tag auto-suggestion | -| `CODE_FORMAT_SUGGEST_ENABLED` | `true` | Nudge for unformatted code pastes | -| `ASK_SUGGEST_ENABLED` | `true` | Nudge for "can I ask?" / "anyone here?" messages | -| `HELP_AUTO_NEEDINFO` | `true` | Auto-post needinfo checklist on thin help posts | -| `HELP_NEEDINFO_MIN_CHARS` | `60` | Opening post shorter than this (and with no code/image) counts as thin | +| `TAG_SUGGEST_ENABLED` | `true` | Keyword → tag auto-suggestion (never fires for members with any server role) | +| `CODE_FORMAT_SUGGEST_ENABLED` | `true` | Nudge for unformatted code pastes (same role exemption) | +| `ASK_SUGGEST_ENABLED` | `true` | Nudge for "can I ask?" / "anyone here?" messages (same role exemption) | +| `SUGGEST_IGNORE_CHANNEL_IDS` | *(none)* | Comma-separated channel IDs where suggestions are suppressed entirely (e.g. staff channels). Listing a forum channel's ID silences all its threads. | +| `HELP_AUTO_NEEDINFO` | `false` | Set `true` to auto-post a concise checklist when a new help post is thin. Off by default — enable after observing the Mark Solved rollout. | +| `HELP_NEEDINFO_MIN_CHARS` | `120` | Combined character count (thread title + post body) below which a post counts as thin. Posts with a code block, inline code, image, or URL are never considered thin. | | `HELP_STALE_SWEEP_ENABLED` | `true` | Nudge + auto-archive abandoned help posts | | `HELP_STALE_NUDGE_HOURS` | `72` | Hours idle before a "still need help?" nudge (3 days) | | `HELP_STALE_ARCHIVE_HOURS` | `168` | Hours after the nudge with no human reply before auto-archiving (7 days) | diff --git a/docker-compose.yml b/docker-compose.yml index b6bdb28..edef0b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: SERVER_UPDATE_NOTIFS_ROLE_ID: ${SERVER_UPDATE_NOTIFS_ROLE_ID:-} # ── Helper-assist features ──────────────────────────────────────────── + SUGGEST_IGNORE_CHANNEL_IDS: ${SUGGEST_IGNORE_CHANNEL_IDS:-} HELP_CHANNEL_IDS: ${HELP_CHANNEL_IDS:-} HELP_FORUM_CHANNEL_IDS: ${HELP_FORUM_CHANNEL_IDS:-} TAG_SUGGEST_ENABLED: ${TAG_SUGGEST_ENABLED:-} diff --git a/src/listeners/tagSuggest.ts b/src/listeners/tagSuggest.ts index f613e7d..97da9a8 100644 --- a/src/listeners/tagSuggest.ts +++ b/src/listeners/tagSuggest.ts @@ -11,6 +11,7 @@ import { tagSuggestEnabled, codeFormatSuggestEnabled, askSuggestEnabled, + suggestIgnoreChannelIds, } from '../utils/config'; import tags, { type Tag, type TagSuggestion } from '../utils/tags'; import universalEmbed from '../utils/embed'; @@ -108,6 +109,20 @@ export class TagSuggestListener extends Listener { if (message.guildId !== SERVER_ID) return; if (message.content.length < 10) return; + // Members with any server role (Trusted and above) are recognised community + // members — helpers, knowledgeable members, staff — who don't need suggestions. + if ((message.member?.roles.cache.size ?? 1) > 1) return; + + // Respect the ignore-channel list. Check both the message's channel and, + // for threads, the parent channel so an entire forum can be suppressed. + if (suggestIgnoreChannelIds.length > 0) { + if (suggestIgnoreChannelIds.includes(message.channelId)) return; + const parentId = message.channel.isThread() + ? message.channel.parentId + : null; + if (parentId && suggestIgnoreChannelIds.includes(parentId)) return; + } + const suggestion = detect(message.content); if (!suggestion) return; diff --git a/src/listeners/threadCreate.ts b/src/listeners/threadCreate.ts index d6e6d0a..357f41c 100644 --- a/src/listeners/threadCreate.ts +++ b/src/listeners/threadCreate.ts @@ -7,17 +7,36 @@ import { type AnyThreadChannel, } from 'discord.js'; import { helpChannelIds, helpAssistConfig } from '../utils/config'; -import { resolveTag } from '../utils/resolveTag'; import universalEmbed from '../utils/embed'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// Concise auto-needinfo text. Kept inline (not in tags.ts) so the full +// /tag needinfo content — used by helpers explicitly — stays unchanged. +const AUTO_NEEDINFO_TEXT = [ + '**To help us help you, please share:**', + '• What you want it to do vs. what\'s actually happening', + '• A photo of your project and a wiring diagram', + '• Your code in a **code block** (\\`\\`\\`)', + '• Any error messages', + '', + 'Once your question is answered, click **Mark Solved** below. 🛠️', +].join('\n'); + +const SOLVED_ONLY_TEXT = + 'When your question is answered, the original poster or a moderator can click **Mark Solved** to close this post. Use `/solved helper:@user` to also thank whoever helped. 🛠️'; + +const solvedRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('solved') + .setLabel('✅ Mark Solved') + .setStyle(ButtonStyle.Success) +); + /** * On a new help thread — a forum post, or a thread opened inside a text help - * channel — this: - * - posts a "Mark Solved" button so the asker can close it in one click, and - * - if the opening post is too thin (short, no code, no image), auto-posts the - * `needinfo` checklist so helpers don't have to ask for the basics. + * channel — sends a single message with the Mark Solved button and, when the + * opening post is thin, an inline needinfo checklist in the same embed. * Disabled unless a help channel is configured (HELP_CHANNEL_IDS). */ export class ThreadCreateListener extends Listener { @@ -30,45 +49,47 @@ export class ThreadCreateListener extends Listener { if (helpChannelIds.length === 0) return; if (!thread.parentId || !helpChannelIds.includes(thread.parentId)) return; + const thin = + helpAssistConfig.autoNeedinfo && (await this.isThinPost(thread)); + const embed = new EmbedBuilder(universalEmbed).setDescription( - 'When your question is answered, the original poster or a moderator can click **Mark Solved** to close this post. Use `/solved helper:@user` to also thank whoever helped. 🛠️' - ); - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('solved') - .setLabel('✅ Mark Solved') - .setStyle(ButtonStyle.Success) + thin ? AUTO_NEEDINFO_TEXT : SOLVED_ONLY_TEXT ); - await thread.send({ embeds: [embed], components: [row] }).catch(() => null); - - if (helpAssistConfig.autoNeedinfo) await this.maybeRequestInfo(thread); + await thread + .send({ embeds: [embed], components: [solvedRow] }) + .catch(() => null); } - /** Post the needinfo checklist when the opening message lacks substance. */ - private async maybeRequestInfo(thread: AnyThreadChannel): Promise { - // The starter message can lag a moment behind ThreadCreate for forum posts. + /** + * Returns true when the opening post lacks enough substance to get meaningful + * help without prompting. Errs strongly on the side of NOT firing: + * + * - Combines the thread title (forum post title) with the body length, so a + * descriptive title alone can clear the threshold. + * - A code block, inline code, an image attachment, or any URL counts as + * substantive content regardless of character count. + * - The threshold (HELP_NEEDINFO_MIN_CHARS, default 120) applies to + * title + body combined, not body alone. + */ + private async isThinPost(thread: AnyThreadChannel): Promise { + // Forum starter messages can lag a moment behind ThreadCreate. let starter = await thread.fetchStarterMessage().catch(() => null); if (!starter) { await delay(1500); starter = await thread.fetchStarterMessage().catch(() => null); } - if (!starter) return; // can't judge it — leave it alone + if (!starter) return false; // can't judge — leave it alone - const hasImage = starter.attachments.size > 0; - const hasCode = starter.content.includes('```'); - const tooShort = - starter.content.trim().length < helpAssistConfig.needinfoMinChars; - if (hasImage || hasCode || !tooShort) return; + const body = starter.content.trim(); - const payload = resolveTag('needinfo', starter.author.id); - if (!payload?.content) return; + if (starter.attachments.size > 0) return false; + if (body.includes('```')) return false; + if (/`[^`\n]+`/.test(body)) return false; // inline code + if (/https?:\/\/\S+/.test(body)) return false; // any URL - await thread - .send({ - content: payload.content, - allowedMentions: { users: [starter.author.id] }, - }) - .catch(() => null); + const titleLen = thread.name.trim().length; + const combined = titleLen + body.length; + return combined < helpAssistConfig.needinfoMinChars; } } diff --git a/src/utils/config.ts b/src/utils/config.ts index 788c784..fe6bf6e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -46,6 +46,13 @@ const idList = (value: string | undefined): string[] => /** Announcement/feed channels whose messages are auto-published (crossposted). */ export const crosspostChannelIds = idList(process.env.CROSSPOST_CHANNEL_IDS); +/** + * Channels (and forum-channel parents) where tag/code/ask suggestions are + * suppressed. Useful for staff channels, announcements, or any channel where + * bot suggestions would be unwelcome. Comma-separated channel IDs. + */ +export const suggestIgnoreChannelIds = idList(process.env.SUGGEST_IGNORE_CHANNEL_IDS); + /** * Channels treated as "help" channels. Entries may be **forum** channels (each * post is a thread) or **regular text** channels (threads opened inside them get @@ -79,9 +86,14 @@ export const askSuggestEnabled = process.env.ASK_SUGGEST_ENABLED !== 'false'; */ export const helpAssistConfig = { /** Auto-post the needinfo checklist when a new help post is too thin. */ - autoNeedinfo: process.env.HELP_AUTO_NEEDINFO !== 'false', - /** A starter message shorter than this (and without code) counts as "thin". */ - needinfoMinChars: posInt(process.env.HELP_NEEDINFO_MIN_CHARS, 60), + autoNeedinfo: process.env.HELP_AUTO_NEEDINFO === 'true', + /** + * Combined character threshold (forum title + post body) below which a new + * help post counts as "thin" for auto-needinfo. Posts that also contain a + * code block, inline code, image, or URL are never considered thin regardless + * of length. Default 120 — conservative to minimise false positives. + */ + needinfoMinChars: posInt(process.env.HELP_NEEDINFO_MIN_CHARS, 120), /** Whether the stale-post nudge/auto-archive sweep runs. */ staleSweepEnabled: process.env.HELP_STALE_SWEEP_ENABLED !== 'false', /** From c5cbcea5613cbbf1bbf200820af4c840c1dca77c Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Mon, 15 Jun 2026 01:46:53 -0500 Subject: [PATCH 21/42] fix: replace implicit role check with explicit SUGGEST_IMMUNE_ROLE_IDS list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check (roles.cache.size > 1) exempted any member with any role, including self-assignable notification/update roles. Replace it with an explicit SUGGEST_IMMUNE_ROLE_IDS config (comma-separated role IDs) so only the intended roles — Trusted, Knowledgeable, Helper, Moderator, etc. — suppress suggestions, while members with only self-assignable roles continue to receive them. If SUGGEST_IMMUNE_ROLE_IDS is unset, no one is exempted. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 3 +++ SETUP.md | 7 ++++--- docker-compose.yml | 1 + src/listeners/tagSuggest.ts | 12 +++++++++--- src/utils/config.ts | 8 ++++++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index b5228bc..b401bb5 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,9 @@ MOD_LOG_CHANNEL_ID= # announcements, etc.). Comma-separated channel IDs. For a forum channel, # listing the parent channel ID silences all its threads. # SUGGEST_IGNORE_CHANNEL_IDS= +# Role IDs whose holders never receive suggestions (Trusted, Knowledgeable, +# Helper, Moderator, etc.). Do NOT include self-assignable notification roles. +# SUGGEST_IMMUNE_ROLE_IDS= # Help channels may be FORUM channels (each post is a thread) or regular TEXT # channels (threads opened inside them get the same treatment). Comma-separated. # HELP_CHANNEL_IDS= # help channels (forum or text) — enables solve button, auto-needinfo, sweep, /openposts diff --git a/SETUP.md b/SETUP.md index ede48a8..3243cfa 100644 --- a/SETUP.md +++ b/SETUP.md @@ -186,9 +186,10 @@ All variables are optional except `BOT_TOKEN`. Leaving a variable blank uses the | Variable | Default | Description | |---|---|---| | `HELP_CHANNEL_IDS` | *(disabled)* | Comma-separated forum or text channel IDs — enables solve button, auto-needinfo, stale sweep, and `/openposts` | -| `TAG_SUGGEST_ENABLED` | `true` | Keyword → tag auto-suggestion (never fires for members with any server role) | -| `CODE_FORMAT_SUGGEST_ENABLED` | `true` | Nudge for unformatted code pastes (same role exemption) | -| `ASK_SUGGEST_ENABLED` | `true` | Nudge for "can I ask?" / "anyone here?" messages (same role exemption) | +| `TAG_SUGGEST_ENABLED` | `true` | Keyword → tag auto-suggestion | +| `CODE_FORMAT_SUGGEST_ENABLED` | `true` | Nudge for unformatted code pastes | +| `ASK_SUGGEST_ENABLED` | `true` | Nudge for "can I ask?" / "anyone here?" messages | +| `SUGGEST_IMMUNE_ROLE_IDS` | *(none)* | Comma-separated role IDs whose holders never receive suggestions (Trusted, Knowledgeable, Helper, Moderator, etc.). Self-assignable notification roles should **not** be listed. | | `SUGGEST_IGNORE_CHANNEL_IDS` | *(none)* | Comma-separated channel IDs where suggestions are suppressed entirely (e.g. staff channels). Listing a forum channel's ID silences all its threads. | | `HELP_AUTO_NEEDINFO` | `false` | Set `true` to auto-post a concise checklist when a new help post is thin. Off by default — enable after observing the Mark Solved rollout. | | `HELP_NEEDINFO_MIN_CHARS` | `120` | Combined character count (thread title + post body) below which a post counts as thin. Posts with a code block, inline code, image, or URL are never considered thin. | diff --git a/docker-compose.yml b/docker-compose.yml index edef0b7..0e5ccf1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: # ── Helper-assist features ──────────────────────────────────────────── SUGGEST_IGNORE_CHANNEL_IDS: ${SUGGEST_IGNORE_CHANNEL_IDS:-} + SUGGEST_IMMUNE_ROLE_IDS: ${SUGGEST_IMMUNE_ROLE_IDS:-} HELP_CHANNEL_IDS: ${HELP_CHANNEL_IDS:-} HELP_FORUM_CHANNEL_IDS: ${HELP_FORUM_CHANNEL_IDS:-} TAG_SUGGEST_ENABLED: ${TAG_SUGGEST_ENABLED:-} diff --git a/src/listeners/tagSuggest.ts b/src/listeners/tagSuggest.ts index 97da9a8..918064a 100644 --- a/src/listeners/tagSuggest.ts +++ b/src/listeners/tagSuggest.ts @@ -12,6 +12,7 @@ import { codeFormatSuggestEnabled, askSuggestEnabled, suggestIgnoreChannelIds, + suggestImmuneRoleIds, } from '../utils/config'; import tags, { type Tag, type TagSuggestion } from '../utils/tags'; import universalEmbed from '../utils/embed'; @@ -109,9 +110,14 @@ export class TagSuggestListener extends Listener { if (message.guildId !== SERVER_ID) return; if (message.content.length < 10) return; - // Members with any server role (Trusted and above) are recognised community - // members — helpers, knowledgeable members, staff — who don't need suggestions. - if ((message.member?.roles.cache.size ?? 1) > 1) return; + // Members holding a recognised role (Trusted and above) don't need suggestions. + // Self-assignable notification roles are intentionally excluded from + // SUGGEST_IMMUNE_ROLE_IDS so those members are still served suggestions. + if ( + suggestImmuneRoleIds.length > 0 && + suggestImmuneRoleIds.some((id) => message.member?.roles.cache.has(id)) + ) + return; // Respect the ignore-channel list. Check both the message's channel and, // for threads, the parent channel so an entire forum can be suppressed. diff --git a/src/utils/config.ts b/src/utils/config.ts index fe6bf6e..fe0c0c7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -53,6 +53,14 @@ export const crosspostChannelIds = idList(process.env.CROSSPOST_CHANNEL_IDS); */ export const suggestIgnoreChannelIds = idList(process.env.SUGGEST_IGNORE_CHANNEL_IDS); +/** + * Role IDs whose holders are never shown tag/code/ask suggestions. Set this to + * the IDs of Trusted, Knowledgeable, Helper, and any other recognised-member + * roles. Self-assignable notification roles and similar should NOT be listed + * here so those members still receive suggestions as normal. + */ +export const suggestImmuneRoleIds = idList(process.env.SUGGEST_IMMUNE_ROLE_IDS); + /** * Channels treated as "help" channels. Entries may be **forum** channels (each * post is a thread) or **regular text** channels (threads opened inside them get From 2ec5cc6058252aaf7a1dbe9b4a8137240d7d83df Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:08:17 -0500 Subject: [PATCH 22/42] chore: use generic Claude co-author line in commit convention Co-Authored-By: Claude --- CLAUDE.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..31cc811 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # hot-reloading dev server (ts-node-dev, transpile-only) +npm run build # TypeScript compile to dist/ +npm start # run compiled output + +# Docker (bot + Postgres, migrations auto-applied) +docker compose up -d +docker compose down +``` + +There is no test suite. Type-check via `npm run build`. + +To add a new slash command, register it in `src/commands/` following the Sapphire `Command` pattern. + +PRs target the `staging` branch (not `main`). + +## Commits + +Format: `action: description` (e.g. `feat: add debounce tag`, `fix: stale sweep not resetting on reply`, `refactor: unify suggestion engine`). + +Always commit with Max as primary author and Claude as co-author: +``` +Co-Authored-By: Claude +``` + +## Architecture + +**Framework:** [Sapphire](https://www.sapphirejs.dev) on top of discord.js v14. Sapphire auto-discovers and registers files from: +- `src/commands/` — slash commands +- `src/listeners/` — Discord gateway event handlers +- `src/interaction-handlers/` — button/select-menu interaction handlers + +**Entry point:** `src/index.ts` — creates the `SapphireClient` with the required gateway intents and calls `client.login()`. + +**Configuration:** `src/utils/config.ts` is the single source of truth for all env vars. Every feature self-disables when its channel/role ID env var is unset. Automod thresholds all have sensible defaults overridable via `AUTOMOD_*` env vars. Only `BOT_TOKEN` is required. + +**Database:** Optional Postgres via Prisma 7 with the `@prisma/adapter-pg` driver adapter. `src/utils/db.ts` exports a Prisma client (or `null` when `DATABASE_URL` is unset). Automod runs fully in-memory without a DB; only the spam-image blocklist and analytics/audit tables require persistence. Schema: `prisma/schema.prisma`. + +**Automod pipeline** (`src/utils/automod/`): +- `signature.ts` — cheap metadata fingerprint per image attachment +- `phash.ts` — perceptual hash (dHash) via Jimp for near-duplicate matching +- `tracker.ts` — in-memory sliding-window state for burst and cross-channel fan-out detection +- `flood.ts` — short-message flood detector +- `crosspost.ts` — cross-channel question-spam detector (Jaccard similarity + new-member spread) +- `blocklist.ts` — confirmed-spam fingerprint store (in-memory + DB) +- `incidents.ts` — creates `Incident` objects passed to the console +- `console.ts` — builds moderator alert embeds with action buttons (Confirm / Timeout / Ban / Delete / Not spam) + +The whole pipeline is triggered in `src/listeners/messageCreate.ts`. The three detectors (image, flood, crosspost) run independently on each message. + +**Tags** (`src/utils/tags.ts`): A `Record` map. Each entry has optional `embeds`, `components`, `content` (string or `(user?) => string`), `botCommandsOnly`, and `suggest` (a `{ pattern: RegExp, prompt: string }` for keyword auto-suggestion). To add a tag: add it to `tags.ts` and add a choice in `src/commands/tag.ts`. + +**Help-channel workflow:** Configured via `HELP_CHANNEL_IDS` (forum or text channels). New help threads get a "Mark Solved" button (`src/listeners/threadCreate.ts`), auto-needinfo if thin (`src/utils/helpPosts.ts`), and are tracked by the stale-post sweep (`src/utils/staleHelpSweep.ts`). The `/solved` command and `solvedButton` interaction handler close threads and update tags. + +## `revamp` branch — work in progress + +All active development is on `revamp`. The branch adds substantial new features on top of the automod core that was ported/built on `main`. + +**Automod additions (revamp):** +- Text-flooding detector (`automod/flood.ts`) — flags N short messages from one user in a sliding window; alerts only by default, `AUTOMOD_FLOOD_AUTO_TIMEOUT=true` to also time out. +- Cross-channel question-spam detector (`automod/crosspost.ts`) — two signals: near-identical fan-out (Jaccard similarity ≥ threshold, any tenure) auto-deletes duplicates and alerts; new-member spread (posting substantively in many channels even when reworded) alerts only. + +**Helper-assist suite (revamp, built in three phases):** + +*Phase 1* — reduces repetitive load on community helpers: +- Keyword → tag auto-suggest (`src/listeners/tagSuggest.ts`): tag entries each carry a `suggest: { pattern, prompt }` field; the listener matches incoming messages and offers a one-click tag button with a per-user cooldown. Toggle: `TAG_SUGGEST_ENABLED`. +- "Request more info" message context-menu (`src/commands/requestInfo.ts`): right-click any message to post the `needinfo` checklist to the asker in one click. +- Solve workflow: `/solved [helper]` command + "Mark Solved" button on new help threads. Shared logic in `src/utils/solveThread.ts` titles the thread with a checkmark, credits the helper, and archives. OP or staff only. + +*Phase 2* — broader coverage and close-the-loop on help posts: +- Tag `suggest` triggers co-located in `tags.ts` (moved out of `tagSuggest.ts`) — adding a suggestible tag is now a one-place change. +- Unformatted-code nudge: detects plain-text code pastes and offers the `codeblock` tag. Toggle: `CODE_FORMAT_SUGGEST_ENABLED`. +- "Just ask" nudge: conservative patterns catch "can I ask?" / "anyone here?" and reply with the `ask` tag. Toggle: `ASK_SUGGEST_ENABLED`. +- Auto-needinfo on thin posts: new help thread with short content and no code/image gets the `needinfo` checklist automatically. Toggle: `HELP_AUTO_NEEDINFO`, threshold: `HELP_NEEDINFO_MIN_CHARS`. +- Stale-post sweep (`src/utils/staleHelpSweep.ts`): nudges open posts idle past `HELP_STALE_NUDGE_HOURS` (default 72h), then auto-archives if no human replies within a further `HELP_STALE_ARCHIVE_HOURS` (default 168h). Runs on an unref'd interval started in the `ready` listener. +- `/openposts` command (`src/commands/openPosts.ts`): ephemeral digest of open help posts, oldest-waiting first. Backed by `src/utils/helpPosts.ts` (shared with the sweep). + +*Phase 3 (timing refinement)* — lengthened stale-post defaults (24h→72h nudge, 72h→168h archive) and generalised help-channel support: `HELP_CHANNEL_IDS` accepts both forum channels and regular text channels; threads inside either get the same lifecycle. Legacy `HELP_FORUM_CHANNEL_IDS` still works and is merged in. From e62899a2fbfb444798897d186ab27deb88e7ae03 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:14:32 -0500 Subject: [PATCH 23/42] feat: make crosspost duplicate-delete opt-in (alert-only default) Gates the near-identical cross-channel auto-delete behind AUTOMOD_CROSSPOST_AUTO_DELETE (default false) so a false positive can't silently delete a legit message until the detector is trusted. Also gitignores the local spam sample. Co-Authored-By: Claude --- .env.example | 1 + .gitignore | 3 +++ docker-compose.yml | 1 + src/listeners/messageCreate.ts | 10 ++++++++-- src/utils/config.ts | 7 +++++++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index b401bb5..3580642 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,4 @@ MOD_LOG_CHANNEL_ID= # AUTOMOD_CROSSPOST_WINDOW_MS=120000 # window for cross-channel detection (2 min) # AUTOMOD_CROSSPOST_MIN_CHARS=12 # ignore messages shorter than this (greetings/reactions) # AUTOMOD_CROSSPOST_SIMILARITY_PCT=80 # token-overlap % at which two messages are the "same" question +# AUTOMOD_CROSSPOST_AUTO_DELETE=false # true = auto-delete duplicate copies; default alert-only diff --git a/.gitignore b/.gitignore index dd3438e..07c4a48 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ build # Scratch notes files scratch.md + +# Local spam sample — never commit +spam_example.png diff --git a/docker-compose.yml b/docker-compose.yml index 0e5ccf1..b6add7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,7 @@ services: AUTOMOD_CROSSPOST_WINDOW_MS: ${AUTOMOD_CROSSPOST_WINDOW_MS:-} AUTOMOD_CROSSPOST_MIN_CHARS: ${AUTOMOD_CROSSPOST_MIN_CHARS:-} AUTOMOD_CROSSPOST_SIMILARITY_PCT: ${AUTOMOD_CROSSPOST_SIMILARITY_PCT:-} + AUTOMOD_CROSSPOST_AUTO_DELETE: ${AUTOMOD_CROSSPOST_AUTO_DELETE:-} # Migrate the database schema, then start the bot. command: sh -c "node_modules/.bin/prisma migrate deploy && npm start" diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index fe40d71..7777fe0 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -222,9 +222,15 @@ export class MessageCreateListener extends Listener { // Auto-delete only genuine duplicates: for a near-identical fan-out, drop // every copy but the first. The content-agnostic spread signal isn't a set - // of duplicates, so it only alerts and waits for a human. + // of duplicates, so it only alerts and waits for a human. Gated behind an + // opt-in flag so a false positive can't silently delete a legit message + // until a server has watched the detector and trusts it. let autoActed = false; - if (detection.kind === 'similar' && messages.length > 1) { + if ( + automodConfig.crosspostAutoDelete && + detection.kind === 'similar' && + messages.length > 1 + ) { const deleted = await deleteIncidentMessages(container.client, { ...incident, messages: messages.slice(1), diff --git a/src/utils/config.ts b/src/utils/config.ts index fe0c0c7..ac400ba 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -201,4 +201,11 @@ export const automodConfig = { * "same" question. Higher = stricter. Default 80%. */ crosspostSimilarityPct: posInt(process.env.AUTOMOD_CROSSPOST_SIMILARITY_PCT, 80), + /** + * When true, a near-identical cross-channel fan-out auto-deletes the duplicate + * copies (keeping the first). Off by default: like flooding, a false positive + * here silently removes a legit user's message, so by default we only alert + * moderators and let them decide from the console. + */ + crosspostAutoDelete: process.env.AUTOMOD_CROSSPOST_AUTO_DELETE === 'true', }; From bc0c393464bdcd87a42bb54353d89fa12358e22f Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:15:56 -0500 Subject: [PATCH 24/42] feat: add severity, allowlist, and learned-keyword tables Co-Authored-By: Claude --- .../migration.sql | 20 ++++++++++++++++ prisma/schema.prisma | 23 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260708000000_detection_redesign/migration.sql diff --git a/prisma/migrations/20260708000000_detection_redesign/migration.sql b/prisma/migrations/20260708000000_detection_redesign/migration.sql new file mode 100644 index 0000000..4d3e702 --- /dev/null +++ b/prisma/migrations/20260708000000_detection_redesign/migration.sql @@ -0,0 +1,20 @@ +ALTER TABLE "SpamSignature" ADD COLUMN "severity" VARCHAR NOT NULL DEFAULT 'spam'; + +CREATE TABLE "AllowSignature" ( + "id" SERIAL NOT NULL, + "signature" VARCHAR NOT NULL, + "kind" VARCHAR NOT NULL, + "addedBy" VARCHAR NOT NULL, + "reason" VARCHAR, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AllowSignature_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "AllowSignature_signature_key" ON "AllowSignature"("signature"); + +CREATE TABLE "ScamKeyword" ( + "keyword" VARCHAR NOT NULL, + "hits" INTEGER NOT NULL DEFAULT 1, + "addedBy" VARCHAR NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + CONSTRAINT "ScamKeyword_pkey" PRIMARY KEY ("keyword") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25beb74..990425e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,13 +31,34 @@ enum MemberAnalyticsEvent { /// Blocklist of image fingerprints confirmed to be spam by a moderator. /// New uploads matching one of these are auto-actioned. `kind` is "meta" for /// exact metadata signatures or "phash" for perceptual hashes (matched by -/// Hamming distance). +/// Hamming distance). `severity` distinguishes "scam" (auto-ban on reappearance) +/// from "spam" (timeout). model SpamSignature { signature String @id @db.VarChar kind String @default("meta") @db.VarChar addedBy String @db.VarChar reason String? @db.VarChar createdAt DateTime @default(now()) @db.Timestamptz(6) + severity String @default("spam") @db.VarChar // "scam" | "spam" +} + +/// Allowlist of image fingerprints that are known-good (negative-training set). +/// Signatures here suppress spam alerts even if they match a SpamSignature cluster. +model AllowSignature { + id Int @id @default(autoincrement()) + signature String @unique @db.VarChar + kind String @db.VarChar // "meta" | "phash" + addedBy String @db.VarChar + reason String? @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) +} + +/// Keywords learned from OCR of confirmed scam images. +model ScamKeyword { + keyword String @id @db.VarChar + hits Int @default(1) + addedBy String @db.VarChar + updatedAt DateTime @updatedAt @db.Timestamptz(6) } /// Audit trail of automod-related moderation actions. From bccec4218ea224cea740d3f95de0688ec590d42e Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:20:03 -0500 Subject: [PATCH 25/42] feat: add config knobs for cluster, scoring, and OCR Co-Authored-By: Claude --- src/utils/config.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/utils/config.ts b/src/utils/config.ts index ac400ba..80a2b6f 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -208,4 +208,32 @@ export const automodConfig = { * moderators and let them decide from the console. */ crosspostAutoDelete: process.env.AUTOMOD_CROSSPOST_AUTO_DELETE === 'true', + + // --- Cross-user cluster --- + clusterMinUsers: posInt(process.env.AUTOMOD_CLUSTER_MIN_USERS, 2), + clusterWindowMs: posInt(process.env.AUTOMOD_CLUSTER_WINDOW_MS, 120_000), + + // --- Confidence scoring thresholds (0-100) --- + scoreHigh: posInt(process.env.AUTOMOD_SCORE_HIGH, 50), + scoreMedium: posInt(process.env.AUTOMOD_SCORE_MEDIUM, 30), + scoreLow: posInt(process.env.AUTOMOD_SCORE_LOW, 15), + logLowConfidence: process.env.AUTOMOD_LOG_LOW_CONFIDENCE === 'true', + + // --- OCR --- + ocrEnabled: process.env.OCR_ENABLED !== 'false', + ocrTimeoutMs: posInt(process.env.AUTOMOD_OCR_TIMEOUT_MS, 5000), + ocrMaxConcurrency: posInt(process.env.AUTOMOD_OCR_MAX_CONCURRENCY, 2), + ocrImageWidth: posInt(process.env.AUTOMOD_OCR_IMAGE_WIDTH, 640), }; + +const DEFAULT_SCAM_KEYWORDS = [ + 'crypto', 'airdrop', 'withdraw', 'withdrawal', 'giveaway', 'free', 'gift', + 'nitro', 'elon', 'musk', 'mrbeast', 'beast games', 'claim', 'wallet', + 'bonus', 'promo', 'reward', 'bitcoin', 'eth', 'usdt', +]; + +/** Seed scam keywords; AUTOMOD_SCAM_KEYWORDS (comma-separated) replaces them. */ +export const seedScamKeywords = (() => { + const override = idList(process.env.AUTOMOD_SCAM_KEYWORDS).map((k) => k.toLowerCase()); + return override.length > 0 ? override : DEFAULT_SCAM_KEYWORDS; +})(); From 6e2b8e680cba1daac4e9774b5861082e347ac613 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:22:24 -0500 Subject: [PATCH 26/42] feat: add pure confidence-scoring engine with tests Co-Authored-By: Claude --- package.json | 3 +- src/utils/automod/score.test.ts | 32 +++++++++++++++++++ src/utils/automod/score.ts | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/utils/automod/score.test.ts create mode 100644 src/utils/automod/score.ts diff --git a/package.json b/package.json index 7ef8c01..6f71ad7 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "start": "node ./dist/src/index.js", "build": "tsc -p .", "dev": "tsnd --respawn --transpile-only --exit-child .", - "postinstall": "prisma generate" + "postinstall": "prisma generate", + "test": "tsc -p . && BOT_TOKEN=test node --test \"dist/**/*.test.js\"" }, "repository": { "type": "git", diff --git a/src/utils/automod/score.test.ts b/src/utils/automod/score.test.ts new file mode 100644 index 0000000..a9b622b --- /dev/null +++ b/src/utils/automod/score.test.ts @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { scoreSignals, type Signals } from './score'; + +const base: Signals = { + scamBlocklist: false, spamBlocklist: false, + clusterUsers: 0, fanoutChannels: 0, + keywordMatches: 0, newAccount: false, hasLinkOrMention: false, + imageOnlyPair: false, burst: false, +}; + +test('scam blocklist forces critical', () => { + const r = scoreSignals({ ...base, scamBlocklist: true }); + assert.equal(r.tier, 'critical'); +}); + +test('two-user cluster reaches high (auto-action) alone', () => { + const r = scoreSignals({ ...base, clusterUsers: 2 }); + assert.equal(r.tier, 'high'); +}); + +test('corroborating signals alone never exceed medium', () => { + const r = scoreSignals({ + ...base, keywordMatches: 5, newAccount: true, + hasLinkOrMention: true, imageOnlyPair: true, burst: true, + }); + assert.equal(r.tier, 'medium'); +}); + +test('no signals is none', () => { + assert.equal(scoreSignals(base).tier, 'none'); +}); diff --git a/src/utils/automod/score.ts b/src/utils/automod/score.ts new file mode 100644 index 0000000..1fb62dd --- /dev/null +++ b/src/utils/automod/score.ts @@ -0,0 +1,55 @@ +import { automodConfig } from '../config'; + +export interface Signals { + scamBlocklist: boolean; + spamBlocklist: boolean; + clusterUsers: number; // distinct accounts sharing this image (incl. author) + fanoutChannels: number; // distinct channels one user posted the image in + keywordMatches: number; // distinct OCR scam keywords matched + newAccount: boolean; + hasLinkOrMention: boolean; + imageOnlyPair: boolean; // image-only message with exactly 2 images + burst: boolean; +} + +export type Tier = 'none' | 'low' | 'medium' | 'high' | 'critical'; + +export interface MatchedSignal { label: string; points: number; } +export interface ScoreResult { score: number; tier: Tier; matched: MatchedSignal[]; } + +// Corroborating contribution is capped below scoreHigh so weak signals alone +// can never reach the auto-action tier. +const CORROBORATING_CAP = 45; + +export function scoreSignals(s: Signals): ScoreResult { + if (s.scamBlocklist) + return { score: 100, tier: 'critical', matched: [{ label: 'Known scam image (mod-confirmed)', points: 100 }] }; + + const strong: MatchedSignal[] = []; + if (s.spamBlocklist) strong.push({ label: 'Known spam image (mod-confirmed)', points: automodConfig.scoreHigh }); + if (s.clusterUsers >= automodConfig.clusterMinUsers) { + const extra = Math.min((s.clusterUsers - 2) * 12, 30); + strong.push({ label: `Same image from ${s.clusterUsers} accounts`, points: 50 + Math.max(0, extra) }); + } + if (s.fanoutChannels >= automodConfig.fanoutChannels) + strong.push({ label: `Image fanned across ${s.fanoutChannels} channels`, points: 50 }); + + const corrob: MatchedSignal[] = []; + if (s.keywordMatches > 0) + corrob.push({ label: `OCR scam keywords x${s.keywordMatches}`, points: Math.min(s.keywordMatches * 12, 30) }); + if (s.newAccount) corrob.push({ label: 'New / low-tenure account', points: 18 }); + if (s.hasLinkOrMention) corrob.push({ label: 'Contains link or @everyone/@here', points: 18 }); + if (s.imageOnlyPair) corrob.push({ label: 'Image-only, exactly 2 images', points: 8 }); + if (s.burst) corrob.push({ label: 'Image burst', points: 12 }); + + const strongTotal = strong.reduce((n, m) => n + m.points, 0); + const corrobTotal = Math.min(corrob.reduce((n, m) => n + m.points, 0), CORROBORATING_CAP); + const score = Math.min(strongTotal + corrobTotal, 100); + + let tier: Tier = 'none'; + if (score >= automodConfig.scoreHigh) tier = 'high'; + else if (score >= automodConfig.scoreMedium) tier = 'medium'; + else if (score >= automodConfig.scoreLow) tier = 'low'; + + return { score, tier, matched: [...strong, ...corrob] }; +} From 1093468374a6d3724e2b7a40ac1e93e14d003bea Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:25:13 -0500 Subject: [PATCH 27/42] feat: add cross-user image cluster index with tests Co-Authored-By: Claude --- src/utils/automod/globalIndex.test.ts | 28 +++++++++++++++ src/utils/automod/globalIndex.ts | 49 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/utils/automod/globalIndex.test.ts create mode 100644 src/utils/automod/globalIndex.ts diff --git a/src/utils/automod/globalIndex.test.ts b/src/utils/automod/globalIndex.test.ts new file mode 100644 index 0000000..6afce29 --- /dev/null +++ b/src/utils/automod/globalIndex.test.ts @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { recordAndCluster, __resetGlobalIndex } from './globalIndex'; + +test('same signature from two users clusters', () => { + __resetGlobalIndex(); + const now = Date.now(); + recordAndCluster({ userId: 'a', channelId: 'c1', messageId: 'm1', at: now, signatures: ['sig1'], hashes: [] }); + const r = recordAndCluster({ userId: 'b', channelId: 'c2', messageId: 'm2', at: now + 1000, signatures: ['sig1'], hashes: [] }); + assert.equal(r.userIds.length, 2); + assert.ok(r.userIds.includes('a') && r.userIds.includes('b')); +}); + +test('same user does not count as a cross-user cluster', () => { + __resetGlobalIndex(); + const now = Date.now(); + recordAndCluster({ userId: 'a', channelId: 'c1', messageId: 'm1', at: now, signatures: ['sig1'], hashes: [] }); + const r = recordAndCluster({ userId: 'a', channelId: 'c2', messageId: 'm2', at: now + 1000, signatures: ['sig1'], hashes: [] }); + assert.equal(r.userIds.length, 1); +}); + +test('stale entries outside the window are pruned', () => { + __resetGlobalIndex(); + const now = Date.now(); + recordAndCluster({ userId: 'a', channelId: 'c1', messageId: 'm1', at: now - 10 * 60_000, signatures: ['sig1'], hashes: [] }); + const r = recordAndCluster({ userId: 'b', channelId: 'c2', messageId: 'm2', at: now, signatures: ['sig1'], hashes: [] }); + assert.equal(r.userIds.length, 1); +}); diff --git a/src/utils/automod/globalIndex.ts b/src/utils/automod/globalIndex.ts new file mode 100644 index 0000000..4e7da98 --- /dev/null +++ b/src/utils/automod/globalIndex.ts @@ -0,0 +1,49 @@ +import { automodConfig } from '../config'; +import { hammingDistance } from './phash'; + +export interface GlobalImageEvent { + userId: string; + channelId: string; + messageId: string; + at: number; + signatures: string[]; + hashes: string[]; +} + +export interface ClusterResult { + userIds: string[]; // distinct users sharing this image within the window + events: GlobalImageEvent[]; +} + +// Flat recent buffer of image events across all users, pruned to the window. +let recent: GlobalImageEvent[] = []; + +/** Record an event and return the cross-user cluster it belongs to. */ +export function recordAndCluster(event: GlobalImageEvent): ClusterResult { + const horizon = event.at - automodConfig.clusterWindowMs; + recent = recent.filter((e) => e.at >= horizon); + + const matches = recent.filter((e) => sharesImage(e, event)); + recent.push(event); + + const cluster = [...matches, event]; + const userIds = [...new Set(cluster.map((e) => e.userId))]; + return { userIds, events: cluster }; +} + +function sharesImage(a: GlobalImageEvent, b: GlobalImageEvent): boolean { + if (a.signatures.some((s) => b.signatures.includes(s))) return true; + for (const h1 of a.hashes) + for (const h2 of b.hashes) + if (hammingDistance(h1, h2) <= automodConfig.phashThreshold) return true; + return false; +} + +/** Test-only reset. */ +export function __resetGlobalIndex(): void { recent = []; } + +const sweep = setInterval(() => { + const horizon = Date.now() - automodConfig.clusterWindowMs; + recent = recent.filter((e) => e.at >= horizon); +}, 60_000); +sweep.unref(); From 5d153f3721cebf7bc0a1c14c2b5bfdbc54b5a4f1 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:26:43 -0500 Subject: [PATCH 28/42] feat: add seed+learned scam keyword store with tests Co-Authored-By: Claude --- src/utils/automod/keywords.test.ts | 20 ++++++++++++ src/utils/automod/keywords.ts | 51 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/utils/automod/keywords.test.ts create mode 100644 src/utils/automod/keywords.ts diff --git a/src/utils/automod/keywords.test.ts b/src/utils/automod/keywords.test.ts new file mode 100644 index 0000000..e624c68 --- /dev/null +++ b/src/utils/automod/keywords.test.ts @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { matchKeywords, tokenizeOcr, learnKeywords, __resetKeywords } from './keywords'; + +test('tokenize lowercases and strips punctuation', () => { + assert.deepEqual(tokenizeOcr('Free CRYPTO!! withdraw$$'), ['free', 'crypto', 'withdraw']); +}); + +test('matches distinct seed keywords', () => { + __resetKeywords(); + const m = matchKeywords('claim your free crypto wallet airdrop now'); + assert.ok(m.length >= 4); // free, crypto, wallet, airdrop +}); + +test('learned keywords are matched after learning', () => { + __resetKeywords(); + assert.equal(matchKeywords('totallyuniquescamword here').length, 0); + learnKeywords(['totallyuniquescamword'], 'modid'); + assert.deepEqual(matchKeywords('totallyuniquescamword here'), ['totallyuniquescamword']); +}); diff --git a/src/utils/automod/keywords.ts b/src/utils/automod/keywords.ts new file mode 100644 index 0000000..b134aa4 --- /dev/null +++ b/src/utils/automod/keywords.ts @@ -0,0 +1,51 @@ +import { container } from '@sapphire/framework'; +import { seedScamKeywords } from '../config'; +import { getPrisma } from '../db'; + +const STOP = new Set(['the','and','you','your','for','with','this','that','have','from','are','now','get','all']); +const learned = new Set(); + +/** Split OCR text into lowercase alphabetic tokens (length >= 3). */ +export function tokenizeOcr(text: string): string[] { + return (text.toLowerCase().match(/[a-z]+/g) ?? []).filter((t) => t.length >= 3); +} + +/** Distinct scam keywords (seed + learned) present in the text. */ +export function matchKeywords(text: string): string[] { + const tokens = new Set(tokenizeOcr(text)); + const active = new Set([...seedScamKeywords, ...learned]); + const hits = new Set(); + for (const kw of active) { + if (kw.includes(' ')) { if (text.toLowerCase().includes(kw)) hits.add(kw); } + else if (tokens.has(kw)) hits.add(kw); + } + return [...hits]; +} + +/** Add distinctive tokens to the learned store (memory + DB). */ +export async function learnKeywords(tokens: string[], addedBy: string): Promise { + const fresh = tokens.filter((t) => t.length >= 4 && !STOP.has(t) && !learned.has(t)); + for (const t of fresh) learned.add(t); + const prisma = getPrisma(); + if (!prisma || fresh.length === 0) return; + try { + await prisma.$transaction(fresh.map((keyword) => + prisma.scamKeyword.upsert({ + where: { keyword }, create: { keyword, addedBy }, + update: { hits: { increment: 1 } }, + }))); + } catch (e) { container.logger.error('Learning keywords failed:', e); } +} + +/** Load learned keywords on startup. Safe without a database. */ +export async function loadKeywords(): Promise { + const prisma = getPrisma(); + if (!prisma) return; + try { + const rows = await prisma.scamKeyword.findMany({ select: { keyword: true } }); + for (const { keyword } of rows) learned.add(keyword); + container.logger.info(`Automod: loaded ${learned.size} learned scam keyword(s).`); + } catch (e) { container.logger.error('Loading keywords failed:', e); } +} + +export function __resetKeywords(): void { learned.clear(); } From 44981ef4f50608c8b013869abf5ff69ffe08cbbd Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:30:41 -0500 Subject: [PATCH 29/42] feat: add best-effort OCR module (tesseract.js) Co-Authored-By: Claude --- package-lock.json | 75 +++++++++++++++++++++++++++++++++++++++- package.json | 3 +- src/utils/automod/ocr.ts | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 src/utils/automod/ocr.ts diff --git a/package-lock.json b/package-lock.json index 099cea7..3077b22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "discord.js": "^14.26.4", "dotenv": "^17.4.2", "jimp": "^1.6.1", - "pg": "^8.21.0" + "pg": "^8.21.0", + "tesseract.js": "^7.0.0" }, "devDependencies": { "@sapphire/ts-config": "^5.0.3", @@ -1658,6 +1659,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/bmp-ts": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", @@ -2391,6 +2398,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/idb-keyval": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz", + "integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2521,6 +2534,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2876,6 +2895,15 @@ "wrappy": "1" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -3276,6 +3304,12 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", @@ -3597,6 +3631,30 @@ "node": ">=10" } }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -3846,6 +3904,12 @@ } } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -3978,6 +4042,15 @@ "graphmatch": "^1.1.0" } }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 6f71ad7..9133924 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "discord.js": "^14.26.4", "dotenv": "^17.4.2", "jimp": "^1.6.1", - "pg": "^8.21.0" + "pg": "^8.21.0", + "tesseract.js": "^7.0.0" }, "devDependencies": { "@sapphire/ts-config": "^5.0.3", diff --git a/src/utils/automod/ocr.ts b/src/utils/automod/ocr.ts new file mode 100644 index 0000000..4ab4541 --- /dev/null +++ b/src/utils/automod/ocr.ts @@ -0,0 +1,64 @@ +import Tesseract from 'tesseract.js'; +import type { Attachment, Message } from 'discord.js'; +import { container } from '@sapphire/framework'; +import { automodConfig } from '../config'; +import { isImageAttachment, attachmentSignature } from './signature'; + +// tesseract.js v7: createWorker(langs) auto-loads and initializes the language model. +// No manual load() / loadLanguage() / initialize() calls needed. +let workerPromise: Promise | null = null; +function getWorker(): Promise { + if (!workerPromise) workerPromise = Tesseract.createWorker('eng'); + return workerPromise; +} + +// Cache OCR text by attachment signature so a raid's repeated image is read once. +const cache = new Map(); +let active = 0; + +function readableUrl(a: Attachment): string { + const base = a.proxyURL || a.url; + const sep = base.includes('?') ? '&' : '?'; + return `${base}${sep}width=${automodConfig.ocrImageWidth}&height=${automodConfig.ocrImageWidth}`; +} + +async function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise((res) => setTimeout(() => res(null), ms)), + ]); +} + +/** OCR all image attachments on a message; returns concatenated text (may be ''). */ +export async function ocrMessage(message: Message): Promise { + if (!automodConfig.ocrEnabled) return ''; + const images = [...message.attachments.values()].filter(isImageAttachment).slice(0, 4); + const texts = await Promise.all(images.map((a) => ocrAttachment(a))); + return texts.filter(Boolean).join('\n'); +} + +async function ocrAttachment(a: Attachment): Promise { + const key = attachmentSignature(a); + const cached = cache.get(key); + if (cached !== undefined) return cached; + if (active >= automodConfig.ocrMaxConcurrency) return ''; + active++; + try { + const text = await withTimeout(runOcr(a), automodConfig.ocrTimeoutMs); + const result = (text ?? '').trim(); + cache.set(key, result); + return result; + } catch (e) { + container.logger.debug(`Automod: OCR failed for ${a.id}:`, e); + return ''; + } finally { active--; } +} + +async function runOcr(a: Attachment): Promise { + const res = await fetch(readableUrl(a), { signal: AbortSignal.timeout(automodConfig.ocrTimeoutMs) }); + if (!res.ok) return ''; + const buffer = Buffer.from(await res.arrayBuffer()); + const worker = await getWorker(); + const { data } = await worker.recognize(buffer); + return data.text ?? ''; +} From 13ca6eccc2a83338b99bb8f86c6944ab231f6bcb Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:32:56 -0500 Subject: [PATCH 30/42] feat: add scam severity and allowlist to the blocklist Co-Authored-By: Claude --- src/interaction-handlers/spamModeration.ts | 3 +- src/listeners/messageCreate.ts | 2 +- src/utils/automod/blocklist.ts | 104 +++++++++++++++++++-- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/src/interaction-handlers/spamModeration.ts b/src/interaction-handlers/spamModeration.ts index e3a1130..1a797ec 100644 --- a/src/interaction-handlers/spamModeration.ts +++ b/src/interaction-handlers/spamModeration.ts @@ -84,7 +84,8 @@ export class SpamModerationHandler extends InteractionHandler { incident.signatures, incident.hashes, moderator.id, - 'confirmed image spam' + 'confirmed image spam', + 'spam' ); clearUser(incident.userId); clearFloodUser(incident.userId); diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 7777fe0..11bdabc 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -73,7 +73,7 @@ export class MessageCreateListener extends Listener { : {} ); - const { blocked, matched } = isBlocklisted(signatures, hashes); + const { blocked, severity: _severity, matched } = isBlocklisted(signatures, hashes); let level: IncidentLevel | null = null; if (blocked) level = 'blocklist'; diff --git a/src/utils/automod/blocklist.ts b/src/utils/automod/blocklist.ts index 1495168..ba7f555 100644 --- a/src/utils/automod/blocklist.ts +++ b/src/utils/automod/blocklist.ts @@ -11,11 +11,16 @@ import { hammingDistance } from './phash'; */ const exactSignatures = new Set(); const perceptualHashes = new Set(); +const scamSignatures = new Set(); // severity === 'scam' (meta) +const scamHashes = new Set(); // severity === 'scam' (phash) +const allowSignatures = new Set(); +const allowHashes = new Set(); export type FingerprintKind = 'meta' | 'phash'; export interface BlocklistMatch { blocked: boolean; + severity: 'scam' | 'spam' | null; /** Fingerprints from the input that matched the blocklist. */ matched: string[]; } @@ -26,12 +31,22 @@ export async function loadBlocklist(): Promise { if (!prisma) return; try { const rows = await prisma.spamSignature.findMany({ - select: { signature: true, kind: true }, + select: { signature: true, kind: true, severity: true }, }); - for (const { signature, kind } of rows) + for (const { signature, kind, severity } of rows) { (kind === 'phash' ? perceptualHashes : exactSignatures).add(signature); + if (severity === 'scam') + (kind === 'phash' ? scamHashes : scamSignatures).add(signature); + } + + const allowRows = await prisma.allowSignature.findMany({ + select: { signature: true, kind: true }, + }); + for (const { signature, kind } of allowRows) + (kind === 'phash' ? allowHashes : allowSignatures).add(signature); + container.logger.info( - `Automod: loaded ${exactSignatures.size} signature(s) and ${perceptualHashes.size} perceptual hash(es) from the blocklist.` + `Automod: loaded ${exactSignatures.size} signature(s) and ${perceptualHashes.size} perceptual hash(es) from the blocklist (${scamSignatures.size + scamHashes.size} scam), ${allowSignatures.size + allowHashes.size} allowlist entry/entries.` ); } catch (error) { container.logger.error('Loading blocklist failed:', error); @@ -58,7 +73,37 @@ export function isBlocklisted( break; } - return { blocked: matched.size > 0, matched: [...matched] }; + const blocked = matched.size > 0; + + let severity: 'scam' | 'spam' | null = null; + if (blocked) { + // Determine if any matched fingerprint is in the scam sets. + let isScam = false; + for (const m of matched) { + if (scamSignatures.has(m)) { isScam = true; break; } + for (const known of scamHashes) + if (hammingDistance(m, known) <= automodConfig.phashThreshold) { + isScam = true; + break; + } + if (isScam) break; + } + severity = isScam ? 'scam' : 'spam'; + } + + return { blocked, severity, matched: [...matched] }; +} + +/** + * Check if incoming fingerprints are in the allowlist. Allowlisted images + * suppress spam alerts even when they cluster-match or blocklist-match. + */ +export function isAllowlisted(signatures: string[], hashes: string[]): boolean { + if (signatures.some((s) => allowSignatures.has(s))) return true; + for (const h of hashes) + for (const known of allowHashes) + if (hammingDistance(h, known) <= automodConfig.phashThreshold) return true; + return false; } /** Add fingerprints to the blocklist (in-memory always; persisted if possible). */ @@ -66,10 +111,17 @@ export async function addToBlocklist( signatures: string[], hashes: string[], addedBy: string, - reason: string + reason: string, + severity: 'scam' | 'spam' = 'spam' ): Promise { - for (const signature of signatures) exactSignatures.add(signature); - for (const hash of hashes) perceptualHashes.add(hash); + for (const signature of signatures) { + exactSignatures.add(signature); + if (severity === 'scam') scamSignatures.add(signature); + } + for (const hash of hashes) { + perceptualHashes.add(hash); + if (severity === 'scam') scamHashes.add(hash); + } const prisma = getPrisma(); if (!prisma) return; @@ -80,12 +132,14 @@ export async function addToBlocklist( kind: 'meta' as FingerprintKind, addedBy, reason, + severity, })), ...hashes.map((signature) => ({ signature, kind: 'phash' as FingerprintKind, addedBy, reason, + severity, })), ]; if (rows.length === 0) return; @@ -96,3 +150,39 @@ export async function addToBlocklist( container.logger.error('Persisting blocklist fingerprints failed:', error); } } + +/** Add fingerprints to the allowlist (in-memory always; persisted if possible). */ +export async function addToAllowlist( + signatures: string[], + hashes: string[], + addedBy: string, + reason?: string +): Promise { + for (const signature of signatures) allowSignatures.add(signature); + for (const hash of hashes) allowHashes.add(hash); + + const prisma = getPrisma(); + if (!prisma) return; + + const rows = [ + ...signatures.map((signature) => ({ + signature, + kind: 'meta' as FingerprintKind, + addedBy, + reason, + })), + ...hashes.map((signature) => ({ + signature, + kind: 'phash' as FingerprintKind, + addedBy, + reason, + })), + ]; + if (rows.length === 0) return; + + try { + await prisma.allowSignature.createMany({ data: rows, skipDuplicates: true }); + } catch (error) { + container.logger.error('Persisting allowlist fingerprints failed:', error); + } +} From bd508bc2bdcb9a7ced83eb1f99a25546177cef86 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:35:20 -0500 Subject: [PATCH 31/42] fix: harden OCR cache against transient-failure poisoning Only cache genuine OCR completions. A timeout or fetch error now returns empty WITHOUT caching, so a transient failure (or an attacker deliberately slowing the first upload) can no longer permanently blind OCR for that image fingerprint on later posts. Also bound the cache size and clear the timeout timer to avoid a dangling handle. Co-Authored-By: Claude --- src/utils/automod/ocr.ts | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/utils/automod/ocr.ts b/src/utils/automod/ocr.ts index 4ab4541..7436740 100644 --- a/src/utils/automod/ocr.ts +++ b/src/utils/automod/ocr.ts @@ -12,8 +12,12 @@ function getWorker(): Promise { return workerPromise; } -// Cache OCR text by attachment signature so a raid's repeated image is read once. +// Cache OCR text by attachment signature so a raid's repeated image is read +// once. Only genuine completions are cached (see ocrAttachment) — a transient +// timeout or fetch error must never poison the cache and permanently suppress +// OCR for that fingerprint. Bounded so it cannot grow without limit. const cache = new Map(); +const MAX_CACHE = 500; let active = 0; function readableUrl(a: Attachment): string { @@ -23,10 +27,15 @@ function readableUrl(a: Attachment): string { } async function withTimeout(p: Promise, ms: number): Promise { - return Promise.race([ - p, - new Promise((res) => setTimeout(() => res(null), ms)), - ]); + let timer: ReturnType; + const timeout = new Promise((res) => { + timer = setTimeout(() => res(null), ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + clearTimeout(timer!); + } } /** OCR all image attachments on a message; returns concatenated text (may be ''). */ @@ -41,11 +50,22 @@ async function ocrAttachment(a: Attachment): Promise { const key = attachmentSignature(a); const cached = cache.get(key); if (cached !== undefined) return cached; + // Best-effort concurrency guard: under load we skip rather than queue + // unboundedly. A skip is deliberately NOT cached, so it can be retried later. if (active >= automodConfig.ocrMaxConcurrency) return ''; active++; try { + // withTimeout yields null on timeout; runOcr throws on fetch/decode failure. + // Only a genuine completion (a string — possibly '' for a text-free image) + // is cached. Transient timeouts/errors return '' WITHOUT caching so they + // can't poison the cache and blind OCR for this fingerprint on later posts. const text = await withTimeout(runOcr(a), automodConfig.ocrTimeoutMs); - const result = (text ?? '').trim(); + if (text === null) return ''; + const result = text.trim(); + if (cache.size >= MAX_CACHE) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } cache.set(key, result); return result; } catch (e) { @@ -56,7 +76,7 @@ async function ocrAttachment(a: Attachment): Promise { async function runOcr(a: Attachment): Promise { const res = await fetch(readableUrl(a), { signal: AbortSignal.timeout(automodConfig.ocrTimeoutMs) }); - if (!res.ok) return ''; + if (!res.ok) throw new Error(`OCR fetch failed with status ${res.status}`); const buffer = Buffer.from(await res.arrayBuffer()); const worker = await getWorker(); const { data } = await worker.recognize(buffer); From 5851ce144aed9205497b9ca8a8c55cbdb81c4a88 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:36:22 -0500 Subject: [PATCH 32/42] feat: carry score, tier, cluster, and OCR on incidents Co-Authored-By: Claude --- src/utils/automod/incidents.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/utils/automod/incidents.ts b/src/utils/automod/incidents.ts index 1566ca0..4c9c9fd 100644 --- a/src/utils/automod/incidents.ts +++ b/src/utils/automod/incidents.ts @@ -29,6 +29,18 @@ export interface Incident { /** Perceptual hashes to blocklist if a moderator confirms this is spam. */ hashes: string[]; createdAt: number; + /** Composite risk score produced by the scoring pipeline. */ + score: number; + /** Qualitative tier bucketed from the score. */ + tier: 'low' | 'medium' | 'high' | 'critical'; + /** Individual signal contributions that made up the score. */ + matched: { label: string; points: number }[]; + /** Distinct user IDs observed in the same cross-user cluster (always includes the author). */ + clusterUserIds: string[]; + /** Concatenated OCR text extracted from image attachments (may be ''). */ + ocrText: string; + /** Category to use when a moderator confirms and blocklists this incident. */ + severity: 'scam' | 'spam'; } /** @@ -39,13 +51,29 @@ export interface Incident { const incidents = new Map(); const TTL_MS = 60 * 60 * 1000; -export function createIncident( - data: Omit -): Incident { +/** + * Input type for `createIncident`. The six scoring/enrichment fields are + * optional so that the flood and crosspost call sites — which don't yet run + * through the scoring pipeline — compile unchanged. Defaults are filled in + * inside `createIncident`. + */ +type IncidentInput = Omit< + Incident, + 'id' | 'createdAt' | 'score' | 'tier' | 'matched' | 'clusterUserIds' | 'ocrText' | 'severity' +> & + Partial>; + +export function createIncident(data: IncidentInput): Incident { const incident: Incident = { ...data, id: randomUUID().slice(0, 8), createdAt: Date.now(), + score: data.score ?? 0, + tier: data.tier ?? 'medium', + matched: data.matched ?? [], + clusterUserIds: data.clusterUserIds ?? [data.userId], + ocrText: data.ocrText ?? '', + severity: data.severity ?? 'spam', }; incidents.set(incident.id, incident); return incident; From 0dc3906ffd38a74fa33543beb30f1adb390e9582 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:40:27 -0500 Subject: [PATCH 33/42] feat: show confidence, signals, thumbnail, and confirm-scam button Co-Authored-By: Claude --- src/utils/automod/console.ts | 97 ++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/src/utils/automod/console.ts b/src/utils/automod/console.ts index cde8b8a..e801235 100644 --- a/src/utils/automod/console.ts +++ b/src/utils/automod/console.ts @@ -15,13 +15,19 @@ import { automodConfig } from '../config'; import { getPrisma } from '../db'; import type { Incident, IncidentLevel } from './incidents'; -const LEVEL_COLOR: Record = { - fanout: 0xe03131, // high confidence — red - blocklist: 0xe03131, - burst: 0xf08c00, // needs review — amber - flood: 0xf08c00, // needs review — amber - crosspost: 0xe03131, // cross-channel — red -}; +/** Embed color driven by confidence tier rather than incident level. */ +function tierColor(tier: Incident['tier']): number { + switch (tier) { + case 'critical': + case 'high': + return 0xe03131; // red + case 'medium': + return 0xf08c00; // amber + case 'low': + default: + return 0x868e96; // grey + } +} const LEVEL_LABEL: Record = { fanout: 'Cross-channel fan-out', @@ -43,6 +49,8 @@ const LEVEL_TITLE: Record = { /** What an automatic action did, shown when the bot acted before a human. */ const HIGH_CONFIDENCE_NOTE = 'High-confidence signal: the messages were deleted and the user was timed out automatically. Review and escalate or reverse below.'; +const CRITICAL_NOTE = + 'Known scam image: the messages were deleted and the user was banned automatically. Review and reverse below if needed.'; const AUTO_ACTION_NOTE: Record = { fanout: HIGH_CONFIDENCE_NOTE, blocklist: HIGH_CONFIDENCE_NOTE, @@ -69,7 +77,8 @@ function actionButton( export function buildAlertPayload( incident: Incident, member: GuildMember | null, - autoActed: boolean + autoActed: boolean, + previewUrl?: string ): MessageCreateOptions { const channelMentions = [...new Set(incident.messages.map((m) => `<#${m.channelId}>`))].join(' ') || @@ -84,8 +93,9 @@ export function buildAlertPayload( ) .join(' • ') || '—'; + // Confidence tier drives the embed color. const embed = new EmbedBuilder() - .setColor(LEVEL_COLOR[incident.level]) + .setColor(tierColor(incident.tier)) .setTitle(LEVEL_TITLE[incident.level]) .setDescription(`<@${incident.userId}> \`${incident.userId}\``) .addFields( @@ -93,11 +103,64 @@ export function buildAlertPayload( name: 'Signal', value: `**${LEVEL_LABEL[incident.level]}** — ${incident.reason}`, }, + { + name: 'Confidence', + value: `${incident.tier.toUpperCase()} — ${incident.score}/100`, + inline: true, + }, { name: 'Channels', value: channelMentions, inline: true }, { name: 'Messages', value: String(incident.messages.length), inline: true } ); - if (member) { + // Signal breakdown — note: points are indicative weights, not addends. + const signalsValue = + incident.matched.length > 0 + ? incident.matched.map((m) => `• ${m.label} (+${m.points})`).join('\n') + : '—'; + embed.addFields({ name: 'Signals (indicative weights)', value: signalsValue }); + + // Cross-user cluster accounts (cap at 10). + if (incident.clusterUserIds.length > 1) { + const cap = 10; + const shown = incident.clusterUserIds.slice(0, cap); + const overflow = incident.clusterUserIds.length - shown.length; + const accountsValue = + shown.map((id) => `<@${id}>`).join(' ') + (overflow > 0 ? ` +${overflow} more` : ''); + embed.addFields({ name: 'Accounts', value: accountsValue }); + } + + // OCR detected text (untrusted — hard-capped at 200 chars, newlines collapsed). + if (incident.ocrText.length > 0) { + const MAX_OCR = 180; + let ocrDisplay = incident.ocrText.replace(/\s*\n\s*/g, ' ').trim(); + if (ocrDisplay.length > MAX_OCR) { + ocrDisplay = ocrDisplay.slice(0, MAX_OCR) + '…'; + } + embed.addFields({ name: 'Detected text', value: ocrDisplay }); + } + + // Thumbnail: prefer the offending image preview; fall back to member avatar. + if (previewUrl) { + embed.setThumbnail(previewUrl); + if (member) { + embed + .setAuthor({ name: member.user.tag, iconURL: member.displayAvatarURL() }) + .addFields( + { + name: 'Account created', + value: time(member.user.createdAt, TimestampStyles.RelativeTime), + inline: true, + }, + { + name: 'Joined server', + value: member.joinedAt + ? time(member.joinedAt, TimestampStyles.RelativeTime) + : 'unknown', + inline: true, + } + ); + } + } else if (member) { embed .setThumbnail(member.displayAvatarURL()) .setFooter({ text: member.user.tag }) @@ -119,13 +182,17 @@ export function buildAlertPayload( embed.addFields({ name: 'Jump to messages', value: jumpLinks }); - if (autoActed) - embed.addFields({ - name: '🔒 Auto-action taken', - value: AUTO_ACTION_NOTE[incident.level], - }); + if (autoActed) { + const autoNote = + incident.tier === 'critical' ? CRITICAL_NOTE : AUTO_ACTION_NOTE[incident.level]; + embed.addFields({ name: '🔒 Auto-action taken', value: autoNote }); + } + // Row 1: confirmscam, confirm, timeout, ban (4 buttons) + // Row 2: delete, dismiss (2 buttons) + // Total: 6 buttons across 2 rows — no row exceeds the Discord limit of 5. const row1 = new ActionRowBuilder().addComponents( + actionButton('confirmscam', '⛔ Confirm scam → ban', ButtonStyle.Danger, incident.id), actionButton('confirm', '✅ Confirm spam', ButtonStyle.Danger, incident.id), actionButton('timeout', '⏳ Timeout', ButtonStyle.Secondary, incident.id), actionButton('ban', '🔨 Ban', ButtonStyle.Danger, incident.id) From b8d19fc7cb4389ae7fe3f1056bf658216be7bf7e Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:42:05 -0500 Subject: [PATCH 34/42] feat: confirm-scam bans + trains, not-spam allowlists Co-Authored-By: Claude --- src/interaction-handlers/spamModeration.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/interaction-handlers/spamModeration.ts b/src/interaction-handlers/spamModeration.ts index 1a797ec..f6f9ff8 100644 --- a/src/interaction-handlers/spamModeration.ts +++ b/src/interaction-handlers/spamModeration.ts @@ -14,7 +14,8 @@ import { type ButtonInteraction, } from 'discord.js'; import { deleteIncident, getIncident } from '../utils/automod/incidents'; -import { addToBlocklist } from '../utils/automod/blocklist'; +import { addToBlocklist, addToAllowlist } from '../utils/automod/blocklist'; +import { tokenizeOcr, learnKeywords } from '../utils/automod/keywords'; import { banMember, deleteIncidentMessages, @@ -132,12 +133,27 @@ export class SpamModerationHandler extends InteractionHandler { summary = `🗑️ Deleted ${deleted} message(s).`; break; } + case 'confirmscam': { + const deleted = await deleteIncidentMessages(container.client, incident); + const targets = [...new Set([incident.userId, ...incident.clusterUserIds])].slice(0, 10); + let banned = 0; + for (const id of targets) + if (await banMember(guild, id, `Confirmed scam by ${moderator.tag}`)) banned++; + await addToBlocklist(incident.signatures, incident.hashes, moderator.id, 'confirmed scam', 'scam'); + if (incident.ocrText) await learnKeywords(tokenizeOcr(incident.ocrText), moderator.id); + clearUser(incident.userId); + clearFloodUser(incident.userId); + clearCrosspostUser(incident.userId); + summary = `⛔ Confirmed scam — banned ${banned}/${targets.length} account(s), deleted ${deleted} message(s), trained blocklist + keywords.`; + break; + } case 'dismiss': { + await addToAllowlist(incident.signatures, incident.hashes, moderator.id, 'marked not spam'); clearUser(incident.userId); clearFloodUser(incident.userId); clearCrosspostUser(incident.userId); summary = - '👌 Marked as not spam. Cleared tracking for this user; no action taken.'; + '👌 Marked as not spam. Cleared tracking for this user; no action taken and allowlisted the image(s).'; break; } default: From 7bfc05645921739538c72c0bf6514a87a72db7ce Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:44:52 -0500 Subject: [PATCH 35/42] feat: orchestrate multi-signal scored image detection Co-Authored-By: Claude --- src/listeners/messageCreate.ts | 150 +++++++++++++++++++++++++-------- 1 file changed, 117 insertions(+), 33 deletions(-) diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 11bdabc..00966a6 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -1,8 +1,9 @@ import { Events, Listener, container } from '@sapphire/framework'; import { PermissionFlagsBits, type GuildMember, type Message } from 'discord.js'; import { SERVER_ID, MOD_LOG_CHANNEL_ID, automodConfig } from '../utils/config'; -import { imageSignatures } from '../utils/automod/signature'; +import { imageSignatures, isImageAttachment } from '../utils/automod/signature'; import { perceptualHashes } from '../utils/automod/phash'; +import { ocrMessage } from '../utils/automod/ocr'; import { recordImageMessage, shouldAlert, @@ -19,7 +20,10 @@ import { markCrosspostAlerted, tokenize, } from '../utils/automod/crosspost'; -import { isBlocklisted } from '../utils/automod/blocklist'; +import { isBlocklisted, isAllowlisted } from '../utils/automod/blocklist'; +import { recordAndCluster } from '../utils/automod/globalIndex'; +import { matchKeywords } from '../utils/automod/keywords'; +import { scoreSignals } from '../utils/automod/score'; import { createIncident, type Incident, @@ -29,6 +33,7 @@ import { buildAlertPayload, deleteIncidentMessages, timeoutMember, + banMember, } from '../utils/automod/console'; export class MessageCreateListener extends Listener { @@ -55,9 +60,17 @@ export class MessageCreateListener extends Listener { // Perceptual hashes are best-effort (network fetch + decode); the metadata // signatures still cover anything that fails to hash. const hashes = await perceptualHashes(message); + + // Moderator-vetted images are never spam: short-circuit before spending any + // OCR / clustering work on them. + if (isAllowlisted(signatures, hashes)) return; + + // OCR text feeds the scam-keyword signal; best-effort and may be ''. + const ocrText = await ocrMessage(message); + // New members get a stricter burst threshold so join-then-spam trips // faster; tenure is useless for compromised veterans, who are instead - // caught by the fan-out/blocklist signals below. + // caught by the fan-out/blocklist/cluster signals below. const isNewMember = this.isNewMember(message.member, now); const detection = recordImageMessage( message.author.id, @@ -73,46 +86,113 @@ export class MessageCreateListener extends Listener { : {} ); - const { blocked, severity: _severity, matched } = isBlocklisted(signatures, hashes); + // Cross-user clustering: the same image posted by several accounts. + const cluster = recordAndCluster({ + userId: message.author.id, + channelId: message.channelId, + messageId: message.id, + at: now, + signatures, + hashes, + }); + + const { blocked, severity } = isBlocklisted(signatures, hashes); - let level: IncidentLevel | null = null; - if (blocked) level = 'blocklist'; - else if (detection.level !== 'none') level = detection.level; - if (!level) return; + // Corroborating content signals. + const imageCount = [...message.attachments.values()].filter( + isImageAttachment + ).length; + const imageOnlyPair = message.content.trim() === '' && imageCount === 2; + const hasLinkOrMention = + /https?:\/\//i.test(message.content) || message.mentions.everyone; + + // Fold every signal into a single confidence score + tier. + const signals = { + scamBlocklist: severity === 'scam', + spamBlocklist: severity === 'spam', + clusterUsers: cluster.userIds.length, + fanoutChannels: + detection.level === 'fanout' ? detection.channels.length : 0, + keywordMatches: matchKeywords(ocrText).length, + newAccount: isNewMember, + hasLinkOrMention: Boolean(hasLinkOrMention), + imageOnlyPair, + burst: detection.level === 'burst', + }; + const { score, tier, matched: matchedSignals } = scoreSignals(signals); + if (tier === 'none') return; + if (tier === 'low' && !automodConfig.logLowConfidence) return; if (!shouldAlert(message.author.id, now)) return; markAlerted(message.author.id, now); - // For blocklist-only hits the detector found no cluster, so the incident is - // just the current message; otherwise use the contributing events. - const messages = - detection.level === 'none' - ? [{ channelId: message.channelId, messageId: message.id }] - : detection.events.map((e) => ({ - channelId: e.channelId, - messageId: e.messageId, - })); + // Deduped message list: cluster events + per-user detection events + the + // current message, keyed by message id. + const msgMap = new Map(); + for (const e of cluster.events) + msgMap.set(e.messageId, { + channelId: e.channelId, + messageId: e.messageId, + }); + if (detection.level !== 'none') + for (const e of detection.events) + msgMap.set(e.messageId, { + channelId: e.channelId, + messageId: e.messageId, + }); + msgMap.set(message.id, { + channelId: message.channelId, + messageId: message.id, + }); + const messages = [...msgMap.values()]; + + // Incident level drives the console title/label; the tier drives colour and + // auto-action. A blocklist hit is always labelled as such; otherwise a + // cross-user cluster or channel fan-out reads as fan-out, else a burst. + const level: IncidentLevel = blocked + ? 'blocklist' + : signals.clusterUsers >= automodConfig.clusterMinUsers || + signals.fanoutChannels >= automodConfig.fanoutChannels + ? 'fanout' + : 'burst'; + + const reason = blocked + ? `Matched a known ${severity} image` + : signals.clusterUsers >= automodConfig.clusterMinUsers + ? `Same image from ${signals.clusterUsers} accounts across ${new Set(cluster.events.map((e) => e.channelId)).size} channel(s)` + : detection.reason || 'Image flagged by confidence scoring'; const incident = createIncident({ userId: message.author.id, guildId: message.guildId, level, - reason: - level === 'blocklist' - ? `Matched ${matched.length} known spam image${matched.length === 1 ? '' : 's'}` - : detection.reason, + reason, messages, - // For a fresh detection, blocklist the fingerprints that triggered it. - // For a blocklist hit, reinforce with this message's fingerprints. - signatures: level === 'blocklist' ? signatures : detection.signatures, - hashes: level === 'blocklist' ? hashes : detection.hashes, + signatures, + hashes, + score, + tier, + matched: matchedSignals, + clusterUserIds: cluster.userIds, + ocrText, + severity: severity ?? 'spam', }); - // Tiered enforcement: high-confidence signals act immediately; a - // same-channel burst only alerts and waits for a human. - const highConfidence = level === 'fanout' || level === 'blocklist'; + // Tiered enforcement, capped at the approved ceiling: only a confirmed scam + // blocklist match (the sole path to `critical`) auto-bans; `high` deletes + // and times out; medium/low alert only and wait for a human. let autoActed = false; - if (highConfidence) { + if (tier === 'critical') { + await deleteIncidentMessages(container.client, incident).catch(() => 0); + const targets = [ + ...new Set([message.author.id, ...cluster.userIds]), + ].slice(0, 10); + for (const id of targets) + await banMember(message.guild, id, `Automod: ${reason}`).catch( + () => false + ); + autoActed = true; + } else if (tier === 'high') { const deleted = await deleteIncidentMessages( container.client, incident @@ -120,12 +200,13 @@ export class MessageCreateListener extends Listener { const timedOut = await timeoutMember( message.guild, message.author.id, - `Automod: ${incident.reason}` + `Automod: ${reason}` ).catch(() => false); autoActed = deleted > 0 || timedOut; } - await this.postAlert(message, incident, autoActed); + const previewUrl = message.attachments.first()?.proxyURL; + await this.postAlert(message, incident, autoActed, previewUrl); } /** @@ -259,7 +340,8 @@ export class MessageCreateListener extends Listener { private async postAlert( message: Message, incident: Incident, - autoActed: boolean + autoActed: boolean, + previewUrl?: string ): Promise { const channel = await container.client.channels .fetch(MOD_LOG_CHANNEL_ID) @@ -275,6 +357,8 @@ export class MessageCreateListener extends Listener { message.member ?? (await message.guild.members.fetch(message.author.id).catch(() => null)); - await channel.send(buildAlertPayload(incident, member, autoActed)); + await channel.send( + buildAlertPayload(incident, member, autoActed, previewUrl) + ); } } From 8ab3ef70aa7f0fc84775cf767fb4509bf16977b9 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:45:59 -0500 Subject: [PATCH 36/42] feat: load learned keywords on startup Co-Authored-By: Claude --- src/listeners/ready.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index f1b0807..7d936fd 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -2,6 +2,7 @@ import { Events, Listener } from '@sapphire/framework'; import type { Client } from 'discord.js'; import { initDatabase } from '../utils/db'; import { loadBlocklist } from '../utils/automod/blocklist'; +import { loadKeywords } from '../utils/automod/keywords'; import { seedInviteCache } from '../utils/inviteCache'; import { startStaleHelpSweep } from '../utils/staleHelpSweep'; import { JOIN_LEAVE_LOG_CHANNEL_ID, SERVER_ID } from '../utils/config'; @@ -21,6 +22,8 @@ export class ReadyListener extends Listener { await initDatabase(); // Warm the automod blocklist from the database (no-op without one). await loadBlocklist(); + // Load learned scam keywords from the database (no-op without one). + await loadKeywords(); // Seed the invite-use cache so join logging can attribute the source. await this.fillInviteCache(client); // Begin nudging/auto-archiving stale help posts (no-op unless configured). From 71430b7eb28ff40ee25c3b42c57cb425ce80b5da Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:53:12 -0500 Subject: [PATCH 37/42] fix: harden automod auto-ban and media handling per security review - auto-critical path bans only the confirmed-scam author, not unverified pHash-cluster bystanders (each raider is banned as author of their own blocklist-matching message); cluster is surfaced for one-click mod action - banMember now skips automod-immune and unbannable in-guild members on every path (auto + manual confirmscam), while still banning departed raid accounts - bound fetched image size (8MB) and add a Jimp-decode timeout to prevent decompression-bomb / hang DoS - add a pHash fetch/decode concurrency cap for raids (AUTOMOD_PHASH_MAX_CONCURRENCY) Co-Authored-By: Claude --- .gitignore | 3 +++ src/listeners/messageCreate.ts | 17 ++++++++++------- src/utils/automod/console.ts | 22 ++++++++++++++++++++-- src/utils/automod/ocr.ts | 5 +++++ src/utils/automod/phash.ts | 34 +++++++++++++++++++++++++++++++++- src/utils/config.ts | 2 ++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 07c4a48..7ab96e0 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,6 @@ scratch.md # Local spam sample — never commit spam_example.png + +# Local working files — never commit +local/ diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 00966a6..337ffdc 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -184,13 +184,16 @@ export class MessageCreateListener extends Listener { let autoActed = false; if (tier === 'critical') { await deleteIncidentMessages(container.client, incident).catch(() => 0); - const targets = [ - ...new Set([message.author.id, ...cluster.userIds]), - ].slice(0, 10); - for (const id of targets) - await banMember(message.guild, id, `Automod: ${reason}`).catch( - () => false - ); + // Auto-ban ONLY the confirmed-scam author. Cluster members are joined by + // lenient pHash similarity and are not independently confirmed, so we do + // not auto-ban them here — each raider posting the blocklisted image is + // banned as the author of their own message. The cluster is still recorded + // on the incident and surfaced in the alert for one-click mod action. + await banMember( + message.guild, + message.author.id, + `Automod: ${reason}` + ).catch(() => false); autoActed = true; } else if (tier === 'high') { const deleted = await deleteIncidentMessages( diff --git a/src/utils/automod/console.ts b/src/utils/automod/console.ts index e801235..944ab59 100644 --- a/src/utils/automod/console.ts +++ b/src/utils/automod/console.ts @@ -3,11 +3,12 @@ import { ButtonBuilder, ButtonStyle, EmbedBuilder, + PermissionFlagsBits, TimestampStyles, time, type Client, type Guild, - type GuildMember, + GuildMember, type MessageCreateOptions, } from 'discord.js'; import { container } from '@sapphire/framework'; @@ -219,12 +220,29 @@ export async function timeoutMember( .catch(() => false); } -/** Ban a member and scrub their last day of messages. */ +/** + * Whether a member is exempt from automod enforcement: staff (Manage Messages) + * or any configured immune role. + */ +function isAutomodImmune(member: GuildMember): boolean { + if (member.permissions.has(PermissionFlagsBits.ManageMessages)) return true; + return automodConfig.immuneRoleIds.some((id) => member.roles.cache.has(id)); +} + +/** + * Ban a member and scrub their last day of messages. Guards every ban path + * (auto and manual): if the target is still in the guild and is either + * automod-immune or not bannable (hierarchy/perms), the ban is skipped and + * `false` is returned. A user who has already left the guild is banned by id + * (departed raid accounts are intended targets). + */ export async function banMember( guild: Guild, userId: string, reason: string ): Promise { + const member = await guild.members.fetch(userId).catch(() => null); + if (member && (!member.bannable || isAutomodImmune(member))) return false; return guild.members .ban(userId, { reason, deleteMessageSeconds: 24 * 60 * 60 }) .then(() => true) diff --git a/src/utils/automod/ocr.ts b/src/utils/automod/ocr.ts index 7436740..055f373 100644 --- a/src/utils/automod/ocr.ts +++ b/src/utils/automod/ocr.ts @@ -74,9 +74,14 @@ async function ocrAttachment(a: Attachment): Promise { } finally { active--; } } +/** Cap on fetched image bytes, to bound memory / decompression-bomb risk. */ +const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB + async function runOcr(a: Attachment): Promise { const res = await fetch(readableUrl(a), { signal: AbortSignal.timeout(automodConfig.ocrTimeoutMs) }); if (!res.ok) throw new Error(`OCR fetch failed with status ${res.status}`); + const len = Number(res.headers.get('content-length') ?? 0); + if (len > MAX_IMAGE_BYTES) throw new Error('image too large'); const buffer = Buffer.from(await res.arrayBuffer()); const worker = await getWorker(); const { data } = await worker.recognize(buffer); diff --git a/src/utils/automod/phash.ts b/src/utils/automod/phash.ts index 7df62e5..0a38fb2 100644 --- a/src/utils/automod/phash.ts +++ b/src/utils/automod/phash.ts @@ -1,6 +1,7 @@ import { Jimp, intToRGBA } from 'jimp'; import type { Attachment, Message } from 'discord.js'; import { container } from '@sapphire/framework'; +import { automodConfig } from '../config'; import { isImageAttachment } from './signature'; // pHash parameters: resize to DCT_INPUT_SIZE×DCT_INPUT_SIZE, then keep the @@ -12,6 +13,27 @@ const DCT_COEFF_SIZE = 8; const MAX_ATTACHMENTS = 4; /** Per-fetch timeout. */ const FETCH_TIMEOUT_MS = 4000; +/** Bound on the Jimp decode of a single image (a crafted image can hang). */ +const DECODE_TIMEOUT_MS = 4000; +/** Cap on fetched image bytes, to bound memory / decompression-bomb risk. */ +const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB + +/** In-flight fetch/decode count, to cap fan-out during a raid. */ +let active = 0; + +/** + * Race a promise against a timeout, resolving to `null` if the timeout wins. + * Used to bound a potentially hanging Jimp decode. + */ +function withTimeout(p: Promise, ms: number): Promise { + let t: ReturnType; + const timeout = new Promise((res) => { + t = setTimeout(() => res(null), ms); + }); + return Promise.race([p, timeout]).finally(() => clearTimeout(t!)) as Promise< + T | null + >; +} /** * Pre-computed cosine table for the 1-D DCT: @@ -138,19 +160,29 @@ export async function perceptualHashes(message: Message): Promise { const hashes = await Promise.all( images.map(async (attachment) => { + // Best-effort concurrency guard: under raid load we skip rather than + // fan out unbounded fetches/decodes. A skip just means no pHash for this + // attachment (the metadata signature still covers it). + if (active >= automodConfig.phashMaxConcurrency) return null; + active++; try { const response = await fetch(thumbnailUrl(attachment), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) return null; + const len = Number(response.headers.get('content-length') ?? 0); + if (len > MAX_IMAGE_BYTES) return null; const buffer = Buffer.from(await response.arrayBuffer()); - return await pHashFromBuffer(buffer); + // Bound the decode: a crafted image can hang Jimp.read with no abort. + return await withTimeout(pHashFromBuffer(buffer), DECODE_TIMEOUT_MS); } catch (error) { container.logger.debug( `Automod: could not perceptual-hash attachment ${attachment.id}:`, error ); return null; + } finally { + active--; } }) ); diff --git a/src/utils/config.ts b/src/utils/config.ts index 80a2b6f..9eb35e7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -133,6 +133,8 @@ export const automodConfig = { * same image. Higher = more lenient/near-duplicate matching. Default 6. */ phashThreshold: posInt(process.env.AUTOMOD_PHASH_THRESHOLD, 6), + /** Max concurrent pHash fetch/decode operations, to bound raid fan-out. */ + phashMaxConcurrency: posInt(process.env.AUTOMOD_PHASH_MAX_CONCURRENCY, 3), /** Sliding window (ms) for detecting cross-channel fan-out. */ fanoutWindowMs: posInt(process.env.AUTOMOD_FANOUT_WINDOW_MS, 120_000), /** How long (ms) auto-applied/console timeouts last. Default 1 hour. */ From ddf57cf8095afa0c5fbb24ec52e8b9b70694212f Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 02:55:48 -0500 Subject: [PATCH 38/42] docs: document cluster, scoring, OCR, and phash env vars for deploy Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 20 ++++++++++++++++++++ docker-compose.yml | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.env.example b/.env.example index 3580642..fed94d6 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,23 @@ MOD_LOG_CHANNEL_ID= # AUTOMOD_CROSSPOST_MIN_CHARS=12 # ignore messages shorter than this (greetings/reactions) # AUTOMOD_CROSSPOST_SIMILARITY_PCT=80 # token-overlap % at which two messages are the "same" question # AUTOMOD_CROSSPOST_AUTO_DELETE=false # true = auto-delete duplicate copies; default alert-only + +# --- Cross-user cluster (coordinated raid detection) --- +# AUTOMOD_CLUSTER_MIN_USERS=2 # distinct accounts posting the same/near image to flag a raid +# AUTOMOD_CLUSTER_WINDOW_MS=120000 # sliding window (ms) for cross-user image clustering + +# --- Confidence scoring (0-100) --- +# AUTOMOD_SCORE_HIGH=50 # >= this auto-deletes + times out (the auto-action threshold) +# AUTOMOD_SCORE_MEDIUM=30 # >= this posts an alert (no auto-action) +# AUTOMOD_SCORE_LOW=15 # >= this logs a low-confidence detection +# AUTOMOD_LOG_LOW_CONFIDENCE=false # true = also post low-confidence hits to mod log (noisier) + +# --- OCR (scam-text reading) --- +# OCR_ENABLED=true # set false to disable OCR entirely +# AUTOMOD_OCR_TIMEOUT_MS=5000 # per-image OCR timeout in ms; on timeout the image is skipped +# AUTOMOD_OCR_MAX_CONCURRENCY=2 # max simultaneous OCR jobs +# AUTOMOD_OCR_IMAGE_WIDTH=640 # width (px) images are fetched at for OCR (larger = more readable, slower) +# AUTOMOD_SCAM_KEYWORDS= # comma-separated list that REPLACES built-in seed keywords; leave unset for defaults + +# --- Perceptual hash backpressure --- +# AUTOMOD_PHASH_MAX_CONCURRENCY=3 # max simultaneous pHash fetch/decode jobs (raid backpressure) diff --git a/docker-compose.yml b/docker-compose.yml index b6add7e..c27c57d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,26 @@ services: AUTOMOD_CROSSPOST_SIMILARITY_PCT: ${AUTOMOD_CROSSPOST_SIMILARITY_PCT:-} AUTOMOD_CROSSPOST_AUTO_DELETE: ${AUTOMOD_CROSSPOST_AUTO_DELETE:-} + # ── Cross-user cluster (coordinated raid detection) ─────────────────── + AUTOMOD_CLUSTER_MIN_USERS: ${AUTOMOD_CLUSTER_MIN_USERS:-} + AUTOMOD_CLUSTER_WINDOW_MS: ${AUTOMOD_CLUSTER_WINDOW_MS:-} + + # ── Confidence scoring (0-100) ──────────────────────────────────────── + AUTOMOD_SCORE_HIGH: ${AUTOMOD_SCORE_HIGH:-} + AUTOMOD_SCORE_MEDIUM: ${AUTOMOD_SCORE_MEDIUM:-} + AUTOMOD_SCORE_LOW: ${AUTOMOD_SCORE_LOW:-} + AUTOMOD_LOG_LOW_CONFIDENCE: ${AUTOMOD_LOG_LOW_CONFIDENCE:-} + + # ── OCR (scam-text reading) ─────────────────────────────────────────── + OCR_ENABLED: ${OCR_ENABLED:-} + AUTOMOD_OCR_TIMEOUT_MS: ${AUTOMOD_OCR_TIMEOUT_MS:-} + AUTOMOD_OCR_MAX_CONCURRENCY: ${AUTOMOD_OCR_MAX_CONCURRENCY:-} + AUTOMOD_OCR_IMAGE_WIDTH: ${AUTOMOD_OCR_IMAGE_WIDTH:-} + AUTOMOD_SCAM_KEYWORDS: ${AUTOMOD_SCAM_KEYWORDS:-} + + # ── Perceptual hash backpressure ────────────────────────────────────── + AUTOMOD_PHASH_MAX_CONCURRENCY: ${AUTOMOD_PHASH_MAX_CONCURRENCY:-} + # Migrate the database schema, then start the bot. command: sh -c "node_modules/.bin/prisma migrate deploy && npm start" From 20b825a0b0757488ec59b8bb271fd7983ac03127 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Wed, 8 Jul 2026 03:02:00 -0500 Subject: [PATCH 39/42] chore: gitignore internal working docs Co-Authored-By: Claude --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 7ab96e0..34d7497 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,7 @@ spam_example.png # Local working files — never commit local/ + +# Internal working docs — never commit/push +HANDOFF.md +docs/superpowers/ From 74f31f2bd29b682c8748d469567513b123971f93 Mon Sep 17 00:00:00 2001 From: Max Bromberg Date: Fri, 10 Jul 2026 00:57:59 -0500 Subject: [PATCH 40/42] fix: pin Node base image and add toolchain for native build The `node:lts-alpine` tag drifted to Node 24, whose ABI (v137) has no @sapphire/type prebuilt binary, forcing a source compile that failed for lack of Python/g++ in the image. Pin to node:22-alpine and install the build toolchain so node-gyp can compile the native module. Co-Authored-By: Claude --- Dockerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b1bd54c..52182c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,14 @@ -FROM node:lts-alpine +# Pinned (not `lts`, which floated up to Node 24 and broke native builds). +FROM node:22-alpine LABEL org.opencontainers.image.source https://github.com/arduinodiscord/bot WORKDIR /srv +# @sapphire/type publishes no Linux prebuilt binary, so node-gyp compiles it +# from source on install — that needs Python and a C/C++ toolchain. +RUN apk add --no-cache python3 make g++ + # Install dependencies first for better layer caching. The postinstall hook runs # `prisma generate`, which needs the schema, so copy prisma/ before installing. COPY package*.json ./ From 1d823d5c5436e8aa4c12fd369bc1c08cdc3c3fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 18:59:53 +0000 Subject: [PATCH 41/42] feat: learning mode so a cold-start corpus still surfaces suspicious images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh deployment the blocklist and learned keywords are empty, so the confidence scorer's strong signals can never fire for a lone spammer and almost every detection died below the alert gate — which meant moderators never saw alerts and could never train the corpus that would have raised the scores. This closes that deadlock: - Learning mode (AUTOMOD_LEARNING_MODE, default 'auto'): while the blocklist holds fewer than AUTOMOD_LEARNING_CORPUS_TARGET confirmed fingerprints, every image message with any nonzero suspicion signal is posted to the mod log, alert-only, with a note explaining why. Auto-actions still require the normal thresholds. 'on'/'off' force the mode either way. - A fired burst detection is now floored at the 'low' tier; its 12-point weight sat below scoreLow, so bursts from established members were computed and then silently discarded. - New 'suspect' incident level so keyword/tenure/link-only hits are no longer mislabelled as bursts in the console. - New scoring signal: a URL inside the image text itself (OCR) — scam images usually carry their payload link in the image where the content check cannot see it. - OCR worker is pre-warmed at startup (the first createWorker call downloads the language model, which blew the per-image OCR budget and silenced the keyword signal), and a failed init is retried instead of cached forever. - Startup now logs learning-mode state and warns loudly when MOD_LOG_CHANNEL_ID is unset; suppressed low-confidence detections are logged so 'why didn't this alert' is answerable from the logs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0189aaDKv8PrTxqCcbEhaz9D --- .env.example | 9 ++++++ SETUP.md | 9 +++++- docker-compose.yml | 4 +++ src/listeners/messageCreate.ts | 45 ++++++++++++++++++++++++++---- src/listeners/ready.ts | 33 ++++++++++++++++++++-- src/utils/automod/blocklist.ts | 18 ++++++++++++ src/utils/automod/console.ts | 13 +++++++++ src/utils/automod/incidents.ts | 16 +++++++---- src/utils/automod/learning.test.ts | 38 +++++++++++++++++++++++++ src/utils/automod/learning.ts | 22 +++++++++++++++ src/utils/automod/ocr.ts | 28 ++++++++++++++++++- src/utils/automod/score.test.ts | 12 +++++++- src/utils/automod/score.ts | 6 ++++ src/utils/config.ts | 21 ++++++++++++++ 14 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 src/utils/automod/learning.test.ts create mode 100644 src/utils/automod/learning.ts diff --git a/.env.example b/.env.example index fed94d6..0c2d8db 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,15 @@ MOD_LOG_CHANNEL_ID= # AUTOMOD_SCORE_LOW=15 # >= this logs a low-confidence detection # AUTOMOD_LOG_LOW_CONFIDENCE=false # true = also post low-confidence hits to mod log (noisier) +# --- Learning (ramp-up) mode --- +# On a fresh deployment the blocklist/keyword corpus is empty, so almost nothing +# scores high enough to alert — and mods can't train a corpus they never see. +# While learning mode is active, EVERY image with any nonzero suspicion signal +# is posted to the mod log (alert-only; auto-actions still require the normal +# thresholds) so moderators can Confirm/Dismiss and train the filter. +# AUTOMOD_LEARNING_MODE=auto # auto = active until the corpus target is met; on/off force it +# AUTOMOD_LEARNING_CORPUS_TARGET=20 # confirmed blocklist fingerprints at which 'auto' retires + # --- OCR (scam-text reading) --- # OCR_ENABLED=true # set false to disable OCR entirely # AUTOMOD_OCR_TIMEOUT_MS=5000 # per-image OCR timeout in ms; on timeout the image is skipped diff --git a/SETUP.md b/SETUP.md index 3243cfa..50a2e7a 100644 --- a/SETUP.md +++ b/SETUP.md @@ -105,7 +105,11 @@ Automod: loaded 0 signature(s) and 0 perceptual hash(es) from the blocklist. Type `/` in your server — the bot's commands (`/tag`, `/solved`, `/openposts`, `/ping`, `/about`, `/say`) should appear. **Automod console active?** -Confirm `MOD_LOG_CHANNEL_ID` is set. Post a test image in two different channels quickly — you should see an alert appear in the mod-log channel within seconds. +Confirm `MOD_LOG_CHANNEL_ID` is set (the bot logs a startup warning if it isn't). Post a test image in two different channels quickly — you should see an alert appear in the mod-log channel within seconds. + +> ⚠️ Test with a **non-moderator account**: anyone with Manage Messages (or a role in `AUTOMOD_IMMUNE_ROLE_IDS`) is exempt from all automod inspection, so images posted by staff never trigger alerts. + +While the blocklist corpus is still small, **learning mode** is active (see below): any image with at least one suspicion signal — scam keywords read from the image, a link, a burst, a new account — is posted to the mod log so moderators can train the filter with the Confirm/Not-spam buttons. **Help-channel features active?** Open a new thread in a configured help channel. You should see a "Mark Solved" button appear. Post a short message with no code/image in a new thread — the needinfo checklist should appear automatically. @@ -159,6 +163,9 @@ All variables are optional except `BOT_TOKEN`. Leaving a variable blank uses the | `AUTOMOD_TIMEOUT_MS` | `3600000` | Duration of auto-applied or console timeouts (1h) | | `AUTOMOD_ALERT_COOLDOWN_MS` | `30000` | Minimum gap between alerts for the same user | | `AUTOMOD_IMMUNE_ROLE_IDS` | *(none)* | Comma-separated role IDs that are never inspected | +| `AUTOMOD_LEARNING_MODE` | `auto` | Ramp-up mode: while active, every image with any nonzero suspicion signal is posted to the mod log (alert-only) so moderators can train the corpus. `auto` retires itself at the corpus target; `on`/`off` force it | +| `AUTOMOD_LEARNING_CORPUS_TARGET` | `20` | Confirmed blocklist fingerprints at which `auto` learning mode switches to normal confidence gating | +| `AUTOMOD_LOG_LOW_CONFIDENCE` | `false` | After learning mode retires, set `true` to keep posting low-confidence hits | ### Text-flooding automod diff --git a/docker-compose.yml b/docker-compose.yml index c27c57d..5dc8979 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,10 @@ services: AUTOMOD_SCORE_LOW: ${AUTOMOD_SCORE_LOW:-} AUTOMOD_LOG_LOW_CONFIDENCE: ${AUTOMOD_LOG_LOW_CONFIDENCE:-} + # ── Learning (ramp-up) mode ─────────────────────────────────────────── + AUTOMOD_LEARNING_MODE: ${AUTOMOD_LEARNING_MODE:-} + AUTOMOD_LEARNING_CORPUS_TARGET: ${AUTOMOD_LEARNING_CORPUS_TARGET:-} + # ── OCR (scam-text reading) ─────────────────────────────────────────── OCR_ENABLED: ${OCR_ENABLED:-} AUTOMOD_OCR_TIMEOUT_MS: ${AUTOMOD_OCR_TIMEOUT_MS:-} diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 337ffdc..aa415f8 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -21,6 +21,7 @@ import { tokenize, } from '../utils/automod/crosspost'; import { isBlocklisted, isAllowlisted } from '../utils/automod/blocklist'; +import { learningModeActive } from '../utils/automod/learning'; import { recordAndCluster } from '../utils/automod/globalIndex'; import { matchKeywords } from '../utils/automod/keywords'; import { scoreSignals } from '../utils/automod/score'; @@ -105,6 +106,9 @@ export class MessageCreateListener extends Listener { const imageOnlyPair = message.content.trim() === '' && imageCount === 2; const hasLinkOrMention = /https?:\/\//i.test(message.content) || message.mentions.everyone; + // Scam images usually carry their payload link inside the image itself, + // where the message-content check above cannot see it. + const ocrHasLink = /https?:\/\/|www\.|discord\.gg\/|t\.me\//i.test(ocrText); // Fold every signal into a single confidence score + tier. const signals = { @@ -116,12 +120,34 @@ export class MessageCreateListener extends Listener { keywordMatches: matchKeywords(ocrText).length, newAccount: isNewMember, hasLinkOrMention: Boolean(hasLinkOrMention), + ocrHasLink, imageOnlyPair, burst: detection.level === 'burst', }; const { score, tier, matched: matchedSignals } = scoreSignals(signals); - if (tier === 'none') return; - if (tier === 'low' && !automodConfig.logLowConfidence) return; + const signalSummary = + matchedSignals.map((m) => `${m.label} (+${m.points})`).join(', ') || 'none'; + container.logger.debug( + `Automod: image from ${message.author.id} in ${message.channelId} scored ${score} (${tier}); signals: ${signalSummary}` + ); + + // While learning mode is active (empty/small corpus), ANY nonzero + // suspicion signal is surfaced so moderators can train the blocklist and + // keywords; the usual tier gate takes over once the corpus has grown. + const learning = learningModeActive(); + if (learning) { + if (score <= 0) return; + } else { + if (tier === 'none') return; + if (tier === 'low' && !automodConfig.logLowConfidence) { + // A real detection died at the gate — say so, or "why didn't this + // alert?" requires a code audit. + container.logger.info( + `Automod: suppressed low-confidence image alert for ${message.author.id} (score ${score}; signals: ${signalSummary}). Set AUTOMOD_LOG_LOW_CONFIDENCE=true to post these.` + ); + return; + } + } if (!shouldAlert(message.author.id, now)) return; markAlerted(message.author.id, now); @@ -148,13 +174,17 @@ export class MessageCreateListener extends Listener { // Incident level drives the console title/label; the tier drives colour and // auto-action. A blocklist hit is always labelled as such; otherwise a - // cross-user cluster or channel fan-out reads as fan-out, else a burst. + // cross-user cluster or channel fan-out reads as fan-out, a burst as a + // burst, and anything scored purely from corroborating signals (keywords, + // tenure, links) as a plain suspect. const level: IncidentLevel = blocked ? 'blocklist' : signals.clusterUsers >= automodConfig.clusterMinUsers || signals.fanoutChannels >= automodConfig.fanoutChannels ? 'fanout' - : 'burst'; + : signals.burst + ? 'burst' + : 'suspect'; const reason = blocked ? `Matched a known ${severity} image` @@ -171,11 +201,16 @@ export class MessageCreateListener extends Listener { signatures, hashes, score, - tier, + // A learning-mode hit may score below every threshold ('none'); it is + // still shown to moderators, as the lowest-confidence bucket. + tier: tier === 'none' ? 'low' : tier, matched: matchedSignals, clusterUserIds: cluster.userIds, ocrText, severity: severity ?? 'spam', + // Flag alerts that only exist because of learning mode, so the console + // explains why moderators are seeing a low-confidence hit. + learning: learning && (tier === 'none' || (tier === 'low' && !automodConfig.logLowConfidence)), }); // Tiered enforcement, capped at the approved ceiling: only a confirmed scam diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index 7d936fd..2e5c9bc 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -1,11 +1,18 @@ import { Events, Listener } from '@sapphire/framework'; import type { Client } from 'discord.js'; import { initDatabase } from '../utils/db'; -import { loadBlocklist } from '../utils/automod/blocklist'; +import { blocklistSize, loadBlocklist } from '../utils/automod/blocklist'; import { loadKeywords } from '../utils/automod/keywords'; +import { learningModeActive } from '../utils/automod/learning'; +import { warmOcr } from '../utils/automod/ocr'; import { seedInviteCache } from '../utils/inviteCache'; import { startStaleHelpSweep } from '../utils/staleHelpSweep'; -import { JOIN_LEAVE_LOG_CHANNEL_ID, SERVER_ID } from '../utils/config'; +import { + JOIN_LEAVE_LOG_CHANNEL_ID, + MOD_LOG_CHANNEL_ID, + SERVER_ID, + automodConfig, +} from '../utils/config'; export class ReadyListener extends Listener { public constructor(context: Listener.Context, options: Listener.Options) { @@ -24,12 +31,34 @@ export class ReadyListener extends Listener { await loadBlocklist(); // Load learned scam keywords from the database (no-op without one). await loadKeywords(); + this.logAutomodStatus(); + // Pre-initialize the OCR worker (model download) off the message path. + void warmOcr(); // Seed the invite-use cache so join logging can attribute the source. await this.fillInviteCache(client); // Begin nudging/auto-archiving stale help posts (no-op unless configured). startStaleHelpSweep(client); } + /** Surface automod state at startup so a silent misconfiguration is visible. */ + private logAutomodStatus(): void { + if (!MOD_LOG_CHANNEL_ID) { + this.container.logger.warn( + 'Automod: MOD_LOG_CHANNEL_ID is not set — ALL spam detection is disabled and no alerts will be posted.' + ); + return; + } + if (learningModeActive()) { + this.container.logger.info( + `Automod: learning mode ACTIVE (blocklist ${blocklistSize()}/${automodConfig.learningCorpusTarget} confirmed fingerprints, mode=${automodConfig.learningMode}) — every image with any suspicion signal is posted to the mod log for training.` + ); + } else { + this.container.logger.info( + `Automod: learning mode inactive (blocklist ${blocklistSize()} fingerprint(s), mode=${automodConfig.learningMode}) — normal confidence gating applies.` + ); + } + } + private async fillInviteCache(client: Client): Promise { // Only needed when join logging is enabled; fetching invites requires the // Manage Server permission, so failures are non-fatal. diff --git a/src/utils/automod/blocklist.ts b/src/utils/automod/blocklist.ts index ba7f555..3e37187 100644 --- a/src/utils/automod/blocklist.ts +++ b/src/utils/automod/blocklist.ts @@ -18,6 +18,24 @@ const allowHashes = new Set(); export type FingerprintKind = 'meta' | 'phash'; +/** Total confirmed fingerprints (metadata + perceptual) in the blocklist. */ +export function blocklistSize(): number { + return exactSignatures.size + perceptualHashes.size; +} + +/** Test-only reset. */ +export function __resetBlocklist(): void { + for (const set of [ + exactSignatures, + perceptualHashes, + scamSignatures, + scamHashes, + allowSignatures, + allowHashes, + ]) + set.clear(); +} + export interface BlocklistMatch { blocked: boolean; severity: 'scam' | 'spam' | null; diff --git a/src/utils/automod/console.ts b/src/utils/automod/console.ts index 944ab59..7726181 100644 --- a/src/utils/automod/console.ts +++ b/src/utils/automod/console.ts @@ -34,6 +34,7 @@ const LEVEL_LABEL: Record = { fanout: 'Cross-channel fan-out', blocklist: 'Known spam image', burst: 'Image burst', + suspect: 'Suspicious image', flood: 'Message flooding', crosspost: 'Cross-channel question spam', }; @@ -43,6 +44,7 @@ const LEVEL_TITLE: Record = { fanout: '🚨 Possible image spam', blocklist: '🚨 Possible image spam', burst: '🚨 Possible image spam', + suspect: '🚨 Possible image spam', flood: '🚨 Possible message flooding', crosspost: '🚨 Possible cross-channel question spam', }; @@ -56,6 +58,7 @@ const AUTO_ACTION_NOTE: Record = { fanout: HIGH_CONFIDENCE_NOTE, blocklist: HIGH_CONFIDENCE_NOTE, burst: HIGH_CONFIDENCE_NOTE, + suspect: HIGH_CONFIDENCE_NOTE, flood: 'The user was timed out automatically. Review and escalate or reverse below.', crosspost: 'The duplicate crossposts were deleted automatically (the first copy was kept). Review and escalate or reverse below.', @@ -189,6 +192,16 @@ export function buildAlertPayload( embed.addFields({ name: '🔒 Auto-action taken', value: autoNote }); } + if (incident.learning) { + embed.addFields({ + name: '📚 Learning mode', + value: + 'Posted because the spam corpus is still training — this may well be legit. ' + + 'Use **Confirm spam / Confirm scam → ban / Not spam** to teach the filter; ' + + 'learning mode retires itself once enough images are confirmed.', + }); + } + // Row 1: confirmscam, confirm, timeout, ban (4 buttons) // Row 2: delete, dismiss (2 buttons) // Total: 6 buttons across 2 rows — no row exceeds the Discord limit of 5. diff --git a/src/utils/automod/incidents.ts b/src/utils/automod/incidents.ts index 4c9c9fd..a624efe 100644 --- a/src/utils/automod/incidents.ts +++ b/src/utils/automod/incidents.ts @@ -1,14 +1,17 @@ import { randomUUID } from 'node:crypto'; /** - * The kinds of incident the moderation console can surface. `burst`, `fanout` - * and `blocklist` come from the image-spam detector; `flood` from the - * text-flooding detector. + * The kinds of incident the moderation console can surface. `burst`, `fanout`, + * `blocklist` and `suspect` come from the image-spam detector (`suspect` is a + * scored hit with no burst/fan-out/blocklist detection behind it, e.g. OCR + * keywords or new-account corroboration); `flood` from the text-flooding + * detector. */ export type IncidentLevel = | 'burst' | 'fanout' | 'blocklist' + | 'suspect' | 'flood' | 'crosspost'; @@ -41,6 +44,8 @@ export interface Incident { ocrText: string; /** Category to use when a moderator confirms and blocklists this incident. */ severity: 'scam' | 'spam'; + /** Whether this alert was posted by learning mode below the normal tier gate. */ + learning: boolean; } /** @@ -59,9 +64,9 @@ const TTL_MS = 60 * 60 * 1000; */ type IncidentInput = Omit< Incident, - 'id' | 'createdAt' | 'score' | 'tier' | 'matched' | 'clusterUserIds' | 'ocrText' | 'severity' + 'id' | 'createdAt' | 'score' | 'tier' | 'matched' | 'clusterUserIds' | 'ocrText' | 'severity' | 'learning' > & - Partial>; + Partial>; export function createIncident(data: IncidentInput): Incident { const incident: Incident = { @@ -74,6 +79,7 @@ export function createIncident(data: IncidentInput): Incident { clusterUserIds: data.clusterUserIds ?? [data.userId], ocrText: data.ocrText ?? '', severity: data.severity ?? 'spam', + learning: data.learning ?? false, }; incidents.set(incident.id, incident); return incident; diff --git a/src/utils/automod/learning.test.ts b/src/utils/automod/learning.test.ts new file mode 100644 index 0000000..50bbd97 --- /dev/null +++ b/src/utils/automod/learning.test.ts @@ -0,0 +1,38 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { automodConfig } from '../config'; +import { learningModeActive } from './learning'; +import { addToBlocklist, __resetBlocklist } from './blocklist'; + +afterEach(() => { + __resetBlocklist(); + automodConfig.learningMode = 'auto'; +}); + +test('auto: active while the corpus is below the target', () => { + assert.equal(learningModeActive(), true); +}); + +test('auto: retires once the corpus reaches the target', async () => { + const signatures = Array.from( + { length: automodConfig.learningCorpusTarget }, + (_, i) => `sig-${i}` + ); + await addToBlocklist(signatures, [], 'test-mod', 'test'); + assert.equal(learningModeActive(), false); +}); + +test('forced on stays active regardless of corpus size', async () => { + automodConfig.learningMode = 'on'; + const signatures = Array.from( + { length: automodConfig.learningCorpusTarget }, + (_, i) => `sig-${i}` + ); + await addToBlocklist(signatures, [], 'test-mod', 'test'); + assert.equal(learningModeActive(), true); +}); + +test('forced off is inactive even with an empty corpus', () => { + automodConfig.learningMode = 'off'; + assert.equal(learningModeActive(), false); +}); diff --git a/src/utils/automod/learning.ts b/src/utils/automod/learning.ts new file mode 100644 index 0000000..e38ef8b --- /dev/null +++ b/src/utils/automod/learning.ts @@ -0,0 +1,22 @@ +import { automodConfig } from '../config'; +import { blocklistSize } from './blocklist'; + +/** + * Whether learning (ramp-up) mode is currently active. + * + * The confidence scorer's strong signals — blocklist hits and learned + * keywords — only exist after moderators have confirmed alerts, but on a cold + * start nothing scores high enough to BE alerted, so the corpus can never + * grow. Learning mode breaks that deadlock: while active, every image message + * with any nonzero suspicion signal is posted to the mod log (alert-only + * below the usual auto-action thresholds) so moderators can click + * Confirm spam / Confirm scam / Not spam and train the filter. + * + * 'auto' (the default) keeps learning mode on until the blocklist holds + * `learningCorpusTarget` confirmed fingerprints; 'on' and 'off' force it. + */ +export function learningModeActive(): boolean { + if (automodConfig.learningMode === 'on') return true; + if (automodConfig.learningMode === 'off') return false; + return blocklistSize() < automodConfig.learningCorpusTarget; +} diff --git a/src/utils/automod/ocr.ts b/src/utils/automod/ocr.ts index 055f373..ba99e9a 100644 --- a/src/utils/automod/ocr.ts +++ b/src/utils/automod/ocr.ts @@ -8,10 +8,36 @@ import { isImageAttachment, attachmentSignature } from './signature'; // No manual load() / loadLanguage() / initialize() calls needed. let workerPromise: Promise | null = null; function getWorker(): Promise { - if (!workerPromise) workerPromise = Tesseract.createWorker('eng'); + if (!workerPromise) { + workerPromise = Tesseract.createWorker('eng'); + // A failed init (e.g. a transient error downloading the language model) + // must not be cached forever, or OCR would stay dead until a restart. + workerPromise.catch(() => { + workerPromise = null; + }); + } return workerPromise; } +/** + * Pre-initialize the OCR worker at startup. The first createWorker() call + * spawns the worker and downloads the language model, which easily blows the + * per-image OCR budget — without warming, the keyword signal silently reads + * '' for every image until the worker finishes initializing. + */ +export async function warmOcr(): Promise { + if (!automodConfig.ocrEnabled) return; + try { + await getWorker(); + container.logger.info('Automod: OCR worker ready.'); + } catch (error) { + container.logger.warn( + 'Automod: OCR worker failed to initialize (keyword signal degraded; will retry on demand):', + error + ); + } +} + // Cache OCR text by attachment signature so a raid's repeated image is read // once. Only genuine completions are cached (see ocrAttachment) — a transient // timeout or fetch error must never poison the cache and permanently suppress diff --git a/src/utils/automod/score.test.ts b/src/utils/automod/score.test.ts index a9b622b..1e474cf 100644 --- a/src/utils/automod/score.test.ts +++ b/src/utils/automod/score.test.ts @@ -6,7 +6,7 @@ const base: Signals = { scamBlocklist: false, spamBlocklist: false, clusterUsers: 0, fanoutChannels: 0, keywordMatches: 0, newAccount: false, hasLinkOrMention: false, - imageOnlyPair: false, burst: false, + ocrHasLink: false, imageOnlyPair: false, burst: false, }; test('scam blocklist forces critical', () => { @@ -30,3 +30,13 @@ test('corroborating signals alone never exceed medium', () => { test('no signals is none', () => { assert.equal(scoreSignals(base).tier, 'none'); }); + +test('a fired burst detection is always at least low', () => { + const r = scoreSignals({ ...base, burst: true }); + assert.equal(r.tier, 'low'); +}); + +test('a link inside the image text contributes to the score', () => { + const r = scoreSignals({ ...base, ocrHasLink: true }); + assert.equal(r.score, 12); +}); diff --git a/src/utils/automod/score.ts b/src/utils/automod/score.ts index 1fb62dd..8c57786 100644 --- a/src/utils/automod/score.ts +++ b/src/utils/automod/score.ts @@ -8,6 +8,7 @@ export interface Signals { keywordMatches: number; // distinct OCR scam keywords matched newAccount: boolean; hasLinkOrMention: boolean; + ocrHasLink: boolean; // a URL / invite link inside the image text itself imageOnlyPair: boolean; // image-only message with exactly 2 images burst: boolean; } @@ -39,6 +40,7 @@ export function scoreSignals(s: Signals): ScoreResult { corrob.push({ label: `OCR scam keywords x${s.keywordMatches}`, points: Math.min(s.keywordMatches * 12, 30) }); if (s.newAccount) corrob.push({ label: 'New / low-tenure account', points: 18 }); if (s.hasLinkOrMention) corrob.push({ label: 'Contains link or @everyone/@here', points: 18 }); + if (s.ocrHasLink) corrob.push({ label: 'Link inside image text (OCR)', points: 12 }); if (s.imageOnlyPair) corrob.push({ label: 'Image-only, exactly 2 images', points: 8 }); if (s.burst) corrob.push({ label: 'Image burst', points: 12 }); @@ -50,6 +52,10 @@ export function scoreSignals(s: Signals): ScoreResult { if (score >= automodConfig.scoreHigh) tier = 'high'; else if (score >= automodConfig.scoreMedium) tier = 'medium'; else if (score >= automodConfig.scoreLow) tier = 'low'; + // A fired burst detection is always worth at least a low-confidence alert: + // its weight alone sits below scoreLow, and without this floor a burst from + // an established member is computed and then silently discarded. + else if (s.burst) tier = 'low'; return { score, tier, matched: [...strong, ...corrob] }; } diff --git a/src/utils/config.ts b/src/utils/config.ts index 9eb35e7..6b15993 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -36,6 +36,14 @@ const posInt = (value: string | undefined, fallback: number): number => { return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; }; +/** Parse the learning-mode switch: 'on' / 'off' force it, anything else is 'auto'. */ +const parseLearningMode = (value: string | undefined): 'auto' | 'on' | 'off' => { + const raw = (value ?? 'auto').trim().toLowerCase(); + if (raw === 'on' || raw === 'true') return 'on'; + if (raw === 'off' || raw === 'false') return 'off'; + return 'auto'; +}; + /** Parse a comma-separated list of ids from the environment. */ const idList = (value: string | undefined): string[] => (value ?? '') @@ -221,6 +229,19 @@ export const automodConfig = { scoreLow: posInt(process.env.AUTOMOD_SCORE_LOW, 15), logLowConfidence: process.env.AUTOMOD_LOG_LOW_CONFIDENCE === 'true', + // --- Learning (ramp-up) mode --- + /** + * While learning mode is active, EVERY image message that produces any + * nonzero suspicion signal is posted to the mod log (alert-only below the + * auto-action thresholds) so moderators can train the blocklist and keyword + * corpus from a cold start. 'auto' (default) keeps it active until the + * blocklist holds `learningCorpusTarget` confirmed fingerprints; 'on' and + * 'off' force it regardless of corpus size. + */ + learningMode: parseLearningMode(process.env.AUTOMOD_LEARNING_MODE), + /** Confirmed blocklist fingerprints at which 'auto' learning mode retires. */ + learningCorpusTarget: posInt(process.env.AUTOMOD_LEARNING_CORPUS_TARGET, 20), + // --- OCR --- ocrEnabled: process.env.OCR_ENABLED !== 'false', ocrTimeoutMs: posInt(process.env.AUTOMOD_OCR_TIMEOUT_MS, 5000), From f66e24b3d31d2c44ad20e0f96f8d33396ea95dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 19:42:16 +0000 Subject: [PATCH 42/42] feat: opt-in learning catch-all posts every image while training MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUTOMOD_LEARNING_CATCH_ALL=true widens learning mode from 'any nonzero suspicion signal' to every image message, including zero-signal ones, so nothing can slip past while the corpus trains. Off by default — in a busy server it is a firehose. Zero-signal alerts say so in their reason line instead of claiming a confidence-scoring hit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0189aaDKv8PrTxqCcbEhaz9D --- .env.example | 1 + SETUP.md | 1 + docker-compose.yml | 1 + src/listeners/messageCreate.ts | 10 +++++++--- src/listeners/ready.ts | 5 ++++- src/utils/config.ts | 7 +++++++ 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 0c2d8db..071f425 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,7 @@ MOD_LOG_CHANNEL_ID= # thresholds) so moderators can Confirm/Dismiss and train the filter. # AUTOMOD_LEARNING_MODE=auto # auto = active until the corpus target is met; on/off force it # AUTOMOD_LEARNING_CORPUS_TARGET=20 # confirmed blocklist fingerprints at which 'auto' retires +# AUTOMOD_LEARNING_CATCH_ALL=false # true = while learning, post EVERY image (even zero-signal) — a firehose in busy servers # --- OCR (scam-text reading) --- # OCR_ENABLED=true # set false to disable OCR entirely diff --git a/SETUP.md b/SETUP.md index 50a2e7a..bf56b20 100644 --- a/SETUP.md +++ b/SETUP.md @@ -165,6 +165,7 @@ All variables are optional except `BOT_TOKEN`. Leaving a variable blank uses the | `AUTOMOD_IMMUNE_ROLE_IDS` | *(none)* | Comma-separated role IDs that are never inspected | | `AUTOMOD_LEARNING_MODE` | `auto` | Ramp-up mode: while active, every image with any nonzero suspicion signal is posted to the mod log (alert-only) so moderators can train the corpus. `auto` retires itself at the corpus target; `on`/`off` force it | | `AUTOMOD_LEARNING_CORPUS_TARGET` | `20` | Confirmed blocklist fingerprints at which `auto` learning mode switches to normal confidence gating | +| `AUTOMOD_LEARNING_CATCH_ALL` | `false` | Set `true` to post **every** image message (even zero-signal ones) while learning mode is active. Guarantees nothing slips past during training, but is a firehose in a busy server | | `AUTOMOD_LOG_LOW_CONFIDENCE` | `false` | After learning mode retires, set `true` to keep posting low-confidence hits | ### Text-flooding automod diff --git a/docker-compose.yml b/docker-compose.yml index 5dc8979..2271cd5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,7 @@ services: # ── Learning (ramp-up) mode ─────────────────────────────────────────── AUTOMOD_LEARNING_MODE: ${AUTOMOD_LEARNING_MODE:-} AUTOMOD_LEARNING_CORPUS_TARGET: ${AUTOMOD_LEARNING_CORPUS_TARGET:-} + AUTOMOD_LEARNING_CATCH_ALL: ${AUTOMOD_LEARNING_CATCH_ALL:-} # ── OCR (scam-text reading) ─────────────────────────────────────────── OCR_ENABLED: ${OCR_ENABLED:-} diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index aa415f8..909542e 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -133,10 +133,11 @@ export class MessageCreateListener extends Listener { // While learning mode is active (empty/small corpus), ANY nonzero // suspicion signal is surfaced so moderators can train the blocklist and - // keywords; the usual tier gate takes over once the corpus has grown. + // keywords — or, with the catch-all opted in, every image message. The + // usual tier gate takes over once the corpus has grown. const learning = learningModeActive(); if (learning) { - if (score <= 0) return; + if (score <= 0 && !automodConfig.learningCatchAll) return; } else { if (tier === 'none') return; if (tier === 'low' && !automodConfig.logLowConfidence) { @@ -190,7 +191,10 @@ export class MessageCreateListener extends Listener { ? `Matched a known ${severity} image` : signals.clusterUsers >= automodConfig.clusterMinUsers ? `Same image from ${signals.clusterUsers} accounts across ${new Set(cluster.events.map((e) => e.channelId)).size} channel(s)` - : detection.reason || 'Image flagged by confidence scoring'; + : detection.reason || + (score > 0 + ? 'Image flagged by confidence scoring' + : 'No suspicion signals — posted by the learning catch-all'); const incident = createIncident({ userId: message.author.id, diff --git a/src/listeners/ready.ts b/src/listeners/ready.ts index 2e5c9bc..cadc40c 100644 --- a/src/listeners/ready.ts +++ b/src/listeners/ready.ts @@ -49,8 +49,11 @@ export class ReadyListener extends Listener { return; } if (learningModeActive()) { + const coverage = automodConfig.learningCatchAll + ? 'EVERY image message is posted to the mod log (catch-all enabled)' + : 'every image with any suspicion signal is posted to the mod log'; this.container.logger.info( - `Automod: learning mode ACTIVE (blocklist ${blocklistSize()}/${automodConfig.learningCorpusTarget} confirmed fingerprints, mode=${automodConfig.learningMode}) — every image with any suspicion signal is posted to the mod log for training.` + `Automod: learning mode ACTIVE (blocklist ${blocklistSize()}/${automodConfig.learningCorpusTarget} confirmed fingerprints, mode=${automodConfig.learningMode}) — ${coverage} for training.` ); } else { this.container.logger.info( diff --git a/src/utils/config.ts b/src/utils/config.ts index 6b15993..2ba2692 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -241,6 +241,13 @@ export const automodConfig = { learningMode: parseLearningMode(process.env.AUTOMOD_LEARNING_MODE), /** Confirmed blocklist fingerprints at which 'auto' learning mode retires. */ learningCorpusTarget: posInt(process.env.AUTOMOD_LEARNING_CORPUS_TARGET, 20), + /** + * When true, learning mode posts EVERY image message — even ones with zero + * suspicion signals — to the mod log. Off by default: in a busy server this + * is a firehose, but it guarantees nothing slips past while the corpus + * trains. Only applies while learning mode is active. + */ + learningCatchAll: process.env.AUTOMOD_LEARNING_CATCH_ALL === 'true', // --- OCR --- ocrEnabled: process.env.OCR_ENABLED !== 'false',