Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
51e148d
Add image-spam automod with moderator console
max-bromberg Jun 11, 2026
83e2cba
Bump discord.js to 14.26 and Sapphire framework to 5.5
max-bromberg Jun 11, 2026
f2886d6
Migrate Prisma 4 to 7 with the pg driver adapter
max-bromberg Jun 11, 2026
0dbcdbc
Bump TypeScript to 6, dotenv to 17, @types/node to 25
max-bromberg Jun 11, 2026
3f5fb0c
Add tenure-aware detection for new members (automod prong 2)
max-bromberg Jun 11, 2026
5c044d1
Add perceptual (dHash) image hashing to the automod
max-bromberg Jun 11, 2026
f162ea1
Port legacy server-management listeners (parity)
max-bromberg Jun 11, 2026
c7ccb71
Port role-select buttons, tag buttons, and a modern /say (parity)
max-bromberg Jun 11, 2026
2c3b69d
Cleanliness pass: tag refactor, Dockerfile fix, ping + docs
max-bromberg Jun 11, 2026
b75c70a
Resolve PR #22 feedback and fix tag grammar/spelling
max-bromberg Jun 11, 2026
8bb885e
Refactor: extract shared embed out of the entry point
max-bromberg Jun 12, 2026
1e79dff
Release v1.0.0: new README, fix repo URLs
max-bromberg Jun 12, 2026
9fd044f
Add Phase 1 helper-assist features
max-bromberg Jun 12, 2026
acc7ab4
Add text-flooding automod detector
claude Jun 12, 2026
8edd57a
Add cross-channel question-spam automod
claude Jun 12, 2026
00b48aa
Add Phase 2 helper-assist features
max-bromberg Jun 12, 2026
6d809b9
Lengthen stale-post timing; support text + forum help channels
max-bromberg Jun 12, 2026
fd89c40
feat: replace dHash with DCT-based pHash for image spam detection
max-bromberg Jun 15, 2026
e80acb4
feat: complete docker-compose env coverage and add Dockge setup guide
max-bromberg Jun 15, 2026
74effe8
feat: harden helper-assist for community rollout
max-bromberg Jun 15, 2026
c5cbcea
fix: replace implicit role check with explicit SUGGEST_IMMUNE_ROLE_ID…
max-bromberg Jun 15, 2026
2ec5cc6
chore: use generic Claude co-author line in commit convention
max-bromberg Jul 8, 2026
e62899a
feat: make crosspost duplicate-delete opt-in (alert-only default)
max-bromberg Jul 8, 2026
bc0c393
feat: add severity, allowlist, and learned-keyword tables
max-bromberg Jul 8, 2026
bccec42
feat: add config knobs for cluster, scoring, and OCR
max-bromberg Jul 8, 2026
6e2b8e6
feat: add pure confidence-scoring engine with tests
max-bromberg Jul 8, 2026
1093468
feat: add cross-user image cluster index with tests
max-bromberg Jul 8, 2026
5d153f3
feat: add seed+learned scam keyword store with tests
max-bromberg Jul 8, 2026
44981ef
feat: add best-effort OCR module (tesseract.js)
max-bromberg Jul 8, 2026
13ca6ec
feat: add scam severity and allowlist to the blocklist
max-bromberg Jul 8, 2026
bd508bc
fix: harden OCR cache against transient-failure poisoning
max-bromberg Jul 8, 2026
5851ce1
feat: carry score, tier, cluster, and OCR on incidents
max-bromberg Jul 8, 2026
0dc3906
feat: show confidence, signals, thumbnail, and confirm-scam button
max-bromberg Jul 8, 2026
b8d19fc
feat: confirm-scam bans + trains, not-spam allowlists
max-bromberg Jul 8, 2026
7bfc056
feat: orchestrate multi-signal scored image detection
max-bromberg Jul 8, 2026
8ab3ef7
feat: load learned keywords on startup
max-bromberg Jul 8, 2026
71430b7
fix: harden automod auto-ban and media handling per security review
max-bromberg Jul 8, 2026
ddf57cf
docs: document cluster, scoring, OCR, and phash env vars for deploy
max-bromberg Jul 8, 2026
20b825a
chore: gitignore internal working docs
max-bromberg Jul 8, 2026
74f31f2
fix: pin Node base image and add toolchain for native build
max-bromberg Jul 10, 2026
1d823d5
feat: learning mode so a cold-start corpus still surfaces suspicious …
claude Jul 12, 2026
f66e24b
feat: opt-in learning catch-all posts every image while training
claude Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# --- 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=

# --- 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

# --- 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=
# 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
# 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=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
# 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_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

# --- 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)

# --- 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
# AUTOMOD_CROSSPOST_AUTO_DELETE=false # true = auto-delete duplicate copies; default alert-only

# --- Cross-user cluster (coordinated raid detection) ---
# AUTOMOD_CLUSTER_MIN_USERS=2 # distinct accounts posting the same/near image to flag a raid
# AUTOMOD_CLUSTER_WINDOW_MS=120000 # sliding window (ms) for cross-user image clustering

# --- Confidence scoring (0-100) ---
# AUTOMOD_SCORE_HIGH=50 # >= this auto-deletes + times out (the auto-action threshold)
# AUTOMOD_SCORE_MEDIUM=30 # >= this posts an alert (no auto-action)
# AUTOMOD_SCORE_LOW=15 # >= this logs a low-confidence detection
# AUTOMOD_LOG_LOW_CONFIDENCE=false # true = also post low-confidence hits to mod log (noisier)

# --- Learning (ramp-up) mode ---
# On a fresh deployment the blocklist/keyword corpus is empty, so almost nothing
# scores high enough to alert — and mods can't train a corpus they never see.
# While learning mode is active, EVERY image with any nonzero suspicion signal
# is posted to the mod log (alert-only; auto-actions still require the normal
# thresholds) so moderators can Confirm/Dismiss and train the filter.
# AUTOMOD_LEARNING_MODE=auto # auto = active until the corpus target is met; on/off force it
# AUTOMOD_LEARNING_CORPUS_TARGET=20 # confirmed blocklist fingerprints at which 'auto' retires
# AUTOMOD_LEARNING_CATCH_ALL=false # true = while learning, post EVERY image (even zero-signal) — a firehose in busy servers

# --- OCR (scam-text reading) ---
# OCR_ENABLED=true # set false to disable OCR entirely
# AUTOMOD_OCR_TIMEOUT_MS=5000 # per-image OCR timeout in ms; on timeout the image is skipped
# AUTOMOD_OCR_MAX_CONCURRENCY=2 # max simultaneous OCR jobs
# AUTOMOD_OCR_IMAGE_WIDTH=640 # width (px) images are fetched at for OCR (larger = more readable, slower)
# AUTOMOD_SCAM_KEYWORDS= # comma-separated list that REPLACES built-in seed keywords; leave unset for defaults

# --- Perceptual hash backpressure ---
# AUTOMOD_PHASH_MAX_CONCURRENCY=3 # max simultaneous pHash fetch/decode jobs (raid backpressure)
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,13 @@ build

# Scratch notes files
scratch.md

# Local spam sample — never commit
spam_example.png

# Local working files — never commit
local/

# Internal working docs — never commit/push
HANDOFF.md
docs/superpowers/
84 changes: 84 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
npm run dev # hot-reloading dev server (ts-node-dev, transpile-only)
npm run build # TypeScript compile to dist/
npm start # run compiled output

# Docker (bot + Postgres, migrations auto-applied)
docker compose up -d
docker compose down
```

There is no test suite. Type-check via `npm run build`.

To add a new slash command, register it in `src/commands/` following the Sapphire `Command` pattern.

PRs target the `staging` branch (not `main`).

## Commits

Format: `action: description` (e.g. `feat: add debounce tag`, `fix: stale sweep not resetting on reply`, `refactor: unify suggestion engine`).

Always commit with Max as primary author and Claude as co-author:
```
Co-Authored-By: Claude <noreply@anthropic.com>
```

## Architecture

**Framework:** [Sapphire](https://www.sapphirejs.dev) on top of discord.js v14. Sapphire auto-discovers and registers files from:
- `src/commands/` — slash commands
- `src/listeners/` — Discord gateway event handlers
- `src/interaction-handlers/` — button/select-menu interaction handlers

**Entry point:** `src/index.ts` — creates the `SapphireClient` with the required gateway intents and calls `client.login()`.

**Configuration:** `src/utils/config.ts` is the single source of truth for all env vars. Every feature self-disables when its channel/role ID env var is unset. Automod thresholds all have sensible defaults overridable via `AUTOMOD_*` env vars. Only `BOT_TOKEN` is required.

**Database:** Optional Postgres via Prisma 7 with the `@prisma/adapter-pg` driver adapter. `src/utils/db.ts` exports a Prisma client (or `null` when `DATABASE_URL` is unset). Automod runs fully in-memory without a DB; only the spam-image blocklist and analytics/audit tables require persistence. Schema: `prisma/schema.prisma`.

**Automod pipeline** (`src/utils/automod/`):
- `signature.ts` — cheap metadata fingerprint per image attachment
- `phash.ts` — perceptual hash (dHash) via Jimp for near-duplicate matching
- `tracker.ts` — in-memory sliding-window state for burst and cross-channel fan-out detection
- `flood.ts` — short-message flood detector
- `crosspost.ts` — cross-channel question-spam detector (Jaccard similarity + new-member spread)
- `blocklist.ts` — confirmed-spam fingerprint store (in-memory + DB)
- `incidents.ts` — creates `Incident` objects passed to the console
- `console.ts` — builds moderator alert embeds with action buttons (Confirm / Timeout / Ban / Delete / Not spam)

The whole pipeline is triggered in `src/listeners/messageCreate.ts`. The three detectors (image, flood, crosspost) run independently on each message.

**Tags** (`src/utils/tags.ts`): A `Record<string, Tag>` map. Each entry has optional `embeds`, `components`, `content` (string or `(user?) => string`), `botCommandsOnly`, and `suggest` (a `{ pattern: RegExp, prompt: string }` for keyword auto-suggestion). To add a tag: add it to `tags.ts` and add a choice in `src/commands/tag.ts`.

**Help-channel workflow:** Configured via `HELP_CHANNEL_IDS` (forum or text channels). New help threads get a "Mark Solved" button (`src/listeners/threadCreate.ts`), auto-needinfo if thin (`src/utils/helpPosts.ts`), and are tracked by the stale-post sweep (`src/utils/staleHelpSweep.ts`). The `/solved` command and `solvedButton` interaction handler close threads and update tags.

## `revamp` branch — work in progress

All active development is on `revamp`. The branch adds substantial new features on top of the automod core that was ported/built on `main`.

**Automod additions (revamp):**
- Text-flooding detector (`automod/flood.ts`) — flags N short messages from one user in a sliding window; alerts only by default, `AUTOMOD_FLOOD_AUTO_TIMEOUT=true` to also time out.
- Cross-channel question-spam detector (`automod/crosspost.ts`) — two signals: near-identical fan-out (Jaccard similarity ≥ threshold, any tenure) auto-deletes duplicates and alerts; new-member spread (posting substantively in many channels even when reworded) alerts only.

**Helper-assist suite (revamp, built in three phases):**

*Phase 1* — reduces repetitive load on community helpers:
- Keyword → tag auto-suggest (`src/listeners/tagSuggest.ts`): tag entries each carry a `suggest: { pattern, prompt }` field; the listener matches incoming messages and offers a one-click tag button with a per-user cooldown. Toggle: `TAG_SUGGEST_ENABLED`.
- "Request more info" message context-menu (`src/commands/requestInfo.ts`): right-click any message to post the `needinfo` checklist to the asker in one click.
- Solve workflow: `/solved [helper]` command + "Mark Solved" button on new help threads. Shared logic in `src/utils/solveThread.ts` titles the thread with a checkmark, credits the helper, and archives. OP or staff only.

*Phase 2* — broader coverage and close-the-loop on help posts:
- Tag `suggest` triggers co-located in `tags.ts` (moved out of `tagSuggest.ts`) — adding a suggestible tag is now a one-place change.
- Unformatted-code nudge: detects plain-text code pastes and offers the `codeblock` tag. Toggle: `CODE_FORMAT_SUGGEST_ENABLED`.
- "Just ask" nudge: conservative patterns catch "can I ask?" / "anyone here?" and reply with the `ask` tag. Toggle: `ASK_SUGGEST_ENABLED`.
- Auto-needinfo on thin posts: new help thread with short content and no code/image gets the `needinfo` checklist automatically. Toggle: `HELP_AUTO_NEEDINFO`, threshold: `HELP_NEEDINFO_MIN_CHARS`.
- Stale-post sweep (`src/utils/staleHelpSweep.ts`): nudges open posts idle past `HELP_STALE_NUDGE_HOURS` (default 72h), then auto-archives if no human replies within a further `HELP_STALE_ARCHIVE_HOURS` (default 168h). Runs on an unref'd interval started in the `ready` listener.
- `/openposts` command (`src/commands/openPosts.ts`): ephemeral digest of open help posts, oldest-waiting first. Backed by `src/utils/helpPosts.ts` (shared with the sweep).

*Phase 3 (timing refinement)* — lengthened stale-post defaults (24h→72h nudge, 72h→168h archive) and generalised help-channel support: `HELP_CHANNEL_IDS` accepts both forum channels and regular text channels; threads inside either get the same lifecycle. Legacy `HELP_FORUM_CHANNEL_IDS` still works and is merged in.
19 changes: 12 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
FROM node:lts-alpine
# Pinned (not `lts`, which floated up to Node 24 and broke native builds).
FROM node:22-alpine

LABEL org.opencontainers.image.source https://github.com/arduinodiscord/bot

WORKDIR /srv

COPY ./package*.json .
COPY ./prisma ./prisma

COPY . .

RUN apk add --update --no-cache openssl1.1-compat
# @sapphire/type publishes no Linux prebuilt binary, so node-gyp compiles it
# from source on install — that needs Python and a C/C++ toolchain.
RUN apk add --no-cache python3 make g++

# Install dependencies first for better layer caching. The postinstall hook runs
# `prisma generate`, which needs the schema, so copy prisma/ before installing.
COPY package*.json ./
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"]
Loading