Files
broccolini-bot/services/surgeChecker.js

261 lines
9.3 KiB
JavaScript

/**
* 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');
const { isEnabled } = require('./notificationEnabled');
// 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) {
if (!isEnabled('surge_tickets')) return;
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) {
if (!isEnabled('surge_game')) return;
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) {
if (!isEnabled('surge_stale')) return;
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) {
if (!isEnabled('surge_needs_response')) return;
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) {
if (!isEnabled('surge_unclaimed')) return;
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) {
if (!isEnabled('surge_tier3_unclaimed')) return;
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) {
if (!isEnabled('surge_no_staff')) return;
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 };