/** * 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, parseThresholdString } = require('../config'); const { mongoose } = require('../db-connection'); const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore'); const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence'); const { enqueueSend } = require('./channelQueue'); const { assertKeysRegistered } = require('./notificationRegistry'); // Alert keys this module drives. Asserted against the registry at load so any // future drift (rename, typo, unregistered key) fails fast rather than // silently breaking the settings-site config editor. const SURGE_ALERT_KEYS = [ 'surge_tickets', 'surge_game', 'surge_stale', 'surge_needs_response', 'surge_unclaimed', 'surge_tier3_unclaimed', 'surge_no_staff' ]; assertKeysRegistered('surgeChecker', SURGE_ALERT_KEYS); const Ticket = mongoose.model('Ticket'); function getThresholdsMs(alertKey) { const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || []; return rawThresholds .map(parseThresholdString) .filter(n => Number.isFinite(n) && n >= 0) .sort((a, b) => a - b); } 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 enqueueSend(channel, { content, embeds: [embed] }); } catch (_) {} } async function checkTicketSurge(client) { const key = 'surge:tickets'; 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) { const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tickets')); if (thresholdMs !== null) { 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 }] ); } } else { clearEscalating(key); } } async function checkGameSurge(client) { const key = 'surge:game'; 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) { const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_game')); if (thresholdMs !== null) { 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); } } else { clearEscalating(key); } } async function checkStaleSurge(client) { const key = 'surge:stale'; const cutoff = new Date(Date.now() - CONFIG.SURGE_STALE_HOURS * 3600000); const count = await Ticket.countDocuments({ status: 'open', lastActivity: { $lte: cutoff, $ne: null } }); if (count >= CONFIG.SURGE_STALE_COUNT) { const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_stale')); if (thresholdMs !== null) { 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 }] ); } } else { clearEscalating(key); } } async function checkNeedsResponseSurge(client) { const key = 'surge:needs_response'; const cutoff = new Date(Date.now() - CONFIG.SURGE_NEEDS_RESPONSE_HOURS * 3600000); const count = await Ticket.countDocuments({ status: 'open', lastMessageAuthorIsStaff: false, lastActivity: { $lte: cutoff, $ne: null } }); if (count >= CONFIG.SURGE_NEEDS_RESPONSE_COUNT) { const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_needs_response')); if (thresholdMs !== null) { await pingStaff(client, `${count} tickets are waiting on a staff response for over ${CONFIG.SURGE_NEEDS_RESPONSE_HOURS} hour(s).`, [] ); } } else { clearEscalating(key); } } async function checkUnclaimedSurge(client) { const key = 'surge:unclaimed'; const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000); const count = await Ticket.countDocuments({ status: 'open', claimedBy: null, createdAt: { $lte: cutoff, $ne: null } }); if (count >= CONFIG.SURGE_UNCLAIMED_COUNT) { const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_unclaimed')); if (thresholdMs !== null) { await pingStaff(client, `${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`, [] ); } } else { clearEscalating(key); } } async function checkTier3UnclaimedSurge(client) { const key = 'surge:tier3_unclaimed'; 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, $ne: null } }).lean(); if (tickets.length > 0) { const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tier3_unclaimed')); if (thresholdMs !== null) { 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 })) ); } } else { clearEscalating(key); } } async function checkZeroStaffSurge(client) { const key = 'surge:no_staff'; if (!CONFIG.STAFF_IDS.length) { clearEscalating(key); return; } const openCount = await Ticket.countDocuments({ status: 'open' }); if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) { clearEscalating(key); return; } const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); if (!guild) { clearEscalating(key); 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) { clearEscalating(key); return; } const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_no_staff')); if (thresholdMs === null) return; 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 };