security hardening

This commit is contained in:
2026-04-18 11:10:41 +00:00
parent a409203025
commit 21618efbad
36 changed files with 1455 additions and 283 deletions

View File

@@ -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 *)"
]
}
}

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
node_modules
.env
.env.*
docs
scripts
*.md
.claude
.opencode

121
CLAUDE.md Normal file
View File

@@ -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 520 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 `"<name> (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.

View File

@@ -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,

512
broccolini_bot_context.md Normal file
View File

@@ -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 111276); game list, tags, staff emoji map
- `db-connection.js` — Mongo connect + require `models.js`; retry helper, shutdown hook
- `models.js`**all 13 Mongoose schemas in one file**
- `gmail-poll.js` — Gmail inbox poll → new ticket creation / follow-up routing
- `get-refresh-token.js` — one-shot OAuth helper (redirect `http://localhost:3000/oauth2callback`)
- `utils.js` — email/game helpers, response template variables
- `package.json` / `Dockerfile` / `docker-compose.yml` — deploy
- `.env.example` / `.env.test.example` — env reference
### `handlers/`
- `buttons.js` — button + modal interactions: claim/unclaim, close confirm, escalate T2/T3, de-escalate, priority, tag, ticket-creation modal
- `commands.js` — slash command router: `/escalate`, `/deescalate`, `/add`, `/remove`, `/transfer`, `/move`, `/claim`, `/unclaim`, `/close`, `/priority`, `/tags`, `/email-routing`, `/setup`, `/help`, `/stats`, `/history`, `/search`, `/notification`, `/staffthread`, `/pinmessages`, `/panel`, `/backup`, `/export`, `/accountinfo`, `/gmailpoll`
- `messages.js``messageCreate`: staff reply → Gmail relay; notify claimer on customer reply; DM alert toggle
- `setup.js` — multi-step `/setup` wizard (modals + select menus)
- `accountinfo.js``/accountinfo` lookup + "send to channel" context menu
- `analytics.js` — in-memory counters: interactions, errors, uptime
- `pendingCloses.js` — shared `Map<channelId, timeout>` for force-close timer
### `services/`
- `channelQueue.js``enqueueRename` (p-queue-style, serialized per channel, respects Discord's 2-rename/10-min cap) and `enqueueMove` (direct `setParent`)
- `tickets.js` — counters, naming, rate limits, auto-close, auto-unclaim, `reconcileDeletedTicketChannels`
- `gmail.js``getGmailClient`, `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail`
- `debugLog.js` — fire-and-forget logging to dedicated Discord channels (`logError`, `logWarn`, `logTicketEvent`, `logGmail`, `logAutomation`, `logSecurity`, `logIntegrity`, `logSystem`)
- `staffNotifications.js``notifyStaffOfReply` (per-ticket cooldown), `notifyAllStaffUnclaimed` (30-min digest)
- `staffSettings.js``StaffSettings.notifyDm` get/set
- `staffSignature.js` — per-staff valediction/display name/tagline blocks
- `staffPresence.js` — presence + message-activity tracking for "no staff available" surge alerts
- `staffThread.js` — optional per-ticket private staff thread + auto-add members of `STAFF_THREAD_ROLE_ID`
- `staffChannel.js`**deprecated.** Legacy per-staffer mirror channels. `STAFF_CATEGORIES` is empty in current `config.js`; `createStaffChannel` is not called from the claim flow.
- `pinMessage.js` — pin helper with optional system-message suppression
- `patternStore.js` — in-memory counter store with scheduled daily/weekly/monthly resets, escalating-cooldown helper
- `patternChecker.js` — periodic pattern detection (user/game/tag/escalation/staff)
- `surgeChecker.js` — volume, game, stale, needs-response, unclaimed, T3-unclaimed, no-staff surge alerts
- `chatAlertChecker.js` — monitor configured chat channels for unresponded messages
- `configPersistence.js` — save/load runtime config to Mongo
- `guildSettings.js` — per-guild `emailRouting` (`thread` | `category`)
### `commands/`
- `register.js` — slash + context menu registration via discord.js REST v10
### `routes/`
- `bosscord.js``/api/tickets*` for bOSScord (Bearer `BOSSCORD_API_KEY`, CORS, DB-ready gate)
- `internalApi.js``/internal/*` for the settings site (`X-Internal-Secret`)
### `api/`
- `bosscordClient.js` — singleton holder for the Discord client (set at startup, read by routes)
### `settings-site/`
- Separate Express app. `server.js` talks to the bot's internal API over `INTERNAL_API_PORT` using `INTERNAL_API_SECRET`. Password-protected dashboard (`SETTINGS_ADMIN_PASSWORD`).
### `scripts/`
- `test-mongodb.js` — connectivity smoke test (`npm run test-mongodb`)
### `docs/`
- `README.md`, `CRITICAL_FILES_AND_HOW_IT_WORKS.md`, `setup/*`, `features/*`, `api/*`, `architecture/*`
## Discord event handler map
| Event | Wired in | Dispatch |
|-------|----------|----------|
| `ready` | `broccolini-discord.js` (single-fire) | DB connect → `registerCommands()` → mount bOSScord API → start 8 background intervals → start internal API server |
| `interactionCreate` | `broccolini-discord.js` | Routes by type: `isButton` / `isModalSubmit``handlers/buttons.js` and `handlers/setup.js`; `isChatInputCommand``handlers/commands.js`; `isContextMenuCommand``handlers/accountinfo.js`; `isAutocomplete` → tags/responses |
| `messageCreate` | `broccolini-discord.js` | `staffPresence.updateStaffLastSeen``chatAlertChecker.handleChatMessage``handlers/messages.handleDiscordReply` |
| `unhandledRejection` | `broccolini-discord.js` | `logError('unhandledRejection', …).catch(() => {})` |
| `SIGTERM`/`SIGINT` | `broccolini-discord.js` | `handleShutdown()` — log + exit |
### Background intervals (all started in `ready`)
| Job | Interval | Source | Config gate |
|-----|----------|--------|-------------|
| Gmail poll | `GMAIL_POLL_INTERVAL_MS` (~30s) | `gmail-poll.js:poll` | always on |
| Auto-close | 60 min | `services/tickets.checkAutoClose` | `AUTO_CLOSE_ENABLED` |
| Unclaimed digest | 30 min | `services/staffNotifications.notifyAllStaffUnclaimed` | `UNCLAIMED_REMINDER_THRESHOLDS` |
| Auto-unclaim | 60 min | `services/tickets.checkAutoUnclaim` | `AUTO_UNCLAIM_*` |
| Pattern checks | `PATTERN_CHECK_INTERVAL_MINUTES` | `services/patternChecker.runPatternChecks` | pattern channel envs |
| Surge checks | 5 min (+30s initial delay) | `services/surgeChecker.runSurgeChecks` | `ALL_STAFF_CHANNEL_ID` |
| Chat monitoring | 5 min | `services/chatAlertChecker.runChatAlertChecks` | `CHAT_ALERT_CHANNEL_IDS` |
| Orphan reconciliation | 60 min | `services/tickets.reconcileDeletedTicketChannels` | always on |
### Button / modal custom IDs
`open_ticket`, `open_ticket_thread`, `open_ticket_channel`, `email_routing_thread`, `email_routing_category`, `claim_ticket`, `close_ticket`, `confirm_close`, `cancel_close`, `escalate_ticket`, `escalate_to_tier2`, `escalate_to_tier3`, `deescalate_ticket`, `priority_*`, `open_panel`, `ticket_modal`, `ticket_modal_thread`, `ticket_modal_channel`, `setup_*` (wizard), `send_account_info_*`.
## Ticket lifecycle
Two sources, one `Ticket` document:
- **Email-sourced** — real Gmail `threadId` in `gmailThreadId`. Staff replies relay to Gmail via `handlers/messages.js``sendGmailReply`.
- **Discord-sourced** — `gmailThreadId` prefixed `discord-` / `discord-msg-`. No Gmail relay; conversation stays in Discord.
State machine:
```
(poll or /panel modal)
┌─────────────────┐
│ created │ — Ticket doc inserted; Discord channel (or thread) created under
│ (status: open, │ TICKET_CATEGORY_ID / DISCORD_TICKET_CATEGORY_ID (+overflow if full);
│ claimedBy: ∅) │ welcome embed + action row posted; role ping; optional pin; optional
└────────┬────────┘ staff thread; optional staff notification alerts
[Claim button] ───▶ claimedBy set; channel renamed via channelQueue (STAFF_EMOJIS prefix)
│ │
│ [Unclaim / auto-unclaim / claim-timeout] ──▶ back to unclaimed
[/escalate or Escalate button → T2 / T3]
│ Non-thread: enqueueMove → *_ESCALATED2/3_CHANNEL_ID category
│ Thread: skips category move (threads can't reparent)
│ Action: "unclaim" clears claim + resets unclaimedReminderssent; "keep" preserves
┌─────────────────┐
│ escalated │ escalationTier ∈ {2, 3}
└────────┬────────┘
[/deescalate] ──▶ step down one tier
[Close button → confirm_close → FORCE_CLOSE_TIMER grace]
┌─────────────────┐
│ closed │ transcript posted to TRANSCRIPT_CHANNEL_ID; closure email sent
│ (status: closed│ for email tickets; channel deleted (5s delay); Transcript doc written
└─────────────────┘
```
Orphan path: `reconcileDeletedTicketChannels` (60 min) finds open tickets whose Discord channel no longer exists and marks them closed.
## MongoDB collections (models.js)
All schemas live in a single file. Only indexes explicitly declared are listed; implicit `_id` and `unique: true` (which creates an index) are marked ✓.
| Collection | Key fields | Indexes | Notes |
|------------|------------|---------|-------|
| **Host** | `hostname`, `ip`, `region`, `status`, `memFree`, `cpuUsage`, `diskFree`, `lastSeen`, `lostInUse[]`, `statsHistory[]` | **none** | `lastSeen: { default: Date.now() }` — frozen at schema-definition time, bug (see P3) |
| **User** | `email`, `discordID`, `customerId`, `passwordHash`, `sessionToken`, `servers[]`, `subusers[]`, `activities[]` | **none** | 700+ lines, shared website schema. `email` / `discordID` queried in `handlers/accountinfo.js:47-54` without index |
| **DashboardMetrics** | `timestamp` (TTL 1yr), `activeUsers`, `workerId` | TTL ✓ | |
| **ErrorLog** | `timestamp` (TTL 30d), `statusCode`, `message`, `stack`, `url`, `method`, `userId`, `userEmail`, `authenticated`, `sessionValid` | TTL ✓ | |
| **Ticket** | `gmailThreadId` ✓ unique, `discordThreadId`, `senderEmail`, `subject`, `status` (`open`/`closed`), `priority`, `claimedBy` (display), `claimerId`, `ticketNumber`, `createdAt`, `lastActivity`, `escalated`, `escalationTier`, `welcomeMessageId`, `ticketTag`, `unclaimedReminderssent[]` *(typo preserved — see below)* | `gmailThreadId` unique ✓ | **`discordThreadId`, `claimedBy`, `status`, `ticketNumber`, `senderEmail` are all hot query fields with no index.** `unclaimedReminderssent` typo is load-bearing — preserved across `models.js:819`, `services/staffNotifications.js:85,111`, `handlers/commands.js:77` |
| **TicketCounter** | `senderLocal` ✓ unique, `counter` | ✓ | |
| **Transcript** | `gmailThreadId`, `transcriptMessageId`, `createdAt` | **none** | `gmailThreadId` queried in `gmail-poll.js:267` without index |
| **Tag** | `name` ✓ unique, `content`, `createdBy`, `useCount` | ✓ | Saved response templates |
| **CloseRequest** | `ticketId` ✓ unique, `requestedBy`, `reason` | ✓ | |
| **GuildSettings** | `guildId` ✓ unique, `emailRouting` (`thread`/`category`) | ✓ | |
| **StaffSettings** | `userId` ✓ unique, `guildId`, `notifyDm` | ✓ | |
| **StaffNotification** | `userId` ✓ unique, `guildId`, `channelId`, `cooldownHours` | ✓ | Per-staffer reply-alert channel |
| **StaffSignature** | `userId` ✓ unique, `guildId`, `valediction`, `displayName`, `tagline` | ✓ | |
## Express API route table
### `routes/bosscord.js` — mounted at `/api` after `ready`, only if `BOSSCORD_API_KEY` is set
| Method | Path | Auth | Input | Response |
|--------|------|------|-------|----------|
| GET | `/api/tickets` | Bearer | query: `status`, `priority`, `claimedBy`, `limit` (≤100) | `{ tickets: [...] }` |
| GET | `/api/me/tickets` | Bearer | header `X-Staff-Discord-Id` or query `claimedBy` | `{ tickets: [...] }` |
| GET | `/api/tickets/:id` | Bearer | path: ObjectId / ticketNumber / gmailThreadId | **raw ticket object** (inconsistent) |
| GET | `/api/tickets/:id/messages` | Bearer | query: `limit` (≤100) | `{ messages: [...] }` |
| POST | `/api/tickets/:id/messages` | Bearer | `{ content: string, displayName?: string }` | `{ ok: true }` (201) |
Middleware (applied once via `router.use`): `corsMiddleware` (`BOSSCORD_CORS_ORIGIN`, defaults to `*`) → `authMiddleware` (Bearer) → `requireDb`.
### `routes/internalApi.js` — `/internal/*` on a separate port (`INTERNAL_API_PORT`)
| Method | Path | Auth | Input | Response |
|--------|------|------|-------|----------|
| GET | `/internal/config` | `X-Internal-Secret` | — | `{ key: value, ... }` (redacted) |
| POST | `/internal/config` | `X-Internal-Secret` | `{ [key]: value }` | `{ applied: [...], errors: [...] }` |
| GET | `/internal/discord/guild` | `X-Internal-Secret` | — | `{ channels, roles, members, categories }` |
| POST | `/internal/restart` | `X-Internal-Secret` | `{ mode, scheduledFor? }`, modes: `immediate` / `scheduled` / `cancel_scheduled` / `pending` | `{ ok: true, mode, ... }` |
| GET | `/internal/restart/status` | `X-Internal-Secret` | — | `{ scheduledRestart: boolean }` |
## Environment variables
### Vars read in `config.js` but missing from `.env.example`
- `DISCORD_BOT_TOKEN` (alias for `DISCORD_TOKEN`)
- `HEALTHCHECK_HOST`
- `NOTIFICATION_THRESHOLDS_JSON`
- `ROLE_TO_PING_ID` (alias for `ROLE_ID_TO_PING`)
- `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS`
- `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS`
- `NODE_ENV`, `ENV_FILE` (implicit)
### Vars in `.env.example` but not read via `config.js`
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` — read directly by `services/gmail.js` and `broccolini-discord.js`, not via `CONFIG`
- `MONGODB_URI` — read directly by `broccolini-discord.js:99` and `scripts/test-mongodb.js`, not via `CONFIG`
- `NGROK_URL` — unused
- `DISCORD_ESCALATED_CATEGORY_ID`, `EMAIL_ESCALATED_CATEGORY_ID` — legacy names, superseded by `*_ESCALATED2/3_CHANNEL_ID`
### Key env categories (see `.env.example` for the full list)
| Category | Vars |
|----------|------|
| Discord core | `DISCORD_TOKEN`, `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID`, `TICKET_CATEGORY_ID`, `DISCORD_TICKET_CATEGORY_ID`, `*_OVERFLOW_CATEGORY_IDS`, `ROLE_ID_TO_PING`, `TRANSCRIPT_CHANNEL_ID`, `LOGGING_CHANNEL_ID`, `DEBUGGING_CHANNEL_ID` |
| Escalation | `EMAIL_ESCALATED2/3_CHANNEL_ID`, `DISCORD_ESCALATED2/3_CHANNEL_ID` |
| Staff notifications | `STAFF_NOTIFICATION_CATEGORY_ID`, `STAFF_EMOJIS`, `CLAIMER_EMOJI_FALLBACK`, `ADMIN_ID`, `UNCLAIMED_REMINDER_THRESHOLDS` |
| Gmail | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `REFRESH_TOKEN`, `MY_EMAIL`, `GMAIL_POLL_INTERVAL_MS` |
| MongoDB | `MONGODB_URI` |
| HTTP | `DISCORD_ONLY_PORT`/`PORT`, `HEALTHCHECK_HOST`, `BOSSCORD_API_KEY`, `BOSSCORD_CORS_ORIGIN`, `INTERNAL_API_PORT`, `INTERNAL_API_SECRET` |
| Automation | `AUTO_CLOSE_*`, `REMINDER_*`, `AUTO_UNCLAIM_*`, `CLAIM_TIMEOUT_*`, `FORCE_CLOSE_TIMER` |
| Rate limits | `GLOBAL_TICKET_LIMIT`, `TICKET_LIMIT_PER_CATEGORY`, `RATE_LIMIT_*` |
| Patterns | `PATTERN_*_THRESHOLD`, `*_PATTERNS_CHANNEL_ID` |
| Surge | `SURGE_*`, `ALL_STAFF_CHANNEL_ID`, `SURGE_ROLE_ID`, `STAFF_IDS` |
| Chat alerts | `CHAT_ALERT_CHANNEL_IDS`, `ALL_STAFF_CHAT_ALERT_CHANNEL_ID`, `CHAT_ALERT_*` |
| Branding | `SUPPORT_NAME`, `LOGO_URL`, `SIGNATURE`, `TICKET_WELCOME_MESSAGE`, `TICKET_CLAIMED_MESSAGE`, `ESCALATION_MESSAGE`, embed colors |
## Key patterns
### Channel queue
`services/channelQueue.js` serializes **renames** (`enqueueRename`) and **moves** (`enqueueMove`). Discord caps renames at 2 per 10 min per channel; the queue emits a relative-time message in the channel when blocked. **Rule:** any code that changes a channel's name or parent must use these helpers. `handlers/commands.js:540` (`/move`) currently bypasses this with a direct `setParent` — see P1 prompt.
### Logging
`services/debugLog.js` is fire-and-forget: every log helper returns a promise and callers attach `.catch(() => {})`. Rule: never `await` logging on a hot path. Channels are selected by the `*_LOG_CHANNEL_ID` env vars (`GMAIL_LOG_CHANNEL_ID`, `AUTOMATION_LOG_CHANNEL_ID`, `RENAME_LOG_CHANNEL_ID`, `SECURITY_LOG_CHANNEL_ID`, `SYSTEM_LOG_CHANNEL_ID`, `DEBUGGING_CHANNEL_ID`).
### Staff detection
Staff = members with `ROLE_ID_TO_PING` or any role in `ADDITIONAL_STAFF_ROLES`. `ADMIN_ID` is a single-user gate for `/staffnotification`. `STAFF_IDS` drives surge "no staff available" calculations with `STAFF_DND_COUNTS_AS_AVAILABLE` as a tiebreaker.
### Claim identity
`Ticket.claimedBy` is a display label (string), `Ticket.claimerId` is the Discord user ID. Channel-name emoji comes from `STAFF_EMOJIS` (`userId:emoji,...`) with `CLAIMER_EMOJI_FALLBACK`.
### Pattern/counter store
`services/patternStore.js` holds in-memory counters keyed by namespace + window (`today`/`week`/`month`) with auto-reset timers from `scheduleResets()`. Not persisted — resets on process restart.
### Deprecated
`services/staffChannel.js` and the `STAFF_CATEGORIES` map are legacy. `STAFF_CATEGORIES` is empty in current `config.js`, `createStaffChannel` is not called from the claim flow, and `Ticket.staffChannelId` is effectively unused. Reply alerts instead flow through `StaffNotification` channels (`/notification add`).
## Known issues (root causes documented; NO fix prompts)
1. **Gmail `invalid_grant`**`gmail-poll.js:351-372`. Polling catches auth errors (`invalid_grant` / `unauthorized` / `Invalid Credentials` / HTTP 401), logs via `logError('Gmail OAuth', …)`, DMs `ADMIN_ID` **once** (`authErrorNotified` flag), and silently no-ops subsequent polls. By design — requires manual `REFRESH_TOKEN` refresh via `node get-refresh-token.js`. The surrounding bot and bOSScord API continue to function.
2. **`STAFF_EMOJIS` encoding** — `config.js` parses `userId:emoji` pairs from env; some custom emojis render as mojibake in channel names. Root cause not yet identified; likely interaction between `.env` file encoding (UTF-8 vs BOM), `dotenv-expand` handling, and Discord's custom emoji syntax (`<:name:id>`) vs Unicode codepoints. Needs a targeted trace through `config.js` parsing.
3. **Escalation button**`handlers/buttons.js` handlers for `escalate_to_tier2` / `escalate_to_tier3`. Reports of the handler "not firing reliably." Root cause not yet identified. Candidate areas: interaction deferral timing (3 s rule), missing `return` between button branches in the dispatcher, or `enqueueMove` back-pressure when the target category is full and the handler errors before replying.
---
# Improvement prompts
Each prompt follows CLAUDE.md's format. Prompts intended for OpenCode to execute. None of the known issues above appear here.
---
## P0 — Fix undefined vars in ticket-closure email body
**Priority:** P0 (broken)
**Files:** `/opt/broccolini-bot/services/gmail.js` (lines 108129), `/opt/broccolini-bot/config.js` (to confirm `TICKET_CLOSE_MESSAGE` / signature vars)
**Problem:** `sendTicketClosedEmail` references `safeCloseMessage` and `safeCloseSignature` on lines 115116 of the HTML body, but neither variable is defined anywhere in the function. Every closure email sent for an email-sourced ticket currently contains literal `undefined` text in both the message paragraph and the signature line, which customers see. This has been broken for an unknown period because nothing tests closure email rendering.
**Fix:**
1. Read the full `sendTicketClosedEmail` function (surrounding ~50 lines) to confirm the escape pattern used by `safeReply` / `safeLogoUrl` / `safeSignature`.
2. Immediately after line 110 (where `safeSignature` is computed), add:
```js
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || CONFIG.DISCORD_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
const safeCloseSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
```
— adjust the CONFIG key to whichever close-message var actually exists (`CONFIG.TICKET_CLOSE_MESSAGE` is the most likely name; fall back to the existing `DISCORD_CLOSE_MESSAGE` if not present). Do not invent a new env var.
3. Do not modify the HTML template structure.
**Verify:**
- Trigger a close on a throwaway **email-sourced** ticket in the test environment.
- Inspect the resulting Gmail message (the customer-bound send) and confirm the `<p>` that previously said `undefined` now contains the configured close message, and the signature block below it renders correctly.
- If no test env exists for Gmail, at minimum console-log `htmlBody` once and grep for `undefined`.
---
## P1 — Route `/move` through `enqueueMove` instead of direct `setParent`
**Priority:** P1 (channel queue bypass — CLAUDE.md Hard Rule #3)
**Files:** `/opt/broccolini-bot/handlers/commands.js` (around line 540), `/opt/broccolini-bot/services/channelQueue.js`
**Problem:** The `/move` slash handler calls `await interaction.channel.setParent(category.id, { lockPermissions: true })` directly. Every other category move in the codebase flows through `services/channelQueue.js`'s `enqueueMove`, which serializes moves and logs via the rename channel. Direct `setParent` skips that serialization and, more importantly, skips the rate-limit / error handling the queue provides.
**Fix:**
1. At the top of `handlers/commands.js`, confirm `enqueueMove` is imported from `../services/channelQueue`. Add the import if missing.
2. Replace line 540 with `await enqueueMove(interaction.channel, category.id);`
3. Confirm `enqueueMove` preserves `lockPermissions: true` behavior (read `services/channelQueue.js:~95`). If it does not, add a `lockPermissions` option to `enqueueMove` (defaulting to `true` to match existing callers), rather than reverting `/move` to a direct call.
4. Leave the surrounding `interaction.reply` / log-channel send untouched.
**Verify:**
- Run `/move` in a test ticket channel targeting another category. Confirm it moves.
- Run two `/move` commands back-to-back from different ticket channels. Confirm both complete without rate-limit errors and both appear in `RENAME_LOG_CHANNEL_ID` (if the queue logs moves there).
---
## P1 — Validate and bound `content` on `POST /api/tickets/:id/messages`
**Priority:** P1 (input validation / security boundary)
**Files:** `/opt/broccolini-bot/routes/bosscord.js` (lines 159223)
**Problem:** The endpoint accepts an arbitrary `content` string with only a type check (`typeof content !== 'string'`). There is no length cap, no whitespace check, and `req.body.displayName` is piped into `sendGmailReply` as `discordUser` without validation. A client bug or malicious caller can post a 10 MB string to Discord (which will error partway through but only after a `channel.send` attempt) or inject arbitrary display names into outbound email. Discord's own cap is 2000 chars per message.
**Fix:**
1. After the existing `content` type check (line 169), add:
```js
const trimmed = content.trim();
if (!trimmed) return res.status(400).json({ error: 'content is empty' });
if (trimmed.length > 2000) return res.status(400).json({ error: 'content exceeds 2000 characters' });
```
Use `trimmed` for the rest of the handler.
2. Validate `displayName`: coerce to string, trim, cap at 80 chars, and replace anything outside `[\w \-.']` with empty string. If the result is empty, fall back to `'bOSScord'`. Do not echo unvalidated user input into the outbound email header.
3. Do not change the response shape.
**Verify:**
- `curl` the endpoint with a 3000-char body and confirm a 400 response.
- `curl` with `{"content":"hi","displayName":"<script>alert(1)</script>"}` and confirm the email (if sent) shows a sanitized display name.
- `curl` with a normal `{"content":"test"}` and confirm the existing happy path still returns `{ok: true}` and delivers to Discord.
---
## P1 — Add hot-path indexes to `Ticket`
**Priority:** P1 (data layer / performance and correctness under load)
**Files:** `/opt/broccolini-bot/models.js` (the `ticketSchema` block ~lines 795821)
**Problem:** Only `gmailThreadId` is indexed on `Ticket`. The live query hotspots are `discordThreadId` (every `messageCreate` does a `findOne` on it — see `handlers/messages.js`), `claimedBy` + `status` (the bOSScord `/api/me/tickets` filter), `status` alone (unclaimed-reminder job scans it every 30 min), and `senderEmail` + `ticketNumber` (search commands). As the collection grows, these turn into full-collection scans on every Discord message.
**Fix:** Inside the `ticketSchema` definition (not inline on the field — use `ticketSchema.index(...)` calls at the end of the schema block so it's obvious what the indexes are):
```js
ticketSchema.index({ discordThreadId: 1 }, { unique: true, sparse: true });
ticketSchema.index({ status: 1, claimedBy: 1 });
ticketSchema.index({ status: 1, lastActivity: -1 });
ticketSchema.index({ senderEmail: 1, createdAt: -1 });
ticketSchema.index({ ticketNumber: 1 });
```
`discordThreadId` should be `unique, sparse` because Discord-only tickets set it immediately, email tickets may briefly lack it during creation, and no two tickets should share a channel. Confirm the sparse-unique behavior doesn't conflict with existing data before enabling (see Verify).
**Verify:**
- Before deploy, run `db.tickets.aggregate([{$group: {_id: "$discordThreadId", c: {$sum: 1}}}, {$match: {c: {$gt: 1}}}])` against `broccoli_db` to confirm no duplicate `discordThreadId` values exist. If any do, investigate (they indicate prior orphaning bugs) before adding the unique index.
- After redeploy, run `db.tickets.getIndexes()` in Atlas and confirm all five new indexes exist.
- Spot-check with `db.tickets.find({discordThreadId: "<some id>"}).explain("executionStats")` — should show `IXSCAN`, not `COLLSCAN`.
---
## P1 — Add index on `Transcript.gmailThreadId`
**Priority:** P1
**Files:** `/opt/broccolini-bot/models.js` (`transcriptSchema`, ~lines 828832)
**Problem:** `gmail-poll.js:267` queries `Transcript.findOne({ gmailThreadId })` on every inbound email that might be a reopen, with no index.
**Fix:** Append `transcriptSchema.index({ gmailThreadId: 1 });` to the schema definition block.
**Verify:** `db.transcripts.getIndexes()` shows the new index; `db.transcripts.find({gmailThreadId: "<id>"}).explain("executionStats")` is `IXSCAN`.
---
## P1 — Validate `/internal/config` POST body against an allowlist
**Priority:** P1 (admin API; wide blast radius)
**Files:** `/opt/broccolini-bot/routes/internalApi.js` (~lines 2939), `/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 87123)
**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 ~595605)
**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 106119), plus bOSScord client code (out of tree)
**Problem:** `/api/tickets` returns `{ tickets: [...] }`, `/api/me/tickets` returns `{ tickets: [...] }`, `/api/tickets/:id/messages` returns `{ messages: [...] }`, but `/api/tickets/:id` returns the raw ticket object. bOSScord has to handle two shapes. CLAUDE.md warns that response-shape changes will break bOSScord.
**Fix:** This is a **coordinated change**. Do not modify `routes/bosscord.js` in isolation. Instead:
1. Open this as a doc-only prompt first: add a note to `docs/api/` (create the file if needed) listing the current shapes and marking the single-ticket endpoint as "wrapped in `{ ticket }` in vNext — bOSScord must be updated in lockstep."
2. Separately, coordinate with the bOSScord repo. Once bOSScord is updated, a follow-up prompt will change line 114 from `res.json(out)` to `res.json({ ticket: out })`.
**Verify (for the doc-only step):** `docs/api/bosscord.md` exists and accurately describes the five endpoints' current and target shapes.
---
## P2 — Audit long-running slash commands for deferReply
**Priority:** P2 (Discord.js best practices)
**Files:** `/opt/broccolini-bot/handlers/commands.js` (read-only audit), `/opt/broccolini-bot/handlers/buttons.js`
**Problem:** Discord requires an interaction response (reply or defer) within 3 seconds. Any command that fetches from Mongo + makes multiple Discord API calls + possibly calls Gmail is at risk. `/escalate` (queue move + channel rename + log send + email?), `/move`, `/transfer`, `/backup`, `/export` are candidates.
**Fix:**
1. **Read-only first:** grep `handlers/commands.js` for each of `/escalate`, `/deescalate`, `/move`, `/transfer`, `/backup`, `/export`, `/search`, `/history`, `/gmailpoll check`, and identify the first user-visible response on each path.
2. For any command where the first `interaction.reply` / `interaction.editReply` happens after two or more awaited calls, add `await interaction.deferReply({ ephemeral: <matching existing ephemerality> });` as the very first action, and convert subsequent `interaction.reply` calls on that path to `interaction.editReply` or `interaction.followUp`.
3. Do not touch commands that already defer.
**Verify:**
- Run `/backup` and `/export` on a server with 100+ tickets. Confirm no `InteractionAlreadyReplied` or `Unknown interaction` errors in console.
- Run `/escalate` and confirm the loading state appears immediately, then resolves.
---
## P2 — Add try/catch around `handleDiscordReply`
**Priority:** P2
**Files:** `/opt/broccolini-bot/broccolini-discord.js` (messageCreate listener, ~lines 159170)
**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).

View File

@@ -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 };

View File

@@ -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.',

View File

@@ -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 <t:${unlockAtUnix}:R>.`
);
}
@@ -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 <t:${unlockAtUnix}:R>.`
);
}
@@ -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}`
);
}

View File

@@ -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 <t:${unlockAtUnix}:R>.`
);
}
@@ -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 <t:${unlockAtUnix}:R>.`
);
}
@@ -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} deescalated 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]

View File

@@ -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}`]

View File

@@ -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 },

19
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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));

View File

@@ -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 };

View File

@@ -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 (_) {}
}
}

View File

@@ -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, '<br>');
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${safeUser} on Discord</p>
<p>${safeReply}</p>
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 12px;">
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
</td>
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
</td>
</tr>
</table>
</div>`;
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, '<br>');
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
<p><strong>Message:</</strong></p>
<p><strong>Message:</strong></p>
<p>${safeCloseMessage}</p>
<p style="margin-top: 16px;">${safeCloseSignature}</p>
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
@@ -202,6 +145,9 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
}
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const serverDisplayName = label;
const safeCloseMessage = safeBody;
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>

View File

@@ -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 (_) {}
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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 } }
);
}
}

View File

@@ -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);

View File

@@ -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 (_) {}
}

View File

@@ -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).`
);

View File

@@ -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

70
settings-site/CLAUDE.md Normal file
View File

@@ -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 `<input type="color">` 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 `<input>` 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 `<input data-key="NEW_KEY">` (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.

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -36,7 +36,7 @@
<span id="bot-status-text">Checking...</span>
</div>
<div class="actions">
<form action="/logout" method="POST" style="display:inline"><button type="submit">Logout</button></form>
<button type="button" id="logout-btn">Logout</button>
</div>
</div>
@@ -159,23 +159,6 @@
<div class="section" id="s-notifications">
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">&#9660;</span></div>
<div class="section-body">
<style>
#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-2); 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-2); }
#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(--surface); 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(--surface); 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; }
</style>
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
@@ -294,7 +277,7 @@
<div class="section" id="s-logging">
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field full-width"><p style="color:var(--text-muted);font-size:13px;">Log channels are configured in the <a href="/channels" style="color:var(--accent);">Channels</a> section. This section shows which logs are active based on configured channels.</p></div>
<div class="field full-width"><p class="logging-hint">Log channels are configured in the <a href="/channels">Channels</a> section. This section shows which logs are active based on configured channels.</p></div>
</div></div>
</div>
@@ -359,7 +342,6 @@
<div class="field"><label>Settings Port</label><input type="number" data-key="SETTINGS_PORT"></div>
<div class="field"><label>Settings Domain</label><input type="text" data-key="SETTINGS_DOMAIN"></div>
<div class="field"><label>Internal API Port</label><input type="number" data-key="INTERNAL_API_PORT"></div>
<div class="field"><label>Internal API Secret</label><input type="password" data-key="INTERNAL_API_SECRET"></div>
<div class="field"><label>Support Name</label><input type="text" data-key="SUPPORT_NAME"></div>
<div class="field"><label>Logo URL</label><input type="text" data-key="LOGO_URL"></div>
<div class="field full-width"><label>Game List (comma-separated)</label><textarea data-key="GAME_LIST" rows="3"></textarea></div>
@@ -378,10 +360,9 @@
<div id="save-bar" class="save-bar">
<span id="change-count">0 unsaved changes</span>
<div class="save-actions">
<button onclick="saveConfig('apply')">Save &amp; Apply</button>
<button onclick="saveConfig('pending')" class="secondary">Save (pending restart)</button>
<button onclick="saveConfig('restart')" class="danger">Save &amp; Restart Now</button>
<button onclick="openScheduleModal()" class="secondary">Schedule restart...</button>
<button type="button" id="save-btn">Save</button>
<button type="button" id="save-restart-btn" class="danger">Save &amp; Restart Now</button>
<button type="button" id="schedule-restart-btn" class="secondary">Schedule restart...</button>
</div>
</div>
@@ -391,8 +372,8 @@
<h3>Schedule restart</h3>
<input type="datetime-local" id="schedule-datetime">
<div class="modal-actions">
<button onclick="confirmScheduledRestart()">Schedule</button>
<button onclick="document.getElementById('schedule-modal').classList.add('hidden')" class="secondary">Cancel</button>
<button type="button" id="schedule-confirm-btn">Schedule</button>
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
</div>
</div>
</div>

View File

@@ -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);
});

View File

@@ -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');
}
});

View File

@@ -6,18 +6,7 @@
<title>Broccolini Settings - Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { 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; }
</style>
<link rel="stylesheet" href="/css/login.css">
</head>
<body>
<div class="login-card">
@@ -29,21 +18,6 @@
<div class="error" id="error">Invalid password</div>
</form>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
window.location.href = '/';
} else {
document.getElementById('error').style.display = 'block';
}
});
</script>
<script src="/js/login.js"></script>
</body>
</html>

View File

@@ -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}`);
});