# 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 `setInterval`s 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-`. - **Context menu "Create Ticket From Message"** does the same, prefilled from the source message (`gmailThreadId` like `discord-msg-`). 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.