audit
This commit is contained in:
@@ -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_ESCALATION_MESSAGE_ENABLED=false # Auto-pin escalation messages
|
||||
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_PORT=12752 # Port for the settings web UI
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -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 test-mongodb` / `test-mongodb:test` — connectivity probe; no test suite exists.
|
||||
- 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.
|
||||
|
||||
@@ -41,7 +42,7 @@ Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 (MongoDB Atlas)
|
||||
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
@@ -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}`.
|
||||
- **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
|
||||
- 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.
|
||||
@@ -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.
|
||||
- `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 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/` 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`.
|
||||
|
||||
@@ -17,7 +17,7 @@ const { handleDiscordReply } = require('./handlers/messages');
|
||||
|
||||
// Services & jobs
|
||||
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 { registerCommands } = require('./commands/register');
|
||||
const bosscordRoutes = require('./routes/bosscord');
|
||||
@@ -37,6 +37,12 @@ function trackInterval(handle) {
|
||||
if (handle) activeIntervals.add(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.
|
||||
@@ -284,8 +290,15 @@ client.once('ready', async () => {
|
||||
|
||||
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
|
||||
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');
|
||||
|
||||
// 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) {
|
||||
console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.');
|
||||
}
|
||||
@@ -329,8 +342,8 @@ internalApp.use('/internal', internalApi);
|
||||
let httpServer = null;
|
||||
let internalServer = null;
|
||||
if (CONFIG.INTERNAL_API_SECRET) {
|
||||
internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '0.0.0.0', () => {
|
||||
console.log(`[internalApi] listening on 0.0.0.0:${CONFIG.INTERNAL_API_PORT}`);
|
||||
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 {
|
||||
console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.');
|
||||
@@ -349,6 +362,10 @@ async function handleShutdown(signal) {
|
||||
try { clearInterval(handle); } catch (_) {}
|
||||
}
|
||||
activeIntervals.clear();
|
||||
for (const handle of activeTimeouts) {
|
||||
try { clearTimeout(handle); } catch (_) {}
|
||||
}
|
||||
activeTimeouts.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 (_) {}
|
||||
@@ -366,6 +383,7 @@ module.exports = {
|
||||
client,
|
||||
setGmailPollInterval,
|
||||
clearGmailPollInterval,
|
||||
trackTimeout,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
getNextTicketNumber,
|
||||
|
||||
90
config.js
90
config.js
@@ -70,6 +70,11 @@ const DEFAULT_NOTIFICATION_THRESHOLDS = {
|
||||
chat_time: ['30m', '1h', '2h', '4h']
|
||||
};
|
||||
|
||||
function toInt(v, fallback) {
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function parseThresholdString(str) {
|
||||
const value = String(str || '').trim();
|
||||
if (!value) return NaN;
|
||||
@@ -136,7 +141,7 @@ const CONFIG = {
|
||||
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
|
||||
LOGO_URL: process.env.LOGO_URL,
|
||||
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
|
||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '<br>'),
|
||||
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_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_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.',
|
||||
GLOBAL_TICKET_LIMIT: parseInt(process.env.GLOBAL_TICKET_LIMIT) || 5,
|
||||
TICKET_LIMIT_PER_CATEGORY: parseInt(process.env.TICKET_LIMIT_PER_CATEGORY) || 3,
|
||||
RATE_LIMIT_TICKETS_PER_USER: parseInt(process.env.RATE_LIMIT_TICKETS_PER_USER) || 0,
|
||||
RATE_LIMIT_WINDOW_MINUTES: parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES) || 60,
|
||||
GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5),
|
||||
TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3),
|
||||
RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0),
|
||||
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),
|
||||
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_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} ☀️',
|
||||
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.',
|
||||
PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true',
|
||||
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
|
||||
@@ -177,9 +182,9 @@ const CONFIG = {
|
||||
PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡',
|
||||
PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟢',
|
||||
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_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',
|
||||
USE_THREADS: process.env.USE_THREADS === 'true',
|
||||
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_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌',
|
||||
BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓',
|
||||
EMBED_COLOR_OPEN: parseInt(process.env.EMBED_COLOR_OPEN) || 0x00FF00,
|
||||
EMBED_COLOR_CLOSED: parseInt(process.env.EMBED_COLOR_CLOSED) || 0xFF0000,
|
||||
EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00,
|
||||
EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600,
|
||||
EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124,
|
||||
EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00),
|
||||
EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000),
|
||||
EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
|
||||
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
|
||||
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
|
||||
STAFF_CATEGORIES: new Map(), // deprecated – kept for staffChannel.js compat
|
||||
STAFF_EMOJIS: (() => {
|
||||
const raw = process.env.STAFF_EMOJIS;
|
||||
@@ -213,8 +218,8 @@ const CONFIG = {
|
||||
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
||||
ADMIN_ID: process.env.ADMIN_ID || null,
|
||||
STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null,
|
||||
FORCE_CLOSE_TIMER: parseInt(process.env.FORCE_CLOSE_TIMER_SECONDS) || 60,
|
||||
GMAIL_POLL_INTERVAL_MS: parseInt(process.env.GMAIL_POLL_INTERVAL_SECONDS || '30') * 1000,
|
||||
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
|
||||
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
|
||||
GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_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,
|
||||
@@ -226,35 +231,35 @@ const CONFIG = {
|
||||
ESCALATION_PATTERNS_CHANNEL_ID: process.env.ESCALATION_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,
|
||||
PATTERN_USER_TICKET_THRESHOLD: parseInt(process.env.PATTERN_USER_TICKET_THRESHOLD) || 3,
|
||||
PATTERN_GAME_TICKET_THRESHOLD: parseInt(process.env.PATTERN_GAME_TICKET_THRESHOLD) || 10,
|
||||
PATTERN_STAFF_STALE_PING_THRESHOLD: parseInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD) || 5,
|
||||
PATTERN_ESCALATION_THRESHOLD: parseInt(process.env.PATTERN_ESCALATION_THRESHOLD) || 3,
|
||||
PATTERN_RAPID_CLOSE_SECONDS: parseInt(process.env.PATTERN_RAPID_CLOSE_SECONDS) || 120,
|
||||
PATTERN_UNCLAIMED_HOURS: parseInt(process.env.PATTERN_UNCLAIMED_HOURS) || 4,
|
||||
PATTERN_CHECK_INTERVAL_MINUTES: parseInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES) || 30,
|
||||
PATTERN_USER_TICKET_THRESHOLD: toInt(process.env.PATTERN_USER_TICKET_THRESHOLD, 3),
|
||||
PATTERN_GAME_TICKET_THRESHOLD: toInt(process.env.PATTERN_GAME_TICKET_THRESHOLD, 10),
|
||||
PATTERN_STAFF_STALE_PING_THRESHOLD: toInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD, 5),
|
||||
PATTERN_ESCALATION_THRESHOLD: toInt(process.env.PATTERN_ESCALATION_THRESHOLD, 3),
|
||||
PATTERN_RAPID_CLOSE_SECONDS: toInt(process.env.PATTERN_RAPID_CLOSE_SECONDS, 120),
|
||||
PATTERN_UNCLAIMED_HOURS: toInt(process.env.PATTERN_UNCLAIMED_HOURS, 4),
|
||||
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_CHAT_ALERT_CHANNEL_ID: process.env.ALL_STAFF_CHAT_ALERT_CHANNEL_ID || null,
|
||||
SURGE_ROLE_ID: process.env.SURGE_ROLE_ID || null,
|
||||
SURGE_TICKET_COUNT: parseInt(process.env.SURGE_TICKET_COUNT) || 10,
|
||||
SURGE_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_TICKET_WINDOW_MINUTES) || 30,
|
||||
SURGE_GAME_TICKET_COUNT: parseInt(process.env.SURGE_GAME_TICKET_COUNT) || 5,
|
||||
SURGE_GAME_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES) || 30,
|
||||
SURGE_STALE_COUNT: parseInt(process.env.SURGE_STALE_COUNT) || 8,
|
||||
SURGE_STALE_HOURS: parseInt(process.env.SURGE_STALE_HOURS) || 2,
|
||||
SURGE_NEEDS_RESPONSE_COUNT: parseInt(process.env.SURGE_NEEDS_RESPONSE_COUNT) || 5,
|
||||
SURGE_NEEDS_RESPONSE_HOURS: parseInt(process.env.SURGE_NEEDS_RESPONSE_HOURS) || 1,
|
||||
SURGE_UNCLAIMED_COUNT: parseInt(process.env.SURGE_UNCLAIMED_COUNT) || 5,
|
||||
SURGE_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_UNCLAIMED_MINUTES) || 30,
|
||||
SURGE_TIER3_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES) || 15,
|
||||
SURGE_COOLDOWN_MINUTES: parseInt(process.env.SURGE_COOLDOWN_MINUTES) || 60,
|
||||
SURGE_TICKET_COUNT: toInt(process.env.SURGE_TICKET_COUNT, 10),
|
||||
SURGE_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_TICKET_WINDOW_MINUTES, 30),
|
||||
SURGE_GAME_TICKET_COUNT: toInt(process.env.SURGE_GAME_TICKET_COUNT, 5),
|
||||
SURGE_GAME_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES, 30),
|
||||
SURGE_STALE_COUNT: toInt(process.env.SURGE_STALE_COUNT, 8),
|
||||
SURGE_STALE_HOURS: toInt(process.env.SURGE_STALE_HOURS, 2),
|
||||
SURGE_NEEDS_RESPONSE_COUNT: toInt(process.env.SURGE_NEEDS_RESPONSE_COUNT, 5),
|
||||
SURGE_NEEDS_RESPONSE_HOURS: toInt(process.env.SURGE_NEEDS_RESPONSE_HOURS, 1),
|
||||
SURGE_UNCLAIMED_COUNT: toInt(process.env.SURGE_UNCLAIMED_COUNT, 5),
|
||||
SURGE_UNCLAIMED_MINUTES: toInt(process.env.SURGE_UNCLAIMED_MINUTES, 30),
|
||||
SURGE_TIER3_UNCLAIMED_MINUTES: toInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES, 15),
|
||||
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_MESSAGE_COUNT: parseInt(process.env.CHAT_ALERT_MESSAGE_COUNT) || 5,
|
||||
CHAT_ALERT_HOURS_WITHOUT_RESPONSE: parseInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE) || 2,
|
||||
CHAT_ALERT_COOLDOWN_MINUTES: parseInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES) || 60,
|
||||
CHAT_ALERT_MESSAGE_COUNT: toInt(process.env.CHAT_ALERT_MESSAGE_COUNT, 5),
|
||||
CHAT_ALERT_HOURS_WITHOUT_RESPONSE: toInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE, 2),
|
||||
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),
|
||||
SURGE_NO_STAFF_COOLDOWN_MINUTES: parseInt(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_COOLDOWN_MINUTES: toInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES, 30),
|
||||
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_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
|
||||
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,
|
||||
PIN_INITIAL_MESSAGE_ENABLED: process.env.PIN_INITIAL_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',
|
||||
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_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,
|
||||
NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON),
|
||||
UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4')
|
||||
|
||||
@@ -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)
|
||||
- **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)
|
||||
- **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** (button or `/claim`)
|
||||
Ticket is updated with `claimedBy` (user id or name). Channel may be renamed (respecting Discord’s 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`)
|
||||
`claimedBy` is cleared; channel rename and message as above.
|
||||
|
||||
@@ -200,7 +200,7 @@ THREAD_PARENT_CHANNEL=
|
||||
- Optimized background job queries
|
||||
|
||||
### 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
|
||||
- Autocomplete debouncing
|
||||
- Batch command registration
|
||||
|
||||
@@ -447,7 +447,7 @@ THREAD_PARENT_CHANNEL=
|
||||
|
||||
### Rate Limits
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -66,8 +66,8 @@ const Transcript = mongoose.model('Transcript');
|
||||
claimed_by: String (Discord user ID),
|
||||
escalated: Boolean (default: false),
|
||||
ticket_number: Number,
|
||||
rename_count: Number (default: 0),
|
||||
rename_window_start: Date
|
||||
rename_count: Number (default: 0), // orphan: no longer read/written (see CLAUDE.md)
|
||||
rename_window_start: Date // orphan: no longer read/written
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -160,9 +160,13 @@ async function poll(client) {
|
||||
|
||||
if (ticketChan) {
|
||||
const truncatedFollowup = followupBody.slice(0, 1800);
|
||||
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
||||
await enqueueSend(
|
||||
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 {
|
||||
// Check ticket limits before creating
|
||||
@@ -256,7 +260,8 @@ async function poll(client) {
|
||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||||
embeds: [ticketInfoEmbed],
|
||||
components: [buttons]
|
||||
components: [buttons],
|
||||
allowedMentions: { parse: ['roles'] }
|
||||
});
|
||||
|
||||
const { createStaffThread } = require('./services/staffThread');
|
||||
@@ -311,7 +316,8 @@ async function poll(client) {
|
||||
}
|
||||
|
||||
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.
|
||||
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
|
||||
@@ -369,7 +375,7 @@ async function poll(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);
|
||||
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
|
||||
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
|
||||
if (CONFIG.ADMIN_ID && !authErrorNotified) {
|
||||
authErrorNotified = true;
|
||||
@@ -379,7 +385,7 @@ async function poll(client) {
|
||||
|
||||
totalErrors++;
|
||||
console.error('POLL ERROR:', e);
|
||||
logError('Gmail poll', e, null, client);
|
||||
logError('Gmail poll', e, null, client).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
isPolling = false;
|
||||
|
||||
@@ -7,6 +7,7 @@ const { CONFIG } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { logSecurity } = require('../services/debugLog');
|
||||
const { enqueueSend } = require('../services/channelQueue');
|
||||
const { isStaff } = require('../utils');
|
||||
|
||||
const User = mongoose.model('User');
|
||||
|
||||
@@ -134,6 +135,13 @@ async function handleAccountInfoCommand(interaction) {
|
||||
async function handleSendAccountInfoToChannel(interaction) {
|
||||
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 [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const { runEscalation, runDeescalation } = require('./commands');
|
||||
const { trackInteraction, trackError } = require('./analytics');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { increment } = require('../services/patternStore');
|
||||
const { logError } = require('../services/debugLog');
|
||||
const { logError, logSystem } = require('../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
@@ -497,8 +497,10 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
});
|
||||
}
|
||||
|
||||
// DM the transcript to the ticket creator (Discord-originated tickets)
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
// DM the transcript to the ticket creator (Discord-originated tickets).
|
||||
// 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();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
@@ -515,7 +517,15 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
files: [dmFile]
|
||||
});
|
||||
} 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;
|
||||
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({
|
||||
name: unclaimedName,
|
||||
type: ChannelType.GuildText,
|
||||
|
||||
@@ -440,6 +440,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
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, {
|
||||
ViewChannel: true,
|
||||
SendMessages: true,
|
||||
@@ -462,6 +463,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
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.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
@@ -524,6 +526,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
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.reply(`Moved ticket to **${category.name}**.`);
|
||||
|
||||
@@ -711,6 +714,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO(queue-migrate): setTopic bypasses channelQueue — could race a pending rename/send on the same channel.
|
||||
await interaction.channel.setTopic(text);
|
||||
await interaction.reply('Topic updated successfully.');
|
||||
} catch (err) {
|
||||
@@ -1085,19 +1089,46 @@ async function handleCommand(interaction) {
|
||||
return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.');
|
||||
}
|
||||
try {
|
||||
const tickets = await Ticket.find().sort({ ticketNumber: 1 }).lean();
|
||||
const lines = ['# Ticket backup – ' + new Date().toISOString(), 'ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier'];
|
||||
for (const t of tickets) {
|
||||
// Stream every ticket through a Mongoose cursor to a tmp file so peak RSS
|
||||
// stays bounded regardless of collection size; attach the file, then unlink.
|
||||
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() : '';
|
||||
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);
|
||||
await enqueueSend(channel, {
|
||||
content: `Ticket backup by ${interaction.user.tag} (${tickets.length} tickets)`,
|
||||
files: [new AttachmentBuilder(buf, { name: `ticket-backup-${Date.now()}.txt` })]
|
||||
content: `Ticket backup by ${interaction.user.tag} (${count} tickets)`,
|
||||
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) {
|
||||
trackError('backup-command', err, interaction);
|
||||
await interaction.editReply('Failed to create backup: ' + (err.message || err));
|
||||
|
||||
@@ -817,7 +817,8 @@ const ticketSchema = new mongoose.Schema({
|
||||
staffChannelId: String,
|
||||
parentCategoryId: String,
|
||||
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({ senderEmail: 1, status: 1 });
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { extractRawEmail, safeEqual } = require('../utils');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -43,8 +43,9 @@ function authMiddleware(req, res, next) {
|
||||
}
|
||||
const auth = req.headers.authorization;
|
||||
const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
|
||||
if (token !== key) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
// Identical response body for missing vs invalid token — don't tell a probe which state it's in.
|
||||
if (!safeEqual(token, key)) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
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' });
|
||||
}
|
||||
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-')) {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { ChannelType } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { safeEqual } = require('../utils');
|
||||
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
||||
const { logSystem } = require('../services/debugLog');
|
||||
const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry');
|
||||
@@ -15,6 +16,7 @@ const {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Intentionally no trust-proxy: loopback-only; global rate-limit bucket.
|
||||
const internalLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
@@ -28,7 +30,7 @@ router.use(internalLimiter);
|
||||
// Middleware: verify internal secret
|
||||
router.use((req, res, next) => {
|
||||
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' });
|
||||
}
|
||||
next();
|
||||
@@ -136,10 +138,13 @@ router.post('/restart', express.json(), (req, res) => {
|
||||
const delay = new Date(scheduledFor).getTime() - Date.now();
|
||||
if (delay <= 0) return res.status(400).json({ error: 'Scheduled time is in the past' });
|
||||
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...');
|
||||
process.exit(0);
|
||||
}, delay);
|
||||
}, delay));
|
||||
if (scheduledRestart && typeof scheduledRestart.unref === 'function') scheduledRestart.unref();
|
||||
res.json({ ok: true, mode, scheduledFor, delayMs: delay });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,4 +129,32 @@ function enqueueSend(channel, ...args) {
|
||||
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 };
|
||||
|
||||
@@ -7,62 +7,155 @@ const ENV_PATH = process.env.ENV_FILE
|
||||
? path.resolve(process.env.ENV_FILE)
|
||||
: 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.
|
||||
* 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() {
|
||||
if (!fs.existsSync(ENV_PATH)) return new Map();
|
||||
const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n');
|
||||
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();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
map.set(key, value);
|
||||
let value = line.slice(idx + 1);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const expected = updates.size;
|
||||
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
const raw = fs.readFileSync(ENV_PATH, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
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();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
if (!trimmed || trimmed.startsWith('#')) { result.push(line); continue; }
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) return line;
|
||||
if (idx === -1) { result.push(line); continue; }
|
||||
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)) {
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
const roundtrip = readEnvFile();
|
||||
if (roundtrip.size !== expected) {
|
||||
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a flat object of { KEY: value } to both CONFIG and .env.
|
||||
* Returns { applied: string[], errors: Array<{ key, error }> }.
|
||||
|
||||
@@ -5,6 +5,10 @@ const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
||||
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() {
|
||||
const auth = new google.auth.OAuth2(
|
||||
@@ -20,8 +24,12 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
// 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 (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
@@ -35,13 +43,13 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
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 (_) {
|
||||
/* 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(
|
||||
finalSubject
|
||||
).toString('base64')}?=`;
|
||||
@@ -72,7 +80,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
@@ -113,8 +121,12 @@ const StaffSignature = mongoose.model('StaffSignature');
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase();
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
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 msgId = null;
|
||||
@@ -128,11 +140,11 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
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 (_) {}
|
||||
|
||||
const finalSubject = subjectLine || subjectHeader;
|
||||
const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
|
||||
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
|
||||
@@ -169,7 +181,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
@@ -216,8 +228,16 @@ async function sendGmailReply(
|
||||
) {
|
||||
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(
|
||||
`Re: ${subject}`
|
||||
safeSubject
|
||||
).toString('base64')}?=`;
|
||||
const safeUser = escapeHtml(discordUser);
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
@@ -229,6 +249,7 @@ async function sendGmailReply(
|
||||
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 safeStaffSigText = signatureBlocks.text;
|
||||
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
@@ -264,11 +285,11 @@ async function sendGmailReply(
|
||||
plainBody.push(companySignatureText);
|
||||
|
||||
const raw = Buffer.from([
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${safeRecipient}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
messageId ? `In-Reply-To: ${messageId}` : '',
|
||||
messageId ? `References: ${messageId}` : '',
|
||||
safeMessageId ? `In-Reply-To: ${safeMessageId}` : '',
|
||||
safeMessageId ? `References: ${safeMessageId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/alternative; boundary="' + boundary + '"',
|
||||
'',
|
||||
|
||||
@@ -83,12 +83,15 @@ function onWeeklyReset(fn) {
|
||||
const firedThresholds = new Map();
|
||||
// key -> window type used for threshold clearing ("today" | "week" | "month")
|
||||
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) {
|
||||
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
|
||||
if (mappedWindowType === windowType) {
|
||||
firedThresholds.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;
|
||||
|
||||
firedThresholdWindows.set(key, windowType);
|
||||
firedThresholdLastSeen.set(key, Date.now());
|
||||
|
||||
const firedForKey = firedThresholds.get(key) || new Set();
|
||||
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
|
||||
@@ -148,18 +152,49 @@ function clearEscalating(key) {
|
||||
escalatingCooldowns.delete(key);
|
||||
}
|
||||
|
||||
const ESCALATING_COOLDOWN_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
const ESCALATING_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
|
||||
const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function cleanupStaleEscalatingCooldowns() {
|
||||
const cutoff = Date.now() - ESCALATING_COOLDOWN_TTL_MS;
|
||||
function cleanupStaleEscalatingCooldowns(now = Date.now()) {
|
||||
const cutoff = now - SWEEP_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?.();
|
||||
// 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() {
|
||||
setTimeout(() => {
|
||||
@@ -243,5 +278,9 @@ module.exports = {
|
||||
isOnCooldown,
|
||||
updateStaffLastSeen,
|
||||
getStaffLastSeen,
|
||||
isStaffRecentlyActive
|
||||
isStaffRecentlyActive,
|
||||
startSweeps,
|
||||
sweepPatternStore,
|
||||
// test-only exports
|
||||
_internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS }
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@ async function deleteStaffChannel(guild, staffChannelId) {
|
||||
if (!staffChannelId) return;
|
||||
try {
|
||||
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();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete staff channel:', e);
|
||||
|
||||
@@ -25,6 +25,23 @@ const StaffNotification = mongoose.model('StaffNotification');
|
||||
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
|
||||
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.
|
||||
* 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 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({
|
||||
status: 'open',
|
||||
claimedBy: null,
|
||||
createdAt: { $ne: null }
|
||||
}).lean();
|
||||
}).sort({ createdAt: 1 }).limit(500).lean();
|
||||
|
||||
if (unclaimedTickets.length === 0) return;
|
||||
|
||||
@@ -103,8 +122,7 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
const channelName = ticket.discordThreadId
|
||||
? `<#${ticket.discordThreadId}>`
|
||||
: `ticket #${ticket.ticketNumber}`;
|
||||
const hoursAgo = Math.floor(ageHours);
|
||||
const alertMsg = `Unclaimed ticket alert: ${channelName} has been unclaimed for ${hoursAgo}+ hour(s) (${highest}h threshold).`;
|
||||
const alertMsg = `[${highest}h+ unclaimed] ${channelName}`;
|
||||
|
||||
for (const rec of staffRecords) {
|
||||
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 }
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { escapeHtml } = require('../utils');
|
||||
|
||||
/**
|
||||
* Returns { text, html } for a staff member's signature.
|
||||
@@ -17,7 +18,7 @@ async function getStaffSignatureBlocks(userId) {
|
||||
if (sig.tagline) lines.push(sig.tagline);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -7,7 +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 { enqueueSend, enqueueDelete } = require('./channelQueue');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const TicketCounter = mongoose.model('TicketCounter');
|
||||
@@ -104,6 +104,26 @@ function minutesFromMs(ms) {
|
||||
|
||||
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.
|
||||
* @param {string} userId - Discord user ID
|
||||
@@ -436,10 +456,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
|
||||
|
||||
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({
|
||||
status: 'open',
|
||||
lastActivity: { $lt: cutoffTime, $ne: null }
|
||||
}).lean());
|
||||
}).sort({ createdAt: 1 }).limit(500).lean());
|
||||
|
||||
let checked = 0, closed = 0;
|
||||
for (const ticket of staleTickets) {
|
||||
@@ -452,14 +473,24 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
if (channel) {
|
||||
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(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
{ $set: { status: 'closed', pendingDelete: true } }
|
||||
));
|
||||
|
||||
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++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -551,10 +582,11 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
|
||||
if (!guild) return { checked: 0, reconciled: 0 };
|
||||
|
||||
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
|
||||
const openTickets = await Ticket.find({
|
||||
status: 'open',
|
||||
discordThreadId: { $ne: null }
|
||||
}).lean();
|
||||
}).sort({ createdAt: 1 }).limit(500).lean();
|
||||
|
||||
let checked = 0, reconciled = 0;
|
||||
for (const ticket of openTickets) {
|
||||
@@ -582,9 +614,39 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
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 = {
|
||||
getNextTicketNumber,
|
||||
pickTicketCategoryId,
|
||||
getOrCreateTicketCategory,
|
||||
cleanupEmptyOverflowCategory,
|
||||
createDiscordTicketAsThread,
|
||||
@@ -605,5 +667,9 @@ module.exports = {
|
||||
checkAutoClose,
|
||||
checkReminders,
|
||||
checkAutoUnclaim,
|
||||
reconcileDeletedTicketChannels
|
||||
reconcileDeletedTicketChannels,
|
||||
resumePendingDeletes,
|
||||
startTicketsSweeps,
|
||||
sweepTicketCreationByUser,
|
||||
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
|
||||
};
|
||||
|
||||
@@ -4,6 +4,11 @@ services:
|
||||
container_name: broccolini-settings
|
||||
restart: unless-stopped
|
||||
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:
|
||||
- "100.114.205.53:12752:12752"
|
||||
networks:
|
||||
|
||||
@@ -6,6 +6,14 @@ const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { doubleCsrf } = require('csrf-csrf');
|
||||
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 PORT = parseInt(process.env.SETTINGS_PORT) || 12752;
|
||||
@@ -57,7 +65,7 @@ app.use(express.urlencoded({ extended: true, limit: '64kb' }));
|
||||
app.use(session({
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: IS_PROD,
|
||||
@@ -111,7 +119,12 @@ async function callBot(method, apiPath, body) {
|
||||
},
|
||||
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) {
|
||||
@@ -162,7 +175,7 @@ app.get('/login', (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;
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
@@ -197,6 +210,14 @@ app.use((err, req, res, next) => {
|
||||
next(err);
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[settings] running on port ${PORT}`);
|
||||
// Default bind is loopback. Production runs in Docker with
|
||||
// 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}`);
|
||||
});
|
||||
|
||||
60
utils.js
60
utils.js
@@ -2,8 +2,24 @@
|
||||
* Pure utility functions – text processing, date formatting, game detection,
|
||||
* priority helpers, template variables.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
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 ---
|
||||
|
||||
const BLOCK_TAG_REGEX =
|
||||
@@ -116,10 +132,8 @@ function stripEmailQuotes(text) {
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
function stripMobileFooter(text) {
|
||||
if (!text) return text;
|
||||
|
||||
const patterns = [
|
||||
// Hoisted to module scope: constructed once at load, not per-call.
|
||||
const MOBILE_FOOTER_REGEXES = [
|
||||
/Sent from my iPhone/i,
|
||||
/Sent from my iPad/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+Android/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;
|
||||
|
||||
for (const re of patterns) {
|
||||
const rx = new RegExp(`\\n*${re.source}\\s*`, 'i');
|
||||
for (const rx of MOBILE_FOOTER_REGEXES) {
|
||||
result = result.replace(rx, '');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -186,22 +199,25 @@ const getFormattedDate = () => {
|
||||
};
|
||||
|
||||
// --- 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 txt = `${subject} ${body}`.toLowerCase();
|
||||
|
||||
for (const game of GAME_NAMES) {
|
||||
const g = game.toLowerCase();
|
||||
const re = new RegExp(`\\b${escapeRegex(g)}\\b`, 'i');
|
||||
if (re.test(txt)) return game;
|
||||
for (const { re, canonical } of GAME_DETECTION.values()) {
|
||||
if (re.test(txt)) return canonical;
|
||||
}
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
@@ -378,6 +394,8 @@ module.exports = {
|
||||
BLOCK_TAG_REGEX,
|
||||
escapeRegex,
|
||||
escapeHtml,
|
||||
safeEqual,
|
||||
isStaff,
|
||||
decodeHtmlEntities,
|
||||
htmlToTextWithBlocks,
|
||||
decodeGmailData,
|
||||
|
||||
Reference in New Issue
Block a user