From 51e148df3000a1a11c777b2994eb33475ac83b01 Mon Sep 17 00:00:00 2001 From: Maxwell Bromberg Date: Thu, 11 Jun 2026 22:27:07 +0000 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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