notification changes

This commit is contained in:
indifferentketchup
2026-04-08 09:22:47 -05:00
parent 4d53ef179f
commit a4fb82620a
9 changed files with 740 additions and 131 deletions

View File

@@ -3,11 +3,17 @@
* and alerts staff when thresholds are crossed.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG } = require('../config');
const { setCooldown, isOnCooldown } = require('./patternStore');
const { CONFIG, parseThresholdString } = require('../config');
const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore');
// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt }
const chatState = new Map();
const chatMessageThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_messages || [])
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n > 0);
const chatTimeThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_time || [])
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n > 0);
function initChatMonitoring(client) {
for (const channelId of CONFIG.CHAT_ALERT_CHANNEL_IDS) {
@@ -34,6 +40,8 @@ async function handleChatMessage(msg, client) {
if (isStaff(msg.member)) {
state.lastStaffMessageAt = new Date();
state.unrespondedCount = 0;
clearEscalating(`chat:messages:${msg.channel.id}`);
clearEscalating(`chat:time:${msg.channel.id}`);
} else {
state.unrespondedCount++;
}
@@ -47,8 +55,7 @@ async function runChatAlertChecks(client) {
// 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);
if (shouldFireCooldownEscalating(cooldownKey, chatMessageThresholdsMs) !== null) {
const embed = new EmbedBuilder()
.setTitle('Chat needs attention')
.setDescription(`<#${channelId}> has ${state.unrespondedCount} unresponded messages.`)
@@ -66,8 +73,7 @@ async function runChatAlertChecks(client) {
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);
if (shouldFireCooldownEscalating(cooldownKey, chatTimeThresholdsMs) !== null) {
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).`)

View File

@@ -3,18 +3,15 @@
* alerts to dedicated Discord channels.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG } = require('../config');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { getAll, get } = require('./patternStore');
const { getAll, get, shouldFireThreshold, onWeeklyReset } = 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());
// rapid_t2_t3 count milestone state (cleared weekly)
const firedCountMilestones = new Map();
onWeeklyReset(() => firedCountMilestones.clear());
// --- Helpers ---
@@ -35,10 +32,31 @@ async function postPattern(client, channelConfigKey, embed) {
} catch (_) {}
}
function shouldFire(key) {
if (firedToday.has(key)) return false;
firedToday.add(key);
return true;
function getWindowStartMs(windowType) {
if (windowType === 'today') {
const start = new Date();
start.setHours(0, 0, 0, 0);
return start.getTime();
}
if (windowType === 'week') return getThisWeekStart().getTime();
if (windowType === 'month') {
const start = new Date();
start.setDate(1);
start.setHours(0, 0, 0, 0);
return start.getTime();
}
return Date.now();
}
function shouldFire(alertKey, key, windowType) {
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0);
if (thresholds.length === 0) return false;
const ageMs = Date.now() - getWindowStartMs(windowType);
return shouldFireThreshold(key, ageMs, thresholds, windowType) !== null;
}
function getThisWeekStart() {
@@ -59,7 +77,7 @@ async function checkUserPatterns(client) {
for (const [userId, count] of todayCounts) {
if (count >= CONFIG.PATTERN_USER_TICKET_THRESHOLD) {
const key = `user_tickets:${userId}:today`;
if (shouldFire(key)) {
if (shouldFire('user_tickets', key, 'today')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Repeat ticket user',
`User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`,
@@ -79,7 +97,7 @@ async function checkUserPatterns(client) {
]);
for (const r of reopens) {
const key = `user_reopen:${r._id}:week`;
if (shouldFire(key)) {
if (shouldFire('user_reopen', key, 'week')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'High reopen rate',
`${r._id} reopened tickets ${r.count}x this week`,
@@ -98,7 +116,7 @@ async function checkUserPatterns(client) {
]);
for (const c of crossGame) {
const key = `user_crossgame:${c._id}:week`;
if (shouldFire(key)) {
if (shouldFire('user_crossgame', key, 'week')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Cross-game user',
`${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`,
@@ -115,7 +133,7 @@ async function checkGamePatterns(client) {
for (const [game, count] of todayCounts) {
if (count >= CONFIG.PATTERN_GAME_TICKET_THRESHOLD) {
const key = `game_surge:${game}:today`;
if (shouldFire(key)) {
if (shouldFire('game_surge', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game ticket surge',
`**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`,
@@ -136,7 +154,7 @@ async function checkGamePatterns(client) {
for (const b of backlog) {
const gameName = b._id || 'Unknown';
const key = `game_backlog:${gameName}:today`;
if (shouldFire(key)) {
if (shouldFire('game_backlog', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game backlog alert',
`**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`,
@@ -163,7 +181,7 @@ async function checkGamePatterns(client) {
const lw = lastWeekMap.get(tw._id);
if (lw && tw.avg > lw * 1.2) {
const key = `game_resolution:${tw._id}:week`;
if (shouldFire(key)) {
if (shouldFire('game_resolution', key, 'week')) {
const twHrs = (tw.avg / 3600000).toFixed(1);
const lwHrs = (lw / 3600000).toFixed(1);
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
@@ -188,7 +206,7 @@ async function checkGamePatterns(client) {
for (const [game, count] of todayCounts) {
if (count >= 3 && !recentGames.has(game)) {
const key = `game_spike:${game}:today`;
if (shouldFire(key)) {
if (shouldFire('game_spike', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Possible outage',
`**${game}**: ${count} tickets today after 0 in the last 3 days.`,
@@ -209,7 +227,7 @@ async function checkTagPatterns(client) {
}
if (topTag && topCount >= 5) {
const key = `tag_top:${topTag}:today`;
if (shouldFire(key)) {
if (shouldFire('tag_top', key, 'today')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Top issue tag today',
`**${topTag}** used ${topCount} times today.`,
@@ -228,7 +246,7 @@ async function checkTagPatterns(client) {
]);
for (const te of tagEscalations) {
const key = `tag_escalation:${te._id}:week`;
if (shouldFire(key)) {
if (shouldFire('tag_escalation', key, 'week')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Tag frequently leads to escalation',
`**${te._id}**: ${te.count} escalated tickets this week.`,
@@ -242,7 +260,7 @@ async function checkTagPatterns(client) {
const untaggedCount = get('untagged_closes', 'total', 'today');
if (untaggedCount >= 5) {
const key = 'untagged_closes:today';
if (shouldFire(key)) {
if (shouldFire('untagged_closes', key, 'today')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'High untagged close rate',
`${untaggedCount} tickets closed today without a tag.`,
@@ -262,7 +280,7 @@ async function checkTagPatterns(client) {
}
if (total >= 5 && maxGame && maxCount / total > 0.8) {
const key = `tag_game_corr:${tag}:${maxGame}:week`;
if (shouldFire(key)) {
if (shouldFire('tag_game_corr', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Auto-tagging opportunity',
`**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`,
@@ -279,7 +297,7 @@ async function checkEscalationPatterns(client) {
for (const [user, count] of userEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `user_esc:${user}:week`;
if (shouldFire(key)) {
if (shouldFire('user_esc', key, 'week')) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Frequent escalation user',
`\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`,
@@ -302,7 +320,7 @@ async function checkEscalationPatterns(client) {
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)) {
if (shouldFire('game_esc_rate', key, 'week')) {
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.`,
@@ -324,9 +342,24 @@ async function checkEscalationPatterns(client) {
const rapidCount = rapid.length;
if (rapidCount >= 3) {
const key = 'rapid_t2_t3:week';
if (shouldFire(key)) {
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.rapid_t2_t3) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.sort((a, b) => a - b);
const firedSet = firedCountMilestones.get(key) || new Set();
let shouldNotify = false;
for (const threshold of thresholds) {
if (rapidCount >= threshold && !firedSet.has(threshold)) {
firedSet.add(threshold);
shouldNotify = true;
break;
}
}
if (shouldNotify) {
firedCountMilestones.set(key, firedSet);
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Tier 2 unable to handle issue type',
'Rapid tier 3 escalations',
`${rapidCount} tickets reached tier 3 this week.`,
0xFF0000
));
@@ -341,7 +374,7 @@ async function checkStaffPatterns(client) {
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)) {
if (shouldFire('staff_no_close', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Claims without closes',
`Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`,
@@ -360,7 +393,7 @@ async function checkStaffPatterns(client) {
]);
for (const o of overloaded) {
const key = `staff_overloaded:${o._id}:today`;
if (shouldFire(key)) {
if (shouldFire('staff_overloaded', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff overloaded',
`Staff \`${o._id}\` has ${o.count} open claimed tickets.`,
@@ -375,7 +408,7 @@ async function checkStaffPatterns(client) {
for (const [staffId, count] of stalePings) {
if (count >= CONFIG.PATTERN_STAFF_STALE_PING_THRESHOLD) {
const key = `staff_stale:${staffId}:today`;
if (shouldFire(key)) {
if (shouldFire('staff_stale', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff stale ping threshold',
`Staff \`${staffId}\` received ${count} stale pings today.`,
@@ -391,7 +424,7 @@ async function checkStaffPatterns(client) {
const claims = get('staff_claims', staffId, 'today');
if (claims > 0 && transfers >= claims) {
const key = `staff_transfer_rate:${staffId}:today`;
if (shouldFire(key)) {
if (shouldFire('staff_transfer_rate', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'High transfer rate',
`Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`,
@@ -406,7 +439,7 @@ async function checkStaffPatterns(client) {
for (const [staffId, count] of weekEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `staff_esc:${staffId}:week`;
if (shouldFire(key)) {
if (shouldFire('staff_esc', key, 'week')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff frequent escalator',
`Staff \`${staffId}\` escalated ${count} tickets this week.`,
@@ -425,7 +458,7 @@ async function checkCombinedPatterns(client) {
for (const [game, count] of gameEsc) {
if (count >= 3) {
const key = `staff_game_esc:${staffId}:${game}:week`;
if (shouldFire(key)) {
if (shouldFire('staff_game_esc', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff may need training for this game',
`Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`,
@@ -444,7 +477,7 @@ async function checkCombinedPatterns(client) {
const tagGameCount = get(`tag_game:${tag}`, game, 'week');
if (tagGameCount >= 5) {
const key = `game_tag_spike:${game}:${tag}:today`;
if (shouldFire(key)) {
if (shouldFire('game_tag_spike', key, 'today')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Specific feature of specific game spiking',
`**${game}** + **${tag}**: ${tagGameCount} tickets this week.`,
@@ -481,7 +514,7 @@ async function checkCombinedPatterns(client) {
const daytimeRate = daytime / daytimeTotal;
if (overnightRate > daytimeRate * 2 && overnight >= 3) {
const key = 'overnight_gap:week';
if (shouldFire(key)) {
if (shouldFire('overnight_gap', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Overnight coverage gap',
`Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`,
@@ -509,7 +542,7 @@ async function checkCombinedPatterns(client) {
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)) {
if (shouldFire('staff_always_esc', key, 'month')) {
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.`,

View File

@@ -68,14 +68,89 @@ function msUntilNextMonth() {
// Callbacks to run on daily reset (e.g. clear firedToday in patternChecker)
const dailyResetCallbacks = [];
const weeklyResetCallbacks = [];
function onDailyReset(fn) {
dailyResetCallbacks.push(fn);
}
function onWeeklyReset(fn) {
weeklyResetCallbacks.push(fn);
}
// --- Threshold firing state ---
// key -> Set<thresholdMs> that have fired within the key's window.
const firedThresholds = new Map();
// key -> window type used for threshold clearing ("today" | "week" | "month")
const firedThresholdWindows = new Map();
function clearFiredThresholdsForWindow(windowType) {
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
if (mappedWindowType === windowType) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
}
}
}
function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
if (!['today', 'week', 'month'].includes(windowType)) return null;
firedThresholdWindows.set(key, windowType);
const firedForKey = firedThresholds.get(key) || new Set();
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
let highestUnfiredCrossed = null;
for (const thresholdMs of sortedThresholds) {
if (ageMs >= thresholdMs && !firedForKey.has(thresholdMs)) {
highestUnfiredCrossed = thresholdMs;
}
}
if (highestUnfiredCrossed === null) return null;
firedForKey.add(highestUnfiredCrossed);
firedThresholds.set(key, firedForKey);
return highestUnfiredCrossed;
}
// --- Escalating cooldown state ---
// key -> { startedAtMs, lastFireAtMs, fireCount }
const escalatingCooldowns = new Map();
function shouldFireCooldownEscalating(key, thresholdsMs) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
const now = Date.now();
let state = escalatingCooldowns.get(key);
if (!state) {
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0 };
escalatingCooldowns.set(key, state);
}
const nextThreshold = sortedThresholds[state.fireCount];
if (typeof nextThreshold !== 'number') return null;
const referenceMs = state.fireCount === 0 ? state.startedAtMs : state.lastFireAtMs;
if ((now - referenceMs) < nextThreshold) return null;
state.fireCount += 1;
state.lastFireAtMs = now;
return nextThreshold;
}
function clearEscalating(key) {
escalatingCooldowns.delete(key);
}
function scheduleDailyReset() {
setTimeout(() => {
store.today = new Map();
clearFiredThresholdsForWindow('today');
for (const fn of dailyResetCallbacks) {
try { fn(); } catch (_) {}
}
@@ -86,6 +161,10 @@ function scheduleDailyReset() {
function scheduleWeeklyReset() {
setTimeout(() => {
store.week = new Map();
clearFiredThresholdsForWindow('week');
for (const fn of weeklyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleWeeklyReset();
}, msUntilNextMonday());
}
@@ -93,6 +172,7 @@ function scheduleWeeklyReset() {
function scheduleMonthlyReset() {
setTimeout(() => {
store.month = new Map();
clearFiredThresholdsForWindow('month');
scheduleMonthlyReset();
}, msUntilNextMonth());
}
@@ -140,6 +220,11 @@ module.exports = {
getAll,
scheduleResets,
onDailyReset,
onWeeklyReset,
firedThresholds,
shouldFireThreshold,
shouldFireCooldownEscalating,
clearEscalating,
setCooldown,
isOnCooldown,
updateStaffLastSeen,

View File

@@ -9,7 +9,7 @@
* per ticket (highest newly-crossed threshold only).
*/
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { CONFIG, parseThresholdString } = require('../config');
const { increment } = require('./patternStore');
const Ticket = mongoose.model('Ticket');
@@ -53,8 +53,12 @@ async function notifyStaffOfReply(guild, ticket, message) {
* threshold) into every staff notification channel.
*/
async function notifyAllStaffUnclaimed(client) {
const thresholds = CONFIG.UNCLAIMED_REMINDER_THRESHOLDS;
if (!thresholds || thresholds.length === 0) return;
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.unclaimed_reminder) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.map(ms => ms / (60 * 60 * 1000));
if (thresholds.length === 0) return;
const sorted = [...thresholds].sort((a, b) => a - b);
const now = Date.now();

View File

@@ -3,13 +3,21 @@
* and pings ALL_STAFF_CHANNEL_ID with role mention.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG } = require('../config');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { setCooldown, isOnCooldown, isStaffRecentlyActive } = require('./patternStore');
const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore');
const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence');
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;
@@ -34,20 +42,24 @@ async function pingStaff(client, message, embedFields) {
}
async function checkTicketSurge(client) {
if (isOnCooldown('surge:tickets', CONFIG.SURGE_COOLDOWN_MINUTES)) 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) {
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 }]
);
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 (isOnCooldown('surge:game', CONFIG.SURGE_COOLDOWN_MINUTES)) 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 } } },
@@ -56,34 +68,42 @@ async function checkGameSurge(client) {
{ $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);
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 (isOnCooldown('surge:stale', CONFIG.SURGE_COOLDOWN_MINUTES)) 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) {
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 }]
);
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 (isOnCooldown('surge:needs_response', CONFIG.SURGE_COOLDOWN_MINUTES)) 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',
@@ -91,16 +111,20 @@ async function checkNeedsResponseSurge(client) {
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).`,
[]
);
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 (isOnCooldown('surge:unclaimed', CONFIG.SURGE_COOLDOWN_MINUTES)) return;
const key = 'surge:unclaimed';
const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000);
const count = await Ticket.countDocuments({
status: 'open',
@@ -108,16 +132,20 @@ async function checkUnclaimedSurge(client) {
createdAt: { $lte: cutoff, $ne: null }
});
if (count >= CONFIG.SURGE_UNCLAIMED_COUNT) {
setCooldown('surge:unclaimed');
await pingStaff(client,
`${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`,
[]
);
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 (isOnCooldown('surge:tier3_unclaimed', 30)) 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',
@@ -126,23 +154,36 @@ async function checkTier3UnclaimedSurge(client) {
createdAt: { $lte: cutoff, $ne: null }
}).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 }))
);
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 (isOnCooldown('surge:no_staff', CONFIG.SURGE_NO_STAFF_COOLDOWN_MINUTES)) return;
if (!CONFIG.STAFF_IDS.length) 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) return;
if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) {
clearEscalating(key);
return;
}
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!guild) return;
if (!guild) {
clearEscalating(key);
return;
}
const { available, source } = isAnyStaffAvailable(guild);
@@ -162,9 +203,13 @@ async function checkZeroStaffSurge(client) {
detailLine = `${offline.length} staff offline/invisible${dndNote}. ${online.length} online.`;
}
if (!noStaff) return;
if (!noStaff) {
clearEscalating(key);
return;
}
setCooldown('surge:no_staff');
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_no_staff'));
if (thresholdMs === null) return;
const fields = [
{ name: 'Open tickets', value: String(openCount), inline: true },