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

121 lines
6.7 KiB
Markdown

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