- secondary rename-bot token was set as RENAME_TOKEN in .env but utils/renamer.js reads RENAMER_BOT; silently no-op'd every rename (host .env renamed separately)
- services/tickets.js canRename gutted to an always-ok shim; Mongo 2/10min per-channel gate is redundant since renames flow through RENAMER_BOT's own bucket. Ticket.renameCount / renameWindowStart remain as orphan fields (no migration)
- handlers/buttons.js + commands.js: drop the four "Channel renamed too quickly" else-branches and the rename-countdown label suffix; replace .catch(() => {}) with .catch(err => logError('rename', err)...)
- services/channelQueue.js: executeRename falls back to channel.setName(currentName) when renamer throws err.fallback === true (401/403/429); classifies non-fallback errors as renameQueue:token/permission (401/403) or renameQueue:secondary-bot ratelimited (429)
- utils/renamer.js: on 401/403 throw err.fallback=true immediately; on 429 respect retry_after up to 2000ms then throw err.fallback=true
- docs: align CLAUDE.md, docs/api/DISCORD_API_VALIDATION.md, docs/architecture/CRITICAL_FILES_AND_HOW_IT_WORKS.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
9.5 KiB
Markdown
122 lines
9.5 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Default mode: reviewer-first
|
||
|
||
Default output is **scoped improvement prompts**, not code edits. Output format:
|
||
|
||
```
|
||
## [Short title]
|
||
**Files:** [files to read/modify]
|
||
**Problem:** [1-2 sentences]
|
||
**Fix:** [specific instructions — what to change, not how to think about it]
|
||
**Verify:** [how to confirm]
|
||
```
|
||
|
||
Keep each prompt to a 5–20 minute task; decompose larger issues.
|
||
|
||
When the user asks for direct fixes, make them — but still avoid unsolicited refactors, rename sweeps, or cleanup beyond the stated scope.
|
||
|
||
## Project
|
||
- **broccolini-bot** — Discord ticketing + support bot for Indifferent Broccoli (game hosting).
|
||
- **Repo:** `/opt/broccolini-bot/` · **Gitea:** `ssh://git@100.114.205.53:2222/indifferentketchup/broccolini-bot.git`
|
||
- **DB:** MongoDB Atlas, database `broccoli_db`.
|
||
- **Host port 8892 → container port 5000** (`CONFIG.PORT`, env `DISCORD_ONLY_PORT`).
|
||
- **Deploy:** `cd /opt/broccolini-bot && git pull && docker compose up --build -d` · tail: `docker logs broccolini-bot --tail 50 -f`
|
||
|
||
## Commands
|
||
- `npm start` — run the bot (entry: `broccolini-discord.js`).
|
||
- `npm run start:test` — run with `ENV_FILE=.env.test`.
|
||
- `npm run start:1p` / `start:test:1p` — inject secrets via 1Password CLI (`op run`).
|
||
- `npm run test-mongodb` / `test-mongodb:test` — connectivity probe; no test suite exists.
|
||
- No lint step configured. No unit/integration test framework.
|
||
|
||
Many files under `scripts/` are one-shot maintenance utilities (backups, user lookups, transcript mapping). They are not wired into CI or into the bot's runtime.
|
||
|
||
## Stack
|
||
Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 (MongoDB Atlas) · googleapis · express-rate-limit · p-queue · dotenv/dotenv-expand.
|
||
|
||
## Hard Rules
|
||
|
||
1. **CommonJS only.** `require` / `module.exports`. Never `import`.
|
||
2. **Read before write.** Never propose or make changes to a file without first reading its current contents.
|
||
3. **Route channel operations through `services/channelQueue.js`**: `enqueueSend(channel, ...args)`, `enqueueRename(channel, name)`, `enqueueMove(channel, categoryId)`. Direct `channel.send(...)` / `channel.setName(...)` calls bypass ordering + rate-limit protection. **Audit note:** several files still bypass (`handlers/commands.js`, `handlers/buttons.js`, `handlers/accountinfo.js`, `handlers/setup.js`, `services/tickets.js`, `services/debugLog.js`, `services/patternChecker.js`, `services/surgeChecker.js`, `services/chatAlertChecker.js`, `services/staffChannel.js`, `routes/bosscord.js:191`) — treat as in-flight cleanup, migrate sends incrementally when touching those files.
|
||
4. **Logging is fire-and-forget.** Never `await logSystem/logError/logAutomation/logGmail/...`. Chain `.catch(() => {})` instead.
|
||
5. **Use `ChannelType` enum from `discord.js`**, not bare integers (`0`, `4`, `5`, `12`, `15`).
|
||
6. **Mongoose schema defaults:** pass function references (`default: Date.now`), never invocations (`default: Date.now()` pins all documents to module-load time).
|
||
7. **No unsolicited refactors.** Don't rename, reorganize, or restructure beyond the fix's scope.
|
||
8. **Backup before destructive data ops.** Provide the backup command first when the fix touches collections/files.
|
||
|
||
## Architecture
|
||
|
||
Single Node process. Entry: `broccolini-discord.js`.
|
||
|
||
### Startup order
|
||
1. Module load: env validation, Discord `Client` created, `interactionCreate` / `messageCreate` listeners registered, `client.login(...)` called.
|
||
2. Public Express app (`app`) is defined at module scope with a **503 gate** — any `/api/*` request before `appReady` returns 503.
|
||
3. `client.once('ready')` (fires after Discord handshake): connects MongoDB, mounts bOSScord routes on `/api` (only if `BOSSCORD_API_KEY` set), calls `app.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST)`, sets `appReady = true`, then starts all background `setInterval`s.
|
||
4. The **internal** Express app (`internalApp`) listens separately on `127.0.0.1:INTERNAL_API_PORT` at module load, guarded by `INTERNAL_API_SECRET`.
|
||
|
||
### Two HTTP surfaces
|
||
- **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`.
|
||
- **Internal (`internalApp`)** — `127.0.0.1` only, `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it.
|
||
|
||
### Intervals & shutdown
|
||
- Every `setInterval` inside `ready` is wrapped via `trackInterval(...)` into the module-scoped `activeIntervals` Set.
|
||
- `handleShutdown(signal)` is idempotent (`shuttingDown` flag): clears every tracked interval, closes both HTTP servers, calls `client.destroy()`, calls `closeMongoDB()`, then `process.exit(0)`. Wired to SIGTERM/SIGINT.
|
||
- `setGmailPollInterval(ms)` and `clearGmailPollInterval()` manage the Gmail poll handle and keep it in sync with `activeIntervals`.
|
||
|
||
### Interaction error handling
|
||
Every `interactionCreate` branch runs through `runHandler(name, interaction, fn)` which catches, `logError`s, and replies ephemerally `'Something went wrong.'` (uses `followUp` when the interaction is already deferred/replied). Setup buttons have their own try/catch for a custom error message.
|
||
|
||
### Tickets (`services/tickets.js`, `models.js`)
|
||
- `Ticket` schema has indexes on `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`.
|
||
- **Discord-originated tickets** use `gmailThreadId` with prefix `discord-` / `discord-msg-` — skip the Gmail reply path entirely.
|
||
- Renames route through `utils/renamer.js` (RENAMER_BOT secondary token). On 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`. `canRename()` is retained as an always-ok shim for back-compat. `Ticket.renameCount` / `Ticket.renameWindowStart` remain in the schema but are now unread/unwritten orphan fields.
|
||
- `getOrCreateTicketCategory()` handles Discord's 50-channels-per-category ceiling by creating `"<name> (Overflow N)"` categories; `cleanupEmptyOverflowCategory()` removes empties. The primary category is never deleted.
|
||
- Scheduled jobs in `ready`: `checkAutoClose`, `checkAutoUnclaim`, `reconcileDeletedTicketChannels`, plus `services/staffNotifications.js#notifyAllStaffUnclaimed` and the pattern/surge/chat checkers.
|
||
|
||
### Gmail bridge (`gmail-poll.js`, `services/gmail.js`)
|
||
- Polls `is:unread category:primary`, creates or appends to ticket channels.
|
||
- **Auth failure halts polling.** On `invalid_grant` / `unauthorized` / 401: `pollSuspended = true`, the poll interval is cleared via `require('./broccolini-discord').clearGmailPollInterval()`, admin is DM'd once. Polling does **not** auto-retry — container must restart after re-auth.
|
||
- `services/gmail.js` exports `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail`, `getGmailClient`. All HTML bodies go through `escapeHtml()`; `Date.now`-derived variables in templates come from `CONFIG` (`TICKET_CLOSE_MESSAGE`, `TICKET_CLOSE_SIGNATURE`, `SUPPORT_NAME`, `LOGO_URL`, `SIGNATURE`, `EMAIL_SIGNATURE`).
|
||
|
||
### Pattern / surge / chat (`services/patternStore.js` et al.)
|
||
- In-memory counters bucketed into `today` / `week` / `month`, with scheduled resets at midnight / Monday 00:00 / 1st 00:00.
|
||
- `escalatingCooldowns` entries carry a `lastUsed` timestamp; a 6-hour interval prunes entries idle for >48h. The cleanup interval is `.unref()`-ed so shutdown isn't blocked by it.
|
||
|
||
## bOSScord integration
|
||
|
||
bOSScord is a separate React + Express cockpit app that consumes this bot's `/api/*` endpoints.
|
||
- Base URL: `http://100.114.205.53:8892/api` · Bearer `${BOSSCORD_API_KEY}`.
|
||
- bOSScord uses its own database (`bosscord_db`) — do not mix models.
|
||
- **Response-shape changes on `/api/*` are breaking** for bOSScord. Coordinate or version.
|
||
|
||
## Known bad state
|
||
|
||
- **Gmail `invalid_grant`** — `REFRESH_TOKEN` is a stale placeholder. Poll suspends automatically on auth error; the rest of the bot still works. Fix by regenerating the token (`node get-refresh-token.js`) and restarting.
|
||
- **`STAFF_EMOJIS` encoding** — some emoji entries render malformed. Root cause not identified.
|
||
- **Escalation button** — handler misfires in some flows. Root cause not identified.
|
||
|
||
Do not re-report these as new findings.
|
||
|
||
## Environment highlights
|
||
|
||
Names and full tables are in `README.md` / `.env.example`. Ones that commonly trip up new code:
|
||
|
||
| Var | Notes |
|
||
|-----|-------|
|
||
| `DISCORD_TOKEN` **or** `DISCORD_BOT_TOKEN` | First non-empty after trim wins. |
|
||
| `DISCORD_ONLY_PORT` | Maps to `CONFIG.PORT` (default 5000). |
|
||
| `HEALTHCHECK_HOST` | Omit for all-interfaces; set `127.0.0.1` for local-only. |
|
||
| `BOSSCORD_API_KEY` | Without it, `/api/*` is never mounted. |
|
||
| `BOSSCORD_CLIENT_ORIGIN` | CORS origin for bOSScord (not `BOSSCORD_CORS_ORIGIN`). |
|
||
| `INTERNAL_API_SECRET` | Without it, the internal settings API is never started. |
|
||
| `INTERNAL_API_PORT` | Internal app's port (127.0.0.1 bind). |
|
||
| `REFRESH_TOKEN` | Gmail OAuth; currently stale — see Known bad state. |
|
||
|
||
## Settings site
|
||
|
||
`settings-site/` contains a separate Express app (`settings-site/server.js`) for the admin UI — it talks to `internalApp` via `INTERNAL_API_SECRET`. It is **not** part of this bot's process. Changes to the bot's `/internal/config` contract (e.g., the `ALLOWED_CONFIG_KEYS` set) may break the settings UI. See `settings-site/CLAUDE.md` for that subproject's architecture and conventions.
|