6.7 KiB
How it works
Broccolini Bot is a single Node.js process. It does three things at once:
- Listens to Discord — slash commands, button clicks, modals, ticket-channel messages.
- Polls Gmail — every N seconds, pulls unread
category:primarymail and turns each thread into a Discord ticket channel. - Serves a couple of HTTP endpoints — a public healthcheck and an internal config/control API.
State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API.
Startup
broccolini-discord.js is the entry point. The order matters:
- Module load — env validation, Discord
Clientis constructed,interactionCreateandmessageCreatelisteners are registered,client.login()is called. - The public Express app is defined at module scope. It has a 503 gate — any
/api/*request before the bot is ready returns 503. (No/api/*routes are mounted in the MVP, so it's dormant.) - The internal Express app binds at module load on
INTERNAL_API_PORT(0.0.0.0inside the container, not host-published). Reachable only from peers on thebroccoli-netDocker network. Auth header:x-internal-secret. client.once('ready')— once Discord finishes its handshake the bot connects MongoDB, starts the public HTTP listener, registers slash commands with Discord's REST API, then starts the Gmail poll plus optionalsetIntervals for auto-close, auto-unclaim, sweeping orphan ticket channels, and a 6-hour Tickets sweep.
Every setInterval in the ready block is wrapped through trackInterval(...) into a module-level Set. handleShutdown (SIGTERM/SIGINT) clears all of them, closes both HTTP servers, calls client.destroy(), then closes Mongo.
Email → Discord (the main flow)
gmail-poll.js:
- Lists unread messages in
category:primary. - For each thread, looks up an existing
TicketbygmailThreadId. If none, creates a Discord channel underTICKET_CATEGORY_ID(or an overflow category if the main is full at Discord's 50-channel limit) and inserts aTicketdocument. - Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings
ROLE_ID_TO_PING. - On subsequent emails in the same thread, just appends the new message to the existing channel.
Auth failure halts polling. On invalid_grant / unauthorized / 401, pollSuspended flips to true, the poll interval is cleared, and the admin is DM'd once. The bot does not retry — fix the token and restart the container.
Discord → Gmail (replies)
handlers/messages.js handles messageCreate in ticket channels:
- If the ticket's
gmailThreadIdstarts withdiscord-ordiscord-msg-, it's a Discord-only ticket — skip Gmail entirely. - Otherwise, the staff message is forwarded to the customer via Gmail (threaded reply) using
services/gmail.js. The staffer's per-user signature (StaffSignature) is appended.
Customer replies coming back via email are picked up by the next Gmail poll and appended to the existing ticket channel.
Discord-only tickets
Two paths create them:
/panelposts an "Open ticket" button. Clicking it opens a modal asking for email, game, and description. The bot creates a channel and aTicketwith a syntheticgmailThreadIdlikediscord-<channelId>.- Context menu "Create Ticket From Message" does the same, prefilled from the source message (
gmailThreadIdlikediscord-msg-<msgId>).
Replies in these channels stay in Discord. No Gmail traffic.
Channel rename / move queue
Discord rate-limits channel renames at 2 per 10 minutes per channel. All channel ops route through services/channelQueue.js:
enqueueRename,enqueueSend,enqueueMove,enqueueDelete— per-channel chained promises. Delete waits for both rename and send tails to drain.- Renames go through
utils/renamer.js, which uses theRENAMER_BOTsecondary token. On 401/403/429 from the secondary, the queue falls back to the primary bot'schannel.setName. - Bypass sites (direct
channel.send/setName) are tagged// TODO(queue-migrate):— grep to find them; they get migrated incrementally when touched.
Logging helpers in services/debugLog.js are fire-and-forget — .catch(() => {}), never await. They post to the configured log channels (system, automation, error, gmail, etc.).
HTTP surfaces
Public (app, port CONFIG.PORT, default 5000):
GET /→Activeonce ready,Startingbefore. Used by DockerHEALTHCHECK./api/*is gated behindappReadyand currently unmounted.
Internal (internalApp, INTERNAL_API_PORT, broccoli-net only, header auth):
GET /config,POST /config— read/write a strict allowlist (ALLOWED_CONFIG_KEYSinservices/configSchema.js). Unknown keys → 400.GET /discord/guild— basic guild info for the settings UI.POST /restart,GET /restart/status— exits the process so the container supervisor restarts it.POST /gmail/reload— reloads the Gmail client after credential changes.
.env writes go through services/configPersistence.js, which stores values in backtick containers because dotenv v17 only decodes \n/\r inside double-quoted strings.
Storage
MongoDB, one database, accessed via Mongoose. Models live in models.js:
| Collection | What it stores |
|---|---|
Ticket |
One per email thread or Discord-only ticket. Tracks gmailThreadId, discordThreadId, status, claimer, priority, escalation tier, ticket tag, last activity, etc. |
TicketCounter |
Per-sender local counter for ticket numbering. |
Transcript |
Closed ticket → transcript message pointer. |
Tag |
Saved-response templates (/response). |
StaffSettings |
Per-user notifyDm preference. |
StaffSignature |
Per-user email signature (valediction, display name, tagline). |
The Ticket schema indexes {gmailThreadId} (unique), {status, lastActivity}, {senderEmail, status}, {discordThreadId}.
Background jobs (in ready)
| Job | Cadence | Toggle |
|---|---|---|
| Gmail poll | GMAIL_POLL_INTERVAL_SECONDS (default 30s; runtime-tunable via /gmailpoll) |
always |
checkAutoClose |
configurable | AUTO_CLOSE_ENABLED |
checkAutoUnclaim |
configurable | AUTO_UNCLAIM_ENABLED |
reconcileDeletedTicketChannels |
hourly + on startup | always |
services/tickets.startTicketsSweeps |
6h, .unref()-ed |
always |
Settings UI (separate process)
settings-site/ is its own small Express app with its own package.json, Dockerfile, and CLAUDE.md. It serves a password-protected dashboard that POSTs config changes to this bot's internal API using INTERNAL_API_SECRET. Any change to ALLOWED_CONFIG_KEYS here can break the UI there — keep them in sync.