This commit is contained in:
2026-04-20 18:05:36 +00:00
parent d73422555d
commit 33b1f276c6
26 changed files with 598 additions and 183 deletions

View File

@@ -180,6 +180,7 @@ STAFF_THREAD_ROLE_ID= # Role whose members are added to the
PIN_INITIAL_MESSAGE_ENABLED=false # Auto-pin the welcome message on ticket creation PIN_INITIAL_MESSAGE_ENABLED=false # Auto-pin the welcome message on ticket creation
PIN_ESCALATION_MESSAGE_ENABLED=false # Auto-pin escalation messages PIN_ESCALATION_MESSAGE_ENABLED=false # Auto-pin escalation messages
PIN_SUPPRESS_SYSTEM_MESSAGE=false # Delete the "X pinned a message" system message after pinning PIN_SUPPRESS_SYSTEM_MESSAGE=false # Delete the "X pinned a message" system message after pinning
TRANSCRIPT_DM_TO_CREATOR=false # DM the transcript file to the ticket creator on close (Discord-origin tickets only)
# --- Settings site & internal API --- # --- Settings site & internal API ---
SETTINGS_PORT=12752 # Port for the settings web UI SETTINGS_PORT=12752 # Port for the settings web UI

View File

@@ -31,6 +31,7 @@ When the user asks for direct fixes, make them — but still avoid unsolicited r
- `npm run start:1p` / `start:test:1p` — inject secrets via 1Password CLI (`op run`). - `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. - `npm run test-mongodb` / `test-mongodb:test` — connectivity probe; no test suite exists.
- No lint step configured. No unit/integration test framework. - No lint step configured. No unit/integration test framework.
- **Verification:** prefer `node --check <file>` for syntax, and inline `node -e "..."` for behavior. For tightly-coupled modules, stub deps via `Module._resolveFilename` override (see `services/channelQueue.js` tests in session history).
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. 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.
@@ -41,7 +42,7 @@ Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 (MongoDB Atlas)
1. **CommonJS only.** `require` / `module.exports`. Never `import`. 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. 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. 3. **Route channel operations through `services/channelQueue.js`**: `enqueueSend`, `enqueueRename`, `enqueueMove`, `enqueueDelete` (awaits both rename+send tails before deleting). Bypass sites are tagged `// TODO(queue-migrate):`grep to find them; migrate incrementally when touching.
4. **Logging is fire-and-forget.** Never `await logSystem/logError/logAutomation/logGmail/...`. Chain `.catch(() => {})` instead. 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`). 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). 6. **Mongoose schema defaults:** pass function references (`default: Date.now`), never invocations (`default: Date.now()` pins all documents to module-load time).
@@ -62,6 +63,8 @@ Single Node process. Entry: `broccolini-discord.js`.
- **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}`. - **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. - **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.
`routes/internalApi.js` is required at module scope by `broccolini-discord.js` *before* the parent's `module.exports` populates — reaching back to the parent (e.g., `trackInterval`, `trackTimeout`, `clearGmailPollInterval`) must use a **lazy `require('../broccolini-discord')` inside the handler**, not a top-level destructure.
### Intervals & shutdown ### Intervals & shutdown
- Every `setInterval` inside `ready` is wrapped via `trackInterval(...)` into the module-scoped `activeIntervals` Set. - 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. - `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.
@@ -86,6 +89,9 @@ Every `interactionCreate` branch runs through `runHandler(name, interaction, fn)
- In-memory counters bucketed into `today` / `week` / `month`, with scheduled resets at midnight / Monday 00:00 / 1st 00:00. - 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. - `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.
### `.env` persistence (`services/configPersistence.js`)
- Values are stored in **backtick** containers because dotenv v17 only decodes `\n`/`\r` inside `"…"` (not `\"` or `\\`) — backticks preserve quotes + literal newlines verbatim. `readEnvFile` joins multi-line backtick values; `writeEnvFile` re-reads after write and throws on key-count mismatch.
## bOSScord integration ## bOSScord integration
bOSScord is a separate React + Express cockpit app that consumes this bot's `/api/*` endpoints. bOSScord is a separate React + Express cockpit app that consumes this bot's `/api/*` endpoints.
@@ -119,3 +125,5 @@ Names and full tables are in `README.md` / `.env.example`. Ones that commonly tr
## Settings site ## 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. `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.
Its Docker build context is `settings-site/` only — parent-repo files (e.g., `utils.js`) are unreachable inside the container. Any shared helper must be inlined or the build context widened in `docker-compose.yml`.

View File

@@ -17,7 +17,7 @@ const { handleDiscordReply } = require('./handlers/messages');
// Services & jobs // Services & jobs
const { sendTicketClosedEmail } = require('./services/gmail'); const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels } = require('./services/tickets'); const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
const { notifyAllStaffUnclaimed } = require('./services/staffNotifications'); const { notifyAllStaffUnclaimed } = require('./services/staffNotifications');
const { registerCommands } = require('./commands/register'); const { registerCommands } = require('./commands/register');
const bosscordRoutes = require('./routes/bosscord'); const bosscordRoutes = require('./routes/bosscord');
@@ -37,6 +37,12 @@ function trackInterval(handle) {
if (handle) activeIntervals.add(handle); if (handle) activeIntervals.add(handle);
return handle; return handle;
} }
// Track one-shot setTimeout handles so shutdown can clear them (e.g., scheduled restarts).
const activeTimeouts = new Set();
function trackTimeout(handle) {
if (handle) activeTimeouts.add(handle);
return handle;
}
/** /**
* Update the Gmail poll interval at runtime. * Update the Gmail poll interval at runtime.
@@ -284,8 +290,15 @@ client.once('ready', async () => {
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)); reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
trackInterval(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));
resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e));
console.log('✓ Reconcile deleted ticket channels: every 1 hour'); console.log('✓ Reconcile deleted ticket channels: every 1 hour');
// Start in-memory Map sweeps (per-module) — keeps long-running processes bounded.
require('./services/patternStore').startSweeps(trackInterval);
require('./services/staffNotifications').startSweeps(trackInterval);
require('./services/tickets').startTicketsSweeps(trackInterval);
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
if (!CONFIG.STAFF_IDS.length) { if (!CONFIG.STAFF_IDS.length) {
console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.'); console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.');
} }
@@ -329,8 +342,8 @@ internalApp.use('/internal', internalApi);
let httpServer = null; let httpServer = null;
let internalServer = null; let internalServer = null;
if (CONFIG.INTERNAL_API_SECRET) { if (CONFIG.INTERNAL_API_SECRET) {
internalServer = 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 0.0.0.0:${CONFIG.INTERNAL_API_PORT}`); console.log(`[internalApi] listening on 127.0.0.1:${CONFIG.INTERNAL_API_PORT}`);
}); });
} else { } else {
console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.'); console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.');
@@ -349,6 +362,10 @@ async function handleShutdown(signal) {
try { clearInterval(handle); } catch (_) {} try { clearInterval(handle); } catch (_) {}
} }
activeIntervals.clear(); activeIntervals.clear();
for (const handle of activeTimeouts) {
try { clearTimeout(handle); } catch (_) {}
}
activeTimeouts.clear();
gmailPollInterval = null; gmailPollInterval = null;
try { if (httpServer) await new Promise(r => httpServer.close(() => r())); } catch (_) {} try { if (httpServer) await new Promise(r => httpServer.close(() => r())); } catch (_) {}
try { if (internalServer) await new Promise(r => internalServer.close(() => r())); } catch (_) {} try { if (internalServer) await new Promise(r => internalServer.close(() => r())); } catch (_) {}
@@ -366,6 +383,7 @@ module.exports = {
client, client,
setGmailPollInterval, setGmailPollInterval,
clearGmailPollInterval, clearGmailPollInterval,
trackTimeout,
sendGmailReply, sendGmailReply,
sendTicketClosedEmail, sendTicketClosedEmail,
getNextTicketNumber, getNextTicketNumber,

View File

@@ -70,6 +70,11 @@ const DEFAULT_NOTIFICATION_THRESHOLDS = {
chat_time: ['30m', '1h', '2h', '4h'] chat_time: ['30m', '1h', '2h', '4h']
}; };
function toInt(v, fallback) {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : fallback;
}
function parseThresholdString(str) { function parseThresholdString(str) {
const value = String(str || '').trim(); const value = String(str || '').trim();
if (!value) return NaN; if (!value) return NaN;
@@ -136,7 +141,7 @@ const CONFIG = {
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(), MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
LOGO_URL: process.env.LOGO_URL, LOGO_URL: process.env.LOGO_URL,
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support', SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
PORT: process.env.DISCORD_ONLY_PORT || 5000, PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '<br>'), SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '<br>'),
GAME_LIST: process.env.GAME_LIST || '', GAME_LIST: process.env.GAME_LIST || '',
@@ -157,19 +162,19 @@ const CONFIG = {
DISCORD_TRANSCRIPT_MESSAGE: process.env.DISCORD_TRANSCRIPT_MESSAGE || 'Your ticket **{channel_name}** has been closed. Here is your transcript. If you still need assistance, please open a new ticket.', DISCORD_TRANSCRIPT_MESSAGE: process.env.DISCORD_TRANSCRIPT_MESSAGE || 'Your ticket **{channel_name}** has been closed. Here is your transcript. If you still need assistance, please open a new ticket.',
DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.', DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.',
AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true', AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true',
AUTO_CLOSE_AFTER_HOURS: parseInt(process.env.AUTO_CLOSE_AFTER_HOURS) || 72, AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72),
AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.', AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.',
GLOBAL_TICKET_LIMIT: parseInt(process.env.GLOBAL_TICKET_LIMIT) || 5, GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5),
TICKET_LIMIT_PER_CATEGORY: parseInt(process.env.TICKET_LIMIT_PER_CATEGORY) || 3, TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3),
RATE_LIMIT_TICKETS_PER_USER: parseInt(process.env.RATE_LIMIT_TICKETS_PER_USER) || 0, RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0),
RATE_LIMIT_WINDOW_MINUTES: parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES) || 60, RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60),
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀', TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',
REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true', REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true',
REMINDER_AFTER_HOURS: parseInt(process.env.REMINDER_AFTER_HOURS) || 24, REMINDER_AFTER_HOURS: toInt(process.env.REMINDER_AFTER_HOURS, 24),
REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.', REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.',
PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true', PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true',
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal', DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
@@ -177,9 +182,9 @@ const CONFIG = {
PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡', PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡',
PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟢', PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟢',
CLAIM_TIMEOUT_ENABLED: process.env.CLAIM_TIMEOUT_ENABLED === 'true', CLAIM_TIMEOUT_ENABLED: process.env.CLAIM_TIMEOUT_ENABLED === 'true',
CLAIM_TIMEOUT_HOURS: parseInt(process.env.CLAIM_TIMEOUT_HOURS) || 48, CLAIM_TIMEOUT_HOURS: toInt(process.env.CLAIM_TIMEOUT_HOURS, 48),
AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true', AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
AUTO_UNCLAIM_AFTER_HOURS: parseInt(process.env.AUTO_UNCLAIM_AFTER_HOURS) || 24, AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true', ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
USE_THREADS: process.env.USE_THREADS === 'true', USE_THREADS: process.env.USE_THREADS === 'true',
THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null, THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null,
@@ -189,11 +194,11 @@ const CONFIG = {
BUTTON_EMOJI_CLOSE: process.env.BUTTON_EMOJI_CLOSE || '🔒', BUTTON_EMOJI_CLOSE: process.env.BUTTON_EMOJI_CLOSE || '🔒',
BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌', BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌',
BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓', BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓',
EMBED_COLOR_OPEN: parseInt(process.env.EMBED_COLOR_OPEN) || 0x00FF00, EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00),
EMBED_COLOR_CLOSED: parseInt(process.env.EMBED_COLOR_CLOSED) || 0xFF0000, EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000),
EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00, EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600, EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124, EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
STAFF_CATEGORIES: new Map(), // deprecated kept for staffChannel.js compat STAFF_CATEGORIES: new Map(), // deprecated kept for staffChannel.js compat
STAFF_EMOJIS: (() => { STAFF_EMOJIS: (() => {
const raw = process.env.STAFF_EMOJIS; const raw = process.env.STAFF_EMOJIS;
@@ -213,8 +218,8 @@ const CONFIG = {
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
ADMIN_ID: process.env.ADMIN_ID || null, ADMIN_ID: process.env.ADMIN_ID || null,
STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null, STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null,
FORCE_CLOSE_TIMER: parseInt(process.env.FORCE_CLOSE_TIMER_SECONDS) || 60, FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
GMAIL_POLL_INTERVAL_MS: parseInt(process.env.GMAIL_POLL_INTERVAL_SECONDS || '30') * 1000, GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null, GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null,
AUTOMATION_LOG_CHANNEL_ID: process.env.AUTOMATION_LOG_CHANNEL_ID || null, AUTOMATION_LOG_CHANNEL_ID: process.env.AUTOMATION_LOG_CHANNEL_ID || null,
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null, RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
@@ -226,35 +231,35 @@ const CONFIG = {
ESCALATION_PATTERNS_CHANNEL_ID: process.env.ESCALATION_PATTERNS_CHANNEL_ID || null, ESCALATION_PATTERNS_CHANNEL_ID: process.env.ESCALATION_PATTERNS_CHANNEL_ID || null,
STAFF_PATTERNS_CHANNEL_ID: process.env.STAFF_PATTERNS_CHANNEL_ID || null, STAFF_PATTERNS_CHANNEL_ID: process.env.STAFF_PATTERNS_CHANNEL_ID || null,
COMBINED_PATTERNS_CHANNEL_ID: process.env.COMBINED_PATTERNS_CHANNEL_ID || null, COMBINED_PATTERNS_CHANNEL_ID: process.env.COMBINED_PATTERNS_CHANNEL_ID || null,
PATTERN_USER_TICKET_THRESHOLD: parseInt(process.env.PATTERN_USER_TICKET_THRESHOLD) || 3, PATTERN_USER_TICKET_THRESHOLD: toInt(process.env.PATTERN_USER_TICKET_THRESHOLD, 3),
PATTERN_GAME_TICKET_THRESHOLD: parseInt(process.env.PATTERN_GAME_TICKET_THRESHOLD) || 10, PATTERN_GAME_TICKET_THRESHOLD: toInt(process.env.PATTERN_GAME_TICKET_THRESHOLD, 10),
PATTERN_STAFF_STALE_PING_THRESHOLD: parseInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD) || 5, PATTERN_STAFF_STALE_PING_THRESHOLD: toInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD, 5),
PATTERN_ESCALATION_THRESHOLD: parseInt(process.env.PATTERN_ESCALATION_THRESHOLD) || 3, PATTERN_ESCALATION_THRESHOLD: toInt(process.env.PATTERN_ESCALATION_THRESHOLD, 3),
PATTERN_RAPID_CLOSE_SECONDS: parseInt(process.env.PATTERN_RAPID_CLOSE_SECONDS) || 120, PATTERN_RAPID_CLOSE_SECONDS: toInt(process.env.PATTERN_RAPID_CLOSE_SECONDS, 120),
PATTERN_UNCLAIMED_HOURS: parseInt(process.env.PATTERN_UNCLAIMED_HOURS) || 4, PATTERN_UNCLAIMED_HOURS: toInt(process.env.PATTERN_UNCLAIMED_HOURS, 4),
PATTERN_CHECK_INTERVAL_MINUTES: parseInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES) || 30, PATTERN_CHECK_INTERVAL_MINUTES: toInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES, 30),
ALL_STAFF_CHANNEL_ID: process.env.ALL_STAFF_CHANNEL_ID || null, ALL_STAFF_CHANNEL_ID: process.env.ALL_STAFF_CHANNEL_ID || null,
ALL_STAFF_CHAT_ALERT_CHANNEL_ID: process.env.ALL_STAFF_CHAT_ALERT_CHANNEL_ID || null, ALL_STAFF_CHAT_ALERT_CHANNEL_ID: process.env.ALL_STAFF_CHAT_ALERT_CHANNEL_ID || null,
SURGE_ROLE_ID: process.env.SURGE_ROLE_ID || null, SURGE_ROLE_ID: process.env.SURGE_ROLE_ID || null,
SURGE_TICKET_COUNT: parseInt(process.env.SURGE_TICKET_COUNT) || 10, SURGE_TICKET_COUNT: toInt(process.env.SURGE_TICKET_COUNT, 10),
SURGE_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_TICKET_WINDOW_MINUTES) || 30, SURGE_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_TICKET_WINDOW_MINUTES, 30),
SURGE_GAME_TICKET_COUNT: parseInt(process.env.SURGE_GAME_TICKET_COUNT) || 5, SURGE_GAME_TICKET_COUNT: toInt(process.env.SURGE_GAME_TICKET_COUNT, 5),
SURGE_GAME_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES) || 30, SURGE_GAME_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES, 30),
SURGE_STALE_COUNT: parseInt(process.env.SURGE_STALE_COUNT) || 8, SURGE_STALE_COUNT: toInt(process.env.SURGE_STALE_COUNT, 8),
SURGE_STALE_HOURS: parseInt(process.env.SURGE_STALE_HOURS) || 2, SURGE_STALE_HOURS: toInt(process.env.SURGE_STALE_HOURS, 2),
SURGE_NEEDS_RESPONSE_COUNT: parseInt(process.env.SURGE_NEEDS_RESPONSE_COUNT) || 5, SURGE_NEEDS_RESPONSE_COUNT: toInt(process.env.SURGE_NEEDS_RESPONSE_COUNT, 5),
SURGE_NEEDS_RESPONSE_HOURS: parseInt(process.env.SURGE_NEEDS_RESPONSE_HOURS) || 1, SURGE_NEEDS_RESPONSE_HOURS: toInt(process.env.SURGE_NEEDS_RESPONSE_HOURS, 1),
SURGE_UNCLAIMED_COUNT: parseInt(process.env.SURGE_UNCLAIMED_COUNT) || 5, SURGE_UNCLAIMED_COUNT: toInt(process.env.SURGE_UNCLAIMED_COUNT, 5),
SURGE_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_UNCLAIMED_MINUTES) || 30, SURGE_UNCLAIMED_MINUTES: toInt(process.env.SURGE_UNCLAIMED_MINUTES, 30),
SURGE_TIER3_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES) || 15, SURGE_TIER3_UNCLAIMED_MINUTES: toInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES, 15),
SURGE_COOLDOWN_MINUTES: parseInt(process.env.SURGE_COOLDOWN_MINUTES) || 60, SURGE_COOLDOWN_MINUTES: toInt(process.env.SURGE_COOLDOWN_MINUTES, 60),
CHAT_ALERT_CHANNEL_IDS: (process.env.CHAT_ALERT_CHANNEL_IDS || '').split(',').filter(Boolean), CHAT_ALERT_CHANNEL_IDS: (process.env.CHAT_ALERT_CHANNEL_IDS || '').split(',').filter(Boolean),
CHAT_ALERT_MESSAGE_COUNT: parseInt(process.env.CHAT_ALERT_MESSAGE_COUNT) || 5, CHAT_ALERT_MESSAGE_COUNT: toInt(process.env.CHAT_ALERT_MESSAGE_COUNT, 5),
CHAT_ALERT_HOURS_WITHOUT_RESPONSE: parseInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE) || 2, CHAT_ALERT_HOURS_WITHOUT_RESPONSE: toInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE, 2),
CHAT_ALERT_COOLDOWN_MINUTES: parseInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES) || 60, CHAT_ALERT_COOLDOWN_MINUTES: toInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES, 60),
STAFF_IDS: (process.env.STAFF_IDS || '').split(',').map(s => s.trim()).filter(Boolean), STAFF_IDS: (process.env.STAFF_IDS || '').split(',').map(s => s.trim()).filter(Boolean),
SURGE_NO_STAFF_COOLDOWN_MINUTES: parseInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES) || 30, SURGE_NO_STAFF_COOLDOWN_MINUTES: toInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES, 30),
SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: parseInt(process.env.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) || 3, SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: toInt(process.env.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD, 3),
STAFF_DND_COUNTS_AS_AVAILABLE: process.env.STAFF_DND_COUNTS_AS_AVAILABLE === 'true', STAFF_DND_COUNTS_AS_AVAILABLE: process.env.STAFF_DND_COUNTS_AS_AVAILABLE === 'true',
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
@@ -262,11 +267,12 @@ const CONFIG = {
STAFF_THREAD_ROLE_ID: process.env.STAFF_THREAD_ROLE_ID || process.env.ROLE_ID_TO_PING || null, STAFF_THREAD_ROLE_ID: process.env.STAFF_THREAD_ROLE_ID || process.env.ROLE_ID_TO_PING || null,
PIN_INITIAL_MESSAGE_ENABLED: process.env.PIN_INITIAL_MESSAGE_ENABLED === 'true', PIN_INITIAL_MESSAGE_ENABLED: process.env.PIN_INITIAL_MESSAGE_ENABLED === 'true',
PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true', PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true',
TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true',
PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true', PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true',
SETTINGS_PORT: parseInt(process.env.SETTINGS_PORT) || 12752, SETTINGS_PORT: toInt(process.env.SETTINGS_PORT, 12752),
SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null, SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com', SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
INTERNAL_API_PORT: parseInt(process.env.INTERNAL_API_PORT) || 12753, INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null, INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null,
NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON), NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON),
UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4') UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4')

View File

@@ -38,7 +38,7 @@ These are the files that give someone the fastest path to understanding the repo
### 8. [**services/tickets.js**](../services/tickets.js) ### 8. [**services/tickets.js**](../services/tickets.js)
- **Why:** Core ticket lifecycle and Discord channel/thread creation. - **Why:** Core ticket lifecycle and Discord channel/thread creation.
- **What you get:** Ticket numbers (`getNextTicketNumber`), channel naming and Discord rate limit handling (2 renames per 10 min), ticket limits and overflow category selection, rate limit for ticket creation per user, `createEmailTicketAsThread` / `createDiscordTicketAsThread`, auto-close/reminder/auto-unclaim jobs, and helpers like `updateTicketActivity`, `canRename`, `makeTicketName`. - **What you get:** Ticket numbers (`getNextTicketNumber`), channel naming, ticket limits and overflow category selection, rate limit for ticket creation per user, `createEmailTicketAsThread` / `createDiscordTicketAsThread`, auto-close/reminder/auto-unclaim jobs, and helpers like `updateTicketActivity`, `canRename` (retained as an always-ok shim — see `utils/renamer.js` and `services/channelQueue.js` for actual rename handling and primary-bot fallback), `makeTicketName`.
### 9. [**handlers/buttons.js**](../handlers/buttons.js) ### 9. [**handlers/buttons.js**](../handlers/buttons.js)
- **Why:** Every button and ticket modal goes through here. - **Why:** Every button and ticket modal goes through here.
@@ -167,7 +167,7 @@ Broccolini Bot is a **Node.js support-ticket bot** that connects **Gmail**, **Di
### Claim / Unclaim / Close ### Claim / Unclaim / Close
- **Claim** (button or `/claim`) - **Claim** (button or `/claim`)
Ticket is updated with `claimedBy` (user id or name). Channel may be renamed (respecting Discords 2 renames per 10 min). Claimed message is posted (template from CONFIG). Ticket is updated with `claimedBy` (user id or name). Channel may be renamed via the secondary-bot path (`utils/renamer.js`), falling back to the primary bot on 401/403/429. Claimed message is posted (template from CONFIG).
- **Unclaim** (button or `/unclaim`) - **Unclaim** (button or `/unclaim`)
`claimedBy` is cleared; channel rename and message as above. `claimedBy` is cleared; channel rename and message as above.

View File

@@ -200,7 +200,7 @@ THREAD_PARENT_CHANNEL=
- Optimized background job queries - Optimized background job queries
### Rate Limit Handling ### Rate Limit Handling
- Channel rename: 2 per 10 minutes per channel (Discord limit). When limit is reached, message: *Channel renamed too quickly. Try again \<t:unlock:R\>.* - Channel rename: Discord enforces 2 per 10 minutes per channel **per bot**. Renames route through `utils/renamer.js` (RENAMER_BOT secondary token); on 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`.
- Modal submission handling - Modal submission handling
- Autocomplete debouncing - Autocomplete debouncing
- Batch command registration - Batch command registration

View File

@@ -447,7 +447,7 @@ THREAD_PARENT_CHANNEL=
### Rate Limits ### Rate Limits
- Channel creation: 50/day per guild - Channel creation: 50/day per guild
- Channel rename: 2 per 10 minutes per channel ([Discord docs](https://discord.com/developers/docs/topics/rate-limits)). When the limit is reached, the bot skips the rename and posts: *Channel renamed too quickly. Try again \<t:unlock:R\>.* - Channel rename: Discord enforces 2 per 10 minutes per channel **per bot** ([Discord docs](https://discord.com/developers/docs/topics/rate-limits)). Renames route through `utils/renamer.js` (RENAMER_BOT secondary token); on 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`.
- Message edits: Be cautious with bulk operations - Message edits: Be cautious with bulk operations
--- ---

View File

@@ -66,8 +66,8 @@ const Transcript = mongoose.model('Transcript');
claimed_by: String (Discord user ID), claimed_by: String (Discord user ID),
escalated: Boolean (default: false), escalated: Boolean (default: false),
ticket_number: Number, ticket_number: Number,
rename_count: Number (default: 0), rename_count: Number (default: 0), // orphan: no longer read/written (see CLAUDE.md)
rename_window_start: Date rename_window_start: Date // orphan: no longer read/written
} }
``` ```

View File

@@ -160,9 +160,13 @@ async function poll(client) {
if (ticketChan) { if (ticketChan) {
const truncatedFollowup = followupBody.slice(0, 1800); const truncatedFollowup = followupBody.slice(0, 1800);
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
await enqueueSend( await enqueueSend(
ticketChan, ticketChan,
`<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}` {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: ['roles'] }
}
); );
} else { } else {
// Check ticket limits before creating // Check ticket limits before creating
@@ -256,7 +260,8 @@ async function poll(client) {
const welcomeMsg = await enqueueSend(ticketChan, { const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed], embeds: [ticketInfoEmbed],
components: [buttons] components: [buttons],
allowedMentions: { parse: ['roles'] }
}); });
const { createStaffThread } = require('./services/staffThread'); const { createStaffThread } = require('./services/staffThread');
@@ -311,7 +316,8 @@ async function poll(client) {
} }
const truncated = firstBody.slice(0, 1900); const truncated = firstBody.slice(0, 1900);
await enqueueSend(ticketChan, `**Message:**\n${truncated}`); // Email body is attacker-controlled — no mentions may fire from its content.
await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } });
// Welcome message skipped for email tickets the email body speaks for itself. // 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. // Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
@@ -369,7 +375,7 @@ async function poll(client) {
pollSuspended = true; pollSuspended = true;
const suspendMsg = 'Gmail OAuth token invalid or expired. Polling SUSPENDED — will not retry automatically. Re-authenticate to resume.'; const suspendMsg = 'Gmail OAuth token invalid or expired. Polling SUSPENDED — will not retry automatically. Re-authenticate to resume.';
console.error('[gmail-poll]', suspendMsg); console.error('[gmail-poll]', suspendMsg);
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client); logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {} try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
if (CONFIG.ADMIN_ID && !authErrorNotified) { if (CONFIG.ADMIN_ID && !authErrorNotified) {
authErrorNotified = true; authErrorNotified = true;
@@ -379,7 +385,7 @@ async function poll(client) {
totalErrors++; totalErrors++;
console.error('POLL ERROR:', e); console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client); logError('Gmail poll', e, null, client).catch(() => {});
} }
} finally { } finally {
isPolling = false; isPolling = false;

View File

@@ -7,6 +7,7 @@ const { CONFIG } = require('../config');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { logSecurity } = require('../services/debugLog'); const { logSecurity } = require('../services/debugLog');
const { enqueueSend } = require('../services/channelQueue'); const { enqueueSend } = require('../services/channelQueue');
const { isStaff } = require('../utils');
const User = mongoose.model('User'); const User = mongoose.model('User');
@@ -134,6 +135,13 @@ async function handleAccountInfoCommand(interaction) {
async function handleSendAccountInfoToChannel(interaction) { async function handleSendAccountInfoToChannel(interaction) {
if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false; if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false;
// Dispatched directly from interactionCreate — no upstream command-level staff gate here, so enforce it.
if (!isStaff(interaction.member)) {
logSecurity('Unauthorized account-info button', interaction.user, `non-staff pressed ${interaction.customId}`, null, 0xff0000).catch(() => {});
await interaction.reply({ content: 'You do not have permission to do that.', ephemeral: true }).catch(() => {});
return true;
}
const payload = interaction.customId.slice(BUTTON_PREFIX.length); const payload = interaction.customId.slice(BUTTON_PREFIX.length);
const [type, value] = payload.includes(':') ? payload.split(':') : [payload, '']; const [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];

View File

@@ -26,7 +26,7 @@ const { runEscalation, runDeescalation } = require('./commands');
const { trackInteraction, trackError } = require('./analytics'); const { trackInteraction, trackError } = require('./analytics');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
const { increment } = require('../services/patternStore'); const { increment } = require('../services/patternStore');
const { logError } = require('../services/debugLog'); const { logError, logSystem } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript'); const Transcript = mongoose.model('Transcript');
@@ -497,8 +497,10 @@ async function handleConfirmClose(interaction, ticket) {
}); });
} }
// DM the transcript to the ticket creator (Discord-originated tickets) // DM the transcript to the ticket creator (Discord-originated tickets).
if (ticket.gmailThreadId?.startsWith('discord-')) { // Gated because many users have DMs from server members disabled — the send
// then 50007s and generates noise. Default off; enable via env when desired.
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop(); const creatorId = ticket.gmailThreadId.split('-').pop();
try { try {
const creator = await interaction.client.users.fetch(creatorId); const creator = await interaction.client.users.fetch(creatorId);
@@ -515,7 +517,15 @@ async function handleConfirmClose(interaction, ticket) {
files: [dmFile] files: [dmFile]
}); });
} catch (dmErr) { } catch (dmErr) {
console.warn(`Could not DM transcript to user ${creatorId}:`, dmErr.message); // 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only.
if (dmErr?.code === 50007) {
logSystem('Transcript DM skipped (recipient has DMs disabled)', [
{ name: 'User', value: creatorId },
{ name: 'Channel', value: channelName }
]).catch(() => {});
} else {
logError('transcript-dm', dmErr).catch(() => {});
}
} }
} }
@@ -643,6 +653,7 @@ async function handleTicketModal(interaction) {
} }
parentCategoryIdForTicket = parentId; parentCategoryIdForTicket = parentId;
try { try {
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
channel = await guild.channels.create({ channel = await guild.channels.create({
name: unclaimedName, name: unclaimedName,
type: ChannelType.GuildText, type: ChannelType.GuildText,

View File

@@ -440,6 +440,7 @@ async function handleCommand(interaction) {
} }
try { try {
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
await interaction.channel.permissionOverwrites.create(user.id, { await interaction.channel.permissionOverwrites.create(user.id, {
ViewChannel: true, ViewChannel: true,
SendMessages: true, SendMessages: true,
@@ -462,6 +463,7 @@ async function handleCommand(interaction) {
} }
try { try {
// TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue — could race a pending rename/send on the same channel.
await interaction.channel.permissionOverwrites.delete(user.id); await interaction.channel.permissionOverwrites.delete(user.id);
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } }); await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) { } catch (err) {
@@ -524,6 +526,7 @@ async function handleCommand(interaction) {
} }
try { try {
// TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) — use enqueueMove so moves serialize with pending renames/sends.
await interaction.channel.setParent(category.id, { lockPermissions: true }); await interaction.channel.setParent(category.id, { lockPermissions: true });
await interaction.reply(`Moved ticket to **${category.name}**.`); await interaction.reply(`Moved ticket to **${category.name}**.`);
@@ -711,6 +714,7 @@ async function handleCommand(interaction) {
} }
try { try {
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
await interaction.channel.setTopic(text); await interaction.channel.setTopic(text);
await interaction.reply('Topic updated successfully.'); await interaction.reply('Topic updated successfully.');
} catch (err) { } catch (err) {
@@ -1085,19 +1089,46 @@ async function handleCommand(interaction) {
return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.'); return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.');
} }
try { try {
const tickets = await Ticket.find().sort({ ticketNumber: 1 }).lean(); // Stream every ticket through a Mongoose cursor to a tmp file so peak RSS
const lines = ['# Ticket backup ' + new Date().toISOString(), 'ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier']; // stays bounded regardless of collection size; attach the file, then unlink.
for (const t of tickets) { const fs = require('fs');
const os = require('os');
const path = require('path');
const tmpName = `ticket-backup-${Date.now()}-${process.pid}.txt`;
const tmpPath = path.join(os.tmpdir(), tmpName);
const ws = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
ws.write('# Ticket backup ' + new Date().toISOString() + '\n');
ws.write('ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier\n');
let count = 0;
const cursor = Ticket.find().sort({ ticketNumber: 1 }).lean().cursor();
for await (const t of cursor) {
const created = t.createdAt ? new Date(t.createdAt).toISOString() : ''; const created = t.createdAt ? new Date(t.createdAt).toISOString() : '';
lines.push([t.ticketNumber, t.status || '', (t.senderEmail || '').replace(/\t/g, ' '), (t.subject || '').replace(/\t/g, ' ').slice(0, 200), created, (t.claimedBy || '').replace(/\t/g, ' '), t.priority || '', t.escalationTier ?? ''].join('\t')); ws.write([
t.ticketNumber,
t.status || '',
(t.senderEmail || '').replace(/\t/g, ' '),
(t.subject || '').replace(/\t/g, ' ').slice(0, 200),
created,
(t.claimedBy || '').replace(/\t/g, ' '),
t.priority || '',
t.escalationTier ?? ''
].join('\t') + '\n');
count++;
} }
const buf = Buffer.from(lines.join('\n'), 'utf8'); await new Promise((resolve, reject) => ws.end(err => err ? reject(err) : resolve()));
try {
const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID); const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID);
await enqueueSend(channel, { await enqueueSend(channel, {
content: `Ticket backup by ${interaction.user.tag} (${tickets.length} tickets)`, content: `Ticket backup by ${interaction.user.tag} (${count} tickets)`,
files: [new AttachmentBuilder(buf, { name: `ticket-backup-${Date.now()}.txt` })] files: [new AttachmentBuilder(tmpPath, { name: tmpName })]
}); });
await interaction.editReply(`Backup complete. ${tickets.length} tickets sent to the backup channel.`); await interaction.editReply(`Backup complete. ${count} tickets sent to the backup channel.`);
} finally {
fs.promises.unlink(tmpPath).catch(() => {});
}
} catch (err) { } catch (err) {
trackError('backup-command', err, interaction); trackError('backup-command', err, interaction);
await interaction.editReply('Failed to create backup: ' + (err.message || err)); await interaction.editReply('Failed to create backup: ' + (err.message || err));

View File

@@ -817,7 +817,8 @@ const ticketSchema = new mongoose.Schema({
staffChannelId: String, staffChannelId: String,
parentCategoryId: String, parentCategoryId: String,
unclaimedRemindersSent: { type: [Number], default: [] }, unclaimedRemindersSent: { type: [Number], default: [] },
lastMessageAuthorIsStaff: { type: Boolean, default: false } lastMessageAuthorIsStaff: { type: Boolean, default: false },
pendingDelete: { type: Boolean, default: false }
}); });
ticketSchema.index({ status: 1, lastActivity: 1 }); ticketSchema.index({ status: 1, lastActivity: 1 });
ticketSchema.index({ senderEmail: 1, status: 1 }); ticketSchema.index({ senderEmail: 1, status: 1 });

View File

@@ -10,7 +10,7 @@ const { getBot } = require('../api/bosscordClient');
const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets'); const { updateTicketActivity } = require('../services/tickets');
const { enqueueSend } = require('../services/channelQueue'); const { enqueueSend } = require('../services/channelQueue');
const { extractRawEmail } = require('../utils'); const { extractRawEmail, safeEqual } = require('../utils');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const router = express.Router(); const router = express.Router();
@@ -43,8 +43,9 @@ function authMiddleware(req, res, next) {
} }
const auth = req.headers.authorization; const auth = req.headers.authorization;
const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null; const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
if (token !== key) { // Identical response body for missing vs invalid token — don't tell a probe which state it's in.
return res.status(401).json({ error: 'Unauthorized' }); if (!safeEqual(token, key)) {
return res.status(401).json({ error: 'unauthorized' });
} }
next(); next();
} }
@@ -189,7 +190,9 @@ router.post('/tickets/:id/messages', express.json(), async (req, res) => {
return res.status(404).json({ error: 'Discord channel not found' }); return res.status(404).json({ error: 'Discord channel not found' });
} }
const discordUser = req.body.displayName || 'bOSScord'; const discordUser = req.body.displayName || 'bOSScord';
await enqueueSend(channel, content); // Content originates from the bOSScord web UI (staff-gated) but still crosses an HTTP boundary —
// allow explicit user/role mentions a staff member typed, block @everyone/@here.
await enqueueSend(channel, { content, allowedMentions: { parse: ['users', 'roles'] } });
if (!ticket.gmailThreadId.startsWith('discord-')) { if (!ticket.gmailThreadId.startsWith('discord-')) {
try { try {

View File

@@ -2,6 +2,7 @@ const express = require('express');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { ChannelType } = require('discord.js'); const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { safeEqual } = require('../utils');
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
const { logSystem } = require('../services/debugLog'); const { logSystem } = require('../services/debugLog');
const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry'); const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry');
@@ -15,6 +16,7 @@ const {
const router = express.Router(); const router = express.Router();
// Intentionally no trust-proxy: loopback-only; global rate-limit bucket.
const internalLimiter = rateLimit({ const internalLimiter = rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
max: 10, max: 10,
@@ -28,7 +30,7 @@ router.use(internalLimiter);
// Middleware: verify internal secret // Middleware: verify internal secret
router.use((req, res, next) => { router.use((req, res, next) => {
const secret = req.headers['x-internal-secret']; const secret = req.headers['x-internal-secret'];
if (!CONFIG.INTERNAL_API_SECRET || secret !== CONFIG.INTERNAL_API_SECRET) { if (!CONFIG.INTERNAL_API_SECRET || !safeEqual(secret, CONFIG.INTERNAL_API_SECRET)) {
return res.status(401).json({ error: 'Unauthorized' }); return res.status(401).json({ error: 'Unauthorized' });
} }
next(); next();
@@ -136,10 +138,13 @@ router.post('/restart', express.json(), (req, res) => {
const delay = new Date(scheduledFor).getTime() - Date.now(); const delay = new Date(scheduledFor).getTime() - Date.now();
if (delay <= 0) return res.status(400).json({ error: 'Scheduled time is in the past' }); if (delay <= 0) return res.status(400).json({ error: 'Scheduled time is in the past' });
if (scheduledRestart) clearTimeout(scheduledRestart); if (scheduledRestart) clearTimeout(scheduledRestart);
scheduledRestart = setTimeout(() => { // Lazy require: broccolini-discord.js requires this file at module scope before its exports are populated.
const { trackTimeout } = require('../broccolini-discord');
scheduledRestart = trackTimeout(setTimeout(() => {
console.log('[restart] Scheduled restart firing...'); console.log('[restart] Scheduled restart firing...');
process.exit(0); process.exit(0);
}, delay); }, delay));
if (scheduledRestart && typeof scheduledRestart.unref === 'function') scheduledRestart.unref();
res.json({ ok: true, mode, scheduledFor, delayMs: delay }); res.json({ ok: true, mode, scheduledFor, delayMs: delay });
return; return;
} }

View File

@@ -129,4 +129,32 @@ function enqueueSend(channel, ...args) {
return next; return next;
} }
module.exports = { enqueueRename, enqueueMove, enqueueSend }; // Delete a channel only after every in-flight send/rename/move on it has drained.
// Chains on both renameChains and sendChains so "pending send in-flight, delete
// racing it" can no longer hit Discord's unknown-channel 10003.
function enqueueDelete(channel) {
if (!channel || typeof channel.delete !== 'function') {
return Promise.reject(new Error('enqueueDelete: invalid channel'));
}
const renameEntry = renameChains.get(channel.id);
const prevRename = renameEntry ? renameEntry.chain : Promise.resolve();
const prevSend = sendChains.get(channel.id) || Promise.resolve();
const next = Promise.all([
prevRename.catch(() => {}),
prevSend.catch(() => {})
]).then(() => channel.delete().catch(() => {}));
if (renameEntry) renameEntry.chain = next;
sendChains.set(channel.id, next);
next.finally(() => {
if (renameEntry && renameChains.get(channel.id) === renameEntry && renameEntry.chain === next) {
renameChains.delete(channel.id);
}
if (sendChains.get(channel.id) === next) sendChains.delete(channel.id);
});
return next;
}
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete };

View File

@@ -7,60 +7,153 @@ const ENV_PATH = process.env.ENV_FILE
? path.resolve(process.env.ENV_FILE) ? path.resolve(process.env.ENV_FILE)
: path.resolve(process.cwd(), '.env'); : path.resolve(process.cwd(), '.env');
/**
* Serialize a runtime value for .env storage.
*
* Default container: backticks. Under dotenv v17, backtick-wrapped values are
* preserved verbatim (literal newlines and inner quotes/backslashes all round-trip
* without escape processing), which is the only container that survives quotes
* AND newlines cleanly. Double-quoted values only decode `\n` / `\r`; `\"` and
* `\\` stay literal, so quoted-with-escapes doesn't round-trip.
*
* Fallback for values that themselves contain a backtick (vanishingly rare in
* env-style config): double-quote with escape-encoded `\\`, `\"`, `\n`, `\r`,
* `\t`. Caveat — CONFIG will receive the still-escaped `\"` / `\\` after boot
* because dotenv v17 won't reverse those. Flagged in the key-count verification
* at the bottom of writeEnvFile; the combination is unreachable via the UI.
*/
function encodeEnvValue(v) {
const s = String(v == null ? '' : v);
if (!s.includes('`')) return '`' + s + '`';
const escaped = s
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
return `"${escaped}"`;
}
/**
* Decode a raw .env value. Backtick and double-quote containers supported;
* unquoted values pass through (hand-edited .env back-compat).
*/
function decodeEnvValue(raw) {
if (raw.length >= 2 && raw.startsWith('`') && raw.endsWith('`')) {
return raw.slice(1, -1);
}
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
const inner = raw.slice(1, -1);
let out = '';
for (let i = 0; i < inner.length; i++) {
if (inner[i] === '\\' && i + 1 < inner.length) {
const next = inner[i + 1];
if (next === 'n') out += '\n';
else if (next === 'r') out += '\r';
else if (next === 't') out += '\t';
else if (next === '"') out += '"';
else if (next === '\\') out += '\\';
else out += inner[i] + next;
i++;
} else {
out += inner[i];
}
}
return out;
}
return raw;
}
/** /**
* Read the current .env file and parse into a key->value Map. * Read the current .env file and parse into a key->value Map.
* Backtick-wrapped values may span multiple physical lines (dotenv behavior);
* this reader joins continuation lines until the closing backtick is found.
* Double-quoted values are decoded (`\n`/`\r` escapes processed);
* unquoted values pass through.
*/ */
function readEnvFile() { function readEnvFile() {
if (!fs.existsSync(ENV_PATH)) return new Map(); if (!fs.existsSync(ENV_PATH)) return new Map();
const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n'); const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n');
const map = new Map(); const map = new Map();
for (const line of lines) { for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue; if (!trimmed || trimmed.startsWith('#')) continue;
const idx = line.indexOf('='); const idx = line.indexOf('=');
if (idx === -1) continue; if (idx === -1) continue;
const key = line.slice(0, idx).trim(); const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim(); let value = line.slice(idx + 1);
map.set(key, value); if (value.trimStart().startsWith('`')) {
let btCount = (value.match(/`/g) || []).length;
while (btCount < 2 && i + 1 < lines.length) {
i++;
value += '\n' + lines[i];
btCount = (value.match(/`/g) || []).length;
}
}
map.set(key, decodeEnvValue(value.trim()));
} }
return map; return map;
} }
/** /**
* Write a Map of key->value back to the .env file, * Write a Map of key->value back to the .env file,
* preserving comments and blank lines. * preserving comments and blank lines. Values are encoded via encodeEnvValue.
*
* After write, re-reads the file and throws if the key count doesn't match the
* expected count — catches truncation or corrupted-quote escaping.
*/ */
function writeEnvFile(updates) { function writeEnvFile(updates) {
const expected = updates.size;
if (!fs.existsSync(ENV_PATH)) { if (!fs.existsSync(ENV_PATH)) {
const lines = []; const lines = [];
for (const [k, v] of updates) lines.push(`${k}=${v}`); for (const [k, v] of updates) lines.push(`${k}=${encodeEnvValue(v)}`);
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8'); fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
return; } else {
}
const raw = fs.readFileSync(ENV_PATH, 'utf8'); const raw = fs.readFileSync(ENV_PATH, 'utf8');
const lines = raw.split('\n'); const lines = raw.split('\n');
const written = new Set(); const written = new Set();
const result = [];
const result = lines.map(line => { for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line; if (!trimmed || trimmed.startsWith('#')) { result.push(line); continue; }
const idx = line.indexOf('='); const idx = line.indexOf('=');
if (idx === -1) return line; if (idx === -1) { result.push(line); continue; }
const key = line.slice(0, idx).trim(); const key = line.slice(0, idx).trim();
const spanStart = i;
let valueSoFar = line.slice(idx + 1);
if (valueSoFar.trimStart().startsWith('`')) {
let btCount = (valueSoFar.match(/`/g) || []).length;
while (btCount < 2 && i + 1 < lines.length) {
i++;
valueSoFar += '\n' + lines[i];
btCount = (valueSoFar.match(/`/g) || []).length;
}
}
if (updates.has(key)) { if (updates.has(key)) {
written.add(key); written.add(key);
return `${key}=${updates.get(key)}`; result.push(`${key}=${encodeEnvValue(updates.get(key))}`);
} else {
for (let j = spanStart; j <= i; j++) result.push(lines[j]);
}
} }
return line;
});
// Append any new keys not already in the file
for (const [k, v] of updates) { for (const [k, v] of updates) {
if (!written.has(k)) result.push(`${k}=${v}`); if (!written.has(k)) result.push(`${k}=${encodeEnvValue(v)}`);
} }
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8'); fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
}
const roundtrip = readEnvFile();
if (roundtrip.size !== expected) {
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
}
} }
/** /**

View File

@@ -5,6 +5,10 @@ const { google } = require('googleapis');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { extractRawEmail, escapeHtml } = require('../utils'); const { extractRawEmail, escapeHtml } = require('../utils');
const { getStaffSignatureBlocks } = require('./staffSignature'); const { getStaffSignatureBlocks } = require('./staffSignature');
const { logError } = require('./debugLog');
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
function getGmailClient() { function getGmailClient() {
const auth = new google.auth.OAuth2( const auth = new google.auth.OAuth2(
@@ -20,8 +24,12 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
const gmail = getGmailClient(); const gmail = getGmailClient();
// Send to the ticket sender (customer), not derived from thread (which can be support) // Send to the ticket sender (customer), not derived from thread (which can be support)
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase(); const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
if (!EMAIL_RE.test(recipientEmail)) {
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
return;
}
let subjectHeader = ticket.subject || 'Support'; let subjectHeader = ticket.subject || 'Support';
let msgId = null; let msgId = null;
@@ -35,13 +43,13 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
if (lastMsg?.payload?.headers) { if (lastMsg?.payload?.headers) {
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) subjectHeader = subj; if (subj) subjectHeader = subj;
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value; msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
} }
} catch (_) { } catch (_) {
/* use ticket.subject and no In-Reply-To if thread fetch fails */ /* use ticket.subject and no In-Reply-To if thread fetch fails */
} }
const finalSubject = `${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`; const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`);
const utf8Subject = `=?utf-8?B?${Buffer.from( const utf8Subject = `=?utf-8?B?${Buffer.from(
finalSubject finalSubject
).toString('base64')}?=`; ).toString('base64')}?=`;
@@ -72,7 +80,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
</div>`; </div>`;
const rawHeaders = [ const rawHeaders = [
`From: ${CONFIG.MY_EMAIL}`, `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`, `To: ${recipientEmail}`,
`Subject: ${utf8Subject}`, `Subject: ${utf8Subject}`,
msgId ? `In-Reply-To: ${msgId}` : '', msgId ? `In-Reply-To: ${msgId}` : '',
@@ -113,8 +121,12 @@ const StaffSignature = mongoose.model('StaffSignature');
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) { async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
try { try {
const gmail = getGmailClient(); const gmail = getGmailClient();
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase(); const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
if (!EMAIL_RE.test(recipientEmail)) {
logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
return;
}
let subjectHeader = ticket.subject || 'Support'; let subjectHeader = ticket.subject || 'Support';
let msgId = null; let msgId = null;
@@ -128,11 +140,11 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
if (lastMsg?.payload?.headers) { if (lastMsg?.payload?.headers) {
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) subjectHeader = subj; if (subj) subjectHeader = subj;
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value; msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
} }
} catch (_) {} } catch (_) {}
const finalSubject = subjectLine || subjectHeader; const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader);
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`; const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support'); const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>'); const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
@@ -169,7 +181,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
</div>`; </div>`;
const rawHeaders = [ const rawHeaders = [
`From: ${CONFIG.MY_EMAIL}`, `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`, `To: ${recipientEmail}`,
`Subject: ${utf8Subject}`, `Subject: ${utf8Subject}`,
msgId ? `In-Reply-To: ${msgId}` : '', msgId ? `In-Reply-To: ${msgId}` : '',
@@ -216,8 +228,16 @@ async function sendGmailReply(
) { ) {
const gmail = getGmailClient(); const gmail = getGmailClient();
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
if (!EMAIL_RE.test(safeRecipient)) {
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
return null;
}
const safeMessageId = sanitizeHeaderValue(messageId);
const safeSubject = sanitizeHeaderValue(`Re: ${subject}`);
const utf8Subject = `=?utf-8?B?${Buffer.from( const utf8Subject = `=?utf-8?B?${Buffer.from(
`Re: ${subject}` safeSubject
).toString('base64')}?=`; ).toString('base64')}?=`;
const safeUser = escapeHtml(discordUser); const safeUser = escapeHtml(discordUser);
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
@@ -229,6 +249,7 @@ async function sendGmailReply(
signatureBlocks = await getStaffSignatureBlocks(userId); signatureBlocks = await getStaffSignatureBlocks(userId);
} }
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : ''; const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = signatureBlocks.text; const safeStaffSigText = signatureBlocks.text;
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>'); const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
@@ -264,11 +285,11 @@ async function sendGmailReply(
plainBody.push(companySignatureText); plainBody.push(companySignatureText);
const raw = Buffer.from([ const raw = Buffer.from([
`From: ${CONFIG.MY_EMAIL}`, `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`, `To: ${safeRecipient}`,
`Subject: ${utf8Subject}`, `Subject: ${utf8Subject}`,
messageId ? `In-Reply-To: ${messageId}` : '', safeMessageId ? `In-Reply-To: ${safeMessageId}` : '',
messageId ? `References: ${messageId}` : '', safeMessageId ? `References: ${safeMessageId}` : '',
'MIME-Version: 1.0', 'MIME-Version: 1.0',
'Content-Type: multipart/alternative; boundary="' + boundary + '"', 'Content-Type: multipart/alternative; boundary="' + boundary + '"',
'', '',

View File

@@ -83,12 +83,15 @@ function onWeeklyReset(fn) {
const firedThresholds = new Map(); const firedThresholds = new Map();
// key -> window type used for threshold clearing ("today" | "week" | "month") // key -> window type used for threshold clearing ("today" | "week" | "month")
const firedThresholdWindows = new Map(); const firedThresholdWindows = new Map();
// key -> last-seen timestamp; drives periodic sweep for keys that outlive their window reset.
const firedThresholdLastSeen = new Map();
function clearFiredThresholdsForWindow(windowType) { function clearFiredThresholdsForWindow(windowType) {
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) { for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
if (mappedWindowType === windowType) { if (mappedWindowType === windowType) {
firedThresholds.delete(key); firedThresholds.delete(key);
firedThresholdWindows.delete(key); firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
} }
} }
} }
@@ -98,6 +101,7 @@ function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) {
if (!['today', 'week', 'month'].includes(windowType)) return null; if (!['today', 'week', 'month'].includes(windowType)) return null;
firedThresholdWindows.set(key, windowType); firedThresholdWindows.set(key, windowType);
firedThresholdLastSeen.set(key, Date.now());
const firedForKey = firedThresholds.get(key) || new Set(); const firedForKey = firedThresholds.get(key) || new Set();
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b); const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
@@ -148,18 +152,49 @@ function clearEscalating(key) {
escalatingCooldowns.delete(key); escalatingCooldowns.delete(key);
} }
const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000; const SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function cleanupStaleEscalatingCooldowns() { function cleanupStaleEscalatingCooldowns(now = Date.now()) {
const cutoff = Date.now() - ESCALATING_COOLDOWN_TTL_MS; const cutoff = now - SWEEP_TTL_MS;
for (const [key, state] of escalatingCooldowns.entries()) { for (const [key, state] of escalatingCooldowns.entries()) {
const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0; const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0;
if (lastUsed < cutoff) escalatingCooldowns.delete(key); if (lastUsed < cutoff) escalatingCooldowns.delete(key);
} }
} }
setInterval(cleanupStaleEscalatingCooldowns, ESCALATING_CLEANUP_INTERVAL_MS).unref?.(); // Sweep every per-Map timestamp-bearing entry older than SWEEP_TTL_MS.
// firedThresholds/firedThresholdWindows are cleared by windowType-resets;
// this sweep covers keys whose window never resets under load.
function sweepPatternStore(now = Date.now()) {
const cutoff = now - SWEEP_TTL_MS;
for (const [key, ts] of cooldowns.entries()) {
if (ts < cutoff) cooldowns.delete(key);
}
for (const [key, ts] of staffLastSeen.entries()) {
if (ts < cutoff) staffLastSeen.delete(key);
}
cleanupStaleEscalatingCooldowns(now);
for (const [key, ts] of firedThresholdLastSeen.entries()) {
if (ts < cutoff) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
}
}
}
/**
* Register the module's sweep on the given trackInterval function.
* Called once from the ready handler. Interval is unref'd so it never
* blocks shutdown; trackInterval ensures handleShutdown clears it.
*/
function startSweeps(trackInterval) {
const handle = setInterval(() => sweepPatternStore(), SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
function scheduleDailyReset() { function scheduleDailyReset() {
setTimeout(() => { setTimeout(() => {
@@ -243,5 +278,9 @@ module.exports = {
isOnCooldown, isOnCooldown,
updateStaffLastSeen, updateStaffLastSeen,
getStaffLastSeen, getStaffLastSeen,
isStaffRecentlyActive isStaffRecentlyActive,
startSweeps,
sweepPatternStore,
// test-only exports
_internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS }
}; };

View File

@@ -79,6 +79,7 @@ async function deleteStaffChannel(guild, staffChannelId) {
if (!staffChannelId) return; if (!staffChannelId) return;
try { try {
const chan = await guild.channels.fetch(staffChannelId).catch(() => null); const chan = await guild.channels.fetch(staffChannelId).catch(() => null);
// TODO(queue-migrate): raw channel.delete bypasses channelQueue (enqueueDelete) — if a staff-channel send is in-flight, this can race it.
if (chan) await chan.delete(); if (chan) await chan.delete();
} catch (e) { } catch (e) {
console.error('Failed to delete staff channel:', e); console.error('Failed to delete staff channel:', e);

View File

@@ -25,6 +25,23 @@ const StaffNotification = mongoose.model('StaffNotification');
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp // In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
const replyCooldowns = new Map(); const replyCooldowns = new Map();
const REPLY_COOLDOWN_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const REPLY_COOLDOWN_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function sweepReplyCooldowns(now = Date.now()) {
const cutoff = now - REPLY_COOLDOWN_SWEEP_TTL_MS;
for (const [key, ts] of replyCooldowns.entries()) {
if (ts < cutoff) replyCooldowns.delete(key);
}
}
function startSweeps(trackInterval) {
const handle = setInterval(() => sweepReplyCooldowns(), REPLY_COOLDOWN_SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
/** /**
* Notify the claiming staff member when a non-staff user replies. * Notify the claiming staff member when a non-staff user replies.
* Respects the staff member's cooldownHours setting (default 1h). * Respects the staff member's cooldownHours setting (default 1h).
@@ -72,11 +89,13 @@ async function notifyAllStaffUnclaimed(client) {
const sorted = [...thresholds].sort((a, b) => a - b); const sorted = [...thresholds].sort((a, b) => a - b);
const now = Date.now(); const now = Date.now();
// Bounded per-tick: oldest-first, capped at 500. A backlog larger than 500
// gets drained in subsequent 30-minute ticks rather than one long run.
const unclaimedTickets = await Ticket.find({ const unclaimedTickets = await Ticket.find({
status: 'open', status: 'open',
claimedBy: null, claimedBy: null,
createdAt: { $ne: null } createdAt: { $ne: null }
}).lean(); }).sort({ createdAt: 1 }).limit(500).lean();
if (unclaimedTickets.length === 0) return; if (unclaimedTickets.length === 0) return;
@@ -103,8 +122,7 @@ async function notifyAllStaffUnclaimed(client) {
const channelName = ticket.discordThreadId const channelName = ticket.discordThreadId
? `<#${ticket.discordThreadId}>` ? `<#${ticket.discordThreadId}>`
: `ticket #${ticket.ticketNumber}`; : `ticket #${ticket.ticketNumber}`;
const hoursAgo = Math.floor(ageHours); const alertMsg = `[${highest}h+ unclaimed] ${channelName}`;
const alertMsg = `Unclaimed ticket alert: ${channelName} has been unclaimed for ${hoursAgo}+ hour(s) (${highest}h threshold).`;
for (const rec of staffRecords) { for (const rec of staffRecords) {
const chan = await guild.channels.fetch(rec.channelId).catch(() => null); const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
@@ -122,4 +140,10 @@ async function notifyAllStaffUnclaimed(client) {
} }
} }
module.exports = { notifyStaffOfReply, notifyAllStaffUnclaimed }; module.exports = {
notifyStaffOfReply,
notifyAllStaffUnclaimed,
startSweeps,
sweepReplyCooldowns,
_internals: { replyCooldowns, REPLY_COOLDOWN_SWEEP_TTL_MS }
};

View File

@@ -1,4 +1,5 @@
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { escapeHtml } = require('../utils');
/** /**
* Returns { text, html } for a staff member's signature. * Returns { text, html } for a staff member's signature.
@@ -17,7 +18,7 @@ async function getStaffSignatureBlocks(userId) {
if (sig.tagline) lines.push(sig.tagline); if (sig.tagline) lines.push(sig.tagline);
const text = lines.join('\n'); const text = lines.join('\n');
const html = lines.map(l => `<div>${l}</div>`).join(''); const html = lines.map(l => `<div>${escapeHtml(l)}</div>`).join('');
return { text, html }; return { text, html };
} }

View File

@@ -7,7 +7,7 @@ const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils'); const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog'); const { logAutomation } = require('../services/debugLog');
const { enqueueSend } = require('./channelQueue'); const { enqueueSend, enqueueDelete } = require('./channelQueue');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter'); const TicketCounter = mongoose.model('TicketCounter');
@@ -104,6 +104,26 @@ function minutesFromMs(ms) {
const ticketCreationByUser = new Map(); // userId -> { count, resetAt } const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function sweepTicketCreationByUser(now = Date.now()) {
// An entry is stale when its window has been expired long enough that no
// legitimate rate-limit decision would still consult it. resetAt is a future
// ms timestamp when the window ends; cutoff is 48h past that.
const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS;
for (const [key, entry] of ticketCreationByUser.entries()) {
if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key);
}
}
function startTicketsSweeps(trackInterval) {
const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
/** /**
* Check if the user can create a ticket (rate limit). If allowed, consumes one slot. * Check if the user can create a ticket (rate limit). If allowed, consumes one slot.
* @param {string} userId - Discord user ID * @param {string} userId - Discord user ID
@@ -436,10 +456,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
if (!CONFIG.AUTO_CLOSE_ENABLED) return; if (!CONFIG.AUTO_CLOSE_ENABLED) return;
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000)); const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
// Bounded per-tick so a huge backlog drains across successive hourly runs.
const staleTickets = await withRetry(() => Ticket.find({ const staleTickets = await withRetry(() => Ticket.find({
status: 'open', status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null } lastActivity: { $lt: cutoffTime, $ne: null }
}).lean()); }).sort({ createdAt: 1 }).limit(500).lean());
let checked = 0, closed = 0; let checked = 0, closed = 0;
for (const ticket of staleTickets) { for (const ticket of staleTickets) {
@@ -452,14 +473,24 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
if (channel) { if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
// resolves; if the doc is gone the unset is a no-op.
await withRetry(() => Ticket.updateOne( await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed' } } { $set: { status: 'closed', pendingDelete: true } }
)); ));
await sendTicketClosedEmail(ticket, 'Auto-Close System'); await sendTicketClosedEmail(ticket, 'Auto-Close System');
setTimeout(() => channel.delete().catch(() => {}), 5000); setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000);
closed++; closed++;
} }
} catch (error) { } catch (error) {
@@ -551,10 +582,11 @@ async function reconcileDeletedTicketChannels(client) {
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return { checked: 0, reconciled: 0 }; if (!guild) return { checked: 0, reconciled: 0 };
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({ const openTickets = await Ticket.find({
status: 'open', status: 'open',
discordThreadId: { $ne: null } discordThreadId: { $ne: null }
}).lean(); }).sort({ createdAt: 1 }).limit(500).lean();
let checked = 0, reconciled = 0; let checked = 0, reconciled = 0;
for (const ticket of openTickets) { for (const ticket of openTickets) {
@@ -582,9 +614,39 @@ async function reconcileDeletedTicketChannels(client) {
return { checked, reconciled }; return { checked, reconciled };
} }
/**
* Resume deletes that were pending when the bot last shut down. Called once
* from the ready handler. Clears the flag regardless of fetch result so a
* stale flag (e.g. channel already gone) can't loop.
*/
async function resumePendingDeletes(client) {
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
if (!pending.length) return 0;
let resumed = 0;
for (const ticket of pending) {
try {
const guild = client.guilds.cache.first();
if (guild && ticket.discordThreadId) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
enqueueDelete(channel).catch(() => {});
resumed++;
}
}
Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
).catch(() => {});
} catch (e) {
console.error('resumePendingDeletes error:', e);
}
}
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
return resumed;
}
module.exports = { module.exports = {
getNextTicketNumber, getNextTicketNumber,
pickTicketCategoryId,
getOrCreateTicketCategory, getOrCreateTicketCategory,
cleanupEmptyOverflowCategory, cleanupEmptyOverflowCategory,
createDiscordTicketAsThread, createDiscordTicketAsThread,
@@ -605,5 +667,9 @@ module.exports = {
checkAutoClose, checkAutoClose,
checkReminders, checkReminders,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels reconcileDeletedTicketChannels,
resumePendingDeletes,
startTicketsSweeps,
sweepTicketCreationByUser,
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
}; };

View File

@@ -4,6 +4,11 @@ services:
container_name: broccolini-settings container_name: broccolini-settings
restart: unless-stopped restart: unless-stopped
env_file: ../.env env_file: ../.env
environment:
# Node must bind all-interfaces inside the container so Docker's DNAT
# from the host-side Tailscale publish below can reach it. The Tailscale
# restriction is enforced by the `ports:` binding, not by Node.
SETTINGS_BIND_HOST: "0.0.0.0"
ports: ports:
- "100.114.205.53:12752:12752" - "100.114.205.53:12752:12752"
networks: networks:

View File

@@ -6,6 +6,14 @@ const helmet = require('helmet');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { doubleCsrf } = require('csrf-csrf'); const { doubleCsrf } = require('csrf-csrf');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
// Mirror of safeEqual() in ../utils.js — duplicated here because the settings-site Docker build context excludes the parent dir.
function safeEqual(a, b) {
const ab = Buffer.from(String(a || ''), 'utf8');
const bb = Buffer.from(String(b || ''), 'utf8');
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
const app = express(); const app = express();
const PORT = parseInt(process.env.SETTINGS_PORT) || 12752; const PORT = parseInt(process.env.SETTINGS_PORT) || 12752;
@@ -57,7 +65,7 @@ app.use(express.urlencoded({ extended: true, limit: '64kb' }));
app.use(session({ app.use(session({
secret: SESSION_SECRET, secret: SESSION_SECRET,
resave: false, resave: false,
saveUninitialized: true, saveUninitialized: false,
cookie: { cookie: {
httpOnly: true, httpOnly: true,
secure: IS_PROD, secure: IS_PROD,
@@ -111,7 +119,12 @@ async function callBot(method, apiPath, body) {
}, },
body: body ? JSON.stringify(body) : undefined body: body ? JSON.stringify(body) : undefined
}); });
return res.json(); const text = await res.text();
try {
return JSON.parse(text);
} catch {
return { error: 'bad_upstream', status: res.status, body: text.slice(0, 500) };
}
} }
function proxy(method, botPath) { function proxy(method, botPath) {
@@ -162,7 +175,7 @@ app.get('/login', (req, res) => {
}); });
app.post('/login', loginLimiter, (req, res) => { app.post('/login', loginLimiter, (req, res) => {
if (req.body.password === ADMIN_PASSWORD) { if (safeEqual(req.body.password, ADMIN_PASSWORD)) {
req.session.authed = true; req.session.authed = true;
return res.json({ ok: true }); return res.json({ ok: true });
} }
@@ -197,6 +210,14 @@ app.use((err, req, res, next) => {
next(err); next(err);
}); });
app.listen(PORT, '0.0.0.0', () => { // Default bind is loopback. Production runs in Docker with
console.log(`[settings] running on port ${PORT}`); // docker-compose.yml publishing `100.114.205.53:12752:12752` — that host-side
// publish is what restricts ingress to the Tailscale IP, and the container
// sets SETTINGS_BIND_HOST=0.0.0.0 so Docker's DNAT can reach Node. Caddy lives
// on a separate host (rustdesk droplet) and reaches this box over Tailscale,
// so 127.0.0.1 is not reachable from Caddy in prod; do not change that
// default without updating docker-compose.yml in lockstep.
const BIND_HOST = process.env.SETTINGS_BIND_HOST || '127.0.0.1';
app.listen(PORT, BIND_HOST, () => {
console.log(`[settings] running on ${BIND_HOST}:${PORT}`);
}); });

View File

@@ -2,8 +2,24 @@
* Pure utility functions text processing, date formatting, game detection, * Pure utility functions text processing, date formatting, game detection,
* priority helpers, template variables. * priority helpers, template variables.
*/ */
const crypto = require('crypto');
const { CONFIG, GAME_NAMES, GAME_ALIASES, TICKET_TAGS } = require('./config'); const { CONFIG, GAME_NAMES, GAME_ALIASES, TICKET_TAGS } = require('./config');
/** Constant-time string compare. Returns false for mismatched length or empty/nullish inputs without throwing. */
function safeEqual(a, b) {
const ab = Buffer.from(String(a || ''), 'utf8');
const bb = Buffer.from(String(b || ''), 'utf8');
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
/** True if the member holds ROLE_ID_TO_PING or any ADDITIONAL_STAFF_ROLES. Safe for null/undefined members. */
function isStaff(member) {
if (!member?.roles?.cache) return false;
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
return additional.some(roleId => member.roles.cache.has(roleId));
}
// --- TEXT PROCESSING --- // --- TEXT PROCESSING ---
const BLOCK_TAG_REGEX = const BLOCK_TAG_REGEX =
@@ -116,10 +132,8 @@ function stripEmailQuotes(text) {
return cleaned.trim(); return cleaned.trim();
} }
function stripMobileFooter(text) { // Hoisted to module scope: constructed once at load, not per-call.
if (!text) return text; const MOBILE_FOOTER_REGEXES = [
const patterns = [
/Sent from my iPhone/i, /Sent from my iPhone/i,
/Sent from my iPad/i, /Sent from my iPad/i,
/Sent from my Apple Watch/i, /Sent from my Apple Watch/i,
@@ -141,15 +155,14 @@ function stripMobileFooter(text) {
/Get\s+Outlook\s+for\s+iOS/i, /Get\s+Outlook\s+for\s+iOS/i,
/Get\s+Outlook\s+for\s+Android/i, /Get\s+Outlook\s+for\s+Android/i,
/Sent with Proton Mail secure email\./i /Sent with Proton Mail secure email\./i
]; ].map(re => new RegExp(`\\n*${re.source}\\s*`, 'i'));
function stripMobileFooter(text) {
if (!text) return text;
let result = text; let result = text;
for (const rx of MOBILE_FOOTER_REGEXES) {
for (const re of patterns) {
const rx = new RegExp(`\\n*${re.source}\\s*`, 'i');
result = result.replace(rx, ''); result = result.replace(rx, '');
} }
return result; return result;
} }
@@ -186,22 +199,25 @@ const getFormattedDate = () => {
}; };
// --- GAME DETECTION --- // --- GAME DETECTION ---
// Map<lowercase-alias, { canonical, re }> built once at module load so detectGame
// doesn't allocate a fresh RegExp per game/alias per call.
const GAME_DETECTION = (() => {
const m = new Map();
const add = (key, canonical) => {
const lower = String(key).toLowerCase();
if (m.has(lower)) return;
m.set(lower, { canonical, re: new RegExp(`\\b${escapeRegex(lower)}\\b`, 'i') });
};
for (const game of GAME_NAMES) add(game, game);
for (const [alias, fullName] of Object.entries(GAME_ALIASES)) add(alias, fullName);
return m;
})();
const detectGame = (subject, body) => { const detectGame = (subject, body) => {
const txt = `${subject} ${body}`.toLowerCase(); const txt = `${subject} ${body}`.toLowerCase();
for (const { re, canonical } of GAME_DETECTION.values()) {
for (const game of GAME_NAMES) { if (re.test(txt)) return canonical;
const g = game.toLowerCase();
const re = new RegExp(`\\b${escapeRegex(g)}\\b`, 'i');
if (re.test(txt)) return game;
} }
for (const [alias, fullName] of Object.entries(GAME_ALIASES)) {
const a = alias.toLowerCase();
const re = new RegExp(`\\b${escapeRegex(a)}\\b`, 'i');
if (re.test(txt)) return fullName;
}
return 'Not Mentioned'; return 'Not Mentioned';
}; };
@@ -378,6 +394,8 @@ module.exports = {
BLOCK_TAG_REGEX, BLOCK_TAG_REGEX,
escapeRegex, escapeRegex,
escapeHtml, escapeHtml,
safeEqual,
isStaff,
decodeHtmlEntities, decodeHtmlEntities,
htmlToTextWithBlocks, htmlToTextWithBlocks,
decodeGmailData, decodeGmailData,