Files
broccolini-bot/HOWITWORKS.md
indifferentketchup 6b94791813 cleanup and simplify
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:15:18 +00:00

6.7 KiB

How it works

Broccolini Bot is a single Node.js process. It does three things at once:

  1. Listens to Discord — slash commands, button clicks, modals, ticket-channel messages.
  2. Polls Gmail — every N seconds, pulls unread category:primary mail and turns each thread into a Discord ticket channel.
  3. 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:

  1. Module load — env validation, Discord Client is constructed, interactionCreate and messageCreate listeners are registered, client.login() is called.
  2. 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.)
  3. The internal Express app binds at module load on INTERNAL_API_PORT (0.0.0.0 inside the container, not host-published). Reachable only from peers on the broccoli-net Docker network. Auth header: x-internal-secret.
  4. 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 optional setIntervals 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:

  1. Lists unread messages in category:primary.
  2. For each thread, looks up an existing Ticket by gmailThreadId. If none, creates a Discord channel under TICKET_CATEGORY_ID (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a Ticket document.
  3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings ROLE_ID_TO_PING.
  4. 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 gmailThreadId starts with discord- or discord-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:

  • /panel posts an "Open ticket" button. Clicking it opens a modal asking for email, game, and description. The bot creates a channel and a Ticket with a synthetic gmailThreadId like discord-<channelId>.
  • Context menu "Create Ticket From Message" does the same, prefilled from the source message (gmailThreadId like discord-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 the RENAMER_BOT secondary token. On 401/403/429 from the secondary, the queue falls back to the primary bot's channel.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 /Active once ready, Starting before. Used by Docker HEALTHCHECK.
  • /api/* is gated behind appReady and currently unmounted.

Internal (internalApp, INTERNAL_API_PORT, broccoli-net only, header auth):

  • GET /config, POST /config — read/write a strict allowlist (ALLOWED_CONFIG_KEYS in services/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.