238 lines
8.4 KiB
JavaScript
238 lines
8.4 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 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 };
|