strip: remove pattern/surge/chat alert monitoring + unused commands
- delete services/{patternChecker,patternStore,surgeChecker,chatAlertChecker,staffNotifications,staffChannel,notificationRegistry,notificationEnabled,staffPresence}.js
- remove /notification, /staffnotification, /tag, /priority
- /escalate: drop action param, always unclaim
- purge PATTERN_*, SURGE_*, CHAT_ALERT_*, STAFF_* env vars from config + .env.example
- drop StaffNotification model
- ~2500 LOC removed
- settings-site /internal/notifications/* endpoints gone (UI will 404 until trimmed)
This commit is contained in:
48
.env.example
48
.env.example
@@ -112,16 +112,10 @@ PRIORITY_MEDIUM_EMOJI=🟡
|
|||||||
PRIORITY_LOW_EMOJI=🟢
|
PRIORITY_LOW_EMOJI=🟢
|
||||||
|
|
||||||
# --- Claiming ---
|
# --- Claiming ---
|
||||||
CLAIM_TIMEOUT_ENABLED=false
|
|
||||||
CLAIM_TIMEOUT_HOURS=48
|
|
||||||
AUTO_UNCLAIM_ENABLED=false
|
AUTO_UNCLAIM_ENABLED=false
|
||||||
AUTO_UNCLAIM_AFTER_HOURS=24
|
AUTO_UNCLAIM_AFTER_HOURS=24
|
||||||
ALLOW_CLAIM_OVERWRITE=false
|
ALLOW_CLAIM_OVERWRITE=false
|
||||||
STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated
|
ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs)
|
||||||
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
|
|
||||||
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
|
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_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
|
||||||
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
|
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
|
SECURITY_LOG_CHANNEL_ID= # Channel for security/audit logs
|
||||||
SYSTEM_LOG_CHANNEL_ID= # Channel for bot lifecycle logs (startup, shutdown, DB events)
|
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 threads ---
|
||||||
STAFF_THREAD_ENABLED=false # Create a private staff thread on each ticket channel
|
STAFF_THREAD_ENABLED=false # Create a private staff thread on each ticket channel
|
||||||
STAFF_THREAD_NAME=Staff Discussion # Name of the private thread
|
STAFF_THREAD_NAME=Staff Discussion # Name of the private thread
|
||||||
|
|||||||
@@ -107,8 +107,6 @@ PRIORITY_MEDIUM_EMOJI=🟡
|
|||||||
PRIORITY_LOW_EMOJI=🟢
|
PRIORITY_LOW_EMOJI=🟢
|
||||||
|
|
||||||
# --- Claiming ---
|
# --- Claiming ---
|
||||||
CLAIM_TIMEOUT_ENABLED=false
|
|
||||||
CLAIM_TIMEOUT_HOURS=48
|
|
||||||
AUTO_UNCLAIM_ENABLED=false
|
AUTO_UNCLAIM_ENABLED=false
|
||||||
AUTO_UNCLAIM_AFTER_HOURS=24
|
AUTO_UNCLAIM_AFTER_HOURS=24
|
||||||
ALLOW_CLAIM_OVERWRITE=false
|
ALLOW_CLAIM_OVERWRITE=false
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const { handleDiscordReply } = require('./handlers/messages');
|
|||||||
// Services & jobs
|
// Services & jobs
|
||||||
const { sendTicketClosedEmail } = require('./services/gmail');
|
const { sendTicketClosedEmail } = require('./services/gmail');
|
||||||
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
|
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
|
||||||
const { notifyAllStaffUnclaimed } = require('./services/staffNotifications');
|
|
||||||
const { registerCommands } = require('./commands/register');
|
const { registerCommands } = require('./commands/register');
|
||||||
const bosscordRoutes = require('./routes/bosscord');
|
const bosscordRoutes = require('./routes/bosscord');
|
||||||
const { setBot } = require('./api/bosscordClient');
|
const { setBot } = require('./api/bosscordClient');
|
||||||
@@ -202,15 +201,6 @@ client.on('interactionCreate', async interaction => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on('messageCreate', async msg => {
|
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);
|
await handleDiscordReply(msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,47 +252,21 @@ client.once('ready', async () => {
|
|||||||
console.log('✓ Auto-close enabled: checking every hour');
|
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) {
|
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
|
||||||
trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000));
|
trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000));
|
||||||
checkAutoUnclaim(client);
|
checkAutoUnclaim(client);
|
||||||
console.log('✓ Auto-unclaim enabled: checking every hour');
|
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));
|
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
|
||||||
trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000));
|
trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000));
|
||||||
resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e));
|
resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e));
|
||||||
console.log('✓ Reconcile deleted ticket channels: every 1 hour');
|
console.log('✓ Reconcile deleted ticket channels: every 1 hour');
|
||||||
|
|
||||||
// Start in-memory Map sweeps (per-module) — keeps long-running processes bounded.
|
// 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);
|
require('./services/tickets').startTicketsSweeps(trackInterval);
|
||||||
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
|
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);
|
console.log('✓ Discord bot ready. Tag:', client.user.tag);
|
||||||
|
|
||||||
logSystem('Bot online', [
|
logSystem('Bot online', [
|
||||||
@@ -310,7 +274,6 @@ client.once('ready', async () => {
|
|||||||
{ name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` },
|
{ 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-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: '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: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
|
||||||
{ name: 'Automation log', value: CONFIG.AUTOMATION_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' },
|
{ name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' },
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
ContextMenuCommandBuilder,
|
ContextMenuCommandBuilder,
|
||||||
ApplicationCommandType
|
ApplicationCommandType
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
|
|
||||||
async function registerCommands() {
|
async function registerCommands() {
|
||||||
if (!CONFIG.CLIENT_ID || !CONFIG.DISCORD_GUILD_ID) return;
|
if (!CONFIG.CLIENT_ID || !CONFIG.DISCORD_GUILD_ID) return;
|
||||||
@@ -22,7 +22,7 @@ async function registerCommands() {
|
|||||||
const commands = [
|
const commands = [
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('escalate')
|
.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])
|
.setContexts([InteractionContextType.Guild])
|
||||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||||
@@ -35,16 +35,6 @@ async function registerCommands() {
|
|||||||
{ name: 'Tier 2', value: '2' },
|
{ name: 'Tier 2', value: '2' },
|
||||||
{ name: 'Tier 3', value: '3' }
|
{ 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()
|
new SlashCommandBuilder()
|
||||||
@@ -128,19 +118,6 @@ async function registerCommands() {
|
|||||||
.setRequired(true)
|
.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()
|
new SlashCommandBuilder()
|
||||||
.setName('response')
|
.setName('response')
|
||||||
.setDescription('Saved response tags (custom templates)')
|
.setDescription('Saved response tags (custom templates)')
|
||||||
@@ -332,25 +309,6 @@ async function registerCommands() {
|
|||||||
.setRequired(false)
|
.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()
|
new SlashCommandBuilder()
|
||||||
.setName('search')
|
.setName('search')
|
||||||
.setDescription('Search for tickets')
|
.setDescription('Search for tickets')
|
||||||
@@ -384,52 +342,6 @@ async function registerCommands() {
|
|||||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.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()
|
new SlashCommandBuilder()
|
||||||
.setName('closetimer')
|
.setName('closetimer')
|
||||||
.setDescription('Set the force-close countdown duration')
|
.setDescription('Set the force-close countdown duration')
|
||||||
|
|||||||
156
config.js
156
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) {
|
function toInt(v, fallback) {
|
||||||
const n = parseInt(v, 10);
|
const n = parseInt(v, 10);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
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 = {
|
const CONFIG = {
|
||||||
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
|
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
|
||||||
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
|
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
|
||||||
@@ -181,8 +104,6 @@ const CONFIG = {
|
|||||||
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
|
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
|
||||||
PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡',
|
PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡',
|
||||||
PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_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_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
|
||||||
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
|
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
|
||||||
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
|
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_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
|
||||||
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
|
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
|
||||||
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
|
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,
|
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),
|
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_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
|
||||||
GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null,
|
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,
|
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
|
||||||
SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_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,
|
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_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
|
||||||
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
|
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
|
||||||
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
|
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_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
|
||||||
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
|
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
|
||||||
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
|
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
|
||||||
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null,
|
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)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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 || '')
|
const GAME_NAMES = (CONFIG.GAME_LIST || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(g => g.trim())
|
.map(g => g.trim())
|
||||||
@@ -346,8 +194,6 @@ const GAME_NAME_TO_KEY = {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
CONFIG,
|
CONFIG,
|
||||||
parseThresholdString,
|
|
||||||
TICKET_TAGS,
|
|
||||||
GAME_NAMES,
|
GAME_NAMES,
|
||||||
GAME_ALIASES,
|
GAME_ALIASES,
|
||||||
GAME_NAME_TO_KEY
|
GAME_NAME_TO_KEY
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ const {
|
|||||||
stripEmailQuotes,
|
stripEmailQuotes,
|
||||||
stripMobileFooter,
|
stripMobileFooter,
|
||||||
detectGame,
|
detectGame,
|
||||||
enforceEmbedLimit
|
enforceEmbedLimit,
|
||||||
|
sanitizeEmbedText
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
const { getGmailClient } = require('./services/gmail');
|
||||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||||
const { getEmailRouting } = require('./services/guildSettings');
|
const { getEmailRouting } = require('./services/guildSettings');
|
||||||
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
||||||
const { increment } = require('./services/patternStore');
|
|
||||||
const { enqueueSend } = require('./services/channelQueue');
|
const { enqueueSend } = require('./services/channelQueue');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
@@ -253,10 +253,10 @@ async function poll(client) {
|
|||||||
const ticketInfoEmbed = new EmbedBuilder()
|
const ticketInfoEmbed = new EmbedBuilder()
|
||||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: 'Name', value: `\`${sName}\``, inline: false },
|
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false },
|
||||||
{ name: 'Email', value: `\`${sEmail}\``, inline: false },
|
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
|
||||||
{ name: 'Game', value: `\`${detectedGame}\``, inline: false },
|
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||||||
{ name: 'Subject', value: `\`${subject || 'No subject'}\``, inline: false }
|
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
enforceEmbedLimit([ticketInfoEmbed]);
|
enforceEmbedLimit([ticketInfoEmbed]);
|
||||||
@@ -347,12 +347,6 @@ async function poll(client) {
|
|||||||
));
|
));
|
||||||
totalProcessed++;
|
totalProcessed++;
|
||||||
logGmail(subject, sEmail, number, detectedGame).catch(() => {});
|
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);
|
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||||
await gmail.users.messages.batchModify({
|
await gmail.users.messages.batchModify({
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
|||||||
const { runEscalation, runDeescalation } = require('./commands');
|
const { runEscalation, runDeescalation } = require('./commands');
|
||||||
const { trackInteraction, trackError } = require('./analytics');
|
const { trackInteraction, trackError } = require('./analytics');
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
const { increment } = require('../services/patternStore');
|
|
||||||
const { logError, logSystem } = require('../services/debugLog');
|
const { logError, logSystem } = require('../services/debugLog');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
@@ -342,11 +341,8 @@ async function handleClaim(interaction, ticket) {
|
|||||||
);
|
);
|
||||||
freshTicket.claimedBy = claimerLabel;
|
freshTicket.claimedBy = claimerLabel;
|
||||||
freshTicket.claimerId = interaction.user.id;
|
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 = '🎫';
|
||||||
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
|
||||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||||
|
|
||||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||||
@@ -385,11 +381,10 @@ async function handleClaim(interaction, ticket) {
|
|||||||
// Unclaim
|
// Unclaim
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||||
{ $set: { claimedBy: null, claimerId: null, staffChannelId: null } }
|
{ $set: { claimedBy: null, claimerId: null } }
|
||||||
);
|
);
|
||||||
freshTicket.claimedBy = null;
|
freshTicket.claimedBy = null;
|
||||||
freshTicket.claimerId = null;
|
freshTicket.claimerId = null;
|
||||||
freshTicket.staffChannelId = null;
|
|
||||||
|
|
||||||
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
|
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
|
||||||
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||||||
@@ -425,10 +420,6 @@ async function handleClaim(interaction, ticket) {
|
|||||||
// --- CONFIRM CLOSE ---
|
// --- CONFIRM CLOSE ---
|
||||||
async function handleConfirmClose(interaction, ticket) {
|
async function handleConfirmClose(interaction, ticket) {
|
||||||
const closedAt = new Date();
|
const closedAt = new Date();
|
||||||
increment('staff_closes', interaction.user.id, 'today');
|
|
||||||
if (!ticket.ticketTag) {
|
|
||||||
increment('untagged_closes', 'total', 'today');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -563,13 +554,6 @@ async function handleConfirmClose(interaction, ticket) {
|
|||||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
{ $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) {
|
if (transcriptMsg?.id) {
|
||||||
await Transcript.create({
|
await Transcript.create({
|
||||||
gmailThreadId: ticket.gmailThreadId,
|
gmailThreadId: ticket.gmailThreadId,
|
||||||
@@ -746,13 +730,6 @@ async function handleTicketModal(interaction) {
|
|||||||
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
|
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(() => {});
|
await interaction.deleteReply().catch(() => {});
|
||||||
|
|
||||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
||||||
|
|||||||
770
handlers/buttons.js.bak-20260421
Normal file
770
handlers/buttons.js.bak-20260421
Normal file
@@ -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 };
|
||||||
@@ -11,9 +11,9 @@ const {
|
|||||||
PermissionFlagsBits
|
PermissionFlagsBits
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
|
const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils');
|
||||||
const { makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { getEmailRouting } = require('../services/guildSettings');
|
const { getEmailRouting } = require('../services/guildSettings');
|
||||||
@@ -24,12 +24,10 @@ const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'
|
|||||||
const { handleAccountInfoCommand } = require('./accountinfo');
|
const { handleAccountInfoCommand } = require('./accountinfo');
|
||||||
const { handleSetupCommand } = require('./setup');
|
const { handleSetupCommand } = require('./setup');
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
const { increment } = require('../services/patternStore');
|
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
const Tag = mongoose.model('Tag');
|
const Tag = mongoose.model('Tag');
|
||||||
const User = mongoose.model('User');
|
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.
|
* 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
|
// Clear claim on escalation
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ 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.escalated = true;
|
||||||
ticket.escalationTier = nextTier;
|
ticket.escalationTier = nextTier;
|
||||||
ticket.claimedBy = null;
|
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 creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||||
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
||||||
@@ -265,12 +257,11 @@ async function handleCommand(interaction) {
|
|||||||
return;
|
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') {
|
if (interaction.commandName === 'escalate') {
|
||||||
const reason = null;
|
const reason = null;
|
||||||
const level = interaction.options.getString('level');
|
const level = interaction.options.getString('level');
|
||||||
const nextTier = level === '3' ? 2 : 1;
|
const nextTier = level === '3' ? 2 : 1;
|
||||||
const action = interaction.options.getString('action');
|
|
||||||
|
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
@@ -301,12 +292,6 @@ async function handleCommand(interaction) {
|
|||||||
try {
|
try {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
await runEscalation(interaction, ticket, nextTier, reason);
|
await runEscalation(interaction, ticket, nextTier, reason);
|
||||||
if (action === 'unclaim') {
|
|
||||||
await Ticket.updateOne(
|
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
|
||||||
{ $set: { claimedBy: null, claimerId: null } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Escalate error:', err);
|
console.error('Escalate error:', err);
|
||||||
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
|
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') {
|
if (interaction.commandName === 'notifydm') {
|
||||||
try {
|
try {
|
||||||
const setting = interaction.options.getString('setting') === 'on';
|
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)
|
// /response – saved response tags (send, create, edit, delete, list)
|
||||||
if (interaction.commandName === 'response') {
|
if (interaction.commandName === 'response') {
|
||||||
trackInteraction('commands', 'response', interaction.user.tag);
|
trackInteraction('commands', 'response', interaction.user.tag);
|
||||||
@@ -936,14 +818,14 @@ async function handleCommand(interaction) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Ticket Management',
|
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 <text>` - Set ticket topic/description\n`/priority <level>` - 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 <text>` - 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',
|
name: 'Saved Responses',
|
||||||
value: '`/tag` - Set ticket category (dropdown)\n`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
value: '`/response send <name>` - 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}`'
|
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 });
|
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
|
// /panel
|
||||||
if (interaction.commandName === 'panel') {
|
if (interaction.commandName === 'panel') {
|
||||||
const channel = interaction.options.getChannel('channel');
|
const channel = interaction.options.getChannel('channel');
|
||||||
|
|||||||
1470
handlers/commands.js.bak-20260421
Normal file
1470
handlers/commands.js.bak-20260421
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,6 @@ const { extractRawEmail } = require('../utils');
|
|||||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||||
const { updateTicketActivity } = require('../services/tickets');
|
const { updateTicketActivity } = require('../services/tickets');
|
||||||
const { getNotifyDm } = require('../services/staffSettings');
|
const { getNotifyDm } = require('../services/staffSettings');
|
||||||
const { pingStaffChannel } = require('../services/staffChannel');
|
|
||||||
const { notifyStaffOfReply } = require('../services/staffNotifications');
|
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
@@ -21,29 +19,6 @@ async function handleDiscordReply(m) {
|
|||||||
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||||
if (!ticket) return;
|
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
|
// Track whether last message is from staff or customer
|
||||||
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
|
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);
|
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() } }
|
{ $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } }
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
|
|
||||||
// Notify claiming staff if a non-staff user replied (works for both Discord and email tickets)
|
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||||||
if (ticket.claimerId && !isStaffMember) {
|
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||||||
const guild = m.guild;
|
const dmEnabled = await getNotifyDm(ticket.claimerId);
|
||||||
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
if (dmEnabled) {
|
||||||
if (freshTicket) {
|
const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null);
|
||||||
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
|
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(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
handlers/messages.js.bak-20260421
Normal file
120
handlers/messages.js.bak-20260421
Normal file
@@ -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 };
|
||||||
@@ -864,14 +864,6 @@ mongoose.model('StaffSettings', new mongoose.Schema({
|
|||||||
updatedAt: { type: Date, default: Date.now }
|
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({
|
mongoose.model('StaffSignature', new mongoose.Schema({
|
||||||
userId: { type: String, required: true, unique: true },
|
userId: { type: String, required: true, unique: true },
|
||||||
guildId: { type: String, required: true },
|
guildId: { type: String, required: true },
|
||||||
|
|||||||
@@ -5,14 +5,7 @@ const { CONFIG } = require('../config');
|
|||||||
const { safeEqual } = require('../utils');
|
const { safeEqual } = require('../utils');
|
||||||
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
||||||
const { logSystem } = require('../services/debugLog');
|
const { logSystem } = require('../services/debugLog');
|
||||||
const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry');
|
|
||||||
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
|
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
|
||||||
const {
|
|
||||||
getAllState: getNotificationState,
|
|
||||||
setKeyEnabled,
|
|
||||||
setCategoryEnabled,
|
|
||||||
setMasterEnabled
|
|
||||||
} = require('../services/notificationEnabled');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -169,67 +162,6 @@ router.get('/restart/status', (req, res) => {
|
|||||||
res.json({ scheduledRestart: !!scheduledRestart });
|
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: <bool> }
|
|
||||||
// { category: <str>, enabled: <bool> }
|
|
||||||
// { key: <str>, enabled: <bool> }
|
|
||||||
//
|
|
||||||
// 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
|
// POST /gmail/reload — hot-swap Gmail OAuth creds after weekly reauth without
|
||||||
// restarting the process. Reads REFRESH_TOKEN from .env via configPersistence,
|
// restarting the process. Reads REFRESH_TOKEN from .env via configPersistence,
|
||||||
// probes Google with users.getProfile, and on success clears pollSuspended and
|
// probes Google with users.getProfile, and on success clears pollSuspended and
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -27,17 +27,12 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
|||||||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
||||||
// Roles and staff
|
// Roles and staff
|
||||||
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
'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
|
// Channel IDs
|
||||||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
||||||
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_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',
|
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
|
||||||
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_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
|
// Messages and labels
|
||||||
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
||||||
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
'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',
|
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
||||||
// Toggles
|
// Toggles
|
||||||
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
'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',
|
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
||||||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
'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',
|
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||||||
'STAFF_DND_COUNTS_AS_AVAILABLE',
|
|
||||||
// Limits and thresholds
|
// Limits and thresholds
|
||||||
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
||||||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
||||||
// Embed colors
|
// Embed colors
|
||||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
|
'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 ----------
|
// ---------- Regex primitives ----------
|
||||||
@@ -207,13 +183,9 @@ const VALIDATORS = {
|
|||||||
|
|
||||||
function inferType(key) {
|
function inferType(key) {
|
||||||
// 1. Explicit overrides
|
// 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 (key === 'LOGO_URL') return 'url';
|
||||||
if (/_EMAIL$/.test(key)) return 'email';
|
if (/_EMAIL$/.test(key)) return 'email';
|
||||||
if (key.includes('COLOR')) return 'hex_color';
|
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.
|
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
|
||||||
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
||||||
|
|
||||||
|
|||||||
262
services/configSchema.js.bak-20260421
Normal file
262
services/configSchema.js.bak-20260421
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* Per-key config value validator registry.
|
||||||
|
*
|
||||||
|
* Pattern-driven type inference for every key in ALLOWED_CONFIG_KEYS.
|
||||||
|
* getValidator(key) returns { type, validate(value) }, where validate returns
|
||||||
|
* { ok: true, coerced } — typed value to assign into CONFIG[key]
|
||||||
|
* { ok: false, error } — human-readable reason surfaced in the save UI
|
||||||
|
*
|
||||||
|
* .env always stores String(coerced); CONFIG gets the typed coerced value so
|
||||||
|
* downstream consumers that compare === true / === 5 still work.
|
||||||
|
*
|
||||||
|
* This file is the canonical source for ALLOWED_CONFIG_KEYS — routes/internalApi
|
||||||
|
* imports the Set from here. That keeps the require graph acyclic:
|
||||||
|
* internalApi -> configPersistence -> configSchema
|
||||||
|
* internalApi -> configSchema
|
||||||
|
* No side effects beyond a one-line startup log of the fallback-string keys.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ALLOWED_CONFIG_KEYS = new Set([
|
||||||
|
// Ticket settings
|
||||||
|
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
|
||||||
|
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
|
||||||
|
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
|
||||||
|
// Escalation categories
|
||||||
|
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
||||||
|
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
||||||
|
// Roles and staff
|
||||||
|
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
||||||
|
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
|
||||||
|
// Channel IDs
|
||||||
|
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
||||||
|
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
|
||||||
|
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
|
||||||
|
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
|
||||||
|
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
|
||||||
|
'STAFF_NOTIFICATION_CATEGORY_ID',
|
||||||
|
// Pattern channel IDs
|
||||||
|
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
|
||||||
|
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID',
|
||||||
|
// Messages and labels
|
||||||
|
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
||||||
|
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
||||||
|
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
||||||
|
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
||||||
|
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
||||||
|
// Branding
|
||||||
|
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
||||||
|
// Toggles
|
||||||
|
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
||||||
|
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
|
||||||
|
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
||||||
|
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
||||||
|
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||||||
|
'STAFF_DND_COUNTS_AS_AVAILABLE',
|
||||||
|
// Limits and thresholds
|
||||||
|
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
||||||
|
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||||||
|
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
||||||
|
// Embed colors
|
||||||
|
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||||
|
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
|
||||||
|
// Pattern thresholds
|
||||||
|
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
|
||||||
|
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
|
||||||
|
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
|
||||||
|
// Surge settings
|
||||||
|
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
|
||||||
|
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
|
||||||
|
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
|
||||||
|
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
|
||||||
|
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
|
||||||
|
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
|
||||||
|
// Chat alerts
|
||||||
|
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
|
||||||
|
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
|
||||||
|
// Notification thresholds
|
||||||
|
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS',
|
||||||
|
// Notification enable state (Phase 9)
|
||||||
|
'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------- Regex primitives ----------
|
||||||
|
|
||||||
|
const SNOWFLAKE_RE = /^[0-9]{17,20}$/;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const HEX_COLOR_RE = /^(?:0x|#)?([0-9A-Fa-f]{6})$/;
|
||||||
|
const INT_RE = /^-?\d+$/;
|
||||||
|
const NUMERIC_COERCE_RE = /^-?\d+(?:\.\d+)?$/;
|
||||||
|
|
||||||
|
function isEmptyInput(v) {
|
||||||
|
return v === '' || v === null || v === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Validators ----------
|
||||||
|
|
||||||
|
const VALIDATORS = {
|
||||||
|
boolean: {
|
||||||
|
type: 'boolean',
|
||||||
|
validate(value) {
|
||||||
|
if (value === true || value === 'true') return { ok: true, coerced: true };
|
||||||
|
if (value === false || value === 'false') return { ok: true, coerced: false };
|
||||||
|
return { ok: false, error: 'must be true or false' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
integer: {
|
||||||
|
type: 'integer',
|
||||||
|
validate(value) {
|
||||||
|
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!INT_RE.test(str)) return { ok: false, error: 'must be a whole number' };
|
||||||
|
const n = parseInt(str, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return { ok: false, error: 'must be zero or a positive integer' };
|
||||||
|
return { ok: true, coerced: n };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hex_color: {
|
||||||
|
type: 'hex_color',
|
||||||
|
validate(value) {
|
||||||
|
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||||||
|
const str = String(value).trim();
|
||||||
|
const m = str.match(HEX_COLOR_RE);
|
||||||
|
if (!m) return { ok: false, error: 'must be a 6-digit hex color like 0xRRGGBB or #RRGGBB' };
|
||||||
|
return { ok: true, coerced: '0x' + m[1].toUpperCase() };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'url',
|
||||||
|
validate(value) {
|
||||||
|
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||||||
|
const str = String(value).trim();
|
||||||
|
try {
|
||||||
|
new URL(str);
|
||||||
|
return { ok: true, coerced: str };
|
||||||
|
} catch (_) {
|
||||||
|
return { ok: false, error: 'must be a valid URL (include the protocol)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: 'email',
|
||||||
|
validate(value) {
|
||||||
|
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!EMAIL_RE.test(str)) return { ok: false, error: 'must look like a valid email address' };
|
||||||
|
return { ok: true, coerced: str };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discord_id: {
|
||||||
|
type: 'discord_id',
|
||||||
|
validate(value) {
|
||||||
|
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!SNOWFLAKE_RE.test(str)) return { ok: false, error: 'must be a Discord ID (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
|
||||||
|
};
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory counter store with TTL windows for pattern detection.
|
|
||||||
* Windows: 'today' resets at midnight, 'week' resets Monday 00:00, 'month' resets 1st 00:00.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// store[window][namespace][key] = count
|
|
||||||
const store = {
|
|
||||||
today: new Map(),
|
|
||||||
week: new Map(),
|
|
||||||
month: new Map()
|
|
||||||
};
|
|
||||||
|
|
||||||
function getNamespaceMap(window, namespace) {
|
|
||||||
const windowMap = store[window];
|
|
||||||
if (!windowMap) return null;
|
|
||||||
if (!windowMap.has(namespace)) windowMap.set(namespace, new Map());
|
|
||||||
return windowMap.get(namespace);
|
|
||||||
}
|
|
||||||
|
|
||||||
function increment(namespace, key, window) {
|
|
||||||
const map = getNamespaceMap(window, namespace);
|
|
||||||
if (!map) return;
|
|
||||||
map.set(key, (map.get(key) || 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function get(namespace, key, window) {
|
|
||||||
const map = getNamespaceMap(window, namespace);
|
|
||||||
if (!map) return 0;
|
|
||||||
return map.get(key) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset(namespace, window) {
|
|
||||||
const windowMap = store[window];
|
|
||||||
if (!windowMap) return;
|
|
||||||
windowMap.delete(namespace);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAll(namespace, window) {
|
|
||||||
const map = getNamespaceMap(window, namespace);
|
|
||||||
if (!map) return new Map();
|
|
||||||
return new Map(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Scheduled resets ---
|
|
||||||
|
|
||||||
function msUntilNextMidnight() {
|
|
||||||
const now = new Date();
|
|
||||||
const next = new Date(now);
|
|
||||||
next.setHours(24, 0, 0, 0);
|
|
||||||
return next.getTime() - now.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function msUntilNextMonday() {
|
|
||||||
const now = new Date();
|
|
||||||
const day = now.getDay(); // 0=Sun
|
|
||||||
const daysUntilMonday = day === 0 ? 1 : (8 - day);
|
|
||||||
const next = new Date(now);
|
|
||||||
next.setDate(now.getDate() + daysUntilMonday);
|
|
||||||
next.setHours(0, 0, 0, 0);
|
|
||||||
return next.getTime() - now.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function msUntilNextMonth() {
|
|
||||||
const now = new Date();
|
|
||||||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
|
|
||||||
return next.getTime() - now.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callbacks to run on daily reset (e.g. clear firedToday in patternChecker)
|
|
||||||
const dailyResetCallbacks = [];
|
|
||||||
const weeklyResetCallbacks = [];
|
|
||||||
|
|
||||||
function onDailyReset(fn) {
|
|
||||||
dailyResetCallbacks.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWeeklyReset(fn) {
|
|
||||||
weeklyResetCallbacks.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Threshold firing state ---
|
|
||||||
// key -> Set<thresholdMs> that have fired within the key's window.
|
|
||||||
const firedThresholds = new Map();
|
|
||||||
// key -> window type used for threshold clearing ("today" | "week" | "month")
|
|
||||||
const firedThresholdWindows = new Map();
|
|
||||||
// key -> last-seen timestamp; drives periodic sweep for keys that outlive their window reset.
|
|
||||||
const firedThresholdLastSeen = new Map();
|
|
||||||
|
|
||||||
function clearFiredThresholdsForWindow(windowType) {
|
|
||||||
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
|
|
||||||
if (mappedWindowType === windowType) {
|
|
||||||
firedThresholds.delete(key);
|
|
||||||
firedThresholdWindows.delete(key);
|
|
||||||
firedThresholdLastSeen.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) {
|
|
||||||
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
|
|
||||||
if (!['today', 'week', 'month'].includes(windowType)) return null;
|
|
||||||
|
|
||||||
firedThresholdWindows.set(key, windowType);
|
|
||||||
firedThresholdLastSeen.set(key, Date.now());
|
|
||||||
|
|
||||||
const firedForKey = firedThresholds.get(key) || new Set();
|
|
||||||
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
|
|
||||||
|
|
||||||
let highestUnfiredCrossed = null;
|
|
||||||
for (const thresholdMs of sortedThresholds) {
|
|
||||||
if (ageMs >= thresholdMs && !firedForKey.has(thresholdMs)) {
|
|
||||||
highestUnfiredCrossed = thresholdMs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (highestUnfiredCrossed === null) return null;
|
|
||||||
|
|
||||||
firedForKey.add(highestUnfiredCrossed);
|
|
||||||
firedThresholds.set(key, firedForKey);
|
|
||||||
return highestUnfiredCrossed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Escalating cooldown state ---
|
|
||||||
// key -> { startedAtMs, lastFireAtMs, fireCount }
|
|
||||||
const escalatingCooldowns = new Map();
|
|
||||||
|
|
||||||
function shouldFireCooldownEscalating(key, thresholdsMs) {
|
|
||||||
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
|
|
||||||
|
|
||||||
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
|
|
||||||
const now = Date.now();
|
|
||||||
let state = escalatingCooldowns.get(key);
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now };
|
|
||||||
escalatingCooldowns.set(key, state);
|
|
||||||
}
|
|
||||||
state.lastUsed = now;
|
|
||||||
|
|
||||||
const nextThreshold = sortedThresholds[state.fireCount];
|
|
||||||
if (typeof nextThreshold !== 'number') return null;
|
|
||||||
|
|
||||||
const referenceMs = state.fireCount === 0 ? state.startedAtMs : state.lastFireAtMs;
|
|
||||||
if ((now - referenceMs) < nextThreshold) return null;
|
|
||||||
|
|
||||||
state.fireCount += 1;
|
|
||||||
state.lastFireAtMs = now;
|
|
||||||
return nextThreshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearEscalating(key) {
|
|
||||||
escalatingCooldowns.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
|
|
||||||
const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function cleanupStaleEscalatingCooldowns(now = Date.now()) {
|
|
||||||
const cutoff = now - SWEEP_TTL_MS;
|
|
||||||
for (const [key, state] of escalatingCooldowns.entries()) {
|
|
||||||
const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0;
|
|
||||||
if (lastUsed < cutoff) escalatingCooldowns.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweep every per-Map timestamp-bearing entry older than SWEEP_TTL_MS.
|
|
||||||
// firedThresholds/firedThresholdWindows are cleared by windowType-resets;
|
|
||||||
// this sweep covers keys whose window never resets under load.
|
|
||||||
function sweepPatternStore(now = Date.now()) {
|
|
||||||
const cutoff = now - SWEEP_TTL_MS;
|
|
||||||
for (const [key, ts] of cooldowns.entries()) {
|
|
||||||
if (ts < cutoff) cooldowns.delete(key);
|
|
||||||
}
|
|
||||||
for (const [key, ts] of staffLastSeen.entries()) {
|
|
||||||
if (ts < cutoff) staffLastSeen.delete(key);
|
|
||||||
}
|
|
||||||
cleanupStaleEscalatingCooldowns(now);
|
|
||||||
for (const [key, ts] of firedThresholdLastSeen.entries()) {
|
|
||||||
if (ts < cutoff) {
|
|
||||||
firedThresholds.delete(key);
|
|
||||||
firedThresholdWindows.delete(key);
|
|
||||||
firedThresholdLastSeen.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the module's sweep on the given trackInterval function.
|
|
||||||
* Called once from the ready handler. Interval is unref'd so it never
|
|
||||||
* blocks shutdown; trackInterval ensures handleShutdown clears it.
|
|
||||||
*/
|
|
||||||
function startSweeps(trackInterval) {
|
|
||||||
const handle = setInterval(() => sweepPatternStore(), SWEEP_INTERVAL_MS);
|
|
||||||
if (typeof handle.unref === 'function') handle.unref();
|
|
||||||
if (typeof trackInterval === 'function') trackInterval(handle);
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleDailyReset() {
|
|
||||||
setTimeout(() => {
|
|
||||||
store.today = new Map();
|
|
||||||
clearFiredThresholdsForWindow('today');
|
|
||||||
for (const fn of dailyResetCallbacks) {
|
|
||||||
try { fn(); } catch (_) {}
|
|
||||||
}
|
|
||||||
scheduleDailyReset();
|
|
||||||
}, msUntilNextMidnight());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleWeeklyReset() {
|
|
||||||
setTimeout(() => {
|
|
||||||
store.week = new Map();
|
|
||||||
clearFiredThresholdsForWindow('week');
|
|
||||||
for (const fn of weeklyResetCallbacks) {
|
|
||||||
try { fn(); } catch (_) {}
|
|
||||||
}
|
|
||||||
scheduleWeeklyReset();
|
|
||||||
}, msUntilNextMonday());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleMonthlyReset() {
|
|
||||||
setTimeout(() => {
|
|
||||||
store.month = new Map();
|
|
||||||
clearFiredThresholdsForWindow('month');
|
|
||||||
scheduleMonthlyReset();
|
|
||||||
}, msUntilNextMonth());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleResets() {
|
|
||||||
scheduleDailyReset();
|
|
||||||
scheduleWeeklyReset();
|
|
||||||
scheduleMonthlyReset();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Cooldown store ---
|
|
||||||
const cooldowns = new Map();
|
|
||||||
|
|
||||||
function setCooldown(key) {
|
|
||||||
cooldowns.set(key, Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOnCooldown(key, cooldownMinutes) {
|
|
||||||
const last = cooldowns.get(key);
|
|
||||||
if (!last) return false;
|
|
||||||
return (Date.now() - last) < cooldownMinutes * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Staff last-seen tracker (fallback for missing presence intent) ---
|
|
||||||
const staffLastSeen = new Map();
|
|
||||||
|
|
||||||
function updateStaffLastSeen(staffId) {
|
|
||||||
staffLastSeen.set(staffId, Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStaffLastSeen(staffId) {
|
|
||||||
return staffLastSeen.get(staffId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStaffRecentlyActive(staffId, withinMinutes = 60) {
|
|
||||||
const last = staffLastSeen.get(staffId);
|
|
||||||
if (!last) return false;
|
|
||||||
return (Date.now() - last) < withinMinutes * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
increment,
|
|
||||||
get,
|
|
||||||
reset,
|
|
||||||
getAll,
|
|
||||||
scheduleResets,
|
|
||||||
onDailyReset,
|
|
||||||
onWeeklyReset,
|
|
||||||
firedThresholds,
|
|
||||||
shouldFireThreshold,
|
|
||||||
shouldFireCooldownEscalating,
|
|
||||||
clearEscalating,
|
|
||||||
setCooldown,
|
|
||||||
isOnCooldown,
|
|
||||||
updateStaffLastSeen,
|
|
||||||
getStaffLastSeen,
|
|
||||||
isStaffRecentlyActive,
|
|
||||||
startSweeps,
|
|
||||||
sweepPatternStore,
|
|
||||||
// test-only exports
|
|
||||||
_internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS }
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 }
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
675
services/tickets.js.bak-20260421
Normal file
675
services/tickets.js.bak-20260421
Normal file
@@ -0,0 +1,675 @@
|
|||||||
|
/**
|
||||||
|
* Ticket database helpers – counters, rename, limits, auto-close,
|
||||||
|
* reminders, auto-unclaim, channel creation.
|
||||||
|
*/
|
||||||
|
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||||
|
const { mongoose, withRetry } = require('../db-connection');
|
||||||
|
const { CONFIG } = require('../config');
|
||||||
|
const { getPriorityEmoji } = require('../utils');
|
||||||
|
const { logAutomation } = require('../services/debugLog');
|
||||||
|
const { enqueueSend, enqueueDelete } = require('./channelQueue');
|
||||||
|
|
||||||
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
const TicketCounter = mongoose.model('TicketCounter');
|
||||||
|
|
||||||
|
// --- TICKET NUMBER ---
|
||||||
|
|
||||||
|
async function getNextTicketNumber(senderEmail) {
|
||||||
|
const senderLocal = senderEmail.split('@')[0].toLowerCase();
|
||||||
|
const counter = await TicketCounter.findOneAndUpdate(
|
||||||
|
{ senderLocal },
|
||||||
|
{ $inc: { counter: 1 } },
|
||||||
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
|
);
|
||||||
|
return { local: senderLocal, number: counter.counter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RENAME + NAMING ---
|
||||||
|
// Renames flow through utils/renamer.js (RENAMER_BOT secondary token),
|
||||||
|
// which has its own Discord rate-limit bucket. We no longer gate on the
|
||||||
|
// primary bot's 2/10min per-channel budget here; 429s from the secondary
|
||||||
|
// bot surface via utils/renamer.js instead.
|
||||||
|
|
||||||
|
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
|
||||||
|
const RENAME_LIMIT = 2;
|
||||||
|
|
||||||
|
function getSenderLocal(senderEmail) {
|
||||||
|
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDiscordSafeName(str) {
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^\p{L}\p{N}\p{Emoji_Presentation}-]/gu, '')
|
||||||
|
.replace(/-{2,}/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a human-friendly creator nickname for channel naming.
|
||||||
|
* Discord tickets: guild member displayName. Email tickets: senderLocal.
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {object} ticket
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function resolveCreatorNickname(guild, ticket) {
|
||||||
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||||
|
const creatorUserId = ticket.gmailThreadId.split('-').pop();
|
||||||
|
try {
|
||||||
|
const member = await guild.members.fetch(creatorUserId);
|
||||||
|
return member.displayName;
|
||||||
|
} catch {
|
||||||
|
return getSenderLocal(ticket.senderEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getSenderLocal(ticket.senderEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a channel name from ticket state.
|
||||||
|
* @param {'unclaimed'|'claimed'|'escalated'|'escalated-claimed'} state
|
||||||
|
* @param {object} ticket
|
||||||
|
* @param {string} creatorNickname - pre-resolved via resolveCreatorNickname
|
||||||
|
* @param {string} [claimerEmoji] - required for claimed / escalated-claimed
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
|
||||||
|
const num = ticket.ticketNumber || 1;
|
||||||
|
switch (state) {
|
||||||
|
case 'claimed':
|
||||||
|
return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
|
||||||
|
case 'escalated':
|
||||||
|
return toDiscordSafeName(`escalated-${creatorNickname}-${num}`);
|
||||||
|
case 'escalated-claimed':
|
||||||
|
return toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`);
|
||||||
|
case 'unclaimed':
|
||||||
|
default:
|
||||||
|
return toDiscordSafeName(`unclaimed-${creatorNickname}-${num}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retained for external callers (bOSScord, scripts). The gate now lives in
|
||||||
|
// the secondary bot's rate bucket; this helper no longer touches Mongo.
|
||||||
|
async function canRename(_ticket) {
|
||||||
|
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesFromMs(ms) {
|
||||||
|
return Math.max(1, Math.ceil(ms / 60000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RATE LIMIT (per-user ticket creation) ---
|
||||||
|
|
||||||
|
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
|
||||||
|
|
||||||
|
const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
|
||||||
|
const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function sweepTicketCreationByUser(now = Date.now()) {
|
||||||
|
// An entry is stale when its window has been expired long enough that no
|
||||||
|
// legitimate rate-limit decision would still consult it. resetAt is a future
|
||||||
|
// ms timestamp when the window ends; cutoff is 48h past that.
|
||||||
|
const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS;
|
||||||
|
for (const [key, entry] of ticketCreationByUser.entries()) {
|
||||||
|
if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTicketsSweeps(trackInterval) {
|
||||||
|
const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS);
|
||||||
|
if (typeof handle.unref === 'function') handle.unref();
|
||||||
|
if (typeof trackInterval === 'function') trackInterval(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user can create a ticket (rate limit). If allowed, consumes one slot.
|
||||||
|
* @param {string} userId - Discord user ID
|
||||||
|
* @returns {{ allowed: boolean, retryAfterMs?: number }}
|
||||||
|
*/
|
||||||
|
function checkTicketCreationRateLimit(userId) {
|
||||||
|
const limit = CONFIG.RATE_LIMIT_TICKETS_PER_USER;
|
||||||
|
const windowMs = (CONFIG.RATE_LIMIT_WINDOW_MINUTES || 60) * 60 * 1000;
|
||||||
|
if (!limit || limit <= 0) return { allowed: true };
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = ticketCreationByUser.get(userId);
|
||||||
|
if (!entry || now >= entry.resetAt) {
|
||||||
|
entry = { count: 1, resetAt: now + windowMs };
|
||||||
|
ticketCreationByUser.set(userId, entry);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
if (entry.count >= limit) {
|
||||||
|
return { allowed: false, retryAfterMs: entry.resetAt - now };
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CHANNEL CREATION (overflow: Discord limit 50 channels per category) ---
|
||||||
|
|
||||||
|
const CHANNELS_PER_CATEGORY_LIMIT = 50;
|
||||||
|
|
||||||
|
function escapeCategoryNameForRegex(name) {
|
||||||
|
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getOrCreateTicketCategory instead.
|
||||||
|
* @returns {null}
|
||||||
|
*/
|
||||||
|
function pickTicketCategoryId(guild, categoryIds) {
|
||||||
|
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countChannelsInCategory(guild, categoryId) {
|
||||||
|
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve or create a ticket category with dynamic overflow (Discord max 50 channels per category).
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {string} primaryCategoryId
|
||||||
|
* @param {string} categoryName Display base name (primary category should match; overflows are "(Overflow N)")
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function getOrCreateTicketCategory(guild, primaryCategoryId, categoryName) {
|
||||||
|
if (!guild) {
|
||||||
|
throw new Error('getOrCreateTicketCategory: guild is required');
|
||||||
|
}
|
||||||
|
if (!primaryCategoryId || !String(primaryCategoryId).trim()) {
|
||||||
|
throw new Error('getOrCreateTicketCategory: primaryCategoryId is required');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let primary = guild.channels.cache.get(primaryCategoryId);
|
||||||
|
if (!primary) {
|
||||||
|
primary = await guild.channels.fetch(primaryCategoryId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!primary || primary.type !== ChannelType.GuildCategory) {
|
||||||
|
throw new Error(`getOrCreateTicketCategory: primary category ${primaryCategoryId} not found or not a category`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = escapeCategoryNameForRegex(categoryName);
|
||||||
|
const overflowRe = new RegExp(`^${escaped} \\(Overflow (\\d+)\\)$`);
|
||||||
|
|
||||||
|
const overflowMatches = [];
|
||||||
|
for (const ch of guild.channels.cache.values()) {
|
||||||
|
if (!ch || ch.type !== ChannelType.GuildCategory) continue;
|
||||||
|
if (ch.id === primaryCategoryId) continue;
|
||||||
|
const m = ch.name.match(overflowRe);
|
||||||
|
if (m) overflowMatches.push({ ch, n: parseInt(m[1], 10) });
|
||||||
|
}
|
||||||
|
overflowMatches.sort((a, b) => a.n - b.n);
|
||||||
|
|
||||||
|
const existingCategories = [primary, ...overflowMatches.map(x => x.ch)];
|
||||||
|
|
||||||
|
for (const cat of existingCategories) {
|
||||||
|
if (countChannelsInCategory(guild, cat.id) < CHANNELS_PER_CATEGORY_LIMIT) {
|
||||||
|
return cat.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highestN = overflowMatches.length > 0 ? Math.max(...overflowMatches.map(x => x.n)) : 0;
|
||||||
|
const nextN = highestN + 1;
|
||||||
|
const newName = `${categoryName} (Overflow ${nextN})`;
|
||||||
|
const lastCat = existingCategories[existingCategories.length - 1];
|
||||||
|
const position = (lastCat?.rawPosition ?? lastCat?.position ?? 0) + 1;
|
||||||
|
|
||||||
|
let newCat;
|
||||||
|
try {
|
||||||
|
newCat = await guild.channels.create({
|
||||||
|
name: newName,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
position
|
||||||
|
});
|
||||||
|
} catch (createErr) {
|
||||||
|
console.error('getOrCreateTicketCategory: failed to create overflow category:', createErr);
|
||||||
|
throw createErr;
|
||||||
|
}
|
||||||
|
return newCat.id;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('getOrCreateTicketCategory:', err);
|
||||||
|
const fallback = guild.channels.cache.get(primaryCategoryId);
|
||||||
|
if (fallback?.type === ChannelType.GuildCategory) {
|
||||||
|
return primaryCategoryId;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an overflow category if it is empty and its name matches "${categoryName} (Overflow N)".
|
||||||
|
* Never deletes the primary category (exact name match).
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {string} categoryId
|
||||||
|
* @param {string} categoryName
|
||||||
|
*/
|
||||||
|
async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
|
||||||
|
try {
|
||||||
|
if (!guild || !categoryId) return;
|
||||||
|
const cached = guild.channels.cache.filter(c => c.parentId === categoryId);
|
||||||
|
if (cached.size !== 0) return;
|
||||||
|
|
||||||
|
let cat = guild.channels.cache.get(categoryId);
|
||||||
|
if (!cat) {
|
||||||
|
cat = await guild.channels.fetch(categoryId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!cat || cat.type !== ChannelType.GuildCategory) return;
|
||||||
|
if (cat.name === categoryName) return;
|
||||||
|
|
||||||
|
const escaped = escapeCategoryNameForRegex(categoryName);
|
||||||
|
const overflowRe = new RegExp(`^${escaped} \\(Overflow \\d+\\)$`);
|
||||||
|
if (!overflowRe.test(cat.name)) return;
|
||||||
|
|
||||||
|
await cat.delete().catch(deleteErr => {
|
||||||
|
console.error('cleanupEmptyOverflowCategory: delete failed:', deleteErr);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('cleanupEmptyOverflowCategory:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
||||||
|
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
||||||
|
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
||||||
|
if (!parentChannel) {
|
||||||
|
throw new Error('Thread parent channel not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = await parentChannel.threads.create({
|
||||||
|
name: `🎫・ticket-${ticketNumber}`,
|
||||||
|
autoArchiveDuration: 1440,
|
||||||
|
type: ChannelType.PrivateThread,
|
||||||
|
invitable: false,
|
||||||
|
reason: `Ticket #${ticketNumber}`
|
||||||
|
});
|
||||||
|
|
||||||
|
await thread.members.add(userId);
|
||||||
|
// Add all members with the support role so they can see and reply in the thread
|
||||||
|
if (CONFIG.ROLE_ID_TO_PING) {
|
||||||
|
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||||
|
if (role?.members?.size) {
|
||||||
|
for (const [memberId] of role.members) {
|
||||||
|
if (memberId === userId) continue; // already added
|
||||||
|
await thread.members.add(memberId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return thread;
|
||||||
|
} else {
|
||||||
|
let parentId;
|
||||||
|
try {
|
||||||
|
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||||||
|
throw new Error('Ticket category not found or could not be allocated');
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel;
|
||||||
|
try {
|
||||||
|
channel = await guild.channels.create({
|
||||||
|
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: parentId,
|
||||||
|
permissionOverwrites: [
|
||||||
|
{
|
||||||
|
id: guild.id,
|
||||||
|
deny: [PermissionFlagsBits.ViewChannel]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
allow: [
|
||||||
|
PermissionFlagsBits.ViewChannel,
|
||||||
|
PermissionFlagsBits.SendMessages,
|
||||||
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CONFIG.ROLE_ID_TO_PING,
|
||||||
|
allow: [
|
||||||
|
PermissionFlagsBits.ViewChannel,
|
||||||
|
PermissionFlagsBits.SendMessages,
|
||||||
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('guild.channels.create (createTicketChannel):', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
|
||||||
|
* Adds creator and all members with ROLE_ID_TO_PING.
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {number} ticketNumber
|
||||||
|
* @param {string} creatorUserId
|
||||||
|
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||||
|
*/
|
||||||
|
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
|
||||||
|
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
|
||||||
|
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
|
||||||
|
const parentChannel = guild.channels.cache.get(parentId);
|
||||||
|
if (!parentChannel) throw new Error('Discord thread parent channel not found');
|
||||||
|
|
||||||
|
const thread = await parentChannel.threads.create({
|
||||||
|
name: `🎫・ticket-${ticketNumber}`,
|
||||||
|
autoArchiveDuration: 1440,
|
||||||
|
type: ChannelType.PrivateThread,
|
||||||
|
invitable: false,
|
||||||
|
reason: `Ticket #${ticketNumber}`
|
||||||
|
});
|
||||||
|
|
||||||
|
await thread.members.add(creatorUserId);
|
||||||
|
if (CONFIG.ROLE_ID_TO_PING) {
|
||||||
|
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||||
|
if (role?.members?.size) {
|
||||||
|
for (const [memberId] of role.members) {
|
||||||
|
if (memberId === creatorUserId) continue;
|
||||||
|
await thread.members.add(memberId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
|
||||||
|
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {number} ticketNumber
|
||||||
|
* @param {string} chanName
|
||||||
|
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||||
|
*/
|
||||||
|
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
|
||||||
|
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
|
||||||
|
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
|
||||||
|
const parentChannel = guild.channels.cache.get(parentId);
|
||||||
|
if (!parentChannel) throw new Error('Email thread parent channel not found');
|
||||||
|
|
||||||
|
const thread = await parentChannel.threads.create({
|
||||||
|
name: chanName || `🎫・ticket-${ticketNumber}`,
|
||||||
|
autoArchiveDuration: 1440,
|
||||||
|
type: ChannelType.PrivateThread,
|
||||||
|
invitable: false,
|
||||||
|
reason: `Ticket #${ticketNumber}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (CONFIG.ROLE_ID_TO_PING) {
|
||||||
|
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||||
|
if (role?.members?.size) {
|
||||||
|
for (const [memberId] of role.members) {
|
||||||
|
await thread.members.add(memberId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LIMITS & PERMISSIONS ---
|
||||||
|
|
||||||
|
async function checkTicketLimits(senderEmail) {
|
||||||
|
if (!CONFIG.GLOBAL_TICKET_LIMIT) return { ok: true };
|
||||||
|
|
||||||
|
const currentCount = await Ticket.countDocuments({ senderEmail, status: 'open' });
|
||||||
|
if (currentCount >= CONFIG.GLOBAL_TICKET_LIMIT) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `You have reached the maximum limit of ${CONFIG.GLOBAL_TICKET_LIMIT} open tickets.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBlacklistedRole(member) {
|
||||||
|
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return member.roles.cache.some(role =>
|
||||||
|
CONFIG.BLACKLISTED_ROLES.includes(role.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTIVITY ---
|
||||||
|
|
||||||
|
async function updateTicketActivity(gmailThreadId) {
|
||||||
|
const now = new Date();
|
||||||
|
await Ticket.updateOne(
|
||||||
|
{ gmailThreadId },
|
||||||
|
{ $set: { lastActivity: now, reminderSent: false } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SCHEDULED CHECKS ---
|
||||||
|
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
|
||||||
|
|
||||||
|
async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||||
|
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
|
||||||
|
|
||||||
|
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
|
||||||
|
// Bounded per-tick so a huge backlog drains across successive hourly runs.
|
||||||
|
const staleTickets = await withRetry(() => Ticket.find({
|
||||||
|
status: 'open',
|
||||||
|
lastActivity: { $lt: cutoffTime, $ne: null }
|
||||||
|
}).sort({ createdAt: 1 }).limit(500).lean());
|
||||||
|
|
||||||
|
let checked = 0, closed = 0;
|
||||||
|
for (const ticket of staleTickets) {
|
||||||
|
checked++;
|
||||||
|
try {
|
||||||
|
const guild = client.guilds.cache.first();
|
||||||
|
if (!guild) continue;
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||||
|
if (channel) {
|
||||||
|
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
||||||
|
|
||||||
|
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
|
||||||
|
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
|
||||||
|
// resolves; if the doc is gone the unset is a no-op.
|
||||||
|
await withRetry(() => Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $set: { status: 'closed', pendingDelete: true } }
|
||||||
|
));
|
||||||
|
|
||||||
|
await sendTicketClosedEmail(ticket, 'Auto-Close System');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
enqueueDelete(channel).then(() => {
|
||||||
|
withRetry(() => Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $unset: { pendingDelete: '' } }
|
||||||
|
)).catch(() => {});
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
closed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkReminders(client) {
|
||||||
|
if (!CONFIG.REMINDER_ENABLED) return;
|
||||||
|
|
||||||
|
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
|
||||||
|
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
|
||||||
|
status: 'open',
|
||||||
|
lastActivity: { $lt: reminderTime, $ne: null },
|
||||||
|
reminderSent: false
|
||||||
|
}).lean());
|
||||||
|
|
||||||
|
let checked = 0, reminded = 0;
|
||||||
|
for (const ticket of ticketsNeedingReminder) {
|
||||||
|
checked++;
|
||||||
|
try {
|
||||||
|
const guild = client.guilds.cache.first();
|
||||||
|
if (!guild) continue;
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||||
|
if (channel) {
|
||||||
|
const ping = ticket.claimedBy
|
||||||
|
? `<@${ticket.claimedBy}>`
|
||||||
|
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
|
||||||
|
const message = CONFIG.REMINDER_MESSAGE
|
||||||
|
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
|
||||||
|
.replace(/\{ping\}/g, ping);
|
||||||
|
await enqueueSend(channel, message);
|
||||||
|
|
||||||
|
await withRetry(() => Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $set: { reminderSent: true } }
|
||||||
|
));
|
||||||
|
reminded++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAutoUnclaim(client) {
|
||||||
|
if (!CONFIG.AUTO_UNCLAIM_ENABLED) return;
|
||||||
|
|
||||||
|
const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000));
|
||||||
|
const staleClaimedTickets = await withRetry(() => Ticket.find({
|
||||||
|
status: 'open',
|
||||||
|
claimedBy: { $ne: null },
|
||||||
|
lastActivity: { $lt: unclaimTime, $ne: null }
|
||||||
|
}).lean());
|
||||||
|
|
||||||
|
let checked = 0, unclaimed = 0;
|
||||||
|
for (const ticket of staleClaimedTickets) {
|
||||||
|
checked++;
|
||||||
|
try {
|
||||||
|
const guild = client.guilds.cache.first();
|
||||||
|
if (!guild) continue;
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||||
|
if (channel) {
|
||||||
|
await withRetry(() => Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $set: { claimedBy: null } }
|
||||||
|
));
|
||||||
|
|
||||||
|
await enqueueSend(channel,
|
||||||
|
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
|
||||||
|
unclaimed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileDeletedTicketChannels(client) {
|
||||||
|
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
|
||||||
|
if (!guild) return { checked: 0, reconciled: 0 };
|
||||||
|
|
||||||
|
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
|
||||||
|
const openTickets = await Ticket.find({
|
||||||
|
status: 'open',
|
||||||
|
discordThreadId: { $ne: null }
|
||||||
|
}).sort({ createdAt: 1 }).limit(500).lean();
|
||||||
|
|
||||||
|
let checked = 0, reconciled = 0;
|
||||||
|
for (const ticket of openTickets) {
|
||||||
|
checked++;
|
||||||
|
try {
|
||||||
|
let channel = guild.channels.cache.get(ticket.discordThreadId);
|
||||||
|
if (!channel) {
|
||||||
|
channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!channel) {
|
||||||
|
await Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $set: { status: 'closed', discordThreadId: null } }
|
||||||
|
);
|
||||||
|
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
|
||||||
|
reconciled++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reconciled > 0) {
|
||||||
|
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
|
||||||
|
}
|
||||||
|
return { checked, reconciled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume deletes that were pending when the bot last shut down. Called once
|
||||||
|
* from the ready handler. Clears the flag regardless of fetch result so a
|
||||||
|
* stale flag (e.g. channel already gone) can't loop.
|
||||||
|
*/
|
||||||
|
async function resumePendingDeletes(client) {
|
||||||
|
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
|
||||||
|
if (!pending.length) return 0;
|
||||||
|
let resumed = 0;
|
||||||
|
for (const ticket of pending) {
|
||||||
|
try {
|
||||||
|
const guild = client.guilds.cache.first();
|
||||||
|
if (guild && ticket.discordThreadId) {
|
||||||
|
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||||
|
if (channel) {
|
||||||
|
enqueueDelete(channel).catch(() => {});
|
||||||
|
resumed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $unset: { pendingDelete: '' } }
|
||||||
|
).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('resumePendingDeletes error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
|
||||||
|
return resumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getNextTicketNumber,
|
||||||
|
getOrCreateTicketCategory,
|
||||||
|
cleanupEmptyOverflowCategory,
|
||||||
|
createDiscordTicketAsThread,
|
||||||
|
createEmailTicketAsThread,
|
||||||
|
RENAME_WINDOW_MS,
|
||||||
|
RENAME_LIMIT,
|
||||||
|
getSenderLocal,
|
||||||
|
toDiscordSafeName,
|
||||||
|
resolveCreatorNickname,
|
||||||
|
makeTicketName,
|
||||||
|
canRename,
|
||||||
|
minutesFromMs,
|
||||||
|
checkTicketCreationRateLimit,
|
||||||
|
createTicketChannel,
|
||||||
|
checkTicketLimits,
|
||||||
|
hasBlacklistedRole,
|
||||||
|
updateTicketActivity,
|
||||||
|
checkAutoClose,
|
||||||
|
checkReminders,
|
||||||
|
checkAutoUnclaim,
|
||||||
|
reconcileDeletedTicketChannels,
|
||||||
|
resumePendingDeletes,
|
||||||
|
startTicketsSweeps,
|
||||||
|
sweepTicketCreationByUser,
|
||||||
|
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
|
||||||
|
};
|
||||||
10
utils.js
10
utils.js
@@ -3,7 +3,7 @@
|
|||||||
* priority helpers, template variables.
|
* priority helpers, template variables.
|
||||||
*/
|
*/
|
||||||
const crypto = require('crypto');
|
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. */
|
/** Constant-time string compare. Returns false for mismatched length or empty/nullish inputs without throwing. */
|
||||||
function safeEqual(a, b) {
|
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 ---
|
// --- TEMPLATE VARIABLES ---
|
||||||
|
|
||||||
function replaceVariables(template, context = {}) {
|
function replaceVariables(template, context = {}) {
|
||||||
@@ -407,6 +400,5 @@ module.exports = {
|
|||||||
detectGame,
|
detectGame,
|
||||||
getPriorityEmoji,
|
getPriorityEmoji,
|
||||||
getPriorityColor,
|
getPriorityColor,
|
||||||
getTicketTagEmoji,
|
|
||||||
replaceVariables
|
replaceVariables
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user