10 KiB
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: Self-hosted MongoDB on same host as bot, database
broccoli_db. Dedicated user per DB. - Host port 8892 → container port 5000 (
CONFIG.PORT, envDISCORD_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 withENV_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 inlinenode -e "..."for behavior. For tightly-coupled modules, stub deps viaModule._resolveFilenameoverride (seeservices/channelQueue.jstests 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
- CommonJS only.
require/module.exports. Neverimport. - Read before write. Never propose or make changes to a file without first reading its current contents.
- 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. - Logging is fire-and-forget. Never
await logSystem/logError/logAutomation/logGmail/.... Chain.catch(() => {})instead. - Use
ChannelTypeenum fromdiscord.js, not bare integers (0,4,5,12,15). - Mongoose schema defaults: pass function references (
default: Date.now), never invocations (default: Date.now()pins all documents to module-load time). - No unsolicited refactors. Don't rename, reorganize, or restructure beyond the fix's scope.
- 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
- Module load: env validation, Discord
Clientcreated,interactionCreate/messageCreatelisteners registered,client.login(...)called. - Public Express app (
app) is defined at module scope with a 503 gate — any/api/*request beforeappReadyreturns 503. client.once('ready')(fires after Discord handshake): connects MongoDB, mounts bOSScord routes on/api(only ifBOSSCORD_API_KEYset), callsapp.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST), setsappReady = true, then starts all backgroundsetIntervals.- The internal Express app (
internalApp) listens separately on0.0.0.0:INTERNAL_API_PORTinside the bot container at module load, guarded byINTERNAL_API_SECRET. Not publicly exposed — reachable only from peers on thebroccoli-netdocker network (notably the settings-site container).
Two HTTP surfaces
- Public (
app) —GET /healthcheck +/api/*(bOSScord consumer). CORS origin isprocess.env.BOSSCORD_CLIENT_ORIGIN(defaulthttp://100.114.205.53:3081). Rate-limited 60 req/min/IP. Auth:Authorization: Bearer ${BOSSCORD_API_KEY}. - Internal (
internalApp) —broccoli-netonly (binds0.0.0.0inside the bot container; no hostports:publish),/internal/*. Rate-limited 10 req/min. Auth:x-internal-secretheader.POST /configenforces an explicitALLOWED_CONFIG_KEYSallowlist; unknown keys return 400.POST /restartexits 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
setIntervalinsidereadyis wrapped viatrackInterval(...)into the module-scopedactiveIntervalsSet. handleShutdown(signal)is idempotent (shuttingDownflag): clears every tracked interval, closes both HTTP servers, callsclient.destroy(), callscloseMongoDB(), thenprocess.exit(0). Wired to SIGTERM/SIGINT.setGmailPollInterval(ms)andclearGmailPollInterval()manage the Gmail poll handle and keep it in sync withactiveIntervals.
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)
Ticketschema has indexes on{gmailThreadId}(unique),{status, lastActivity},{senderEmail, status},{discordThreadId}.- Discord-originated tickets use
gmailThreadIdwith prefixdiscord-/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.jsfalls back to the primary bot viachannel.setName.canRename()inservices/tickets.jsis retained as an always-ok shim for back-compat.Ticket.renameCount/Ticket.renameWindowStartremain 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, plusservices/staffNotifications.js#notifyAllStaffUnclaimedand 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 viarequire('./broccolini-discord').clearGmailPollInterval(), admin is DM'd once. Polling does not auto-retry — container must restart after re-auth. services/gmail.jsexportssendGmailReply,sendTicketClosedEmail,sendTicketNotificationEmail,getGmailClient. All HTML bodies go throughescapeHtml();Date.now-derived variables in templates come fromCONFIG(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. escalatingCooldownsentries carry alastUsedtimestamp; 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/\rinside"…"(not\"or\\) — backticks preserve quotes + literal newlines verbatim.readEnvFilejoins multi-line backtick values;writeEnvFilere-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_grant—REFRESH_TOKENis 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_EMOJISencoding — 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.