/** * 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, $ne: null } }); 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, $ne: null } }); 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 };