Files
broccolini-bot/HOWITWORKS.md
indifferentketchup 2ccdbf72aa Email ticketing fixes, comms polish, and .env cleanup
Inbound:
- Gmail poll query is:unread in:inbox (was category:primary, which matched
  nothing on a no-tabs Workspace inbox)

Outbound email:
- Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new
  TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails
- Replies quote the customer's latest message (gmail_quote markup so clients
  collapse it), embed custom emoji inline via CID attachment, and strip Discord
  role mentions
- Tagline spacing fix in the company signature

Discord side:
- Suppress all mentions in log + transcript posts (no more pinging on close)
- Drop the staff-role ping from new-ticket and follow-up notifications
- Ticket channels inherit category permissions instead of setting per-channel
  overwrites (removes the Manage Roles requirement)

Gmail folders:
- Folder/label routing (gmailLabels.js) with /folder; close files to Complete

Config:
- Remove ~56 stale .env keys for long-removed features; refresh stale copy

Docs:
- Design specs for folder routing, email-flow toggle, and per-staff metrics
2026-06-04 22:05:20 +00:00

6.8 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 in:inbox 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 in:inbox (excludes Spam, Trash, and anything filter-skipped from the inbox).
  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.