From 636348d824a11e9f09dc5ca177515db259ff347f Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 21 Apr 2026 15:57:18 +0000 Subject: [PATCH] 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) --- .env.example | 48 +- .env.test.example | 2 - broccolini-discord.js | 37 - commands/register.js | 92 +- config.js | 156 +-- gmail-poll.js | 18 +- handlers/buttons.js | 27 +- handlers/buttons.js.bak-20260421 | 770 +++++++++++++ handlers/commands.js | 193 +--- handlers/commands.js.bak-20260421 | 1470 +++++++++++++++++++++++++ handlers/messages.js | 44 +- handlers/messages.js.bak-20260421 | 120 ++ models.js | 8 - routes/internalApi.js | 68 -- services/chatAlertChecker.js | 98 -- services/configSchema.js | 34 +- services/configSchema.js.bak-20260421 | 262 +++++ services/notificationEnabled.js | 102 -- services/notificationRegistry.js | 214 ---- services/patternChecker.js | 587 ---------- services/patternStore.js | 286 ----- services/staffChannel.js | 89 -- services/staffNotifications.js | 149 --- services/staffPresence.js | 48 - services/surgeChecker.js | 260 ----- services/tickets.js.bak-20260421 | 675 ++++++++++++ utils.js | 10 +- 27 files changed, 3335 insertions(+), 2532 deletions(-) create mode 100644 handlers/buttons.js.bak-20260421 create mode 100644 handlers/commands.js.bak-20260421 create mode 100644 handlers/messages.js.bak-20260421 delete mode 100644 services/chatAlertChecker.js create mode 100644 services/configSchema.js.bak-20260421 delete mode 100644 services/notificationEnabled.js delete mode 100644 services/notificationRegistry.js delete mode 100644 services/patternChecker.js delete mode 100644 services/patternStore.js delete mode 100644 services/staffChannel.js delete mode 100644 services/staffNotifications.js delete mode 100644 services/staffPresence.js delete mode 100644 services/surgeChecker.js create mode 100644 services/tickets.js.bak-20260421 diff --git a/.env.example b/.env.example index 35fcc87..2cf9be0 100644 --- a/.env.example +++ b/.env.example @@ -112,16 +112,10 @@ PRIORITY_MEDIUM_EMOJI=🟑 PRIORITY_LOW_EMOJI=🟒 # --- Claiming --- -CLAIM_TIMEOUT_ENABLED=false -CLAIM_TIMEOUT_HOURS=48 AUTO_UNCLAIM_ENABLED=false AUTO_UNCLAIM_AFTER_HOURS=24 ALLOW_CLAIM_OVERWRITE=false -STAFF_EMOJIS=224692549225283584:πŸ… # userId:emoji pairs, comma-separated -CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry in STAFF_EMOJIS -ADMIN_ID= # Discord user ID of the bot admin (for /staffnotification) -STAFF_NOTIFICATION_CATEGORY_ID= # Category for staff notification channels (created by /notification add) -UNCLAIMED_REMINDER_THRESHOLDS=1,2,4 # Comma-separated hour thresholds for unclaimed ticket alerts +ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs) FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60) GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30) GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs @@ -130,46 +124,6 @@ RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue log SECURITY_LOG_CHANNEL_ID= # Channel for security/audit logs SYSTEM_LOG_CHANNEL_ID= # Channel for bot lifecycle logs (startup, shutdown, DB events) -# --- Pattern detection --- -USER_PATTERNS_CHANNEL_ID= # Channel for repeat-user pattern alerts -GAME_PATTERNS_CHANNEL_ID= # Channel for game-specific pattern alerts -TAG_PATTERNS_CHANNEL_ID= # Channel for ticket tag pattern alerts -ESCALATION_PATTERNS_CHANNEL_ID= # Channel for escalation pattern alerts -STAFF_PATTERNS_CHANNEL_ID= # Channel for staff workload pattern alerts -COMBINED_PATTERNS_CHANNEL_ID= # Channel for combined/cross-cutting pattern alerts -PATTERN_USER_TICKET_THRESHOLD=3 # Tickets per user before alerting -PATTERN_GAME_TICKET_THRESHOLD=10 # Tickets per game before alerting -PATTERN_STAFF_STALE_PING_THRESHOLD=5 # Stale pings before alerting -PATTERN_ESCALATION_THRESHOLD=3 # Escalations before alerting -PATTERN_RAPID_CLOSE_SECONDS=120 # Seconds; closes faster than this are flagged -PATTERN_UNCLAIMED_HOURS=4 # Hours unclaimed before flagging -PATTERN_CHECK_INTERVAL_MINUTES=30 # Minutes between pattern check runs - -# --- Surge & chat alerts --- -ALL_STAFF_CHANNEL_ID= # Channel for staff surge alerts -ALL_STAFF_CHAT_ALERT_CHANNEL_ID= # Channel for chat monitoring alerts -SURGE_ROLE_ID= # Role to ping on surge alerts -SURGE_TICKET_COUNT=10 # Ticket count to trigger surge -SURGE_TICKET_WINDOW_MINUTES=30 # Window for ticket surge -SURGE_GAME_TICKET_COUNT=5 # Per-game ticket count for surge -SURGE_GAME_TICKET_WINDOW_MINUTES=30 # Window for game surge -SURGE_STALE_COUNT=8 # Stale tickets to trigger alert -SURGE_STALE_HOURS=2 # Hours before ticket is stale -SURGE_NEEDS_RESPONSE_COUNT=5 # Tickets awaiting response to trigger alert -SURGE_NEEDS_RESPONSE_HOURS=1 # Hours awaiting response -SURGE_UNCLAIMED_COUNT=5 # Unclaimed tickets for surge alert -SURGE_UNCLAIMED_MINUTES=30 # Minutes unclaimed before counting -SURGE_TIER3_UNCLAIMED_MINUTES=15 # Minutes before tier 3 unclaimed alert -SURGE_COOLDOWN_MINUTES=60 # Cooldown between surge alerts -CHAT_ALERT_CHANNEL_IDS= # Comma-separated channel IDs to monitor -CHAT_ALERT_MESSAGE_COUNT=5 # Unresponded messages to trigger alert -CHAT_ALERT_HOURS_WITHOUT_RESPONSE=2 # Hours without staff response to alert -CHAT_ALERT_COOLDOWN_MINUTES=60 # Cooldown between chat alerts -STAFF_IDS= # Comma-separated Discord user IDs of all staff members -SURGE_NO_STAFF_COOLDOWN_MINUTES=30 # Cooldown between zero-staff alerts -SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD=3 # Min open tickets before alerting -STAFF_DND_COUNTS_AS_AVAILABLE=false # Whether DND status counts as available - # --- Staff threads --- STAFF_THREAD_ENABLED=false # Create a private staff thread on each ticket channel STAFF_THREAD_NAME=Staff Discussion # Name of the private thread diff --git a/.env.test.example b/.env.test.example index 85386cf..e6ff759 100644 --- a/.env.test.example +++ b/.env.test.example @@ -107,8 +107,6 @@ PRIORITY_MEDIUM_EMOJI=🟑 PRIORITY_LOW_EMOJI=🟒 # --- Claiming --- -CLAIM_TIMEOUT_ENABLED=false -CLAIM_TIMEOUT_HOURS=48 AUTO_UNCLAIM_ENABLED=false AUTO_UNCLAIM_AFTER_HOURS=24 ALLOW_CLAIM_OVERWRITE=false diff --git a/broccolini-discord.js b/broccolini-discord.js index d7b96e1..ff934bd 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -18,7 +18,6 @@ const { handleDiscordReply } = require('./handlers/messages'); // Services & jobs const { sendTicketClosedEmail } = require('./services/gmail'); const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets'); -const { notifyAllStaffUnclaimed } = require('./services/staffNotifications'); const { registerCommands } = require('./commands/register'); const bosscordRoutes = require('./routes/bosscord'); const { setBot } = require('./api/bosscordClient'); @@ -202,15 +201,6 @@ client.on('interactionCreate', async interaction => { }); client.on('messageCreate', async msg => { - // Track staff last-seen for zero-staff detection fallback - if (!msg.author.bot && CONFIG.STAFF_IDS.includes(msg.author.id)) { - const { updateStaffLastSeen } = require('./services/patternStore'); - updateStaffLastSeen(msg.author.id); - } - // Chat channel monitoring - const { handleChatMessage } = require('./services/chatAlertChecker'); - await handleChatMessage(msg, client).catch(() => {}); - // Existing ticket reply handler await handleDiscordReply(msg); }); @@ -262,47 +252,21 @@ client.once('ready', async () => { console.log('βœ“ Auto-close enabled: checking every hour'); } - trackInterval(setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000)); - notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)); - console.log('βœ“ Staff unclaimed reminders: checking every 30 minutes'); - if (CONFIG.AUTO_UNCLAIM_ENABLED) { trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000)); checkAutoUnclaim(client); console.log('βœ“ Auto-unclaim enabled: checking every hour'); } - const { runPatternChecks } = require('./services/patternChecker'); - const { scheduleResets } = require('./services/patternStore'); - scheduleResets(); - trackInterval(setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000)); - console.log(`βœ“ Pattern checks: every ${CONFIG.PATTERN_CHECK_INTERVAL_MINUTES} minutes`); - - const { runSurgeChecks } = require('./services/surgeChecker'); - trackInterval(setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000)); - setTimeout(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 30000); - console.log('βœ“ Surge checks: every 5 minutes'); - - const { initChatMonitoring, runChatAlertChecks } = require('./services/chatAlertChecker'); - initChatMonitoring(client); - trackInterval(setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000)); - console.log('βœ“ Chat alert monitoring: every 5 minutes'); - reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)); trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000)); resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e)); console.log('βœ“ Reconcile deleted ticket channels: every 1 hour'); // Start in-memory Map sweeps (per-module) β€” keeps long-running processes bounded. - require('./services/patternStore').startSweeps(trackInterval); - require('./services/staffNotifications').startSweeps(trackInterval); require('./services/tickets').startTicketsSweeps(trackInterval); console.log('βœ“ Memory sweeps registered: every 6 hours (unref\'d)'); - if (!CONFIG.STAFF_IDS.length) { - console.warn('[surgeChecker] STAFF_IDS is not set β€” zero-staff detection disabled.'); - } - console.log('βœ“ Discord bot ready. Tag:', client.user.tag); logSystem('Bot online', [ @@ -310,7 +274,6 @@ client.once('ready', async () => { { name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` }, { name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' }, { name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' }, - { name: 'Claim timeout', value: CONFIG.CLAIM_TIMEOUT_ENABLED ? `enabled (${CONFIG.CLAIM_TIMEOUT_HOURS}h)` : 'disabled' }, { name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' }, { name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' }, { name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' }, diff --git a/commands/register.js b/commands/register.js index b6c0473..d4df42a 100644 --- a/commands/register.js +++ b/commands/register.js @@ -12,7 +12,7 @@ const { ContextMenuCommandBuilder, ApplicationCommandType } = require('discord.js'); -const { CONFIG, TICKET_TAGS } = require('../config'); +const { CONFIG } = require('../config'); async function registerCommands() { if (!CONFIG.CLIENT_ID || !CONFIG.DISCORD_GUILD_ID) return; @@ -22,7 +22,7 @@ async function registerCommands() { const commands = [ new SlashCommandBuilder() .setName('escalate') - .setDescription('Escalate this ticket to tier 2 or tier 3') + .setDescription('Escalate this ticket to tier 2 or tier 3 (always unclaims)') .setContexts([InteractionContextType.Guild]) .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) @@ -35,16 +35,6 @@ async function registerCommands() { { name: 'Tier 2', value: '2' }, { name: 'Tier 3', value: '3' } ) - ) - .addStringOption(opt => - opt - .setName('action') - .setDescription('Unclaim ticket or keep current claimer') - .setRequired(true) - .addChoices( - { name: 'Unclaim', value: 'unclaim' }, - { name: 'Keep', value: 'keep' } - ) ), new SlashCommandBuilder() @@ -128,19 +118,6 @@ async function registerCommands() { .setRequired(true) ), - new SlashCommandBuilder() - .setName('tag') - .setDescription('Set ticket category (dropdown)') - .setContexts([InteractionContextType.Guild]) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .addStringOption(o => - o - .setName('category') - .setDescription('Ticket category tag') - .setRequired(true) - .addChoices(...(TICKET_TAGS || []).map(({ value, emoji, name }) => ({ name: `${emoji} ${name}`, value }))) - ), - new SlashCommandBuilder() .setName('response') .setDescription('Saved response tags (custom templates)') @@ -332,25 +309,6 @@ async function registerCommands() { .setRequired(false) ), - new SlashCommandBuilder() - .setName('priority') - .setDescription('Set the priority of this ticket') - .setContexts([InteractionContextType.Guild]) - .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .addStringOption(opt => - opt - .setName('level') - .setDescription('Priority level') - .setRequired(true) - .addChoices( - { name: '🟒 Low', value: 'low' }, - { name: '🟑 Normal', value: 'normal' }, - { name: '🟠 Medium', value: 'medium' }, - { name: 'πŸ”΄ High', value: 'high' } - ) - ), - new SlashCommandBuilder() .setName('search') .setDescription('Search for tickets') @@ -384,52 +342,6 @@ async function registerCommands() { .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - new SlashCommandBuilder() - .setName('notification') - .setDescription('Manage your staff notification settings') - .setContexts([InteractionContextType.Guild]) - .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .addSubcommand(sub => - sub - .setName('set') - .setDescription('Set your notification cooldown (hours between alerts per ticket)') - .addIntegerOption(opt => - opt - .setName('hours') - .setDescription('Cooldown in hours (1–6)') - .setMinValue(1) - .setMaxValue(6) - .setRequired(true) - ) - ) - .addSubcommand(sub => - sub - .setName('add') - .setDescription('Create a notification channel for a staff member') - .addUserOption(opt => - opt.setName('member').setDescription('Staff member').setRequired(true) - ) - ), - - new SlashCommandBuilder() - .setName('staffnotification') - .setDescription('Override notification cooldown for another staff member (admin only)') - .setContexts([InteractionContextType.Guild]) - .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addUserOption(opt => - opt.setName('member').setDescription('Staff member').setRequired(true) - ) - .addIntegerOption(opt => - opt - .setName('hours') - .setDescription('Cooldown in hours (1–6)') - .setMinValue(1) - .setMaxValue(6) - .setRequired(true) - ), - new SlashCommandBuilder() .setName('closetimer') .setDescription('Set the force-close countdown duration') diff --git a/config.js b/config.js index 49b8821..9597f2c 100644 --- a/config.js +++ b/config.js @@ -30,88 +30,11 @@ if (!envPath) { } } -const DEFAULT_NOTIFICATION_THRESHOLDS = { - // patternChecker - age-based (time since condition first became true) - user_tickets: ['15m', '30m', '1h', '3h'], - user_reopen: ['1h', '4h', '1d'], - user_crossgame: ['1h', '1d'], - game_surge: ['15m', '30m', '1h'], - game_backlog: ['30m', '1h', '3h', '6h'], - game_resolution: ['1d'], - game_spike: ['15m', '30m'], - tag_top: ['1h', '6h', '1d'], - tag_escalation: ['1h', '6h', '1d'], - untagged_closes: ['1h', '1d'], - tag_game_corr: ['1d'], - user_esc: ['1h', '6h', '1d'], - game_esc_rate: ['1d'], - rapid_t2_t3: ['3', '5', '10', '15', '20', '30', '50'], // count-based milestones, not time - staff_no_close: ['1h', '3h'], - staff_overloaded: ['1h', '3h', '6h'], - staff_stale: ['1h', '3h'], - staff_transfer_rate: ['1h', '1d'], - staff_esc: ['1h', '6h', '1d'], - staff_game_esc: ['1d'], - game_tag_spike: ['1h', '6h'], - overnight_gap: ['1d'], - staff_always_esc: ['1d'], - // surgeChecker - cooldown-escalating (repeat alerts spaced further apart) - surge_tickets: ['10m', '30m', '1h', '2h', '3h'], - surge_game: ['10m', '30m', '1h', '2h'], - surge_stale: ['30m', '1h', '2h', '4h'], - surge_needs_response: ['15m', '30m', '1h', '3h'], - surge_unclaimed: ['15m', '30m', '1h', '2h', '4h'], - surge_tier3_unclaimed: ['10m', '15m', '30m', '1h', '2h'], - surge_no_staff: ['10m', '20m', '30m', '1h'], - // staffNotifications - age-based per ticket (hours) - unclaimed_reminder: ['1h', '2h', '4h', '8h', '1d'], - // chatAlertChecker - cooldown-escalating - chat_messages: ['15m', '30m', '1h', '3h'], - chat_time: ['30m', '1h', '2h', '4h'] -}; - function toInt(v, fallback) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : fallback; } -function parseThresholdString(str) { - const value = String(str || '').trim(); - if (!value) return NaN; - - // Integers without a unit are raw count milestones. - if (/^\d+$/.test(value)) return parseInt(value, 10); - - let totalMs = 0; - const re = /(\d+)([mhd])/g; - let match; - let consumed = ''; - while ((match = re.exec(value)) !== null) { - const amount = parseInt(match[1], 10); - const unit = match[2]; - consumed += match[0]; - if (unit === 'm') totalMs += amount * 60 * 1000; - else if (unit === 'h') totalMs += amount * 60 * 60 * 1000; - else if (unit === 'd') totalMs += amount * 24 * 60 * 60 * 1000; - } - - if (!consumed || consumed !== value) return NaN; - return totalMs; -} - -function parseNotificationThresholdsJson(raw) { - if (!raw || !String(raw).trim()) return DEFAULT_NOTIFICATION_THRESHOLDS; - try { - const parsedJson = JSON.parse(raw); - if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) { - return parsedJson; - } - } catch (err) { - console.warn('[config] Failed to parse NOTIFICATION_THRESHOLDS_JSON, using default:', err.message); - } - return DEFAULT_NOTIFICATION_THRESHOLDS; -} - const CONFIG = { DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(), DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null, @@ -181,8 +104,6 @@ const CONFIG = { PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || 'πŸ”΄', PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟑', PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟒', - CLAIM_TIMEOUT_ENABLED: process.env.CLAIM_TIMEOUT_ENABLED === 'true', - CLAIM_TIMEOUT_HOURS: toInt(process.env.CLAIM_TIMEOUT_HOURS, 48), AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true', AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24), ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true', @@ -199,25 +120,7 @@ const CONFIG = { EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00), EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600), EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124), - STAFF_CATEGORIES: new Map(), // deprecated – kept for staffChannel.js compat - STAFF_EMOJIS: (() => { - const raw = process.env.STAFF_EMOJIS; - const map = new Map(); - if (!raw || !String(raw).trim()) return map; - for (const part of String(raw).split(',')) { - const seg = part.trim(); - if (!seg) continue; - const idx = seg.indexOf(':'); - if (idx === -1) continue; - const userId = seg.slice(0, idx).trim(); - const emoji = seg.slice(idx + 1).trim(); - if (userId && emoji) map.set(userId, emoji); - } - return map; - })(), - CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', ADMIN_ID: process.env.ADMIN_ID || null, - STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null, FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null, @@ -225,42 +128,6 @@ const CONFIG = { RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null, SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_LOG_CHANNEL_ID || null, SYSTEM_LOG_CHANNEL_ID: process.env.SYSTEM_LOG_CHANNEL_ID || null, - USER_PATTERNS_CHANNEL_ID: process.env.USER_PATTERNS_CHANNEL_ID || null, - GAME_PATTERNS_CHANNEL_ID: process.env.GAME_PATTERNS_CHANNEL_ID || null, - TAG_PATTERNS_CHANNEL_ID: process.env.TAG_PATTERNS_CHANNEL_ID || null, - ESCALATION_PATTERNS_CHANNEL_ID: process.env.ESCALATION_PATTERNS_CHANNEL_ID || null, - STAFF_PATTERNS_CHANNEL_ID: process.env.STAFF_PATTERNS_CHANNEL_ID || null, - COMBINED_PATTERNS_CHANNEL_ID: process.env.COMBINED_PATTERNS_CHANNEL_ID || null, - PATTERN_USER_TICKET_THRESHOLD: toInt(process.env.PATTERN_USER_TICKET_THRESHOLD, 3), - PATTERN_GAME_TICKET_THRESHOLD: toInt(process.env.PATTERN_GAME_TICKET_THRESHOLD, 10), - PATTERN_STAFF_STALE_PING_THRESHOLD: toInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD, 5), - PATTERN_ESCALATION_THRESHOLD: toInt(process.env.PATTERN_ESCALATION_THRESHOLD, 3), - PATTERN_RAPID_CLOSE_SECONDS: toInt(process.env.PATTERN_RAPID_CLOSE_SECONDS, 120), - PATTERN_UNCLAIMED_HOURS: toInt(process.env.PATTERN_UNCLAIMED_HOURS, 4), - PATTERN_CHECK_INTERVAL_MINUTES: toInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES, 30), - ALL_STAFF_CHANNEL_ID: process.env.ALL_STAFF_CHANNEL_ID || null, - ALL_STAFF_CHAT_ALERT_CHANNEL_ID: process.env.ALL_STAFF_CHAT_ALERT_CHANNEL_ID || null, - SURGE_ROLE_ID: process.env.SURGE_ROLE_ID || null, - SURGE_TICKET_COUNT: toInt(process.env.SURGE_TICKET_COUNT, 10), - SURGE_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_TICKET_WINDOW_MINUTES, 30), - SURGE_GAME_TICKET_COUNT: toInt(process.env.SURGE_GAME_TICKET_COUNT, 5), - SURGE_GAME_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES, 30), - SURGE_STALE_COUNT: toInt(process.env.SURGE_STALE_COUNT, 8), - SURGE_STALE_HOURS: toInt(process.env.SURGE_STALE_HOURS, 2), - SURGE_NEEDS_RESPONSE_COUNT: toInt(process.env.SURGE_NEEDS_RESPONSE_COUNT, 5), - SURGE_NEEDS_RESPONSE_HOURS: toInt(process.env.SURGE_NEEDS_RESPONSE_HOURS, 1), - SURGE_UNCLAIMED_COUNT: toInt(process.env.SURGE_UNCLAIMED_COUNT, 5), - SURGE_UNCLAIMED_MINUTES: toInt(process.env.SURGE_UNCLAIMED_MINUTES, 30), - SURGE_TIER3_UNCLAIMED_MINUTES: toInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES, 15), - SURGE_COOLDOWN_MINUTES: toInt(process.env.SURGE_COOLDOWN_MINUTES, 60), - CHAT_ALERT_CHANNEL_IDS: (process.env.CHAT_ALERT_CHANNEL_IDS || '').split(',').filter(Boolean), - CHAT_ALERT_MESSAGE_COUNT: toInt(process.env.CHAT_ALERT_MESSAGE_COUNT, 5), - CHAT_ALERT_HOURS_WITHOUT_RESPONSE: toInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE, 2), - CHAT_ALERT_COOLDOWN_MINUTES: toInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES, 60), - STAFF_IDS: (process.env.STAFF_IDS || '').split(',').map(s => s.trim()).filter(Boolean), - SURGE_NO_STAFF_COOLDOWN_MINUTES: toInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES, 30), - SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: toInt(process.env.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD, 3), - STAFF_DND_COUNTS_AS_AVAILABLE: process.env.STAFF_DND_COUNTS_AS_AVAILABLE === 'true', STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', @@ -273,28 +140,9 @@ const CONFIG = { SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null, SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com', INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753), - INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null, - NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON), - UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4') - .split(',') - .map(s => parseInt(s.trim(), 10)) - .filter(n => !isNaN(n) && n > 0) + INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null }; -/** Ticket category tags for /tag set – [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */ -const TICKET_TAGS = [ - { value: 'server-down', emoji: '⬇️', name: 'Server Down' }, - { value: 'stuck-restarting', emoji: '⏳', name: 'Stuck Restarting' }, - { value: 'cant-connect', emoji: 'πŸ“΅', name: "Can't Connect" }, - { value: 'server-lag', emoji: '🐌', name: 'Server Lag' }, - { value: 'billing', emoji: 'πŸ’³', name: 'Billing' }, - { value: 'refund-request', emoji: 'πŸ’Έ', name: 'Refund Request' }, - { value: 'mod-help', emoji: 'πŸ”§', name: 'Mod Help' }, - { value: 'backup-restore', emoji: 'πŸ’Ύ', name: 'Backup Restore' }, - { value: 'world-save', emoji: '🌍', name: 'World / Save' }, - { value: 'server-config', emoji: 'βš™οΈ', name: 'Server Config' } -]; - const GAME_NAMES = (CONFIG.GAME_LIST || '') .split(',') .map(g => g.trim()) @@ -346,8 +194,6 @@ const GAME_NAME_TO_KEY = { module.exports = { CONFIG, - parseThresholdString, - TICKET_TAGS, GAME_NAMES, GAME_ALIASES, GAME_NAME_TO_KEY diff --git a/gmail-poll.js b/gmail-poll.js index 3df5d0d..00b1877 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -16,13 +16,13 @@ const { stripEmailQuotes, stripMobileFooter, detectGame, - enforceEmbedLimit + enforceEmbedLimit, + sanitizeEmbedText } = require('./utils'); const { getGmailClient } = require('./services/gmail'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { getEmailRouting } = require('./services/guildSettings'); const { logError, logGmail, logAutomation } = require('./services/debugLog'); -const { increment } = require('./services/patternStore'); const { enqueueSend } = require('./services/channelQueue'); const Ticket = mongoose.model('Ticket'); @@ -253,10 +253,10 @@ async function poll(client) { const ticketInfoEmbed = new EmbedBuilder() .setColor(CONFIG.EMBED_COLOR_INFO) .addFields( - { name: 'Name', value: `\`${sName}\``, inline: false }, - { name: 'Email', value: `\`${sEmail}\``, inline: false }, - { name: 'Game', value: `\`${detectedGame}\``, inline: false }, - { name: 'Subject', value: `\`${subject || 'No subject'}\``, inline: false } + { name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false }, + { name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false }, + { name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false }, + { name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false } ); enforceEmbedLimit([ticketInfoEmbed]); @@ -347,12 +347,6 @@ async function poll(client) { )); totalProcessed++; logGmail(subject, sEmail, number, detectedGame).catch(() => {}); - increment('user_tickets', sEmail, 'today'); - increment('user_tickets', sEmail, 'week'); - if (detectedGame) { - increment('game_tickets', detectedGame, 'today'); - increment('game_tickets', detectedGame, 'week'); - } } console.log('Archiving/reading Gmail message', msgRef.id); await gmail.users.messages.batchModify({ diff --git a/handlers/buttons.js b/handlers/buttons.js index 60d9da3..49df4e3 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -25,7 +25,6 @@ const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { trackInteraction, trackError } = require('./analytics'); const { pendingCloses } = require('./pendingCloses'); -const { increment } = require('../services/patternStore'); const { logError, logSystem } = require('../services/debugLog'); const Ticket = mongoose.model('Ticket'); @@ -342,11 +341,8 @@ async function handleClaim(interaction, ticket) { ); freshTicket.claimedBy = claimerLabel; freshTicket.claimerId = interaction.user.id; - increment('staff_claims', interaction.user.id, 'today'); - increment('staff_claims', interaction.user.id, 'week'); - // Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK) - const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK; + const claimerEmoji = '🎫'; const creatorNickname = await resolveCreatorNickname(guild, freshTicket); const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; @@ -385,11 +381,10 @@ async function handleClaim(interaction, ticket) { // Unclaim await Ticket.updateOne( { gmailThreadId: freshTicket.gmailThreadId }, - { $set: { claimedBy: null, claimerId: null, staffChannelId: null } } + { $set: { claimedBy: null, claimerId: null } } ); freshTicket.claimedBy = null; freshTicket.claimerId = null; - freshTicket.staffChannelId = null; const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket); const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed'; @@ -425,10 +420,6 @@ async function handleClaim(interaction, ticket) { // --- CONFIRM CLOSE --- async function handleConfirmClose(interaction, ticket) { const closedAt = new Date(); - increment('staff_closes', interaction.user.id, 'today'); - if (!ticket.ticketTag) { - increment('untagged_closes', 'total', 'today'); - } try { await interaction.update({ content: 'Archiving and closing...', components: [] }); } catch { @@ -563,13 +554,6 @@ async function handleConfirmClose(interaction, ticket) { { $set: { discordThreadId: null, status: 'closed' } } ); - try { - const { deleteStaffChannel } = require('../services/staffChannel'); - await deleteStaffChannel(interaction.guild, ticket.staffChannelId); - } catch (e) { - console.error('Delete staff channel (close):', e); - } - if (transcriptMsg?.id) { await Transcript.create({ gmailThreadId: ticket.gmailThreadId, @@ -746,13 +730,6 @@ async function handleTicketModal(interaction) { await pinMessage(welcomeMsg, interaction.client).catch(() => {}); } - increment('user_tickets', interaction.user.id, 'today'); - increment('user_tickets', interaction.user.id, 'week'); - if (game) { - increment('game_tickets', game, 'today'); - increment('game_tickets', game, 'week'); - } - await interaction.deleteReply().catch(() => {}); const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); diff --git a/handlers/buttons.js.bak-20260421 b/handlers/buttons.js.bak-20260421 new file mode 100644 index 0000000..60d9da3 --- /dev/null +++ b/handlers/buttons.js.bak-20260421 @@ -0,0 +1,770 @@ +/** + * Button interaction handlers – claim, close, priority, tag delete, + * open-ticket panel button, and ticket_modal submission. + */ +const { + ChannelType, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + AttachmentBuilder, + EmbedBuilder, + PermissionFlagsBits, + ModalBuilder, + TextInputBuilder, + TextInputStyle +} = require('discord.js'); +const { mongoose } = require('../db-connection'); +const { CONFIG } = require('../config'); +const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets'); +const { sendTicketClosedEmail } = require('../services/gmail'); +const { getTicketActionRow } = require('../utils/ticketComponents'); +const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils'); +const { setEmailRouting } = require('../services/guildSettings'); +const { enqueueRename, enqueueSend } = require('../services/channelQueue'); +const { runEscalation, runDeescalation } = require('./commands'); +const { trackInteraction, trackError } = require('./analytics'); +const { pendingCloses } = require('./pendingCloses'); +const { increment } = require('../services/patternStore'); +const { logError, logSystem } = require('../services/debugLog'); + +const Ticket = mongoose.model('Ticket'); +const Transcript = mongoose.model('Transcript'); +const Tag = mongoose.model('Tag'); +const User = mongoose.model('User'); + +/** + * Main button/modal handler – called from interactionCreate. + */ +async function handleButton(interaction) { + // --- "Open Ticket" panel buttons β†’ show modal --- + if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') { + const modalCustomId = interaction.customId === 'open_ticket' + ? 'ticket_modal' + : interaction.customId === 'open_ticket_thread' + ? 'ticket_modal_thread' + : 'ticket_modal_channel'; + const modal = new ModalBuilder() + .setCustomId(modalCustomId) + .setTitle('Please Enter Your Information'); + + const emailInput = new TextInputBuilder() + .setCustomId('ticket_email') + .setLabel('Account Email:') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Example: broccoli@indifferentbroccoli.com') + .setRequired(true) + .setMaxLength(100); + + const gameInput = new TextInputBuilder() + .setCustomId('ticket_game') + .setLabel('What game do you need help with?') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Example: Project Zomboid, Minecraft') + .setRequired(true) + .setMaxLength(100); + + const descriptionInput = new TextInputBuilder() + .setCustomId('ticket_description') + .setLabel('What do you need help with?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder("Example: I can't connect to my server.") + .setRequired(true) + .setMaxLength(1000); + + modal.addComponents( + new ActionRowBuilder().addComponents(emailInput), + new ActionRowBuilder().addComponents(gameInput), + new ActionRowBuilder().addComponents(descriptionInput) + ); + + return await interaction.showModal(modal); + } + + // --- Email routing (no ticket required) --- + if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') { + const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category'; + try { + await setEmailRouting(interaction.guild.id, value); + const label = value === 'thread' ? '**threads**' : '**channels in a category**'; + await interaction.reply({ + content: `Done. New email tickets will now be created as ${label}.`, + ephemeral: true + }); + } catch (err) { + trackError('email-routing-button', err, interaction); + await interaction.reply({ + content: 'Failed to update email routing.', + ephemeral: true + }).catch(() => {}); + } + return; + } + + // --- Ticket-scoped buttons (need ticket lookup) --- + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ + content: 'This channel is not linked to a ticket, or the ticket could not be found.', + ephemeral: true + }); + } + + // --- CLAIM / UNCLAIM --- + if (interaction.customId === 'claim_ticket') { + return handleClaim(interaction, ticket); + } + + // --- CLOSE --- + if (interaction.customId === 'close_ticket') { + const confirmRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('confirm_close') + .setLabel('Confirm Close') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('cancel_close') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + + return interaction.reply({ + content: 'Are you sure you want to close this ticket?', + components: [confirmRow] + }); + } + + if (interaction.customId === 'confirm_close') { + const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; + if (pendingCloses.has(interaction.channel.id)) { + return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); + } + const cancelRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('cancel_close') + .setLabel('Cancel Close') + .setStyle(ButtonStyle.Secondary) + ); + await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] }); + const timerId = setTimeout(async () => { + pendingCloses.delete(interaction.channel.id); + const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!freshTicket || freshTicket.status === 'closed') return; + const { logTicketEvent } = require('../services/debugLog'); + logTicketEvent('Force-close timer fired', [ + { name: 'Ticket', value: interaction.channel.name || interaction.channel.id }, + { name: 'Set by', value: interaction.user.tag }, + { name: 'Duration', value: `${timerSeconds}s` } + ]).catch(() => {}); + await handleConfirmClose(interaction, freshTicket); + }, timerSeconds * 1000); + pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); + return; + } + + if (interaction.customId === 'cancel_close') { + const pending = pendingCloses.get(interaction.channel.id); + if (pending) { + clearTimeout(pending.timeout); + pendingCloses.delete(interaction.channel.id); + } + return interaction.update({ content: 'Close cancelled.', components: [] }); + } + + // --- ESCALATE (prompt for tier 2 or 3) --- + if (interaction.customId === 'escalate_ticket') { + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier >= 2) { + return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true }); + } + const escalateButtons = []; + if (currentTier < 1) { + escalateButtons.push( + new ButtonBuilder() + .setCustomId('escalate_to_tier2') + .setLabel('To Tier 2') + .setStyle(ButtonStyle.Secondary) + ); + } + if (currentTier < 2) { + escalateButtons.push( + new ButtonBuilder() + .setCustomId('escalate_to_tier3') + .setLabel('To Tier 3') + .setStyle(ButtonStyle.Secondary) + ); + } + const choiceRow = new ActionRowBuilder().addComponents(escalateButtons); + return interaction.reply({ + content: 'Escalate to which tier?', + components: [choiceRow], + ephemeral: true + }); + } + + if (interaction.customId === 'escalate_to_tier2') { + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier >= 1) { + return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true }); + } + const categoryId = ticket.gmailThreadId.startsWith('discord-') + ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID + : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID; + if (!categoryId && !interaction.channel.isThread()) { + return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true }); + } + try { + await interaction.deferReply(); + await runEscalation(interaction, ticket, 1, null); + } catch (err) { + trackError('escalate-button-tier2', err, interaction); + await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() => + interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {}) + ); + } + return; + } + + if (interaction.customId === 'escalate_to_tier3') { + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier >= 2) { + return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true }); + } + const categoryId = ticket.gmailThreadId.startsWith('discord-') + ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID + : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID; + if (!categoryId && !interaction.channel.isThread()) { + return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true }); + } + try { + await interaction.deferReply(); + await runEscalation(interaction, ticket, 2, null); + } catch (err) { + trackError('escalate-button-tier3', err, interaction); + await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() => + interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {}) + ); + } + return; + } + + // --- DEESCALATE --- + if (interaction.customId === 'deescalate_ticket') { + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier === 0) { + return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true }); + } + try { + await interaction.deferReply({ ephemeral: true }); + await runDeescalation(interaction, ticket); + } catch (err) { + trackError('deescalate-button', err, interaction); + await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() => + interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {}) + ); + } + return; + } + + // --- TAG DELETE CONFIRM --- + if (interaction.customId.startsWith('confirm_delete_tag::')) { + trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag); + const tagName = interaction.customId.slice('confirm_delete_tag::'.length); + + try { + const result = await Tag.deleteOne({ name: tagName }); + + if (result.deletedCount === 0) { + await interaction.update({ + content: `❌ Tag "${tagName}" not found.`, + components: [] + }); + } else { + await interaction.update({ + content: `βœ… Tag "${tagName}" deleted successfully.`, + components: [] + }); + } + } catch (err) { + trackError('tag-delete-confirm', err, interaction); + await interaction.update({ + content: '❌ Failed to delete tag.', + components: [] + }); + } + } + + if (interaction.customId === 'cancel_delete_tag') { + return interaction.update({ content: 'Tag deletion cancelled.', components: [] }); + } + + // Priority is set via /priority slash command only; no priority buttons in tickets. +} + +// --- CLAIM LOGIC --- +async function handleClaim(interaction, ticket) { + const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean(); + if (!freshTicket) { + return interaction.reply({ content: 'Ticket data missing.', ephemeral: true }); + } + + const isClaimed = !!freshTicket.claimedBy; + const claimerLabel = + interaction.member?.displayName || interaction.user.username; + const guild = interaction.guild; + const isClaimedByMe = freshTicket.claimedBy === claimerLabel; + + const [row0] = interaction.message.components; + if (!row0) { + return interaction.reply({ content: 'No components to update.', ephemeral: true }); + } + + const row = ActionRowBuilder.from(row0); + const [btnClose, btnClaim] = row.components; + + if (!btnClose || !btnClaim) { + return interaction.reply({ content: 'Buttons missing.', ephemeral: true }); + } + + if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { + const { logSecurity } = require('../services/debugLog'); + logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {}); + return interaction.reply({ + content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, + ephemeral: true + }); + } + + if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) { + await Ticket.updateOne( + { gmailThreadId: freshTicket.gmailThreadId }, + { $set: { claimedBy: claimerLabel, claimerId: interaction.user.id } } + ); + freshTicket.claimedBy = claimerLabel; + freshTicket.claimerId = interaction.user.id; + increment('staff_claims', interaction.user.id, 'today'); + increment('staff_claims', interaction.user.id, 'week'); + + // Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK) + const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK; + const creatorNickname = await resolveCreatorNickname(guild, freshTicket); + + const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed'; + const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji); + enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); + + const label = `Unclaim (${claimerLabel})`; + + btnClose + .setCustomId('close_ticket') + .setLabel(CONFIG.BUTTON_LABEL_CLOSE) + .setEmoji(CONFIG.BUTTON_EMOJI_CLOSE) + .setStyle(ButtonStyle.Secondary) + .setDisabled(false); + + btnClaim + .setCustomId('claim_ticket') + .setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM) + .setStyle(ButtonStyle.Secondary) + .setDisabled(false) + .setLabel(label); + + await interaction.update({ components: [row] }); + const claimText = CONFIG.TICKET_CLAIMED_MESSAGE + .replace(/\{staff_mention\}/g, interaction.user.toString()) + .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); + const claimEmbed = new EmbedBuilder() + .setTitle('βœ… Ticket Claimed') + .setDescription(claimText) + .setColor(CONFIG.EMBED_COLOR_CLAIMED) + .setFooter({ text: `Claimed by ${claimerLabel}` }); + await interaction.followUp({ embeds: [claimEmbed] }); + const { addMemberToStaffThread } = require('../services/staffThread'); + await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {}); + } else { + // Unclaim + await Ticket.updateOne( + { gmailThreadId: freshTicket.gmailThreadId }, + { $set: { claimedBy: null, claimerId: null, staffChannelId: null } } + ); + freshTicket.claimedBy = null; + freshTicket.claimerId = null; + freshTicket.staffChannelId = null; + + const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket); + const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed'; + enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim)).catch(err => logError('rename', err).catch(() => {})); + + btnClose + .setCustomId('close_ticket') + .setLabel(CONFIG.BUTTON_LABEL_CLOSE) + .setEmoji(CONFIG.BUTTON_EMOJI_CLOSE) + .setStyle(ButtonStyle.Secondary) + .setDisabled(false); + + btnClaim + .setCustomId('claim_ticket') + .setEmoji(CONFIG.BUTTON_EMOJI_CLAIM) + .setStyle(ButtonStyle.Secondary) + .setDisabled(false) + .setLabel(CONFIG.BUTTON_LABEL_CLAIM); + + await interaction.update({ components: [row] }); + const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE + .replace(/\{staff_mention\}/g, interaction.user.toString()) + .replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username); + const unclaimEmbed = new EmbedBuilder() + .setTitle('πŸ”“ Ticket Unclaimed') + .setDescription(unclaimText) + .setColor(0x808080) + .setFooter({ text: `Unclaimed by ${claimerLabel}` }); + await interaction.followUp({ embeds: [unclaimEmbed] }); + } +} + +// --- CONFIRM CLOSE --- +async function handleConfirmClose(interaction, ticket) { + const closedAt = new Date(); + increment('staff_closes', interaction.user.id, 'today'); + if (!ticket.ticketTag) { + increment('untagged_closes', 'total', 'today'); + } + try { + await interaction.update({ content: 'Archiving and closing...', components: [] }); + } catch { + // Already acknowledged – fall back to editReply + await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {}); + } + try { + const messages = await interaction.channel.messages.fetch({ limit: 100 }); + const log = + `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` + + messages + .reverse() + .map( + m => + `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}` + ) + .join('\n'); + + const file = new AttachmentBuilder(Buffer.from(log), { + name: `transcript-${interaction.channel.name}.txt` + }); + + const channelName = interaction.channel.name; + const opened = new Date(ticket.createdAt); + const openedStr = opened.toLocaleString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short' + }); + const closedStr = closedAt.toLocaleString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short' + }); + + // In-ticket message before transcript is posted (Discord close message) + const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE; + await enqueueSend(interaction.channel, discordCloseContent); + + const transcriptChan = await interaction.client.channels + .fetch(CONFIG.TRANSCRIPT_CHAN) + .catch(() => null); + let transcriptMsg = null; + + const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE + .replace(/\{channel_name\}/g, channelName) + .replace(/\{email\}/g, ticket.senderEmail || '') + .replace(/\{date_opened\}/g, openedStr) + .replace(/\{date_closed\}/g, closedStr) + + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; + + if (transcriptChan) { + transcriptMsg = await enqueueSend(transcriptChan, { + content: transcriptContent, + files: [file] + }); + } + + // DM the transcript to the ticket creator (Discord-originated tickets). + // Gated because many users have DMs from server members disabled β€” the send + // then 50007s and generates noise. Default off; enable via env when desired. + if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) { + const creatorId = ticket.gmailThreadId.split('-').pop(); + try { + const creator = await interaction.client.users.fetch(creatorId); + const dmFile = new AttachmentBuilder(Buffer.from(log), { + name: `transcript-${channelName}.txt` + }); + const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE + .replace(/\{channel_name\}/g, channelName) + .replace(/\{email\}/g, ticket.senderEmail || '') + .replace(/\{date_opened\}/g, openedStr) + .replace(/\{date_closed\}/g, closedStr); + await creator.send({ + content: dmContent, + files: [dmFile] + }); + } catch (dmErr) { + // 50007 = "Cannot send messages to this user" β€” user has DMs off. Expected class; debug-level only. + if (dmErr?.code === 50007) { + logSystem('Transcript DM skipped (recipient has DMs disabled)', [ + { name: 'User', value: creatorId }, + { name: 'Channel', value: channelName } + ]).catch(() => {}); + } else { + logError('transcript-dm', dmErr).catch(() => {}); + } + } + } + + const logChan = await interaction.client.channels + .fetch(CONFIG.LOG_CHAN) + .catch(() => null); + if (logChan) { + const closerMention = interaction.user.toString(); + const closerDisplayName = interaction.member?.displayName || interaction.user.username; + + let logMsg; + if (ticket.gmailThreadId?.startsWith('discord-')) { + const creatorId = ticket.gmailThreadId.split('-').pop(); + try { + const creator = await interaction.client.users.fetch(creatorId); + const creatorMention = creator.toString(); + logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`; + } catch { + logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`; + } + } else { + logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; + } + await enqueueSend(logChan, logMsg); + } + + const closerDisplayName = + interaction.member?.displayName || interaction.user.username; + + if (!ticket.gmailThreadId?.startsWith('discord-')) { + await sendTicketClosedEmail(ticket, closerDisplayName); + } + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { discordThreadId: null, status: 'closed' } } + ); + + try { + const { deleteStaffChannel } = require('../services/staffChannel'); + await deleteStaffChannel(interaction.guild, ticket.staffChannelId); + } catch (e) { + console.error('Delete staff channel (close):', e); + } + + if (transcriptMsg?.id) { + await Transcript.create({ + gmailThreadId: ticket.gmailThreadId, + transcriptMessageId: transcriptMsg.id, + createdAt: new Date() + }); + } + + const parentCatId = ticket.parentCategoryId; + const guildRef = interaction.guild; + + setTimeout( + () => interaction.channel.delete().catch(() => {}), + 5000 + ); + setTimeout(() => { + (async () => { + if (parentCatId && guildRef) { + await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME); + } + })(); + }, 6000); + } catch (e) { + console.error('Close ticket error:', e); + } +} + +/** + * Handle the ticket_modal submission (from the open-ticket panel button). + */ +async function handleTicketModal(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase(); + const game = interaction.fields.getTextInputValue('ticket_game').trim(); + const description = interaction.fields.getTextInputValue('ticket_description'); + const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80); + const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; + + const useThread = + interaction.customId === 'ticket_modal_thread' || + (interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID); + + const rateLimit = checkTicketCreationRateLimit(interaction.user.id); + if (!rateLimit.allowed) { + const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000); + return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`); + } + + try { + const guild = interaction.guild; + const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); + const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; + + const creatorNicknameModal = interaction.member?.displayName || interaction.user.username; + const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`); + + let channel; + let parentCategoryIdForTicket = null; + if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) { + try { + channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id); + parentCategoryIdForTicket = channel.parent?.parentId ?? null; + } catch (err) { + console.error('Discord ticket thread create failed:', err.message); + return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.'); + } + } else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) { + return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.'); + } else { + let parentId; + try { + parentId = await getOrCreateTicketCategory( + guild, + CONFIG.DISCORD_TICKET_CATEGORY_ID, + CONFIG.TICKET_CATEGORY_NAME + ); + } catch (err) { + console.error('getOrCreateTicketCategory (ticket modal):', err); + return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.'); + } + parentCategoryIdForTicket = parentId; + try { + // TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue. + channel = await guild.channels.create({ + name: unclaimedName, + type: ChannelType.GuildText, + parent: parentId, + permissionOverwrites: [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { + id: interaction.user.id, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + }, + { + id: CONFIG.ROLE_ID_TO_PING, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + } + ] + }); + } catch (err) { + console.error('guild.channels.create (ticket modal):', err); + return interaction.editReply('Failed to create ticket channel. Contact an administrator.'); + } + } + + const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`; + const now = new Date(); + await Ticket.create({ + gmailThreadId, + discordThreadId: channel.id, + senderEmail: email, + subject, + game: game || null, + createdAt: now, + status: 'open', + ticketNumber, + priority, + lastActivity: now, + parentCategoryId: parentCategoryIdForTicket + }); + + const displayName = interaction.member?.displayName || interaction.user.username; + + const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description; + + const welcomeEmbed = new EmbedBuilder() + .setTitle("We got your ticket.") + .setDescription("We'll be with you as soon as possible.") + .setColor(5763719) + .setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png"); + + const infoEmbed = new EmbedBuilder() + .setColor(5763719) + .setDescription(truncateEmbedDescription( + `**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` + + `**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` + + `**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\`` + )); + + const resourcesEmbed = new EmbedBuilder() + .setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:") + .setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.") + .setColor(5763719) + .addFields( + { name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false } + ) + .setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" }); + + const actionRow = getTicketActionRow({ escalationTier: 0 }); + + enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]); + let welcomeMsg; + try { + welcomeMsg = await enqueueSend(channel, { + content: `Hey There ${interaction.user} πŸ₯¦`, + embeds: [welcomeEmbed, infoEmbed, resourcesEmbed], + components: [actionRow] + }); + + await Ticket.updateOne( + { discordThreadId: channel.id }, + { $set: { welcomeMessageId: welcomeMsg.id } } + ); + } catch (err) { + console.error('welcomeMessageId-save', err); + } + + const { createStaffThread } = require('../services/staffThread'); + await createStaffThread(channel, interaction.client).catch(() => {}); + + if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { + const { pinMessage } = require('../services/pinMessage'); + await pinMessage(welcomeMsg, interaction.client).catch(() => {}); + } + + increment('user_tickets', interaction.user.id, 'today'); + increment('user_tickets', interaction.user.id, 'week'); + if (game) { + increment('game_tickets', game, 'today'); + increment('game_tickets', game, 'week'); + } + + await interaction.deleteReply().catch(() => {}); + + const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); + if (logChan) { + await enqueueSend(logChan, + `πŸ“ ${channel.name} created by ${interaction.user.tag}` + ); + } + } catch (err) { + console.error('Ticket creation error:', err); + await interaction.editReply('Failed to create ticket. Please contact an administrator.'); + } +} + +module.exports = { handleButton, handleTicketModal }; diff --git a/handlers/commands.js b/handlers/commands.js index b8dd912..b873339 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -11,9 +11,9 @@ const { PermissionFlagsBits } = require('discord.js'); const { mongoose } = require('../db-connection'); -const { CONFIG, TICKET_TAGS } = require('../config'); -const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils'); -const { makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets'); +const { CONFIG } = require('../config'); +const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils'); +const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets'); const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { getEmailRouting } = require('../services/guildSettings'); @@ -24,12 +24,10 @@ const { logTicketEvent, logSecurity, logError } = require('../services/debugLog' const { handleAccountInfoCommand } = require('./accountinfo'); const { handleSetupCommand } = require('./setup'); const { pendingCloses } = require('./pendingCloses'); -const { increment } = require('../services/patternStore'); const Ticket = mongoose.model('Ticket'); const Tag = mongoose.model('Tag'); const User = mongoose.model('User'); -const StaffNotification = mongoose.model('StaffNotification'); /** * True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES. @@ -74,17 +72,11 @@ async function runEscalation(interaction, ticket, nextTier, reason) { // Clear claim on escalation await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, - { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedRemindersSent: [] } } + { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } } ); ticket.escalated = true; ticket.escalationTier = nextTier; ticket.claimedBy = null; - increment('escalations', ticket.game || 'unknown', 'today'); - increment('escalations', ticket.game || 'unknown', 'week'); - increment('user_escalations', ticket.senderEmail, 'week'); - increment('staff_escalations', interaction.user.id, 'today'); - increment('staff_escalations', interaction.user.id, 'week'); - if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week'); const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); const newName = makeTicketName('escalated', ticket, creatorNickname); @@ -265,12 +257,11 @@ async function handleCommand(interaction) { return; } - // /escalate (tier 2 or 3 via level; works for both email and Discord) + // /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate. if (interaction.commandName === 'escalate') { const reason = null; const level = interaction.options.getString('level'); const nextTier = level === '3' ? 2 : 1; - const action = interaction.options.getString('action'); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!ticket) { @@ -301,12 +292,6 @@ async function handleCommand(interaction) { try { await interaction.deferReply(); await runEscalation(interaction, ticket, nextTier, reason); - if (action === 'unclaim') { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { claimedBy: null, claimerId: null } } - ); - } } catch (err) { console.error('Escalate error:', err); await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() => @@ -315,83 +300,6 @@ async function handleCommand(interaction) { } } - // /notification set | /notification add - if (interaction.commandName === 'notification') { - const sub = interaction.options.getSubcommand(); - if (sub === 'set') { - const hours = interaction.options.getInteger('hours'); - try { - await StaffNotification.findOneAndUpdate( - { userId: interaction.user.id }, - { $set: { cooldownHours: hours, updatedAt: new Date() } }, - { upsert: true } - ); - return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true }); - } catch (err) { - console.error('notification set error:', err); - return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); - } - } - if (sub === 'add') { - if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) { - return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true }); - } - const member = interaction.options.getMember('member'); - if (!member) { - return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true }); - } - const displayName = member.displayName; - const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || ''; - const chanName = toDiscordSafeName(`${displayName}${emoji}`); - try { - const newChannel = await interaction.guild.channels.create({ - name: chanName, - type: ChannelType.GuildText, - parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID, - permissionOverwrites: [ - { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] }, - { id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }, - ...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : []) - ] - }); - await StaffNotification.findOneAndUpdate( - { userId: member.id }, - { $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } }, - { upsert: true } - ); - return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true }); - } catch (err) { - console.error('notification add error:', err); - return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {}); - } - } - return; - } - - // /staffnotification (admin only) - if (interaction.commandName === 'staffnotification') { - if (interaction.user.id !== CONFIG.ADMIN_ID) { - logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {}); - return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true }); - } - const member = interaction.options.getMember('member'); - const hours = interaction.options.getInteger('hours'); - if (!member) { - return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true }); - } - try { - await StaffNotification.findOneAndUpdate( - { userId: member.id }, - { $set: { cooldownHours: hours, updatedAt: new Date() } }, - { upsert: true } - ); - return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true }); - } catch (err) { - console.error('staffnotification error:', err); - return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); - } - } - if (interaction.commandName === 'notifydm') { try { const setting = interaction.options.getString('setting') === 'on'; @@ -723,32 +631,6 @@ async function handleCommand(interaction) { } } - // /tag – ticket category dropdown only - if (interaction.commandName === 'tag') { - trackInteraction('commands', 'tag', interaction.user.tag); - const categoryValue = interaction.options.getString('category'); - const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); - if (!ticket) { - return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); - } - try { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { ticketTag: categoryValue } } - ); - const tagEntry = (TICKET_TAGS || []).find(t => t.value === categoryValue); - const emoji = tagEntry ? tagEntry.emoji : ''; - const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`; - await interaction.reply(channelMessage); - increment('tag_usage', categoryValue, 'today'); - increment('tag_usage', categoryValue, 'week'); - if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week'); - } catch (err) { - trackError('tag-command', err, interaction); - await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true }); - } - } - // /response – saved response tags (send, create, edit, delete, list) if (interaction.commandName === 'response') { trackInteraction('commands', 'response', interaction.user.tag); @@ -936,14 +818,14 @@ async function handleCommand(interaction) { }, { name: 'Ticket Management', - value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic ` - Set ticket topic/description\n`/priority ` - Set ticket priority\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user' + value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic ` - Set ticket topic/description\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user' }, { - name: 'Tags & Responses', - value: '`/tag` - Set ticket category (dropdown)\n`/response send ` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses' + name: 'Saved Responses', + value: '`/response send ` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses' }, { - name: 'Variables (for tags)', + name: 'Variables (for responses)', value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`' }, { @@ -960,63 +842,6 @@ async function handleCommand(interaction) { await interaction.reply({ embeds: [embed], ephemeral: true }); } - // /priority - if (interaction.commandName === 'priority') { - const level = interaction.options.getString('level'); - - const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); - if (!ticket) { - return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); - } - - const priorityOrder = ['low', 'normal', 'medium', 'high']; - const oldIdx = priorityOrder.indexOf((ticket.priority || 'normal').toLowerCase()); - const newIdx = priorityOrder.indexOf(level.toLowerCase()); - const emoji = getPriorityEmoji(level); - const levelLabel = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase(); - - let channelMessage; - if (level === 'normal') { - channelMessage = 'Your ticket priority has returned to Normal.'; - } else if (newIdx > oldIdx) { - channelMessage = `Your ticket has been upgraded to ${emoji} **${levelLabel}** ${emoji}.`; - } else if (newIdx < oldIdx) { - channelMessage = `Your ticket has been downgraded to ${emoji} **${levelLabel}** ${emoji}.`; - } else { - channelMessage = `Priority set to ${emoji} **${levelLabel}** ${emoji}.`; - } - - try { - await Ticket.updateOne( - { gmailThreadId: ticket.gmailThreadId }, - { $set: { priority: level } } - ); - - const priorityTitle = - newIdx === oldIdx - ? 'Priority Set' - : `Priority ${newIdx > oldIdx ? 'Upgraded' : 'Downgraded'} β†’ ${levelLabel}`; - const priorityEmbed = new EmbedBuilder() - .setTitle(priorityTitle) - .setDescription(channelMessage) - .setColor(getPriorityColor(level)) - .setFooter({ text: interaction.member?.displayName || interaction.user.username }); - await interaction.reply({ embeds: [priorityEmbed] }); - - if (level === 'high' && ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-')) { - await sendTicketNotificationEmail( - ticket, - `Priority updated: ${levelLabel}`, - channelMessage, - interaction.member?.displayName || interaction.user.username - ); - } - } catch (err) { - console.error('Priority update error:', err); - await interaction.reply({ content: 'Failed to update priority.', ephemeral: true }); - } - } - // /panel if (interaction.commandName === 'panel') { const channel = interaction.options.getChannel('channel'); diff --git a/handlers/commands.js.bak-20260421 b/handlers/commands.js.bak-20260421 new file mode 100644 index 0000000..b8dd912 --- /dev/null +++ b/handlers/commands.js.bak-20260421 @@ -0,0 +1,1470 @@ +/** + * Slash command, context menu, and autocomplete handlers. + */ +const { + ChannelType, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + AttachmentBuilder, + EmbedBuilder, + PermissionFlagsBits +} = require('discord.js'); +const { mongoose } = require('../db-connection'); +const { CONFIG, TICKET_TAGS } = require('../config'); +const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils'); +const { makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets'); +const { sendTicketNotificationEmail } = require('../services/gmail'); +const { getTicketActionRow } = require('../utils/ticketComponents'); +const { getEmailRouting } = require('../services/guildSettings'); +const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); +const { setNotifyDm } = require('../services/staffSettings'); +const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics'); +const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'); +const { handleAccountInfoCommand } = require('./accountinfo'); +const { handleSetupCommand } = require('./setup'); +const { pendingCloses } = require('./pendingCloses'); +const { increment } = require('../services/patternStore'); + +const Ticket = mongoose.model('Ticket'); +const Tag = mongoose.model('Tag'); +const User = mongoose.model('User'); +const StaffNotification = mongoose.model('StaffNotification'); + +/** + * True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES. + * Used to restrict commands to staff only; customers cannot use bot commands. + * @param {import('discord.js').GuildMember|null} member + * @returns {boolean} + */ +function hasStaffRole(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)); +} + +/** + * Reply ephemeral and return true if the interaction is in a guild and the user is not staff (so caller should return). + * @param {import('discord.js').CommandInteraction|import('discord.js').ContextMenuCommandInteraction} interaction + * @returns {Promise} true if caller should return (user is not allowed) + */ +async function requireStaffRole(interaction) { + if (!interaction.guild) return false; + if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false; + if (hasStaffRole(interaction.member)) return false; + const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support'; + await interaction.reply({ + content: `This command is only available to the support team (${roleMention}).`, + ephemeral: true + }); + logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {}); + return true; +} + +/** + * Run escalation to a target DB tier (1 = tier 2, 2 = tier 3). Caller must validate ticket and currentTier < nextTier. + */ +async function runEscalation(interaction, ticket, nextTier, reason) { + const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); + const categoryId = nextTier === 1 + ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) + : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); + + // Clear claim on escalation + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedRemindersSent: [] } } + ); + ticket.escalated = true; + ticket.escalationTier = nextTier; + ticket.claimedBy = null; + increment('escalations', ticket.game || 'unknown', 'today'); + increment('escalations', ticket.game || 'unknown', 'week'); + increment('user_escalations', ticket.senderEmail, 'week'); + increment('staff_escalations', interaction.user.id, 'today'); + increment('staff_escalations', interaction.user.id, 'week'); + if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week'); + + const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); + const newName = makeTicketName('escalated', ticket, creatorNickname); + enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {})); + + if (!interaction.channel.isThread() && categoryId) { + await enqueueMove(interaction.channel, categoryId); + } + + const pendingEmbed = new EmbedBuilder() + .setDescription('Ticket will be escalated in a few seconds.') + .setColor(CONFIG.EMBED_COLOR_INFO); + await interaction.editReply({ embeds: [pendingEmbed] }); + + const creatorId = isDiscordTicket + ? (ticket.gmailThreadId.split('-').pop() || '').trim() + : null; + const creatorMention = creatorId ? `<@${creatorId}>` : ''; + const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member'; + const heyLine = creatorMention + ? `Hey There ${creatorMention} πŸ₯¦` + : 'Hey There πŸ₯¦'; + // Creator + role pings are intentional; still block @everyone/@here if somehow interpolated. + await enqueueSend(interaction.channel, { + content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`, + allowedMentions: { parse: ['users', 'roles'] } + }); + + const escalationBody = CONFIG.ESCALATION_MESSAGE + .replace(/\\n/g, '\n') + .replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME); + const escalatedEmbed = new EmbedBuilder() + .setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`) + .setDescription(escalationBody) + .setColor(CONFIG.EMBED_COLOR_ESCALATED) + .setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` }); + const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true }; + const escalationRow = getTicketActionRow(updatedTicketForRow); + const escalationMsg = await enqueueSend(interaction.channel, { + content: null, + embeds: [escalatedEmbed], + components: [escalationRow] + }); + + if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) { + const { pinMessage } = require('../services/pinMessage'); + await pinMessage(escalationMsg, interaction.client).catch(() => {}); + } + + if (!isDiscordTicket && ticket.gmailThreadId) { + try { + const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\\n/g, '\n').replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME); + await sendTicketNotificationEmail( + ticket, + `Ticket escalated to ${nextTier === 1 ? 'tier 2' : 'tier 3'}`, + emailBody, + interaction.member?.displayName || interaction.user.username + ); + } catch (emailErr) { + console.error('Escalation email failed (non-fatal):', emailErr.message); + } + } + + if (nextTier === 2) { + if (!ticket.welcomeMessageId) { + console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation'); + } else { + try { + const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId); + await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] }); + } catch (e) { + console.error('Failed to update welcome message after escalate:', e.message); + } + } + } + + const logChan = await interaction.client.channels + .fetch(CONFIG.LOG_CHAN) + .catch(() => null); + if (logChan) { + const ticketType = isDiscordTicket ? 'Discord' : 'Email'; + const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; + await enqueueSend(logChan, + `${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}` + ); + } +} + +/** + * Run deescalation one step. Caller must validate ticket and currentTier >= 1. + */ +async function runDeescalation(interaction, ticket) { + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); + const newTier = currentTier - 1; + + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } } + ); + ticket.escalated = newTier > 0; + ticket.escalationTier = newTier; + ticket.claimedBy = null; + + const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket); + const state = newTier === 0 ? 'unclaimed' : 'escalated'; + enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {})); + + if (!interaction.channel.isThread()) { + try { + if (newTier === 0) { + const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID; + if (homeCategory) await enqueueMove(interaction.channel, homeCategory); + } else if (newTier === 1) { + const t2Category = isDiscordTicket + ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID + : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID; + if (t2Category) await enqueueMove(interaction.channel, t2Category); + } + } catch (e) { + console.error('Move error (deescalate):', e); + } + } + + const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3'; + const deescalateEmbed = new EmbedBuilder() + .setColor(0x00BFFF) + .setTitle(`βœ… De-escalated to ${tierLabel} Support`) + .setFooter({ text: interaction.member?.displayName || interaction.user.username }); + await interaction.editReply({ embeds: [deescalateEmbed] }); + + const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); + if (logChan) { + const ticketType = isDiscordTicket ? 'Discord' : 'Email'; + await enqueueSend(logChan, + `${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.` + ); + } +} + +/** + * Main slash-command handler. + */ +async function handleCommand(interaction) { + // Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES) + if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return; + + // /setup + if (interaction.commandName === 'setup') { + return handleSetupCommand(interaction); + } + + // /email-routing – switch where new email tickets are created (thread vs category) + if (interaction.commandName === 'email-routing') { + await interaction.deferReply({ ephemeral: true }); + try { + const current = await getEmailRouting(interaction.guild.id); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('email_routing_thread') + .setLabel('Threads') + .setStyle(ButtonStyle.Primary) + .setEmoji('🧡'), + new ButtonBuilder() + .setCustomId('email_routing_category') + .setLabel('Category channels') + .setStyle(ButtonStyle.Primary) + .setEmoji('πŸ“') + ); + await interaction.editReply({ + content: `Email ticket routing: **${current}**. Choose where new email tickets should be created:`, + components: [row] + }); + } catch (err) { + trackError('email-routing-command', err, interaction); + await interaction.editReply('Failed to load routing options.').catch(() => {}); + } + return; + } + + // /escalate (tier 2 or 3 via level; works for both email and Discord) + if (interaction.commandName === 'escalate') { + const reason = null; + const level = interaction.options.getString('level'); + const nextTier = level === '3' ? 2 : 1; + const action = interaction.options.getString('action'); + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier >= 2) { + return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true }); + } + + if (nextTier <= currentTier) { + return interaction.reply({ content: 'Ticket is already at or past that tier.', ephemeral: true }); + } + + const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); + const categoryId = nextTier === 1 + ? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID) + : (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID); + const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3'; + if (!categoryId && !interaction.channel.isThread()) { + return interaction.reply({ + content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`, + ephemeral: true + }); + } + + try { + await interaction.deferReply(); + await runEscalation(interaction, ticket, nextTier, reason); + if (action === 'unclaim') { + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { claimedBy: null, claimerId: null } } + ); + } + } catch (err) { + console.error('Escalate error:', err); + await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() => + interaction.followUp({ content: 'Failed to escalate this ticket.', ephemeral: true }).catch(() => {}) + ); + } + } + + // /notification set | /notification add + if (interaction.commandName === 'notification') { + const sub = interaction.options.getSubcommand(); + if (sub === 'set') { + const hours = interaction.options.getInteger('hours'); + try { + await StaffNotification.findOneAndUpdate( + { userId: interaction.user.id }, + { $set: { cooldownHours: hours, updatedAt: new Date() } }, + { upsert: true } + ); + return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true }); + } catch (err) { + console.error('notification set error:', err); + return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); + } + } + if (sub === 'add') { + if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) { + return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true }); + } + const member = interaction.options.getMember('member'); + if (!member) { + return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true }); + } + const displayName = member.displayName; + const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || ''; + const chanName = toDiscordSafeName(`${displayName}${emoji}`); + try { + const newChannel = await interaction.guild.channels.create({ + name: chanName, + type: ChannelType.GuildText, + parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID, + permissionOverwrites: [ + { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }, + ...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : []) + ] + }); + await StaffNotification.findOneAndUpdate( + { userId: member.id }, + { $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } }, + { upsert: true } + ); + return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true }); + } catch (err) { + console.error('notification add error:', err); + return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {}); + } + } + return; + } + + // /staffnotification (admin only) + if (interaction.commandName === 'staffnotification') { + if (interaction.user.id !== CONFIG.ADMIN_ID) { + logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {}); + return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true }); + } + const member = interaction.options.getMember('member'); + const hours = interaction.options.getInteger('hours'); + if (!member) { + return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true }); + } + try { + await StaffNotification.findOneAndUpdate( + { userId: member.id }, + { $set: { cooldownHours: hours, updatedAt: new Date() } }, + { upsert: true } + ); + return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true }); + } catch (err) { + console.error('staffnotification error:', err); + return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); + } + } + + if (interaction.commandName === 'notifydm') { + try { + const setting = interaction.options.getString('setting') === 'on'; + await setNotifyDm(interaction.user.id, interaction.guildId, setting); + await interaction.reply({ + content: `DM notifications ${setting ? 'enabled βœ…' : 'disabled πŸ”•'}.`, + ephemeral: true + }); + } catch (err) { + console.error('notifydm error:', err); + await interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {}); + } + return; + } + + // /deescalate (tier 3 β†’ tier 2, tier 2 β†’ normal) + if (interaction.commandName === 'deescalate') { + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); + if (currentTier === 0) { + return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true }); + } + + try { + await interaction.deferReply({ ephemeral: true }); + await runDeescalation(interaction, ticket); + } catch (err) { + console.error('Deescalate error:', err); + await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() => + interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {}) + ); + } + } + + // /add + if (interaction.commandName === 'add') { + const user = interaction.options.getUser('user'); + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + try { + // TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue β€” could race a pending rename/send on the same channel. + await interaction.channel.permissionOverwrites.create(user.id, { + ViewChannel: true, + SendMessages: true, + ReadMessageHistory: true + }); + await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } }); + } catch (err) { + console.error('Add user error:', err); + await interaction.reply({ content: 'Failed to add user.', ephemeral: true }); + } + } + + // /remove + if (interaction.commandName === 'remove') { + const user = interaction.options.getUser('user'); + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + try { + // TODO(queue-migrate): permissionOverwrites mutation bypasses channelQueue β€” could race a pending rename/send on the same channel. + await interaction.channel.permissionOverwrites.delete(user.id); + await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } }); + } catch (err) { + console.error('Remove user error:', err); + await interaction.reply({ content: 'Failed to remove user.', ephemeral: true }); + } + } + + // /transfer + if (interaction.commandName === 'transfer') { + const member = interaction.options.getUser('member'); + const reason = interaction.options.getString('reason') || 'No reason provided'; + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + const staffRoleId = CONFIG.ROLE_TO_PING_ID; + const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null); + + if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) { + return interaction.reply({ content: 'The target member must have the staff role.', ephemeral: true }); + } + + try { + const claimerLabel = guildMember.displayName || guildMember.user.username; + + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { claimedBy: claimerLabel } } + ); + + // `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping. + await interaction.reply({ + content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`, + allowedMentions: { parse: ['users'] } + }); + + const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); + if (logChan) { + await enqueueSend(logChan, { + content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`, + allowedMentions: { parse: ['users'] } + }); + } + } catch (err) { + console.error('Transfer error:', err); + await interaction.reply({ content: 'Failed to transfer ticket.', ephemeral: true }); + } + } + + // /move + if (interaction.commandName === 'move') { + const category = interaction.options.getChannel('category'); + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + try { + // TODO(queue-migrate): setParent bypasses channelQueue (enqueueMove) β€” use enqueueMove so moves serialize with pending renames/sends. + await interaction.channel.setParent(category.id, { lockPermissions: true }); + await interaction.reply(`Moved ticket to **${category.name}**.`); + + const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null); + if (logChan) { + await enqueueSend(logChan, + `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}` + ); + } + } catch (err) { + console.error('Move error:', err); + await interaction.reply({ content: 'Failed to move ticket.', ephemeral: true }); + } + } + + // /gmailpoll + // /staffthread + if (interaction.commandName === 'staffthread') { + const sub = interaction.options.getSubcommand(); + if (sub === 'toggle') { + CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED; + return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + if (sub === 'name') { + const name = interaction.options.getString('thread_name').slice(0, 100); + CONFIG.STAFF_THREAD_NAME = name; + return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true }); + } + if (sub === 'autorole') { + const enabled = interaction.options.getBoolean('enabled'); + CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled; + return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + return; + } + + // /pinmessages + if (interaction.commandName === 'pinmessages') { + const sub = interaction.options.getSubcommand(); + const enabled = interaction.options.getBoolean('enabled'); + if (sub === 'initial') { + CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled; + return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + if (sub === 'escalation') { + CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled; + return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + if (sub === 'suppress') { + CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled; + return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true }); + } + return; + } + + if (interaction.commandName === 'gmailpoll') { + const seconds = parseInt(interaction.options.getString('interval'), 10); + const { setGmailPollInterval } = require('../broccolini-discord'); + setGmailPollInterval(seconds * 1000); + logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {}); + return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true }); + } + + // /closetimer + if (interaction.commandName === 'closetimer') { + const seconds = parseInt(interaction.options.getString('seconds'), 10); + CONFIG.FORCE_CLOSE_TIMER = seconds; + logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {}); + return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true }); + } + + // /cancel-close + if (interaction.commandName === 'cancel-close') { + const pending = pendingCloses.get(interaction.channel.id); + if (!pending) { + return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true }); + } + clearTimeout(pending.timeout); + const { logTicketEvent } = require('../services/debugLog'); + logTicketEvent('Force-close cancelled', [ + { name: 'Ticket', value: interaction.channel.name || interaction.channel.id }, + { name: 'Cancelled by', value: interaction.user.tag }, + { name: 'Original setter', value: pending.username || 'Unknown' } + ], interaction).catch(() => {}); + pendingCloses.delete(interaction.channel.id); + return interaction.reply({ content: 'Close cancelled.', ephemeral: true }); + } + + // /force-close + if (interaction.commandName === 'force-close') { + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + if (pendingCloses.has(interaction.channel.id)) { + return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); + } + + const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; + await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`); + + const channelRef = interaction.channel; + const clientRef = interaction.client; + const timerId = setTimeout(async () => { + pendingCloses.delete(channelRef.id); + const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean(); + if (!freshTicket || freshTicket.status === 'closed') return; + + try { + await Ticket.updateOne( + { gmailThreadId: freshTicket.gmailThreadId }, + { $set: { status: 'closed' } } + ); + + await enqueueSend(channelRef, 'Ticket force-closed. Archiving...'); + + try { + await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE); + + const messages = await channelRef.messages.fetch({ limit: 100 }); + const log = + `TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` + + messages + .reverse() + .map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`) + .join('\n'); + + const file = new AttachmentBuilder(Buffer.from(log), { + name: `transcript-${channelRef.name}.txt` + }); + + const transcriptChan = await clientRef.channels + .fetch(CONFIG.TRANSCRIPT_CHAN) + .catch(() => null); + + if (transcriptChan) { + const closedAt = new Date(); + const openedStr = new Date(freshTicket.createdAt).toLocaleString('en-US', { + month: '2-digit', day: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: true, timeZoneName: 'short' + }); + const closedStr = closedAt.toLocaleString('en-US', { + month: '2-digit', day: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: true, timeZoneName: 'short' + }); + const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE + .replace(/\{channel_name\}/g, channelRef.name) + .replace(/\{email\}/g, freshTicket.senderEmail || '') + .replace(/\{date_opened\}/g, openedStr) + .replace(/\{date_closed\}/g, closedStr) + + `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`; + await enqueueSend(transcriptChan, { + content: transcriptContent, + files: [file] + }); + } + } catch (tErr) { + console.error('Transcript error (force-close):', tErr); + } + + setTimeout(async () => { + try { + await channelRef.delete('Ticket force-closed'); + } catch (e) { + console.error('Failed to delete channel:', e); + } + }, 5000); + } catch (err) { + console.error('Force close error:', err); + } + }, timerSeconds * 1000); + pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); + } + + // /topic + if (interaction.commandName === 'topic') { + const text = interaction.options.getString('text'); + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + try { + // TODO(queue-migrate): setTopic bypasses channelQueue β€” could race a pending rename/send on the same channel. + await interaction.channel.setTopic(text); + await interaction.reply('Topic updated successfully.'); + } catch (err) { + console.error('Topic error:', err); + await interaction.reply({ content: 'Failed to update topic.', ephemeral: true }); + } + } + + // /tag – ticket category dropdown only + if (interaction.commandName === 'tag') { + trackInteraction('commands', 'tag', interaction.user.tag); + const categoryValue = interaction.options.getString('category'); + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + try { + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { ticketTag: categoryValue } } + ); + const tagEntry = (TICKET_TAGS || []).find(t => t.value === categoryValue); + const emoji = tagEntry ? tagEntry.emoji : ''; + const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`; + await interaction.reply(channelMessage); + increment('tag_usage', categoryValue, 'today'); + increment('tag_usage', categoryValue, 'week'); + if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week'); + } catch (err) { + trackError('tag-command', err, interaction); + await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true }); + } + } + + // /response – saved response tags (send, create, edit, delete, list) + if (interaction.commandName === 'response') { + trackInteraction('commands', 'response', interaction.user.tag); + const subcommand = interaction.options.getSubcommand(); + + try { + if (subcommand === 'send') { + const name = interaction.options.getString('name'); + const tag = await Tag.findOne({ name }).lean(); + if (!tag) { + return interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true }); + } + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + const context = { + ticket: ticket || {}, + staff: { + username: interaction.user.username, + displayName: interaction.member?.displayName, + mention: interaction.user.toString() + }, + guild: interaction.guild + }; + + const content = replaceVariables(tag.content, context); + await Tag.updateOne({ name }, { $inc: { useCount: 1 } }); + // Tag bodies are staff-authored but may include variable substitutions from user/ticket data. + // Disable all mention parsing so a `@everyone` in a tag body never pings. + await interaction.reply({ content, allowedMentions: { parse: [] } }); + } + + else if (subcommand === 'create') { + const name = interaction.options.getString('name'); + const content = interaction.options.getString('content'); + + try { + await Tag.create({ name, content, createdBy: interaction.user.id }); + await interaction.reply({ content: `βœ… Tag "${name}" created successfully.`, ephemeral: true }); + } catch (err) { + if (err.code === 11000 || err.message?.includes('duplicate')) { + await interaction.reply({ content: `❌ Tag "${name}" already exists.`, ephemeral: true }); + } else { + trackError('tag-create', err, interaction); + await interaction.reply({ content: '❌ Failed to create tag.', ephemeral: true }); + } + } + } + + else if (subcommand === 'edit') { + const name = interaction.options.getString('name'); + const content = interaction.options.getString('content'); + + try { + const result = await Tag.updateOne({ name }, { $set: { content } }); + + if (result.matchedCount === 0) { + await interaction.reply({ content: `❌ Tag "${name}" not found.`, ephemeral: true }); + } else { + await interaction.reply({ content: `βœ… Tag "${name}" updated successfully.`, ephemeral: true }); + } + } catch (err) { + trackError('tag-edit', err, interaction); + await interaction.reply({ content: '❌ Failed to edit tag.', ephemeral: true }); + } + } + + else if (subcommand === 'delete') { + const name = interaction.options.getString('name'); + // Use :: delimiter so tag names with underscores are parsed correctly (Discord customId max 100 chars) + const customId = `confirm_delete_tag::${name}`.slice(0, 100); + const confirmRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(customId) + .setLabel('Yes, Delete Tag') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('cancel_delete_tag') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + + return interaction.reply({ + content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`, + components: [confirmRow], + ephemeral: true + }); + } + + else if (subcommand === 'list') { + await interaction.deferReply({ ephemeral: true }); + + const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean(); + + if (!tags || tags.length === 0) { + return interaction.editReply({ content: 'πŸ“‹ No tags available.' }); + } + + const embed = new EmbedBuilder() + .setTitle('πŸ“‹ Available Saved Responses') + .setDescription( + tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n') + ) + .setColor(CONFIG.EMBED_COLOR_INFO) + .setFooter({ text: `Total: ${tags.length} tags` }); + + await interaction.editReply({ embeds: [embed] }); + } + } catch (err) { + trackError('response-command', err, interaction); + const errorMsg = '❌ An error occurred while processing the response command.'; + if (interaction.deferred) { + await interaction.editReply(errorMsg); + } else { + await interaction.reply({ content: errorMsg, ephemeral: true }); + } + } + } + + // /signature + if (interaction.commandName === 'signature') { + try { + // Fetch existing signature data if it exists + const StaffSignature = mongoose.model('StaffSignature'); + const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean(); + + // Create modal + const { ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } = require('discord.js'); + const modal = new ModalBuilder() + .setCustomId(`signature_modal_${interaction.user.id}`) + .setTitle('Staff Signature Settings'); + + // Add text inputs to modal + const valedictionInput = new TextInputBuilder() + .setCustomId('valediction') + .setLabel('Valediction (e.g. "Best regards", "Thanks")') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setValue(existingSignature?.valediction || ''); + + const displayNameInput = new TextInputBuilder() + .setCustomId('display_name') + .setLabel('Display Name (e.g. "Support Team")') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setValue(existingSignature?.displayName || ''); + + const taglineInput = new TextInputBuilder() + .setCustomId('tagline') + .setLabel('Tagline (e.g. "Technical Support Specialist")') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setValue(existingSignature?.tagline || ''); + + const valedictionRow = new ActionRowBuilder().addComponents(valedictionInput); + const displayNameRow = new ActionRowBuilder().addComponents(displayNameInput); + const taglineRow = new ActionRowBuilder().addComponents(taglineInput); + + modal.addComponents(valedictionRow, displayNameRow, taglineRow); + + await interaction.showModal(modal); + } catch (err) { + console.error('Signature command error:', err); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ content: 'Failed to open signature settings.', ephemeral: true }).catch(() => {}); + } + } + return; + } + + // /accountinfo + if (interaction.commandName === 'accountinfo') { + await handleAccountInfoCommand(interaction); + return; + } + + // /help + if (interaction.commandName === 'help') { + const embed = new EmbedBuilder() + .setTitle('Ticket System - Commands') + .setColor(CONFIG.EMBED_COLOR_OPEN) + .addFields([ + { + name: 'User Management', + value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket' + }, + { + name: 'Ticket Management', + value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic ` - Set ticket topic/description\n`/priority ` - Set ticket priority\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user' + }, + { + name: 'Tags & Responses', + value: '`/tag` - Set ticket category (dropdown)\n`/response send ` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses' + }, + { + name: 'Variables (for tags)', + value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`' + }, + { + name: 'Panel System', + value: '`/panel #channel` - Create a ticket panel for Discord-side tickets' + }, + { + name: 'Escalation', + value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3β†’2 or tier 2β†’normal)' + } + ]) + .setFooter({ text: 'Click buttons on ticket messages to claim/close' }); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + + // /priority + if (interaction.commandName === 'priority') { + const level = interaction.options.getString('level'); + + const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); + if (!ticket) { + return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true }); + } + + const priorityOrder = ['low', 'normal', 'medium', 'high']; + const oldIdx = priorityOrder.indexOf((ticket.priority || 'normal').toLowerCase()); + const newIdx = priorityOrder.indexOf(level.toLowerCase()); + const emoji = getPriorityEmoji(level); + const levelLabel = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase(); + + let channelMessage; + if (level === 'normal') { + channelMessage = 'Your ticket priority has returned to Normal.'; + } else if (newIdx > oldIdx) { + channelMessage = `Your ticket has been upgraded to ${emoji} **${levelLabel}** ${emoji}.`; + } else if (newIdx < oldIdx) { + channelMessage = `Your ticket has been downgraded to ${emoji} **${levelLabel}** ${emoji}.`; + } else { + channelMessage = `Priority set to ${emoji} **${levelLabel}** ${emoji}.`; + } + + try { + await Ticket.updateOne( + { gmailThreadId: ticket.gmailThreadId }, + { $set: { priority: level } } + ); + + const priorityTitle = + newIdx === oldIdx + ? 'Priority Set' + : `Priority ${newIdx > oldIdx ? 'Upgraded' : 'Downgraded'} β†’ ${levelLabel}`; + const priorityEmbed = new EmbedBuilder() + .setTitle(priorityTitle) + .setDescription(channelMessage) + .setColor(getPriorityColor(level)) + .setFooter({ text: interaction.member?.displayName || interaction.user.username }); + await interaction.reply({ embeds: [priorityEmbed] }); + + if (level === 'high' && ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-')) { + await sendTicketNotificationEmail( + ticket, + `Priority updated: ${levelLabel}`, + channelMessage, + interaction.member?.displayName || interaction.user.username + ); + } + } catch (err) { + console.error('Priority update error:', err); + await interaction.reply({ content: 'Failed to update priority.', ephemeral: true }); + } + } + + // /panel + if (interaction.commandName === 'panel') { + const channel = interaction.options.getChannel('channel'); + const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null (use CONFIG default) + const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets'; + const description = interaction.options.getString('description') || + 'Need help? Click below to create a ticket. 🎟'; + + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(description) + .setColor(0x2ecc71) + .setThumbnail(CONFIG.LOGO_URL || null) + .setFooter({ text: 'Indifferent Broccoli Tickets' }); + + let row; + if (panelType === 'both') { + row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('open_ticket_thread') + .setLabel('Create ticket (thread)') + .setStyle(ButtonStyle.Secondary) + .setEmoji('🧡'), + new ButtonBuilder() + .setCustomId('open_ticket_channel') + .setLabel('Create ticket (channel)') + .setStyle(ButtonStyle.Secondary) + .setEmoji('πŸ“') + ); + } else if (panelType === 'thread') { + row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('open_ticket_thread') + .setLabel('Create ticket') + .setStyle(ButtonStyle.Secondary) + .setEmoji('🧡') + ); + } else if (panelType === 'category') { + row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('open_ticket_channel') + .setLabel('Create ticket') + .setStyle(ButtonStyle.Secondary) + .setEmoji('πŸ“') + ); + } else { + row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('open_ticket') + .setLabel('Create ticket') + .setStyle(ButtonStyle.Secondary) + .setEmoji('βœ…') + ); + } + + try { + await enqueueSend(channel, { embeds: [embed], components: [row] }); + await interaction.reply({ content: `Panel created in ${channel}!`, ephemeral: true }); + } catch (err) { + console.error('Panel creation error:', err); + await interaction.reply({ content: 'Failed to create panel.', ephemeral: true }); + } + } + + // /backup – export full ticket list to BACKUP_EXPORT_CHANNEL_ID + if (interaction.commandName === 'backup') { + trackInteraction('commands', 'backup', interaction.user.tag); + await interaction.deferReply({ ephemeral: true }); + if (!CONFIG.BACKUP_EXPORT_CHANNEL_ID) { + return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.'); + } + try { + // Stream every ticket through a Mongoose cursor to a tmp file so peak RSS + // stays bounded regardless of collection size; attach the file, then unlink. + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const tmpName = `ticket-backup-${Date.now()}-${process.pid}.txt`; + const tmpPath = path.join(os.tmpdir(), tmpName); + const ws = fs.createWriteStream(tmpPath, { encoding: 'utf8' }); + + ws.write('# Ticket backup – ' + new Date().toISOString() + '\n'); + ws.write('ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier\n'); + + let count = 0; + const cursor = Ticket.find().sort({ ticketNumber: 1 }).lean().cursor(); + for await (const t of cursor) { + const created = t.createdAt ? new Date(t.createdAt).toISOString() : ''; + ws.write([ + t.ticketNumber, + t.status || '', + (t.senderEmail || '').replace(/\t/g, ' '), + (t.subject || '').replace(/\t/g, ' ').slice(0, 200), + created, + (t.claimedBy || '').replace(/\t/g, ' '), + t.priority || '', + t.escalationTier ?? '' + ].join('\t') + '\n'); + count++; + } + await new Promise((resolve, reject) => ws.end(err => err ? reject(err) : resolve())); + + try { + const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID); + await enqueueSend(channel, { + content: `Ticket backup by ${interaction.user.tag} (${count} tickets)`, + files: [new AttachmentBuilder(tmpPath, { name: tmpName })] + }); + await interaction.editReply(`Backup complete. ${count} tickets sent to the backup channel.`); + } finally { + fs.promises.unlink(tmpPath).catch(() => {}); + } + } catch (err) { + trackError('backup-command', err, interaction); + await interaction.editReply('Failed to create backup: ' + (err.message || err)); + } + } + + // /export – export tickets with optional status and limit to BACKUP_EXPORT_CHANNEL_ID + if (interaction.commandName === 'export') { + trackInteraction('commands', 'export', interaction.user.tag); + await interaction.deferReply({ ephemeral: true }); + if (!CONFIG.BACKUP_EXPORT_CHANNEL_ID) { + return interaction.editReply('BACKUP_EXPORT_CHANNEL_ID is not set in .env.'); + } + try { + const status = interaction.options.getString('status') || null; + const limit = interaction.options.getInteger('limit') || 500; + const filter = status ? { status } : {}; + const tickets = await Ticket.find(filter).sort({ ticketNumber: -1 }).limit(limit).lean(); + const lines = ['# Ticket export – ' + new Date().toISOString() + (status ? ` (status=${status})` : '') + ` limit=${limit}`, 'ticketNumber\tstatus\tsenderEmail\tsubject\tcreatedAt\tclaimedBy\tpriority\tescalationTier']; + for (const t of tickets) { + const created = t.createdAt ? new Date(t.createdAt).toISOString() : ''; + lines.push([t.ticketNumber, t.status || '', (t.senderEmail || '').replace(/\t/g, ' '), (t.subject || '').replace(/\t/g, ' ').slice(0, 200), created, (t.claimedBy || '').replace(/\t/g, ' '), t.priority || '', t.escalationTier ?? ''].join('\t')); + } + const buf = Buffer.from(lines.join('\n'), 'utf8'); + const channel = await interaction.client.channels.fetch(CONFIG.BACKUP_EXPORT_CHANNEL_ID); + await enqueueSend(channel, { + content: `Ticket export by ${interaction.user.tag} (${tickets.length} tickets${status ? ` status=${status}` : ''})`, + files: [new AttachmentBuilder(buf, { name: `ticket-export-${Date.now()}.txt` })] + }); + await interaction.editReply(`Export complete. ${tickets.length} tickets sent to the backup channel.`); + } catch (err) { + trackError('export-command', err, interaction); + await interaction.editReply('Failed to export: ' + (err.message || err)); + } + } + + // /search + if (interaction.commandName === 'search') { + trackInteraction('commands', 'search', interaction.user.tag); + await interaction.deferReply({ ephemeral: true }); + + try { + const query = interaction.options.getString('query'); + const status = interaction.options.getString('status') || 'all'; + + const regex = new RegExp(escapeRegex(query), 'i'); + const filter = { + $or: [ + { senderEmail: regex }, + { subject: regex } + ] + }; + const ticketNum = parseInt(query, 10); + if (!Number.isNaN(ticketNum) && String(ticketNum) === query.trim()) { + filter.$or.push({ ticketNumber: ticketNum }); + } + if (status !== 'all') filter.status = status; + + const results = await Ticket.find(filter).sort({ createdAt: -1 }).limit(10).lean(); + + if (!results || results.length === 0) { + return interaction.editReply('πŸ” No tickets found matching your query.'); + } + + const embed = new EmbedBuilder() + .setTitle(`πŸ” Search Results for "${query}"`) + .setDescription(`Found ${results.length} ticket(s)`) + .setColor(CONFIG.EMBED_COLOR_INFO); + + for (const ticket of results.slice(0, 5)) { + const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal'); + const statusEmoji = ticket.status === 'open' ? '🟒' : 'πŸ”΄'; + embed.addFields({ + name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`, + value: `**Subject:** ${ticket.subject || 'No subject'}\n**From:** ${ticket.senderEmail}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`, + inline: false + }); + } + + if (results.length > 5) { + embed.setFooter({ text: `Showing 5 of ${results.length} results` }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (err) { + trackError('search-command', err, interaction); + await interaction.editReply('❌ An error occurred while searching.'); + } + } + + // /fix-stale-tickets + if (interaction.commandName === 'fix-stale-tickets') { + if (interaction.user.id !== CONFIG.ADMIN_ID) { + return interaction.reply({ content: 'You do not have permission to run this command.', ephemeral: true }); + } + await interaction.deferReply({ ephemeral: true }); + try { + const result = await Ticket.updateMany( + { status: 'open', lastActivity: null }, + [{ $set: { lastActivity: '$createdAt' } }] + ); + await interaction.editReply(`Fixed ${result.modifiedCount} ticket(s).`); + } catch (err) { + console.error('fix-stale-tickets:', err); + await interaction.editReply('❌ Failed to backfill tickets.').catch(() => {}); + } + } + + // /stats + if (interaction.commandName === 'stats') { + trackInteraction('commands', 'stats', interaction.user.tag); + await interaction.deferReply({ ephemeral: true }); + + try { + const summary = getAnalyticsSummary(); + + const ticketStats = await Ticket.aggregate([ + { $group: { _id: '$status', count: { $sum: 1 } } } + ]); + + const openCount = ticketStats.find(s => s._id === 'open')?.count || 0; + const closedCount = ticketStats.find(s => s._id === 'closed')?.count || 0; + const claimedCount = await Ticket.countDocuments({ status: 'open', claimedBy: { $ne: null } }); + + const embed = new EmbedBuilder() + .setTitle('πŸ“Š Bot Statistics & Analytics') + .setColor(CONFIG.EMBED_COLOR_INFO) + .addFields([ + { name: '⏱️ Uptime', value: summary.uptime, inline: true }, + { name: 'πŸ’¬ Total Interactions', value: summary.totalInteractions.toString(), inline: true }, + { name: 'πŸ“ˆ Commands Used', value: summary.commandsUsed.toString(), inline: true }, + { name: '🎫 Open Tickets', value: openCount.toString(), inline: true }, + { name: 'βœ… Closed Tickets', value: closedCount.toString(), inline: true }, + { name: 'πŸ“Œ Claimed Tickets', value: (claimedCount || 0).toString(), inline: true }, + { name: 'πŸ”₯ Most Used Command', value: summary.mostUsedCommand, inline: true }, + { name: '❌ Errors (Last Hour)', value: summary.errorsLastHour.toString(), inline: true }, + { name: 'πŸ“‰ Error Rate', value: summary.errorRate, inline: true }, + { name: 'πŸ“‹ Top Commands', value: summary.topCommands.join('\n') || 'None', inline: false } + ]) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (err) { + trackError('stats-command', err, interaction); + await interaction.editReply('❌ An error occurred while fetching statistics.'); + } + } +} + +/** + * Context menu interaction handler. + */ +async function handleContextMenu(interaction) { + // Restrict all guild context menus to staff role only + if (await requireStaffRole(interaction)) return; + + // Create Ticket From Message + if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') { + trackInteraction('contextMenus', 'create-ticket-from-message', interaction.user.tag); + await interaction.deferReply({ ephemeral: true }); + + const rateLimit = checkTicketCreationRateLimit(interaction.user.id); + if (!rateLimit.allowed) { + const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000); + return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`); + } + + try { + const message = interaction.targetMessage; + const subject = `Message from ${message.author.tag}`; + const description = message.content || 'No content'; + + const guild = interaction.guild; + const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); + const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; + + let channel; + let parentCategoryIdForTicket = null; + if (CONFIG.DISCORD_THREAD_CHANNEL_ID) { + try { + channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id); + parentCategoryIdForTicket = channel.parent?.parentId ?? null; + } catch (err) { + console.error('Discord ticket thread create (from message) failed:', err.message); + return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.'); + } + } else { + let parentId; + try { + parentId = await getOrCreateTicketCategory( + guild, + CONFIG.DISCORD_TICKET_CATEGORY_ID, + CONFIG.TICKET_CATEGORY_NAME + ); + } catch (err) { + console.error('getOrCreateTicketCategory (context menu ticket):', err); + return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.'); + } + parentCategoryIdForTicket = parentId; + try { + channel = await guild.channels.create({ + name: `ticket-${ticketNumber}`, + type: ChannelType.GuildText, + parent: parentId, + permissionOverwrites: [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { + id: message.author.id, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + }, + { + id: CONFIG.ROLE_ID_TO_PING, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] + } + ] + }); + } catch (err) { + console.error('guild.channels.create (context menu ticket):', err); + return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.'); + } + } + + const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`; + const now = new Date(); + await Ticket.create({ + gmailThreadId, + discordThreadId: channel.id, + senderEmail: message.author.tag, + subject, + createdAt: now, + status: 'open', + ticketNumber, + priority: 'normal', + lastActivity: now, + parentCategoryId: parentCategoryIdForTicket + }); + + const welcomeEmbed = new EmbedBuilder() + .setDescription(CONFIG.TICKET_WELCOME_MESSAGE) + .setColor(CONFIG.EMBED_COLOR_INFO); + + const infoEmbed = new EmbedBuilder() + .setColor(CONFIG.EMBED_COLOR_INFO) + .addFields( + { name: 'From message', value: `[Jump to message](${message.url})` }, + { name: 'Creator', value: message.author.toString(), inline: true }, + { name: 'Created by Staff', value: interaction.user.toString(), inline: true }, + { name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false } + ); + + const row = getTicketActionRow({ escalationTier: 0 }); + + try { + const welcomeMsg = await enqueueSend(channel, { + content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} πŸ₯¦`, + embeds: [welcomeEmbed, infoEmbed], + components: [row] + }); + + await Ticket.updateOne( + { discordThreadId: channel.id }, + { $set: { welcomeMessageId: welcomeMsg.id } } + ); + } catch (err) { + console.error('welcomeMessageId-save', err); + } + + await interaction.editReply(`βœ… Ticket created: ${channel}`); + } catch (err) { + trackError('create-ticket-from-message', err, interaction); + await interaction.editReply('❌ Failed to create ticket from message.'); + } + } + + // View User Tickets + if (interaction.isUserContextMenuCommand() && interaction.commandName === 'View User Tickets') { + trackInteraction('contextMenus', 'view-user-tickets', interaction.user.tag); + await interaction.deferReply({ ephemeral: true }); + + try { + const targetUser = interaction.targetUser; + + const tickets = await Ticket.find({ senderEmail: targetUser.tag }) + .sort({ createdAt: -1 }) + .limit(10) + .lean(); + + if (!tickets || tickets.length === 0) { + return interaction.editReply(`πŸ“‹ No tickets found for ${targetUser.tag}`); + } + + const embed = new EmbedBuilder() + .setTitle(`πŸ“‹ Tickets for ${targetUser.tag}`) + .setDescription(`Found ${tickets.length} ticket(s)`) + .setColor(CONFIG.EMBED_COLOR_INFO); + + for (const ticket of tickets.slice(0, 5)) { + const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal'); + const statusEmoji = ticket.status === 'open' ? '🟒' : 'πŸ”΄'; + embed.addFields({ + name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`, + value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`, + inline: false + }); + } + + if (tickets.length > 5) { + embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (err) { + trackError('view-user-tickets', err, interaction); + await interaction.editReply('❌ Failed to fetch user tickets.'); + } + } +} + +/** + * Autocomplete handler. + */ +async function handleAutocomplete(interaction) { + if (interaction.commandName === 'response') { + const subcommand = interaction.options.getSubcommand(); + if (['send', 'edit', 'delete'].includes(subcommand)) { + const focusedValue = interaction.options.getFocused(); + const tags = await Tag.find().sort({ name: 1 }).select('name').lean(); + + const filtered = tags + .filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase())) + .slice(0, 25) + .map(t => ({ name: t.name, value: t.name })); + + await interaction.respond(filtered); + } + } +} + +module.exports = { handleCommand, handleContextMenu, handleAutocomplete, runEscalation, runDeescalation }; \ No newline at end of file diff --git a/handlers/messages.js b/handlers/messages.js index 1f9635a..f7cc6a8 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -7,8 +7,6 @@ const { extractRawEmail } = require('../utils'); const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { updateTicketActivity } = require('../services/tickets'); const { getNotifyDm } = require('../services/staffSettings'); -const { pingStaffChannel } = require('../services/staffChannel'); -const { notifyStaffOfReply } = require('../services/staffNotifications'); const Ticket = mongoose.model('Ticket'); @@ -21,29 +19,6 @@ async function handleDiscordReply(m) { const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); if (!ticket) return; - if (ticket.claimerId && m.author.id !== ticket.claimerId && ticket.staffChannelId) { - try { - const staffChan = await m.guild.channels.fetch(ticket.staffChannelId).catch(() => null); - if (staffChan) { - await pingStaffChannel(staffChan, ticket.claimerId, m); - } - const dmEnabled = await getNotifyDm(ticket.claimerId); - if (dmEnabled) { - const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null); - if (staffMember) { - const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`; - await staffMember - .send( - `New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})` - ) - .catch(() => {}); - } - } - } catch (e) { - console.error('Staff ping error:', e); - } - } - // Track whether last message is from staff or customer const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING); @@ -52,12 +27,19 @@ async function handleDiscordReply(m) { { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } ).catch(() => {}); - // Notify claiming staff if a non-staff user replied (works for both Discord and email tickets) - if (ticket.claimerId && !isStaffMember) { - const guild = m.guild; - const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); - if (freshTicket) { - await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e)); + // DM the claimer if they have notifydm on and a non-staff user replied. + if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) { + const dmEnabled = await getNotifyDm(ticket.claimerId); + if (dmEnabled) { + const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null); + if (staffMember) { + const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`; + await staffMember + .send( + `New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})` + ) + .catch(() => {}); + } } } diff --git a/handlers/messages.js.bak-20260421 b/handlers/messages.js.bak-20260421 new file mode 100644 index 0000000..1f9635a --- /dev/null +++ b/handlers/messages.js.bak-20260421 @@ -0,0 +1,120 @@ +/** + * Discord messageCreate handler – forwards staff replies to Gmail. + */ +const { mongoose } = require('../db-connection'); +const { CONFIG } = require('../config'); +const { extractRawEmail } = require('../utils'); +const { getGmailClient, sendGmailReply } = require('../services/gmail'); +const { updateTicketActivity } = require('../services/tickets'); +const { getNotifyDm } = require('../services/staffSettings'); +const { pingStaffChannel } = require('../services/staffChannel'); +const { notifyStaffOfReply } = require('../services/staffNotifications'); + +const Ticket = mongoose.model('Ticket'); + +/** + * Handle a Discord message in a ticket channel β†’ relay to Gmail (email tickets only). + */ +async function handleDiscordReply(m) { + if (m.author.bot || m.interaction) return; + + const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); + if (!ticket) return; + + if (ticket.claimerId && m.author.id !== ticket.claimerId && ticket.staffChannelId) { + try { + const staffChan = await m.guild.channels.fetch(ticket.staffChannelId).catch(() => null); + if (staffChan) { + await pingStaffChannel(staffChan, ticket.claimerId, m); + } + const dmEnabled = await getNotifyDm(ticket.claimerId); + if (dmEnabled) { + const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null); + if (staffMember) { + const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`; + await staffMember + .send( + `New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})` + ) + .catch(() => {}); + } + } + } catch (e) { + console.error('Staff ping error:', e); + } + } + + // Track whether last message is from staff or customer + const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); + const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING); + Ticket.updateOne( + { discordThreadId: m.channel.id }, + { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } + ).catch(() => {}); + + // Notify claiming staff if a non-staff user replied (works for both Discord and email tickets) + if (ticket.claimerId && !isStaffMember) { + const guild = m.guild; + const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); + if (freshTicket) { + await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e)); + } + } + + const discordUser = m.member?.displayName || m.author.username; + + if (ticket.gmailThreadId.startsWith('discord-')) { + return; + } + + // Email tickets: send reply via Gmail. + try { + const gmail = getGmailClient(); + const thread = await gmail.users.threads.get({ + userId: 'me', + id: ticket.gmailThreadId + }); + + const last = [...thread.data.messages].reverse().find(msg => { + const from = + msg.payload.headers.find(h => h.name === 'From')?.value || ''; + return !from.toLowerCase().includes(CONFIG.MY_EMAIL); + }); + + if (!last) return; + + let recipient = + last.payload.headers.find(h => h.name === 'From')?.value || ''; + const replyTo = + last.payload.headers.find(h => h.name === 'Reply-To')?.value; + if (replyTo) recipient = replyTo; + + const subject = + last.payload.headers.find(h => h.name === 'Subject')?.value || + 'Support'; + const msgId = + last.payload.headers.find(h => h.name === 'Message-ID')?.value; + + const recipientEmail = extractRawEmail(recipient).toLowerCase(); + if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) { + console.warn('Bad recipient for reply:', recipientEmail); + return; + } + + await sendGmailReply( + ticket.gmailThreadId, + m.content, + recipientEmail, + subject, + discordUser, + msgId, + m.author.id + ); + + await updateTicketActivity(ticket.gmailThreadId); + } catch (e) { + console.error('REPLY ERROR:', e); + } +} + +module.exports = { handleDiscordReply }; diff --git a/models.js b/models.js index e160d7d..76e0c51 100644 --- a/models.js +++ b/models.js @@ -864,14 +864,6 @@ mongoose.model('StaffSettings', new mongoose.Schema({ updatedAt: { type: Date, default: Date.now } })); -mongoose.model('StaffNotification', new mongoose.Schema({ - userId: { type: String, required: true, unique: true }, - guildId: String, - channelId: String, - cooldownHours: { type: Number, default: 1 }, - updatedAt: { type: Date, default: Date.now } -})); - mongoose.model('StaffSignature', new mongoose.Schema({ userId: { type: String, required: true, unique: true }, guildId: { type: String, required: true }, diff --git a/routes/internalApi.js b/routes/internalApi.js index 375d364..b37a02a 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -5,14 +5,7 @@ const { CONFIG } = require('../config'); const { safeEqual } = require('../utils'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { logSystem } = require('../services/debugLog'); -const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry'); const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema'); -const { - getAllState: getNotificationState, - setKeyEnabled, - setCategoryEnabled, - setMasterEnabled -} = require('../services/notificationEnabled'); const router = express.Router(); @@ -169,67 +162,6 @@ router.get('/restart/status', (req, res) => { res.json({ scheduledRestart: !!scheduledRestart }); }); -// GET /notifications/alerts β€” canonical bot-side notification alert catalog -router.get('/notifications/alerts', (req, res) => { - res.json(NOTIFICATION_REGISTRY); -}); - -// GET /notifications/state β€” Phase 9: master flag + per-key enable map -router.get('/notifications/state', (req, res) => { - res.json(getNotificationState()); -}); - -// POST /notifications/toggle β€” Phase 9: mutate one of {master, category, key} -// -// Body shapes (exactly one of these must be used): -// { master: true, enabled: } -// { category: , enabled: } -// { key: , enabled: } -// -// Mutates CONFIG in memory via notificationEnabled, then persists through -// applyConfigUpdates so the value passes schema validation and ends up in .env. -router.post('/notifications/toggle', express.json(), async (req, res) => { - const body = req.body; - if (!body || typeof body !== 'object' || Array.isArray(body)) { - return res.status(400).json({ error: 'Invalid body' }); - } - if (typeof body.enabled !== 'boolean') { - return res.status(400).json({ error: '`enabled` must be boolean' }); - } - - const hasMaster = Object.prototype.hasOwnProperty.call(body, 'master'); - const hasCategory = Object.prototype.hasOwnProperty.call(body, 'category'); - const hasKey = Object.prototype.hasOwnProperty.call(body, 'key'); - const specifiedCount = Number(hasMaster) + Number(hasCategory) + Number(hasKey); - if (specifiedCount !== 1) { - return res.status(400).json({ error: 'Specify exactly one of: master, category, key' }); - } - - let updates; - if (hasMaster) { - setMasterEnabled(body.enabled); - updates = { NOTIFICATIONS_MASTER_ENABLED: body.enabled }; - } else if (hasCategory) { - if (typeof body.category !== 'string' || !Object.prototype.hasOwnProperty.call(NOTIFICATION_REGISTRY, body.category)) { - return res.status(400).json({ error: 'Unknown category' }); - } - const newJson = setCategoryEnabled(body.category, body.enabled); - updates = { NOTIFICATION_ENABLED_JSON: newJson }; - } else { - if (typeof body.key !== 'string' || !body.key) { - return res.status(400).json({ error: '`key` must be a non-empty string' }); - } - const newJson = setKeyEnabled(body.key, body.enabled); - updates = { NOTIFICATION_ENABLED_JSON: newJson }; - } - - const result = applyConfigUpdates(updates); - if (result.errors.length > 0) { - return res.status(500).json({ error: 'Persistence failed', details: result.errors }); - } - res.json({ state: getNotificationState() }); -}); - // POST /gmail/reload β€” hot-swap Gmail OAuth creds after weekly reauth without // restarting the process. Reads REFRESH_TOKEN from .env via configPersistence, // probes Google with users.getProfile, and on success clears pollSuspended and diff --git a/services/chatAlertChecker.js b/services/chatAlertChecker.js deleted file mode 100644 index 632fd2f..0000000 --- a/services/chatAlertChecker.js +++ /dev/null @@ -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 }; diff --git a/services/configSchema.js b/services/configSchema.js index 64a718f..1b36333 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -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'; diff --git a/services/configSchema.js.bak-20260421 b/services/configSchema.js.bak-20260421 new file mode 100644 index 0000000..64a718f --- /dev/null +++ b/services/configSchema.js.bak-20260421 @@ -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 (17–20 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 +}; diff --git a/services/notificationEnabled.js b/services/notificationEnabled.js deleted file mode 100644 index 42218a9..0000000 --- a/services/notificationEnabled.js +++ /dev/null @@ -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 -}; diff --git a/services/notificationRegistry.js b/services/notificationRegistry.js deleted file mode 100644 index 71ec650..0000000 --- a/services/notificationRegistry.js +++ /dev/null @@ -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 }; diff --git a/services/patternChecker.js b/services/patternChecker.js deleted file mode 100644 index 27b31ba..0000000 --- a/services/patternChecker.js +++ /dev/null @@ -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 }; diff --git a/services/patternStore.js b/services/patternStore.js deleted file mode 100644 index 97c3314..0000000 --- a/services/patternStore.js +++ /dev/null @@ -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 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 } -}; diff --git a/services/staffChannel.js b/services/staffChannel.js deleted file mode 100644 index 02adc7d..0000000 --- a/services/staffChannel.js +++ /dev/null @@ -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 }; diff --git a/services/staffNotifications.js b/services/staffNotifications.js deleted file mode 100644 index 17dd380..0000000 --- a/services/staffNotifications.js +++ /dev/null @@ -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 } -}; diff --git a/services/staffPresence.js b/services/staffPresence.js deleted file mode 100644 index c00fb76..0000000 --- a/services/staffPresence.js +++ /dev/null @@ -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 }; diff --git a/services/surgeChecker.js b/services/surgeChecker.js deleted file mode 100644 index e73d201..0000000 --- a/services/surgeChecker.js +++ /dev/null @@ -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 }; diff --git a/services/tickets.js.bak-20260421 b/services/tickets.js.bak-20260421 new file mode 100644 index 0000000..20ee603 --- /dev/null +++ b/services/tickets.js.bak-20260421 @@ -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} + */ +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} + */ +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} + */ +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} + */ +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 } +}; diff --git a/utils.js b/utils.js index 960d05f..ce46793 100644 --- a/utils.js +++ b/utils.js @@ -3,7 +3,7 @@ * priority helpers, template variables. */ const crypto = require('crypto'); -const { CONFIG, GAME_NAMES, GAME_ALIASES, TICKET_TAGS } = require('./config'); +const { CONFIG, GAME_NAMES, GAME_ALIASES } = require('./config'); /** Constant-time string compare. Returns false for mismatched length or empty/nullish inputs without throwing. */ function safeEqual(a, b) { @@ -243,13 +243,6 @@ function getPriorityColor(priority) { } } -/** Returns emoji for a ticket-tag key (e.g. server-down β†’ ⬇️). Priority always comes first in channel name, then tag. */ -function getTicketTagEmoji(tagKey) { - if (!tagKey) return ''; - const t = (TICKET_TAGS || []).find(x => x.value === tagKey); - return t ? t.emoji : ''; -} - // --- TEMPLATE VARIABLES --- function replaceVariables(template, context = {}) { @@ -407,6 +400,5 @@ module.exports = { detectGame, getPriorityEmoji, getPriorityColor, - getTicketTagEmoji, replaceVariables };