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