Files
broccolini-bot/CLAUDE.md
2026-04-21 14:32:34 +00:00

10 KiB
Raw Blame History

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 520 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: Self-hosted MongoDB on same host as bot, database broccoli_db. Dedicated user per 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.
  • Verification: prefer node --check <file> for syntax, and inline node -e "..." for behavior. For tightly-coupled modules, stub deps via Module._resolveFilename override (see services/channelQueue.js tests in session history).

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 · 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, enqueueRename, enqueueMove, enqueueDelete (awaits both rename+send tails before deleting). Bypass sites are tagged // TODO(queue-migrate): — grep to find them; migrate incrementally when touching.
  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 setIntervals.
  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.

routes/internalApi.js is required at module scope by broccolini-discord.js before the parent's module.exports populates — reaching back to the parent (e.g., trackInterval, trackTimeout, clearGmailPollInterval) must use a lazy require('../broccolini-discord') inside the handler, not a top-level destructure.

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, logErrors, 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() in services/tickets.js 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.

.env persistence (services/configPersistence.js)

  • Values are stored in backtick containers because dotenv v17 only decodes \n/\r inside "…" (not \" or \\) — backticks preserve quotes + literal newlines verbatim. readEnvFile joins multi-line backtick values; writeEnvFile re-reads after write and throws on key-count mismatch.

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_grantREFRESH_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.

Its Docker build context is settings-site/ only — parent-repo files (e.g., utils.js) are unreachable inside the container. Any shared helper must be inlined or the build context widened in docker-compose.yml.