strip: remove pattern/surge/chat alert monitoring + unused commands

- delete services/{patternChecker,patternStore,surgeChecker,chatAlertChecker,staffNotifications,staffChannel,notificationRegistry,notificationEnabled,staffPresence}.js
- remove /notification, /staffnotification, /tag, /priority
- /escalate: drop action param, always unclaim
- purge PATTERN_*, SURGE_*, CHAT_ALERT_*, STAFF_* env vars from config + .env.example
- drop StaffNotification model
- ~2500 LOC removed
- settings-site /internal/notifications/* endpoints gone (UI will 404 until trimmed)
This commit is contained in:
2026-04-21 15:57:18 +00:00
parent 298cf13d5c
commit 636348d824
27 changed files with 3335 additions and 2532 deletions

View File

@@ -1,98 +0,0 @@
/**
* Chat monitoring — tracks unresponded messages in configured channels
* and alerts staff when thresholds are crossed.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
const CHAT_ALERT_KEYS = ['chat_messages', 'chat_time'];
assertKeysRegistered('chatAlertChecker', CHAT_ALERT_KEYS);
// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt }
const chatState = new Map();
const chatMessageThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_messages || [])
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n > 0);
const chatTimeThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_time || [])
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n > 0);
function initChatMonitoring(client) {
for (const channelId of CONFIG.CHAT_ALERT_CHANNEL_IDS) {
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;
clearEscalating(`chat:messages:${msg.channel.id}`);
clearEscalating(`chat:time:${msg.channel.id}`);
} 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 (isEnabled('chat_messages') && state.unrespondedCount >= CONFIG.CHAT_ALERT_MESSAGE_COUNT) {
const cooldownKey = `chat:messages:${channelId}`;
if (shouldFireCooldownEscalating(cooldownKey, chatMessageThresholdsMs) !== null) {
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 enqueueSend(alertChan, { content, embeds: [embed] });
} catch (_) {}
}
}
// Time threshold
const hoursSinceStaff = (Date.now() - state.lastStaffMessageAt.getTime()) / 3600000;
if (isEnabled('chat_time') && hoursSinceStaff >= CONFIG.CHAT_ALERT_HOURS_WITHOUT_RESPONSE && state.unrespondedCount > 0) {
const cooldownKey = `chat:time:${channelId}`;
if (shouldFireCooldownEscalating(cooldownKey, chatTimeThresholdsMs) !== null) {
const embed = new EmbedBuilder()
.setTitle('Chat without staff response')
.setDescription(`<#${channelId}> has had no staff response for ${Math.floor(hoursSinceStaff)} hour(s) with ${state.unrespondedCount} pending message(s).`)
.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 enqueueSend(alertChan, { content, embeds: [embed] });
} catch (_) {}
}
}
}
}
module.exports = { initChatMonitoring, handleChatMessage, runChatAlertChecks };

View File

@@ -27,17 +27,12 @@ const ALLOWED_CONFIG_KEYS = new Set([
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
'ADMIN_ID',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
'STAFF_NOTIFICATION_CATEGORY_ID',
// Pattern channel IDs
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID',
// Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
@@ -48,36 +43,17 @@ const ALLOWED_CONFIG_KEYS = new Set([
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
// Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
'ALLOW_CLAIM_OVERWRITE',
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
'STAFF_DND_COUNTS_AS_AVAILABLE',
// Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
// Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
// Pattern thresholds
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
// Surge settings
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
// Chat alerts
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
// Notification thresholds
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS',
// Notification enable state (Phase 9)
'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED'
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
]);
// ---------- Regex primitives ----------
@@ -207,13 +183,9 @@ const VALIDATORS = {
function inferType(key) {
// 1. Explicit overrides
if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json';
if (key === 'NOTIFICATION_ENABLED_JSON') return 'json';
if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean';
if (key === 'LOGO_URL') return 'url';
if (/_EMAIL$/.test(key)) return 'email';
if (key.includes('COLOR')) return 'hex_color';
if (/_EMOJIS$/.test(key)) return 'string_or_json';
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
if (key === 'ROLE_ID_TO_PING') return 'discord_id';

View File

@@ -0,0 +1,262 @@
/**
* Per-key config value validator registry.
*
* Pattern-driven type inference for every key in ALLOWED_CONFIG_KEYS.
* getValidator(key) returns { type, validate(value) }, where validate returns
* { ok: true, coerced } — typed value to assign into CONFIG[key]
* { ok: false, error } — human-readable reason surfaced in the save UI
*
* .env always stores String(coerced); CONFIG gets the typed coerced value so
* downstream consumers that compare === true / === 5 still work.
*
* This file is the canonical source for ALLOWED_CONFIG_KEYS — routes/internalApi
* imports the Set from here. That keeps the require graph acyclic:
* internalApi -> configPersistence -> configSchema
* internalApi -> configSchema
* No side effects beyond a one-line startup log of the fallback-string keys.
*/
'use strict';
const ALLOWED_CONFIG_KEYS = new Set([
// Ticket settings
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
// Escalation categories
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
'STAFF_NOTIFICATION_CATEGORY_ID',
// Pattern channel IDs
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID',
// Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
// Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
'STAFF_DND_COUNTS_AS_AVAILABLE',
// Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
// Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
// Pattern thresholds
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
// Surge settings
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
// Chat alerts
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
// Notification thresholds
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS',
// Notification enable state (Phase 9)
'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED'
]);
// ---------- Regex primitives ----------
const SNOWFLAKE_RE = /^[0-9]{17,20}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const HEX_COLOR_RE = /^(?:0x|#)?([0-9A-Fa-f]{6})$/;
const INT_RE = /^-?\d+$/;
const NUMERIC_COERCE_RE = /^-?\d+(?:\.\d+)?$/;
function isEmptyInput(v) {
return v === '' || v === null || v === undefined;
}
// ---------- Validators ----------
const VALIDATORS = {
boolean: {
type: 'boolean',
validate(value) {
if (value === true || value === 'true') return { ok: true, coerced: true };
if (value === false || value === 'false') return { ok: true, coerced: false };
return { ok: false, error: 'must be true or false' };
}
},
integer: {
type: 'integer',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value).trim();
if (!INT_RE.test(str)) return { ok: false, error: 'must be a whole number' };
const n = parseInt(str, 10);
if (!Number.isFinite(n) || n < 0) return { ok: false, error: 'must be zero or a positive integer' };
return { ok: true, coerced: n };
}
},
hex_color: {
type: 'hex_color',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value).trim();
const m = str.match(HEX_COLOR_RE);
if (!m) return { ok: false, error: 'must be a 6-digit hex color like 0xRRGGBB or #RRGGBB' };
return { ok: true, coerced: '0x' + m[1].toUpperCase() };
}
},
url: {
type: 'url',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value).trim();
try {
new URL(str);
return { ok: true, coerced: str };
} catch (_) {
return { ok: false, error: 'must be a valid URL (include the protocol)' };
}
}
},
email: {
type: 'email',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value).trim();
if (!EMAIL_RE.test(str)) return { ok: false, error: 'must look like a valid email address' };
return { ok: true, coerced: str };
}
},
discord_id: {
type: 'discord_id',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value).trim();
if (!SNOWFLAKE_RE.test(str)) return { ok: false, error: 'must be a Discord ID (1720 digits) or empty' };
return { ok: true, coerced: str };
}
},
discord_id_list: {
type: 'discord_id_list',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value).trim();
if (str === '') return { ok: true, coerced: '' };
const parts = str.split(',').map(p => p.trim()).filter(Boolean);
for (const p of parts) {
if (!SNOWFLAKE_RE.test(p)) return { ok: false, error: `"${p}" is not a Discord ID` };
}
return { ok: true, coerced: parts.join(',') };
}
},
json: {
type: 'json',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value);
try {
JSON.parse(str);
return { ok: true, coerced: str };
} catch (_) {
return { ok: false, error: 'must be valid JSON' };
}
}
},
string_or_json: {
type: 'string_or_json',
validate(value) {
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
return { ok: true, coerced: String(value) };
}
},
// Fallback. Preserves legacy coercion so CONFIG.* values keep their types
// for consumers that compare with === true / === 5 (see old applyConfigUpdates).
string: {
type: 'string',
validate(value) {
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
if (value === 'true' || value === true) return { ok: true, coerced: true };
if (value === 'false' || value === false) return { ok: true, coerced: false };
const str = String(value);
if (str !== '' && NUMERIC_COERCE_RE.test(str)) return { ok: true, coerced: Number(str) };
return { ok: true, coerced: str };
}
}
};
// ---------- Type inference ----------
function inferType(key) {
// 1. Explicit overrides
if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json';
if (key === 'NOTIFICATION_ENABLED_JSON') return 'json';
if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean';
if (key === 'LOGO_URL') return 'url';
if (/_EMAIL$/.test(key)) return 'email';
if (key.includes('COLOR')) return 'hex_color';
if (/_EMOJIS$/.test(key)) return 'string_or_json';
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
// 2. Name patterns
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';
if (/_IDS$/.test(key)) return 'discord_id_list';
if (/_ID$/.test(key)) return 'discord_id';
if (/_HOURS$|_MINUTES$|_SECONDS$|_COUNT$|_LIMIT$|_THRESHOLD$/.test(key)) return 'integer';
// 3. Fallback
return 'string';
}
function getValidator(key) {
return VALIDATORS[inferType(key)];
}
// Pre-build per-key validator map for callers that want O(1) lookup
// (and for the smoke test / boot log).
const ALL_VALIDATORS = {};
for (const key of ALLOWED_CONFIG_KEYS) {
ALL_VALIDATORS[key] = getValidator(key);
}
// ---------- Startup log (no-op if console.log is suppressed) ----------
(function logDistribution() {
const dist = {};
const fallback = [];
for (const [key, v] of Object.entries(ALL_VALIDATORS)) {
dist[v.type] = (dist[v.type] || 0) + 1;
if (v.type === 'string') fallback.push(key);
}
console.log('[configSchema] type distribution:', JSON.stringify(dist));
if (fallback.length) {
console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', '));
}
})();
module.exports = {
ALLOWED_CONFIG_KEYS,
VALIDATORS,
ALL_VALIDATORS,
getValidator,
inferType
};

View File

@@ -1,102 +0,0 @@
/**
* Canonical enable/disable state accessor for per-alert notifications.
*
* State lives in two CONFIG keys:
* - NOTIFICATIONS_MASTER_ENABLED (boolean) — global kill switch
* - NOTIFICATION_ENABLED_JSON (JSON string → flat { [key]: boolean })
*
* Defaults: master off, every key off. Unknown keys in the JSON are ignored
* on read (registry is the source of truth); keys missing from the JSON are
* treated as false. Master off short-circuits every read — isEnabled never
* returns true when master is off, so checkers bail without logs or metrics.
*
* Setters mutate CONFIG in memory and return the new value so the caller can
* persist it via configPersistence.applyConfigUpdates. .env writes happen
* there so schema validation and partial-success semantics stay consistent.
*/
'use strict';
const { CONFIG } = require('../config');
const { REGISTRY } = require('./notificationRegistry');
function parseState() {
const raw = CONFIG.NOTIFICATION_ENABLED_JSON;
if (raw === undefined || raw === null || raw === '') return {};
if (typeof raw === 'object' && !Array.isArray(raw)) return raw;
try {
const parsed = JSON.parse(String(raw));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch (_) {}
return {};
}
function isMasterOn() {
const v = CONFIG.NOTIFICATIONS_MASTER_ENABLED;
return v === true || v === 'true';
}
function isEnabled(alertKey) {
if (!isMasterOn()) return false;
const state = parseState();
return state[alertKey] === true;
}
function isCategoryEnabled(category) {
if (!isMasterOn()) return false;
const entries = REGISTRY[category];
if (!Array.isArray(entries) || entries.length === 0) return false;
const state = parseState();
return entries.every(e => state[e.key] === true);
}
function getAllState() {
const state = parseState();
const perKey = {};
for (const entries of Object.values(REGISTRY)) {
if (!Array.isArray(entries)) continue;
for (const e of entries) {
perKey[e.key] = state[e.key] === true;
}
}
return { master: isMasterOn(), perKey };
}
function serialize(state) {
const ordered = {};
Object.keys(state).sort().forEach(k => { ordered[k] = state[k] === true; });
return JSON.stringify(ordered);
}
function setKeyEnabled(key, enabled) {
const state = parseState();
state[String(key)] = enabled === true;
const json = serialize(state);
CONFIG.NOTIFICATION_ENABLED_JSON = json;
return json;
}
function setCategoryEnabled(category, enabled) {
const state = parseState();
const entries = REGISTRY[category];
if (Array.isArray(entries)) {
for (const e of entries) state[e.key] = enabled === true;
}
const json = serialize(state);
CONFIG.NOTIFICATION_ENABLED_JSON = json;
return json;
}
function setMasterEnabled(enabled) {
const value = enabled === true;
CONFIG.NOTIFICATIONS_MASTER_ENABLED = value;
return value;
}
module.exports = {
isEnabled,
isCategoryEnabled,
getAllState,
setKeyEnabled,
setCategoryEnabled,
setMasterEnabled
};

View File

@@ -1,214 +0,0 @@
/**
* Canonical notification alert registry.
*
* Single source of truth for the 32 registered alert keys across surgeChecker,
* patternChecker, staffNotifications, and chatAlertChecker. Consumed by:
* - the checker services (startup drift-check, Phase 9 enable gating)
* - routes/internalApi.js GET /notifications/alerts
* - settings-site UI (via proxied /api/notifications/alerts, with fallback)
*
* Not covered here (intentionally fallback-only in the UI):
* - rapid_t2_t3 — uses count-milestone firing, not shouldFire()
*
* `windowType` is the reset window used by shouldFire() for pattern keys
* (today/week/month). For surge, unclaimed, and chat, firing is
* cooldown-escalating rather than window-based, so windowType is null.
*/
const REGISTRY = Object.freeze({
surge: Object.freeze([
Object.freeze({
key: 'surge_tickets',
description: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
windowType: null
}),
Object.freeze({
key: 'surge_game',
description: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
windowType: null
}),
Object.freeze({
key: 'surge_stale',
description: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
windowType: null
}),
Object.freeze({
key: 'surge_needs_response',
description: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
windowType: null
}),
Object.freeze({
key: 'surge_unclaimed',
description: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
windowType: null
}),
Object.freeze({
key: 'surge_tier3_unclaimed',
description: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
windowType: null
}),
Object.freeze({
key: 'surge_no_staff',
description: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
windowType: null
})
]),
patterns: Object.freeze([
Object.freeze({
key: 'user_tickets',
description: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
windowType: 'today'
}),
Object.freeze({
key: 'user_reopen',
description: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
windowType: 'week'
}),
Object.freeze({
key: 'user_crossgame',
description: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
windowType: 'week'
}),
Object.freeze({
key: 'game_surge',
description: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
windowType: 'today'
}),
Object.freeze({
key: 'game_backlog',
description: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
windowType: 'today'
}),
Object.freeze({
key: 'game_resolution',
description: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
windowType: 'week'
}),
Object.freeze({
key: 'game_spike',
description: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
windowType: 'today'
}),
Object.freeze({
key: 'tag_top',
description: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
windowType: 'today'
}),
Object.freeze({
key: 'tag_escalation',
description: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
windowType: 'week'
}),
Object.freeze({
key: 'untagged_closes',
description: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
windowType: 'today'
}),
Object.freeze({
key: 'tag_game_corr',
description: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
windowType: 'week'
}),
Object.freeze({
key: 'user_esc',
description: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
windowType: 'week'
}),
Object.freeze({
key: 'game_esc_rate',
description: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
windowType: 'week'
}),
Object.freeze({
key: 'staff_no_close',
description: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_overloaded',
description: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_stale',
description: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_transfer_rate',
description: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_esc',
description: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
windowType: 'week'
}),
Object.freeze({
key: 'staff_game_esc',
description: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
windowType: 'week'
}),
Object.freeze({
key: 'game_tag_spike',
description: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
windowType: 'today'
}),
Object.freeze({
key: 'overnight_gap',
description: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
windowType: 'week'
}),
Object.freeze({
key: 'staff_always_esc',
description: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
windowType: 'month'
})
]),
unclaimed: Object.freeze([
Object.freeze({
key: 'unclaimed_reminder',
description: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
windowType: null
})
]),
chat: Object.freeze([
Object.freeze({
key: 'chat_messages',
description: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
windowType: null
}),
Object.freeze({
key: 'chat_time',
description: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.',
windowType: null
})
])
});
const ALL_KEYS = Object.freeze([
...REGISTRY.surge.map(e => e.key),
...REGISTRY.patterns.map(e => e.key),
...REGISTRY.unclaimed.map(e => e.key),
...REGISTRY.chat.map(e => e.key)
]);
const ALL_KEYS_SET = new Set(ALL_KEYS);
/**
* Throws if any of `keys` is not in the registry. Call at module load from
* each checker that references registry keys so drift fails fast.
*/
function assertKeysRegistered(moduleName, keys) {
const missing = keys.filter(k => !ALL_KEYS_SET.has(k));
if (missing.length > 0) {
throw new Error(
`[notificationRegistry] ${moduleName} references keys not in REGISTRY: ${missing.join(', ')}`
);
}
}
module.exports = { REGISTRY, ALL_KEYS, assertKeysRegistered };

View File

@@ -1,587 +0,0 @@
/**
* Pattern detection — scheduled checks that analyze ticket trends and post
* alerts to dedicated Discord channels.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert keys this module fires via shouldFire()/standard threshold path.
// rapid_t2_t3 is intentionally excluded — it uses count-milestone firing below
// via firedCountMilestones, not the shouldFire() pipeline, so it is not part
// of the notification registry.
const PATTERN_ALERT_KEYS = [
'user_tickets', 'user_reopen', 'user_crossgame',
'game_surge', 'game_backlog', 'game_resolution', 'game_spike',
'tag_top', 'tag_escalation', 'untagged_closes', 'tag_game_corr',
'user_esc', 'game_esc_rate',
'staff_no_close', 'staff_overloaded', 'staff_stale', 'staff_transfer_rate',
'staff_esc', 'staff_game_esc',
'game_tag_spike', 'overnight_gap', 'staff_always_esc'
];
assertKeysRegistered('patternChecker', PATTERN_ALERT_KEYS);
const Ticket = mongoose.model('Ticket');
// rapid_t2_t3 count milestone state (cleared weekly)
const firedCountMilestones = new Map();
onWeeklyReset(() => firedCountMilestones.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 enqueueSend(channel, { embeds: [embed] });
} catch (_) {}
}
function getWindowStartMs(windowType) {
if (windowType === 'today') {
const start = new Date();
start.setHours(0, 0, 0, 0);
return start.getTime();
}
if (windowType === 'week') return getThisWeekStart().getTime();
if (windowType === 'month') {
const start = new Date();
start.setDate(1);
start.setHours(0, 0, 0, 0);
return start.getTime();
}
return Date.now();
}
function shouldFire(alertKey, key, windowType) {
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0);
if (thresholds.length === 0) return false;
const ageMs = Date.now() - getWindowStartMs(windowType);
return shouldFireThreshold(key, ageMs, thresholds, windowType) !== null;
}
function getThisWeekStart() {
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 (isEnabled('user_tickets') && shouldFire('user_tickets', key, 'today')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Repeat ticket user',
`User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`,
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 (isEnabled('user_reopen') && shouldFire('user_reopen', key, 'week')) {
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 (isEnabled('user_crossgame') && shouldFire('user_crossgame', key, 'week')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Cross-game user',
`${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`,
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 (isEnabled('game_surge') && shouldFire('game_surge', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game ticket surge',
`**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`,
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 (isEnabled('game_backlog') && shouldFire('game_backlog', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game backlog alert',
`**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`,
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 (isEnabled('game_resolution') && shouldFire('game_resolution', key, 'week')) {
const twHrs = (tw.avg / 3600000).toFixed(1);
const lwHrs = (lw / 3600000).toFixed(1);
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'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 (isEnabled('game_spike') && shouldFire('game_spike', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Possible outage',
`**${game}**: ${count} tickets today after 0 in the last 3 days.`,
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 (isEnabled('tag_top') && shouldFire('tag_top', key, 'today')) {
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 (isEnabled('tag_escalation') && shouldFire('tag_escalation', key, 'week')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Tag frequently leads to escalation',
`**${te._id}**: ${te.count} escalated tickets this week.`,
0xFFAA00
));
}
}
} catch (_) {}
// Untagged closes
const untaggedCount = get('untagged_closes', 'total', 'today');
if (untaggedCount >= 5) {
const key = 'untagged_closes:today';
if (isEnabled('untagged_closes') && shouldFire('untagged_closes', key, 'today')) {
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 (isEnabled('tag_game_corr') && shouldFire('tag_game_corr', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Auto-tagging opportunity',
`**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`,
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 (isEnabled('user_esc') && shouldFire('user_esc', key, 'week')) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Frequent escalation user',
`\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`,
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 (isEnabled('game_esc_rate') && shouldFire('game_esc_rate', key, 'week')) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'High escalation rate for game',
`**${tw._id}**: ${tw.count}/${gameTotal} tickets escalated (${Math.round(tw.count / gameTotal * 100)}%) this week.`,
0xFF6600
));
}
}
}
} catch (_) {}
// Rapid tier 2→3
if (!isEnabled('rapid_t2_t3')) return;
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';
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.rapid_t2_t3) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.sort((a, b) => a - b);
const firedSet = firedCountMilestones.get(key) || new Set();
let shouldNotify = false;
for (const threshold of thresholds) {
if (rapidCount >= threshold && !firedSet.has(threshold)) {
firedSet.add(threshold);
shouldNotify = true;
break;
}
}
if (shouldNotify) {
firedCountMilestones.set(key, firedSet);
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Rapid tier 3 escalations',
`${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 (isEnabled('staff_no_close') && shouldFire('staff_no_close', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Claims without closes',
`Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`,
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 (isEnabled('staff_overloaded') && shouldFire('staff_overloaded', key, 'today')) {
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 (isEnabled('staff_stale') && shouldFire('staff_stale', key, 'today')) {
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 (isEnabled('staff_transfer_rate') && shouldFire('staff_transfer_rate', key, 'today')) {
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 (isEnabled('staff_esc') && shouldFire('staff_esc', key, 'week')) {
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 (isEnabled('staff_game_esc') && shouldFire('staff_game_esc', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff may need training for this game',
`Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`,
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 (isEnabled('game_tag_spike') && shouldFire('game_tag_spike', key, 'today')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Specific feature of specific game spiking',
`**${game}** + **${tag}**: ${tagGameCount} tickets this week.`,
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 (isEnabled('overnight_gap') && shouldFire('overnight_gap', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Overnight coverage gap',
`Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`,
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 (isEnabled('staff_always_esc') && shouldFire('staff_always_esc', key, 'month')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff always escalates this game',
`Staff \`${s._id.staff}\` escalated ${s.escalated}/${s.total} **${s._id.game}** tickets this month.`,
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 };

View File

@@ -1,286 +0,0 @@
/**
* 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 = [];
const weeklyResetCallbacks = [];
function onDailyReset(fn) {
dailyResetCallbacks.push(fn);
}
function onWeeklyReset(fn) {
weeklyResetCallbacks.push(fn);
}
// --- Threshold firing state ---
// key -> Set<thresholdMs> that have fired within the key's window.
const firedThresholds = new Map();
// key -> window type used for threshold clearing ("today" | "week" | "month")
const firedThresholdWindows = new Map();
// key -> last-seen timestamp; drives periodic sweep for keys that outlive their window reset.
const firedThresholdLastSeen = new Map();
function clearFiredThresholdsForWindow(windowType) {
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
if (mappedWindowType === windowType) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
}
}
}
function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
if (!['today', 'week', 'month'].includes(windowType)) return null;
firedThresholdWindows.set(key, windowType);
firedThresholdLastSeen.set(key, Date.now());
const firedForKey = firedThresholds.get(key) || new Set();
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
let highestUnfiredCrossed = null;
for (const thresholdMs of sortedThresholds) {
if (ageMs >= thresholdMs && !firedForKey.has(thresholdMs)) {
highestUnfiredCrossed = thresholdMs;
}
}
if (highestUnfiredCrossed === null) return null;
firedForKey.add(highestUnfiredCrossed);
firedThresholds.set(key, firedForKey);
return highestUnfiredCrossed;
}
// --- Escalating cooldown state ---
// key -> { startedAtMs, lastFireAtMs, fireCount }
const escalatingCooldowns = new Map();
function shouldFireCooldownEscalating(key, thresholdsMs) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
const now = Date.now();
let state = escalatingCooldowns.get(key);
if (!state) {
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now };
escalatingCooldowns.set(key, state);
}
state.lastUsed = now;
const nextThreshold = sortedThresholds[state.fireCount];
if (typeof nextThreshold !== 'number') return null;
const referenceMs = state.fireCount === 0 ? state.startedAtMs : state.lastFireAtMs;
if ((now - referenceMs) < nextThreshold) return null;
state.fireCount += 1;
state.lastFireAtMs = now;
return nextThreshold;
}
function clearEscalating(key) {
escalatingCooldowns.delete(key);
}
const SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function cleanupStaleEscalatingCooldowns(now = Date.now()) {
const cutoff = now - SWEEP_TTL_MS;
for (const [key, state] of escalatingCooldowns.entries()) {
const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0;
if (lastUsed < cutoff) escalatingCooldowns.delete(key);
}
}
// Sweep every per-Map timestamp-bearing entry older than SWEEP_TTL_MS.
// firedThresholds/firedThresholdWindows are cleared by windowType-resets;
// this sweep covers keys whose window never resets under load.
function sweepPatternStore(now = Date.now()) {
const cutoff = now - SWEEP_TTL_MS;
for (const [key, ts] of cooldowns.entries()) {
if (ts < cutoff) cooldowns.delete(key);
}
for (const [key, ts] of staffLastSeen.entries()) {
if (ts < cutoff) staffLastSeen.delete(key);
}
cleanupStaleEscalatingCooldowns(now);
for (const [key, ts] of firedThresholdLastSeen.entries()) {
if (ts < cutoff) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
}
}
}
/**
* Register the module's sweep on the given trackInterval function.
* Called once from the ready handler. Interval is unref'd so it never
* blocks shutdown; trackInterval ensures handleShutdown clears it.
*/
function startSweeps(trackInterval) {
const handle = setInterval(() => sweepPatternStore(), SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
function scheduleDailyReset() {
setTimeout(() => {
store.today = new Map();
clearFiredThresholdsForWindow('today');
for (const fn of dailyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleDailyReset();
}, msUntilNextMidnight());
}
function scheduleWeeklyReset() {
setTimeout(() => {
store.week = new Map();
clearFiredThresholdsForWindow('week');
for (const fn of weeklyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleWeeklyReset();
}, msUntilNextMonday());
}
function scheduleMonthlyReset() {
setTimeout(() => {
store.month = new Map();
clearFiredThresholdsForWindow('month');
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,
onWeeklyReset,
firedThresholds,
shouldFireThreshold,
shouldFireCooldownEscalating,
clearEscalating,
setCooldown,
isOnCooldown,
updateStaffLastSeen,
getStaffLastSeen,
isStaffRecentlyActive,
startSweeps,
sweepPatternStore,
// test-only exports
_internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS }
};

View File

@@ -1,89 +0,0 @@
const { CONFIG } = require('../config');
const { enqueueSend } = require('./channelQueue');
/**
* Create a staff tracking channel for a ticket.
* Returns the created channel or null if no staff category configured.
*/
async function createStaffChannel(guild, ticket, claimerId, channelName) {
const categoryId = CONFIG.STAFF_CATEGORIES.get(claimerId);
if (!categoryId) return null;
try {
const { ChannelType } = require('discord.js');
const staffChan = await guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: categoryId
});
// Build pinned embed with ticket info + jump link to original ticket channel
const { EmbedBuilder } = require('discord.js');
const originalChannel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
const jumpLink = originalChannel ? `https://discord.com/channels/${guild.id}/${ticket.discordThreadId}` : null;
const embed = new EmbedBuilder()
.setTitle(`🎫 Ticket #${ticket.ticketNumber}`)
.setColor(0x5865f2)
.addFields(
{ name: 'Customer', value: ticket.senderEmail || 'Unknown', inline: true },
{ name: 'Game', value: ticket.game || 'Not detected', inline: true },
{ name: 'Subject', value: ticket.subject || 'No subject', inline: false },
{ name: 'Original Ticket', value: jumpLink ? `[Jump to ticket](${jumpLink})` : 'Unknown', inline: false }
)
.setFooter({ text: `Claimed by ${ticket.claimedBy || 'Unknown'}` })
.setTimestamp();
const pinMsg = await enqueueSend(staffChan, { embeds: [embed] });
await pinMsg.pin().catch(() => {});
return staffChan;
} catch (e) {
console.error('Failed to create staff channel:', e);
return null;
}
}
/**
* Ping the staff channel with a customer reply, including jump link and message copy.
*/
async function pingStaffChannel(staffChannel, claimerId, originalMessage) {
if (!staffChannel) return;
try {
const jumpLink = `https://discord.com/channels/${originalMessage.guild.id}/${originalMessage.channel.id}/${originalMessage.id}`;
await enqueueSend(staffChannel,
`<@${claimerId}> Customer replied in ticket:\n> ${originalMessage.content.slice(0, 500)}\n[Jump to message](${jumpLink})`
);
} catch (e) {
console.error('Failed to ping staff channel:', e);
}
}
/**
* Move staff channel to a different category.
*/
async function moveStaffChannel(staffChannel, categoryId) {
if (!staffChannel || !categoryId) return;
try {
const { enqueueMove } = require('./channelQueue');
await enqueueMove(staffChannel, categoryId);
} catch (e) {
console.error('Failed to move staff channel:', e);
}
}
/**
* Delete the staff tracking channel.
*/
async function deleteStaffChannel(guild, staffChannelId) {
if (!staffChannelId) return;
try {
const chan = await guild.channels.fetch(staffChannelId).catch(() => null);
// TODO(queue-migrate): raw channel.delete bypasses channelQueue (enqueueDelete) — if a staff-channel send is in-flight, this can race it.
if (chan) await chan.delete();
} catch (e) {
console.error('Failed to delete staff channel:', e);
}
}
module.exports = { createStaffChannel, pingStaffChannel, moveStaffChannel, deleteStaffChannel };

View File

@@ -1,149 +0,0 @@
/**
* Staff notification service reply alerts and unclaimed ticket reminders.
*
* notifyStaffOfReply: posts in the claimer's notification channel when a
* non-staff user replies, respecting a per-staff cooldown.
*
* notifyAllStaffUnclaimed: background job that checks unclaimed tickets
* against configurable hour thresholds and posts one alert per threshold
* per ticket (highest newly-crossed threshold only).
*/
const { mongoose } = require('../db-connection');
const { CONFIG, parseThresholdString } = require('../config');
const { increment } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert key this module drives. Registered to fail fast on drift.
const UNCLAIMED_ALERT_KEYS = ['unclaimed_reminder'];
assertKeysRegistered('staffNotifications', UNCLAIMED_ALERT_KEYS);
const Ticket = mongoose.model('Ticket');
const StaffNotification = mongoose.model('StaffNotification');
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
const replyCooldowns = new Map();
const REPLY_COOLDOWN_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const REPLY_COOLDOWN_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function sweepReplyCooldowns(now = Date.now()) {
const cutoff = now - REPLY_COOLDOWN_SWEEP_TTL_MS;
for (const [key, ts] of replyCooldowns.entries()) {
if (ts < cutoff) replyCooldowns.delete(key);
}
}
function startSweeps(trackInterval) {
const handle = setInterval(() => sweepReplyCooldowns(), REPLY_COOLDOWN_SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
/**
* Notify the claiming staff member when a non-staff user replies.
* Respects the staff member's cooldownHours setting (default 1h).
* Posts in their notification channel if one exists.
*/
async function notifyStaffOfReply(guild, ticket, message) {
if (!ticket.claimerId) return;
const staffRecord = await StaffNotification.findOne({ userId: ticket.claimerId }).lean();
if (!staffRecord?.channelId) return;
const cooldownMs = (staffRecord.cooldownHours || 1) * 60 * 60 * 1000;
const cooldownKey = `${ticket.claimerId}:${ticket.gmailThreadId}`;
const lastNotified = replyCooldowns.get(cooldownKey) || 0;
if (Date.now() - lastNotified < cooldownMs) return;
const notifChannel = await guild.channels.fetch(staffRecord.channelId).catch(() => null);
if (!notifChannel) return;
const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`;
const snippet = message.content?.slice(0, 300) || '(no text)';
await enqueueSend(
notifChannel,
`New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})`
);
replyCooldowns.set(cooldownKey, Date.now());
}
/**
* Background job: check all open unclaimed tickets against hour thresholds.
* For each ticket, find the highest threshold that has been crossed but not
* yet recorded. Post one notification per ticket per run (the highest new
* threshold) into every staff notification channel.
*/
async function notifyAllStaffUnclaimed(client) {
if (!isEnabled('unclaimed_reminder')) return;
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.unclaimed_reminder) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.map(ms => ms / (60 * 60 * 1000));
if (thresholds.length === 0) return;
const sorted = [...thresholds].sort((a, b) => a - b);
const now = Date.now();
// Bounded per-tick: oldest-first, capped at 500. A backlog larger than 500
// gets drained in subsequent 30-minute ticks rather than one long run.
const unclaimedTickets = await Ticket.find({
status: 'open',
claimedBy: null,
createdAt: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean();
if (unclaimedTickets.length === 0) return;
const staffRecords = await StaffNotification.find({ channelId: { $ne: null } }).lean();
if (staffRecords.length === 0) return;
const guild = CONFIG.DISCORD_GUILD_ID
? client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID)
: client.guilds.cache.first();
if (!guild) return;
for (const ticket of unclaimedTickets) {
const ageMs = now - new Date(ticket.createdAt).getTime();
const ageHours = ageMs / (60 * 60 * 1000);
const alreadySent = ticket.unclaimedRemindersSent || [];
// Find thresholds crossed but not yet sent
const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t));
if (crossedNew.length === 0) continue;
// Only send the highest newly-crossed threshold
const highest = crossedNew[crossedNew.length - 1];
const channelName = ticket.discordThreadId
? `<#${ticket.discordThreadId}>`
: `ticket #${ticket.ticketNumber}`;
const alertMsg = `[${highest}h+ unclaimed] ${channelName}`;
for (const rec of staffRecords) {
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
if (chan) {
await enqueueSend(chan, alertMsg).catch(e => console.error('Unclaimed notify send:', e));
increment('staff_stale_pings', rec.userId, 'today');
increment('staff_stale_pings', rec.userId, 'week');
}
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $addToSet: { unclaimedRemindersSent: highest } }
);
}
}
module.exports = {
notifyStaffOfReply,
notifyAllStaffUnclaimed,
startSweeps,
sweepReplyCooldowns,
_internals: { replyCooldowns, REPLY_COOLDOWN_SWEEP_TTL_MS }
};

View File

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

View File

@@ -1,260 +0,0 @@
/**
* Surge detection — checks for critical ticket volume/staffing conditions
* and pings ALL_STAFF_CHANNEL_ID with role mention.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore');
const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert keys this module drives. Asserted against the registry at load so any
// future drift (rename, typo, unregistered key) fails fast rather than
// silently breaking the settings-site config editor.
const SURGE_ALERT_KEYS = [
'surge_tickets',
'surge_game',
'surge_stale',
'surge_needs_response',
'surge_unclaimed',
'surge_tier3_unclaimed',
'surge_no_staff'
];
assertKeysRegistered('surgeChecker', SURGE_ALERT_KEYS);
const Ticket = mongoose.model('Ticket');
function getThresholdsMs(alertKey) {
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || [];
return rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.sort((a, b) => a - b);
}
async function pingStaff(client, message, embedFields) {
const channelId = CONFIG.ALL_STAFF_CHANNEL_ID;
if (!channelId || !client) return;
try {
const channel = await client.channels.fetch(channelId);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Staff Alert')
.setDescription(message)
.setColor(0xFF4400)
.setTimestamp();
if (embedFields.length > 0) {
embed.addFields(embedFields.map(f => ({
name: f.name,
value: String(f.value).slice(0, 1024),
inline: f.inline ?? true
})));
}
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
await enqueueSend(channel, { content, embeds: [embed] });
} catch (_) {}
}
async function checkTicketSurge(client) {
if (!isEnabled('surge_tickets')) return;
const key = 'surge:tickets';
const since = new Date(Date.now() - CONFIG.SURGE_TICKET_WINDOW_MINUTES * 60000);
const count = await Ticket.countDocuments({ createdAt: { $gte: since } });
if (count >= CONFIG.SURGE_TICKET_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tickets'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets created in the past ${CONFIG.SURGE_TICKET_WINDOW_MINUTES} minutes.`,
[{ name: 'Action needed', value: 'Check open tickets and claim.', inline: false }]
);
}
} else {
clearEscalating(key);
}
}
async function checkGameSurge(client) {
if (!isEnabled('surge_game')) return;
const key = 'surge:game';
const since = new Date(Date.now() - CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES * 60000);
const gameCounts = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, game: { $ne: null } } },
{ $group: { _id: '$game', count: { $sum: 1 } } },
{ $match: { count: { $gte: CONFIG.SURGE_GAME_TICKET_COUNT } } },
{ $sort: { count: -1 } }
]);
if (gameCounts.length > 0) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_game'));
if (thresholdMs !== null) {
const fields = gameCounts.map(g => ({
name: g._id,
value: `${g.count} tickets in ${CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES} min`,
inline: true
}));
await pingStaff(client, 'Game ticket surge detected.', fields);
}
} else {
clearEscalating(key);
}
}
async function checkStaleSurge(client) {
if (!isEnabled('surge_stale')) return;
const key = 'surge:stale';
const cutoff = new Date(Date.now() - CONFIG.SURGE_STALE_HOURS * 3600000);
const count = await Ticket.countDocuments({
status: 'open',
lastActivity: { $lte: cutoff, $ne: null }
});
if (count >= CONFIG.SURGE_STALE_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_stale'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets have had no activity in the past ${CONFIG.SURGE_STALE_HOURS} hours.`,
[{ name: 'Action needed', value: 'Review and respond to stale tickets.', inline: false }]
);
}
} else {
clearEscalating(key);
}
}
async function checkNeedsResponseSurge(client) {
if (!isEnabled('surge_needs_response')) return;
const key = 'surge:needs_response';
const cutoff = new Date(Date.now() - CONFIG.SURGE_NEEDS_RESPONSE_HOURS * 3600000);
const count = await Ticket.countDocuments({
status: 'open',
lastMessageAuthorIsStaff: false,
lastActivity: { $lte: cutoff, $ne: null }
});
if (count >= CONFIG.SURGE_NEEDS_RESPONSE_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_needs_response'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets are waiting on a staff response for over ${CONFIG.SURGE_NEEDS_RESPONSE_HOURS} hour(s).`,
[]
);
}
} else {
clearEscalating(key);
}
}
async function checkUnclaimedSurge(client) {
if (!isEnabled('surge_unclaimed')) return;
const key = 'surge:unclaimed';
const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000);
const count = await Ticket.countDocuments({
status: 'open',
claimedBy: null,
createdAt: { $lte: cutoff, $ne: null }
});
if (count >= CONFIG.SURGE_UNCLAIMED_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_unclaimed'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`,
[]
);
}
} else {
clearEscalating(key);
}
}
async function checkTier3UnclaimedSurge(client) {
if (!isEnabled('surge_tier3_unclaimed')) return;
const key = 'surge:tier3_unclaimed';
const cutoff = new Date(Date.now() - CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES * 60000);
const tickets = await Ticket.find({
status: 'open',
escalationTier: 2,
claimedBy: null,
createdAt: { $lte: cutoff, $ne: null }
}).lean();
if (tickets.length > 0) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tier3_unclaimed'));
if (thresholdMs !== null) {
await pingStaff(client,
`${tickets.length} Tier 3 ticket(s) unclaimed for over ${CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES} minutes.`,
tickets.map(t => ({ name: t.subject || 'No subject', value: `<#${t.discordThreadId}>`, inline: true }))
);
}
} else {
clearEscalating(key);
}
}
async function checkZeroStaffSurge(client) {
if (!isEnabled('surge_no_staff')) return;
const key = 'surge:no_staff';
if (!CONFIG.STAFF_IDS.length) {
clearEscalating(key);
return;
}
const openCount = await Ticket.countDocuments({ status: 'open' });
if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) {
clearEscalating(key);
return;
}
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!guild) {
clearEscalating(key);
return;
}
const { available, source } = isAnyStaffAvailable(guild);
let noStaff = false;
let detailLine = '';
const { online, dnd, offline } = getStaffAvailability(guild);
if (source === 'unknown') {
const recentlyActive = CONFIG.STAFF_IDS.filter(id => isStaffRecentlyActive(id, 60));
if (recentlyActive.length === 0) {
noStaff = true;
detailLine = 'No staff active in the last 60 minutes (presence intent unavailable, using message activity fallback).';
}
} else if (!available) {
noStaff = true;
const dndNote = dnd.length > 0 ? ` (${dnd.length} on DND)` : '';
detailLine = `${offline.length} staff offline/invisible${dndNote}. ${online.length} online.`;
}
if (!noStaff) {
clearEscalating(key);
return;
}
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_no_staff'));
if (thresholdMs === null) return;
const fields = [
{ name: 'Open tickets', value: String(openCount), inline: true },
{ name: 'Detection method', value: source === 'unknown' ? 'Message activity' : 'Presence', inline: true },
{ name: source === 'unknown' ? 'Note' : 'Staff status', value: detailLine, inline: false }
];
await pingStaff(client,
`${openCount} open ticket(s) with no staff available to respond.`,
fields
);
}
async function runSurgeChecks(client) {
try { await checkTicketSurge(client); } catch (e) { console.error('checkTicketSurge:', e); }
try { await checkGameSurge(client); } catch (e) { console.error('checkGameSurge:', e); }
try { await checkStaleSurge(client); } catch (e) { console.error('checkStaleSurge:', e); }
try { await checkNeedsResponseSurge(client); } catch (e) { console.error('checkNeedsResponseSurge:', e); }
try { await checkUnclaimedSurge(client); } catch (e) { console.error('checkUnclaimedSurge:', e); }
try { await checkTier3UnclaimedSurge(client); } catch (e) { console.error('checkTier3UnclaimedSurge:', e); }
try { await checkZeroStaffSurge(client); } catch (e) { console.error('checkZeroStaffSurge:', e); }
}
module.exports = { runSurgeChecks };

View File

@@ -0,0 +1,675 @@
/**
* Ticket database helpers counters, rename, limits, auto-close,
* reminders, auto-unclaim, channel creation.
*/
const { ChannelType, PermissionFlagsBits } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog');
const { enqueueSend, enqueueDelete } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter');
// --- TICKET NUMBER ---
async function getNextTicketNumber(senderEmail) {
const senderLocal = senderEmail.split('@')[0].toLowerCase();
const counter = await TicketCounter.findOneAndUpdate(
{ senderLocal },
{ $inc: { counter: 1 } },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
return { local: senderLocal, number: counter.counter };
}
// --- RENAME + NAMING ---
// Renames flow through utils/renamer.js (RENAMER_BOT secondary token),
// which has its own Discord rate-limit bucket. We no longer gate on the
// primary bot's 2/10min per-channel budget here; 429s from the secondary
// bot surface via utils/renamer.js instead.
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
const RENAME_LIMIT = 2;
function getSenderLocal(senderEmail) {
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
}
function toDiscordSafeName(str) {
return str
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}\p{Emoji_Presentation}-]/gu, '')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100);
}
/**
* Resolve a human-friendly creator nickname for channel naming.
* Discord tickets: guild member displayName. Email tickets: senderLocal.
* @param {import('discord.js').Guild} guild
* @param {object} ticket
* @returns {Promise<string>}
*/
async function resolveCreatorNickname(guild, ticket) {
if (ticket.gmailThreadId.startsWith('discord-')) {
const creatorUserId = ticket.gmailThreadId.split('-').pop();
try {
const member = await guild.members.fetch(creatorUserId);
return member.displayName;
} catch {
return getSenderLocal(ticket.senderEmail);
}
}
return getSenderLocal(ticket.senderEmail);
}
/**
* Build a channel name from ticket state.
* @param {'unclaimed'|'claimed'|'escalated'|'escalated-claimed'} state
* @param {object} ticket
* @param {string} creatorNickname - pre-resolved via resolveCreatorNickname
* @param {string} [claimerEmoji] - required for claimed / escalated-claimed
* @returns {string}
*/
function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
const num = ticket.ticketNumber || 1;
switch (state) {
case 'claimed':
return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
case 'escalated':
return toDiscordSafeName(`escalated-${creatorNickname}-${num}`);
case 'escalated-claimed':
return toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`);
case 'unclaimed':
default:
return toDiscordSafeName(`unclaimed-${creatorNickname}-${num}`);
}
}
// Retained for external callers (bOSScord, scripts). The gate now lives in
// the secondary bot's rate bucket; this helper no longer touches Mongo.
async function canRename(_ticket) {
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
}
function minutesFromMs(ms) {
return Math.max(1, Math.ceil(ms / 60000));
}
// --- RATE LIMIT (per-user ticket creation) ---
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function sweepTicketCreationByUser(now = Date.now()) {
// An entry is stale when its window has been expired long enough that no
// legitimate rate-limit decision would still consult it. resetAt is a future
// ms timestamp when the window ends; cutoff is 48h past that.
const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS;
for (const [key, entry] of ticketCreationByUser.entries()) {
if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key);
}
}
function startTicketsSweeps(trackInterval) {
const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
/**
* Check if the user can create a ticket (rate limit). If allowed, consumes one slot.
* @param {string} userId - Discord user ID
* @returns {{ allowed: boolean, retryAfterMs?: number }}
*/
function checkTicketCreationRateLimit(userId) {
const limit = CONFIG.RATE_LIMIT_TICKETS_PER_USER;
const windowMs = (CONFIG.RATE_LIMIT_WINDOW_MINUTES || 60) * 60 * 1000;
if (!limit || limit <= 0) return { allowed: true };
const now = Date.now();
let entry = ticketCreationByUser.get(userId);
if (!entry || now >= entry.resetAt) {
entry = { count: 1, resetAt: now + windowMs };
ticketCreationByUser.set(userId, entry);
return { allowed: true };
}
if (entry.count >= limit) {
return { allowed: false, retryAfterMs: entry.resetAt - now };
}
entry.count++;
return { allowed: true };
}
// --- CHANNEL CREATION (overflow: Discord limit 50 channels per category) ---
const CHANNELS_PER_CATEGORY_LIMIT = 50;
function escapeCategoryNameForRegex(name) {
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* @deprecated Use getOrCreateTicketCategory instead.
* @returns {null}
*/
function pickTicketCategoryId(guild, categoryIds) {
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
return null;
}
function countChannelsInCategory(guild, categoryId) {
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
}
/**
* Resolve or create a ticket category with dynamic overflow (Discord max 50 channels per category).
* @param {import('discord.js').Guild} guild
* @param {string} primaryCategoryId
* @param {string} categoryName Display base name (primary category should match; overflows are "(Overflow N)")
* @returns {Promise<string>}
*/
async function getOrCreateTicketCategory(guild, primaryCategoryId, categoryName) {
if (!guild) {
throw new Error('getOrCreateTicketCategory: guild is required');
}
if (!primaryCategoryId || !String(primaryCategoryId).trim()) {
throw new Error('getOrCreateTicketCategory: primaryCategoryId is required');
}
try {
let primary = guild.channels.cache.get(primaryCategoryId);
if (!primary) {
primary = await guild.channels.fetch(primaryCategoryId).catch(() => null);
}
if (!primary || primary.type !== ChannelType.GuildCategory) {
throw new Error(`getOrCreateTicketCategory: primary category ${primaryCategoryId} not found or not a category`);
}
const escaped = escapeCategoryNameForRegex(categoryName);
const overflowRe = new RegExp(`^${escaped} \\(Overflow (\\d+)\\)$`);
const overflowMatches = [];
for (const ch of guild.channels.cache.values()) {
if (!ch || ch.type !== ChannelType.GuildCategory) continue;
if (ch.id === primaryCategoryId) continue;
const m = ch.name.match(overflowRe);
if (m) overflowMatches.push({ ch, n: parseInt(m[1], 10) });
}
overflowMatches.sort((a, b) => a.n - b.n);
const existingCategories = [primary, ...overflowMatches.map(x => x.ch)];
for (const cat of existingCategories) {
if (countChannelsInCategory(guild, cat.id) < CHANNELS_PER_CATEGORY_LIMIT) {
return cat.id;
}
}
const highestN = overflowMatches.length > 0 ? Math.max(...overflowMatches.map(x => x.n)) : 0;
const nextN = highestN + 1;
const newName = `${categoryName} (Overflow ${nextN})`;
const lastCat = existingCategories[existingCategories.length - 1];
const position = (lastCat?.rawPosition ?? lastCat?.position ?? 0) + 1;
let newCat;
try {
newCat = await guild.channels.create({
name: newName,
type: ChannelType.GuildCategory,
position
});
} catch (createErr) {
console.error('getOrCreateTicketCategory: failed to create overflow category:', createErr);
throw createErr;
}
return newCat.id;
} catch (err) {
console.error('getOrCreateTicketCategory:', err);
const fallback = guild.channels.cache.get(primaryCategoryId);
if (fallback?.type === ChannelType.GuildCategory) {
return primaryCategoryId;
}
throw err;
}
}
/**
* Delete an overflow category if it is empty and its name matches "${categoryName} (Overflow N)".
* Never deletes the primary category (exact name match).
* @param {import('discord.js').Guild} guild
* @param {string} categoryId
* @param {string} categoryName
*/
async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
try {
if (!guild || !categoryId) return;
const cached = guild.channels.cache.filter(c => c.parentId === categoryId);
if (cached.size !== 0) return;
let cat = guild.channels.cache.get(categoryId);
if (!cat) {
cat = await guild.channels.fetch(categoryId).catch(() => null);
}
if (!cat || cat.type !== ChannelType.GuildCategory) return;
if (cat.name === categoryName) return;
const escaped = escapeCategoryNameForRegex(categoryName);
const overflowRe = new RegExp(`^${escaped} \\(Overflow \\d+\\)$`);
if (!overflowRe.test(cat.name)) return;
await cat.delete().catch(deleteErr => {
console.error('cleanupEmptyOverflowCategory: delete failed:', deleteErr);
});
} catch (err) {
console.error('cleanupEmptyOverflowCategory:', err);
}
}
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
if (!parentChannel) {
throw new Error('Thread parent channel not found');
}
const thread = await parentChannel.threads.create({
name: `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
await thread.members.add(userId);
// Add all members with the support role so they can see and reply in the thread
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
if (memberId === userId) continue; // already added
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
} else {
let parentId;
try {
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
} catch (e) {
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
throw new Error('Ticket category not found or could not be allocated');
}
let channel;
try {
channel = await guild.channels.create({
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: userId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}
]
});
} catch (e) {
console.error('guild.channels.create (createTicketChannel):', e);
throw e;
}
return channel;
}
}
/**
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
* Adds creator and all members with ROLE_ID_TO_PING.
* @param {import('discord.js').Guild} guild
* @param {number} ticketNumber
* @param {string} creatorUserId
* @returns {Promise<import('discord.js').ThreadChannel>}
*/
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
const parentChannel = guild.channels.cache.get(parentId);
if (!parentChannel) throw new Error('Discord thread parent channel not found');
const thread = await parentChannel.threads.create({
name: `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
await thread.members.add(creatorUserId);
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
if (memberId === creatorUserId) continue;
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
}
/**
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
* @param {import('discord.js').Guild} guild
* @param {number} ticketNumber
* @param {string} chanName
* @returns {Promise<import('discord.js').ThreadChannel>}
*/
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
const parentChannel = guild.channels.cache.get(parentId);
if (!parentChannel) throw new Error('Email thread parent channel not found');
const thread = await parentChannel.threads.create({
name: chanName || `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
}
// --- LIMITS & PERMISSIONS ---
async function checkTicketLimits(senderEmail) {
if (!CONFIG.GLOBAL_TICKET_LIMIT) return { ok: true };
const currentCount = await Ticket.countDocuments({ senderEmail, status: 'open' });
if (currentCount >= CONFIG.GLOBAL_TICKET_LIMIT) {
return {
ok: false,
reason: `You have reached the maximum limit of ${CONFIG.GLOBAL_TICKET_LIMIT} open tickets.`
};
}
return { ok: true };
}
function hasBlacklistedRole(member) {
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
return false;
}
return member.roles.cache.some(role =>
CONFIG.BLACKLISTED_ROLES.includes(role.id)
);
}
// --- ACTIVITY ---
async function updateTicketActivity(gmailThreadId) {
const now = new Date();
await Ticket.updateOne(
{ gmailThreadId },
{ $set: { lastActivity: now, reminderSent: false } }
);
}
// --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
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));
// Bounded per-tick so a huge backlog drains across successive hourly runs.
const staleTickets = await withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean());
let checked = 0, closed = 0;
for (const ticket of staleTickets) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
// resolves; if the doc is gone the unset is a no-op.
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', pendingDelete: true } }
));
await sendTicketClosedEmail(ticket, 'Auto-Close System');
setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).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 withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: reminderTime, $ne: null },
reminderSent: false
}).lean());
let checked = 0, reminded = 0;
for (const ticket of ticketsNeedingReminder) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
const ping = ticket.claimedBy
? `<@${ticket.claimedBy}>`
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
const message = CONFIG.REMINDER_MESSAGE
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
.replace(/\{ping\}/g, ping);
await enqueueSend(channel, message);
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 withRetry(() => Ticket.find({
status: 'open',
claimedBy: { $ne: null },
lastActivity: { $lt: unclaimTime, $ne: null }
}).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 withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: null } }
));
await enqueueSend(channel,
`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(() => {});
}
async function reconcileDeletedTicketChannels(client) {
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return { checked: 0, reconciled: 0 };
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({
status: 'open',
discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean();
let checked = 0, reconciled = 0;
for (const ticket of openTickets) {
checked++;
try {
let channel = guild.channels.cache.get(ticket.discordThreadId);
if (!channel) {
channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
}
if (!channel) {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', discordThreadId: null } }
);
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
reconciled++;
}
} catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
}
}
if (reconciled > 0) {
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
}
return { checked, reconciled };
}
/**
* Resume deletes that were pending when the bot last shut down. Called once
* from the ready handler. Clears the flag regardless of fetch result so a
* stale flag (e.g. channel already gone) can't loop.
*/
async function resumePendingDeletes(client) {
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
if (!pending.length) return 0;
let resumed = 0;
for (const ticket of pending) {
try {
const guild = client.guilds.cache.first();
if (guild && ticket.discordThreadId) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
enqueueDelete(channel).catch(() => {});
resumed++;
}
}
Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
).catch(() => {});
} catch (e) {
console.error('resumePendingDeletes error:', e);
}
}
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
return resumed;
}
module.exports = {
getNextTicketNumber,
getOrCreateTicketCategory,
cleanupEmptyOverflowCategory,
createDiscordTicketAsThread,
createEmailTicketAsThread,
RENAME_WINDOW_MS,
RENAME_LIMIT,
getSenderLocal,
toDiscordSafeName,
resolveCreatorNickname,
makeTicketName,
canRename,
minutesFromMs,
checkTicketCreationRateLimit,
createTicketChannel,
checkTicketLimits,
hasBlacklistedRole,
updateTicketActivity,
checkAutoClose,
checkReminders,
checkAutoUnclaim,
reconcileDeletedTicketChannels,
resumePendingDeletes,
startTicketsSweeps,
sweepTicketCreationByUser,
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
};