513 lines
39 KiB
Markdown
513 lines
39 KiB
Markdown
# broccolini_bot_context.md
|
||
|
||
Single-source structural map of `/opt/broccolini-bot`. Generated for review use; not authoritative over code — re-read files before acting on anything here.
|
||
|
||
## Overview
|
||
|
||
Node.js (CommonJS) Discord ticketing bot for Indifferent Broccoli. Single process hosts:
|
||
|
||
- A discord.js v14 client (ticket lifecycle, slash/button/modal handlers, context menus)
|
||
- A Gmail bridge (~30s polling → Discord channels; staff replies → Gmail)
|
||
- A Mongoose/MongoDB Atlas layer (`broccoli_db`) for tickets + settings
|
||
- Two Express servers: healthcheck + bOSScord API (`PORT`, default 5000 → host 8892), and an internal settings API (`INTERNAL_API_PORT`)
|
||
- Background jobs: auto-close, unclaimed reminders, auto-unclaim, pattern detection, surge detection, chat monitoring, orphan reconciliation
|
||
|
||
Container: `docker compose up --build -d`. Port 5000 inside → 8892 outside. No test runner, linter, or build step.
|
||
|
||
**CLAUDE.md Hard Rule #3 clarification:** the repo's `services/channelQueue.js` only exposes `enqueueRename` / `enqueueMove`. There is no `enqueueSend`. In practice the rule applies to **renames and category moves**, not to `channel.send`. Direct `channel.send` is the norm throughout `handlers/` and is not treated as a violation in this document.
|
||
|
||
## File tree (one-line purposes)
|
||
|
||
### Root
|
||
- `broccolini-discord.js` — entry point; wires client, events, background jobs, two HTTP servers
|
||
- `config.js` — env → `CONFIG` object (119 vars, lines 111–276); game list, tags, staff emoji map
|
||
- `db-connection.js` — Mongo connect + require `models.js`; retry helper, shutdown hook
|
||
- `models.js` — **all 13 Mongoose schemas in one file**
|
||
- `gmail-poll.js` — Gmail inbox poll → new ticket creation / follow-up routing
|
||
- `get-refresh-token.js` — one-shot OAuth helper (redirect `http://localhost:3000/oauth2callback`)
|
||
- `utils.js` — email/game helpers, response template variables
|
||
- `package.json` / `Dockerfile` / `docker-compose.yml` — deploy
|
||
- `.env.example` / `.env.test.example` — env reference
|
||
|
||
### `handlers/`
|
||
- `buttons.js` — button + modal interactions: claim/unclaim, close confirm, escalate T2/T3, de-escalate, priority, tag, ticket-creation modal
|
||
- `commands.js` — slash command router: `/escalate`, `/deescalate`, `/add`, `/remove`, `/transfer`, `/move`, `/claim`, `/unclaim`, `/close`, `/priority`, `/tags`, `/email-routing`, `/setup`, `/help`, `/stats`, `/history`, `/search`, `/notification`, `/staffthread`, `/pinmessages`, `/panel`, `/backup`, `/export`, `/accountinfo`, `/gmailpoll`
|
||
- `messages.js` — `messageCreate`: staff reply → Gmail relay; notify claimer on customer reply; DM alert toggle
|
||
- `setup.js` — multi-step `/setup` wizard (modals + select menus)
|
||
- `accountinfo.js` — `/accountinfo` lookup + "send to channel" context menu
|
||
- `analytics.js` — in-memory counters: interactions, errors, uptime
|
||
- `pendingCloses.js` — shared `Map<channelId, timeout>` for force-close timer
|
||
|
||
### `services/`
|
||
- `channelQueue.js` — `enqueueRename` (p-queue-style, serialized per channel, respects Discord's 2-rename/10-min cap) and `enqueueMove` (direct `setParent`)
|
||
- `tickets.js` — counters, naming, rate limits, auto-close, auto-unclaim, `reconcileDeletedTicketChannels`
|
||
- `gmail.js` — `getGmailClient`, `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail`
|
||
- `debugLog.js` — fire-and-forget logging to dedicated Discord channels (`logError`, `logWarn`, `logTicketEvent`, `logGmail`, `logAutomation`, `logSecurity`, `logIntegrity`, `logSystem`)
|
||
- `staffNotifications.js` — `notifyStaffOfReply` (per-ticket cooldown), `notifyAllStaffUnclaimed` (30-min digest)
|
||
- `staffSettings.js` — `StaffSettings.notifyDm` get/set
|
||
- `staffSignature.js` — per-staff valediction/display name/tagline blocks
|
||
- `staffPresence.js` — presence + message-activity tracking for "no staff available" surge alerts
|
||
- `staffThread.js` — optional per-ticket private staff thread + auto-add members of `STAFF_THREAD_ROLE_ID`
|
||
- `staffChannel.js` — **deprecated.** Legacy per-staffer mirror channels. `STAFF_CATEGORIES` is empty in current `config.js`; `createStaffChannel` is not called from the claim flow.
|
||
- `pinMessage.js` — pin helper with optional system-message suppression
|
||
- `patternStore.js` — in-memory counter store with scheduled daily/weekly/monthly resets, escalating-cooldown helper
|
||
- `patternChecker.js` — periodic pattern detection (user/game/tag/escalation/staff)
|
||
- `surgeChecker.js` — volume, game, stale, needs-response, unclaimed, T3-unclaimed, no-staff surge alerts
|
||
- `chatAlertChecker.js` — monitor configured chat channels for unresponded messages
|
||
- `configPersistence.js` — save/load runtime config to Mongo
|
||
- `guildSettings.js` — per-guild `emailRouting` (`thread` | `category`)
|
||
|
||
### `commands/`
|
||
- `register.js` — slash + context menu registration via discord.js REST v10
|
||
|
||
### `routes/`
|
||
- `bosscord.js` — `/api/tickets*` for bOSScord (Bearer `BOSSCORD_API_KEY`, CORS, DB-ready gate)
|
||
- `internalApi.js` — `/internal/*` for the settings site (`X-Internal-Secret`)
|
||
|
||
### `api/`
|
||
- `bosscordClient.js` — singleton holder for the Discord client (set at startup, read by routes)
|
||
|
||
### `settings-site/`
|
||
- Separate Express app. `server.js` talks to the bot's internal API over `INTERNAL_API_PORT` using `INTERNAL_API_SECRET`. Password-protected dashboard (`SETTINGS_ADMIN_PASSWORD`).
|
||
|
||
### `scripts/`
|
||
- `test-mongodb.js` — connectivity smoke test (`npm run test-mongodb`)
|
||
|
||
### `docs/`
|
||
- `README.md`, `CRITICAL_FILES_AND_HOW_IT_WORKS.md`, `setup/*`, `features/*`, `api/*`, `architecture/*`
|
||
|
||
## Discord event handler map
|
||
|
||
| Event | Wired in | Dispatch |
|
||
|-------|----------|----------|
|
||
| `ready` | `broccolini-discord.js` (single-fire) | DB connect → `registerCommands()` → mount bOSScord API → start 8 background intervals → start internal API server |
|
||
| `interactionCreate` | `broccolini-discord.js` | Routes by type: `isButton` / `isModalSubmit` → `handlers/buttons.js` and `handlers/setup.js`; `isChatInputCommand` → `handlers/commands.js`; `isContextMenuCommand` → `handlers/accountinfo.js`; `isAutocomplete` → tags/responses |
|
||
| `messageCreate` | `broccolini-discord.js` | `staffPresence.updateStaffLastSeen` → `chatAlertChecker.handleChatMessage` → `handlers/messages.handleDiscordReply` |
|
||
| `unhandledRejection` | `broccolini-discord.js` | `logError('unhandledRejection', …).catch(() => {})` |
|
||
| `SIGTERM`/`SIGINT` | `broccolini-discord.js` | `handleShutdown()` — log + exit |
|
||
|
||
### Background intervals (all started in `ready`)
|
||
|
||
| Job | Interval | Source | Config gate |
|
||
|-----|----------|--------|-------------|
|
||
| Gmail poll | `GMAIL_POLL_INTERVAL_MS` (~30s) | `gmail-poll.js:poll` | always on |
|
||
| Auto-close | 60 min | `services/tickets.checkAutoClose` | `AUTO_CLOSE_ENABLED` |
|
||
| Unclaimed digest | 30 min | `services/staffNotifications.notifyAllStaffUnclaimed` | `UNCLAIMED_REMINDER_THRESHOLDS` |
|
||
| Auto-unclaim | 60 min | `services/tickets.checkAutoUnclaim` | `AUTO_UNCLAIM_*` |
|
||
| Pattern checks | `PATTERN_CHECK_INTERVAL_MINUTES` | `services/patternChecker.runPatternChecks` | pattern channel envs |
|
||
| Surge checks | 5 min (+30s initial delay) | `services/surgeChecker.runSurgeChecks` | `ALL_STAFF_CHANNEL_ID` |
|
||
| Chat monitoring | 5 min | `services/chatAlertChecker.runChatAlertChecks` | `CHAT_ALERT_CHANNEL_IDS` |
|
||
| Orphan reconciliation | 60 min | `services/tickets.reconcileDeletedTicketChannels` | always on |
|
||
|
||
### Button / modal custom IDs
|
||
|
||
`open_ticket`, `open_ticket_thread`, `open_ticket_channel`, `email_routing_thread`, `email_routing_category`, `claim_ticket`, `close_ticket`, `confirm_close`, `cancel_close`, `escalate_ticket`, `escalate_to_tier2`, `escalate_to_tier3`, `deescalate_ticket`, `priority_*`, `open_panel`, `ticket_modal`, `ticket_modal_thread`, `ticket_modal_channel`, `setup_*` (wizard), `send_account_info_*`.
|
||
|
||
## Ticket lifecycle
|
||
|
||
Two sources, one `Ticket` document:
|
||
|
||
- **Email-sourced** — real Gmail `threadId` in `gmailThreadId`. Staff replies relay to Gmail via `handlers/messages.js` → `sendGmailReply`.
|
||
- **Discord-sourced** — `gmailThreadId` prefixed `discord-` / `discord-msg-`. No Gmail relay; conversation stays in Discord.
|
||
|
||
State machine:
|
||
|
||
```
|
||
(poll or /panel modal)
|
||
│
|
||
▼
|
||
┌─────────────────┐
|
||
│ created │ — Ticket doc inserted; Discord channel (or thread) created under
|
||
│ (status: open, │ TICKET_CATEGORY_ID / DISCORD_TICKET_CATEGORY_ID (+overflow if full);
|
||
│ claimedBy: ∅) │ welcome embed + action row posted; role ping; optional pin; optional
|
||
└────────┬────────┘ staff thread; optional staff notification alerts
|
||
│
|
||
[Claim button] ───▶ claimedBy set; channel renamed via channelQueue (STAFF_EMOJIS prefix)
|
||
│ │
|
||
│ [Unclaim / auto-unclaim / claim-timeout] ──▶ back to unclaimed
|
||
│
|
||
[/escalate or Escalate button → T2 / T3]
|
||
│ Non-thread: enqueueMove → *_ESCALATED2/3_CHANNEL_ID category
|
||
│ Thread: skips category move (threads can't reparent)
|
||
│ Action: "unclaim" clears claim + resets unclaimedReminderssent; "keep" preserves
|
||
▼
|
||
┌─────────────────┐
|
||
│ escalated │ escalationTier ∈ {2, 3}
|
||
└────────┬────────┘
|
||
│
|
||
[/deescalate] ──▶ step down one tier
|
||
│
|
||
[Close button → confirm_close → FORCE_CLOSE_TIMER grace]
|
||
│
|
||
▼
|
||
┌─────────────────┐
|
||
│ closed │ transcript posted to TRANSCRIPT_CHANNEL_ID; closure email sent
|
||
│ (status: closed│ for email tickets; channel deleted (5s delay); Transcript doc written
|
||
└─────────────────┘
|
||
```
|
||
|
||
Orphan path: `reconcileDeletedTicketChannels` (60 min) finds open tickets whose Discord channel no longer exists and marks them closed.
|
||
|
||
## MongoDB collections (models.js)
|
||
|
||
All schemas live in a single file. Only indexes explicitly declared are listed; implicit `_id` and `unique: true` (which creates an index) are marked ✓.
|
||
|
||
| Collection | Key fields | Indexes | Notes |
|
||
|------------|------------|---------|-------|
|
||
| **Host** | `hostname`, `ip`, `region`, `status`, `memFree`, `cpuUsage`, `diskFree`, `lastSeen`, `lostInUse[]`, `statsHistory[]` | **none** | `lastSeen: { default: Date.now() }` — frozen at schema-definition time, bug (see P3) |
|
||
| **User** | `email`, `discordID`, `customerId`, `passwordHash`, `sessionToken`, `servers[]`, `subusers[]`, `activities[]` | **none** | 700+ lines, shared website schema. `email` / `discordID` queried in `handlers/accountinfo.js:47-54` without index |
|
||
| **DashboardMetrics** | `timestamp` (TTL 1yr), `activeUsers`, `workerId` | TTL ✓ | |
|
||
| **ErrorLog** | `timestamp` (TTL 30d), `statusCode`, `message`, `stack`, `url`, `method`, `userId`, `userEmail`, `authenticated`, `sessionValid` | TTL ✓ | |
|
||
| **Ticket** | `gmailThreadId` ✓ unique, `discordThreadId`, `senderEmail`, `subject`, `status` (`open`/`closed`), `priority`, `claimedBy` (display), `claimerId`, `ticketNumber`, `createdAt`, `lastActivity`, `escalated`, `escalationTier`, `welcomeMessageId`, `ticketTag`, `unclaimedReminderssent[]` *(typo preserved — see below)* | `gmailThreadId` unique ✓ | **`discordThreadId`, `claimedBy`, `status`, `ticketNumber`, `senderEmail` are all hot query fields with no index.** `unclaimedReminderssent` typo is load-bearing — preserved across `models.js:819`, `services/staffNotifications.js:85,111`, `handlers/commands.js:77` |
|
||
| **TicketCounter** | `senderLocal` ✓ unique, `counter` | ✓ | |
|
||
| **Transcript** | `gmailThreadId`, `transcriptMessageId`, `createdAt` | **none** | `gmailThreadId` queried in `gmail-poll.js:267` without index |
|
||
| **Tag** | `name` ✓ unique, `content`, `createdBy`, `useCount` | ✓ | Saved response templates |
|
||
| **CloseRequest** | `ticketId` ✓ unique, `requestedBy`, `reason` | ✓ | |
|
||
| **GuildSettings** | `guildId` ✓ unique, `emailRouting` (`thread`/`category`) | ✓ | |
|
||
| **StaffSettings** | `userId` ✓ unique, `guildId`, `notifyDm` | ✓ | |
|
||
| **StaffNotification** | `userId` ✓ unique, `guildId`, `channelId`, `cooldownHours` | ✓ | Per-staffer reply-alert channel |
|
||
| **StaffSignature** | `userId` ✓ unique, `guildId`, `valediction`, `displayName`, `tagline` | ✓ | |
|
||
|
||
## Express API route table
|
||
|
||
### `routes/bosscord.js` — mounted at `/api` after `ready`, only if `BOSSCORD_API_KEY` is set
|
||
|
||
| Method | Path | Auth | Input | Response |
|
||
|--------|------|------|-------|----------|
|
||
| GET | `/api/tickets` | Bearer | query: `status`, `priority`, `claimedBy`, `limit` (≤100) | `{ tickets: [...] }` |
|
||
| GET | `/api/me/tickets` | Bearer | header `X-Staff-Discord-Id` or query `claimedBy` | `{ tickets: [...] }` |
|
||
| GET | `/api/tickets/:id` | Bearer | path: ObjectId / ticketNumber / gmailThreadId | **raw ticket object** (inconsistent) |
|
||
| GET | `/api/tickets/:id/messages` | Bearer | query: `limit` (≤100) | `{ messages: [...] }` |
|
||
| POST | `/api/tickets/:id/messages` | Bearer | `{ content: string, displayName?: string }` | `{ ok: true }` (201) |
|
||
|
||
Middleware (applied once via `router.use`): `corsMiddleware` (`BOSSCORD_CORS_ORIGIN`, defaults to `*`) → `authMiddleware` (Bearer) → `requireDb`.
|
||
|
||
### `routes/internalApi.js` — `/internal/*` on a separate port (`INTERNAL_API_PORT`)
|
||
|
||
| Method | Path | Auth | Input | Response |
|
||
|--------|------|------|-------|----------|
|
||
| GET | `/internal/config` | `X-Internal-Secret` | — | `{ key: value, ... }` (redacted) |
|
||
| POST | `/internal/config` | `X-Internal-Secret` | `{ [key]: value }` | `{ applied: [...], errors: [...] }` |
|
||
| GET | `/internal/discord/guild` | `X-Internal-Secret` | — | `{ channels, roles, members, categories }` |
|
||
| POST | `/internal/restart` | `X-Internal-Secret` | `{ mode, scheduledFor? }`, modes: `immediate` / `scheduled` / `cancel_scheduled` / `pending` | `{ ok: true, mode, ... }` |
|
||
| GET | `/internal/restart/status` | `X-Internal-Secret` | — | `{ scheduledRestart: boolean }` |
|
||
|
||
## Environment variables
|
||
|
||
### Vars read in `config.js` but missing from `.env.example`
|
||
- `DISCORD_BOT_TOKEN` (alias for `DISCORD_TOKEN`)
|
||
- `HEALTHCHECK_HOST`
|
||
- `NOTIFICATION_THRESHOLDS_JSON`
|
||
- `ROLE_TO_PING_ID` (alias for `ROLE_ID_TO_PING`)
|
||
- `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS`
|
||
- `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS`
|
||
- `NODE_ENV`, `ENV_FILE` (implicit)
|
||
|
||
### Vars in `.env.example` but not read via `config.js`
|
||
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` — read directly by `services/gmail.js` and `broccolini-discord.js`, not via `CONFIG`
|
||
- `MONGODB_URI` — read directly by `broccolini-discord.js:99` and `scripts/test-mongodb.js`, not via `CONFIG`
|
||
- `NGROK_URL` — unused
|
||
- `DISCORD_ESCALATED_CATEGORY_ID`, `EMAIL_ESCALATED_CATEGORY_ID` — legacy names, superseded by `*_ESCALATED2/3_CHANNEL_ID`
|
||
|
||
### Key env categories (see `.env.example` for the full list)
|
||
|
||
| Category | Vars |
|
||
|----------|------|
|
||
| Discord core | `DISCORD_TOKEN`, `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID`, `TICKET_CATEGORY_ID`, `DISCORD_TICKET_CATEGORY_ID`, `*_OVERFLOW_CATEGORY_IDS`, `ROLE_ID_TO_PING`, `TRANSCRIPT_CHANNEL_ID`, `LOGGING_CHANNEL_ID`, `DEBUGGING_CHANNEL_ID` |
|
||
| Escalation | `EMAIL_ESCALATED2/3_CHANNEL_ID`, `DISCORD_ESCALATED2/3_CHANNEL_ID` |
|
||
| Staff notifications | `STAFF_NOTIFICATION_CATEGORY_ID`, `STAFF_EMOJIS`, `CLAIMER_EMOJI_FALLBACK`, `ADMIN_ID`, `UNCLAIMED_REMINDER_THRESHOLDS` |
|
||
| Gmail | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `REFRESH_TOKEN`, `MY_EMAIL`, `GMAIL_POLL_INTERVAL_MS` |
|
||
| MongoDB | `MONGODB_URI` |
|
||
| HTTP | `DISCORD_ONLY_PORT`/`PORT`, `HEALTHCHECK_HOST`, `BOSSCORD_API_KEY`, `BOSSCORD_CORS_ORIGIN`, `INTERNAL_API_PORT`, `INTERNAL_API_SECRET` |
|
||
| Automation | `AUTO_CLOSE_*`, `REMINDER_*`, `AUTO_UNCLAIM_*`, `CLAIM_TIMEOUT_*`, `FORCE_CLOSE_TIMER` |
|
||
| Rate limits | `GLOBAL_TICKET_LIMIT`, `TICKET_LIMIT_PER_CATEGORY`, `RATE_LIMIT_*` |
|
||
| Patterns | `PATTERN_*_THRESHOLD`, `*_PATTERNS_CHANNEL_ID` |
|
||
| Surge | `SURGE_*`, `ALL_STAFF_CHANNEL_ID`, `SURGE_ROLE_ID`, `STAFF_IDS` |
|
||
| Chat alerts | `CHAT_ALERT_CHANNEL_IDS`, `ALL_STAFF_CHAT_ALERT_CHANNEL_ID`, `CHAT_ALERT_*` |
|
||
| Branding | `SUPPORT_NAME`, `LOGO_URL`, `SIGNATURE`, `TICKET_WELCOME_MESSAGE`, `TICKET_CLAIMED_MESSAGE`, `ESCALATION_MESSAGE`, embed colors |
|
||
|
||
## Key patterns
|
||
|
||
### Channel queue
|
||
`services/channelQueue.js` serializes **renames** (`enqueueRename`) and **moves** (`enqueueMove`). Discord caps renames at 2 per 10 min per channel; the queue emits a relative-time message in the channel when blocked. **Rule:** any code that changes a channel's name or parent must use these helpers. `handlers/commands.js:540` (`/move`) currently bypasses this with a direct `setParent` — see P1 prompt.
|
||
|
||
### Logging
|
||
`services/debugLog.js` is fire-and-forget: every log helper returns a promise and callers attach `.catch(() => {})`. Rule: never `await` logging on a hot path. Channels are selected by the `*_LOG_CHANNEL_ID` env vars (`GMAIL_LOG_CHANNEL_ID`, `AUTOMATION_LOG_CHANNEL_ID`, `RENAME_LOG_CHANNEL_ID`, `SECURITY_LOG_CHANNEL_ID`, `SYSTEM_LOG_CHANNEL_ID`, `DEBUGGING_CHANNEL_ID`).
|
||
|
||
### Staff detection
|
||
Staff = members with `ROLE_ID_TO_PING` or any role in `ADDITIONAL_STAFF_ROLES`. `ADMIN_ID` is a single-user gate for `/staffnotification`. `STAFF_IDS` drives surge "no staff available" calculations with `STAFF_DND_COUNTS_AS_AVAILABLE` as a tiebreaker.
|
||
|
||
### Claim identity
|
||
`Ticket.claimedBy` is a display label (string), `Ticket.claimerId` is the Discord user ID. Channel-name emoji comes from `STAFF_EMOJIS` (`userId:emoji,...`) with `CLAIMER_EMOJI_FALLBACK`.
|
||
|
||
### Pattern/counter store
|
||
`services/patternStore.js` holds in-memory counters keyed by namespace + window (`today`/`week`/`month`) with auto-reset timers from `scheduleResets()`. Not persisted — resets on process restart.
|
||
|
||
### Deprecated
|
||
`services/staffChannel.js` and the `STAFF_CATEGORIES` map are legacy. `STAFF_CATEGORIES` is empty in current `config.js`, `createStaffChannel` is not called from the claim flow, and `Ticket.staffChannelId` is effectively unused. Reply alerts instead flow through `StaffNotification` channels (`/notification add`).
|
||
|
||
## Known issues (root causes documented; NO fix prompts)
|
||
|
||
1. **Gmail `invalid_grant`** — `gmail-poll.js:351-372`. Polling catches auth errors (`invalid_grant` / `unauthorized` / `Invalid Credentials` / HTTP 401), logs via `logError('Gmail OAuth', …)`, DMs `ADMIN_ID` **once** (`authErrorNotified` flag), and silently no-ops subsequent polls. By design — requires manual `REFRESH_TOKEN` refresh via `node get-refresh-token.js`. The surrounding bot and bOSScord API continue to function.
|
||
2. **`STAFF_EMOJIS` encoding** — `config.js` parses `userId:emoji` pairs from env; some custom emojis render as mojibake in channel names. Root cause not yet identified; likely interaction between `.env` file encoding (UTF-8 vs BOM), `dotenv-expand` handling, and Discord's custom emoji syntax (`<:name:id>`) vs Unicode codepoints. Needs a targeted trace through `config.js` parsing.
|
||
3. **Escalation button** — `handlers/buttons.js` handlers for `escalate_to_tier2` / `escalate_to_tier3`. Reports of the handler "not firing reliably." Root cause not yet identified. Candidate areas: interaction deferral timing (3 s rule), missing `return` between button branches in the dispatcher, or `enqueueMove` back-pressure when the target category is full and the handler errors before replying.
|
||
|
||
---
|
||
|
||
# Improvement prompts
|
||
|
||
Each prompt follows CLAUDE.md's format. Prompts intended for OpenCode to execute. None of the known issues above appear here.
|
||
|
||
---
|
||
|
||
## P0 — Fix undefined vars in ticket-closure email body
|
||
|
||
**Priority:** P0 (broken)
|
||
**Files:** `/opt/broccolini-bot/services/gmail.js` (lines 108–129), `/opt/broccolini-bot/config.js` (to confirm `TICKET_CLOSE_MESSAGE` / signature vars)
|
||
**Problem:** `sendTicketClosedEmail` references `safeCloseMessage` and `safeCloseSignature` on lines 115–116 of the HTML body, but neither variable is defined anywhere in the function. Every closure email sent for an email-sourced ticket currently contains literal `undefined` text in both the message paragraph and the signature line, which customers see. This has been broken for an unknown period because nothing tests closure email rendering.
|
||
**Fix:**
|
||
1. Read the full `sendTicketClosedEmail` function (surrounding ~50 lines) to confirm the escape pattern used by `safeReply` / `safeLogoUrl` / `safeSignature`.
|
||
2. Immediately after line 110 (where `safeSignature` is computed), add:
|
||
```js
|
||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || CONFIG.DISCORD_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||
const safeCloseSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||
```
|
||
— adjust the CONFIG key to whichever close-message var actually exists (`CONFIG.TICKET_CLOSE_MESSAGE` is the most likely name; fall back to the existing `DISCORD_CLOSE_MESSAGE` if not present). Do not invent a new env var.
|
||
3. Do not modify the HTML template structure.
|
||
**Verify:**
|
||
- Trigger a close on a throwaway **email-sourced** ticket in the test environment.
|
||
- Inspect the resulting Gmail message (the customer-bound send) and confirm the `<p>` that previously said `undefined` now contains the configured close message, and the signature block below it renders correctly.
|
||
- If no test env exists for Gmail, at minimum console-log `htmlBody` once and grep for `undefined`.
|
||
|
||
---
|
||
|
||
## P1 — Route `/move` through `enqueueMove` instead of direct `setParent`
|
||
|
||
**Priority:** P1 (channel queue bypass — CLAUDE.md Hard Rule #3)
|
||
**Files:** `/opt/broccolini-bot/handlers/commands.js` (around line 540), `/opt/broccolini-bot/services/channelQueue.js`
|
||
**Problem:** The `/move` slash handler calls `await interaction.channel.setParent(category.id, { lockPermissions: true })` directly. Every other category move in the codebase flows through `services/channelQueue.js`'s `enqueueMove`, which serializes moves and logs via the rename channel. Direct `setParent` skips that serialization and, more importantly, skips the rate-limit / error handling the queue provides.
|
||
**Fix:**
|
||
1. At the top of `handlers/commands.js`, confirm `enqueueMove` is imported from `../services/channelQueue`. Add the import if missing.
|
||
2. Replace line 540 with `await enqueueMove(interaction.channel, category.id);`
|
||
3. Confirm `enqueueMove` preserves `lockPermissions: true` behavior (read `services/channelQueue.js:~95`). If it does not, add a `lockPermissions` option to `enqueueMove` (defaulting to `true` to match existing callers), rather than reverting `/move` to a direct call.
|
||
4. Leave the surrounding `interaction.reply` / log-channel send untouched.
|
||
**Verify:**
|
||
- Run `/move` in a test ticket channel targeting another category. Confirm it moves.
|
||
- Run two `/move` commands back-to-back from different ticket channels. Confirm both complete without rate-limit errors and both appear in `RENAME_LOG_CHANNEL_ID` (if the queue logs moves there).
|
||
|
||
---
|
||
|
||
## P1 — Validate and bound `content` on `POST /api/tickets/:id/messages`
|
||
|
||
**Priority:** P1 (input validation / security boundary)
|
||
**Files:** `/opt/broccolini-bot/routes/bosscord.js` (lines 159–223)
|
||
**Problem:** The endpoint accepts an arbitrary `content` string with only a type check (`typeof content !== 'string'`). There is no length cap, no whitespace check, and `req.body.displayName` is piped into `sendGmailReply` as `discordUser` without validation. A client bug or malicious caller can post a 10 MB string to Discord (which will error partway through but only after a `channel.send` attempt) or inject arbitrary display names into outbound email. Discord's own cap is 2000 chars per message.
|
||
**Fix:**
|
||
1. After the existing `content` type check (line 169), add:
|
||
```js
|
||
const trimmed = content.trim();
|
||
if (!trimmed) return res.status(400).json({ error: 'content is empty' });
|
||
if (trimmed.length > 2000) return res.status(400).json({ error: 'content exceeds 2000 characters' });
|
||
```
|
||
Use `trimmed` for the rest of the handler.
|
||
2. Validate `displayName`: coerce to string, trim, cap at 80 chars, and replace anything outside `[\w \-.']` with empty string. If the result is empty, fall back to `'bOSScord'`. Do not echo unvalidated user input into the outbound email header.
|
||
3. Do not change the response shape.
|
||
**Verify:**
|
||
- `curl` the endpoint with a 3000-char body and confirm a 400 response.
|
||
- `curl` with `{"content":"hi","displayName":"<script>alert(1)</script>"}` and confirm the email (if sent) shows a sanitized display name.
|
||
- `curl` with a normal `{"content":"test"}` and confirm the existing happy path still returns `{ok: true}` and delivers to Discord.
|
||
|
||
---
|
||
|
||
## P1 — Add hot-path indexes to `Ticket`
|
||
|
||
**Priority:** P1 (data layer / performance and correctness under load)
|
||
**Files:** `/opt/broccolini-bot/models.js` (the `ticketSchema` block ~lines 795–821)
|
||
**Problem:** Only `gmailThreadId` is indexed on `Ticket`. The live query hotspots are `discordThreadId` (every `messageCreate` does a `findOne` on it — see `handlers/messages.js`), `claimedBy` + `status` (the bOSScord `/api/me/tickets` filter), `status` alone (unclaimed-reminder job scans it every 30 min), and `senderEmail` + `ticketNumber` (search commands). As the collection grows, these turn into full-collection scans on every Discord message.
|
||
**Fix:** Inside the `ticketSchema` definition (not inline on the field — use `ticketSchema.index(...)` calls at the end of the schema block so it's obvious what the indexes are):
|
||
```js
|
||
ticketSchema.index({ discordThreadId: 1 }, { unique: true, sparse: true });
|
||
ticketSchema.index({ status: 1, claimedBy: 1 });
|
||
ticketSchema.index({ status: 1, lastActivity: -1 });
|
||
ticketSchema.index({ senderEmail: 1, createdAt: -1 });
|
||
ticketSchema.index({ ticketNumber: 1 });
|
||
```
|
||
`discordThreadId` should be `unique, sparse` because Discord-only tickets set it immediately, email tickets may briefly lack it during creation, and no two tickets should share a channel. Confirm the sparse-unique behavior doesn't conflict with existing data before enabling (see Verify).
|
||
**Verify:**
|
||
- Before deploy, run `db.tickets.aggregate([{$group: {_id: "$discordThreadId", c: {$sum: 1}}}, {$match: {c: {$gt: 1}}}])` against `broccoli_db` to confirm no duplicate `discordThreadId` values exist. If any do, investigate (they indicate prior orphaning bugs) before adding the unique index.
|
||
- After redeploy, run `db.tickets.getIndexes()` in Atlas and confirm all five new indexes exist.
|
||
- Spot-check with `db.tickets.find({discordThreadId: "<some id>"}).explain("executionStats")` — should show `IXSCAN`, not `COLLSCAN`.
|
||
|
||
---
|
||
|
||
## P1 — Add index on `Transcript.gmailThreadId`
|
||
|
||
**Priority:** P1
|
||
**Files:** `/opt/broccolini-bot/models.js` (`transcriptSchema`, ~lines 828–832)
|
||
**Problem:** `gmail-poll.js:267` queries `Transcript.findOne({ gmailThreadId })` on every inbound email that might be a reopen, with no index.
|
||
**Fix:** Append `transcriptSchema.index({ gmailThreadId: 1 });` to the schema definition block.
|
||
**Verify:** `db.transcripts.getIndexes()` shows the new index; `db.transcripts.find({gmailThreadId: "<id>"}).explain("executionStats")` is `IXSCAN`.
|
||
|
||
---
|
||
|
||
## P1 — Validate `/internal/config` POST body against an allowlist
|
||
|
||
**Priority:** P1 (admin API; wide blast radius)
|
||
**Files:** `/opt/broccolini-bot/routes/internalApi.js` (~lines 29–39), `/opt/broccolini-bot/config.js` (to derive the allowlist)
|
||
**Problem:** `POST /internal/config` forwards the request body to `applyConfigUpdates()` with only a type check (`typeof body === 'object'`). Any caller with `INTERNAL_API_SECRET` can set arbitrary keys. An attacker who exfiltrates the secret can poison `CONFIG` with unknown keys that silently shadow code reads.
|
||
**Fix:**
|
||
1. Build a module-level `const ALLOWED_CONFIG_KEYS = new Set([...])` containing every key defined in `config.js`. Generate this by reading `config.js`; do not hand-type it. If `config.js` exports the list (or can cheaply derive it from `Object.keys(CONFIG)`), prefer that.
|
||
2. At the top of the POST handler, iterate `Object.keys(req.body)` and collect any not in `ALLOWED_CONFIG_KEYS`. If any exist, return 400 with `{ error: 'Unknown config keys', rejected: [...] }`.
|
||
3. Do not change successful-path behavior.
|
||
**Verify:**
|
||
- `curl -H "x-internal-secret: $S" -H 'content-type: application/json' -d '{"TICKET_CATEGORY_ID":"123"}' .../internal/config` — still works.
|
||
- `curl ... -d '{"NOT_A_REAL_KEY":"x"}' ...` — returns 400 with the rejected key listed.
|
||
|
||
---
|
||
|
||
## P1 — Validate `scheduledFor` on `/internal/restart`
|
||
|
||
**Priority:** P1
|
||
**Files:** `/opt/broccolini-bot/routes/internalApi.js` (~lines 87–123)
|
||
**Problem:** `POST /internal/restart` passes `scheduledFor` to `new Date()` without format checks. Invalid strings become `Invalid Date`, past timestamps schedule in the past (immediate restart), and there is no upper bound on how far in the future a restart can be scheduled.
|
||
**Fix:** When `mode === 'scheduled'`:
|
||
1. Require `scheduledFor` to be a string matching ISO-8601 (`Date.parse` returning a finite number is sufficient).
|
||
2. Reject if `Number.isNaN(parsed)` — return 400 `{ error: 'scheduledFor must be a valid ISO-8601 timestamp' }`.
|
||
3. Reject if the timestamp is in the past or more than 24 hours in the future — return 400.
|
||
**Verify:** POST with `{mode:"scheduled", scheduledFor:"not-a-date"}` returns 400. POST with a timestamp 2 min in the future succeeds. POST with a timestamp 1 week in the future returns 400.
|
||
|
||
---
|
||
|
||
## P2 — Fix unsafe async IIFE in force-close cleanup
|
||
|
||
**Priority:** P2 (silent error swallowing; reliability)
|
||
**Files:** `/opt/broccolini-bot/handlers/buttons.js` (lines ~595–605)
|
||
**Problem:** After channel deletion on force-close, a `setTimeout` wraps an async IIFE that calls `cleanupEmptyOverflowCategory(...)` without a `.catch`. A thrown error from that cleanup is an unhandled rejection that the global handler logs but no one sees per-ticket, and the force-close flow appears successful even when cleanup failed.
|
||
**Fix:** Replace the IIFE with:
|
||
```js
|
||
setTimeout(() => {
|
||
cleanupEmptyOverflowCategory(/* same args */)
|
||
.catch((err) => logError('cleanupEmptyOverflowCategory', err).catch(() => {}));
|
||
}, 6000);
|
||
```
|
||
(Do not `await` the `logError` call — logging is fire-and-forget per CLAUDE.md Hard Rule #4.)
|
||
**Verify:** Force-close a ticket in an overflow category with the cleanup function temporarily throwing. Confirm the error surfaces in the debug channel instead of only the global `unhandledRejection` log.
|
||
|
||
---
|
||
|
||
## P2 — Normalize `/api/tickets/:id` response shape
|
||
|
||
**Priority:** P2 (API contract — **coordinate with bOSScord**)
|
||
**Files:** `/opt/broccolini-bot/routes/bosscord.js` (lines 106–119), plus bOSScord client code (out of tree)
|
||
**Problem:** `/api/tickets` returns `{ tickets: [...] }`, `/api/me/tickets` returns `{ tickets: [...] }`, `/api/tickets/:id/messages` returns `{ messages: [...] }`, but `/api/tickets/:id` returns the raw ticket object. bOSScord has to handle two shapes. CLAUDE.md warns that response-shape changes will break bOSScord.
|
||
**Fix:** This is a **coordinated change**. Do not modify `routes/bosscord.js` in isolation. Instead:
|
||
1. Open this as a doc-only prompt first: add a note to `docs/api/` (create the file if needed) listing the current shapes and marking the single-ticket endpoint as "wrapped in `{ ticket }` in vNext — bOSScord must be updated in lockstep."
|
||
2. Separately, coordinate with the bOSScord repo. Once bOSScord is updated, a follow-up prompt will change line 114 from `res.json(out)` to `res.json({ ticket: out })`.
|
||
**Verify (for the doc-only step):** `docs/api/bosscord.md` exists and accurately describes the five endpoints' current and target shapes.
|
||
|
||
---
|
||
|
||
## P2 — Audit long-running slash commands for deferReply
|
||
|
||
**Priority:** P2 (Discord.js best practices)
|
||
**Files:** `/opt/broccolini-bot/handlers/commands.js` (read-only audit), `/opt/broccolini-bot/handlers/buttons.js`
|
||
**Problem:** Discord requires an interaction response (reply or defer) within 3 seconds. Any command that fetches from Mongo + makes multiple Discord API calls + possibly calls Gmail is at risk. `/escalate` (queue move + channel rename + log send + email?), `/move`, `/transfer`, `/backup`, `/export` are candidates.
|
||
**Fix:**
|
||
1. **Read-only first:** grep `handlers/commands.js` for each of `/escalate`, `/deescalate`, `/move`, `/transfer`, `/backup`, `/export`, `/search`, `/history`, `/gmailpoll check`, and identify the first user-visible response on each path.
|
||
2. For any command where the first `interaction.reply` / `interaction.editReply` happens after two or more awaited calls, add `await interaction.deferReply({ ephemeral: <matching existing ephemerality> });` as the very first action, and convert subsequent `interaction.reply` calls on that path to `interaction.editReply` or `interaction.followUp`.
|
||
3. Do not touch commands that already defer.
|
||
**Verify:**
|
||
- Run `/backup` and `/export` on a server with 100+ tickets. Confirm no `InteractionAlreadyReplied` or `Unknown interaction` errors in console.
|
||
- Run `/escalate` and confirm the loading state appears immediately, then resolves.
|
||
|
||
---
|
||
|
||
## P2 — Add try/catch around `handleDiscordReply`
|
||
|
||
**Priority:** P2
|
||
**Files:** `/opt/broccolini-bot/broccolini-discord.js` (messageCreate listener, ~lines 159–170)
|
||
**Problem:** `handleDiscordReply(msg)` is called inside the `messageCreate` listener without explicit error handling. Any rejection (Gmail send failure, Mongo write error) becomes an `unhandledRejection` that the global handler logs but without message/channel context.
|
||
**Fix:** Wrap the call:
|
||
```js
|
||
handleDiscordReply(msg).catch((err) =>
|
||
logError('handleDiscordReply', err, null).catch(() => {})
|
||
);
|
||
```
|
||
Do not `await` — the event listener should not block on relay.
|
||
**Verify:** Throw a test error inside `handleDiscordReply` once; confirm the debug channel shows the error with the `handleDiscordReply` context label, not `unhandledRejection`.
|
||
|
||
---
|
||
|
||
## P2 — Sweep for token leakage in error logs
|
||
|
||
**Priority:** P2 (defense in depth)
|
||
**Files:** `/opt/broccolini-bot/services/gmail.js`, `/opt/broccolini-bot/gmail-poll.js`, `/opt/broccolini-bot/routes/bosscord.js`, `/opt/broccolini-bot/routes/internalApi.js`, `/opt/broccolini-bot/services/debugLog.js`
|
||
**Problem:** `logError(ctx, err)` forwards `err.stack` and `err.message` to a Discord channel. OAuth 401 responses from googleapis sometimes include the bearer token or refresh token in the error object's `config.headers.Authorization`. The bOSScord auth middleware sees raw `Authorization` headers. There is no active sanitization on the way to the log channel.
|
||
**Fix:**
|
||
1. **Audit:** read `services/debugLog.js:logError` and confirm exactly what fields of `err` get embedded in the Discord embed.
|
||
2. If `err.config` or `err.response.config.headers` are interpolated, add a sanitize step that strips `Authorization`, `refresh_token`, `access_token`, and any key matching `/token|secret|password/i` from the logged object before calling `.send`.
|
||
3. If only `err.message` and `err.stack` are logged, grep those for `process.env.REFRESH_TOKEN`, `process.env.BOSSCORD_API_KEY`, `process.env.INTERNAL_API_SECRET` literally — if the values appear, redact them before posting.
|
||
**Verify:** Force a Gmail 401 (e.g., in test env with a deliberately invalid token) and confirm the debug-channel log does not contain the refresh token string.
|
||
|
||
---
|
||
|
||
## P3 — Fix `Host.lastSeen` default (frozen at schema-definition time)
|
||
|
||
**Priority:** P3
|
||
**Files:** `/opt/broccolini-bot/models.js` (Host schema, around the `lastSeen` field)
|
||
**Problem:** `lastSeen: { type: Number, default: Date.now() }` — `Date.now()` is **called once** when the schema is defined at process start. Every new `Host` document gets the same timestamp (process start time) as the default, not the creation time.
|
||
**Fix:** Change to `default: Date.now` (pass the function reference) or `default: () => Date.now()`. No behavior change for existing docs.
|
||
**Verify:** `new Host({hostname:'x'}).save()` twice across a few seconds; confirm the two documents have different `lastSeen` values.
|
||
|
||
---
|
||
|
||
## P3 — Remove unused `p-queue` dependency
|
||
|
||
**Priority:** P3
|
||
**Files:** `/opt/broccolini-bot/package.json`, `/opt/broccolini-bot/package-lock.json`
|
||
**Problem:** `p-queue@^6.6.2` is declared in `dependencies` but never `require`d anywhere in the codebase (the channel queue implements its own serialization). Dead dependency bloats the install and the supply-chain surface.
|
||
**Fix:** `npm uninstall p-queue`. Commit both `package.json` and `package-lock.json`.
|
||
**Verify:** `grep -r "p-queue" .` returns no results outside `node_modules`. `npm ls` does not list it. Bot starts cleanly.
|
||
|
||
---
|
||
|
||
## P3 — Mark `services/staffChannel.js` as deprecated (or delete)
|
||
|
||
**Priority:** P3
|
||
**Files:** `/opt/broccolini-bot/services/staffChannel.js`, `/opt/broccolini-bot/models.js` (`Ticket.staffChannelId`)
|
||
**Problem:** `STAFF_CATEGORIES` is empty in `config.js`, `createStaffChannel` is not called from the claim flow, `Ticket.staffChannelId` is never read. The file still exports four functions that could mislead a reader into thinking the mirror-channel pattern is active.
|
||
**Fix:**
|
||
1. First verify: grep the repo for `staffChannel`, `createStaffChannel`, `staffChannelId`. Confirm the only matches are definitions + legacy doc references.
|
||
2. If truly unreferenced: add a file-top comment `// DEPRECATED: legacy per-staffer mirror channels. Not used in the current claim flow. Kept for history — do not reintroduce.` Leave the code in place to avoid git-history loss. Do **not** delete `Ticket.staffChannelId` (old tickets may have the field).
|
||
3. If any active caller exists (unexpected), stop and report the finding — do not modify.
|
||
**Verify:** After the comment is added, bot starts cleanly. `grep -r staffChannelId handlers services routes` shows no runtime read-sites.
|
||
|
||
---
|
||
|
||
## P3 — Reconcile `.env.example` with `config.js`
|
||
|
||
**Priority:** P3 (documentation hygiene)
|
||
**Files:** `/opt/broccolini-bot/.env.example`, `/opt/broccolini-bot/config.js`
|
||
**Problem:** 8 vars are read in code but not documented; 6 are documented but never read. New operators hit both problems on day one.
|
||
**Fix:**
|
||
1. **Add to `.env.example`** (as commented entries with one-line descriptions): `HEALTHCHECK_HOST`, `NOTIFICATION_THRESHOLDS_JSON`, `ROLE_TO_PING_ID` (as alias note on the existing `ROLE_ID_TO_PING`), `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS`, `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS`. `DISCORD_BOT_TOKEN` should be added as an explicit alias comment under `DISCORD_TOKEN`.
|
||
2. **Remove from `.env.example`**: `NGROK_URL` (unused), `DISCORD_ESCALATED_CATEGORY_ID`, `EMAIL_ESCALATED_CATEGORY_ID` (legacy; superseded by `*_ESCALATED2/3_CHANNEL_ID`).
|
||
3. Do **not** move `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, or `MONGODB_URI` — they are read directly (not via `CONFIG`) and should stay in `.env.example`.
|
||
**Verify:** Diff `.env.example` against `Object.keys(require('./config').CONFIG)` plus the three directly-read vars. No gaps either way.
|
||
|
||
---
|
||
|
||
## P3 — CVE sweep on top-level dependencies
|
||
|
||
**Priority:** P3 (read-only audit)
|
||
**Files:** `/opt/broccolini-bot/package.json`, `/opt/broccolini-bot/package-lock.json`
|
||
**Problem:** `mongoose@^6.12.0` is a generation behind (v7/v8 shipped), `express@^5.2.1` is early in the v5 line, `googleapis@^171.x` ships frequently with transitive fixes. No active `npm audit` output is documented.
|
||
**Fix (read-only):** Run `npm audit --omit=dev --json` at the repo root and paste the result into a new `docs/audit/npm-audit-YYYY-MM-DD.md`. Do not auto-upgrade. Flag any `high` / `critical` findings separately so they can be triaged individually.
|
||
**Verify:** The audit file exists and lists each finding with CVE ID, affected package, and fix version. No package.json changes in this prompt.
|
||
|
||
---
|
||
|
||
# End of improvement prompts
|
||
|
||
Total: 1 P0, 7 P1, 5 P2, 5 P3 — 18 prompts. Three known issues deliberately excluded (Gmail `invalid_grant`, `STAFF_EMOJIS` encoding, escalation button).
|