diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8ef4c26..850f46d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,24 @@ "Bash(node --check handlers/buttons.js)", "Bash(node --check gmail-poll.js)", "Bash(node --check handlers/pendingCloses.js)", - "Bash(node --check commands/register.js)" + "Bash(node --check commands/register.js)", + "Bash(grep -E \"\\\\.js$|\\\\.json$|^d\")", + "Bash(grep -r \"require\\\\|import\" /opt/broccolini-bot/*.js /opt/broccolini-bot/routes/*.js /opt/broccolini-bot/api/*.js)", + "Bash(grep -r \"require\\\\|import\" /opt/broccolini-bot/*.js)", + "Bash(grep *)", + "Bash(npm install *)", + "Bash(node -e \"require\\('./routes/bosscord'\\)\")", + "Bash(node -e \"require\\('./routes/internalApi'\\)\")", + "Bash(node -e \"require\\('express-rate-limit'\\)\")", + "Bash(node --check services/surgeChecker.js)", + "Bash(node --check services/patternChecker.js)", + "Bash(node --check services/chatAlertChecker.js)", + "Bash(node --check services/debugLog.js)", + "Bash(node --check services/staffChannel.js)", + "Bash(node --check handlers/messages.js)", + "Bash(node *)", + "Bash(npm info *)", + "Bash(npm ls *)" ] } } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..48635b4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +node_modules +.env +.env.* +docs +scripts +*.md +.claude +.opencode diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..33d0530 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Default mode: reviewer-first + +Default output is **scoped improvement prompts**, not code edits. Output format: + +``` +## [Short title] +**Files:** [files to read/modify] +**Problem:** [1-2 sentences] +**Fix:** [specific instructions — what to change, not how to think about it] +**Verify:** [how to confirm] +``` + +Keep each prompt to a 5–20 minute task; decompose larger issues. + +When the user asks for direct fixes, make them — but still avoid unsolicited refactors, rename sweeps, or cleanup beyond the stated scope. + +## Project +- **broccolini-bot** — Discord ticketing + support bot for Indifferent Broccoli (game hosting). +- **Repo:** `/opt/broccolini-bot/` · **Gitea:** `ssh://git@100.114.205.53:2222/indifferentketchup/broccolini-bot.git` +- **DB:** MongoDB Atlas, database `broccoli_db`. +- **Host port 8892 → container port 5000** (`CONFIG.PORT`, env `DISCORD_ONLY_PORT`). +- **Deploy:** `cd /opt/broccolini-bot && git pull && docker compose up --build -d` · tail: `docker logs broccolini-bot --tail 50 -f` + +## Commands +- `npm start` — run the bot (entry: `broccolini-discord.js`). +- `npm run start:test` — run with `ENV_FILE=.env.test`. +- `npm run start:1p` / `start:test:1p` — inject secrets via 1Password CLI (`op run`). +- `npm run test-mongodb` / `test-mongodb:test` — connectivity probe; no test suite exists. +- No lint step configured. No unit/integration test framework. + +Many files under `scripts/` are one-shot maintenance utilities (backups, user lookups, transcript mapping). They are not wired into CI or into the bot's runtime. + +## Stack +Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 (MongoDB Atlas) · googleapis · express-rate-limit · p-queue · dotenv/dotenv-expand. + +## Hard Rules + +1. **CommonJS only.** `require` / `module.exports`. Never `import`. +2. **Read before write.** Never propose or make changes to a file without first reading its current contents. +3. **Route channel operations through `services/channelQueue.js`**: `enqueueSend(channel, ...args)`, `enqueueRename(channel, name)`, `enqueueMove(channel, categoryId)`. Direct `channel.send(...)` / `channel.setName(...)` calls bypass ordering + rate-limit protection. **Audit note:** several files still bypass (`handlers/commands.js`, `handlers/buttons.js`, `handlers/accountinfo.js`, `handlers/setup.js`, `services/tickets.js`, `services/debugLog.js`, `services/patternChecker.js`, `services/surgeChecker.js`, `services/chatAlertChecker.js`, `services/staffChannel.js`, `routes/bosscord.js:191`) — treat as in-flight cleanup, migrate sends incrementally when touching those files. +4. **Logging is fire-and-forget.** Never `await logSystem/logError/logAutomation/logGmail/...`. Chain `.catch(() => {})` instead. +5. **Use `ChannelType` enum from `discord.js`**, not bare integers (`0`, `4`, `5`, `12`, `15`). +6. **Mongoose schema defaults:** pass function references (`default: Date.now`), never invocations (`default: Date.now()` pins all documents to module-load time). +7. **No unsolicited refactors.** Don't rename, reorganize, or restructure beyond the fix's scope. +8. **Backup before destructive data ops.** Provide the backup command first when the fix touches collections/files. + +## Architecture + +Single Node process. Entry: `broccolini-discord.js`. + +### Startup order +1. Module load: env validation, Discord `Client` created, `interactionCreate` / `messageCreate` listeners registered, `client.login(...)` called. +2. Public Express app (`app`) is defined at module scope with a **503 gate** — any `/api/*` request before `appReady` returns 503. +3. `client.once('ready')` (fires after Discord handshake): connects MongoDB, mounts bOSScord routes on `/api` (only if `BOSSCORD_API_KEY` set), calls `app.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST)`, sets `appReady = true`, then starts all background `setInterval`s. +4. The **internal** Express app (`internalApp`) listens separately on `127.0.0.1:INTERNAL_API_PORT` at module load, guarded by `INTERNAL_API_SECRET`. + +### Two HTTP surfaces +- **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`. +- **Internal (`internalApp`)** — `127.0.0.1` only, `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it. + +### Intervals & shutdown +- Every `setInterval` inside `ready` is wrapped via `trackInterval(...)` into the module-scoped `activeIntervals` Set. +- `handleShutdown(signal)` is idempotent (`shuttingDown` flag): clears every tracked interval, closes both HTTP servers, calls `client.destroy()`, calls `closeMongoDB()`, then `process.exit(0)`. Wired to SIGTERM/SIGINT. +- `setGmailPollInterval(ms)` and `clearGmailPollInterval()` manage the Gmail poll handle and keep it in sync with `activeIntervals`. + +### Interaction error handling +Every `interactionCreate` branch runs through `runHandler(name, interaction, fn)` which catches, `logError`s, and replies ephemerally `'Something went wrong.'` (uses `followUp` when the interaction is already deferred/replied). Setup buttons have their own try/catch for a custom error message. + +### Tickets (`services/tickets.js`, `models.js`) +- `Ticket` schema has indexes on `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`. +- **Discord-originated tickets** use `gmailThreadId` with prefix `discord-` / `discord-msg-` — skip the Gmail reply path entirely. +- `canRename(ticket)` enforces Discord's 2-rename/10-min per-channel limit via **two atomic `findOneAndUpdate` calls** (reset-if-expired, then increment-if-under-limit) — never a read-then-update. +- `getOrCreateTicketCategory()` handles Discord's 50-channels-per-category ceiling by creating `" (Overflow N)"` categories; `cleanupEmptyOverflowCategory()` removes empties. The primary category is never deleted. +- Scheduled jobs in `ready`: `checkAutoClose`, `checkAutoUnclaim`, `reconcileDeletedTicketChannels`, plus `services/staffNotifications.js#notifyAllStaffUnclaimed` and the pattern/surge/chat checkers. + +### Gmail bridge (`gmail-poll.js`, `services/gmail.js`) +- Polls `is:unread category:primary`, creates or appends to ticket channels. +- **Auth failure halts polling.** On `invalid_grant` / `unauthorized` / 401: `pollSuspended = true`, the poll interval is cleared via `require('./broccolini-discord').clearGmailPollInterval()`, admin is DM'd once. Polling does **not** auto-retry — container must restart after re-auth. +- `services/gmail.js` exports `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail`, `getGmailClient`. All HTML bodies go through `escapeHtml()`; `Date.now`-derived variables in templates come from `CONFIG` (`TICKET_CLOSE_MESSAGE`, `TICKET_CLOSE_SIGNATURE`, `SUPPORT_NAME`, `LOGO_URL`, `SIGNATURE`, `EMAIL_SIGNATURE`). + +### Pattern / surge / chat (`services/patternStore.js` et al.) +- In-memory counters bucketed into `today` / `week` / `month`, with scheduled resets at midnight / Monday 00:00 / 1st 00:00. +- `escalatingCooldowns` entries carry a `lastUsed` timestamp; a 6-hour interval prunes entries idle for >48h. The cleanup interval is `.unref()`-ed so shutdown isn't blocked by it. + +## bOSScord integration + +bOSScord is a separate React + Express cockpit app that consumes this bot's `/api/*` endpoints. +- Base URL: `http://100.114.205.53:8892/api` · Bearer `${BOSSCORD_API_KEY}`. +- bOSScord uses its own database (`bosscord_db`) — do not mix models. +- **Response-shape changes on `/api/*` are breaking** for bOSScord. Coordinate or version. + +## Known bad state + +- **Gmail `invalid_grant`** — `REFRESH_TOKEN` is a stale placeholder. Poll suspends automatically on auth error; the rest of the bot still works. Fix by regenerating the token (`node get-refresh-token.js`) and restarting. +- **`STAFF_EMOJIS` encoding** — some emoji entries render malformed. Root cause not identified. +- **Escalation button** — handler misfires in some flows. Root cause not identified. + +Do not re-report these as new findings. + +## Environment highlights + +Names and full tables are in `README.md` / `.env.example`. Ones that commonly trip up new code: + +| Var | Notes | +|-----|-------| +| `DISCORD_TOKEN` **or** `DISCORD_BOT_TOKEN` | First non-empty after trim wins. | +| `DISCORD_ONLY_PORT` | Maps to `CONFIG.PORT` (default 5000). | +| `HEALTHCHECK_HOST` | Omit for all-interfaces; set `127.0.0.1` for local-only. | +| `BOSSCORD_API_KEY` | Without it, `/api/*` is never mounted. | +| `BOSSCORD_CLIENT_ORIGIN` | CORS origin for bOSScord (not `BOSSCORD_CORS_ORIGIN`). | +| `INTERNAL_API_SECRET` | Without it, the internal settings API is never started. | +| `INTERNAL_API_PORT` | Internal app's port (127.0.0.1 bind). | +| `REFRESH_TOKEN` | Gmail OAuth; currently stale — see Known bad state. | + +## Settings site + +`settings-site/` contains a separate Express app (`settings-site/server.js`) for the admin UI — it talks to `internalApp` via `INTERNAL_API_SECRET`. It is **not** part of this bot's process. Changes to the bot's `/internal/config` contract (e.g., the `ALLOWED_CONFIG_KEYS` set) may break the settings UI. See `settings-site/CLAUDE.md` for that subproject's architecture and conventions. diff --git a/broccolini-discord.js b/broccolini-discord.js index 58fb0e6..437e5e9 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -4,7 +4,7 @@ */ const { Client, GatewayIntentBits, Partials } = require('discord.js'); const express = require('express'); -const { connectMongoDB } = require('./db-connection'); +const { connectMongoDB, closeMongoDB } = require('./db-connection'); const { CONFIG } = require('./config'); const { mongoose } = require('./db-connection'); @@ -31,15 +31,33 @@ const { getNextTicketNumber } = require('./services/tickets'); const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils'); let gmailPollInterval = null; +// Track all background setInterval handles so shutdown can clear them. +const activeIntervals = new Set(); +function trackInterval(handle) { + if (handle) activeIntervals.add(handle); + return handle; +} /** * Update the Gmail poll interval at runtime. * @param {number} ms - new interval in milliseconds */ function setGmailPollInterval(ms) { - if (gmailPollInterval) clearInterval(gmailPollInterval); + if (gmailPollInterval) { + clearInterval(gmailPollInterval); + activeIntervals.delete(gmailPollInterval); + } CONFIG.GMAIL_POLL_INTERVAL_MS = ms; gmailPollInterval = setInterval(() => poll(client), ms); + activeIntervals.add(gmailPollInterval); +} + +function clearGmailPollInterval() { + if (gmailPollInterval) { + clearInterval(gmailPollInterval); + activeIntervals.delete(gmailPollInterval); + gmailPollInterval = null; + } } // --- VALIDATE CONFIG --- @@ -71,9 +89,28 @@ const client = new Client({ }); // --- EVENT: interactionCreate --- +async function safeReplyError(interaction) { + const payload = { content: 'Something went wrong.', ephemeral: true }; + if (interaction.deferred || interaction.replied) { + await interaction.followUp(payload).catch(() => {}); + } else { + await interaction.reply(payload).catch(() => {}); + } +} + +async function runHandler(name, interaction, fn) { + try { + return await fn(); + } catch (err) { + console.error(`${name} error:`, err); + logError(name, err instanceof Error ? err : new Error(String(err)), null, client).catch(() => {}); + await safeReplyError(interaction); + } +} + client.on('interactionCreate', async interaction => { if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) { - const handled = await handleSendAccountInfoToChannel(interaction); + const handled = await runHandler('handleSendAccountInfoToChannel', interaction, () => handleSendAccountInfoToChannel(interaction)); if (handled) return; } @@ -83,6 +120,7 @@ client.on('interactionCreate', async interaction => { if (handled) return; } catch (err) { console.error('Setup button error:', err); + logError('handleSetupButton', err, null, client).catch(() => {}); await interaction.reply({ content: `Setup error: ${err.message}. Try \`/setup\` again.`, ephemeral: true @@ -92,11 +130,11 @@ client.on('interactionCreate', async interaction => { } if (interaction.isButton()) { - return handleButton(interaction); + return runHandler('handleButton', interaction, () => handleButton(interaction)); } if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) { - const handled = await handleSetupModal(interaction); + const handled = await runHandler('handleSetupModal', interaction, () => handleSetupModal(interaction)); if (handled) return; } @@ -109,9 +147,10 @@ client.on('interactionCreate', async interaction => { const StaffSignature = mongoose.model('StaffSignature'); await StaffSignature.findOneAndUpdate( - { userId: interaction.user.id }, - { + { userId: interaction.user.id, guildId: interaction.guildId }, + { userId: interaction.user.id, + guildId: interaction.guildId, valediction, displayName, tagline, @@ -135,24 +174,24 @@ client.on('interactionCreate', async interaction => { } if (interaction.isModalSubmit() && ['ticket_modal', 'ticket_modal_thread', 'ticket_modal_channel'].includes(interaction.customId)) { - return handleTicketModal(interaction); + return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction)); } if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) { - const handled = await handleSetupSelect(interaction); + const handled = await runHandler('handleSetupSelect', interaction, () => handleSetupSelect(interaction)); if (handled) return; } if (interaction.isChatInputCommand()) { - return handleCommand(interaction); + return runHandler('handleCommand', interaction, () => handleCommand(interaction)); } if (interaction.isMessageContextMenuCommand() || interaction.isUserContextMenuCommand()) { - return handleContextMenu(interaction); + return runHandler('handleContextMenu', interaction, () => handleContextMenu(interaction)); } if (interaction.isAutocomplete()) { - return handleAutocomplete(interaction); + return runHandler('handleAutocomplete', interaction, () => handleAutocomplete(interaction)); } }); @@ -184,6 +223,11 @@ client.once('ready', async () => { res.status(500).json({ error: 'Internal server error' }); }); } + const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined; + httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => { + console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); + }); + appReady = true; console.log(`Broccolini Bot active on port ${CONFIG.PORT}`); const guild = CONFIG.DISCORD_GUILD_ID @@ -203,21 +247,21 @@ client.once('ready', async () => { registerCommands().catch(console.error); - gmailPollInterval = setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS); + gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS)); poll(client); if (CONFIG.AUTO_CLOSE_ENABLED) { - setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000); + trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000)); checkAutoClose(client, sendTicketClosedEmail); console.log('✓ Auto-close enabled: checking every hour'); } - setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000); + trackInterval(setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000)); notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)); console.log('✓ Staff unclaimed reminders: checking every 30 minutes'); if (CONFIG.AUTO_UNCLAIM_ENABLED) { - setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000); + trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000)); checkAutoUnclaim(client); console.log('✓ Auto-unclaim enabled: checking every hour'); } @@ -225,21 +269,21 @@ client.once('ready', async () => { const { runPatternChecks } = require('./services/patternChecker'); const { scheduleResets } = require('./services/patternStore'); scheduleResets(); - setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000); + trackInterval(setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000)); console.log(`✓ Pattern checks: every ${CONFIG.PATTERN_CHECK_INTERVAL_MINUTES} minutes`); const { runSurgeChecks } = require('./services/surgeChecker'); - setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000); + trackInterval(setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000)); setTimeout(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 30000); console.log('✓ Surge checks: every 5 minutes'); const { initChatMonitoring, runChatAlertChecks } = require('./services/chatAlertChecker'); initChatMonitoring(client); - setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000); + trackInterval(setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000)); console.log('✓ Chat alert monitoring: every 5 minutes'); reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)); - setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000); + trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000)); console.log('✓ Reconcile deleted ticket channels: every 1 hour'); if (!CONFIG.STAFF_IDS.length) { @@ -266,20 +310,26 @@ client.login(CONFIG.DISCORD_TOKEN); const app = express(); app.use(express.json()); -app.get('/', (req, res) => res.send('Active')); -// Mount bOSScord API only after MongoDB is connected (inside ready), to avoid 500 on first request -const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined; -app.listen(CONFIG.PORT, healthcheckHost, () => { - console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); +// Reject API traffic with 503 until ready event has fired and routes are mounted. +let appReady = false; +app.use((req, res, next) => { + if (!appReady && req.path.startsWith('/api')) { + return res.status(503).json({ error: 'Bot is starting; API not ready yet.' }); + } + next(); }); +app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting')); +// app.listen is called inside client.once('ready') after MongoDB connects and routes mount. // --- Internal API for settings site --- const internalApi = require('./routes/internalApi'); const internalApp = express(); internalApp.use('/internal', internalApi); +let httpServer = null; +let internalServer = null; if (CONFIG.INTERNAL_API_SECRET) { - internalApp.listen(CONFIG.INTERNAL_API_PORT, '0.0.0.0', () => { + internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '127.0.0.1', () => { console.log(`[internalApi] listening on 127.0.0.1:${CONFIG.INTERNAL_API_PORT}`); }); } else { @@ -287,8 +337,23 @@ if (CONFIG.INTERNAL_API_SECRET) { } // --- Shutdown & error handlers --- +let shuttingDown = false; async function handleShutdown(signal) { - await Promise.race([logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]), new Promise(r => setTimeout(r, 2000))]); + if (shuttingDown) return; + shuttingDown = true; + await Promise.race([ + logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]), + new Promise(r => setTimeout(r, 2000)) + ]); + for (const handle of activeIntervals) { + try { clearInterval(handle); } catch (_) {} + } + activeIntervals.clear(); + gmailPollInterval = null; + try { if (httpServer) await new Promise(r => httpServer.close(() => r())); } catch (_) {} + try { if (internalServer) await new Promise(r => internalServer.close(() => r())); } catch (_) {} + try { client.destroy(); } catch (_) {} + try { await closeMongoDB(); } catch (_) {} process.exit(0); } process.on('SIGTERM', () => handleShutdown('SIGTERM')); @@ -300,6 +365,7 @@ process.on('unhandledRejection', (reason) => { module.exports = { client, setGmailPollInterval, + clearGmailPollInterval, sendGmailReply, sendTicketClosedEmail, getNextTicketNumber, diff --git a/broccolini_bot_context.md b/broccolini_bot_context.md new file mode 100644 index 0000000..21b2a71 --- /dev/null +++ b/broccolini_bot_context.md @@ -0,0 +1,512 @@ +# 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` 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, '
'); + const safeCloseSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); + ``` + — 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 `

` 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":""}` 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: ""}).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: ""}).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: });` 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). diff --git a/gmail-poll.js b/gmail-poll.js index 35b86a0..01e52ad 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -23,20 +23,25 @@ const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, creat const { getEmailRouting } = require('./services/guildSettings'); const { logError, logGmail, logAutomation } = require('./services/debugLog'); const { increment } = require('./services/patternStore'); +const { enqueueSend } = require('./services/channelQueue'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); let isPolling = false; let authErrorNotified = false; +let pollSuspended = false; let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0; +function setPollSuspended(val) { pollSuspended = !!val; } +function isPollSuspended() { return pollSuspended; } + /** * Poll Gmail for unread primary-inbox messages and route them to Discord. * @param {import('discord.js').Client} client */ async function poll(client) { - if (isPolling) return; + if (isPolling || pollSuspended) return; isPolling = true; try { pollCount++; @@ -155,7 +160,8 @@ async function poll(client) { if (ticketChan) { const truncatedFollowup = followupBody.slice(0, 1800); - await ticketChan.send( + await enqueueSend( + ticketChan, `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}` ); } else { @@ -247,7 +253,7 @@ async function poll(client) { ); enforceEmbedLimit([ticketInfoEmbed]); - const welcomeMsg = await ticketChan.send({ + const welcomeMsg = await enqueueSend(ticketChan, { content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, embeds: [ticketInfoEmbed], components: [buttons] @@ -275,7 +281,8 @@ async function poll(client) { .catch(() => null); if (transcriptChan) { - await ticketChan.send( + await enqueueSend( + ticketChan, `This email thread has ${transcriptRows.length} previous transcript(s):` ); @@ -286,11 +293,11 @@ async function poll(client) { if (!transcriptMsg) continue; - await ticketChan.send(`Transcript: ${transcriptMsg.url}`); + await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`); const originalAttachment = transcriptMsg.attachments.first(); if (originalAttachment) { - await ticketChan.send({ + await enqueueSend(ticketChan, { content: 'Transcript file:', files: [originalAttachment.url] }); @@ -304,7 +311,7 @@ async function poll(client) { } const truncated = firstBody.slice(0, 1900); - await ticketChan.send(`**Message:**\n${truncated}`); + await enqueueSend(ticketChan, `**Message:**\n${truncated}`); // Welcome message skipped for email tickets – the email body speaks for itself. // Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js. @@ -359,10 +366,14 @@ async function poll(client) { e.code === 401; if (isAuthError) { - logError('Gmail OAuth', { message: 'Gmail OAuth token invalid or expired. Re-authentication required.', stack: e.stack || e.message || String(e) }, null, client); + pollSuspended = true; + const suspendMsg = 'Gmail OAuth token invalid or expired. Polling SUSPENDED — will not retry automatically. Re-authenticate to resume.'; + console.error('[gmail-poll]', suspendMsg); + logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client); + try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {} if (CONFIG.ADMIN_ID && !authErrorNotified) { authErrorNotified = true; - client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send('Gmail OAuth token invalid or expired. Re-authentication required.')).catch(() => {}); + client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {}); } } @@ -375,4 +386,4 @@ async function poll(client) { } } -module.exports = { poll }; +module.exports = { poll, setPollSuspended, isPollSuspended }; diff --git a/handlers/accountinfo.js b/handlers/accountinfo.js index 46b56d2..e1eb25d 100644 --- a/handlers/accountinfo.js +++ b/handlers/accountinfo.js @@ -6,6 +6,7 @@ const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require(' const { CONFIG } = require('../config'); const { mongoose } = require('../db-connection'); const { logSecurity } = require('../services/debugLog'); +const { enqueueSend } = require('../services/channelQueue'); const User = mongoose.model('User'); @@ -167,7 +168,7 @@ async function handleSendAccountInfoToChannel(interaction) { } const embed = buildAccountInfoEmbed(user, `${interaction.user.tag} (from ticket)`); - await channel.send({ embeds: [embed] }); + await enqueueSend(channel, { embeds: [embed] }); await interaction.update({ content: 'Account info sent to account transcript channel.', diff --git a/handlers/buttons.js b/handlers/buttons.js index 6179aa3..12c4b68 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -21,7 +21,7 @@ const { sendTicketClosedEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils'); const { setEmailRouting } = require('../services/guildSettings'); -const { enqueueRename } = require('../services/channelQueue'); +const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { trackInteraction, trackError } = require('./analytics'); const { pendingCloses } = require('./pendingCloses'); @@ -356,7 +356,7 @@ async function handleClaim(interaction, ticket) { } else { const unlockAtMs = Date.now() + renameInfo.waitMs; const unlockAtUnix = Math.floor(unlockAtMs / 1000); - await interaction.channel.send( + await enqueueSend(interaction.channel, `Channel renamed too quickly. Try again .` ); } @@ -410,7 +410,7 @@ async function handleClaim(interaction, ticket) { } else { const unlockAtMs = Date.now() + renameInfo.waitMs; const unlockAtUnix = Math.floor(unlockAtMs / 1000); - await interaction.channel.send( + await enqueueSend(interaction.channel, `Channel renamed too quickly. Try again .` ); } @@ -496,7 +496,7 @@ async function handleConfirmClose(interaction, ticket) { // In-ticket message before transcript is posted (Discord close message) const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE; - await interaction.channel.send(discordCloseContent); + await enqueueSend(interaction.channel, discordCloseContent); const transcriptChan = await interaction.client.channels .fetch(CONFIG.TRANSCRIPT_CHAN) @@ -511,7 +511,7 @@ async function handleConfirmClose(interaction, ticket) { + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; if (transcriptChan) { - transcriptMsg = await transcriptChan.send({ + transcriptMsg = await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] }); @@ -559,7 +559,7 @@ async function handleConfirmClose(interaction, ticket) { } else { logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; } - await logChan.send(logMsg); + await enqueueSend(logChan, logMsg); } const closerDisplayName = @@ -732,7 +732,7 @@ async function handleTicketModal(interaction) { enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]); try { - const welcomeMsg = await channel.send({ + const welcomeMsg = await enqueueSend(channel, { content: `Hey There ${interaction.user} 🥦`, embeds: [welcomeEmbed, infoEmbed, resourcesEmbed], components: [actionRow] @@ -765,7 +765,7 @@ async function handleTicketModal(interaction) { const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { - await logChan.send( + await enqueueSend(logChan, `📝 ${channel.name} created by ${interaction.user.tag}` ); } diff --git a/handlers/commands.js b/handlers/commands.js index fdd4081..9a7b64d 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -17,7 +17,7 @@ const { canRename, makeTicketName, resolveCreatorNickname, getSenderLocal, toDis const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { getEmailRouting } = require('../services/guildSettings'); -const { enqueueRename, enqueueMove } = require('../services/channelQueue'); +const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); const { setNotifyDm } = require('../services/staffSettings'); const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics'); const { logTicketEvent, logSecurity } = require('../services/debugLog'); @@ -74,7 +74,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { // Clear claim on escalation await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, - { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedReminderssent: [] } } + { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedRemindersSent: [] } } ); ticket.escalated = true; ticket.escalationTier = nextTier; @@ -94,7 +94,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { } else { const unlockAtMs = Date.now() + renameInfo.waitMs; const unlockAtUnix = Math.floor(unlockAtMs / 1000); - await interaction.channel.send( + await enqueueSend(interaction.channel, `Channel renamed too quickly. Try again .` ); } @@ -116,7 +116,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦'; - await interaction.channel.send( + await enqueueSend(interaction.channel, `${heyLine}\n**Getting the senior ${roleMention} for you.**` ); @@ -130,7 +130,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { .setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` }); const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true }; const escalationRow = getTicketActionRow(updatedTicketForRow); - const escalationMsg = await interaction.channel.send({ + const escalationMsg = await enqueueSend(interaction.channel, { content: null, embeds: [escalatedEmbed], components: [escalationRow] @@ -174,7 +174,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) { if (logChan) { const ticketType = isDiscordTicket ? 'Discord' : 'Email'; const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; - await logChan.send( + await enqueueSend(logChan, `${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}` ); } @@ -204,7 +204,7 @@ async function runDeescalation(interaction, ticket) { } else { const unlockAtMs = Date.now() + renameInfo.waitMs; const unlockAtUnix = Math.floor(unlockAtMs / 1000); - await interaction.channel.send( + await enqueueSend(interaction.channel, `Channel renamed too quickly. Try again .` ); } @@ -235,7 +235,7 @@ async function runDeescalation(interaction, ticket) { const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { const ticketType = isDiscordTicket ? 'Discord' : 'Email'; - await logChan.send( + await enqueueSend(logChan, `${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.` ); } @@ -517,7 +517,7 @@ async function handleCommand(interaction) { const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { - await logChan.send( + await enqueueSend(logChan, `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}` ); } @@ -542,7 +542,7 @@ async function handleCommand(interaction) { const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); if (logChan) { - await logChan.send( + await enqueueSend(logChan, `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}` ); } @@ -652,10 +652,10 @@ async function handleCommand(interaction) { { $set: { status: 'closed' } } ); - await channelRef.send('Ticket force-closed. Archiving...'); + await enqueueSend(channelRef, 'Ticket force-closed. Archiving...'); try { - await channelRef.send(CONFIG.DISCORD_CLOSE_MESSAGE); + await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE); const messages = await channelRef.messages.fetch({ limit: 100 }); const log = @@ -691,7 +691,7 @@ async function handleCommand(interaction) { .replace(/\{date_opened\}/g, openedStr) .replace(/\{date_closed\}/g, closedStr) + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; - await transcriptChan.send({ + await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] }); @@ -1080,7 +1080,7 @@ async function handleCommand(interaction) { } try { - await channel.send({ embeds: [embed], components: [row] }); + await enqueueSend(channel, { embeds: [embed], components: [row] }); await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true }); } catch (err) { console.error('Panel creation error:', err); @@ -1104,7 +1104,7 @@ async function handleCommand(interaction) { } const buf = Buffer.from(lines.join('\n'), 'utf8'); const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID); - await channel.send({ + await enqueueSend(channel, { content: `Ticket backup by ${interaction.user.tag} (${tickets.length} tickets)`, files: [new AttachmentBuilder(buf, { name: `ticket-backup-${Date.now()}.txt` })] }); @@ -1134,7 +1134,7 @@ async function handleCommand(interaction) { } const buf = Buffer.from(lines.join('\n'), 'utf8'); const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID); - await channel.send({ + await enqueueSend(channel, { content: `Ticket export by ${interaction.user.tag} (${tickets.length} tickets${status ? ` status=${status}` : ''})`, files: [new AttachmentBuilder(buf, { name: `ticket-export-${Date.now()}.txt` })] }); @@ -1362,7 +1362,7 @@ async function handleContextMenu(interaction) { const row = getTicketActionRow({ escalationTier: 0 }); try { - const welcomeMsg = await channel.send({ + const welcomeMsg = await enqueueSend(channel, { content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`, embeds: [welcomeEmbed, infoEmbed], components: [row] diff --git a/handlers/setup.js b/handlers/setup.js index ddce269..c40031f 100644 --- a/handlers/setup.js +++ b/handlers/setup.js @@ -15,6 +15,7 @@ const { ChannelSelectMenuBuilder } = require('discord.js'); const { CONFIG } = require('../config'); +const { enqueueSend } = require('../services/channelQueue'); const TOTAL_STEPS = 5; const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes @@ -505,7 +506,7 @@ async function handleSetupButton(interaction) { ); } - await channel.send({ embeds: [embed], components: [row] }); + await enqueueSend(channel, { embeds: [embed], components: [row] }); const envLines = state.ticketType === 'both' ? [`DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`, `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`] diff --git a/models.js b/models.js index 2b04637..1b10efe 100644 --- a/models.js +++ b/models.js @@ -11,7 +11,7 @@ mongoose.model('Host', new mongoose.Schema({ memFree: Number, cpuUsage: Number, diskFree: Number, - lastSeen: { type: Number, default: Date.now() }, // Add this + lastSeen: { type: Number, default: Date.now }, lostInUse: { type: [Number], default: [] }, statsHistory: [{ timestamp: Number, @@ -792,33 +792,37 @@ mongoose.model('ErrorLog', new mongoose.Schema({ // ===== Broccolini Bot Models ===== -mongoose.model('Ticket', new mongoose.Schema({ +const ticketSchema = new mongoose.Schema({ gmailThreadId: { type: String, required: true, unique: true, index: true }, discordThreadId: String, broccoliniTicketId: Number, - lastSyncedBroccoliniArticleId: Number, // last agent reply we pushed to Discord/Gmail + lastSyncedBroccoliniArticleId: Number, senderEmail: { type: String, required: true }, subject: String, createdAt: { type: Date, default: Date.now }, status: { type: String, default: 'open', enum: ['open', 'closed'] }, transcriptMessageId: String, - claimedBy: String, // Discord user ID or display name + claimedBy: String, escalated: { type: Boolean, default: false }, - escalationTier: { type: Number, default: 0 }, // 0 = none, 1 = tier 2, 2 = tier 3 + escalationTier: { type: Number, default: 0 }, ticketNumber: Number, renameCount: { type: Number, default: 0 }, renameWindowStart: Date, priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] }, - ticketTag: String, // e.g. server-down, billing – used for channel name prefix (after priority emoji) + ticketTag: String, lastActivity: Date, reminderSent: { type: Boolean, default: false }, welcomeMessageId: String, claimerId: String, staffChannelId: String, parentCategoryId: String, - unclaimedReminderssent: { type: [Number], default: [] }, + unclaimedRemindersSent: { type: [Number], default: [] }, lastMessageAuthorIsStaff: { type: Boolean, default: false } -})); +}); +ticketSchema.index({ status: 1, lastActivity: 1 }); +ticketSchema.index({ senderEmail: 1, status: 1 }); +ticketSchema.index({ discordThreadId: 1 }); +mongoose.model('Ticket', ticketSchema); mongoose.model('TicketCounter', new mongoose.Schema({ senderLocal: { type: String, required: true, unique: true }, diff --git a/package-lock.json b/package-lock.json index 359381c..52451c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "dotenv": "^17.2.4", "dotenv-expand": "^11.0.6", "express": "^5.2.1", + "express-rate-limit": "^8.3.2", "googleapis": "^171.4.0", "mongodb": "^7.1.0", "mongoose": "^6.12.0", @@ -2513,6 +2514,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", diff --git a/package.json b/package.json index 55d47ed..24456f9 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "dependencies": { - "p-queue": "^6.6.2", "discord.js": "^14.25.1", "dotenv": "^17.2.4", "dotenv-expand": "^11.0.6", "express": "^5.2.1", + "express-rate-limit": "^8.3.2", "googleapis": "^171.4.0", "mongodb": "^7.1.0", - "mongoose": "^6.12.0" + "mongoose": "^6.12.0", + "p-queue": "^6.6.2" }, "name": "broccolini-bot", "version": "1.0.0", diff --git a/routes/bosscord.js b/routes/bosscord.js index 5fbb5c6..0b8c72f 100644 --- a/routes/bosscord.js +++ b/routes/bosscord.js @@ -5,16 +5,26 @@ require('../models'); // ensure Ticket model is registered const express = require('express'); const mongoose = require('mongoose'); +const rateLimit = require('express-rate-limit'); const { getBot } = require('../api/bosscordClient'); const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { updateTicketActivity } = require('../services/tickets'); +const { enqueueSend } = require('../services/channelQueue'); const { extractRawEmail } = require('../utils'); const { CONFIG } = require('../config'); const router = express.Router(); const Ticket = mongoose.model('Ticket'); -const CORS_ORIGIN = process.env.BOSSCORD_CORS_ORIGIN || '*'; +const CORS_ORIGIN = process.env.BOSSCORD_CLIENT_ORIGIN || 'http://100.114.205.53:3081'; + +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later.' } +}); function corsMiddleware(req, res, next) { res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN); @@ -39,6 +49,7 @@ function authMiddleware(req, res, next) { next(); } +router.use(apiLimiter); router.use(corsMiddleware); router.use(authMiddleware); @@ -178,7 +189,7 @@ router.post('/tickets/:id/messages', express.json(), async (req, res) => { return res.status(404).json({ error: 'Discord channel not found' }); } const discordUser = req.body.displayName || 'bOSScord'; - await channel.send(content); + await enqueueSend(channel, content); if (!ticket.gmailThreadId.startsWith('discord-')) { try { diff --git a/routes/internalApi.js b/routes/internalApi.js index 6c41ff0..53e43a7 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -1,10 +1,22 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); +const { ChannelType } = require('discord.js'); const { CONFIG } = require('../config'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { logSystem } = require('../services/debugLog'); const router = express.Router(); +const internalLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later.' } +}); + +router.use(internalLimiter); + // Middleware: verify internal secret router.use((req, res, next) => { const secret = req.headers['x-internal-secret']; @@ -25,12 +37,77 @@ router.get('/config', (req, res) => { res.json(obj); }); -// POST /config — apply config updates +// POST /config — apply config updates (allowlisted keys only) +const ALLOWED_CONFIG_KEYS = new Set([ + // Ticket settings + 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME', + 'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS', + 'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS', + // Escalation categories + 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', + 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', + // Roles and staff + 'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', + 'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK', + // Channel IDs + 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', + 'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID', + 'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID', + 'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID', + 'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID', + 'STAFF_NOTIFICATION_CATEGORY_ID', + // Pattern channel IDs + 'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID', + 'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID', + // Messages and labels + 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', + 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', + 'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE', + 'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', + 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', + // Branding + 'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST', + // Toggles + 'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS', + 'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE', + 'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY', + 'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', + 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE', + 'STAFF_DND_COUNTS_AS_AVAILABLE', + // Limits and thresholds + 'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY', + 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', + 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', + // Embed colors + 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', + 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI', + // Pattern thresholds + 'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD', + 'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD', + 'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES', + // Surge settings + 'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES', + 'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES', + 'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS', + 'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS', + 'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES', + 'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD', + // Chat alerts + 'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT', + 'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES', + // Notification thresholds + 'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS' +]); + router.post('/config', express.json(), async (req, res) => { const updates = req.body; - if (!updates || typeof updates !== 'object') { + if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { return res.status(400).json({ error: 'Invalid body' }); } + const rejected = Object.keys(updates).filter(k => !ALLOWED_CONFIG_KEYS.has(k)); + if (rejected.length > 0) { + return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` }); + } const result = applyConfigUpdates(updates); await logSystem('Config updated via settings UI', [ { name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false }, @@ -50,8 +127,14 @@ router.get('/discord/guild', async (req, res) => { await guild.members.fetch().catch(() => {}); + const CHANNEL_TYPES = [ + ChannelType.GuildText, + ChannelType.GuildCategory, + ChannelType.GuildAnnouncement, + ChannelType.GuildForum + ]; const channels = guild.channels.cache - .filter(c => [0, 4, 5, 15].includes(c.type)) + .filter(c => CHANNEL_TYPES.includes(c.type)) .map(c => ({ id: c.id, name: c.name, type: c.type, parentId: c.parentId })) .sort((a, b) => a.name.localeCompare(b.name)); @@ -71,7 +154,7 @@ router.get('/discord/guild', async (req, res) => { .sort((a, b) => a.displayName.localeCompare(b.displayName)); const categories = guild.channels.cache - .filter(c => c.type === 4) + .filter(c => c.type === ChannelType.GuildCategory) .map(c => ({ id: c.id, name: c.name })) .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/services/channelQueue.js b/services/channelQueue.js index 89e85da..917422e 100644 --- a/services/channelQueue.js +++ b/services/channelQueue.js @@ -95,4 +95,20 @@ function enqueueMove(channel, categoryId) { return channel.setParent(categoryId, { lockPermissions: true }); } -module.exports = { enqueueRename, enqueueMove }; +// Per-channel promise chain for send ordering and to prevent interleaving. +const sendChains = new Map(); + +function enqueueSend(channel, ...args) { + if (!channel || typeof channel.send !== 'function') { + return Promise.reject(new Error('enqueueSend: invalid channel')); + } + const prev = sendChains.get(channel.id) || Promise.resolve(); + const next = prev.catch(() => {}).then(() => channel.send(...args)); + sendChains.set(channel.id, next); + next.catch(() => {}).finally(() => { + if (sendChains.get(channel.id) === next) sendChains.delete(channel.id); + }); + return next; +} + +module.exports = { enqueueRename, enqueueMove, enqueueSend }; diff --git a/services/chatAlertChecker.js b/services/chatAlertChecker.js index d08907f..0f34c13 100644 --- a/services/chatAlertChecker.js +++ b/services/chatAlertChecker.js @@ -5,6 +5,7 @@ const { EmbedBuilder } = require('discord.js'); const { CONFIG, parseThresholdString } = require('../config'); const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore'); +const { enqueueSend } = require('./channelQueue'); // channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt } const chatState = new Map(); @@ -64,7 +65,7 @@ async function runChatAlertChecks(client) { try { const alertChan = await client.channels.fetch(alertChannelId); const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined; - if (alertChan) await alertChan.send({ content, embeds: [embed] }); + if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] }); } catch (_) {} } } @@ -82,7 +83,7 @@ async function runChatAlertChecks(client) { try { const alertChan = await client.channels.fetch(alertChannelId); const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined; - if (alertChan) await alertChan.send({ content, embeds: [embed] }); + if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] }); } catch (_) {} } } diff --git a/services/gmail.js b/services/gmail.js index f22dacd..87ef9e8 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -15,65 +15,6 @@ function getGmailClient() { return google.gmail({ version: 'v1', auth }); } -async function sendGmailReply( - threadId, - replyText, - recipientEmail, - subject, - discordUser, - messageId -) { - const gmail = getGmailClient(); - - const utf8Subject = `=?utf-8?B?${Buffer.from( - `Re: ${subject}` - ).toString('base64')}?=`; - const safeUser = escapeHtml(discordUser); - const safeReply = escapeHtml(replyText).replace(/\n/g, '
'); - const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); - const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); - const htmlBody = ` -

-

From: ${safeUser} on Discord

-

${safeReply}

-
- - - - - -
- ${safeLogoUrl ? `` : ''} - -

${safeUser}

-
${safeSignature}
-
-
`; - - const headers = [ - `From: ${CONFIG.MY_EMAIL}`, - `To: ${recipientEmail}`, - `Subject: ${utf8Subject}`, - messageId ? `In-Reply-To: ${messageId}` : '', - messageId ? `References: ${messageId}` : '', - 'MIME-Version: 1.0', - 'Content-Type: text/html; charset="UTF-8"', - '', - htmlBody - ].filter(Boolean); - - const raw = Buffer.from(headers.join('\r\n')) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw, threadId } - }); -} - async function sendTicketClosedEmail(ticket, discordDisplayName) { try { const gmail = getGmailClient(); @@ -105,13 +46,15 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) { finalSubject ).toString('base64')}?=`; - const serverDisplayName = escapeHtml(discordDisplayName || 'Support'); + const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support'); const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); + const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '
'); + const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '
'); const htmlBody = `

From: ${serverDisplayName} on Discord

-

Message:

+

Message:

${safeCloseMessage}

${safeCloseSignature}


@@ -202,6 +145,9 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro } const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); + const serverDisplayName = label; + const safeCloseMessage = safeBody; + const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '
'); const htmlBody = `

From: ${serverDisplayName} on Discord

diff --git a/services/patternChecker.js b/services/patternChecker.js index 24b61df..4a70085 100644 --- a/services/patternChecker.js +++ b/services/patternChecker.js @@ -6,6 +6,7 @@ const { EmbedBuilder } = require('discord.js'); const { CONFIG, parseThresholdString } = require('../config'); const { mongoose } = require('../db-connection'); const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore'); +const { enqueueSend } = require('./channelQueue'); const Ticket = mongoose.model('Ticket'); @@ -28,7 +29,7 @@ async function postPattern(client, channelConfigKey, embed) { if (!channelId || !client) return; try { const channel = await client.channels.fetch(channelId); - if (channel) await channel.send({ embeds: [embed] }); + if (channel) await enqueueSend(channel, { embeds: [embed] }); } catch (_) {} } diff --git a/services/patternStore.js b/services/patternStore.js index 6b9d880..3b00041 100644 --- a/services/patternStore.js +++ b/services/patternStore.js @@ -128,9 +128,10 @@ function shouldFireCooldownEscalating(key, thresholdsMs) { let state = escalatingCooldowns.get(key); if (!state) { - state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0 }; + state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now }; escalatingCooldowns.set(key, state); } + state.lastUsed = now; const nextThreshold = sortedThresholds[state.fireCount]; if (typeof nextThreshold !== 'number') return null; @@ -147,6 +148,19 @@ function clearEscalating(key) { escalatingCooldowns.delete(key); } +const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000; +const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; + +function cleanupStaleEscalatingCooldowns() { + const cutoff = Date.now() - ESCALATING_COOLDOWN_TTL_MS; + for (const [key, state] of escalatingCooldowns.entries()) { + const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0; + if (lastUsed < cutoff) escalatingCooldowns.delete(key); + } +} + +setInterval(cleanupStaleEscalatingCooldowns, ESCALATING_CLEANUP_INTERVAL_MS).unref?.(); + function scheduleDailyReset() { setTimeout(() => { store.today = new Map(); diff --git a/services/staffChannel.js b/services/staffChannel.js index e316f37..9507ca5 100644 --- a/services/staffChannel.js +++ b/services/staffChannel.js @@ -1,4 +1,5 @@ const { CONFIG } = require('../config'); +const { enqueueSend } = require('./channelQueue'); /** * Create a staff tracking channel for a ticket. @@ -33,7 +34,7 @@ async function createStaffChannel(guild, ticket, claimerId, channelName) { .setFooter({ text: `Claimed by ${ticket.claimedBy || 'Unknown'}` }) .setTimestamp(); - const pinMsg = await staffChan.send({ embeds: [embed] }); + const pinMsg = await enqueueSend(staffChan, { embeds: [embed] }); await pinMsg.pin().catch(() => {}); return staffChan; @@ -50,7 +51,7 @@ async function pingStaffChannel(staffChannel, claimerId, originalMessage) { if (!staffChannel) return; try { const jumpLink = `https://discord.com/channels/${originalMessage.guild.id}/${originalMessage.channel.id}/${originalMessage.id}`; - await staffChannel.send( + await enqueueSend(staffChannel, `<@${claimerId}> Customer replied in ticket:\n> ${originalMessage.content.slice(0, 500)}\n[Jump to message](${jumpLink})` ); } catch (e) { diff --git a/services/staffNotifications.js b/services/staffNotifications.js index 7da4221..d4e1c00 100644 --- a/services/staffNotifications.js +++ b/services/staffNotifications.js @@ -11,6 +11,7 @@ const { mongoose } = require('../db-connection'); const { CONFIG, parseThresholdString } = require('../config'); const { increment } = require('./patternStore'); +const { enqueueSend } = require('./channelQueue'); const Ticket = mongoose.model('Ticket'); const StaffNotification = mongoose.model('StaffNotification'); @@ -39,7 +40,8 @@ async function notifyStaffOfReply(guild, ticket, message) { const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`; const snippet = message.content?.slice(0, 300) || '(no text)'; - await notifChannel.send( + await enqueueSend( + notifChannel, `New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})` ); @@ -82,7 +84,7 @@ async function notifyAllStaffUnclaimed(client) { for (const ticket of unclaimedTickets) { const ageMs = now - new Date(ticket.createdAt).getTime(); const ageHours = ageMs / (60 * 60 * 1000); - const alreadySent = ticket.unclaimedReminderssent || []; + const alreadySent = ticket.unclaimedRemindersSent || []; // Find thresholds crossed but not yet sent const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t)); @@ -100,7 +102,7 @@ async function notifyAllStaffUnclaimed(client) { for (const rec of staffRecords) { const chan = await guild.channels.fetch(rec.channelId).catch(() => null); if (chan) { - await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e)); + await enqueueSend(chan, alertMsg).catch(e => console.error('Unclaimed notify send:', e)); increment('staff_stale_pings', rec.userId, 'today'); increment('staff_stale_pings', rec.userId, 'week'); } @@ -108,7 +110,7 @@ async function notifyAllStaffUnclaimed(client) { await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, - { $addToSet: { unclaimedReminderssent: highest } } + { $addToSet: { unclaimedRemindersSent: highest } } ); } } diff --git a/services/staffThread.js b/services/staffThread.js index 20c68fa..8c3ada1 100644 --- a/services/staffThread.js +++ b/services/staffThread.js @@ -5,7 +5,7 @@ * Notes: * - The bot requires CREATE_PRIVATE_THREADS and SEND_MESSAGES_IN_THREADS * permissions on every ticket category. - * - Private threads (type: 12) require the server to have Community features + * - Private threads (ChannelType.PrivateThread) require the server to have Community features * OR the channel to be in a server with Boost level that unlocks private * threads. If thread creation fails with code 50024 or 160004, a warning * is logged via logWarn. @@ -15,6 +15,7 @@ * servers. The 300ms delay between adds avoids the thread member add rate * limit (approximately 5/second). */ +const { ChannelType } = require('discord.js'); const { CONFIG } = require('../config'); const { logError, logWarn } = require('./debugLog'); @@ -32,7 +33,7 @@ async function createStaffThread(channel, client) { const thread = await channel.threads.create({ name: threadName, - type: 12, // ChannelType.PrivateThread + type: ChannelType.PrivateThread, invitable: false, reason: 'Staff discussion thread for ticket' }); @@ -84,7 +85,7 @@ async function addMemberToStaffThread(channel, memberId) { try { const threads = await channel.threads.fetchActive(); const staffThread = threads.threads.find(t => - t.name === CONFIG.STAFF_THREAD_NAME && t.type === 12 + t.name === CONFIG.STAFF_THREAD_NAME && t.type === ChannelType.PrivateThread ); if (!staffThread) return; await staffThread.members.add(memberId); diff --git a/services/surgeChecker.js b/services/surgeChecker.js index 5642ae7..73e6774 100644 --- a/services/surgeChecker.js +++ b/services/surgeChecker.js @@ -7,6 +7,7 @@ const { CONFIG, parseThresholdString } = require('../config'); const { mongoose } = require('../db-connection'); const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore'); const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence'); +const { enqueueSend } = require('./channelQueue'); const Ticket = mongoose.model('Ticket'); @@ -37,7 +38,7 @@ async function pingStaff(client, message, embedFields) { }))); } const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined; - await channel.send({ content, embeds: [embed] }); + await enqueueSend(channel, { content, embeds: [embed] }); } catch (_) {} } diff --git a/services/tickets.js b/services/tickets.js index f532d09..0c909c8 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -7,6 +7,7 @@ const { mongoose, withRetry } = require('../db-connection'); const { CONFIG } = require('../config'); const { getPriorityEmoji } = require('../utils'); const { logAutomation } = require('../services/debugLog'); +const { enqueueSend } = require('./channelQueue'); const Ticket = mongoose.model('Ticket'); const TicketCounter = mongoose.model('TicketCounter'); @@ -89,48 +90,51 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) { async function canRename(ticket) { const now = Date.now(); - const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }) - .select('renameCount renameWindowStart') - .lean(); - if (!fresh) { - return { ok: false, remaining: 0, waitMs: RENAME_WINDOW_MS }; + const windowCutoff = new Date(now - RENAME_WINDOW_MS); + + // Atomic: reset the window if the stored start is older than the cutoff; count = 1. + const resetDoc = await Ticket.findOneAndUpdate( + { + gmailThreadId: ticket.gmailThreadId, + $or: [ + { renameWindowStart: { $lt: windowCutoff } }, + { renameWindowStart: null }, + { renameWindowStart: { $exists: false } } + ] + }, + { $set: { renameWindowStart: new Date(now), renameCount: 1 } }, + { new: true, projection: { renameCount: 1, renameWindowStart: 1 } } + ).lean(); + + if (resetDoc) { + ticket.renameWindowStart = resetDoc.renameWindowStart; + ticket.renameCount = resetDoc.renameCount; + return { ok: true, remaining: RENAME_LIMIT - resetDoc.renameCount, waitMs: 0 }; } - const windowStart = (fresh.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || 0; - const count = fresh.renameCount || 0; - - if (now - windowStart >= RENAME_WINDOW_MS) { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { renameWindowStart: new Date(now), renameCount: 0 } } - ); - ticket.renameWindowStart = new Date(now); - ticket.renameCount = 0; - return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 }; - } - - if (count >= RENAME_LIMIT) { - const waitMs = RENAME_WINDOW_MS - (now - windowStart); - return { ok: false, remaining: 0, waitMs }; - } - - const updated = await Ticket.findOneAndUpdate( - { gmailThreadId: ticket.gmailThreadId }, + // Atomic: within window, only increment if count < limit. + const incDoc = await Ticket.findOneAndUpdate( + { + gmailThreadId: ticket.gmailThreadId, + renameCount: { $lt: RENAME_LIMIT } + }, { $inc: { renameCount: 1 } }, - { returnDocument: 'after' } - ) - .select('renameCount renameWindowStart') - .lean(); + { new: true, projection: { renameCount: 1, renameWindowStart: 1 } } + ).lean(); - if (!updated) { - const waitMs = RENAME_WINDOW_MS - (now - windowStart); - return { ok: false, remaining: 0, waitMs }; + if (incDoc) { + ticket.renameWindowStart = incDoc.renameWindowStart; + ticket.renameCount = incDoc.renameCount; + return { ok: true, remaining: RENAME_LIMIT - incDoc.renameCount, waitMs: 0 }; } - const newCount = updated.renameCount || 0; - ticket.renameCount = newCount; - ticket.renameWindowStart = updated.renameWindowStart; - return { ok: true, remaining: RENAME_LIMIT - newCount, waitMs: 0 }; + // At limit — read the window start to compute waitMs. + const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }) + .select('renameWindowStart') + .lean(); + const windowStart = (fresh?.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || now; + const waitMs = Math.max(0, RENAME_WINDOW_MS - (now - windowStart)); + return { ok: false, remaining: 0, waitMs }; } function minutesFromMs(ms) { @@ -487,7 +491,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) { const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); if (channel) { - await channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); + await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); await withRetry(() => Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, @@ -531,7 +535,7 @@ async function checkReminders(client) { const message = CONFIG.REMINDER_MESSAGE .replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS)) .replace(/\{ping\}/g, ping); - await channel.send(message); + await enqueueSend(channel, message); await withRetry(() => Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, @@ -570,7 +574,7 @@ async function checkAutoUnclaim(client) { { $set: { claimedBy: null } } )); - await channel.send( + await enqueueSend(channel, `This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).` ); diff --git a/settings-site/.env.example b/settings-site/.env.example index c5d398c..c7e03c4 100644 --- a/settings-site/.env.example +++ b/settings-site/.env.example @@ -3,3 +3,6 @@ SETTINGS_ADMIN_PASSWORD= SETTINGS_DOMAIN=tickets.indifferentketchup.com INTERNAL_API_PORT=12753 INTERNAL_API_SECRET= +# Cookie-signing + CSRF secret. Generate with: openssl rand -hex 32 +SESSION_SECRET= +NODE_ENV=production diff --git a/settings-site/CLAUDE.md b/settings-site/CLAUDE.md new file mode 100644 index 0000000..970ea89 --- /dev/null +++ b/settings-site/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Scope + +This is the **settings-site** subdirectory of the broccolini-bot repo. It is a **separate Express process** that provides an admin web UI for editing the bot's runtime config. It is **not** part of the bot's Node process. + +The parent repo's rules in `/opt/broccolini-bot/CLAUDE.md` still apply here — especially **CommonJS only**, **read before write**, and **no unsolicited refactors**. Read that file alongside this one. + +## Commands + +- `npm start` — run the settings site (`node server.js`). +- `npm run dev` — run with `node --watch` for auto-reload. +- No lint, no test framework, no build step. Frontend is vanilla JS served from `public/` — no bundler. +- Deploy via its own compose file: `docker compose up --build -d` from this directory. Container name `broccolini-settings`, joins external `broccoli-net`. + +## Architecture + +### Two processes, one `.env` +The settings site is a thin HTTPS-oriented proxy in front of the bot's internal API: + +``` +browser ──► settings server.js (:SETTINGS_PORT, default 12752) + │ session auth (SETTINGS_ADMIN_PASSWORD) + ▼ + bot internalApp (127.0.0.1:INTERNAL_API_PORT, default 12753) + │ header auth (x-internal-secret = INTERNAL_API_SECRET) + ▼ + routes/internalApi.js in /opt/broccolini-bot +``` + +`server.js` loads `../.env` (the **bot's** env file) — both processes share it. `docker-compose.yml` also mounts `env_file: ../.env`, not a local one. There is no settings-site-specific env beyond what's in `.env.example`. + +### Proxied endpoints +`server.js` exposes five authenticated endpoints that forward to the bot's `/internal/*` API via `callBot()`: + +| Settings route | Bot route | +|---|---| +| `GET /api/config` | `GET /internal/config` | +| `POST /api/config` | `POST /internal/config` | +| `GET /api/discord/guild` | `GET /internal/discord/guild` | +| `POST /api/restart` | `POST /internal/restart` | +| `GET /api/restart/status` | `GET /internal/restart/status` | + +Every response-shape change in the bot's `/internal/*` handlers (`routes/internalApi.js`) is a breaking change here. The bot also gates `POST /internal/config` on an `ALLOWED_CONFIG_KEYS` allowlist — **adding a new field to the UI requires adding the key to that Set in the bot first**, otherwise the save returns 400 for that key. + +### Session cookie requires HTTPS +`server.js:20-26` sets `cookie.secure: true`. Browsers will refuse to persist the session cookie over plain HTTP, so login silently fails when not behind an HTTPS reverse proxy (`SETTINGS_DOMAIN` is the deployed domain). If you're reproducing a login bug, check this first before debugging auth logic. The `session secret` falls back to `'fallback-secret-change-me'` when `INTERNAL_API_SECRET` is unset — don't rely on the fallback in any environment that matters. + +### Client-side routing +`public/index.html` is a single page with all sections rendered; `public/js/app.js` toggles `.hidden` on sections based on `location.pathname`. Routes live in the `ROUTES` map (`app.js:425`). The server has `app.get('*', requireAuth, …)` as a catch-all back to `index.html` (`server.js:97`), so any new client route works without server changes as long as it's added to `ROUTES`. + +### Config field binding (frontend) +Any form element with `data-key="SOME_CONFIG_KEY"` participates in the editor: +- `populateFields()` (`app.js:102`) fills it from `GET /api/config` and wires change listeners. +- Checkboxes serialize to the strings `'true'` / `'false'`, and `` serializes to `0xRRGGBB` — this matches how the bot stores these values. +- `pendingChanges` accumulates diffs; `saveConfig()` POSTs the whole diff at once. +- `data-smart="channel|category|role|member|multi-member"` swaps the bare `` for a searchable Discord picker backed by `GET /api/discord/guild` (see `public/js/discord.js`). + +**To add a new editable config field:** (1) add the key to the bot's `ALLOWED_CONFIG_KEYS`, (2) add a `` (optionally `data-smart=…`) inside the appropriate `.section` in `public/index.html`. No JS changes needed. + +### Notification thresholds editor +The Notifications section is **not** a simple `data-key` field — it's a custom editor (`app.js:239-423`) that serializes into a single hidden `NOTIFICATION_THRESHOLDS_JSON` field. Alert keys are hard-coded in `NOTIFICATION_TAB_KEYS` (surge / patterns / unclaimed / chat) and described in `NOTIFICATION_ALERT_DESCRIPTIONS`. **Adding a new alert key requires editing both of those objects** — otherwise it won't show up in any tab. Threshold values accept whole numbers or duration strings matching `^(\d+[mhd])+$` (e.g. `15m`, `1h`, `1d6h`). + +## Gotchas + +- The frontend has no framework and no build — edit `public/js/*.js` directly; changes are live on reload. +- `getaddrinfo` failures from `callBot()` surface to the UI as "Bot unreachable" (502). This is almost always the bot process being down or the internal port being wrong, not a bug in this codebase. +- `docker-compose.yml` binds the port to the Tailscale IP `100.114.205.53:12752` — not `0.0.0.0`. Changing that binding has security implications. diff --git a/settings-site/package-lock.json b/settings-site/package-lock.json index 656c5ea..a1050b9 100644 --- a/settings-site/package-lock.json +++ b/settings-site/package-lock.json @@ -8,9 +8,13 @@ "name": "broccolini-settings", "version": "1.0.0", "dependencies": { + "cookie-parser": "^1.4.6", + "csrf-csrf": "^4.0.3", "dotenv": "^16.0.0", "express": "^4.18.0", + "express-rate-limit": "^7.4.0", "express-session": "^1.17.3", + "helmet": "^8.0.0", "node-fetch": "^2.7.0" } }, @@ -116,11 +120,39 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, + "node_modules/csrf-csrf": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz", + "integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -268,6 +300,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", @@ -399,6 +446,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/settings-site/package.json b/settings-site/package.json index 808ac94..1c9c08e 100644 --- a/settings-site/package.json +++ b/settings-site/package.json @@ -7,9 +7,13 @@ "dev": "node --watch server.js" }, "dependencies": { - "express": "^4.18.0", - "express-session": "^1.17.3", + "cookie-parser": "^1.4.6", + "csrf-csrf": "^4.0.3", "dotenv": "^16.0.0", + "express": "^4.18.0", + "express-rate-limit": "^7.4.0", + "express-session": "^1.17.3", + "helmet": "^8.0.0", "node-fetch": "^2.7.0" } } diff --git a/settings-site/public/css/login.css b/settings-site/public/css/login.css new file mode 100644 index 0000000..b02ba36 --- /dev/null +++ b/settings-site/public/css/login.css @@ -0,0 +1,49 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: 'Inter', sans-serif; + background: #0f1117; + color: #e0e0e0; + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} +.login-card { + background: #1e2235; + border: 1px solid #2a2d3e; + border-radius: 16px; + padding: 48px 40px; + width: 380px; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} +.login-card h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; } +.login-card p { font-size: 14px; color: #888; margin-bottom: 32px; } +.login-card input { + width: 100%; + padding: 12px 16px; + background: #0f1117; + border: 1px solid #2a2d3e; + border-radius: 8px; + color: #e0e0e0; + font-size: 14px; + margin-bottom: 16px; + outline: none; + transition: border-color 200ms; +} +.login-card input:focus { border-color: #5865f2; } +.login-card button { + width: 100%; + padding: 12px; + background: #5865f2; + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 200ms; +} +.login-card button:hover { background: #4752c4; } +.error { color: #ed4245; font-size: 13px; margin-top: 8px; display: none; } +.error.visible { display: block; } diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css index 62387d2..4a3c033 100644 --- a/settings-site/public/css/main.css +++ b/settings-site/public/css/main.css @@ -132,3 +132,26 @@ body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--tex .loading.hidden { display: none; } .spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } + +/* Notifications section */ +#s-notifications .notif-tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } +#s-notifications .notif-tab-btn { border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: 8px; padding: 8px 12px; cursor: pointer; } +#s-notifications .notif-tab-btn.active { border-color: var(--accent); color: var(--accent); } +#s-notifications .notif-panel.hidden { display: none; } +#s-notifications .notif-editor { border: 1px solid var(--border); border-radius: 10px; padding: 14px; margin-bottom: 14px; background: var(--surface); } +#s-notifications .notif-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0; min-height: 28px; } +#s-notifications .notif-chip { display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--border); background: var(--bg); border-radius: 999px; padding: 4px 10px; font-size: 12px; } +#s-notifications .notif-chip button { border: none; background: transparent; color: var(--text-muted); cursor: pointer; padding: 0; line-height: 1; font-size: 14px; } +#s-notifications .notif-input-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +#s-notifications .notif-input-row input { width: 220px; } +#s-notifications .notif-presets { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } +#s-notifications .notif-presets button { padding: 6px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); cursor: pointer; } +#s-notifications .notif-trigger { margin-top: 10px; } +#s-notifications .notif-trigger summary { cursor: pointer; color: var(--text-muted); font-weight: 600; margin-bottom: 10px; } + +/* Logging section cross-link hint */ +.logging-hint { color: var(--text-muted); font-size: 13px; } +.logging-hint a { color: var(--accent); } + +/* Logout form inline layout */ +.logout-form { display: inline; } diff --git a/settings-site/public/index.html b/settings-site/public/index.html index 3a279ef..39d0e71 100644 --- a/settings-site/public/index.html +++ b/settings-site/public/index.html @@ -36,7 +36,7 @@ Checking...
-
+
@@ -159,23 +159,6 @@

Notifications

Threshold milestones and trigger conditions by alert category

- -
@@ -294,7 +277,7 @@

Logging

Log channel configuration (channels set in Channels section)

-

Log channels are configured in the Channels section. This section shows which logs are active based on configured channels.

+

Log channels are configured in the Channels section. This section shows which logs are active based on configured channels.

@@ -359,7 +342,6 @@
-
@@ -378,10 +360,9 @@
0 unsaved changes
- - - - + + +
@@ -391,8 +372,8 @@

Schedule restart

diff --git a/settings-site/public/js/app.js b/settings-site/public/js/app.js index cad3c11..25656f9 100644 --- a/settings-site/public/js/app.js +++ b/settings-site/public/js/app.js @@ -1,6 +1,19 @@ let savedConfig = {}; let pendingChanges = {}; let notificationThresholdsState = {}; +let csrfToken = ''; + +async function fetchCsrfToken() { + const res = await fetch('/api/csrf-token', { credentials: 'same-origin' }); + if (!res.ok) throw new Error('Failed to fetch CSRF token'); + const data = await res.json(); + csrfToken = data.csrfToken; + return csrfToken; +} + +function csrfHeaders(base = {}) { + return { ...base, 'x-csrf-token': csrfToken }; +} const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d']; const NOTIFICATION_TAB_KEYS = { @@ -80,8 +93,9 @@ const NOTIFICATION_ALERT_DESCRIPTIONS = { async function init() { document.getElementById('loading').classList.remove('hidden'); try { + await fetchCsrfToken(); const [config] = await Promise.all([ - fetch('/api/config').then(r => r.json()), + fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()), DiscordFields.fetchGuildData() ]); savedConfig = config; @@ -177,10 +191,16 @@ function updateSaveBar() { } async function saveConfig(mode) { + const buttons = document.querySelectorAll('#save-bar button'); + buttons.forEach(b => b.disabled = true); try { + if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) { + return; + } const res = await fetch('/api/config', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + headers: csrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(pendingChanges) }); const data = await res.json(); @@ -191,19 +211,25 @@ async function saveConfig(mode) { document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed')); showToast(`${data.applied.length} settings saved.`, 'success'); } - if (data.errors && data.errors.length > 0) { + const hasErrors = data.errors && data.errors.length > 0; + if (hasErrors) { showToast(`Errors: ${data.errors.join(', ')}`, 'error'); } - if (mode === 'restart') { + if (mode === 'restart' && !hasErrors) { await fetch('/api/restart', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + headers: csrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ mode: 'immediate' }) }); showToast('Restart initiated.', 'warning'); + } else if (mode === 'restart' && hasErrors) { + showToast('Restart cancelled due to save errors.', 'warning'); } } catch (e) { showToast('Failed to save. Bot may be unreachable.', 'error'); + } finally { + buttons.forEach(b => b.disabled = false); } } @@ -221,13 +247,36 @@ async function confirmScheduledRestart() { if (!dt) return; await fetch('/api/restart', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + headers: csrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() }) }); document.getElementById('schedule-modal').classList.add('hidden'); showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning'); } +async function doLogout() { + try { + await fetch('/logout', { + method: 'POST', + credentials: 'same-origin', + headers: csrfHeaders() + }); + } catch (e) { /* ignore */ } + window.location.href = '/login'; +} + +function setupActionButtons() { + document.getElementById('save-btn')?.addEventListener('click', () => saveConfig('save')); + document.getElementById('save-restart-btn')?.addEventListener('click', () => saveConfig('restart')); + document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal); + document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart); + document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => { + document.getElementById('schedule-modal').classList.add('hidden'); + }); + document.getElementById('logout-btn')?.addEventListener('click', doLogout); +} + function showToast(message, type = 'success') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; @@ -470,6 +519,7 @@ function setupSidebarRouting() { document.addEventListener('DOMContentLoaded', async () => { setupSidebarRouting(); + setupActionButtons(); await init(); navigate(location.pathname, false); }); diff --git a/settings-site/public/js/login.js b/settings-site/public/js/login.js new file mode 100644 index 0000000..db4b46f --- /dev/null +++ b/settings-site/public/js/login.js @@ -0,0 +1,36 @@ +async function fetchCsrfToken() { + const res = await fetch('/api/csrf-token', { credentials: 'same-origin' }); + if (!res.ok) throw new Error('Failed to fetch CSRF token'); + const data = await res.json(); + return data.csrfToken; +} + +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const password = document.getElementById('password').value; + const errorEl = document.getElementById('error'); + errorEl.classList.remove('visible'); + + try { + const csrfToken = await fetchCsrfToken(); + const res = await fetch('/login', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken + }, + body: JSON.stringify({ password }) + }); + if (res.ok) { + window.location.href = '/'; + } else { + const data = await res.json().catch(() => ({})); + errorEl.textContent = data.error || 'Invalid password'; + errorEl.classList.add('visible'); + } + } catch (err) { + errorEl.textContent = 'Login failed. Please try again.'; + errorEl.classList.add('visible'); + } +}); diff --git a/settings-site/public/login.html b/settings-site/public/login.html index b73e5ba..9e3c24c 100644 --- a/settings-site/public/login.html +++ b/settings-site/public/login.html @@ -6,18 +6,7 @@ Broccolini Settings - Login - + - + diff --git a/settings-site/server.js b/settings-site/server.js index 1294410..1bfb8aa 100644 --- a/settings-site/server.js +++ b/settings-site/server.js @@ -1,6 +1,10 @@ require('dotenv').config({ path: process.env.ENV_FILE || '../.env' }); const express = require('express'); const session = require('express-session'); +const cookieParser = require('cookie-parser'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const { doubleCsrf } = require('csrf-csrf'); const path = require('path'); const fetch = require('node-fetch'); @@ -9,29 +13,96 @@ const PORT = parseInt(process.env.SETTINGS_PORT) || 12752; const INTERNAL_URL = process.env.INTERNAL_API_URL || `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`; const SECRET = process.env.INTERNAL_API_SECRET; const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD; +const SESSION_SECRET = process.env.SESSION_SECRET; +const IS_PROD = process.env.NODE_ENV === 'production'; -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(express.static(path.join(__dirname, 'public'), { index: false })); -app.use(session({ - secret: SECRET || 'fallback-secret-change-me', - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - secure: true, - sameSite: 'lax', - maxAge: 8 * 60 * 60 * 1000 // 8 hours +if (!SESSION_SECRET) { + console.error('[settings] FATAL: SESSION_SECRET env var is required (min 32 random bytes)'); + process.exit(1); +} +if (!SECRET) { + console.error('[settings] FATAL: INTERNAL_API_SECRET env var is required'); + process.exit(1); +} +if (!ADMIN_PASSWORD) { + console.error('[settings] FATAL: SETTINGS_ADMIN_PASSWORD env var is required'); + process.exit(1); +} + +// Single-hop reverse proxy (Caddy at /opt/caddy/Caddyfile on the rustdesk +// droplet — not accessible from this box; assumed to set X-Forwarded-Proto +// and X-Forwarded-For). Required so express-session marks the connection +// as secure and rate limits key off the real client IP. +app.set('trust proxy', 1); + +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", 'https://fonts.googleapis.com'], + fontSrc: ["'self'", 'https://fonts.gstatic.com'], + imgSrc: ["'self'", 'data:', 'https:'], + scriptSrc: ["'self'"], + connectSrc: ["'self'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + objectSrc: ["'none'"] + } } })); -// Auth middleware +app.use(express.json({ limit: '64kb' })); +app.use(express.urlencoded({ extended: true, limit: '64kb' })); + +app.use(session({ + secret: SESSION_SECRET, + resave: false, + saveUninitialized: true, + cookie: { + httpOnly: true, + secure: IS_PROD, + sameSite: 'strict', + maxAge: 8 * 60 * 60 * 1000 + } +})); + +app.use(cookieParser(SESSION_SECRET)); + +const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({ + getSecret: () => SESSION_SECRET, + getSessionIdentifier: (req) => req.sessionID || '', + cookieName: IS_PROD ? '__Host-x-csrf-token' : 'x-csrf-token', + cookieOptions: { + sameSite: 'strict', + secure: IS_PROD, + httpOnly: true, + path: '/' + }, + getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'] +}); + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many login attempts, please try again later.' } +}); + +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later.' } +}); + function requireAuth(req, res, next) { if (req.session?.authed) return next(); res.redirect('/login'); } -// Internal API proxy helper async function callBot(method, apiPath, body) { const res = await fetch(`${INTERNAL_URL}${apiPath}`, { method, @@ -44,14 +115,21 @@ async function callBot(method, apiPath, body) { return res.json(); } -// Routes +app.use(express.static(path.join(__dirname, 'public'), { index: false })); + +app.get('/api/csrf-token', (req, res) => { + const csrfToken = generateCsrfToken(req, res); + res.json({ csrfToken }); +}); + +app.use(doubleCsrfProtection); + app.get('/login', (req, res) => { if (req.session?.authed) return res.redirect('/'); res.sendFile(path.join(__dirname, 'public', 'login.html')); }); -app.post('/login', (req, res) => { - if (!ADMIN_PASSWORD) return res.status(503).json({ error: 'SETTINGS_ADMIN_PASSWORD not set' }); +app.post('/login', loginLimiter, (req, res) => { if (req.body.password === ADMIN_PASSWORD) { req.session.authed = true; return res.json({ ok: true }); @@ -60,36 +138,34 @@ app.post('/login', (req, res) => { }); app.post('/logout', (req, res) => { - req.session.destroy(); - res.redirect('/login'); + req.session.destroy(() => res.json({ ok: true })); }); app.get('/', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -// Proxy to bot internal API -app.get('/api/config', requireAuth, async (req, res) => { +app.get('/api/config', apiLimiter, requireAuth, async (req, res) => { try { res.json(await callBot('GET', '/config')); } catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } }); -app.post('/api/config', requireAuth, async (req, res) => { +app.post('/api/config', apiLimiter, requireAuth, async (req, res) => { try { res.json(await callBot('POST', '/config', req.body)); } catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } }); -app.get('/api/discord/guild', requireAuth, async (req, res) => { +app.get('/api/discord/guild', apiLimiter, requireAuth, async (req, res) => { try { res.json(await callBot('GET', '/discord/guild')); } catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } }); -app.post('/api/restart', requireAuth, async (req, res) => { +app.post('/api/restart', apiLimiter, requireAuth, async (req, res) => { try { res.json(await callBot('POST', '/restart', req.body)); } catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } }); -app.get('/api/restart/status', requireAuth, async (req, res) => { +app.get('/api/restart/status', apiLimiter, requireAuth, async (req, res) => { try { res.json(await callBot('GET', '/restart/status')); } catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } }); @@ -98,6 +174,13 @@ app.get('*', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); +app.use((err, req, res, next) => { + if (err && (err.code === 'EBADCSRFTOKEN' || err.code === 'ERR_BAD_CSRF_TOKEN')) { + return res.status(403).json({ error: 'Invalid CSRF token' }); + } + next(err); +}); + app.listen(PORT, '0.0.0.0', () => { console.log(`[settings] running on port ${PORT}`); });