diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5946c2a..8ef4c26 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "Bash(node --check config.js)", "Bash(node --check handlers/commands.js)", "Bash(node --check handlers/buttons.js)", - "Bash(node --check gmail-poll.js)" + "Bash(node --check gmail-poll.js)", + "Bash(node --check handlers/pendingCloses.js)", + "Bash(node --check commands/register.js)" ] } } diff --git a/.env.example b/.env.example index 819c41f..99d1ce4 100644 --- a/.env.example +++ b/.env.example @@ -122,6 +122,71 @@ CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry i ADMIN_ID= # Discord user ID of the bot admin (for /staffnotification) STAFF_NOTIFICATION_CATEGORY_ID= # Category for staff notification channels (created by /notification add) UNCLAIMED_REMINDER_THRESHOLDS=1,2,4 # Comma-separated hour thresholds for unclaimed ticket alerts +FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60) +GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30) +GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs +AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs +RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs +SECURITY_LOG_CHANNEL_ID= # Channel for security/audit logs +SYSTEM_LOG_CHANNEL_ID= # Channel for bot lifecycle logs (startup, shutdown, DB events) + +# --- Pattern detection --- +USER_PATTERNS_CHANNEL_ID= # Channel for repeat-user pattern alerts +GAME_PATTERNS_CHANNEL_ID= # Channel for game-specific pattern alerts +TAG_PATTERNS_CHANNEL_ID= # Channel for ticket tag pattern alerts +ESCALATION_PATTERNS_CHANNEL_ID= # Channel for escalation pattern alerts +STAFF_PATTERNS_CHANNEL_ID= # Channel for staff workload pattern alerts +COMBINED_PATTERNS_CHANNEL_ID= # Channel for combined/cross-cutting pattern alerts +PATTERN_USER_TICKET_THRESHOLD=3 # Tickets per user before alerting +PATTERN_GAME_TICKET_THRESHOLD=10 # Tickets per game before alerting +PATTERN_STAFF_STALE_PING_THRESHOLD=5 # Stale pings before alerting +PATTERN_ESCALATION_THRESHOLD=3 # Escalations before alerting +PATTERN_RAPID_CLOSE_SECONDS=120 # Seconds; closes faster than this are flagged +PATTERN_UNCLAIMED_HOURS=4 # Hours unclaimed before flagging +PATTERN_CHECK_INTERVAL_MINUTES=30 # Minutes between pattern check runs + +# --- Surge & chat alerts --- +ALL_STAFF_CHANNEL_ID= # Channel for staff surge alerts +ALL_STAFF_CHAT_ALERT_CHANNEL_ID= # Channel for chat monitoring alerts +SURGE_ROLE_ID= # Role to ping on surge alerts +SURGE_TICKET_COUNT=10 # Ticket count to trigger surge +SURGE_TICKET_WINDOW_MINUTES=30 # Window for ticket surge +SURGE_GAME_TICKET_COUNT=5 # Per-game ticket count for surge +SURGE_GAME_TICKET_WINDOW_MINUTES=30 # Window for game surge +SURGE_STALE_COUNT=8 # Stale tickets to trigger alert +SURGE_STALE_HOURS=2 # Hours before ticket is stale +SURGE_NEEDS_RESPONSE_COUNT=5 # Tickets awaiting response to trigger alert +SURGE_NEEDS_RESPONSE_HOURS=1 # Hours awaiting response +SURGE_UNCLAIMED_COUNT=5 # Unclaimed tickets for surge alert +SURGE_UNCLAIMED_MINUTES=30 # Minutes unclaimed before counting +SURGE_TIER3_UNCLAIMED_MINUTES=15 # Minutes before tier 3 unclaimed alert +SURGE_COOLDOWN_MINUTES=60 # Cooldown between surge alerts +CHAT_ALERT_CHANNEL_IDS= # Comma-separated channel IDs to monitor +CHAT_ALERT_MESSAGE_COUNT=5 # Unresponded messages to trigger alert +CHAT_ALERT_HOURS_WITHOUT_RESPONSE=2 # Hours without staff response to alert +CHAT_ALERT_COOLDOWN_MINUTES=60 # Cooldown between chat alerts +STAFF_IDS= # Comma-separated Discord user IDs of all staff members +SURGE_NO_STAFF_COOLDOWN_MINUTES=30 # Cooldown between zero-staff alerts +SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD=3 # Min open tickets before alerting +STAFF_DND_COUNTS_AS_AVAILABLE=false # Whether DND status counts as available + +# --- Staff threads --- +STAFF_THREAD_ENABLED=false # Create a private staff thread on each ticket channel +STAFF_THREAD_NAME=Staff Discussion # Name of the private thread +STAFF_THREAD_AUTO_ADD_ROLE=false # Auto-add all members of STAFF_THREAD_ROLE_ID to thread on creation +STAFF_THREAD_ROLE_ID= # Role whose members are added to the thread (defaults to ROLE_ID_TO_PING) + +# --- Message pinning --- +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 + +# --- Settings site & internal API --- +SETTINGS_PORT=12752 # Port for the settings web UI +SETTINGS_ADMIN_PASSWORD= # Password to access the settings UI +SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (update when domain changes) +INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally) +INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string) # --- Thread-style tickets (legacy) --- USE_THREADS=false diff --git a/README.md b/README.md index af03407..890ea00 100644 --- a/README.md +++ b/README.md @@ -59,16 +59,18 @@ Built for game-server hosting support (Indifferent Broccoli), with game detectio - **Transcripts** posted to a configured channel; closure email for email tickets. - **Auto-close**, **inactivity reminders**, **auto-unclaim** (all optional via env). -### Staff notifications (optional) +### Staff notifications & alerts (optional) -- **`/notification add`** creates a **dedicated text channel** per staff member under `STAFF_NOTIFICATION_CATEGORY_ID` and stores it in **`StaffNotification`** (MongoDB). -- When a **non-staff** user replies in a ticket **claimed** by someone who has a notification channel, the bot posts an alert there (subject to **per-ticket cooldown** hours, configurable via `/notification set` or admin **`/staffnotification`**). -- A background job runs **every 30 minutes** and, if `UNCLAIMED_REMINDER_THRESHOLDS` is set, posts **unclaimed ticket** digests to those same channels when tickets cross age thresholds. -- **`/notifydm`** toggles optional **DM** alerts to the claimer on customer reply (separate from the notification channel); stored in **`StaffSettings`**. +- **Per-staff notification channels**: **`/notification add`** creates a **dedicated text channel** per staff member under `STAFF_NOTIFICATION_CATEGORY_ID` and stores it in **`StaffNotification`** (MongoDB). When a **non-staff** user replies in a ticket claimed by someone with a notification channel, the bot posts an alert there (subject to **per-ticket cooldown** via `/notification set` or admin **`/staffnotification`**). +- **Unclaimed digests**: a background job runs **every 30 minutes** and, if `UNCLAIMED_REMINDER_THRESHOLDS` is set, posts **unclaimed ticket** digests to those same channels when tickets cross age thresholds. +- **DM reply alerts**: **`/notifydm`** toggles optional **DM** alerts to the claimer on customer reply (separate from the notification channel); stored in **`StaffSettings`**. +- **Staff threads** (optional): when `STAFF_THREAD_ENABLED` is true, each ticket channel can get a private **staff-only thread** named `STAFF_THREAD_NAME`; on claim, the claimer can be added to that thread, and (optionally) all members of `STAFF_THREAD_ROLE_ID` are auto-added. +- **Pins** (optional): `PIN_INITIAL_MESSAGE_ENABLED` and `PIN_ESCALATION_MESSAGE_ENABLED` enable auto-pinning of the ticket welcome message and escalation messages; `PIN_SUPPRESS_SYSTEM_MESSAGE` hides the default “X pinned a message” system notice. +- **Chat monitoring & surge detection**: see [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for automatic alerts about busy chats, surging games, backlogs, and no-staff situations. -See [Staff notification channels](#staff-notification-channels--reply-alerts). +See [Staff notification channels](#staff-notification-channels--reply-alerts) and [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for details. -**Note:** Older docs referred to per-staffer **mirror** channels driven by `STAFF_CATEGORIES`. In current `config.js` that map is **deprecated and always empty**, and **`createStaffChannel` is not called** from the claim flow—**`staffChannelId` on tickets is effectively unused.** Reply alerts use **`StaffNotification`** channels instead. +**Note:** Older docs referred to per-staffer **mirror** channels driven by `STAFF_CATEGORIES`. In current `config.js` that map is **deprecated and always empty**, and **`createStaffChannel` is not called** from the claim flow—**`staffChannelId` on tickets is effectively unused.** Reply alerts use **`StaffNotification`** channels instead, and staff discussion happens in optional **staff threads**. ### Extras @@ -201,6 +203,32 @@ Slash `/escalate` and buttons require the appropriate tier IDs for **non-thread* | `ADMIN_ID` | Discord user ID allowed to use **`/staffnotification`** (override cooldown for another member). | | `UNCLAIMED_REMINDER_THRESHOLDS` | Comma-separated **hours** (e.g. `1,2,4`); drives unclaimed ticket alerts into notification channels. | +### Logging & observability + +| Variable | Description | +|----------|-------------| +| `GMAIL_LOG_CHANNEL_ID` | Channel for Gmail poll activity logs. | +| `AUTOMATION_LOG_CHANNEL_ID` | Channel for auto-close/auto-unclaim/reminder logs. | +| `RENAME_LOG_CHANNEL_ID` | Channel for channel rename queue logs. | +| `SECURITY_LOG_CHANNEL_ID` | Channel for security/audit logs. | +| `SYSTEM_LOG_CHANNEL_ID` | Channel for bot lifecycle logs (startup, shutdown, DB events). | + +### Pattern detection & surge/chat alerts + +Core behaviour is configured via `.env.example`; high level: + +- **Pattern detection** (`patternStore.js`, `patternChecker.js`): + - `USER_PATTERNS_CHANNEL_ID`, `GAME_PATTERNS_CHANNEL_ID`, `TAG_PATTERNS_CHANNEL_ID`, `ESCALATION_PATTERNS_CHANNEL_ID`, `STAFF_PATTERNS_CHANNEL_ID`, `COMBINED_PATTERNS_CHANNEL_ID` select where pattern embeds are posted. + - Threshold envs like `PATTERN_USER_TICKET_THRESHOLD`, `PATTERN_GAME_TICKET_THRESHOLD`, `PATTERN_UNCLAIMED_HOURS`, `PATTERN_ESCALATION_THRESHOLD`, `PATTERN_RAPID_CLOSE_SECONDS` tune when alerts fire. + - Windows (`today`, `week`, `month`) reset automatically via scheduled timers in `patternStore.scheduleResets()`. +- **Surge detection** (`surgeChecker.js`): + - `ALL_STAFF_CHANNEL_ID` is the primary surge-alert channel; `SURGE_ROLE_ID` is pinged when set. + - `SURGE_TICKET_COUNT` / `SURGE_TICKET_WINDOW_MINUTES`, `SURGE_GAME_TICKET_COUNT` / `SURGE_GAME_TICKET_WINDOW_MINUTES`, `SURGE_STALE_*`, `SURGE_NEEDS_RESPONSE_*`, `SURGE_UNCLAIMED_*`, `SURGE_TIER3_UNCLAIMED_MINUTES`, `SURGE_COOLDOWN_MINUTES` control volume/backlog alerts. + - `SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD`, `SURGE_NO_STAFF_COOLDOWN_MINUTES`, `STAFF_IDS`, and `STAFF_DND_COUNTS_AS_AVAILABLE` drive “no staff available” alerts (presence-based with message activity fallback). +- **Chat monitoring** (`chatAlertChecker.js`): + - `CHAT_ALERT_CHANNEL_IDS` lists channels to monitor. + - `CHAT_ALERT_MESSAGE_COUNT`, `CHAT_ALERT_HOURS_WITHOUT_RESPONSE`, `CHAT_ALERT_COOLDOWN_MINUTES` configure when to send chat-attention alerts to `ALL_STAFF_CHAT_ALERT_CHANNEL_ID`. + ### Google / Gmail | Variable | Required | Description | diff --git a/broccolini-discord.js b/broccolini-discord.js index f28c19a..61f986f 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -22,13 +22,25 @@ const { registerCommands } = require('./commands/register'); const bosscordRoutes = require('./routes/bosscord'); const { setBot } = require('./api/bosscordClient'); const { poll } = require('./gmail-poll'); -const { setClient: setDebugClient } = require('./services/debugLog'); +const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog'); // Re-export utilities for any external consumers const { sendGmailReply } = require('./services/gmail'); const { getNextTicketNumber } = require('./services/tickets'); const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils'); +let gmailPollInterval = null; + +/** + * Update the Gmail poll interval at runtime. + * @param {number} ms - new interval in milliseconds + */ +function setGmailPollInterval(ms) { + if (gmailPollInterval) clearInterval(gmailPollInterval); + CONFIG.GMAIL_POLL_INTERVAL_MS = ms; + gmailPollInterval = setInterval(() => poll(client), ms); +} + // --- VALIDATE CONFIG --- if (!CONFIG.DISCORD_TOKEN) { console.error('DISCORD_TOKEN or DISCORD_BOT_TOKEN is not set in .env'); @@ -51,7 +63,8 @@ const client = new Client({ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildMembers + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal ], partials: [Partials.Channel] }); @@ -108,7 +121,18 @@ client.on('interactionCreate', async interaction => { } }); -client.on('messageCreate', handleDiscordReply); +client.on('messageCreate', async msg => { + // Track staff last-seen for zero-staff detection fallback + if (!msg.author.bot && CONFIG.STAFF_IDS.includes(msg.author.id)) { + const { updateStaffLastSeen } = require('./services/patternStore'); + updateStaffLastSeen(msg.author.id); + } + // Chat channel monitoring + const { handleChatMessage } = require('./services/chatAlertChecker'); + await handleChatMessage(msg, client).catch(() => {}); + // Existing ticket reply handler + await handleDiscordReply(msg); +}); client.once('ready', async () => { if (!process.env.MONGODB_URI) { @@ -144,7 +168,7 @@ client.once('ready', async () => { registerCommands().catch(console.error); - setInterval(() => poll(client), 30000); + gmailPollInterval = setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS); poll(client); if (CONFIG.AUTO_CLOSE_ENABLED) { @@ -163,7 +187,40 @@ client.once('ready', async () => { console.log('✓ Auto-unclaim enabled: checking every hour'); } + const { runPatternChecks } = require('./services/patternChecker'); + const { scheduleResets } = require('./services/patternStore'); + scheduleResets(); + setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000); + console.log(`✓ Pattern checks: every ${CONFIG.PATTERN_CHECK_INTERVAL_MINUTES} minutes`); + + const { runSurgeChecks } = require('./services/surgeChecker'); + setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000); + setTimeout(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 30000); + console.log('✓ Surge checks: every 5 minutes'); + + const { initChatMonitoring, runChatAlertChecks } = require('./services/chatAlertChecker'); + initChatMonitoring(client); + setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000); + console.log('✓ Chat alert monitoring: every 5 minutes'); + + if (!CONFIG.STAFF_IDS.length) { + console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.'); + } + console.log('✓ Discord bot ready. Tag:', client.user.tag); + + logSystem('Bot online', [ + { name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' }, + { name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` }, + { name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' }, + { name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' }, + { name: 'Claim timeout', value: CONFIG.CLAIM_TIMEOUT_ENABLED ? `enabled (${CONFIG.CLAIM_TIMEOUT_HOURS}h)` : 'disabled' }, + { name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' }, + { name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' }, + { name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' }, + { name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' }, + { name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' } + ]).catch(() => {}); }); client.login(CONFIG.DISCORD_TOKEN); @@ -177,8 +234,33 @@ app.listen(CONFIG.PORT, healthcheckHost, () => { console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); }); +// --- Internal API for settings site --- +const internalApi = require('./routes/internalApi'); +const internalApp = express(); +internalApp.use('/internal', internalApi); + +if (CONFIG.INTERNAL_API_SECRET) { + 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.'); +} + +// --- Shutdown & error handlers --- +async function handleShutdown(signal) { + await Promise.race([logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]), new Promise(r => setTimeout(r, 2000))]); + process.exit(0); +} +process.on('SIGTERM', () => handleShutdown('SIGTERM')); +process.on('SIGINT', () => handleShutdown('SIGINT')); +process.on('unhandledRejection', (reason) => { + logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))).catch(() => {}); +}); + module.exports = { client, + setGmailPollInterval, sendGmailReply, sendTicketClosedEmail, getNextTicketNumber, diff --git a/commands/register.js b/commands/register.js index d2a0ffa..1a4c512 100644 --- a/commands/register.js +++ b/commands/register.js @@ -430,6 +430,120 @@ async function registerCommands() { .setRequired(true) ), + new SlashCommandBuilder() + .setName('closetimer') + .setDescription('Set the force-close countdown duration') + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .addStringOption(opt => + opt + .setName('seconds') + .setDescription('Countdown duration') + .setRequired(true) + .addChoices( + { name: '5s', value: '5' }, + { name: '10s', value: '10' }, + { name: '30s', value: '30' }, + { name: '45s', value: '45' }, + { name: '1m', value: '60' }, + { name: '2m', value: '120' }, + { name: '3m', value: '180' }, + { name: '4m', value: '240' }, + { name: '5m', value: '300' }, + { name: '10m', value: '600' } + ) + ), + + new SlashCommandBuilder() + .setName('staffthread') + .setDescription('Manage staff discussion threads on ticket channels') + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(sub => + sub.setName('toggle').setDescription('Toggle staff threads on/off') + ) + .addSubcommand(sub => + sub + .setName('name') + .setDescription('Set the staff thread name') + .addStringOption(opt => + opt.setName('thread_name').setDescription('Thread name').setMaxLength(100).setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('autorole') + .setDescription('Toggle auto-adding role members to staff thread') + .addBooleanOption(opt => + opt.setName('enabled').setDescription('Enable or disable').setRequired(true) + ) + ), + + new SlashCommandBuilder() + .setName('pinmessages') + .setDescription('Manage auto-pinning of ticket messages') + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(sub => + sub + .setName('initial') + .setDescription('Toggle auto-pin of welcome message') + .addBooleanOption(opt => + opt.setName('enabled').setDescription('Enable or disable').setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('escalation') + .setDescription('Toggle auto-pin of escalation messages') + .addBooleanOption(opt => + opt.setName('enabled').setDescription('Enable or disable').setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('suppress') + .setDescription('Toggle suppression of pin system messages') + .addBooleanOption(opt => + opt.setName('enabled').setDescription('Enable or disable').setRequired(true) + ) + ), + + new SlashCommandBuilder() + .setName('gmailpoll') + .setDescription('Set the Gmail poll interval') + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addStringOption(opt => + opt + .setName('interval') + .setDescription('Poll interval') + .setRequired(true) + .addChoices( + { name: '5s', value: '5' }, + { name: '10s', value: '10' }, + { name: '30s', value: '30' }, + { name: '45s', value: '45' }, + { name: '1m', value: '60' }, + { name: '2m', value: '120' }, + { name: '3m', value: '180' }, + { name: '4m', value: '240' }, + { name: '5m', value: '300' }, + { name: '10m', value: '600' } + ) + ), + + new SlashCommandBuilder() + .setName('cancel-close') + .setDescription('Cancel a pending force-close countdown') + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + new SlashCommandBuilder() .setName('accountinfo') .setDescription('Look up website account info by email or Discord user') diff --git a/config.js b/config.js index 642d752..ecbe5dc 100644 --- a/config.js +++ b/config.js @@ -138,6 +138,61 @@ 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, + 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, + SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_LOG_CHANNEL_ID || null, + SYSTEM_LOG_CHANNEL_ID: process.env.SYSTEM_LOG_CHANNEL_ID || null, + USER_PATTERNS_CHANNEL_ID: process.env.USER_PATTERNS_CHANNEL_ID || null, + GAME_PATTERNS_CHANNEL_ID: process.env.GAME_PATTERNS_CHANNEL_ID || null, + TAG_PATTERNS_CHANNEL_ID: process.env.TAG_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, + 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, + 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, + 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, + 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, + 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', + STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', + 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', + PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true', + SETTINGS_PORT: parseInt(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_SECRET: process.env.INTERNAL_API_SECRET || null, UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4') .split(',') .map(s => parseInt(s.trim(), 10)) diff --git a/db-connection.js b/db-connection.js index ebbfa55..4d4ab67 100644 --- a/db-connection.js +++ b/db-connection.js @@ -25,14 +25,20 @@ async function connectMongoDB(uri, options = {}) { // Handle connection events mongoose.connection.on('error', (err) => { console.error('MongoDB connection error:', err); + const { logSystem: ls } = require('./services/debugLog'); + ls('MongoDB error', [{ name: 'Error', value: err.message }], null, 0xFF0000).catch(() => {}); }); mongoose.connection.on('disconnected', () => { console.warn('MongoDB disconnected. Attempting to reconnect...'); + const { logSystem: ls } = require('./services/debugLog'); + ls('MongoDB disconnected', [], null, 0xFFFF00).catch(() => {}); }); mongoose.connection.on('reconnected', () => { console.log('✓ MongoDB reconnected'); + const { logSystem: ls } = require('./services/debugLog'); + ls('MongoDB reconnected', []).catch(() => {}); }); } catch (err) { @@ -55,8 +61,32 @@ async function closeMongoDB() { } } +/** + * Retry a function on Mongoose connection errors. + * @param {Function} fn - async function to execute + * @param {object} options - { retries: 3, delayMs: 500 } + * @returns {Promise<*>} + */ +async function withRetry(fn, options = {}) { + const { retries = 3, delayMs = 500 } = options; + let lastError; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + const isConnectionError = err.name === 'MongoNetworkError' || + mongoose.connection.readyState !== 1; + if (!isConnectionError || attempt >= retries) throw err; + await new Promise(r => setTimeout(r, delayMs)); + } + } + throw lastError; +} + module.exports = { connectMongoDB, closeMongoDB, + withRetry, mongoose }; diff --git a/gmail-poll.js b/gmail-poll.js index 17e9222..06c73f0 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -8,7 +8,7 @@ const { ButtonStyle, EmbedBuilder } = require('discord.js'); -const { mongoose } = require('./db-connection'); +const { mongoose, withRetry } = require('./db-connection'); const { CONFIG, GAME_NAME_TO_KEY } = require('./config'); const { getCleanBody, @@ -16,23 +16,38 @@ const { stripEmailQuotes, stripMobileFooter, detectGame, - getFormattedDate + getFormattedDate, + truncateEmbedField, + enforceEmbedLimit } = require('./utils'); const { getGmailClient } = require('./services/gmail'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { getEmailRouting } = require('./services/guildSettings'); -const { logError } = require('./services/debugLog'); +const { logError, logGmail } = require('./services/debugLog'); +const { increment } = require('./services/patternStore'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); +let isPolling = false; +let authErrorNotified = false; +let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0; + /** * Poll Gmail for unread primary-inbox messages and route them to Discord. * @param {import('discord.js').Client} client */ async function poll(client) { - console.log('Running poll()...'); + if (isPolling) return; + isPolling = true; try { + pollCount++; + if (pollCount % 10 === 0) { + logGmail('Poll summary', `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`, null, null).catch(() => {}); + pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0; + } + console.log('Running poll()...'); + try { const gmail = getGmailClient(); const list = await gmail.users.messages.list({ userId: 'me', @@ -68,6 +83,7 @@ async function poll(client) { email.data.payload.headers.find(h => h.name === 'From') ?.value || ''; if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) { + totalSkipped++; await gmail.users.messages.batchModify({ userId: 'me', requestBody: { @@ -146,6 +162,7 @@ async function poll(client) { // Check ticket limits before creating const limitCheck = await checkTicketLimits(sEmail); if (!limitCheck.ok) { + totalSkipped++; console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`); await gmail.users.messages.batchModify({ userId: 'me', @@ -227,20 +244,29 @@ async function poll(client) { .setColor(CONFIG.EMBED_COLOR_INFO) .addFields({ name: 'Ticket Info', - value: + value: truncateEmbedField( `**Name:** ${sName}\n` + `**Email:** ${sEmail}\n` + `**Date:** ${getFormattedDate()}\n` + `**Game:** ${detectedGame}\n` + - `**Subject:** ${subject || 'No subject'}` + `**Subject:** ${subject || 'No subject'}`) }); - await ticketChan.send({ + enforceEmbedLimit([welcomeEmbed, ticketInfoEmbed]); + const welcomeMsg = await ticketChan.send({ content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, embeds: [welcomeEmbed, ticketInfoEmbed], components: [buttons] }); + const { createStaffThread } = require('./services/staffThread'); + await createStaffThread(ticketChan, client).catch(() => {}); + + if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { + const { pinMessage } = require('./services/pinMessage'); + await pinMessage(welcomeMsg, client).catch(() => {}); + } + // On reopen, link previous transcripts if (isReopened) { try { @@ -292,7 +318,7 @@ async function poll(client) { const now = new Date(); const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; - await Ticket.findOneAndUpdate( + await withRetry(() => Ticket.findOneAndUpdate( { gmailThreadId: email.data.threadId }, { $set: { @@ -308,7 +334,15 @@ async function poll(client) { } }, { upsert: true, new: true } - ); + )); + totalProcessed++; + logGmail(subject, sEmail, number, detectedGame).catch(() => {}); + increment('user_tickets', sEmail, 'today'); + increment('user_tickets', sEmail, 'week'); + if (detectedGame) { + increment('game_tickets', detectedGame, 'today'); + increment('game_tickets', detectedGame, 'week'); + } } console.log('Archiving/reading Gmail message', msgRef.id); await gmail.users.messages.batchModify({ @@ -319,10 +353,32 @@ async function poll(client) { } }); } + authErrorNotified = false; } catch (e) { + const isAuthError = + (e.message && ( + e.message.includes('invalid_grant') || + e.message.includes('unauthorized') || + e.message.includes('Invalid Credentials') + )) || + e.status === 401 || + e.code === 401; + + if (isAuthError) { + logError('Gmail OAuth', { message: 'Gmail OAuth token invalid or expired. Re-authentication required.', stack: e.stack || e.message || String(e) }, null, client); + if (CONFIG.ADMIN_ID && !authErrorNotified) { + authErrorNotified = true; + client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send('Gmail OAuth token invalid or expired. Re-authentication required.')).catch(() => {}); + } + } + + totalErrors++; console.error('POLL ERROR:', e); logError('Gmail poll', e, null, client); } + } finally { + isPolling = false; + } } module.exports = { poll }; diff --git a/handlers/accountinfo.js b/handlers/accountinfo.js index aef7b0e..46b56d2 100644 --- a/handlers/accountinfo.js +++ b/handlers/accountinfo.js @@ -5,6 +5,7 @@ const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); const { CONFIG } = require('../config'); const { mongoose } = require('../db-connection'); +const { logSecurity } = require('../services/debugLog'); const User = mongoose.model('User'); @@ -98,6 +99,11 @@ async function handleAccountInfoCommand(interaction) { }); } + const identifier = subcommand === 'email' + ? interaction.options.getString('email') + : interaction.options.getUser('user')?.tag || 'unknown'; + logSecurity('Account lookup', interaction.user, `lookup: ${subcommand} → ${identifier}`, null, 0x0099ff).catch(() => {}); + const embed = buildAccountInfoEmbed(user, interaction.user.tag); const components = []; diff --git a/handlers/buttons.js b/handlers/buttons.js index e2002ab..d44f8fe 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -19,10 +19,13 @@ const { CONFIG } = require('../config'); const { canRename, makeTicketName, resolveCreatorNickname, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets'); const { sendTicketClosedEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); +const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils'); const { setEmailRouting } = require('../services/guildSettings'); const { enqueueRename } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { trackInteraction, trackError } = require('./analytics'); +const { pendingCloses } = require('./pendingCloses'); +const { increment } = require('../services/patternStore'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); @@ -131,7 +134,25 @@ async function handleButton(interaction) { } if (interaction.customId === 'confirm_close') { - return handleConfirmClose(interaction, ticket); + const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; + if (pendingCloses.has(interaction.channel.id)) { + return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); + } + await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`, components: [] }); + const timerId = setTimeout(async () => { + pendingCloses.delete(interaction.channel.id); + const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!freshTicket || freshTicket.status === 'closed') return; + const { logTicketEvent } = require('../services/debugLog'); + logTicketEvent('Force-close timer fired', [ + { name: 'Ticket', value: interaction.channel.name || interaction.channel.id }, + { name: 'Set by', value: interaction.user.tag }, + { name: 'Duration', value: `${timerSeconds}s` } + ]).catch(() => {}); + await handleConfirmClose(interaction, freshTicket); + }, timerSeconds * 1000); + pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); + return; } if (interaction.customId === 'cancel_close') { @@ -294,6 +315,8 @@ async function handleClaim(interaction, ticket) { } if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { + const { logSecurity } = require('../services/debugLog'); + logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {}); return interaction.reply({ content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, ephemeral: true @@ -307,6 +330,8 @@ async function handleClaim(interaction, ticket) { ); freshTicket.claimedBy = claimerLabel; freshTicket.claimerId = interaction.user.id; + increment('staff_claims', interaction.user.id, 'today'); + increment('staff_claims', interaction.user.id, 'week'); // Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK) const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK; @@ -358,6 +383,8 @@ async function handleClaim(interaction, ticket) { .setColor(CONFIG.EMBED_COLOR_CLAIMED) .setFooter({ text: `Claimed by ${claimerLabel}` }); await interaction.followUp({ embeds: [claimEmbed] }); + const { addMemberToStaffThread } = require('../services/staffThread'); + await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {}); } else { // Unclaim await Ticket.updateOne( @@ -415,6 +442,10 @@ async function handleClaim(interaction, ticket) { // --- CONFIRM CLOSE --- async function handleConfirmClose(interaction, ticket) { const closedAt = new Date(); + increment('staff_closes', interaction.user.id, 'today'); + if (!ticket.ticketTag) { + increment('untagged_closes', 'total', 'today'); + } try { await interaction.update({ content: 'Archiving and closing...', components: [] }); } catch { @@ -669,35 +700,64 @@ async function handleTicketModal(interaction) { const displayName = interaction.member?.displayName || interaction.user.username; - // Welcome embed (dark grey #1e2124) - const welcomeEmbed = new EmbedBuilder() - .setDescription(CONFIG.TICKET_WELCOME_MESSAGE) - .setColor(CONFIG.EMBED_COLOR_INFO) - .setFooter({ text: 'Indifferent Broccoli Tickets' }); - - // Ticket details embed (dark) – short labels, trimmed description const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description; + + const welcomeEmbed = new EmbedBuilder() + .setTitle("We got your ticket.") + .setDescription("We'll be with you as soon as possible.") + .setColor(5763719) + .setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png") + .setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" }); + const infoEmbed = new EmbedBuilder() - .setColor(CONFIG.EMBED_COLOR_INFO) + .setColor(5763719) + .setDescription(truncateEmbedDescription( + `**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` + + `**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` + + `**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\`` + )); + + const resourcesEmbed = new EmbedBuilder() + .setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:") + .setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.") + .setColor(5763719) .addFields( - { name: 'Email', value: email, inline: true }, - { name: 'Game', value: game || 'Not specified', inline: true }, - { name: 'Description', value: descTrimmed, inline: false } + { name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false } ) - .setTimestamp(); + .setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" }); const actionRow = getTicketActionRow({ escalationTier: 0 }); - const welcomeMsg = await channel.send({ - content: `Hey There ${interaction.user} 🥦`, - embeds: [welcomeEmbed, infoEmbed], - components: [actionRow] - }); + enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]); + try { + const welcomeMsg = await channel.send({ + content: `Hey There ${interaction.user} 🥦`, + embeds: [welcomeEmbed, infoEmbed, resourcesEmbed], + components: [actionRow] + }); - await Ticket.updateOne( - { discordThreadId: channel.id }, - { $set: { welcomeMessageId: welcomeMsg.id } } - ); + await Ticket.updateOne( + { discordThreadId: channel.id }, + { $set: { welcomeMessageId: welcomeMsg.id } } + ); + } catch (err) { + console.error('welcomeMessageId-save', err); + } + + const { createStaffThread } = require('../services/staffThread'); + await createStaffThread(channel, interaction.client).catch(() => {}); + + if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { + const { pinMessage } = require('../services/pinMessage'); + await pinMessage(welcomeMsg, interaction.client).catch(() => {}); + } + + increment('user_tickets', interaction.user.id, 'today'); + increment('user_tickets', interaction.user.id, 'week'); + if (game) { + increment('game_tickets', game, 'today'); + increment('game_tickets', game, 'week'); + } await interaction.deleteReply().catch(() => {}); diff --git a/handlers/commands.js b/handlers/commands.js index 68a4774..63b9c56 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -20,8 +20,11 @@ const { getEmailRouting } = require('../services/guildSettings'); const { enqueueRename, enqueueMove } = require('../services/channelQueue'); const { setNotifyDm } = require('../services/staffSettings'); const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics'); +const { logTicketEvent, logSecurity } = require('../services/debugLog'); const { handleAccountInfoCommand } = require('./accountinfo'); const { handleSetupCommand } = require('./setup'); +const { pendingCloses } = require('./pendingCloses'); +const { increment } = require('../services/patternStore'); const Ticket = mongoose.model('Ticket'); const Tag = mongoose.model('Tag'); @@ -55,6 +58,7 @@ async function requireStaffRole(interaction) { content: `This command is only available to the support team (${roleMention}).`, ephemeral: true }); + logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {}); return true; } @@ -75,6 +79,12 @@ async function runEscalation(interaction, ticket, nextTier, reason) { ticket.escalated = true; ticket.escalationTier = nextTier; ticket.claimedBy = null; + increment('escalations', ticket.game || 'unknown', 'today'); + increment('escalations', ticket.game || 'unknown', 'week'); + increment('user_escalations', ticket.senderEmail, 'week'); + increment('staff_escalations', interaction.user.id, 'today'); + increment('staff_escalations', interaction.user.id, 'week'); + if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week'); const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); const renameInfo = await canRename(ticket); @@ -124,12 +134,17 @@ async function runEscalation(interaction, ticket, nextTier, reason) { .setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` }); const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true }; const escalationRow = getTicketActionRow(updatedTicketForRow); - await interaction.channel.send({ + const escalationMsg = await interaction.channel.send({ content: null, embeds: [escalatedEmbed], components: [escalationRow] }); + if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) { + const { pinMessage } = require('../services/pinMessage'); + await pinMessage(escalationMsg, interaction.client).catch(() => {}); + } + if (!isDiscordTicket && ticket.gmailThreadId) { try { const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (reason ? `\n\nReason: ${reason}` : ''); @@ -144,12 +159,16 @@ async function runEscalation(interaction, ticket, nextTier, reason) { } } - if (nextTier === 2 && ticket.welcomeMessageId) { - try { - const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId); - await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] }); - } catch (e) { - console.error('Failed to update welcome message after escalate:', e.message); + if (nextTier === 2) { + if (!ticket.welcomeMessageId) { + console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation'); + } else { + try { + const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId); + await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] }); + } catch (e) { + console.error('Failed to update welcome message after escalate:', e.message); + } } } @@ -376,6 +395,7 @@ async function handleCommand(interaction) { // /staffnotification (admin only) if (interaction.commandName === 'staffnotification') { if (interaction.user.id !== CONFIG.ADMIN_ID) { + logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {}); return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true }); } const member = interaction.options.getMember('member'); @@ -540,6 +560,79 @@ async function handleCommand(interaction) { } } + // /gmailpoll + // /staffthread + if (interaction.commandName === 'staffthread') { + const sub = interaction.options.getSubcommand(); + if (sub === 'toggle') { + CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED; + return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + if (sub === 'name') { + const name = interaction.options.getString('thread_name').slice(0, 100); + CONFIG.STAFF_THREAD_NAME = name; + return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true }); + } + if (sub === 'autorole') { + const enabled = interaction.options.getBoolean('enabled'); + CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled; + return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + return; + } + + // /pinmessages + if (interaction.commandName === 'pinmessages') { + const sub = interaction.options.getSubcommand(); + const enabled = interaction.options.getBoolean('enabled'); + if (sub === 'initial') { + CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled; + return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + if (sub === 'escalation') { + CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled; + return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + if (sub === 'suppress') { + CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled; + return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + return; + } + + if (interaction.commandName === 'gmailpoll') { + const seconds = parseInt(interaction.options.getString('interval'), 10); + const { setGmailPollInterval } = require('../broccolini-discord'); + setGmailPollInterval(seconds * 1000); + logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {}); + return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true }); + } + + // /closetimer + if (interaction.commandName === 'closetimer') { + const seconds = parseInt(interaction.options.getString('seconds'), 10); + CONFIG.FORCE_CLOSE_TIMER = seconds; + logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {}); + return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true }); + } + + // /cancel-close + if (interaction.commandName === 'cancel-close') { + const pending = pendingCloses.get(interaction.channel.id); + if (!pending) { + return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true }); + } + clearTimeout(pending.timeout); + const { logTicketEvent } = require('../services/debugLog'); + logTicketEvent('Force-close cancelled', [ + { name: 'Ticket', value: interaction.channel.name || interaction.channel.id }, + { name: 'Cancelled by', value: interaction.user.tag }, + { name: 'Original setter', value: pending.username || 'Unknown' } + ], interaction).catch(() => {}); + pendingCloses.delete(interaction.channel.id); + return interaction.reply({ content: 'Close cancelled.', ephemeral: true }); + } + // /force-close if (interaction.commandName === 'force-close') { const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); @@ -547,71 +640,86 @@ async function handleCommand(interaction) { return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); } - try { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { status: 'closed' } } - ); + if (pendingCloses.has(interaction.channel.id)) { + return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); + } - await interaction.reply('Ticket force-closed. Archiving...'); + const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; + await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`); + + const channelRef = interaction.channel; + const clientRef = interaction.client; + const timerId = setTimeout(async () => { + pendingCloses.delete(channelRef.id); + const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean(); + if (!freshTicket || freshTicket.status === 'closed') return; try { - await interaction.channel.send(CONFIG.DISCORD_CLOSE_MESSAGE); + await Ticket.updateOne( + { gmailThreadId: freshTicket.gmailThreadId }, + { $set: { status: 'closed' } } + ); - const messages = await interaction.channel.messages.fetch({ limit: 100 }); - const log = - `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` + - messages - .reverse() - .map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`) - .join('\n'); + await channelRef.send('Ticket force-closed. Archiving...'); - const file = new AttachmentBuilder(Buffer.from(log), { - name: `transcript-${interaction.channel.name}.txt` - }); - - const transcriptChan = await interaction.client.channels - .fetch(CONFIG.TRANSCRIPT_CHAN) - .catch(() => null); - - if (transcriptChan) { - const closedAt = new Date(); - const openedStr = new Date(ticket.createdAt).toLocaleString('en-US', { - month: '2-digit', day: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: true, timeZoneName: 'short' - }); - const closedStr = closedAt.toLocaleString('en-US', { - month: '2-digit', day: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: true, timeZoneName: 'short' - }); - const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE - .replace(/\{channel_name\}/g, interaction.channel.name) - .replace(/\{email\}/g, ticket.senderEmail || '') - .replace(/\{date_opened\}/g, openedStr) - .replace(/\{date_closed\}/g, closedStr) - + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; - await transcriptChan.send({ - content: transcriptContent, - files: [file] - }); - } - } catch (tErr) { - console.error('Transcript error (force-close):', tErr); - } - - setTimeout(async () => { try { - await interaction.channel.delete('Ticket force-closed'); - } catch (e) { - console.error('Failed to delete channel:', e); + await channelRef.send(CONFIG.DISCORD_CLOSE_MESSAGE); + + const messages = await channelRef.messages.fetch({ limit: 100 }); + const log = + `TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` + + messages + .reverse() + .map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`) + .join('\n'); + + const file = new AttachmentBuilder(Buffer.from(log), { + name: `transcript-${channelRef.name}.txt` + }); + + const transcriptChan = await clientRef.channels + .fetch(CONFIG.TRANSCRIPT_CHAN) + .catch(() => null); + + if (transcriptChan) { + const closedAt = new Date(); + const openedStr = new Date(freshTicket.createdAt).toLocaleString('en-US', { + month: '2-digit', day: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: true, timeZoneName: 'short' + }); + const closedStr = closedAt.toLocaleString('en-US', { + month: '2-digit', day: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: true, timeZoneName: 'short' + }); + const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE + .replace(/\{channel_name\}/g, channelRef.name) + .replace(/\{email\}/g, freshTicket.senderEmail || '') + .replace(/\{date_opened\}/g, openedStr) + .replace(/\{date_closed\}/g, closedStr) + + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; + await transcriptChan.send({ + content: transcriptContent, + files: [file] + }); + } + } catch (tErr) { + console.error('Transcript error (force-close):', tErr); } - }, 5000); - } catch (err) { - console.error('Force close error:', err); - await interaction.reply({ content: 'Failed to close ticket.', ephemeral: true }); - } + + setTimeout(async () => { + try { + await channelRef.delete('Ticket force-closed'); + } catch (e) { + console.error('Failed to delete channel:', e); + } + }, 5000); + } catch (err) { + console.error('Force close error:', err); + } + }, timerSeconds * 1000); + pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); } // /topic @@ -649,6 +757,9 @@ async function handleCommand(interaction) { const emoji = tagEntry ? tagEntry.emoji : ''; const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`; await interaction.reply(channelMessage); + increment('tag_usage', categoryValue, 'today'); + increment('tag_usage', categoryValue, 'week'); + if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week'); } catch (err) { trackError('tag-command', err, interaction); await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true }); @@ -1189,16 +1300,20 @@ async function handleContextMenu(interaction) { const row = getTicketActionRow({ escalationTier: 0 }); - const welcomeMsg = await channel.send({ - content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`, - embeds: [welcomeEmbed, infoEmbed], - components: [row] - }); + try { + const welcomeMsg = await channel.send({ + content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`, + embeds: [welcomeEmbed, infoEmbed], + components: [row] + }); - await Ticket.updateOne( - { discordThreadId: channel.id }, - { $set: { welcomeMessageId: welcomeMsg.id } } - ); + await Ticket.updateOne( + { discordThreadId: channel.id }, + { $set: { welcomeMessageId: welcomeMsg.id } } + ); + } catch (err) { + console.error('welcomeMessageId-save', err); + } await interaction.editReply(`✅ Ticket created: ${channel}`); } catch (err) { diff --git a/handlers/messages.js b/handlers/messages.js index 6e91eee..7698be3 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -44,16 +44,20 @@ async function handleDiscordReply(m) { } } + // Track whether last message is from staff or customer + const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); + const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING); + Ticket.updateOne( + { discordThreadId: m.channel.id }, + { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } + ).catch(() => {}); + // Notify claiming staff if a non-staff user replied (works for both Discord and email tickets) - if (ticket.claimerId) { + if (ticket.claimerId && !isStaffMember) { const guild = m.guild; - const member = await guild.members.fetch(m.author.id).catch(() => null); - const isStaff = member && CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING); - if (!isStaff) { - const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); - if (freshTicket) { - await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e)); - } + const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); + if (freshTicket) { + await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e)); } } diff --git a/handlers/pendingCloses.js b/handlers/pendingCloses.js new file mode 100644 index 0000000..ca93243 --- /dev/null +++ b/handlers/pendingCloses.js @@ -0,0 +1,8 @@ +/** + * Shared pending-close timer map. + * Keyed by channel.id → { timeout, userId, username }. + * Used by buttons.js (sets timers) and commands.js (cancel-close clears them). + */ +const pendingCloses = new Map(); + +module.exports = { pendingCloses }; diff --git a/models.js b/models.js index 6826ef1..618e860 100644 --- a/models.js +++ b/models.js @@ -816,7 +816,8 @@ mongoose.model('Ticket', new mongoose.Schema({ claimerId: String, staffChannelId: String, parentCategoryId: String, - unclaimedReminderssent: { type: [Number], default: [] } + unclaimedReminderssent: { type: [Number], default: [] }, + lastMessageAuthorIsStaff: { type: Boolean, default: false } })); mongoose.model('TicketCounter', new mongoose.Schema({ diff --git a/routes/internalApi.js b/routes/internalApi.js new file mode 100644 index 0000000..6c41ff0 --- /dev/null +++ b/routes/internalApi.js @@ -0,0 +1,129 @@ +const express = require('express'); +const { CONFIG } = require('../config'); +const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); +const { logSystem } = require('../services/debugLog'); + +const router = express.Router(); + +// 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) { + return res.status(401).json({ error: 'Unauthorized' }); + } + next(); +}); + +// GET /config — return all current .env values (redacted secrets) +router.get('/config', (req, res) => { + const map = readAllConfig(); + const obj = {}; + const REDACTED = ['DISCORD_TOKEN', 'REFRESH_TOKEN', 'GOOGLE_CLIENT_SECRET', 'MONGODB_URI', 'INTERNAL_API_SECRET', 'SETTINGS_ADMIN_PASSWORD']; + for (const [k, v] of map) { + obj[k] = REDACTED.includes(k) ? '••••••••' : v; + } + res.json(obj); +}); + +// POST /config — apply config updates +router.post('/config', express.json(), async (req, res) => { + const updates = req.body; + if (!updates || typeof updates !== 'object') { + return res.status(400).json({ error: 'Invalid body' }); + } + const result = applyConfigUpdates(updates); + await logSystem('Config updated via settings UI', [ + { name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false }, + { name: 'Errors', value: result.errors.join(', ') || 'none', inline: false } + ]).catch(() => {}); + res.json(result); +}); + +// GET /discord/guild — return guild info for smart dropdowns +router.get('/discord/guild', async (req, res) => { + try { + const client = require('../api/bosscordClient').getBot(); + if (!client) return res.status(503).json({ error: 'Bot not ready' }); + + const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); + if (!guild) return res.status(404).json({ error: 'Guild not found' }); + + await guild.members.fetch().catch(() => {}); + + const channels = guild.channels.cache + .filter(c => [0, 4, 5, 15].includes(c.type)) + .map(c => ({ id: c.id, name: c.name, type: c.type, parentId: c.parentId })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const roles = guild.roles.cache + .filter(r => !r.managed && r.id !== guild.id) + .map(r => ({ id: r.id, name: r.name, color: r.hexColor })) + .sort((a, b) => b.position - a.position); + + const members = guild.members.cache + .filter(m => !m.user.bot) + .map(m => ({ + id: m.id, + username: m.user.username, + displayName: m.displayName, + avatar: m.user.displayAvatarURL({ size: 32 }) + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + const categories = guild.channels.cache + .filter(c => c.type === 4) + .map(c => ({ id: c.id, name: c.name })) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ channels, roles, members, categories }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /restart — restart the bot process +let scheduledRestart = null; + +router.post('/restart', express.json(), (req, res) => { + const { mode, scheduledFor } = req.body; + + if (mode === 'immediate') { + res.json({ ok: true, mode }); + setTimeout(() => { + console.log('[restart] Restarting bot process...'); + process.exit(0); // Docker/systemd will restart + }, 1500); + return; + } + + if (mode === 'scheduled' && scheduledFor) { + 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(() => { + console.log('[restart] Scheduled restart firing...'); + process.exit(0); + }, delay); + res.json({ ok: true, mode, scheduledFor, delayMs: delay }); + return; + } + + if (mode === 'cancel_scheduled') { + if (scheduledRestart) { clearTimeout(scheduledRestart); scheduledRestart = null; } + res.json({ ok: true, cancelled: true }); + return; + } + + if (mode === 'pending') { + res.json({ ok: true, mode: 'pending', note: 'Restart required on next manual restart' }); + return; + } + + res.status(400).json({ error: 'Invalid mode' }); +}); + +router.get('/restart/status', (req, res) => { + res.json({ scheduledRestart: !!scheduledRestart }); +}); + +module.exports = router; diff --git a/services/channelQueue.js b/services/channelQueue.js index 948747e..7066ec0 100644 --- a/services/channelQueue.js +++ b/services/channelQueue.js @@ -1,32 +1,87 @@ /** - * Serialized channel renames/moves to avoid Discord rate limits (e.g. 2 renames / 10 min per channel). + * Per-channel rename rate limiting with queue. + * Discord allows 2 channel renames per 10 minutes per channel. + * We use a 9-minute window for safety margin. */ -const PQueue = require('p-queue').default; -const channelQueue = new PQueue({ - concurrency: 1, - intervalCap: 2, - interval: 10000 -}); +const RENAME_WINDOW_MS = 9 * 60 * 1000; +const RENAME_LIMIT = 2; + +// Per-channel state: { count, windowStart, queue: [{newName, resolve, reject}], processing } +const renameState = new Map(); + +function getOrInitState(channelId) { + let state = renameState.get(channelId); + if (!state) { + state = { count: 0, windowStart: 0, queue: [], processing: false }; + renameState.set(channelId, state); + } + return state; +} + +async function executeRename(channel, newName) { + await channel.setName(newName); +} + +function processQueue(channel, state) { + if (state.queue.length === 0 || state.processing) return; + + const now = Date.now(); + const timeUntilExpiry = (state.windowStart + RENAME_WINDOW_MS) - now; + + if (timeUntilExpiry > 0) { + state.processing = true; + setTimeout(async () => { + state.processing = false; + // New window + if (state.queue.length > 3) { + const { logWarn } = require('../services/debugLog'); + logWarn('renameQueue', `Channel ${channel.name} has ${state.queue.length} renames queued`).catch(() => {}); + } + const item = state.queue.shift(); + if (!item) return; + state.count = 1; + state.windowStart = Date.now(); + try { + await executeRename(channel, item.newName); + item.resolve(); + } catch (err) { + item.reject(err); + } + // Continue processing remaining queue items + processQueue(channel, state); + }, timeUntilExpiry); + } +} function enqueueRename(channel, newName) { - return channelQueue.add(async () => { - try { - await channel.setName(newName); - } catch (err) { - const msg = err?.message || String(err); - if (msg.includes('429') || msg.toLowerCase().includes('rate limit')) { - console.warn(`enqueueRename: rate limit renaming channel "${channel.name}"`); - return; - } - console.error('enqueueRename:', err); - throw err; + return new Promise((resolve, reject) => { + const state = getOrInitState(channel.id); + const now = Date.now(); + + // Window expired — reset + if (now - state.windowStart >= RENAME_WINDOW_MS) { + state.count = 1; + state.windowStart = now; + executeRename(channel, newName).then(resolve).catch(reject); + return; } + + // Within window and under limit + if (state.count < RENAME_LIMIT) { + state.count++; + executeRename(channel, newName).then(resolve).catch(reject); + return; + } + + // At limit — queue it + state.queue.push({ newName, resolve, reject }); + processQueue(channel, state); }); } function enqueueMove(channel, categoryId) { - return channelQueue.add(() => channel.setParent(categoryId, { lockPermissions: true })); + return channel.setParent(categoryId, { lockPermissions: true }); } -module.exports = { channelQueue, enqueueRename, enqueueMove }; +module.exports = { enqueueRename, enqueueMove }; diff --git a/services/chatAlertChecker.js b/services/chatAlertChecker.js new file mode 100644 index 0000000..b43e046 --- /dev/null +++ b/services/chatAlertChecker.js @@ -0,0 +1,86 @@ +/** + * Chat monitoring — tracks unresponded messages in configured channels + * and alerts staff when thresholds are crossed. + */ +const { EmbedBuilder } = require('discord.js'); +const { CONFIG } = require('../config'); +const { setCooldown, isOnCooldown } = require('./patternStore'); + +// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt } +const chatState = new Map(); + +function initChatMonitoring(client) { + for (const channelId of CONFIG.CHAT_ALERT_CHANNEL_IDS) { + chatState.set(channelId, { + lastStaffMessageAt: new Date(), + unrespondedCount: 0, + lastAlertAt: null + }); + } +} + +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)); +} + +async function handleChatMessage(msg, client) { + if (msg.author.bot) return; + if (!chatState.has(msg.channel.id)) return; + + const state = chatState.get(msg.channel.id); + if (isStaff(msg.member)) { + state.lastStaffMessageAt = new Date(); + state.unrespondedCount = 0; + } else { + state.unrespondedCount++; + } +} + +async function runChatAlertChecks(client) { + const alertChannelId = CONFIG.ALL_STAFF_CHAT_ALERT_CHANNEL_ID; + if (!alertChannelId || !client) return; + + for (const [channelId, state] of chatState) { + // Message count threshold + if (state.unrespondedCount >= CONFIG.CHAT_ALERT_MESSAGE_COUNT) { + const cooldownKey = `chat:messages:${channelId}`; + if (!isOnCooldown(cooldownKey, CONFIG.CHAT_ALERT_COOLDOWN_MINUTES)) { + setCooldown(cooldownKey); + const embed = new EmbedBuilder() + .setTitle('Chat needs attention') + .setDescription(`<#${channelId}> has ${state.unrespondedCount} unresponded messages.`) + .setColor(0xFF8800) + .setTimestamp(); + try { + const alertChan = await client.channels.fetch(alertChannelId); + const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined; + if (alertChan) await alertChan.send({ content, embeds: [embed] }); + } catch (_) {} + } + } + + // Time threshold + const hoursSinceStaff = (Date.now() - state.lastStaffMessageAt.getTime()) / 3600000; + if (hoursSinceStaff >= CONFIG.CHAT_ALERT_HOURS_WITHOUT_RESPONSE && state.unrespondedCount > 0) { + const cooldownKey = `chat:time:${channelId}`; + if (!isOnCooldown(cooldownKey, CONFIG.CHAT_ALERT_COOLDOWN_MINUTES)) { + setCooldown(cooldownKey); + const embed = new EmbedBuilder() + .setTitle('Chat without staff response') + .setDescription(`<#${channelId}> has had no staff response for ${Math.floor(hoursSinceStaff)} hour(s) with ${state.unrespondedCount} pending message(s).`) + .setColor(0xFF8800) + .setTimestamp(); + try { + const alertChan = await client.channels.fetch(alertChannelId); + const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined; + if (alertChan) await alertChan.send({ content, embeds: [embed] }); + } catch (_) {} + } + } + } +} + +module.exports = { initChatMonitoring, handleChatMessage, runChatAlertChecks }; diff --git a/services/configPersistence.js b/services/configPersistence.js new file mode 100644 index 0000000..c7bf7c3 --- /dev/null +++ b/services/configPersistence.js @@ -0,0 +1,105 @@ +const fs = require('fs'); +const path = require('path'); +const { CONFIG } = require('../config'); + +const ENV_PATH = process.env.ENV_FILE + ? path.resolve(process.env.ENV_FILE) + : path.resolve(process.cwd(), '.env'); + +/** + * Read the current .env file and parse into a key->value Map. + */ +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) { + 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); + } + return map; +} + +/** + * Write a Map of key->value back to the .env file, + * preserving comments and blank lines. + */ +function writeEnvFile(updates) { + if (!fs.existsSync(ENV_PATH)) { + const lines = []; + for (const [k, v] of updates) lines.push(`${k}=${v}`); + fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8'); + return; + } + + const raw = fs.readFileSync(ENV_PATH, 'utf8'); + const lines = raw.split('\n'); + const written = new Set(); + + const result = lines.map(line => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return line; + const idx = line.indexOf('='); + if (idx === -1) return line; + const key = line.slice(0, idx).trim(); + if (updates.has(key)) { + written.add(key); + return `${key}=${updates.get(key)}`; + } + 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}`); + } + + fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8'); +} + +/** + * Apply a flat object of { KEY: value } to both CONFIG and .env. + * Returns { applied: string[], errors: string[] } + */ +function applyConfigUpdates(updates) { + const applied = []; + const errors = []; + + for (const [key, rawValue] of Object.entries(updates)) { + try { + if (rawValue === 'true' || rawValue === 'false') { + CONFIG[key] = rawValue === 'true'; + } else if (!isNaN(rawValue) && rawValue !== '') { + CONFIG[key] = Number(rawValue); + } else { + CONFIG[key] = rawValue; + } + applied.push(key); + } catch (err) { + errors.push(`${key}: ${err.message}`); + } + } + + // Write to .env + const envMap = readEnvFile(); + for (const [key, value] of Object.entries(updates)) { + envMap.set(key, String(value)); + } + writeEnvFile(envMap); + + return { applied, errors }; +} + +/** + * Read all current env values for the settings UI. + */ +function readAllConfig() { + return readEnvFile(); +} + +module.exports = { applyConfigUpdates, readAllConfig, readEnvFile, writeEnvFile }; diff --git a/services/debugLog.js b/services/debugLog.js index 39c9029..4dba18c 100644 --- a/services/debugLog.js +++ b/services/debugLog.js @@ -1,7 +1,8 @@ /** - * Send error details to DEBUGGING_CHANNEL_ID when set. - * Call setClient(client) from the main bot on ready so errors can be posted. + * Structured logging service – posts embeds to dedicated Discord channels. + * Call setClient(client) from the main bot on ready so logs can be posted. */ +const { EmbedBuilder } = require('discord.js'); const { CONFIG } = require('../config'); let client = null; @@ -10,13 +11,21 @@ function setClient(c) { client = c; } -/** - * Post an error to the debugging channel (if DEBUGGING_CHANNEL_ID and client are set). - * @param {string} context - e.g. 'escalate', 'deescalate', 'email-routing', 'Gmail poll' - * @param {Error} error - * @param {import('discord.js').Interaction} [interaction] - * @param {import('discord.js').Client} [overrideClient] - use this client instead of stored (e.g. from gmail-poll) - */ +// --- Helpers --- + +async function sendToChannel(channelId, embed, overrideClient) { + const c = overrideClient || client; + if (!c || !channelId) return; + try { + const channel = await c.channels.fetch(channelId); + if (channel) await channel.send({ embeds: [embed] }); + } catch (_) { + // ignore send failures + } +} + +// --- logError (backwards-compatible) --- + async function logError(context, error, interaction = null, overrideClient = null) { const c = overrideClient || client; if (!c || !CONFIG.DEBUGGING_CHANNEL_ID) return; @@ -38,4 +47,124 @@ async function logError(context, error, interaction = null, overrideClient = nul } } -module.exports = { setClient, logError }; +// --- logWarn --- + +async function logWarn(context, message, overrideClient = null) { + const embed = new EmbedBuilder() + .setTitle(`Warning: ${context}`) + .setDescription(String(message).slice(0, 4000)) + .setColor(0xFFFF00) + .setTimestamp(); + await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient); +} + +// --- logEvent (generic – posts to any configured channel) --- + +async function logEvent(channelConfigKey, embed, overrideClient = null) { + const channelId = CONFIG[channelConfigKey]; + await sendToChannel(channelId, embed, overrideClient); +} + +// --- logTicketEvent --- + +async function logTicketEvent(action, fields, interaction = null) { + const embed = new EmbedBuilder() + .setTitle(action) + .setColor(CONFIG.EMBED_COLOR_INFO || 0x1e2124) + .addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true }))) + .setTimestamp(); + if (interaction?.user?.tag) { + embed.setFooter({ text: interaction.user.tag }); + } + await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client); +} + +// --- logGmail --- + +async function logGmail(subject, sender, ticketNumber, game) { + const embed = new EmbedBuilder() + .setTitle('Email Ticket Created') + .setColor(0x00BFFF) + .addFields( + { name: 'Subject', value: String(subject || 'No subject').slice(0, 256), inline: false }, + { name: 'Sender', value: String(sender || 'unknown'), inline: true }, + { name: 'Ticket #', value: String(ticketNumber || '?'), inline: true }, + { name: 'Game', value: String(game || 'Not detected'), inline: true } + ) + .setTimestamp(); + await sendToChannel(CONFIG.GMAIL_LOG_CHANNEL_ID, embed); +} + +// --- logAutomation --- + +async function logAutomation(action, ticketChannelName, detail) { + const embed = new EmbedBuilder() + .setTitle(action) + .setColor(0x9B59B6) + .setTimestamp(); + if (ticketChannelName) { + embed.addFields({ name: 'Ticket', value: String(ticketChannelName), inline: true }); + } + if (detail) { + embed.addFields({ name: 'Detail', value: String(detail).slice(0, 1024), inline: false }); + } + await sendToChannel(CONFIG.AUTOMATION_LOG_CHANNEL_ID, embed); +} + +// --- logSecurity --- + +async function logSecurity(action, user, detail, overrideClient = null, color = 0xFF6600) { + const embed = new EmbedBuilder() + .setTitle('Security Event') + .setColor(color) + .addFields( + { name: 'Action', value: String(action).slice(0, 256), inline: false }, + { name: 'User', value: user ? `${user.tag} (${user.id})` : 'Unknown', inline: true }, + { name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false }, + { name: 'Timestamp', value: new Date().toISOString(), inline: true } + ) + .setTimestamp(); + await sendToChannel(CONFIG.SECURITY_LOG_CHANNEL_ID, embed, overrideClient); +} + +// --- logIntegrity --- + +async function logIntegrity(issue, detail, overrideClient = null) { + const embed = new EmbedBuilder() + .setTitle('Ticket Integrity Issue') + .setColor(0xFF0000) + .addFields( + { name: 'Issue', value: String(issue).slice(0, 256), inline: false }, + { name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false }, + { name: 'Timestamp', value: new Date().toISOString(), inline: true } + ) + .setTimestamp(); + await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient); +} + +// --- logSystem --- + +async function logSystem(message, fields = [], overrideClient = null, color = 0x0099ff) { + const embed = new EmbedBuilder() + .setTitle(message) + .setColor(color) + .setTimestamp(); + if (fields.length > 0) { + embed.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true }))); + } + embed.addFields({ name: 'Timestamp', value: new Date().toISOString(), inline: true }); + await sendToChannel(CONFIG.SYSTEM_LOG_CHANNEL_ID, embed, overrideClient); +} + +module.exports = { + setClient, + logError, + logWarn, + logEvent, + logTicketEvent, + logGmail, + logAutomation, + logSecurity, + logIntegrity, + logSystem +}; diff --git a/services/patternChecker.js b/services/patternChecker.js new file mode 100644 index 0000000..a50ec30 --- /dev/null +++ b/services/patternChecker.js @@ -0,0 +1,535 @@ +/** + * Pattern detection — scheduled checks that analyze ticket trends and post + * alerts to dedicated Discord channels. + */ +const { EmbedBuilder } = require('discord.js'); +const { CONFIG } = require('../config'); +const { mongoose } = require('../db-connection'); +const { getAll, get } = require('./patternStore'); + +const Ticket = mongoose.model('Ticket'); + +// Deduplication: keys that have already fired today +const firedToday = new Set(); + +// Register daily reset +const { onDailyReset } = require('./patternStore'); +onDailyReset(() => firedToday.clear()); + +// --- Helpers --- + +function buildEmbed(title, description, color = 0xFFAA00) { + return new EmbedBuilder() + .setTitle(title) + .setDescription(String(description).slice(0, 4000)) + .setColor(color) + .setTimestamp(); +} + +async function postPattern(client, channelConfigKey, embed) { + const channelId = CONFIG[channelConfigKey]; + if (!channelId || !client) return; + try { + const channel = await client.channels.fetch(channelId); + if (channel) await channel.send({ embeds: [embed] }); + } catch (_) {} +} + +function shouldFire(key) { + if (firedToday.has(key)) return false; + firedToday.add(key); + return true; +} + +function getThisWeekStart() { + const now = new Date(); + const day = now.getDay(); + const diff = day === 0 ? 6 : day - 1; + const monday = new Date(now); + monday.setDate(now.getDate() - diff); + monday.setHours(0, 0, 0, 0); + return monday; +} + +// --- Check functions --- + +async function checkUserPatterns(client) { + // Surge: users with tickets >= threshold today + const todayCounts = getAll('user_tickets', 'today'); + for (const [userId, count] of todayCounts) { + if (count >= CONFIG.PATTERN_USER_TICKET_THRESHOLD) { + const key = `user_tickets:${userId}:today`; + if (shouldFire(key)) { + postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed( + 'Repeat ticket user', + `User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`, + 0xFFAA00 + )); + } + } + } + + // Reopens this week + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + try { + const reopens = await Ticket.aggregate([ + { $match: { reopenedAt: { $gte: since } } }, + { $group: { _id: '$senderEmail', count: { $sum: 1 } } }, + { $match: { count: { $gte: 2 } } } + ]); + for (const r of reopens) { + const key = `user_reopen:${r._id}:week`; + if (shouldFire(key)) { + postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed( + 'High reopen rate', + `${r._id} reopened tickets ${r.count}x this week`, + 0xFFAA00 + )); + } + } + } catch (_) {} + + // Cross-game: users with tickets across 3+ games this week + try { + const crossGame = await Ticket.aggregate([ + { $match: { createdAt: { $gte: since }, status: { $ne: 'closed' } } }, + { $group: { _id: '$senderEmail', games: { $addToSet: '$game' } } }, + { $match: { 'games.2': { $exists: true } } } + ]); + for (const c of crossGame) { + const key = `user_crossgame:${c._id}:week`; + if (shouldFire(key)) { + postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed( + 'Cross-game user', + `${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`, + 0x00AAFF + )); + } + } + } catch (_) {} +} + +async function checkGamePatterns(client) { + // Surge: games with tickets >= threshold today + const todayCounts = getAll('game_tickets', 'today'); + for (const [game, count] of todayCounts) { + if (count >= CONFIG.PATTERN_GAME_TICKET_THRESHOLD) { + const key = `game_surge:${game}:today`; + if (shouldFire(key)) { + postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( + 'Game ticket surge', + `**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`, + 0xFF6600 + )); + } + } + } + + // Backlog: unclaimed tickets older than threshold + try { + const cutoff = new Date(Date.now() - CONFIG.PATTERN_UNCLAIMED_HOURS * 3600000); + const backlog = await Ticket.aggregate([ + { $match: { status: 'open', claimedBy: null, createdAt: { $lte: cutoff } } }, + { $group: { _id: '$game', count: { $sum: 1 } } }, + { $match: { count: { $gte: 3 } } } + ]); + for (const b of backlog) { + const gameName = b._id || 'Unknown'; + const key = `game_backlog:${gameName}:today`; + if (shouldFire(key)) { + postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( + 'Game backlog alert', + `**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`, + 0xFF0000 + )); + } + } + } catch (_) {} + + // Resolution time trending: this week vs last week + try { + const thisWeekStart = getThisWeekStart(); + const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000); + const thisWeek = await Ticket.aggregate([ + { $match: { status: 'closed', closedAt: { $gte: thisWeekStart }, game: { $ne: null } } }, + { $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } } + ]); + const lastWeek = await Ticket.aggregate([ + { $match: { status: 'closed', closedAt: { $gte: lastWeekStart, $lt: thisWeekStart }, game: { $ne: null } } }, + { $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } } + ]); + const lastWeekMap = new Map(lastWeek.map(l => [l._id, l.avg])); + for (const tw of thisWeek) { + const lw = lastWeekMap.get(tw._id); + if (lw && tw.avg > lw * 1.2) { + const key = `game_resolution:${tw._id}:week`; + if (shouldFire(key)) { + const twHrs = (tw.avg / 3600000).toFixed(1); + const lwHrs = (lw / 3600000).toFixed(1); + postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( + 'Resolution time increasing', + `**${tw._id}**: ${twHrs}h avg this week vs ${lwHrs}h last week (+${((tw.avg / lw - 1) * 100).toFixed(0)}%).`, + 0xFFAA00 + )); + } + } + } + } catch (_) {} + + // Spike after silence: games with 0 tickets in last 3 days but 3+ today + try { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); + const recentByGame = await Ticket.aggregate([ + { $match: { createdAt: { $gte: threeDaysAgo, $lt: todayStart }, game: { $ne: null } } }, + { $group: { _id: '$game', count: { $sum: 1 } } } + ]); + const recentGames = new Set(recentByGame.map(r => r._id)); + for (const [game, count] of todayCounts) { + if (count >= 3 && !recentGames.has(game)) { + const key = `game_spike:${game}:today`; + if (shouldFire(key)) { + postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed( + 'Possible outage', + `**${game}**: ${count} tickets today after 0 in the last 3 days.`, + 0xFF0000 + )); + } + } + } + } catch (_) {} +} + +async function checkTagPatterns(client) { + // Most common tag today + const todayTags = getAll('tag_usage', 'today'); + let topTag = null, topCount = 0; + for (const [tag, count] of todayTags) { + if (count > topCount) { topTag = tag; topCount = count; } + } + if (topTag && topCount >= 5) { + const key = `tag_top:${topTag}:today`; + if (shouldFire(key)) { + postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( + 'Top issue tag today', + `**${topTag}** used ${topCount} times today.`, + 0x00AAFF + )); + } + } + + // Tag→escalation correlation + try { + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const tagEscalations = await Ticket.aggregate([ + { $match: { createdAt: { $gte: since }, escalationTier: { $gte: 1 }, ticketTag: { $ne: null } } }, + { $group: { _id: '$ticketTag', count: { $sum: 1 } } }, + { $match: { count: { $gte: 3 } } } + ]); + for (const te of tagEscalations) { + const key = `tag_escalation:${te._id}:week`; + if (shouldFire(key)) { + postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( + 'Tag frequently leads to escalation', + `**${te._id}**: ${te.count} escalated tickets this week.`, + 0xFFAA00 + )); + } + } + } catch (_) {} + + // Untagged closes + const untaggedCount = get('untagged_closes', 'total', 'today'); + if (untaggedCount >= 5) { + const key = 'untagged_closes:today'; + if (shouldFire(key)) { + postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed( + 'High untagged close rate', + `${untaggedCount} tickets closed today without a tag.`, + 0xFFAA00 + )); + } + } + + // Tag↔game correlation: for each tag this week, check if one game dominates + const weekTags = getAll('tag_usage', 'week'); + for (const [tag] of weekTags) { + const tagGameCounts = getAll(`tag_game:${tag}`, 'week'); + let total = 0, maxGame = null, maxCount = 0; + for (const [game, count] of tagGameCounts) { + total += count; + if (count > maxCount) { maxGame = game; maxCount = count; } + } + if (total >= 5 && maxGame && maxCount / total > 0.8) { + const key = `tag_game_corr:${tag}:${maxGame}:week`; + if (shouldFire(key)) { + postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( + 'Auto-tagging opportunity', + `**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`, + 0x00AAFF + )); + } + } + } +} + +async function checkEscalationPatterns(client) { + // User escalation rate + const userEscalations = getAll('user_escalations', 'week'); + for (const [user, count] of userEscalations) { + if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) { + const key = `user_esc:${user}:week`; + if (shouldFire(key)) { + postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed( + 'Frequent escalation user', + `\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`, + 0xFFAA00 + )); + } + } + } + + // Game escalation rate vs baseline + try { + const thisWeekStart = getThisWeekStart(); + const thisWeek = await Ticket.aggregate([ + { $match: { escalationTier: { $gte: 1 }, createdAt: { $gte: thisWeekStart } } }, + { $group: { _id: '$game', count: { $sum: 1 } } } + ]); + const totalThisWeek = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart } }); + for (const tw of thisWeek) { + if (!tw._id) continue; + const gameTotal = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart }, game: tw._id }); + if (gameTotal > 0 && tw.count / gameTotal > 0.5) { + const key = `game_esc_rate:${tw._id}:week`; + if (shouldFire(key)) { + postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed( + 'High escalation rate for game', + `**${tw._id}**: ${tw.count}/${gameTotal} tickets escalated (${Math.round(tw.count / gameTotal * 100)}%) this week.`, + 0xFF6600 + )); + } + } + } + } catch (_) {} + + // Rapid tier 2→3 + try { + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const rapid = await Ticket.find({ + escalationTier: 2, + escalatedAt: { $gte: since } + }).lean(); + // Count tickets where escalation happened very quickly (approximate: check if tier was changed recently) + const rapidCount = rapid.length; + if (rapidCount >= 3) { + const key = 'rapid_t2_t3:week'; + if (shouldFire(key)) { + postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed( + 'Tier 2 unable to handle issue type', + `${rapidCount} tickets reached tier 3 this week.`, + 0xFF0000 + )); + } + } + } catch (_) {} +} + +async function checkStaffPatterns(client) { + // Claims without closes + const todayClaims = getAll('staff_claims', 'today'); + for (const [staffId, claims] of todayClaims) { + if (claims >= 3 && get('staff_closes', staffId, 'today') === 0) { + const key = `staff_no_close:${staffId}:today`; + if (shouldFire(key)) { + postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( + 'Claims without closes', + `Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`, + 0xFFAA00 + )); + } + } + } + + // Overloaded: open tickets per claimer + try { + const overloaded = await Ticket.aggregate([ + { $match: { status: 'open', claimerId: { $ne: null } } }, + { $group: { _id: '$claimerId', count: { $sum: 1 } } }, + { $match: { count: { $gte: 5 } } } + ]); + for (const o of overloaded) { + const key = `staff_overloaded:${o._id}:today`; + if (shouldFire(key)) { + postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( + 'Staff overloaded', + `Staff \`${o._id}\` has ${o.count} open claimed tickets.`, + 0xFF6600 + )); + } + } + } catch (_) {} + + // Stale ping threshold + const stalePings = getAll('staff_stale_pings', 'today'); + for (const [staffId, count] of stalePings) { + if (count >= CONFIG.PATTERN_STAFF_STALE_PING_THRESHOLD) { + const key = `staff_stale:${staffId}:today`; + if (shouldFire(key)) { + postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( + 'Staff stale ping threshold', + `Staff \`${staffId}\` received ${count} stale pings today.`, + 0xFFAA00 + )); + } + } + } + + // Transfer rate + const todayTransfers = getAll('staff_transfers', 'today'); + for (const [staffId, transfers] of todayTransfers) { + const claims = get('staff_claims', staffId, 'today'); + if (claims > 0 && transfers >= claims) { + const key = `staff_transfer_rate:${staffId}:today`; + if (shouldFire(key)) { + postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( + 'High transfer rate', + `Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`, + 0xFFAA00 + )); + } + } + } + + // Escalations per staff + const weekEscalations = getAll('staff_escalations', 'week'); + for (const [staffId, count] of weekEscalations) { + if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) { + const key = `staff_esc:${staffId}:week`; + if (shouldFire(key)) { + postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed( + 'Staff frequent escalator', + `Staff \`${staffId}\` escalated ${count} tickets this week.`, + 0xFFAA00 + )); + } + } + } +} + +async function checkCombinedPatterns(client) { + // Staff+game escalation correlation + const weekEscStaff = getAll('staff_escalations', 'week'); + for (const [staffId] of weekEscStaff) { + const gameEsc = getAll(`staff_game_escalations:${staffId}`, 'week'); + for (const [game, count] of gameEsc) { + if (count >= 3) { + const key = `staff_game_esc:${staffId}:${game}:week`; + if (shouldFire(key)) { + postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( + 'Staff may need training for this game', + `Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`, + 0xFFAA00 + )); + } + } + } + } + + // Game+tag spike: specific game+tag combo >= 5 today + const todayGames = getAll('game_tickets', 'today'); + const todayTags = getAll('tag_usage', 'today'); + for (const [game] of todayGames) { + for (const [tag] of todayTags) { + const tagGameCount = get(`tag_game:${tag}`, game, 'week'); + if (tagGameCount >= 5) { + const key = `game_tag_spike:${game}:${tag}:today`; + if (shouldFire(key)) { + postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( + 'Specific feature of specific game spiking', + `**${game}** + **${tag}**: ${tagGameCount} tickets this week.`, + 0xFF6600 + )); + } + } + } + } + + // Overnight escalation gap: compare 00:00-06:00 vs daytime escalation rates + try { + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const overnight = await Ticket.countDocuments({ + createdAt: { $gte: since }, + escalationTier: { $gte: 1 }, + $expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] } + }); + const daytime = await Ticket.countDocuments({ + createdAt: { $gte: since }, + escalationTier: { $gte: 1 }, + $expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] } + }); + const overnightTotal = await Ticket.countDocuments({ + createdAt: { $gte: since }, + $expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] } + }); + const daytimeTotal = await Ticket.countDocuments({ + createdAt: { $gte: since }, + $expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] } + }); + if (overnightTotal > 0 && daytimeTotal > 0) { + const overnightRate = overnight / overnightTotal; + const daytimeRate = daytime / daytimeTotal; + if (overnightRate > daytimeRate * 2 && overnight >= 3) { + const key = 'overnight_gap:week'; + if (shouldFire(key)) { + postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( + 'Overnight coverage gap', + `Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`, + 0xFF0000 + )); + } + } + } + } catch (_) {} + + // Staff never resolves game X without escalating + try { + const monthStart = new Date(); + monthStart.setDate(1); + monthStart.setHours(0, 0, 0, 0); + const staffGameStats = await Ticket.aggregate([ + { $match: { claimerId: { $ne: null }, game: { $ne: null }, createdAt: { $gte: monthStart } } }, + { $group: { + _id: { staff: '$claimerId', game: '$game' }, + total: { $sum: 1 }, + escalated: { $sum: { $cond: [{ $gte: ['$escalationTier', 1] }, 1, 0] } } + }}, + { $match: { total: { $gte: 3 } } } + ]); + for (const s of staffGameStats) { + if (s.escalated / s.total >= 0.9) { + const key = `staff_always_esc:${s._id.staff}:${s._id.game}:month`; + if (shouldFire(key)) { + postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed( + 'Staff always escalates this game', + `Staff \`${s._id.staff}\` escalated ${s.escalated}/${s.total} **${s._id.game}** tickets this month.`, + 0xFF6600 + )); + } + } + } + } catch (_) {} +} + +// --- Main entry point --- + +async function runPatternChecks(client) { + try { await checkUserPatterns(client); } catch (e) { console.error('checkUserPatterns:', e); } + try { await checkGamePatterns(client); } catch (e) { console.error('checkGamePatterns:', e); } + try { await checkTagPatterns(client); } catch (e) { console.error('checkTagPatterns:', e); } + try { await checkEscalationPatterns(client); } catch (e) { console.error('checkEscalationPatterns:', e); } + try { await checkStaffPatterns(client); } catch (e) { console.error('checkStaffPatterns:', e); } + try { await checkCombinedPatterns(client); } catch (e) { console.error('checkCombinedPatterns:', e); } +} + +module.exports = { runPatternChecks }; diff --git a/services/patternStore.js b/services/patternStore.js new file mode 100644 index 0000000..3e3d625 --- /dev/null +++ b/services/patternStore.js @@ -0,0 +1,148 @@ +/** + * In-memory counter store with TTL windows for pattern detection. + * Windows: 'today' resets at midnight, 'week' resets Monday 00:00, 'month' resets 1st 00:00. + */ + +// store[window][namespace][key] = count +const store = { + today: new Map(), + week: new Map(), + month: new Map() +}; + +function getNamespaceMap(window, namespace) { + const windowMap = store[window]; + if (!windowMap) return null; + if (!windowMap.has(namespace)) windowMap.set(namespace, new Map()); + return windowMap.get(namespace); +} + +function increment(namespace, key, window) { + const map = getNamespaceMap(window, namespace); + if (!map) return; + map.set(key, (map.get(key) || 0) + 1); +} + +function get(namespace, key, window) { + const map = getNamespaceMap(window, namespace); + if (!map) return 0; + return map.get(key) || 0; +} + +function reset(namespace, window) { + const windowMap = store[window]; + if (!windowMap) return; + windowMap.delete(namespace); +} + +function getAll(namespace, window) { + const map = getNamespaceMap(window, namespace); + if (!map) return new Map(); + return new Map(map); +} + +// --- Scheduled resets --- + +function msUntilNextMidnight() { + const now = new Date(); + const next = new Date(now); + next.setHours(24, 0, 0, 0); + return next.getTime() - now.getTime(); +} + +function msUntilNextMonday() { + const now = new Date(); + const day = now.getDay(); // 0=Sun + const daysUntilMonday = day === 0 ? 1 : (8 - day); + const next = new Date(now); + next.setDate(now.getDate() + daysUntilMonday); + next.setHours(0, 0, 0, 0); + return next.getTime() - now.getTime(); +} + +function msUntilNextMonth() { + const now = new Date(); + const next = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0); + return next.getTime() - now.getTime(); +} + +// Callbacks to run on daily reset (e.g. clear firedToday in patternChecker) +const dailyResetCallbacks = []; + +function onDailyReset(fn) { + dailyResetCallbacks.push(fn); +} + +function scheduleDailyReset() { + setTimeout(() => { + store.today = new Map(); + for (const fn of dailyResetCallbacks) { + try { fn(); } catch (_) {} + } + scheduleDailyReset(); + }, msUntilNextMidnight()); +} + +function scheduleWeeklyReset() { + setTimeout(() => { + store.week = new Map(); + scheduleWeeklyReset(); + }, msUntilNextMonday()); +} + +function scheduleMonthlyReset() { + setTimeout(() => { + store.month = new Map(); + scheduleMonthlyReset(); + }, msUntilNextMonth()); +} + +function scheduleResets() { + scheduleDailyReset(); + scheduleWeeklyReset(); + scheduleMonthlyReset(); +} + +// --- Cooldown store --- +const cooldowns = new Map(); + +function setCooldown(key) { + cooldowns.set(key, Date.now()); +} + +function isOnCooldown(key, cooldownMinutes) { + const last = cooldowns.get(key); + if (!last) return false; + return (Date.now() - last) < cooldownMinutes * 60 * 1000; +} + +// --- Staff last-seen tracker (fallback for missing presence intent) --- +const staffLastSeen = new Map(); + +function updateStaffLastSeen(staffId) { + staffLastSeen.set(staffId, Date.now()); +} + +function getStaffLastSeen(staffId) { + return staffLastSeen.get(staffId) || null; +} + +function isStaffRecentlyActive(staffId, withinMinutes = 60) { + const last = staffLastSeen.get(staffId); + if (!last) return false; + return (Date.now() - last) < withinMinutes * 60 * 1000; +} + +module.exports = { + increment, + get, + reset, + getAll, + scheduleResets, + onDailyReset, + setCooldown, + isOnCooldown, + updateStaffLastSeen, + getStaffLastSeen, + isStaffRecentlyActive +}; diff --git a/services/pinMessage.js b/services/pinMessage.js new file mode 100644 index 0000000..e51bcda --- /dev/null +++ b/services/pinMessage.js @@ -0,0 +1,41 @@ +/** + * Auto-pin utility — pins a message with error handling and optional + * system message suppression. + * + * Discord rate-limits pin operations to approximately 5 per second per + * channel. Since pins only happen on ticket creation and escalation (low + * frequency), no additional rate limiting is needed. The bot requires + * MANAGE_MESSAGES permission to pin — if this is missing, the pin will + * fail with code 50013 and be caught by the catch block. + */ +const { CONFIG } = require('../config'); +const { logWarn } = require('./debugLog'); + +/** + * Pin a message in a channel. + * @param {import('discord.js').Message} message + * @param {import('discord.js').Client} client + */ +async function pinMessage(message, client) { + try { + await message.pin(); + + if (CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE) { + await new Promise(r => setTimeout(r, 1000)); + const systemMessages = await message.channel.messages.fetch({ limit: 5 }); + const pinNotice = systemMessages.find(m => + m.type === 6 && // MessageType.ChannelPinnedMessage + Date.now() - m.createdTimestamp < 10000 + ); + if (pinNotice) await pinNotice.delete().catch(() => {}); + } + } catch (err) { + if (err.code === 30003) { + await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {}); + } else { + await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {}); + } + } +} + +module.exports = { pinMessage }; diff --git a/services/staffNotifications.js b/services/staffNotifications.js index 63532da..ebd7d4f 100644 --- a/services/staffNotifications.js +++ b/services/staffNotifications.js @@ -10,6 +10,7 @@ */ const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); +const { increment } = require('./patternStore'); const Ticket = mongoose.model('Ticket'); const StaffNotification = mongoose.model('StaffNotification'); @@ -96,6 +97,8 @@ async function notifyAllStaffUnclaimed(client) { const chan = await guild.channels.fetch(rec.channelId).catch(() => null); if (chan) { await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e)); + increment('staff_stale_pings', rec.userId, 'today'); + increment('staff_stale_pings', rec.userId, 'week'); } } diff --git a/services/staffPresence.js b/services/staffPresence.js new file mode 100644 index 0000000..c00fb76 --- /dev/null +++ b/services/staffPresence.js @@ -0,0 +1,48 @@ +/** + * Staff presence detection — checks Discord presence status for staff members. + * Requires GuildPresences intent enabled in Discord Developer Portal. + */ +const { CONFIG } = require('../config'); + +/** + * Get categorized availability of all configured staff members. + * @param {import('discord.js').Guild} guild + * @returns {{ online: string[], dnd: string[], offline: string[], unknown: string[] }} + */ +function getStaffAvailability(guild) { + const results = { + online: [], + dnd: [], + offline: [], + unknown: [] + }; + + for (const staffId of CONFIG.STAFF_IDS) { + const member = guild.members.cache.get(staffId); + if (!member) { results.offline.push(staffId); continue; } + + const status = member.presence?.status; + if (!status) { results.unknown.push(staffId); continue; } + + if (status === 'online' || status === 'idle') results.online.push(staffId); + else if (status === 'dnd') results.dnd.push(staffId); + else results.offline.push(staffId); + } + + return results; +} + +/** + * Check if any staff member is currently available. + * @param {import('discord.js').Guild} guild + * @returns {{ available: boolean|null, source: string }} + */ +function isAnyStaffAvailable(guild) { + const { online, dnd, unknown } = getStaffAvailability(guild); + if (online.length > 0) return { available: true, source: 'presence' }; + if (CONFIG.STAFF_DND_COUNTS_AS_AVAILABLE && dnd.length > 0) return { available: true, source: 'presence_dnd' }; + if (unknown.length === CONFIG.STAFF_IDS.length) return { available: null, source: 'unknown' }; + return { available: false, source: 'presence' }; +} + +module.exports = { getStaffAvailability, isAnyStaffAvailable }; diff --git a/services/staffThread.js b/services/staffThread.js new file mode 100644 index 0000000..20c68fa --- /dev/null +++ b/services/staffThread.js @@ -0,0 +1,96 @@ +/** + * Staff discussion threads — creates a private thread on each ticket channel + * for staff-only communication. + * + * Notes: + * - The bot requires CREATE_PRIVATE_THREADS and SEND_MESSAGES_IN_THREADS + * permissions on every ticket category. + * - Private threads (type: 12) require the server to have Community features + * OR the channel to be in a server with Boost level that unlocks private + * threads. If thread creation fails with code 50024 or 160004, a warning + * is logged via logWarn. + * - invitable: false means only staff with MANAGE_THREADS can add additional + * members — this is intentional for privacy. + * - guild.members.fetch() in addRoleMembersToThread can be slow on large + * servers. The 300ms delay between adds avoids the thread member add rate + * limit (approximately 5/second). + */ +const { CONFIG } = require('../config'); +const { logError, logWarn } = require('./debugLog'); + +/** + * Create a private staff thread on a ticket channel. + * @param {import('discord.js').TextChannel} channel + * @param {import('discord.js').Client} client + * @returns {Promise} + */ +async function createStaffThread(channel, client) { + if (!CONFIG.STAFF_THREAD_ENABLED) return null; + + try { + const threadName = CONFIG.STAFF_THREAD_NAME.slice(0, 100); + + const thread = await channel.threads.create({ + name: threadName, + type: 12, // ChannelType.PrivateThread + invitable: false, + reason: 'Staff discussion thread for ticket' + }); + + if (CONFIG.STAFF_THREAD_AUTO_ADD_ROLE && CONFIG.STAFF_THREAD_ROLE_ID) { + await addRoleMembersToThread(thread, channel.guild, client); + } + + return thread; + } catch (err) { + // Detect permission / channel type errors + if (err.code === 50024 || err.code === 160004) { + logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {}); + } + await logError('staffThread:create', err, null, client).catch(() => {}); + return null; + } +} + +/** + * Add all members of the staff role to the thread. + */ +async function addRoleMembersToThread(thread, guild, client) { + try { + const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null); + if (!role) return; + + await guild.members.fetch(); + const members = guild.members.cache.filter(m => + m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot + ); + + for (const [, member] of members) { + await thread.members.add(member.id).catch(() => {}); + await new Promise(r => setTimeout(r, 300)); + } + } catch (err) { + await logError('staffThread:addMembers', err, null, client).catch(() => {}); + } +} + +/** + * Add a single member to the staff thread for a ticket channel. + * Call this when a ticket is claimed. + */ +async function addMemberToStaffThread(channel, memberId) { + if (!CONFIG.STAFF_THREAD_ENABLED) return; + + try { + const threads = await channel.threads.fetchActive(); + const staffThread = threads.threads.find(t => + t.name === CONFIG.STAFF_THREAD_NAME && t.type === 12 + ); + if (!staffThread) return; + await staffThread.members.add(memberId); + } catch { + // non-critical, ignore + } +} + +module.exports = { createStaffThread, addMemberToStaffThread }; diff --git a/services/surgeChecker.js b/services/surgeChecker.js new file mode 100644 index 0000000..0a3124f --- /dev/null +++ b/services/surgeChecker.js @@ -0,0 +1,191 @@ +/** + * Surge detection — checks for critical ticket volume/staffing conditions + * and pings ALL_STAFF_CHANNEL_ID with role mention. + */ +const { EmbedBuilder } = require('discord.js'); +const { CONFIG } = require('../config'); +const { mongoose } = require('../db-connection'); +const { setCooldown, isOnCooldown, isStaffRecentlyActive } = require('./patternStore'); +const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence'); + +const Ticket = mongoose.model('Ticket'); + +async function pingStaff(client, message, embedFields) { + const channelId = CONFIG.ALL_STAFF_CHANNEL_ID; + if (!channelId || !client) return; + try { + const channel = await client.channels.fetch(channelId); + if (!channel) return; + const embed = new EmbedBuilder() + .setTitle('Staff Alert') + .setDescription(message) + .setColor(0xFF4400) + .setTimestamp(); + if (embedFields.length > 0) { + embed.addFields(embedFields.map(f => ({ + name: f.name, + value: String(f.value).slice(0, 1024), + inline: f.inline ?? true + }))); + } + const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined; + await channel.send({ content, embeds: [embed] }); + } catch (_) {} +} + +async function checkTicketSurge(client) { + if (isOnCooldown('surge:tickets', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const since = new Date(Date.now() - CONFIG.SURGE_TICKET_WINDOW_MINUTES * 60000); + const count = await Ticket.countDocuments({ createdAt: { $gte: since } }); + if (count >= CONFIG.SURGE_TICKET_COUNT) { + setCooldown('surge:tickets'); + await pingStaff(client, + `${count} tickets created in the past ${CONFIG.SURGE_TICKET_WINDOW_MINUTES} minutes.`, + [{ name: 'Action needed', value: 'Check open tickets and claim.', inline: false }] + ); + } +} + +async function checkGameSurge(client) { + if (isOnCooldown('surge:game', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const since = new Date(Date.now() - CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES * 60000); + const gameCounts = await Ticket.aggregate([ + { $match: { createdAt: { $gte: since }, game: { $ne: null } } }, + { $group: { _id: '$game', count: { $sum: 1 } } }, + { $match: { count: { $gte: CONFIG.SURGE_GAME_TICKET_COUNT } } }, + { $sort: { count: -1 } } + ]); + if (gameCounts.length > 0) { + setCooldown('surge:game'); + const fields = gameCounts.map(g => ({ + name: g._id, + value: `${g.count} tickets in ${CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES} min`, + inline: true + })); + await pingStaff(client, 'Game ticket surge detected.', fields); + } +} + +async function checkStaleSurge(client) { + if (isOnCooldown('surge:stale', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const cutoff = new Date(Date.now() - CONFIG.SURGE_STALE_HOURS * 3600000); + const count = await Ticket.countDocuments({ + status: 'open', + lastActivity: { $lte: cutoff } + }); + if (count >= CONFIG.SURGE_STALE_COUNT) { + setCooldown('surge:stale'); + await pingStaff(client, + `${count} tickets have had no activity in the past ${CONFIG.SURGE_STALE_HOURS} hours.`, + [{ name: 'Action needed', value: 'Review and respond to stale tickets.', inline: false }] + ); + } +} + +async function checkNeedsResponseSurge(client) { + if (isOnCooldown('surge:needs_response', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const cutoff = new Date(Date.now() - CONFIG.SURGE_NEEDS_RESPONSE_HOURS * 3600000); + const count = await Ticket.countDocuments({ + status: 'open', + lastMessageAuthorIsStaff: false, + lastActivity: { $lte: cutoff } + }); + if (count >= CONFIG.SURGE_NEEDS_RESPONSE_COUNT) { + setCooldown('surge:needs_response'); + await pingStaff(client, + `${count} tickets are waiting on a staff response for over ${CONFIG.SURGE_NEEDS_RESPONSE_HOURS} hour(s).`, + [] + ); + } +} + +async function checkUnclaimedSurge(client) { + if (isOnCooldown('surge:unclaimed', CONFIG.SURGE_COOLDOWN_MINUTES)) return; + const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000); + const count = await Ticket.countDocuments({ + status: 'open', + claimedBy: null, + createdAt: { $lte: cutoff } + }); + if (count >= CONFIG.SURGE_UNCLAIMED_COUNT) { + setCooldown('surge:unclaimed'); + await pingStaff(client, + `${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`, + [] + ); + } +} + +async function checkTier3UnclaimedSurge(client) { + if (isOnCooldown('surge:tier3_unclaimed', 30)) return; + const cutoff = new Date(Date.now() - CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES * 60000); + const tickets = await Ticket.find({ + status: 'open', + escalationTier: 2, + claimedBy: null, + createdAt: { $lte: cutoff } + }).lean(); + if (tickets.length > 0) { + setCooldown('surge:tier3_unclaimed'); + await pingStaff(client, + `${tickets.length} Tier 3 ticket(s) unclaimed for over ${CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES} minutes.`, + tickets.map(t => ({ name: t.subject || 'No subject', value: `<#${t.discordThreadId}>`, inline: true })) + ); + } +} + +async function checkZeroStaffSurge(client) { + if (isOnCooldown('surge:no_staff', CONFIG.SURGE_NO_STAFF_COOLDOWN_MINUTES)) return; + if (!CONFIG.STAFF_IDS.length) return; + + const openCount = await Ticket.countDocuments({ status: 'open' }); + if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) return; + + const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); + if (!guild) return; + + const { available, source } = isAnyStaffAvailable(guild); + + let noStaff = false; + let detailLine = ''; + const { online, dnd, offline } = getStaffAvailability(guild); + + if (source === 'unknown') { + const recentlyActive = CONFIG.STAFF_IDS.filter(id => isStaffRecentlyActive(id, 60)); + if (recentlyActive.length === 0) { + noStaff = true; + detailLine = 'No staff active in the last 60 minutes (presence intent unavailable, using message activity fallback).'; + } + } else if (!available) { + noStaff = true; + const dndNote = dnd.length > 0 ? ` (${dnd.length} on DND)` : ''; + detailLine = `${offline.length} staff offline/invisible${dndNote}. ${online.length} online.`; + } + + if (!noStaff) return; + + setCooldown('surge:no_staff'); + + const fields = [ + { name: 'Open tickets', value: String(openCount), inline: true }, + { name: 'Detection method', value: source === 'unknown' ? 'Message activity' : 'Presence', inline: true }, + { name: source === 'unknown' ? 'Note' : 'Staff status', value: detailLine, inline: false } + ]; + + await pingStaff(client, + `${openCount} open ticket(s) with no staff available to respond.`, + fields + ); +} + +async function runSurgeChecks(client) { + try { await checkTicketSurge(client); } catch (e) { console.error('checkTicketSurge:', e); } + try { await checkGameSurge(client); } catch (e) { console.error('checkGameSurge:', e); } + try { await checkStaleSurge(client); } catch (e) { console.error('checkStaleSurge:', e); } + try { await checkNeedsResponseSurge(client); } catch (e) { console.error('checkNeedsResponseSurge:', e); } + try { await checkUnclaimedSurge(client); } catch (e) { console.error('checkUnclaimedSurge:', e); } + try { await checkTier3UnclaimedSurge(client); } catch (e) { console.error('checkTier3UnclaimedSurge:', e); } + try { await checkZeroStaffSurge(client); } catch (e) { console.error('checkZeroStaffSurge:', e); } +} + +module.exports = { runSurgeChecks }; diff --git a/services/tickets.js b/services/tickets.js index c377e0a..af43cc3 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -3,9 +3,10 @@ * reminders, auto-unclaim, channel creation. */ const { ChannelType, PermissionFlagsBits } = require('discord.js'); -const { mongoose } = require('../db-connection'); +const { mongoose, withRetry } = require('../db-connection'); const { CONFIG } = require('../config'); const { getPriorityEmoji } = require('../utils'); +const { logAutomation } = require('../services/debugLog'); const Ticket = mongoose.model('Ticket'); const TicketCounter = mongoose.model('TicketCounter'); @@ -472,12 +473,14 @@ 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)); - const staleTickets = await Ticket.find({ + const staleTickets = await withRetry(() => Ticket.find({ status: 'open', lastActivity: { $lt: cutoffTime, $ne: null } - }).lean(); + }).lean()); + let checked = 0, closed = 0; for (const ticket of staleTickets) { + checked++; try { const guild = client.guilds.cache.first(); if (!guild) continue; @@ -486,32 +489,36 @@ async function checkAutoClose(client, sendTicketClosedEmail) { if (channel) { await channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); - await Ticket.updateOne( + await withRetry(() => Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { status: 'closed' } } - ); + )); await sendTicketClosedEmail(ticket, 'Auto-Close System'); setTimeout(() => channel.delete().catch(() => {}), 5000); + closed++; } } catch (error) { console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error); } } + logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {}); } async function checkReminders(client) { if (!CONFIG.REMINDER_ENABLED) return; const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000)); - const ticketsNeedingReminder = await Ticket.find({ + const ticketsNeedingReminder = await withRetry(() => Ticket.find({ status: 'open', lastActivity: { $lt: reminderTime, $ne: null }, reminderSent: false - }).lean(); + }).lean()); + let checked = 0, reminded = 0; for (const ticket of ticketsNeedingReminder) { + checked++; try { const guild = client.guilds.cache.first(); if (!guild) continue; @@ -526,49 +533,55 @@ async function checkReminders(client) { .replace(/\{ping\}/g, ping); await channel.send(message); - await Ticket.updateOne( + await withRetry(() => Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { reminderSent: true } } - ); + )); + reminded++; } } catch (error) { console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error); } } + logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {}); } async function checkAutoUnclaim(client) { if (!CONFIG.AUTO_UNCLAIM_ENABLED) return; const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000)); - const staleClaimedTickets = await Ticket.find({ + const staleClaimedTickets = await withRetry(() => Ticket.find({ status: 'open', claimedBy: { $ne: null }, lastActivity: { $lt: unclaimTime, $ne: null } - }).lean(); + }).lean()); + let checked = 0, unclaimed = 0; for (const ticket of staleClaimedTickets) { + checked++; try { const guild = client.guilds.cache.first(); if (!guild) continue; const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); if (channel) { - await Ticket.updateOne( + await withRetry(() => Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { claimedBy: null } } - ); + )); await channel.send( `This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).` ); console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`); + unclaimed++; } } catch (error) { console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error); } } + logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {}); } module.exports = { diff --git a/settings-site/.env.example b/settings-site/.env.example new file mode 100644 index 0000000..c5d398c --- /dev/null +++ b/settings-site/.env.example @@ -0,0 +1,5 @@ +SETTINGS_PORT=12752 +SETTINGS_ADMIN_PASSWORD= +SETTINGS_DOMAIN=tickets.indifferentketchup.com +INTERNAL_API_PORT=12753 +INTERNAL_API_SECRET= diff --git a/settings-site/docker-compose.yml b/settings-site/docker-compose.yml new file mode 100644 index 0000000..991a412 --- /dev/null +++ b/settings-site/docker-compose.yml @@ -0,0 +1,13 @@ +services: + broccolini-settings: + build: . + container_name: broccolini-settings + restart: unless-stopped + env_file: ../.env + ports: + - "100.114.205.53:12752:12752" + network_mode: host + # network_mode: host is needed so the settings site can reach + # 127.0.0.1:12753 (the bot's internal API). If running both as + # Docker containers on the same host, use a shared Docker network + # instead and reference the bot container by name. diff --git a/settings-site/package.json b/settings-site/package.json new file mode 100644 index 0000000..808ac94 --- /dev/null +++ b/settings-site/package.json @@ -0,0 +1,15 @@ +{ + "name": "broccolini-settings", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.18.0", + "express-session": "^1.17.3", + "dotenv": "^16.0.0", + "node-fetch": "^2.7.0" + } +} diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css new file mode 100644 index 0000000..62387d2 --- /dev/null +++ b/settings-site/public/css/main.css @@ -0,0 +1,134 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --card: #1e2235; + --border: #2a2d3e; + --accent: #5865f2; + --accent-hover: #4752c4; + --success: #57f287; + --warning: #fee75c; + --danger: #ed4245; + --text: #e0e0e0; + --text-muted: #888; + --sidebar-width: 260px; +} + +body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); display: flex; min-height: 100vh; } + +/* Top bar */ +.topbar { position: fixed; top: 0; left: var(--sidebar-width); right: 0; height: 56px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 24px; z-index: 100; } +.topbar h1 { font-size: 16px; font-weight: 600; } +.topbar .status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-muted); } +.topbar .status .dot { width: 8px; height: 8px; border-radius: 50%; } +.topbar .status .dot.online { background: var(--success); } +.topbar .status .dot.offline { background: var(--danger); } +.topbar .actions { display: flex; gap: 12px; align-items: center; } +.topbar .actions button { background: none; border: 1px solid var(--border); color: var(--text-muted); padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: all 200ms; } +.topbar .actions button:hover { border-color: var(--accent); color: var(--text); } + +/* Sidebar */ +.sidebar { position: fixed; top: 0; left: 0; width: var(--sidebar-width); height: 100vh; background: var(--surface); border-right: 1px solid var(--border); padding: 16px 0; overflow-y: auto; z-index: 101; } +.sidebar .logo { padding: 12px 20px 24px; font-size: 18px; font-weight: 700; } +.sidebar a { display: flex; align-items: center; gap: 10px; padding: 10px 20px; color: var(--text-muted); text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; transition: all 200ms; } +.sidebar a:hover { color: var(--text); background: rgba(88,101,242,0.08); } +.sidebar a.active { color: var(--accent); border-left-color: var(--accent); background: rgba(88,101,242,0.1); } + +/* Main */ +.main { margin-left: var(--sidebar-width); margin-top: 56px; padding: 24px; flex: 1; padding-bottom: 100px; } + +/* Section cards */ +.section { margin-bottom: 24px; } +.section-header { background: var(--card); border: 1px solid var(--border); border-radius: 12px 12px 0 0; padding: 20px 24px; cursor: pointer; display: flex; align-items: center; gap: 12px; transition: background 200ms; } +.section-header:hover { background: #232740; } +.section-header h2 { font-size: 15px; font-weight: 600; flex: 1; } +.section-header p { font-size: 12px; color: var(--text-muted); } +.section-header .chevron { transition: transform 200ms; font-size: 18px; color: var(--text-muted); } +.section.collapsed .section-header { border-radius: 12px; } +.section.collapsed .section-body { display: none; } +.section.collapsed .chevron { transform: rotate(-90deg); } +.section-body { background: var(--card); border: 1px solid var(--border); border-top: none; border-radius: 0 0 12px 12px; padding: 24px; } + +/* Field grid */ +.field-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; } +.field { display: flex; flex-direction: column; gap: 6px; } +.field.full-width { grid-column: 1 / -1; } +.field label { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.field input, .field select, .field textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text); font-size: 14px; font-family: inherit; outline: none; transition: border-color 200ms; } +.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(88,101,242,0.2); } +.field textarea { min-height: 80px; resize: vertical; } +.field input.changed, .field select.changed, .field textarea.changed { border-color: var(--warning); } +.field .hint { font-size: 11px; color: var(--text-muted); } + +/* Toggle switch */ +.toggle-wrap { display: flex; align-items: center; gap: 12px; } +.toggle { position: relative; width: 44px; height: 24px; } +.toggle input { opacity: 0; width: 0; height: 0; } +.toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 12px; cursor: pointer; transition: background 200ms; } +.toggle .slider::before { content: ''; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; background: #fff; border-radius: 50%; transition: transform 200ms; } +.toggle input:checked + .slider { background: var(--accent); } +.toggle input:checked + .slider::before { transform: translateX(20px); } + +/* Color picker */ +.color-field { display: flex; align-items: center; gap: 10px; } +.color-field input[type="color"] { width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; background: none; padding: 2px; } + +/* Smart select */ +.smart-select { position: relative; } +.smart-select-display { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; min-height: 42px; transition: border-color 200ms; } +.smart-select-display:hover { border-color: var(--accent); } +.smart-select-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 10px; margin-top: 4px; z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); max-height: 300px; overflow: hidden; display: flex; flex-direction: column; } +.smart-select-dropdown.hidden { display: none; } +.ss-search { background: var(--bg); border: none; border-bottom: 1px solid var(--border); padding: 10px 14px; color: var(--text); font-size: 13px; outline: none; } +.ss-list { overflow-y: auto; max-height: 250px; padding: 4px; } +.ss-option { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 13px; transition: background 200ms; } +.ss-option:hover { background: rgba(88,101,242,0.15); } +.ss-option.selected { background: rgba(88,101,242,0.2); } +.ss-option.ss-clear { color: var(--text-muted); font-style: italic; } +.ss-label { flex: 1; } +.ss-sub { font-size: 11px; color: var(--text-muted); } +.ss-id { font-size: 11px; color: var(--text-muted); font-family: monospace; } +.ss-placeholder { color: var(--text-muted); } +.ss-avatar { width: 20px; height: 20px; border-radius: 50%; } +.ss-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } + +/* Save bar */ +.save-bar { position: fixed; bottom: 0; left: var(--sidebar-width); right: 0; background: var(--surface); border-top: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; transform: translateY(100%); transition: transform 300ms ease; z-index: 100; } +.save-bar.visible { transform: translateY(0); } +.save-bar span { font-size: 13px; color: var(--warning); font-weight: 500; } +.save-actions { display: flex; gap: 8px; } +.save-actions button { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: all 200ms; } +.save-actions button:first-child { background: var(--accent); color: #fff; } +.save-actions button:first-child:hover { background: var(--accent-hover); } +.save-actions button.secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); } +.save-actions button.secondary:hover { border-color: var(--accent); } +.save-actions button.danger { background: var(--danger); color: #fff; } +.save-actions button.danger:hover { background: #c9363a; } + +/* Toast */ +#toast-container { position: fixed; top: 72px; right: 24px; z-index: 300; display: flex; flex-direction: column; gap: 8px; } +.toast { padding: 12px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; animation: toast-in 300ms ease; } +.toast-success { background: rgba(87,242,135,0.15); color: var(--success); border: 1px solid rgba(87,242,135,0.3); } +.toast-warning { background: rgba(254,231,92,0.15); color: var(--warning); border: 1px solid rgba(254,231,92,0.3); } +.toast-error { background: rgba(237,66,69,0.15); color: var(--danger); border: 1px solid rgba(237,66,69,0.3); } +@keyframes toast-in { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } } + +/* Modal */ +.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 400; } +.modal.hidden { display: none; } +.modal-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; min-width: 340px; } +.modal-card h3 { margin-bottom: 16px; font-size: 16px; } +.modal-card input { width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 14px; margin-bottom: 16px; } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; } +.modal-actions button { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; } +.modal-actions button:first-child { background: var(--accent); color: #fff; } +.modal-actions button.secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); } + +/* Loading */ +.loading { position: fixed; inset: 0; background: var(--bg); display: flex; align-items: center; justify-content: center; z-index: 500; } +.loading.hidden { display: none; } +.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/settings-site/public/index.html b/settings-site/public/index.html new file mode 100644 index 0000000..280699c --- /dev/null +++ b/settings-site/public/index.html @@ -0,0 +1,311 @@ + + + + + + Broccolini Settings + + + +
+
+ + + + + +
+

Settings

+
+ + Checking... +
+
+
+
+
+ + +
+ + +
+

Core

Discord bot credentials and guild

+
+
+
+
+
+
+ + +
+

Channels

Channel assignments for logging, transcripts, and alerts

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+

Categories

Ticket category assignments and escalation targets

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+

Gmail

Google OAuth credentials and email settings

+
+
+
+
+
+
+
+ + +
+

Ticket Behavior

Automation, limits, and messages

+
+
Enabled
+
+
Enabled
+
+
Enabled
+
Enabled
+
+
Enabled
+
+
Enabled
+
+
+
+
+
+
Variables: {staff_mention}, {staff_name}
+
+
Variables: {support_name}
+
Variables: {ping}, {hours}
+
+
+ + +
+

Staff Threads

Private staff discussion threads on ticket channels

+
+
Enabled
+
+
Enabled
+
+
+
+ + +
+

Pin Messages

Auto-pin welcome and escalation messages

+
+
Enabled
+
Enabled
+
Enabled
+
+
+ + +
+

Surge Alerts

Ticket volume and staffing alerts

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enabled
+
+
+
+
+
+
+
+ + +
+

Pattern Detection

Thresholds for automated pattern alerts

+
+
+
+
+
+
+
+
+
+
+ + +
+

Logging

Log channel configuration (channels set in Channels section)

+
+

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

+
+
+ + +
+

Automation

Polling intervals and timer durations

+
+
+
+
+
+ + +
+

Appearance

Embed colors, button labels, and emojis

+
+
Open tickets
+
Closed tickets
+
Claimed tickets
+
Escalated tickets
+
Info embeds
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+

Staff

Staff IDs, emojis, and admin settings

+
+
+
+
Format: 123456:emoji,789012:emoji
+
Role IDs with staff permissions
+
Role IDs that cannot open tickets
+
e.g. 1,2,4
+
+
+ + +
+

Advanced

Ports, URLs, game list, branding

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Variables: {channel_name}, {email}, {date_opened}, {date_closed}
+
+
+
+ +
+ + +
+ 0 unsaved changes +
+ + + + +
+
+ + + + + + + + diff --git a/settings-site/public/js/app.js b/settings-site/public/js/app.js new file mode 100644 index 0000000..ccf5d37 --- /dev/null +++ b/settings-site/public/js/app.js @@ -0,0 +1,175 @@ +let savedConfig = {}; +let pendingChanges = {}; + +async function init() { + document.getElementById('loading').classList.remove('hidden'); + try { + const [config] = await Promise.all([ + fetch('/api/config').then(r => r.json()), + DiscordFields.fetchGuildData() + ]); + savedConfig = config; + document.getElementById('bot-status-dot').className = 'dot online'; + document.getElementById('bot-status-text').textContent = 'Connected'; + populateFields(config); + initSmartSelects(config); + } catch (e) { + document.getElementById('bot-status-dot').className = 'dot offline'; + document.getElementById('bot-status-text').textContent = 'Unreachable'; + } + document.getElementById('loading').classList.add('hidden'); + setupSectionToggles(); + setupSaveBar(); +} + +function populateFields(config) { + document.querySelectorAll('[data-key]').forEach(el => { + const key = el.dataset.key; + const value = config[key] || ''; + if (el.type === 'checkbox') { + el.checked = value === 'true' || value === true; + } else if (el.type === 'color') { + // Convert 0xRRGGBB to #RRGGBB + const num = parseInt(value) || 0; + el.value = '#' + num.toString(16).padStart(6, '0'); + } else { + el.value = value; + } + el.addEventListener('change', () => handleFieldChange(el, key)); + el.addEventListener('input', () => { + if (el.type === 'text' || el.type === 'number' || el.type === 'password' || el.tagName === 'TEXTAREA') { + handleFieldChange(el, key); + } + }); + }); +} + +function handleFieldChange(el, key) { + let value; + if (el.type === 'checkbox') { + value = el.checked ? 'true' : 'false'; + } else if (el.type === 'color') { + value = '0x' + el.value.slice(1).toUpperCase(); + } else { + value = el.value; + } + markChanged(key, value); + el.classList.toggle('changed', key in pendingChanges); +} + +function initSmartSelects(config) { + document.querySelectorAll('[data-smart]').forEach(el => { + const key = el.dataset.key; + const type = el.dataset.smart; + const value = config[key] || ''; + if (type === 'channel') DiscordFields.renderChannelSelect(el, value); + else if (type === 'category') DiscordFields.renderCategorySelect(el, value); + else if (type === 'role') DiscordFields.renderRoleSelect(el, value); + else if (type === 'member') DiscordFields.renderMemberSelect(el, value); + else if (type === 'multi-member') DiscordFields.renderMultiMemberSelect(el, value); + }); +} + +function setupSectionToggles() { + document.querySelectorAll('.section-header').forEach(header => { + header.addEventListener('click', () => { + header.closest('.section').classList.toggle('collapsed'); + }); + }); + // Sidebar navigation + document.querySelectorAll('.sidebar a').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const target = document.getElementById(link.getAttribute('href').slice(1)); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + target.classList.remove('collapsed'); + } + document.querySelectorAll('.sidebar a').forEach(l => l.classList.remove('active')); + link.classList.add('active'); + }); + }); +} + +function markChanged(key, value) { + if (String(value) === String(savedConfig[key] || '')) { + delete pendingChanges[key]; + } else { + pendingChanges[key] = value; + } + updateSaveBar(); +} + +function setupSaveBar() { + updateSaveBar(); +} + +function updateSaveBar() { + const bar = document.getElementById('save-bar'); + const count = Object.keys(pendingChanges).length; + bar.classList.toggle('visible', count > 0); + document.getElementById('change-count').textContent = + `${count} unsaved change${count !== 1 ? 's' : ''}`; +} + +async function saveConfig(mode) { + try { + const res = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pendingChanges) + }); + const data = await res.json(); + if (data.applied) { + for (const key of data.applied) savedConfig[key] = pendingChanges[key]; + pendingChanges = {}; + updateSaveBar(); + document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed')); + showToast(`${data.applied.length} settings saved.`, 'success'); + } + if (data.errors && data.errors.length > 0) { + showToast(`Errors: ${data.errors.join(', ')}`, 'error'); + } + if (mode === 'restart') { + await fetch('/api/restart', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'immediate' }) + }); + showToast('Restart initiated.', 'warning'); + } + } catch (e) { + showToast('Failed to save. Bot may be unreachable.', 'error'); + } +} + +function openScheduleModal() { + const modal = document.getElementById('schedule-modal'); + modal.classList.remove('hidden'); + const dt = document.getElementById('schedule-datetime'); + const min = new Date(Date.now() + 60000).toISOString().slice(0, 16); + dt.min = min; + dt.value = min; +} + +async function confirmScheduledRestart() { + const dt = document.getElementById('schedule-datetime').value; + if (!dt) return; + await fetch('/api/restart', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() }) + }); + document.getElementById('schedule-modal').classList.add('hidden'); + showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning'); +} + +function showToast(message, type = 'success') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + document.getElementById('toast-container').appendChild(toast); + setTimeout(() => toast.remove(), 3500); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/settings-site/public/js/discord.js b/settings-site/public/js/discord.js new file mode 100644 index 0000000..9622ff9 --- /dev/null +++ b/settings-site/public/js/discord.js @@ -0,0 +1,195 @@ +// Discord guild data cache +let guildData = null; +let guildDataPromise = null; + +async function fetchGuildData() { + if (guildData) return guildData; + if (guildDataPromise) return guildDataPromise; + guildDataPromise = fetch('/api/discord/guild') + .then(r => r.json()) + .then(data => { guildData = data; return data; }) + .catch(() => ({ channels: [], roles: [], members: [], categories: [] })); + return guildDataPromise; +} + +async function renderChannelSelect(el, currentValue, filter) { + const data = await fetchGuildData(); + const channels = filter ? data.channels.filter(filter) : data.channels; + renderSmartSelect(el, channels.map(c => ({ + id: c.id, + label: `#${c.name}`, + sub: c.parentId ? (data.categories.find(cat => cat.id === c.parentId)?.name || null) : null + })), currentValue); +} + +async function renderCategorySelect(el, currentValue) { + const data = await fetchGuildData(); + renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue); +} + +async function renderRoleSelect(el, currentValue) { + const data = await fetchGuildData(); + renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue); +} + +async function renderMemberSelect(el, currentValue) { + const data = await fetchGuildData(); + renderSmartSelect(el, data.members.map(m => ({ + id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar + })), currentValue); +} + +async function renderMultiMemberSelect(el, currentValue) { + const data = await fetchGuildData(); + const currentIds = (currentValue || '').split(',').map(s => s.trim()).filter(Boolean); + renderMultiSelect(el, data.members.map(m => ({ + id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar + })), currentIds); +} + +function renderSmartSelect(inputEl, options, currentValue) { + const wrapper = document.createElement('div'); + wrapper.className = 'smart-select'; + + const current = options.find(o => o.id === currentValue); + const display = document.createElement('div'); + display.className = 'smart-select-display'; + display.innerHTML = current + ? `${current.label}${current.id}` + : `Not set`; + + const dropdown = document.createElement('div'); + dropdown.className = 'smart-select-dropdown hidden'; + + const search = document.createElement('input'); + search.type = 'text'; + search.placeholder = 'Search...'; + search.className = 'ss-search'; + + const list = document.createElement('div'); + list.className = 'ss-list'; + + const clearOpt = document.createElement('div'); + clearOpt.className = 'ss-option ss-clear'; + clearOpt.textContent = 'Clear (not set)'; + clearOpt.addEventListener('click', () => { + inputEl.value = ''; + display.innerHTML = `Not set`; + dropdown.classList.add('hidden'); + inputEl.dispatchEvent(new Event('change')); + }); + list.appendChild(clearOpt); + + function renderOptions(filter = '') { + while (list.children.length > 1) list.removeChild(list.lastChild); + const filtered = filter + ? options.filter(o => o.label.toLowerCase().includes(filter.toLowerCase()) || (o.sub || '').toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter)) + : options; + for (const opt of filtered.slice(0, 50)) { + const item = document.createElement('div'); + item.className = 'ss-option' + (opt.id === inputEl.value ? ' selected' : ''); + let inner = ''; + if (opt.avatar) inner += ``; + if (opt.color && opt.color !== '#000000') inner += ``; + inner += `${opt.label}`; + if (opt.sub) inner += `${opt.sub}`; + item.innerHTML = inner; + item.addEventListener('click', () => { + inputEl.value = opt.id; + display.innerHTML = `${opt.label}${opt.id}`; + dropdown.classList.add('hidden'); + inputEl.dispatchEvent(new Event('change')); + }); + list.appendChild(item); + } + } + + renderOptions(); + search.addEventListener('input', () => renderOptions(search.value)); + display.addEventListener('click', () => { + dropdown.classList.toggle('hidden'); + if (!dropdown.classList.contains('hidden')) search.focus(); + }); + document.addEventListener('click', (e) => { + if (!wrapper.contains(e.target)) dropdown.classList.add('hidden'); + }); + + dropdown.appendChild(search); + dropdown.appendChild(list); + wrapper.appendChild(display); + wrapper.appendChild(dropdown); + inputEl.style.display = 'none'; + inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling); +} + +function renderMultiSelect(inputEl, options, currentIds) { + const wrapper = document.createElement('div'); + wrapper.className = 'smart-select'; + const selected = new Set(currentIds); + + function updateInput() { + inputEl.value = [...selected].join(','); + inputEl.dispatchEvent(new Event('change')); + } + + function renderChips() { + chipsEl.innerHTML = ''; + for (const id of selected) { + const opt = options.find(o => o.id === id); + const chip = document.createElement('span'); + chip.className = 'ss-option selected'; + chip.style.cssText = 'display:inline-flex;padding:4px 8px;margin:2px;border-radius:12px;font-size:12px;cursor:pointer;'; + chip.textContent = opt ? opt.label : id; + chip.title = 'Click to remove'; + chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); }); + chipsEl.appendChild(chip); + } + } + + const chipsEl = document.createElement('div'); + chipsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;'; + renderChips(); + + const addBtn = document.createElement('div'); + addBtn.className = 'smart-select-display'; + addBtn.innerHTML = '+ Add'; + + const dropdown = document.createElement('div'); + dropdown.className = 'smart-select-dropdown hidden'; + const search = document.createElement('input'); + search.type = 'text'; search.placeholder = 'Search...'; search.className = 'ss-search'; + const list = document.createElement('div'); + list.className = 'ss-list'; + + function renderOptions(filter = '') { + list.innerHTML = ''; + const filtered = filter + ? options.filter(o => !selected.has(o.id) && (o.label.toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter))) + : options.filter(o => !selected.has(o.id)); + for (const opt of filtered.slice(0, 50)) { + const item = document.createElement('div'); + item.className = 'ss-option'; + let inner = ''; + if (opt.avatar) inner += ``; + inner += `${opt.label}`; + if (opt.sub) inner += `${opt.sub}`; + item.innerHTML = inner; + item.addEventListener('click', () => { selected.add(opt.id); renderChips(); renderOptions(search.value); updateInput(); }); + list.appendChild(item); + } + } + renderOptions(); + search.addEventListener('input', () => renderOptions(search.value)); + addBtn.addEventListener('click', () => { dropdown.classList.toggle('hidden'); if (!dropdown.classList.contains('hidden')) search.focus(); }); + document.addEventListener('click', (e) => { if (!wrapper.contains(e.target)) dropdown.classList.add('hidden'); }); + + dropdown.appendChild(search); + dropdown.appendChild(list); + wrapper.appendChild(chipsEl); + wrapper.appendChild(addBtn); + wrapper.appendChild(dropdown); + inputEl.style.display = 'none'; + inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling); +} + +window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect }; diff --git a/settings-site/public/login.html b/settings-site/public/login.html new file mode 100644 index 0000000..b73e5ba --- /dev/null +++ b/settings-site/public/login.html @@ -0,0 +1,49 @@ + + + + + + Broccolini Settings - Login + + + + + +
+

Broccolini Settings

+

Enter the admin password to continue.

+
+ + +
Invalid password
+
+
+ + + diff --git a/settings-site/server.js b/settings-site/server.js new file mode 100644 index 0000000..5f11c61 --- /dev/null +++ b/settings-site/server.js @@ -0,0 +1,98 @@ +require('dotenv').config({ path: process.env.ENV_FILE || '../.env' }); +const express = require('express'); +const session = require('express-session'); +const path = require('path'); +const fetch = require('node-fetch'); + +const app = express(); +const PORT = parseInt(process.env.SETTINGS_PORT) || 12752; +const INTERNAL_URL = `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`; +const SECRET = process.env.INTERNAL_API_SECRET; +const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD; + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.static(path.join(__dirname, 'public'))); +app.use(session({ + secret: SECRET || 'fallback-secret-change-me', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: false, // set true if behind HTTPS proxy + maxAge: 8 * 60 * 60 * 1000 // 8 hours + } +})); + +// Auth middleware +function requireAuth(req, res, next) { + if (req.session?.authed) return next(); + res.redirect('/login'); +} + +// Internal API proxy helper +async function callBot(method, apiPath, body) { + const res = await fetch(`${INTERNAL_URL}${apiPath}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-internal-secret': SECRET + }, + body: body ? JSON.stringify(body) : undefined + }); + return res.json(); +} + +// Routes +app.get('/login', (req, res) => { + if (req.session?.authed) return res.redirect('/'); + res.sendFile(path.join(__dirname, 'public', 'login.html')); +}); + +app.post('/login', (req, res) => { + if (!ADMIN_PASSWORD) return res.status(503).json({ error: 'SETTINGS_ADMIN_PASSWORD not set' }); + if (req.body.password === ADMIN_PASSWORD) { + req.session.authed = true; + return res.json({ ok: true }); + } + res.status(401).json({ error: 'Invalid password' }); +}); + +app.post('/logout', (req, res) => { + req.session.destroy(); + res.redirect('/login'); +}); + +app.get('/', requireAuth, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// Proxy to bot internal API +app.get('/api/config', requireAuth, async (req, res) => { + try { res.json(await callBot('GET', '/config')); } + catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } +}); + +app.post('/api/config', requireAuth, async (req, res) => { + try { res.json(await callBot('POST', '/config', req.body)); } + catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } +}); + +app.get('/api/discord/guild', requireAuth, async (req, res) => { + try { res.json(await callBot('GET', '/discord/guild')); } + catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } +}); + +app.post('/api/restart', requireAuth, async (req, res) => { + try { res.json(await callBot('POST', '/restart', req.body)); } + catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } +}); + +app.get('/api/restart/status', requireAuth, async (req, res) => { + try { res.json(await callBot('GET', '/restart/status')); } + catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`[settings] running on port ${PORT}`); +}); diff --git a/utils.js b/utils.js index 0b814fa..95a7e81 100644 --- a/utils.js +++ b/utils.js @@ -275,7 +275,106 @@ function replaceVariables(template, context = {}) { return result; } +/** Sanitize user input for safe embedding in Discord code blocks. */ +function sanitizeEmbedText(str) { + if (str == null) return ''; + return String(str).replace(/```/g, "'''").trim(); +} + +// --- EMBED TRUNCATION --- + +/** Truncate a string for use as an embed field value (max 1024). */ +function truncateEmbedField(str, max = 1024) { + if (str == null) return ''; + const s = String(str); + return s.length > max ? s.slice(0, max - 3) + '...' : s; +} + +/** Truncate a string for use as an embed description (max 4096). */ +function truncateEmbedDescription(str, max = 4096) { + if (str == null) return ''; + const s = String(str); + return s.length > max ? s.slice(0, max - 3) + '...' : s; +} + +/** + * Enforce the 6 000 char total embed limit across an array of EmbedBuilder + * instances. Mutates in place: trims the largest description first, then + * largest field values, until the total is under 6 000 chars. + * Returns the same array for chaining. + */ +function enforceEmbedLimit(embeds) { + const charCount = (e) => { + const d = e.data || {}; + let total = 0; + if (d.title) total += d.title.length; + if (d.description) total += d.description.length; + if (d.footer?.text) total += d.footer.text.length; + if (d.author?.name) total += d.author.name.length; + if (d.fields) { + for (const f of d.fields) { + if (f.name) total += f.name.length; + if (f.value) total += f.value.length; + } + } + return total; + }; + + const LIMIT = 6000; + + const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0); + + // Trim largest descriptions first + while (totalChars() > LIMIT) { + let largestIdx = -1; + let largestLen = 0; + for (let i = 0; i < embeds.length; i++) { + const desc = embeds[i].data?.description; + if (desc && desc.length > largestLen) { + largestLen = desc.length; + largestIdx = i; + } + } + if (largestIdx === -1 || largestLen <= 4) break; + const excess = totalChars() - LIMIT; + const newLen = Math.max(1, largestLen - excess - 3); + embeds[largestIdx].setDescription( + embeds[largestIdx].data.description.slice(0, newLen) + '...' + ); + if (totalChars() <= LIMIT) break; + // If still over, loop will pick next largest + } + + // Trim largest field values + while (totalChars() > LIMIT) { + let targetEmbed = null; + let targetFieldIdx = -1; + let targetLen = 0; + for (const e of embeds) { + const fields = e.data?.fields || []; + for (let fi = 0; fi < fields.length; fi++) { + if (fields[fi].value && fields[fi].value.length > targetLen) { + targetLen = fields[fi].value.length; + targetEmbed = e; + targetFieldIdx = fi; + } + } + } + if (!targetEmbed || targetLen <= 4) break; + const excess = totalChars() - LIMIT; + const newLen = Math.max(1, targetLen - excess - 3); + targetEmbed.data.fields[targetFieldIdx].value = + targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...'; + } + + return embeds; +} + module.exports = { + sanitizeEmbedText, + truncateEmbedField, + truncateEmbedDescription, + enforceEmbedLimit, BLOCK_TAG_REGEX, escapeRegex, escapeHtml,