huge changes
This commit is contained in:
@@ -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 };
|
||||
|
||||
86
services/chatAlertChecker.js
Normal file
86
services/chatAlertChecker.js
Normal 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 };
|
||||
105
services/configPersistence.js
Normal file
105
services/configPersistence.js
Normal 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 };
|
||||
@@ -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
535
services/patternChecker.js
Normal 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
148
services/patternStore.js
Normal 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
41
services/pinMessage.js
Normal 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 };
|
||||
@@ -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
48
services/staffPresence.js
Normal 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
96
services/staffThread.js
Normal 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
191
services/surgeChecker.js
Normal 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 };
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user