huge changes

This commit is contained in:
indifferentketchup
2026-04-07 01:43:06 -05:00
parent ca63ecbcfd
commit 69c247ed1b
37 changed files with 3468 additions and 169 deletions

View File

@@ -1,32 +1,87 @@
/**
* Serialized channel renames/moves to avoid Discord rate limits (e.g. 2 renames / 10 min per channel).
* Per-channel rename rate limiting with queue.
* Discord allows 2 channel renames per 10 minutes per channel.
* We use a 9-minute window for safety margin.
*/
const PQueue = require('p-queue').default;
const channelQueue = new PQueue({
concurrency: 1,
intervalCap: 2,
interval: 10000
});
const RENAME_WINDOW_MS = 9 * 60 * 1000;
const RENAME_LIMIT = 2;
// Per-channel state: { count, windowStart, queue: [{newName, resolve, reject}], processing }
const renameState = new Map();
function getOrInitState(channelId) {
let state = renameState.get(channelId);
if (!state) {
state = { count: 0, windowStart: 0, queue: [], processing: false };
renameState.set(channelId, state);
}
return state;
}
async function executeRename(channel, newName) {
await channel.setName(newName);
}
function processQueue(channel, state) {
if (state.queue.length === 0 || state.processing) return;
const now = Date.now();
const timeUntilExpiry = (state.windowStart + RENAME_WINDOW_MS) - now;
if (timeUntilExpiry > 0) {
state.processing = true;
setTimeout(async () => {
state.processing = false;
// New window
if (state.queue.length > 3) {
const { logWarn } = require('../services/debugLog');
logWarn('renameQueue', `Channel ${channel.name} has ${state.queue.length} renames queued`).catch(() => {});
}
const item = state.queue.shift();
if (!item) return;
state.count = 1;
state.windowStart = Date.now();
try {
await executeRename(channel, item.newName);
item.resolve();
} catch (err) {
item.reject(err);
}
// Continue processing remaining queue items
processQueue(channel, state);
}, timeUntilExpiry);
}
}
function enqueueRename(channel, newName) {
return channelQueue.add(async () => {
try {
await channel.setName(newName);
} catch (err) {
const msg = err?.message || String(err);
if (msg.includes('429') || msg.toLowerCase().includes('rate limit')) {
console.warn(`enqueueRename: rate limit renaming channel "${channel.name}"`);
return;
}
console.error('enqueueRename:', err);
throw err;
return new Promise((resolve, reject) => {
const state = getOrInitState(channel.id);
const now = Date.now();
// Window expired — reset
if (now - state.windowStart >= RENAME_WINDOW_MS) {
state.count = 1;
state.windowStart = now;
executeRename(channel, newName).then(resolve).catch(reject);
return;
}
// Within window and under limit
if (state.count < RENAME_LIMIT) {
state.count++;
executeRename(channel, newName).then(resolve).catch(reject);
return;
}
// At limit — queue it
state.queue.push({ newName, resolve, reject });
processQueue(channel, state);
});
}
function enqueueMove(channel, categoryId) {
return channelQueue.add(() => channel.setParent(categoryId, { lockPermissions: true }));
return channel.setParent(categoryId, { lockPermissions: true });
}
module.exports = { channelQueue, enqueueRename, enqueueMove };
module.exports = { enqueueRename, enqueueMove };

View File

@@ -0,0 +1,86 @@
/**
* Chat monitoring — tracks unresponded messages in configured channels
* and alerts staff when thresholds are crossed.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG } = require('../config');
const { setCooldown, isOnCooldown } = require('./patternStore');
// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt }
const chatState = new Map();
function initChatMonitoring(client) {
for (const channelId of CONFIG.CHAT_ALERT_CHANNEL_IDS) {
chatState.set(channelId, {
lastStaffMessageAt: new Date(),
unrespondedCount: 0,
lastAlertAt: null
});
}
}
function isStaff(member) {
if (!member?.roles?.cache) return false;
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
return additional.some(roleId => member.roles.cache.has(roleId));
}
async function handleChatMessage(msg, client) {
if (msg.author.bot) return;
if (!chatState.has(msg.channel.id)) return;
const state = chatState.get(msg.channel.id);
if (isStaff(msg.member)) {
state.lastStaffMessageAt = new Date();
state.unrespondedCount = 0;
} else {
state.unrespondedCount++;
}
}
async function runChatAlertChecks(client) {
const alertChannelId = CONFIG.ALL_STAFF_CHAT_ALERT_CHANNEL_ID;
if (!alertChannelId || !client) return;
for (const [channelId, state] of chatState) {
// 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);
const embed = new EmbedBuilder()
.setTitle('Chat needs attention')
.setDescription(`<#${channelId}> has ${state.unrespondedCount} unresponded messages.`)
.setColor(0xFF8800)
.setTimestamp();
try {
const alertChan = await client.channels.fetch(alertChannelId);
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
if (alertChan) await alertChan.send({ content, embeds: [embed] });
} catch (_) {}
}
}
// Time threshold
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);
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).`)
.setColor(0xFF8800)
.setTimestamp();
try {
const alertChan = await client.channels.fetch(alertChannelId);
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
if (alertChan) await alertChan.send({ content, embeds: [embed] });
} catch (_) {}
}
}
}
}
module.exports = { initChatMonitoring, handleChatMessage, runChatAlertChecks };

View File

@@ -0,0 +1,105 @@
const fs = require('fs');
const path = require('path');
const { CONFIG } = require('../config');
const ENV_PATH = process.env.ENV_FILE
? path.resolve(process.env.ENV_FILE)
: path.resolve(process.cwd(), '.env');
/**
* Read the current .env file and parse into a key->value Map.
*/
function readEnvFile() {
if (!fs.existsSync(ENV_PATH)) return new Map();
const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n');
const map = new Map();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = line.indexOf('=');
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
map.set(key, value);
}
return map;
}
/**
* Write a Map of key->value back to the .env file,
* preserving comments and blank lines.
*/
function writeEnvFile(updates) {
if (!fs.existsSync(ENV_PATH)) {
const lines = [];
for (const [k, v] of updates) lines.push(`${k}=${v}`);
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
return;
}
const raw = fs.readFileSync(ENV_PATH, 'utf8');
const lines = raw.split('\n');
const written = new Set();
const result = lines.map(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const idx = line.indexOf('=');
if (idx === -1) return line;
const key = line.slice(0, idx).trim();
if (updates.has(key)) {
written.add(key);
return `${key}=${updates.get(key)}`;
}
return line;
});
// Append any new keys not already in the file
for (const [k, v] of updates) {
if (!written.has(k)) result.push(`${k}=${v}`);
}
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
}
/**
* Apply a flat object of { KEY: value } to both CONFIG and .env.
* Returns { applied: string[], errors: string[] }
*/
function applyConfigUpdates(updates) {
const applied = [];
const errors = [];
for (const [key, rawValue] of Object.entries(updates)) {
try {
if (rawValue === 'true' || rawValue === 'false') {
CONFIG[key] = rawValue === 'true';
} else if (!isNaN(rawValue) && rawValue !== '') {
CONFIG[key] = Number(rawValue);
} else {
CONFIG[key] = rawValue;
}
applied.push(key);
} catch (err) {
errors.push(`${key}: ${err.message}`);
}
}
// Write to .env
const envMap = readEnvFile();
for (const [key, value] of Object.entries(updates)) {
envMap.set(key, String(value));
}
writeEnvFile(envMap);
return { applied, errors };
}
/**
* Read all current env values for the settings UI.
*/
function readAllConfig() {
return readEnvFile();
}
module.exports = { applyConfigUpdates, readAllConfig, readEnvFile, writeEnvFile };

View File

@@ -1,7 +1,8 @@
/**
* Send error details to DEBUGGING_CHANNEL_ID when set.
* Call setClient(client) from the main bot on ready so errors can be posted.
* Structured logging service posts embeds to dedicated Discord channels.
* Call setClient(client) from the main bot on ready so logs can be posted.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG } = require('../config');
let client = null;
@@ -10,13 +11,21 @@ function setClient(c) {
client = c;
}
/**
* Post an error to the debugging channel (if DEBUGGING_CHANNEL_ID and client are set).
* @param {string} context - e.g. 'escalate', 'deescalate', 'email-routing', 'Gmail poll'
* @param {Error} error
* @param {import('discord.js').Interaction} [interaction]
* @param {import('discord.js').Client} [overrideClient] - use this client instead of stored (e.g. from gmail-poll)
*/
// --- Helpers ---
async function sendToChannel(channelId, embed, overrideClient) {
const c = overrideClient || client;
if (!c || !channelId) return;
try {
const channel = await c.channels.fetch(channelId);
if (channel) await channel.send({ embeds: [embed] });
} catch (_) {
// ignore send failures
}
}
// --- logError (backwards-compatible) ---
async function logError(context, error, interaction = null, overrideClient = null) {
const c = overrideClient || client;
if (!c || !CONFIG.DEBUGGING_CHANNEL_ID) return;
@@ -38,4 +47,124 @@ async function logError(context, error, interaction = null, overrideClient = nul
}
}
module.exports = { setClient, logError };
// --- logWarn ---
async function logWarn(context, message, overrideClient = null) {
const embed = new EmbedBuilder()
.setTitle(`Warning: ${context}`)
.setDescription(String(message).slice(0, 4000))
.setColor(0xFFFF00)
.setTimestamp();
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
}
// --- logEvent (generic posts to any configured channel) ---
async function logEvent(channelConfigKey, embed, overrideClient = null) {
const channelId = CONFIG[channelConfigKey];
await sendToChannel(channelId, embed, overrideClient);
}
// --- logTicketEvent ---
async function logTicketEvent(action, fields, interaction = null) {
const embed = new EmbedBuilder()
.setTitle(action)
.setColor(CONFIG.EMBED_COLOR_INFO || 0x1e2124)
.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true })))
.setTimestamp();
if (interaction?.user?.tag) {
embed.setFooter({ text: interaction.user.tag });
}
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
}
// --- logGmail ---
async function logGmail(subject, sender, ticketNumber, game) {
const embed = new EmbedBuilder()
.setTitle('Email Ticket Created')
.setColor(0x00BFFF)
.addFields(
{ name: 'Subject', value: String(subject || 'No subject').slice(0, 256), inline: false },
{ name: 'Sender', value: String(sender || 'unknown'), inline: true },
{ name: 'Ticket #', value: String(ticketNumber || '?'), inline: true },
{ name: 'Game', value: String(game || 'Not detected'), inline: true }
)
.setTimestamp();
await sendToChannel(CONFIG.GMAIL_LOG_CHANNEL_ID, embed);
}
// --- logAutomation ---
async function logAutomation(action, ticketChannelName, detail) {
const embed = new EmbedBuilder()
.setTitle(action)
.setColor(0x9B59B6)
.setTimestamp();
if (ticketChannelName) {
embed.addFields({ name: 'Ticket', value: String(ticketChannelName), inline: true });
}
if (detail) {
embed.addFields({ name: 'Detail', value: String(detail).slice(0, 1024), inline: false });
}
await sendToChannel(CONFIG.AUTOMATION_LOG_CHANNEL_ID, embed);
}
// --- logSecurity ---
async function logSecurity(action, user, detail, overrideClient = null, color = 0xFF6600) {
const embed = new EmbedBuilder()
.setTitle('Security Event')
.setColor(color)
.addFields(
{ name: 'Action', value: String(action).slice(0, 256), inline: false },
{ name: 'User', value: user ? `${user.tag} (${user.id})` : 'Unknown', inline: true },
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
)
.setTimestamp();
await sendToChannel(CONFIG.SECURITY_LOG_CHANNEL_ID, embed, overrideClient);
}
// --- logIntegrity ---
async function logIntegrity(issue, detail, overrideClient = null) {
const embed = new EmbedBuilder()
.setTitle('Ticket Integrity Issue')
.setColor(0xFF0000)
.addFields(
{ name: 'Issue', value: String(issue).slice(0, 256), inline: false },
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
)
.setTimestamp();
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
}
// --- logSystem ---
async function logSystem(message, fields = [], overrideClient = null, color = 0x0099ff) {
const embed = new EmbedBuilder()
.setTitle(message)
.setColor(color)
.setTimestamp();
if (fields.length > 0) {
embed.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true })));
}
embed.addFields({ name: 'Timestamp', value: new Date().toISOString(), inline: true });
await sendToChannel(CONFIG.SYSTEM_LOG_CHANNEL_ID, embed, overrideClient);
}
module.exports = {
setClient,
logError,
logWarn,
logEvent,
logTicketEvent,
logGmail,
logAutomation,
logSecurity,
logIntegrity,
logSystem
};

535
services/patternChecker.js Normal file
View File

@@ -0,0 +1,535 @@
/**
* Pattern detection — scheduled checks that analyze ticket trends and post
* alerts to dedicated Discord channels.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG } = require('../config');
const { mongoose } = require('../db-connection');
const { getAll, get } = 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());
// --- Helpers ---
function buildEmbed(title, description, color = 0xFFAA00) {
return new EmbedBuilder()
.setTitle(title)
.setDescription(String(description).slice(0, 4000))
.setColor(color)
.setTimestamp();
}
async function postPattern(client, channelConfigKey, embed) {
const channelId = CONFIG[channelConfigKey];
if (!channelId || !client) return;
try {
const channel = await client.channels.fetch(channelId);
if (channel) await channel.send({ embeds: [embed] });
} catch (_) {}
}
function shouldFire(key) {
if (firedToday.has(key)) return false;
firedToday.add(key);
return true;
}
function getThisWeekStart() {
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? 6 : day - 1;
const monday = new Date(now);
monday.setDate(now.getDate() - diff);
monday.setHours(0, 0, 0, 0);
return monday;
}
// --- Check functions ---
async function checkUserPatterns(client) {
// Surge: users with tickets >= threshold today
const todayCounts = getAll('user_tickets', 'today');
for (const [userId, count] of todayCounts) {
if (count >= CONFIG.PATTERN_USER_TICKET_THRESHOLD) {
const key = `user_tickets:${userId}:today`;
if (shouldFire(key)) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Repeat ticket user',
`User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`,
0xFFAA00
));
}
}
}
// Reopens this week
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
try {
const reopens = await Ticket.aggregate([
{ $match: { reopenedAt: { $gte: since } } },
{ $group: { _id: '$senderEmail', count: { $sum: 1 } } },
{ $match: { count: { $gte: 2 } } }
]);
for (const r of reopens) {
const key = `user_reopen:${r._id}:week`;
if (shouldFire(key)) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'High reopen rate',
`${r._id} reopened tickets ${r.count}x this week`,
0xFFAA00
));
}
}
} catch (_) {}
// Cross-game: users with tickets across 3+ games this week
try {
const crossGame = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, status: { $ne: 'closed' } } },
{ $group: { _id: '$senderEmail', games: { $addToSet: '$game' } } },
{ $match: { 'games.2': { $exists: true } } }
]);
for (const c of crossGame) {
const key = `user_crossgame:${c._id}:week`;
if (shouldFire(key)) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Cross-game user',
`${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`,
0x00AAFF
));
}
}
} catch (_) {}
}
async function checkGamePatterns(client) {
// Surge: games with tickets >= threshold today
const todayCounts = getAll('game_tickets', 'today');
for (const [game, count] of todayCounts) {
if (count >= CONFIG.PATTERN_GAME_TICKET_THRESHOLD) {
const key = `game_surge:${game}:today`;
if (shouldFire(key)) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game ticket surge',
`**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`,
0xFF6600
));
}
}
}
// Backlog: unclaimed tickets older than threshold
try {
const cutoff = new Date(Date.now() - CONFIG.PATTERN_UNCLAIMED_HOURS * 3600000);
const backlog = await Ticket.aggregate([
{ $match: { status: 'open', claimedBy: null, createdAt: { $lte: cutoff } } },
{ $group: { _id: '$game', count: { $sum: 1 } } },
{ $match: { count: { $gte: 3 } } }
]);
for (const b of backlog) {
const gameName = b._id || 'Unknown';
const key = `game_backlog:${gameName}:today`;
if (shouldFire(key)) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game backlog alert',
`**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`,
0xFF0000
));
}
}
} catch (_) {}
// Resolution time trending: this week vs last week
try {
const thisWeekStart = getThisWeekStart();
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const thisWeek = await Ticket.aggregate([
{ $match: { status: 'closed', closedAt: { $gte: thisWeekStart }, game: { $ne: null } } },
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
]);
const lastWeek = await Ticket.aggregate([
{ $match: { status: 'closed', closedAt: { $gte: lastWeekStart, $lt: thisWeekStart }, game: { $ne: null } } },
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
]);
const lastWeekMap = new Map(lastWeek.map(l => [l._id, l.avg]));
for (const tw of thisWeek) {
const lw = lastWeekMap.get(tw._id);
if (lw && tw.avg > lw * 1.2) {
const key = `game_resolution:${tw._id}:week`;
if (shouldFire(key)) {
const twHrs = (tw.avg / 3600000).toFixed(1);
const lwHrs = (lw / 3600000).toFixed(1);
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Resolution time increasing',
`**${tw._id}**: ${twHrs}h avg this week vs ${lwHrs}h last week (+${((tw.avg / lw - 1) * 100).toFixed(0)}%).`,
0xFFAA00
));
}
}
}
} catch (_) {}
// Spike after silence: games with 0 tickets in last 3 days but 3+ today
try {
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
const recentByGame = await Ticket.aggregate([
{ $match: { createdAt: { $gte: threeDaysAgo, $lt: todayStart }, game: { $ne: null } } },
{ $group: { _id: '$game', count: { $sum: 1 } } }
]);
const recentGames = new Set(recentByGame.map(r => r._id));
for (const [game, count] of todayCounts) {
if (count >= 3 && !recentGames.has(game)) {
const key = `game_spike:${game}:today`;
if (shouldFire(key)) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Possible outage',
`**${game}**: ${count} tickets today after 0 in the last 3 days.`,
0xFF0000
));
}
}
}
} catch (_) {}
}
async function checkTagPatterns(client) {
// Most common tag today
const todayTags = getAll('tag_usage', 'today');
let topTag = null, topCount = 0;
for (const [tag, count] of todayTags) {
if (count > topCount) { topTag = tag; topCount = count; }
}
if (topTag && topCount >= 5) {
const key = `tag_top:${topTag}:today`;
if (shouldFire(key)) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Top issue tag today',
`**${topTag}** used ${topCount} times today.`,
0x00AAFF
));
}
}
// Tag→escalation correlation
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const tagEscalations = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, escalationTier: { $gte: 1 }, ticketTag: { $ne: null } } },
{ $group: { _id: '$ticketTag', count: { $sum: 1 } } },
{ $match: { count: { $gte: 3 } } }
]);
for (const te of tagEscalations) {
const key = `tag_escalation:${te._id}:week`;
if (shouldFire(key)) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Tag frequently leads to escalation',
`**${te._id}**: ${te.count} escalated tickets this week.`,
0xFFAA00
));
}
}
} catch (_) {}
// Untagged closes
const untaggedCount = get('untagged_closes', 'total', 'today');
if (untaggedCount >= 5) {
const key = 'untagged_closes:today';
if (shouldFire(key)) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'High untagged close rate',
`${untaggedCount} tickets closed today without a tag.`,
0xFFAA00
));
}
}
// Tag↔game correlation: for each tag this week, check if one game dominates
const weekTags = getAll('tag_usage', 'week');
for (const [tag] of weekTags) {
const tagGameCounts = getAll(`tag_game:${tag}`, 'week');
let total = 0, maxGame = null, maxCount = 0;
for (const [game, count] of tagGameCounts) {
total += count;
if (count > maxCount) { maxGame = game; maxCount = count; }
}
if (total >= 5 && maxGame && maxCount / total > 0.8) {
const key = `tag_game_corr:${tag}:${maxGame}:week`;
if (shouldFire(key)) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Auto-tagging opportunity',
`**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`,
0x00AAFF
));
}
}
}
}
async function checkEscalationPatterns(client) {
// User escalation rate
const userEscalations = getAll('user_escalations', 'week');
for (const [user, count] of userEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `user_esc:${user}:week`;
if (shouldFire(key)) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Frequent escalation user',
`\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`,
0xFFAA00
));
}
}
}
// Game escalation rate vs baseline
try {
const thisWeekStart = getThisWeekStart();
const thisWeek = await Ticket.aggregate([
{ $match: { escalationTier: { $gte: 1 }, createdAt: { $gte: thisWeekStart } } },
{ $group: { _id: '$game', count: { $sum: 1 } } }
]);
const totalThisWeek = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart } });
for (const tw of thisWeek) {
if (!tw._id) continue;
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)) {
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.`,
0xFF6600
));
}
}
}
} catch (_) {}
// Rapid tier 2→3
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const rapid = await Ticket.find({
escalationTier: 2,
escalatedAt: { $gte: since }
}).lean();
// Count tickets where escalation happened very quickly (approximate: check if tier was changed recently)
const rapidCount = rapid.length;
if (rapidCount >= 3) {
const key = 'rapid_t2_t3:week';
if (shouldFire(key)) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Tier 2 unable to handle issue type',
`${rapidCount} tickets reached tier 3 this week.`,
0xFF0000
));
}
}
} catch (_) {}
}
async function checkStaffPatterns(client) {
// Claims without closes
const todayClaims = getAll('staff_claims', 'today');
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)) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Claims without closes',
`Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`,
0xFFAA00
));
}
}
}
// Overloaded: open tickets per claimer
try {
const overloaded = await Ticket.aggregate([
{ $match: { status: 'open', claimerId: { $ne: null } } },
{ $group: { _id: '$claimerId', count: { $sum: 1 } } },
{ $match: { count: { $gte: 5 } } }
]);
for (const o of overloaded) {
const key = `staff_overloaded:${o._id}:today`;
if (shouldFire(key)) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff overloaded',
`Staff \`${o._id}\` has ${o.count} open claimed tickets.`,
0xFF6600
));
}
}
} catch (_) {}
// Stale ping threshold
const stalePings = getAll('staff_stale_pings', 'today');
for (const [staffId, count] of stalePings) {
if (count >= CONFIG.PATTERN_STAFF_STALE_PING_THRESHOLD) {
const key = `staff_stale:${staffId}:today`;
if (shouldFire(key)) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff stale ping threshold',
`Staff \`${staffId}\` received ${count} stale pings today.`,
0xFFAA00
));
}
}
}
// Transfer rate
const todayTransfers = getAll('staff_transfers', 'today');
for (const [staffId, transfers] of todayTransfers) {
const claims = get('staff_claims', staffId, 'today');
if (claims > 0 && transfers >= claims) {
const key = `staff_transfer_rate:${staffId}:today`;
if (shouldFire(key)) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'High transfer rate',
`Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`,
0xFFAA00
));
}
}
}
// Escalations per staff
const weekEscalations = getAll('staff_escalations', 'week');
for (const [staffId, count] of weekEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `staff_esc:${staffId}:week`;
if (shouldFire(key)) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff frequent escalator',
`Staff \`${staffId}\` escalated ${count} tickets this week.`,
0xFFAA00
));
}
}
}
}
async function checkCombinedPatterns(client) {
// Staff+game escalation correlation
const weekEscStaff = getAll('staff_escalations', 'week');
for (const [staffId] of weekEscStaff) {
const gameEsc = getAll(`staff_game_escalations:${staffId}`, 'week');
for (const [game, count] of gameEsc) {
if (count >= 3) {
const key = `staff_game_esc:${staffId}:${game}:week`;
if (shouldFire(key)) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff may need training for this game',
`Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`,
0xFFAA00
));
}
}
}
}
// Game+tag spike: specific game+tag combo >= 5 today
const todayGames = getAll('game_tickets', 'today');
const todayTags = getAll('tag_usage', 'today');
for (const [game] of todayGames) {
for (const [tag] of todayTags) {
const tagGameCount = get(`tag_game:${tag}`, game, 'week');
if (tagGameCount >= 5) {
const key = `game_tag_spike:${game}:${tag}:today`;
if (shouldFire(key)) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Specific feature of specific game spiking',
`**${game}** + **${tag}**: ${tagGameCount} tickets this week.`,
0xFF6600
));
}
}
}
}
// Overnight escalation gap: compare 00:00-06:00 vs daytime escalation rates
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const overnight = await Ticket.countDocuments({
createdAt: { $gte: since },
escalationTier: { $gte: 1 },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
});
const daytime = await Ticket.countDocuments({
createdAt: { $gte: since },
escalationTier: { $gte: 1 },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
});
const overnightTotal = await Ticket.countDocuments({
createdAt: { $gte: since },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
});
const daytimeTotal = await Ticket.countDocuments({
createdAt: { $gte: since },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
});
if (overnightTotal > 0 && daytimeTotal > 0) {
const overnightRate = overnight / overnightTotal;
const daytimeRate = daytime / daytimeTotal;
if (overnightRate > daytimeRate * 2 && overnight >= 3) {
const key = 'overnight_gap:week';
if (shouldFire(key)) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Overnight coverage gap',
`Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`,
0xFF0000
));
}
}
}
} catch (_) {}
// Staff never resolves game X without escalating
try {
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const staffGameStats = await Ticket.aggregate([
{ $match: { claimerId: { $ne: null }, game: { $ne: null }, createdAt: { $gte: monthStart } } },
{ $group: {
_id: { staff: '$claimerId', game: '$game' },
total: { $sum: 1 },
escalated: { $sum: { $cond: [{ $gte: ['$escalationTier', 1] }, 1, 0] } }
}},
{ $match: { total: { $gte: 3 } } }
]);
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)) {
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.`,
0xFF6600
));
}
}
}
} catch (_) {}
}
// --- Main entry point ---
async function runPatternChecks(client) {
try { await checkUserPatterns(client); } catch (e) { console.error('checkUserPatterns:', e); }
try { await checkGamePatterns(client); } catch (e) { console.error('checkGamePatterns:', e); }
try { await checkTagPatterns(client); } catch (e) { console.error('checkTagPatterns:', e); }
try { await checkEscalationPatterns(client); } catch (e) { console.error('checkEscalationPatterns:', e); }
try { await checkStaffPatterns(client); } catch (e) { console.error('checkStaffPatterns:', e); }
try { await checkCombinedPatterns(client); } catch (e) { console.error('checkCombinedPatterns:', e); }
}
module.exports = { runPatternChecks };

148
services/patternStore.js Normal file
View File

@@ -0,0 +1,148 @@
/**
* In-memory counter store with TTL windows for pattern detection.
* Windows: 'today' resets at midnight, 'week' resets Monday 00:00, 'month' resets 1st 00:00.
*/
// store[window][namespace][key] = count
const store = {
today: new Map(),
week: new Map(),
month: new Map()
};
function getNamespaceMap(window, namespace) {
const windowMap = store[window];
if (!windowMap) return null;
if (!windowMap.has(namespace)) windowMap.set(namespace, new Map());
return windowMap.get(namespace);
}
function increment(namespace, key, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return;
map.set(key, (map.get(key) || 0) + 1);
}
function get(namespace, key, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return 0;
return map.get(key) || 0;
}
function reset(namespace, window) {
const windowMap = store[window];
if (!windowMap) return;
windowMap.delete(namespace);
}
function getAll(namespace, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return new Map();
return new Map(map);
}
// --- Scheduled resets ---
function msUntilNextMidnight() {
const now = new Date();
const next = new Date(now);
next.setHours(24, 0, 0, 0);
return next.getTime() - now.getTime();
}
function msUntilNextMonday() {
const now = new Date();
const day = now.getDay(); // 0=Sun
const daysUntilMonday = day === 0 ? 1 : (8 - day);
const next = new Date(now);
next.setDate(now.getDate() + daysUntilMonday);
next.setHours(0, 0, 0, 0);
return next.getTime() - now.getTime();
}
function msUntilNextMonth() {
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
return next.getTime() - now.getTime();
}
// Callbacks to run on daily reset (e.g. clear firedToday in patternChecker)
const dailyResetCallbacks = [];
function onDailyReset(fn) {
dailyResetCallbacks.push(fn);
}
function scheduleDailyReset() {
setTimeout(() => {
store.today = new Map();
for (const fn of dailyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleDailyReset();
}, msUntilNextMidnight());
}
function scheduleWeeklyReset() {
setTimeout(() => {
store.week = new Map();
scheduleWeeklyReset();
}, msUntilNextMonday());
}
function scheduleMonthlyReset() {
setTimeout(() => {
store.month = new Map();
scheduleMonthlyReset();
}, msUntilNextMonth());
}
function scheduleResets() {
scheduleDailyReset();
scheduleWeeklyReset();
scheduleMonthlyReset();
}
// --- Cooldown store ---
const cooldowns = new Map();
function setCooldown(key) {
cooldowns.set(key, Date.now());
}
function isOnCooldown(key, cooldownMinutes) {
const last = cooldowns.get(key);
if (!last) return false;
return (Date.now() - last) < cooldownMinutes * 60 * 1000;
}
// --- Staff last-seen tracker (fallback for missing presence intent) ---
const staffLastSeen = new Map();
function updateStaffLastSeen(staffId) {
staffLastSeen.set(staffId, Date.now());
}
function getStaffLastSeen(staffId) {
return staffLastSeen.get(staffId) || null;
}
function isStaffRecentlyActive(staffId, withinMinutes = 60) {
const last = staffLastSeen.get(staffId);
if (!last) return false;
return (Date.now() - last) < withinMinutes * 60 * 1000;
}
module.exports = {
increment,
get,
reset,
getAll,
scheduleResets,
onDailyReset,
setCooldown,
isOnCooldown,
updateStaffLastSeen,
getStaffLastSeen,
isStaffRecentlyActive
};

41
services/pinMessage.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* Auto-pin utility — pins a message with error handling and optional
* system message suppression.
*
* Discord rate-limits pin operations to approximately 5 per second per
* channel. Since pins only happen on ticket creation and escalation (low
* frequency), no additional rate limiting is needed. The bot requires
* MANAGE_MESSAGES permission to pin — if this is missing, the pin will
* fail with code 50013 and be caught by the catch block.
*/
const { CONFIG } = require('../config');
const { logWarn } = require('./debugLog');
/**
* Pin a message in a channel.
* @param {import('discord.js').Message} message
* @param {import('discord.js').Client} client
*/
async function pinMessage(message, client) {
try {
await message.pin();
if (CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE) {
await new Promise(r => setTimeout(r, 1000));
const systemMessages = await message.channel.messages.fetch({ limit: 5 });
const pinNotice = systemMessages.find(m =>
m.type === 6 && // MessageType.ChannelPinnedMessage
Date.now() - m.createdTimestamp < 10000
);
if (pinNotice) await pinNotice.delete().catch(() => {});
}
} catch (err) {
if (err.code === 30003) {
await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
} else {
await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
}
}
}
module.exports = { pinMessage };

View File

@@ -10,6 +10,7 @@
*/
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { increment } = require('./patternStore');
const Ticket = mongoose.model('Ticket');
const StaffNotification = mongoose.model('StaffNotification');
@@ -96,6 +97,8 @@ async function notifyAllStaffUnclaimed(client) {
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
if (chan) {
await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e));
increment('staff_stale_pings', rec.userId, 'today');
increment('staff_stale_pings', rec.userId, 'week');
}
}

48
services/staffPresence.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* Staff presence detection — checks Discord presence status for staff members.
* Requires GuildPresences intent enabled in Discord Developer Portal.
*/
const { CONFIG } = require('../config');
/**
* Get categorized availability of all configured staff members.
* @param {import('discord.js').Guild} guild
* @returns {{ online: string[], dnd: string[], offline: string[], unknown: string[] }}
*/
function getStaffAvailability(guild) {
const results = {
online: [],
dnd: [],
offline: [],
unknown: []
};
for (const staffId of CONFIG.STAFF_IDS) {
const member = guild.members.cache.get(staffId);
if (!member) { results.offline.push(staffId); continue; }
const status = member.presence?.status;
if (!status) { results.unknown.push(staffId); continue; }
if (status === 'online' || status === 'idle') results.online.push(staffId);
else if (status === 'dnd') results.dnd.push(staffId);
else results.offline.push(staffId);
}
return results;
}
/**
* Check if any staff member is currently available.
* @param {import('discord.js').Guild} guild
* @returns {{ available: boolean|null, source: string }}
*/
function isAnyStaffAvailable(guild) {
const { online, dnd, unknown } = getStaffAvailability(guild);
if (online.length > 0) return { available: true, source: 'presence' };
if (CONFIG.STAFF_DND_COUNTS_AS_AVAILABLE && dnd.length > 0) return { available: true, source: 'presence_dnd' };
if (unknown.length === CONFIG.STAFF_IDS.length) return { available: null, source: 'unknown' };
return { available: false, source: 'presence' };
}
module.exports = { getStaffAvailability, isAnyStaffAvailable };

96
services/staffThread.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* Staff discussion threads — creates a private thread on each ticket channel
* for staff-only communication.
*
* Notes:
* - The bot requires CREATE_PRIVATE_THREADS and SEND_MESSAGES_IN_THREADS
* permissions on every ticket category.
* - Private threads (type: 12) require the server to have Community features
* OR the channel to be in a server with Boost level that unlocks private
* threads. If thread creation fails with code 50024 or 160004, a warning
* is logged via logWarn.
* - invitable: false means only staff with MANAGE_THREADS can add additional
* members — this is intentional for privacy.
* - guild.members.fetch() in addRoleMembersToThread can be slow on large
* servers. The 300ms delay between adds avoids the thread member add rate
* limit (approximately 5/second).
*/
const { CONFIG } = require('../config');
const { logError, logWarn } = require('./debugLog');
/**
* Create a private staff thread on a ticket channel.
* @param {import('discord.js').TextChannel} channel
* @param {import('discord.js').Client} client
* @returns {Promise<import('discord.js').ThreadChannel|null>}
*/
async function createStaffThread(channel, client) {
if (!CONFIG.STAFF_THREAD_ENABLED) return null;
try {
const threadName = CONFIG.STAFF_THREAD_NAME.slice(0, 100);
const thread = await channel.threads.create({
name: threadName,
type: 12, // ChannelType.PrivateThread
invitable: false,
reason: 'Staff discussion thread for ticket'
});
if (CONFIG.STAFF_THREAD_AUTO_ADD_ROLE && CONFIG.STAFF_THREAD_ROLE_ID) {
await addRoleMembersToThread(thread, channel.guild, client);
}
return thread;
} catch (err) {
// Detect permission / channel type errors
if (err.code === 50024 || err.code === 160004) {
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
}
await logError('staffThread:create', err, null, client).catch(() => {});
return null;
}
}
/**
* Add all members of the staff role to the thread.
*/
async function addRoleMembersToThread(thread, guild, client) {
try {
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
if (!role) return;
await guild.members.fetch();
const members = guild.members.cache.filter(m =>
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot
);
for (const [, member] of members) {
await thread.members.add(member.id).catch(() => {});
await new Promise(r => setTimeout(r, 300));
}
} catch (err) {
await logError('staffThread:addMembers', err, null, client).catch(() => {});
}
}
/**
* Add a single member to the staff thread for a ticket channel.
* Call this when a ticket is claimed.
*/
async function addMemberToStaffThread(channel, memberId) {
if (!CONFIG.STAFF_THREAD_ENABLED) return;
try {
const threads = await channel.threads.fetchActive();
const staffThread = threads.threads.find(t =>
t.name === CONFIG.STAFF_THREAD_NAME && t.type === 12
);
if (!staffThread) return;
await staffThread.members.add(memberId);
} catch {
// non-critical, ignore
}
}
module.exports = { createStaffThread, addMemberToStaffThread };

191
services/surgeChecker.js Normal file
View File

@@ -0,0 +1,191 @@
/**
* 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 }
});
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 }
});
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 };

View File

@@ -3,9 +3,10 @@
* reminders, auto-unclaim, channel creation.
*/
const { ChannelType, PermissionFlagsBits } = require('discord.js');
const { mongoose } = require('../db-connection');
const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter');
@@ -472,12 +473,14 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
const staleTickets = await Ticket.find({
const staleTickets = await withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null }
}).lean();
}).lean());
let checked = 0, closed = 0;
for (const ticket of staleTickets) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
@@ -486,32 +489,36 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
if (channel) {
await channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
await Ticket.updateOne(
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed' } }
);
));
await sendTicketClosedEmail(ticket, 'Auto-Close System');
setTimeout(() => channel.delete().catch(() => {}), 5000);
closed++;
}
} catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
}
}
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
}
async function checkReminders(client) {
if (!CONFIG.REMINDER_ENABLED) return;
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
const ticketsNeedingReminder = await Ticket.find({
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: reminderTime, $ne: null },
reminderSent: false
}).lean();
}).lean());
let checked = 0, reminded = 0;
for (const ticket of ticketsNeedingReminder) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
@@ -526,49 +533,55 @@ async function checkReminders(client) {
.replace(/\{ping\}/g, ping);
await channel.send(message);
await Ticket.updateOne(
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { reminderSent: true } }
);
));
reminded++;
}
} catch (error) {
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
}
}
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
}
async function checkAutoUnclaim(client) {
if (!CONFIG.AUTO_UNCLAIM_ENABLED) return;
const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000));
const staleClaimedTickets = await Ticket.find({
const staleClaimedTickets = await withRetry(() => Ticket.find({
status: 'open',
claimedBy: { $ne: null },
lastActivity: { $lt: unclaimTime, $ne: null }
}).lean();
}).lean());
let checked = 0, unclaimed = 0;
for (const ticket of staleClaimedTickets) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
await Ticket.updateOne(
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: null } }
);
));
await channel.send(
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
);
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
unclaimed++;
}
} catch (error) {
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
}
}
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
}
module.exports = {