Compare commits
6 Commits
pre-mvp-st
...
ca737039f8
| Author | SHA1 | Date | |
|---|---|---|---|
| ca737039f8 | |||
| bf901039bc | |||
| 071fae2ea3 | |||
| 3300a7fc19 | |||
| 1a46fb696a | |||
| 636348d824 |
@@ -45,7 +45,9 @@
|
||||
"Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)",
|
||||
"Bash(git log *)",
|
||||
"Bash(curl *)",
|
||||
"Bash(docker inspect *)"
|
||||
"Bash(docker inspect *)",
|
||||
"Bash(git -C /opt/broccolini-bot tag)",
|
||||
"Bash(git -C /opt/broccolini-bot branch)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
54
.env.example
54
.env.example
@@ -35,7 +35,6 @@ TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
|
||||
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
|
||||
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
|
||||
BACKUP_EXPORT_CHANNEL_ID= # Channel where /backup and /export post .txt files; optional
|
||||
ACCOUNT_INFO_CHANNEL_ID= # Channel for account info lookups; optional
|
||||
DISCORD_CHANNEL_ID= # General Discord channel (if used)
|
||||
|
||||
# --- Discord: Ticket copy & buttons ---
|
||||
@@ -59,11 +58,6 @@ NGROK_URL= # Public URL (optional); run ngrok outside thi
|
||||
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
|
||||
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
|
||||
|
||||
# --- bOSScord (support cockpit) ---
|
||||
# Set BOSSCORD_API_KEY to enable /api (ticket list, thread, send message). Use same key in bOSScord app login.
|
||||
# BOSSCORD_API_KEY= # e.g. from: openssl rand -hex 32
|
||||
# BOSSCORD_CORS_ORIGIN=* # Optional; default * (set to bOSScord origin in production)
|
||||
|
||||
# --- Database ---
|
||||
MONGODB_URI= # MongoDB connection string (e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db)
|
||||
# MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently)
|
||||
@@ -112,16 +106,10 @@ PRIORITY_MEDIUM_EMOJI=🟡
|
||||
PRIORITY_LOW_EMOJI=🟢
|
||||
|
||||
# --- Claiming ---
|
||||
CLAIM_TIMEOUT_ENABLED=false
|
||||
CLAIM_TIMEOUT_HOURS=48
|
||||
AUTO_UNCLAIM_ENABLED=false
|
||||
AUTO_UNCLAIM_AFTER_HOURS=24
|
||||
ALLOW_CLAIM_OVERWRITE=false
|
||||
STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated
|
||||
CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry in STAFF_EMOJIS
|
||||
ADMIN_ID= # Discord user ID of the bot admin (for /staffnotification)
|
||||
STAFF_NOTIFICATION_CATEGORY_ID= # Category for staff notification channels (created by /notification add)
|
||||
UNCLAIMED_REMINDER_THRESHOLDS=1,2,4 # Comma-separated hour thresholds for unclaimed ticket alerts
|
||||
ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs)
|
||||
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
|
||||
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
|
||||
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
|
||||
@@ -130,46 +118,6 @@ RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue log
|
||||
SECURITY_LOG_CHANNEL_ID= # Channel for security/audit logs
|
||||
SYSTEM_LOG_CHANNEL_ID= # Channel for bot lifecycle logs (startup, shutdown, DB events)
|
||||
|
||||
# --- Pattern detection ---
|
||||
USER_PATTERNS_CHANNEL_ID= # Channel for repeat-user pattern alerts
|
||||
GAME_PATTERNS_CHANNEL_ID= # Channel for game-specific pattern alerts
|
||||
TAG_PATTERNS_CHANNEL_ID= # Channel for ticket tag pattern alerts
|
||||
ESCALATION_PATTERNS_CHANNEL_ID= # Channel for escalation pattern alerts
|
||||
STAFF_PATTERNS_CHANNEL_ID= # Channel for staff workload pattern alerts
|
||||
COMBINED_PATTERNS_CHANNEL_ID= # Channel for combined/cross-cutting pattern alerts
|
||||
PATTERN_USER_TICKET_THRESHOLD=3 # Tickets per user before alerting
|
||||
PATTERN_GAME_TICKET_THRESHOLD=10 # Tickets per game before alerting
|
||||
PATTERN_STAFF_STALE_PING_THRESHOLD=5 # Stale pings before alerting
|
||||
PATTERN_ESCALATION_THRESHOLD=3 # Escalations before alerting
|
||||
PATTERN_RAPID_CLOSE_SECONDS=120 # Seconds; closes faster than this are flagged
|
||||
PATTERN_UNCLAIMED_HOURS=4 # Hours unclaimed before flagging
|
||||
PATTERN_CHECK_INTERVAL_MINUTES=30 # Minutes between pattern check runs
|
||||
|
||||
# --- Surge & chat alerts ---
|
||||
ALL_STAFF_CHANNEL_ID= # Channel for staff surge alerts
|
||||
ALL_STAFF_CHAT_ALERT_CHANNEL_ID= # Channel for chat monitoring alerts
|
||||
SURGE_ROLE_ID= # Role to ping on surge alerts
|
||||
SURGE_TICKET_COUNT=10 # Ticket count to trigger surge
|
||||
SURGE_TICKET_WINDOW_MINUTES=30 # Window for ticket surge
|
||||
SURGE_GAME_TICKET_COUNT=5 # Per-game ticket count for surge
|
||||
SURGE_GAME_TICKET_WINDOW_MINUTES=30 # Window for game surge
|
||||
SURGE_STALE_COUNT=8 # Stale tickets to trigger alert
|
||||
SURGE_STALE_HOURS=2 # Hours before ticket is stale
|
||||
SURGE_NEEDS_RESPONSE_COUNT=5 # Tickets awaiting response to trigger alert
|
||||
SURGE_NEEDS_RESPONSE_HOURS=1 # Hours awaiting response
|
||||
SURGE_UNCLAIMED_COUNT=5 # Unclaimed tickets for surge alert
|
||||
SURGE_UNCLAIMED_MINUTES=30 # Minutes unclaimed before counting
|
||||
SURGE_TIER3_UNCLAIMED_MINUTES=15 # Minutes before tier 3 unclaimed alert
|
||||
SURGE_COOLDOWN_MINUTES=60 # Cooldown between surge alerts
|
||||
CHAT_ALERT_CHANNEL_IDS= # Comma-separated channel IDs to monitor
|
||||
CHAT_ALERT_MESSAGE_COUNT=5 # Unresponded messages to trigger alert
|
||||
CHAT_ALERT_HOURS_WITHOUT_RESPONSE=2 # Hours without staff response to alert
|
||||
CHAT_ALERT_COOLDOWN_MINUTES=60 # Cooldown between chat alerts
|
||||
STAFF_IDS= # Comma-separated Discord user IDs of all staff members
|
||||
SURGE_NO_STAFF_COOLDOWN_MINUTES=30 # Cooldown between zero-staff alerts
|
||||
SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD=3 # Min open tickets before alerting
|
||||
STAFF_DND_COUNTS_AS_AVAILABLE=false # Whether DND status counts as available
|
||||
|
||||
# --- Staff threads ---
|
||||
STAFF_THREAD_ENABLED=false # Create a private staff thread on each ticket channel
|
||||
STAFF_THREAD_NAME=Staff Discussion # Name of the private thread
|
||||
|
||||
@@ -36,7 +36,6 @@ TRANSCRIPT_CHANNEL_ID=
|
||||
LOGGING_CHANNEL_ID=
|
||||
DEBUGGING_CHANNEL_ID=
|
||||
BACKUP_EXPORT_CHANNEL_ID=
|
||||
ACCOUNT_INFO_CHANNEL_ID=
|
||||
DISCORD_CHANNEL_ID=
|
||||
|
||||
# --- Discord: Ticket copy & buttons ---
|
||||
@@ -59,10 +58,6 @@ MY_EMAIL=
|
||||
DISCORD_ONLY_PORT=5000
|
||||
# HEALTHCHECK_HOST=
|
||||
|
||||
# --- bOSScord (support cockpit) ---
|
||||
# BOSSCORD_API_KEY=
|
||||
# BOSSCORD_CORS_ORIGIN=*
|
||||
|
||||
# --- Database (test cluster or local) ---
|
||||
MONGODB_URI= # e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db_test?authSource=broccoli_db_test
|
||||
# MONGODB_DATABASE=
|
||||
@@ -107,8 +102,6 @@ PRIORITY_MEDIUM_EMOJI=🟡
|
||||
PRIORITY_LOW_EMOJI=🟢
|
||||
|
||||
# --- Claiming ---
|
||||
CLAIM_TIMEOUT_ENABLED=false
|
||||
CLAIM_TIMEOUT_HOURS=48
|
||||
AUTO_UNCLAIM_ENABLED=false
|
||||
AUTO_UNCLAIM_AFTER_HOURS=24
|
||||
ALLOW_CLAIM_OVERWRITE=false
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -49,4 +49,8 @@ cursor.yml
|
||||
*.local.yml
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
*.bak
|
||||
*.bak-*
|
||||
|
||||
*.bak
|
||||
*.bak-*
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# You can override the included template(s) by including variable overrides
|
||||
# SAST customization: https://docs.gitlab.com/user/application_security/sast/#available-cicd-variables
|
||||
# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure/
|
||||
# Dependency Scanning customization: https://docs.gitlab.com/user/application_security/dependency_scanning/#customizing-analyzer-behavior
|
||||
# Container Scanning customization: https://docs.gitlab.com/user/application_security/container_scanning/#customizing-analyzer-behavior
|
||||
# Note that environment variables can be set in several places
|
||||
# See https://docs.gitlab.com/ci/variables/#cicd-variable-precedence
|
||||
stages:
|
||||
- test
|
||||
- secret-detection
|
||||
sast:
|
||||
stage: test
|
||||
include:
|
||||
- template: Security/SAST.gitlab-ci.yml
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
variables:
|
||||
SECRET_DETECTION_ENABLED: 'true'
|
||||
secret_detection:
|
||||
stage: secret-detection
|
||||
87
FEATURES.md
87
FEATURES.md
@@ -1,87 +0,0 @@
|
||||
## Broccolini Bot – Feature Overview
|
||||
|
||||
Broccolini Bot is a Discord support bot that turns Gmail emails and Discord messages into trackable support tickets stored in MongoDB.
|
||||
|
||||
---
|
||||
|
||||
## Email & Discord Ticketing
|
||||
|
||||
**Summary:** Connects Gmail and Discord so each support conversation becomes a ticket channel or thread.
|
||||
|
||||
- Email → Discord ticket channels or threads (with overflow categories)
|
||||
- Discord-only tickets created from panels or context menus
|
||||
- Full Gmail reply threading for email-sourced tickets
|
||||
- Ticket transcripts saved to a Discord channel and optionally emailed on close
|
||||
|
||||
---
|
||||
|
||||
## Ticket Workflow & Management
|
||||
|
||||
**Summary:** Provides a structured workflow for creating, handling, and closing tickets.
|
||||
|
||||
- Claim / unclaim with claimer emojis in channel names
|
||||
- Priority levels (low / normal / medium / high) with emojis
|
||||
- Escalation and de-escalation between tiered support categories
|
||||
- Close confirmation, force-close, and automatic transcript generation
|
||||
- Auto-close, inactivity reminders, and auto-unclaim (configurable)
|
||||
- Per-ticket limits and global ticket limits to prevent abuse
|
||||
|
||||
---
|
||||
|
||||
## Discord UI: Panels, Buttons & Modals
|
||||
|
||||
**Summary:** Uses rich Discord components so users and staff interact with tickets through buttons and forms.
|
||||
|
||||
- `/panel` command to post “Open ticket” panels
|
||||
- Ticket creation via modal (email, game, description fields)
|
||||
- Ticket action row with Close, Claim, Escalate, and De-escalate buttons
|
||||
- Thread-style or category-channel tickets, or panels that offer both
|
||||
- `/setup` wizard to guide initial panel and category configuration
|
||||
|
||||
---
|
||||
|
||||
## Staff Tools & Notifications
|
||||
|
||||
**Summary:** Gives staff better visibility and control over tickets and workloads.
|
||||
|
||||
- `/add` and `/remove` to manage who can see a ticket
|
||||
- `/transfer`, `/move`, `/topic`, `/stats`, `/search`, `/backup`, `/export`
|
||||
- `/accountinfo` for account lookups by email or Discord user
|
||||
- Per-staff notification channels with reply alerts and unclaimed digests
|
||||
- Optional DM reply alerts via `/notifydm`
|
||||
- Optional private staff-only threads attached to ticket channels
|
||||
|
||||
---
|
||||
|
||||
## Tags, Saved Responses & Variables
|
||||
|
||||
**Summary:** Speeds up replies and keeps tickets categorized.
|
||||
|
||||
- `/tag` command with predefined ticket tags and emojis
|
||||
- `/response` commands to create, edit, send, delete, and list saved replies
|
||||
- Template variables (ticket, staff, server, date/time, hours, etc.) in responses
|
||||
- Tag usage and response usage tracked in MongoDB
|
||||
|
||||
---
|
||||
|
||||
## Automation, Patterns & Surge Detection
|
||||
|
||||
**Summary:** Monitors ticket volume and chat activity to warn staff about problems early.
|
||||
|
||||
- Background jobs for auto-close, reminders, and auto-unclaim
|
||||
- Pattern detection for repeat users, games, tags, escalations, and stale tickets
|
||||
- Surge detection for high ticket volume, backlogs, and no-staff situations
|
||||
- Chat alerts for busy channels or messages without staff replies
|
||||
|
||||
---
|
||||
|
||||
## Settings UI, Logging, API & Configuration
|
||||
|
||||
**Summary:** Provides a web UI plus environment-based configuration and optional integrations.
|
||||
|
||||
- Optional Broccolini settings web UI (`settings-site/`) to edit Discord channels, categories, Gmail credentials, ticket behavior, surge alerts, pattern thresholds, appearance, and advanced options without touching `.env`
|
||||
- All behavior still backed by `.env` and `config.js` (messages, colors, timeouts, limits)
|
||||
- Dedicated Discord channels for transcripts, logs, security, automation, and Gmail polling
|
||||
- Optional HTTP API under `/api` with token-based auth
|
||||
- Healthcheck endpoint (`GET /`) for Docker and load balancers
|
||||
|
||||
@@ -11,17 +11,15 @@ const { mongoose } = require('./db-connection');
|
||||
// Handlers
|
||||
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
||||
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
||||
const { handleSendAccountInfoToChannel, BUTTON_PREFIX } = require('./handlers/accountinfo');
|
||||
const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup');
|
||||
const { handleDiscordReply } = require('./handlers/messages');
|
||||
|
||||
// Services & jobs
|
||||
const { sendTicketClosedEmail } = require('./services/gmail');
|
||||
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
|
||||
const { notifyAllStaffUnclaimed } = require('./services/staffNotifications');
|
||||
const { registerCommands } = require('./commands/register');
|
||||
const bosscordRoutes = require('./routes/bosscord');
|
||||
const { setBot } = require('./api/bosscordClient');
|
||||
// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup.
|
||||
const { setBot } = require('./api/botClient');
|
||||
const { poll } = require('./gmail-poll');
|
||||
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog');
|
||||
|
||||
@@ -115,11 +113,6 @@ async function runHandler(name, interaction, fn) {
|
||||
}
|
||||
|
||||
client.on('interactionCreate', async interaction => {
|
||||
if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) {
|
||||
const handled = await runHandler('handleSendAccountInfoToChannel', interaction, () => handleSendAccountInfoToChannel(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
|
||||
try {
|
||||
const handled = await handleSetupButton(interaction);
|
||||
@@ -202,15 +195,6 @@ client.on('interactionCreate', async interaction => {
|
||||
});
|
||||
|
||||
client.on('messageCreate', async msg => {
|
||||
// Track staff last-seen for zero-staff detection fallback
|
||||
if (!msg.author.bot && CONFIG.STAFF_IDS.includes(msg.author.id)) {
|
||||
const { updateStaffLastSeen } = require('./services/patternStore');
|
||||
updateStaffLastSeen(msg.author.id);
|
||||
}
|
||||
// Chat channel monitoring
|
||||
const { handleChatMessage } = require('./services/chatAlertChecker');
|
||||
await handleChatMessage(msg, client).catch(() => {});
|
||||
// Existing ticket reply handler
|
||||
await handleDiscordReply(msg);
|
||||
});
|
||||
|
||||
@@ -222,13 +206,6 @@ client.once('ready', async () => {
|
||||
await connectMongoDB(process.env.MONGODB_URI);
|
||||
setDebugClient(client);
|
||||
setBot(client);
|
||||
if (process.env.BOSSCORD_API_KEY) {
|
||||
app.use('/api', bosscordRoutes);
|
||||
app.use('/api', (err, req, res, next) => {
|
||||
console.error('bOSScord API error:', err && err.stack ? err.stack : err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
}
|
||||
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
|
||||
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
|
||||
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
||||
@@ -262,47 +239,21 @@ client.once('ready', async () => {
|
||||
console.log('✓ Auto-close enabled: checking every hour');
|
||||
}
|
||||
|
||||
trackInterval(setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000));
|
||||
notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e));
|
||||
console.log('✓ Staff unclaimed reminders: checking every 30 minutes');
|
||||
|
||||
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
|
||||
trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000));
|
||||
checkAutoUnclaim(client);
|
||||
console.log('✓ Auto-unclaim enabled: checking every hour');
|
||||
}
|
||||
|
||||
const { runPatternChecks } = require('./services/patternChecker');
|
||||
const { scheduleResets } = require('./services/patternStore');
|
||||
scheduleResets();
|
||||
trackInterval(setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000));
|
||||
console.log(`✓ Pattern checks: every ${CONFIG.PATTERN_CHECK_INTERVAL_MINUTES} minutes`);
|
||||
|
||||
const { runSurgeChecks } = require('./services/surgeChecker');
|
||||
trackInterval(setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000));
|
||||
setTimeout(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 30000);
|
||||
console.log('✓ Surge checks: every 5 minutes');
|
||||
|
||||
const { initChatMonitoring, runChatAlertChecks } = require('./services/chatAlertChecker');
|
||||
initChatMonitoring(client);
|
||||
trackInterval(setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000));
|
||||
console.log('✓ Chat alert monitoring: every 5 minutes');
|
||||
|
||||
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
|
||||
trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000));
|
||||
resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e));
|
||||
console.log('✓ Reconcile deleted ticket channels: every 1 hour');
|
||||
|
||||
// Start in-memory Map sweeps (per-module) — keeps long-running processes bounded.
|
||||
require('./services/patternStore').startSweeps(trackInterval);
|
||||
require('./services/staffNotifications').startSweeps(trackInterval);
|
||||
require('./services/tickets').startTicketsSweeps(trackInterval);
|
||||
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
|
||||
|
||||
if (!CONFIG.STAFF_IDS.length) {
|
||||
console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.');
|
||||
}
|
||||
|
||||
console.log('✓ Discord bot ready. Tag:', client.user.tag);
|
||||
|
||||
logSystem('Bot online', [
|
||||
@@ -310,7 +261,6 @@ client.once('ready', async () => {
|
||||
{ name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` },
|
||||
{ name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' },
|
||||
{ name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' },
|
||||
{ name: 'Claim timeout', value: CONFIG.CLAIM_TIMEOUT_ENABLED ? `enabled (${CONFIG.CLAIM_TIMEOUT_HOURS}h)` : 'disabled' },
|
||||
{ name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
|
||||
{ name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
|
||||
{ name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' },
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
ContextMenuCommandBuilder,
|
||||
ApplicationCommandType
|
||||
} = require('discord.js');
|
||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
async function registerCommands() {
|
||||
if (!CONFIG.CLIENT_ID || !CONFIG.DISCORD_GUILD_ID) return;
|
||||
@@ -22,7 +22,7 @@ async function registerCommands() {
|
||||
const commands = [
|
||||
new SlashCommandBuilder()
|
||||
.setName('escalate')
|
||||
.setDescription('Escalate this ticket to tier 2 or tier 3')
|
||||
.setDescription('Escalate this ticket to tier 2 or tier 3 (always unclaims)')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
@@ -35,16 +35,6 @@ async function registerCommands() {
|
||||
{ name: 'Tier 2', value: '2' },
|
||||
{ name: 'Tier 3', value: '3' }
|
||||
)
|
||||
)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('action')
|
||||
.setDescription('Unclaim ticket or keep current claimer')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: 'Unclaim', value: 'unclaim' },
|
||||
{ name: 'Keep', value: 'keep' }
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
@@ -128,19 +118,6 @@ async function registerCommands() {
|
||||
.setRequired(true)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('tag')
|
||||
.setDescription('Set ticket category (dropdown)')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addStringOption(o =>
|
||||
o
|
||||
.setName('category')
|
||||
.setDescription('Ticket category tag')
|
||||
.setRequired(true)
|
||||
.addChoices(...(TICKET_TAGS || []).map(({ value, emoji, name }) => ({ name: `${emoji} ${name}`, value })))
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('response')
|
||||
.setDescription('Saved response tags (custom templates)')
|
||||
@@ -332,25 +309,6 @@ async function registerCommands() {
|
||||
.setRequired(false)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('priority')
|
||||
.setDescription('Set the priority of this ticket')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('level')
|
||||
.setDescription('Priority level')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: '🟢 Low', value: 'low' },
|
||||
{ name: '🟡 Normal', value: 'normal' },
|
||||
{ name: '🟠 Medium', value: 'medium' },
|
||||
{ name: '🔴 High', value: 'high' }
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('search')
|
||||
.setDescription('Search for tickets')
|
||||
@@ -384,52 +342,6 @@ async function registerCommands() {
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('notification')
|
||||
.setDescription('Manage your staff notification settings')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('set')
|
||||
.setDescription('Set your notification cooldown (hours between alerts per ticket)')
|
||||
.addIntegerOption(opt =>
|
||||
opt
|
||||
.setName('hours')
|
||||
.setDescription('Cooldown in hours (1–6)')
|
||||
.setMinValue(1)
|
||||
.setMaxValue(6)
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('add')
|
||||
.setDescription('Create a notification channel for a staff member')
|
||||
.addUserOption(opt =>
|
||||
opt.setName('member').setDescription('Staff member').setRequired(true)
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('staffnotification')
|
||||
.setDescription('Override notification cooldown for another staff member (admin only)')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addUserOption(opt =>
|
||||
opt.setName('member').setDescription('Staff member').setRequired(true)
|
||||
)
|
||||
.addIntegerOption(opt =>
|
||||
opt
|
||||
.setName('hours')
|
||||
.setDescription('Cooldown in hours (1–6)')
|
||||
.setMinValue(1)
|
||||
.setMaxValue(6)
|
||||
.setRequired(true)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('closetimer')
|
||||
.setDescription('Set the force-close countdown duration')
|
||||
@@ -551,31 +463,6 @@ async function registerCommands() {
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('accountinfo')
|
||||
.setDescription('Look up website account info by email or Discord user')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('email')
|
||||
.setDescription('Look up by email address')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('email').setDescription('Account email').setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('discord')
|
||||
.setDescription('Look up by Discord user')
|
||||
.addUserOption(opt =>
|
||||
opt.setName('user').setDescription('Discord user').setRequired(true)
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('signature')
|
||||
.setDescription('Set your personal email signature (valediction, display name, tagline)')
|
||||
|
||||
159
config.js
159
config.js
@@ -30,88 +30,11 @@ if (!envPath) {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_NOTIFICATION_THRESHOLDS = {
|
||||
// patternChecker - age-based (time since condition first became true)
|
||||
user_tickets: ['15m', '30m', '1h', '3h'],
|
||||
user_reopen: ['1h', '4h', '1d'],
|
||||
user_crossgame: ['1h', '1d'],
|
||||
game_surge: ['15m', '30m', '1h'],
|
||||
game_backlog: ['30m', '1h', '3h', '6h'],
|
||||
game_resolution: ['1d'],
|
||||
game_spike: ['15m', '30m'],
|
||||
tag_top: ['1h', '6h', '1d'],
|
||||
tag_escalation: ['1h', '6h', '1d'],
|
||||
untagged_closes: ['1h', '1d'],
|
||||
tag_game_corr: ['1d'],
|
||||
user_esc: ['1h', '6h', '1d'],
|
||||
game_esc_rate: ['1d'],
|
||||
rapid_t2_t3: ['3', '5', '10', '15', '20', '30', '50'], // count-based milestones, not time
|
||||
staff_no_close: ['1h', '3h'],
|
||||
staff_overloaded: ['1h', '3h', '6h'],
|
||||
staff_stale: ['1h', '3h'],
|
||||
staff_transfer_rate: ['1h', '1d'],
|
||||
staff_esc: ['1h', '6h', '1d'],
|
||||
staff_game_esc: ['1d'],
|
||||
game_tag_spike: ['1h', '6h'],
|
||||
overnight_gap: ['1d'],
|
||||
staff_always_esc: ['1d'],
|
||||
// surgeChecker - cooldown-escalating (repeat alerts spaced further apart)
|
||||
surge_tickets: ['10m', '30m', '1h', '2h', '3h'],
|
||||
surge_game: ['10m', '30m', '1h', '2h'],
|
||||
surge_stale: ['30m', '1h', '2h', '4h'],
|
||||
surge_needs_response: ['15m', '30m', '1h', '3h'],
|
||||
surge_unclaimed: ['15m', '30m', '1h', '2h', '4h'],
|
||||
surge_tier3_unclaimed: ['10m', '15m', '30m', '1h', '2h'],
|
||||
surge_no_staff: ['10m', '20m', '30m', '1h'],
|
||||
// staffNotifications - age-based per ticket (hours)
|
||||
unclaimed_reminder: ['1h', '2h', '4h', '8h', '1d'],
|
||||
// chatAlertChecker - cooldown-escalating
|
||||
chat_messages: ['15m', '30m', '1h', '3h'],
|
||||
chat_time: ['30m', '1h', '2h', '4h']
|
||||
};
|
||||
|
||||
function toInt(v, fallback) {
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function parseThresholdString(str) {
|
||||
const value = String(str || '').trim();
|
||||
if (!value) return NaN;
|
||||
|
||||
// Integers without a unit are raw count milestones.
|
||||
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
||||
|
||||
let totalMs = 0;
|
||||
const re = /(\d+)([mhd])/g;
|
||||
let match;
|
||||
let consumed = '';
|
||||
while ((match = re.exec(value)) !== null) {
|
||||
const amount = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
consumed += match[0];
|
||||
if (unit === 'm') totalMs += amount * 60 * 1000;
|
||||
else if (unit === 'h') totalMs += amount * 60 * 60 * 1000;
|
||||
else if (unit === 'd') totalMs += amount * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
if (!consumed || consumed !== value) return NaN;
|
||||
return totalMs;
|
||||
}
|
||||
|
||||
function parseNotificationThresholdsJson(raw) {
|
||||
if (!raw || !String(raw).trim()) return DEFAULT_NOTIFICATION_THRESHOLDS;
|
||||
try {
|
||||
const parsedJson = JSON.parse(raw);
|
||||
if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
|
||||
return parsedJson;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[config] Failed to parse NOTIFICATION_THRESHOLDS_JSON, using default:', err.message);
|
||||
}
|
||||
return DEFAULT_NOTIFICATION_THRESHOLDS;
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
|
||||
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
|
||||
@@ -134,7 +57,6 @@ const CONFIG = {
|
||||
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
|
||||
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
||||
BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null,
|
||||
ACCOUNT_INFO_CHANNEL_ID: process.env.ACCOUNT_INFO_CHANNEL_ID || null,
|
||||
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
|
||||
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
||||
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
||||
@@ -143,7 +65,7 @@ const CONFIG = {
|
||||
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
|
||||
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
|
||||
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '<br>'),
|
||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
|
||||
GAME_LIST: process.env.GAME_LIST || '',
|
||||
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
|
||||
EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null,
|
||||
@@ -181,8 +103,6 @@ const CONFIG = {
|
||||
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
|
||||
PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡',
|
||||
PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟢',
|
||||
CLAIM_TIMEOUT_ENABLED: process.env.CLAIM_TIMEOUT_ENABLED === 'true',
|
||||
CLAIM_TIMEOUT_HOURS: toInt(process.env.CLAIM_TIMEOUT_HOURS, 48),
|
||||
AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
|
||||
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
|
||||
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
|
||||
@@ -199,25 +119,7 @@ const CONFIG = {
|
||||
EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
|
||||
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
|
||||
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
|
||||
STAFF_CATEGORIES: new Map(), // deprecated – kept for staffChannel.js compat
|
||||
STAFF_EMOJIS: (() => {
|
||||
const raw = process.env.STAFF_EMOJIS;
|
||||
const map = new Map();
|
||||
if (!raw || !String(raw).trim()) return map;
|
||||
for (const part of String(raw).split(',')) {
|
||||
const seg = part.trim();
|
||||
if (!seg) continue;
|
||||
const idx = seg.indexOf(':');
|
||||
if (idx === -1) continue;
|
||||
const userId = seg.slice(0, idx).trim();
|
||||
const emoji = seg.slice(idx + 1).trim();
|
||||
if (userId && emoji) map.set(userId, emoji);
|
||||
}
|
||||
return map;
|
||||
})(),
|
||||
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
||||
ADMIN_ID: process.env.ADMIN_ID || null,
|
||||
STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null,
|
||||
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
|
||||
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
|
||||
GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null,
|
||||
@@ -225,42 +127,6 @@ const CONFIG = {
|
||||
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
|
||||
SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_LOG_CHANNEL_ID || null,
|
||||
SYSTEM_LOG_CHANNEL_ID: process.env.SYSTEM_LOG_CHANNEL_ID || null,
|
||||
USER_PATTERNS_CHANNEL_ID: process.env.USER_PATTERNS_CHANNEL_ID || null,
|
||||
GAME_PATTERNS_CHANNEL_ID: process.env.GAME_PATTERNS_CHANNEL_ID || null,
|
||||
TAG_PATTERNS_CHANNEL_ID: process.env.TAG_PATTERNS_CHANNEL_ID || null,
|
||||
ESCALATION_PATTERNS_CHANNEL_ID: process.env.ESCALATION_PATTERNS_CHANNEL_ID || null,
|
||||
STAFF_PATTERNS_CHANNEL_ID: process.env.STAFF_PATTERNS_CHANNEL_ID || null,
|
||||
COMBINED_PATTERNS_CHANNEL_ID: process.env.COMBINED_PATTERNS_CHANNEL_ID || null,
|
||||
PATTERN_USER_TICKET_THRESHOLD: toInt(process.env.PATTERN_USER_TICKET_THRESHOLD, 3),
|
||||
PATTERN_GAME_TICKET_THRESHOLD: toInt(process.env.PATTERN_GAME_TICKET_THRESHOLD, 10),
|
||||
PATTERN_STAFF_STALE_PING_THRESHOLD: toInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD, 5),
|
||||
PATTERN_ESCALATION_THRESHOLD: toInt(process.env.PATTERN_ESCALATION_THRESHOLD, 3),
|
||||
PATTERN_RAPID_CLOSE_SECONDS: toInt(process.env.PATTERN_RAPID_CLOSE_SECONDS, 120),
|
||||
PATTERN_UNCLAIMED_HOURS: toInt(process.env.PATTERN_UNCLAIMED_HOURS, 4),
|
||||
PATTERN_CHECK_INTERVAL_MINUTES: toInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES, 30),
|
||||
ALL_STAFF_CHANNEL_ID: process.env.ALL_STAFF_CHANNEL_ID || null,
|
||||
ALL_STAFF_CHAT_ALERT_CHANNEL_ID: process.env.ALL_STAFF_CHAT_ALERT_CHANNEL_ID || null,
|
||||
SURGE_ROLE_ID: process.env.SURGE_ROLE_ID || null,
|
||||
SURGE_TICKET_COUNT: toInt(process.env.SURGE_TICKET_COUNT, 10),
|
||||
SURGE_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_TICKET_WINDOW_MINUTES, 30),
|
||||
SURGE_GAME_TICKET_COUNT: toInt(process.env.SURGE_GAME_TICKET_COUNT, 5),
|
||||
SURGE_GAME_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES, 30),
|
||||
SURGE_STALE_COUNT: toInt(process.env.SURGE_STALE_COUNT, 8),
|
||||
SURGE_STALE_HOURS: toInt(process.env.SURGE_STALE_HOURS, 2),
|
||||
SURGE_NEEDS_RESPONSE_COUNT: toInt(process.env.SURGE_NEEDS_RESPONSE_COUNT, 5),
|
||||
SURGE_NEEDS_RESPONSE_HOURS: toInt(process.env.SURGE_NEEDS_RESPONSE_HOURS, 1),
|
||||
SURGE_UNCLAIMED_COUNT: toInt(process.env.SURGE_UNCLAIMED_COUNT, 5),
|
||||
SURGE_UNCLAIMED_MINUTES: toInt(process.env.SURGE_UNCLAIMED_MINUTES, 30),
|
||||
SURGE_TIER3_UNCLAIMED_MINUTES: toInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES, 15),
|
||||
SURGE_COOLDOWN_MINUTES: toInt(process.env.SURGE_COOLDOWN_MINUTES, 60),
|
||||
CHAT_ALERT_CHANNEL_IDS: (process.env.CHAT_ALERT_CHANNEL_IDS || '').split(',').filter(Boolean),
|
||||
CHAT_ALERT_MESSAGE_COUNT: toInt(process.env.CHAT_ALERT_MESSAGE_COUNT, 5),
|
||||
CHAT_ALERT_HOURS_WITHOUT_RESPONSE: toInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE, 2),
|
||||
CHAT_ALERT_COOLDOWN_MINUTES: toInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES, 60),
|
||||
STAFF_IDS: (process.env.STAFF_IDS || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
SURGE_NO_STAFF_COOLDOWN_MINUTES: toInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES, 30),
|
||||
SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: toInt(process.env.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD, 3),
|
||||
STAFF_DND_COUNTS_AS_AVAILABLE: process.env.STAFF_DND_COUNTS_AS_AVAILABLE === 'true',
|
||||
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
|
||||
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
|
||||
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
|
||||
@@ -273,28 +139,9 @@ const CONFIG = {
|
||||
SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
|
||||
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
|
||||
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
|
||||
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null,
|
||||
NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON),
|
||||
UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4')
|
||||
.split(',')
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
.filter(n => !isNaN(n) && n > 0)
|
||||
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null
|
||||
};
|
||||
|
||||
/** Ticket category tags for /tag set – [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */
|
||||
const TICKET_TAGS = [
|
||||
{ value: 'server-down', emoji: '⬇️', name: 'Server Down' },
|
||||
{ value: 'stuck-restarting', emoji: '⏳', name: 'Stuck Restarting' },
|
||||
{ value: 'cant-connect', emoji: '📵', name: "Can't Connect" },
|
||||
{ value: 'server-lag', emoji: '🐌', name: 'Server Lag' },
|
||||
{ value: 'billing', emoji: '💳', name: 'Billing' },
|
||||
{ value: 'refund-request', emoji: '💸', name: 'Refund Request' },
|
||||
{ value: 'mod-help', emoji: '🔧', name: 'Mod Help' },
|
||||
{ value: 'backup-restore', emoji: '💾', name: 'Backup Restore' },
|
||||
{ value: 'world-save', emoji: '🌍', name: 'World / Save' },
|
||||
{ value: 'server-config', emoji: '⚙️', name: 'Server Config' }
|
||||
];
|
||||
|
||||
const GAME_NAMES = (CONFIG.GAME_LIST || '')
|
||||
.split(',')
|
||||
.map(g => g.trim())
|
||||
@@ -346,8 +193,6 @@ const GAME_NAME_TO_KEY = {
|
||||
|
||||
module.exports = {
|
||||
CONFIG,
|
||||
parseThresholdString,
|
||||
TICKET_TAGS,
|
||||
GAME_NAMES,
|
||||
GAME_ALIASES,
|
||||
GAME_NAME_TO_KEY
|
||||
|
||||
@@ -16,13 +16,13 @@ const {
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
detectGame,
|
||||
enforceEmbedLimit
|
||||
enforceEmbedLimit,
|
||||
sanitizeEmbedText
|
||||
} = require('./utils');
|
||||
const { getGmailClient } = require('./services/gmail');
|
||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||
const { getEmailRouting } = require('./services/guildSettings');
|
||||
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
||||
const { increment } = require('./services/patternStore');
|
||||
const { enqueueSend } = require('./services/channelQueue');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
@@ -253,10 +253,10 @@ async function poll(client) {
|
||||
const ticketInfoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields(
|
||||
{ name: 'Name', value: `\`${sName}\``, inline: false },
|
||||
{ name: 'Email', value: `\`${sEmail}\``, inline: false },
|
||||
{ name: 'Game', value: `\`${detectedGame}\``, inline: false },
|
||||
{ name: 'Subject', value: `\`${subject || 'No subject'}\``, inline: false }
|
||||
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false },
|
||||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
|
||||
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||
);
|
||||
|
||||
enforceEmbedLimit([ticketInfoEmbed]);
|
||||
@@ -347,12 +347,6 @@ async function poll(client) {
|
||||
));
|
||||
totalProcessed++;
|
||||
logGmail(subject, sEmail, number, detectedGame).catch(() => {});
|
||||
increment('user_tickets', sEmail, 'today');
|
||||
increment('user_tickets', sEmail, 'week');
|
||||
if (detectedGame) {
|
||||
increment('game_tickets', detectedGame, 'today');
|
||||
increment('game_tickets', detectedGame, 'week');
|
||||
}
|
||||
}
|
||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||
await gmail.users.messages.batchModify({
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* Account info command: look up website User by email or Discord ID,
|
||||
* show ephemeral embed with option to send transcript to account info channel.
|
||||
*/
|
||||
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { logSecurity } = require('../services/debugLog');
|
||||
const { enqueueSend } = require('../services/channelQueue');
|
||||
const { isStaff } = require('../utils');
|
||||
|
||||
const User = mongoose.model('User');
|
||||
|
||||
const BUTTON_PREFIX = 'send_account_info_';
|
||||
const MAX_CUSTOM_ID_LENGTH = 100;
|
||||
|
||||
function buildAccountInfoEmbed(user, requestedBy = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Account Info')
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setTimestamp();
|
||||
|
||||
embed.addFields({
|
||||
name: 'Email',
|
||||
value: user.email || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: 'Discord ID',
|
||||
value: user.discordID ? `<@${user.discordID}>` : '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: 'Customer ID',
|
||||
value: user.customerId || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
|
||||
const servers = user.servers || [];
|
||||
const serverOrder = user.serverOrder || [];
|
||||
const ordered = serverOrder.length
|
||||
? serverOrder.map(id => servers.find(s => s._id && s._id.toString() === id) || servers[serverOrder.indexOf(id)]).filter(Boolean)
|
||||
: servers;
|
||||
|
||||
if (ordered.length === 0) {
|
||||
embed.addFields({
|
||||
name: 'Servers',
|
||||
value: '*No servers*',
|
||||
inline: false
|
||||
});
|
||||
} else {
|
||||
ordered.forEach((server, i) => {
|
||||
const n = i + 1;
|
||||
embed.addFields({
|
||||
name: `Server ${n} – Game`,
|
||||
value: server.game || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: `Server ${n} – IP`,
|
||||
value: server.ip || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: `Server ${n} – Port`,
|
||||
value: server.serverPort != null ? String(server.serverPort) : '*not set*',
|
||||
inline: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (requestedBy) {
|
||||
embed.setFooter({ text: `Requested by ${requestedBy}` });
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
async function handleAccountInfoCommand(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
let user = null;
|
||||
|
||||
if (subcommand === 'email') {
|
||||
const email = (interaction.options.getString('email') || '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
return interaction.reply({ content: 'Please provide an email.', ephemeral: true });
|
||||
}
|
||||
user = await User.findOne({ email }).lean();
|
||||
} else if (subcommand === 'discord') {
|
||||
const target = interaction.options.getUser('user');
|
||||
if (!target) {
|
||||
return interaction.reply({ content: 'Please provide a Discord user.', ephemeral: true });
|
||||
}
|
||||
user = await User.findOne({ discordID: target.id }).lean();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return interaction.reply({
|
||||
content: subcommand === 'email' ? 'No account found for that email.' : 'No account found for that Discord user/ID.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const identifier = subcommand === 'email'
|
||||
? interaction.options.getString('email')
|
||||
: interaction.options.getUser('user')?.tag || 'unknown';
|
||||
logSecurity('Account lookup', interaction.user, `lookup: ${subcommand} → ${identifier}`, null, 0x0099ff).catch(() => {});
|
||||
|
||||
const embed = buildAccountInfoEmbed(user, interaction.user.tag);
|
||||
const components = [];
|
||||
|
||||
if (CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
|
||||
const safeEmail = (user.email || '').slice(0, 50);
|
||||
const safeDiscordId = (user.discordID || '').slice(0, 50);
|
||||
const customId = `${BUTTON_PREFIX}discord:${safeDiscordId}`;
|
||||
if (customId.length <= MAX_CUSTOM_ID_LENGTH) {
|
||||
components.push(
|
||||
new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(customId)
|
||||
.setLabel('Send to account info channel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
embeds: [embed],
|
||||
components,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSendAccountInfoToChannel(interaction) {
|
||||
if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false;
|
||||
|
||||
// Dispatched directly from interactionCreate — no upstream command-level staff gate here, so enforce it.
|
||||
if (!isStaff(interaction.member)) {
|
||||
logSecurity('Unauthorized account-info button', interaction.user, `non-staff pressed ${interaction.customId}`, null, 0xff0000).catch(() => {});
|
||||
await interaction.reply({ content: 'You do not have permission to do that.', ephemeral: true }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = interaction.customId.slice(BUTTON_PREFIX.length);
|
||||
const [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];
|
||||
|
||||
let user = null;
|
||||
if (type === 'email') {
|
||||
const email = Buffer.from(value, 'base64').toString('utf8').toLowerCase();
|
||||
user = await User.findOne({ email }).lean();
|
||||
} else if (type === 'discord' && value) {
|
||||
user = await User.findOne({ discordID: value }).lean();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
await interaction.update({ content: 'Account no longer found.', components: [] }).catch(() =>
|
||||
interaction.followUp({ content: 'Account no longer found.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
|
||||
await interaction.update({ content: 'Account info channel is not configured.', components: [] }).catch(() =>
|
||||
interaction.followUp({ content: 'Account info channel is not configured.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const channel = await interaction.client.channels.fetch(CONFIG.ACCOUNT_INFO_CHANNEL_ID).catch(() => null);
|
||||
if (!channel) {
|
||||
await interaction.update({ content: 'Could not find account info channel.', components: [] }).catch(() =>
|
||||
interaction.followUp({ content: 'Could not find account info channel.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const embed = buildAccountInfoEmbed(user, `${interaction.user.tag} (from ticket)`);
|
||||
await enqueueSend(channel, { embeds: [embed] });
|
||||
|
||||
await interaction.update({
|
||||
content: 'Account info sent to account transcript channel.',
|
||||
components: []
|
||||
}).catch(() =>
|
||||
interaction.followUp({ content: 'Account info sent to account transcript channel.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAccountInfoEmbed,
|
||||
handleAccountInfoCommand,
|
||||
handleSendAccountInfoToChannel,
|
||||
BUTTON_PREFIX
|
||||
};
|
||||
@@ -25,13 +25,11 @@ 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.
|
||||
@@ -342,11 +340,8 @@ async function handleClaim(interaction, ticket) {
|
||||
);
|
||||
freshTicket.claimedBy = claimerLabel;
|
||||
freshTicket.claimerId = interaction.user.id;
|
||||
increment('staff_claims', interaction.user.id, 'today');
|
||||
increment('staff_claims', interaction.user.id, 'week');
|
||||
|
||||
// Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK)
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const claimerEmoji = '🎫';
|
||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||
|
||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||
@@ -385,11 +380,10 @@ async function handleClaim(interaction, ticket) {
|
||||
// Unclaim
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { claimedBy: null, claimerId: null, staffChannelId: null } }
|
||||
{ $set: { claimedBy: null, claimerId: null } }
|
||||
);
|
||||
freshTicket.claimedBy = null;
|
||||
freshTicket.claimerId = null;
|
||||
freshTicket.staffChannelId = null;
|
||||
|
||||
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
|
||||
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||||
@@ -425,10 +419,6 @@ async function handleClaim(interaction, ticket) {
|
||||
// --- CONFIRM CLOSE ---
|
||||
async function handleConfirmClose(interaction, ticket) {
|
||||
const closedAt = new Date();
|
||||
increment('staff_closes', interaction.user.id, 'today');
|
||||
if (!ticket.ticketTag) {
|
||||
increment('untagged_closes', 'total', 'today');
|
||||
}
|
||||
try {
|
||||
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
||||
} catch {
|
||||
@@ -563,13 +553,6 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
||||
);
|
||||
|
||||
try {
|
||||
const { deleteStaffChannel } = require('../services/staffChannel');
|
||||
await deleteStaffChannel(interaction.guild, ticket.staffChannelId);
|
||||
} catch (e) {
|
||||
console.error('Delete staff channel (close):', e);
|
||||
}
|
||||
|
||||
if (transcriptMsg?.id) {
|
||||
await Transcript.create({
|
||||
gmailThreadId: ticket.gmailThreadId,
|
||||
@@ -746,13 +729,6 @@ async function handleTicketModal(interaction) {
|
||||
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
increment('user_tickets', interaction.user.id, 'today');
|
||||
increment('user_tickets', interaction.user.id, 'week');
|
||||
if (game) {
|
||||
increment('game_tickets', game, 'today');
|
||||
increment('game_tickets', game, 'week');
|
||||
}
|
||||
|
||||
await interaction.deleteReply().catch(() => {});
|
||||
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
||||
|
||||
@@ -11,9 +11,9 @@ const {
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
||||
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
|
||||
const { makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { CONFIG } = require('../config');
|
||||
const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { getEmailRouting } = require('../services/guildSettings');
|
||||
@@ -21,15 +21,11 @@ const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channel
|
||||
const { setNotifyDm } = require('../services/staffSettings');
|
||||
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
||||
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
|
||||
const { handleAccountInfoCommand } = require('./accountinfo');
|
||||
const { handleSetupCommand } = require('./setup');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { increment } = require('../services/patternStore');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Tag = mongoose.model('Tag');
|
||||
const User = mongoose.model('User');
|
||||
const StaffNotification = mongoose.model('StaffNotification');
|
||||
|
||||
/**
|
||||
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
||||
@@ -74,17 +70,11 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
// Clear claim on escalation
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null, unclaimedRemindersSent: [] } }
|
||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = true;
|
||||
ticket.escalationTier = nextTier;
|
||||
ticket.claimedBy = null;
|
||||
increment('escalations', ticket.game || 'unknown', 'today');
|
||||
increment('escalations', ticket.game || 'unknown', 'week');
|
||||
increment('user_escalations', ticket.senderEmail, 'week');
|
||||
increment('staff_escalations', interaction.user.id, 'today');
|
||||
increment('staff_escalations', interaction.user.id, 'week');
|
||||
if (ticket.game) increment(`staff_game_escalations:${interaction.user.id}`, ticket.game, 'week');
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
||||
@@ -265,12 +255,11 @@ async function handleCommand(interaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// /escalate (tier 2 or 3 via level; works for both email and Discord)
|
||||
// /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate.
|
||||
if (interaction.commandName === 'escalate') {
|
||||
const reason = null;
|
||||
const level = interaction.options.getString('level');
|
||||
const nextTier = level === '3' ? 2 : 1;
|
||||
const action = interaction.options.getString('action');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
@@ -301,12 +290,6 @@ async function handleCommand(interaction) {
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, nextTier, reason);
|
||||
if (action === 'unclaim') {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: null, claimerId: null } }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Escalate error:', err);
|
||||
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
|
||||
@@ -315,83 +298,6 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /notification set | /notification add
|
||||
if (interaction.commandName === 'notification') {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'set') {
|
||||
const hours = interaction.options.getInteger('hours');
|
||||
try {
|
||||
await StaffNotification.findOneAndUpdate(
|
||||
{ userId: interaction.user.id },
|
||||
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
|
||||
{ upsert: true }
|
||||
);
|
||||
return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('notification set error:', err);
|
||||
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (sub === 'add') {
|
||||
if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) {
|
||||
return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true });
|
||||
}
|
||||
const member = interaction.options.getMember('member');
|
||||
if (!member) {
|
||||
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
|
||||
}
|
||||
const displayName = member.displayName;
|
||||
const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || '';
|
||||
const chanName = toDiscordSafeName(`${displayName}${emoji}`);
|
||||
try {
|
||||
const newChannel = await interaction.guild.channels.create({
|
||||
name: chanName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID,
|
||||
permissionOverwrites: [
|
||||
{ id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{ id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] },
|
||||
...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : [])
|
||||
]
|
||||
});
|
||||
await StaffNotification.findOneAndUpdate(
|
||||
{ userId: member.id },
|
||||
{ $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } },
|
||||
{ upsert: true }
|
||||
);
|
||||
return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('notification add error:', err);
|
||||
return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /staffnotification (admin only)
|
||||
if (interaction.commandName === 'staffnotification') {
|
||||
if (interaction.user.id !== CONFIG.ADMIN_ID) {
|
||||
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
|
||||
return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true });
|
||||
}
|
||||
const member = interaction.options.getMember('member');
|
||||
const hours = interaction.options.getInteger('hours');
|
||||
if (!member) {
|
||||
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await StaffNotification.findOneAndUpdate(
|
||||
{ userId: member.id },
|
||||
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
|
||||
{ upsert: true }
|
||||
);
|
||||
return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true });
|
||||
} catch (err) {
|
||||
console.error('staffnotification error:', err);
|
||||
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.commandName === 'notifydm') {
|
||||
try {
|
||||
const setting = interaction.options.getString('setting') === 'on';
|
||||
@@ -723,32 +629,6 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /tag – ticket category dropdown only
|
||||
if (interaction.commandName === 'tag') {
|
||||
trackInteraction('commands', 'tag', interaction.user.tag);
|
||||
const categoryValue = interaction.options.getString('category');
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { ticketTag: categoryValue } }
|
||||
);
|
||||
const tagEntry = (TICKET_TAGS || []).find(t => t.value === categoryValue);
|
||||
const emoji = tagEntry ? tagEntry.emoji : '';
|
||||
const channelMessage = `Your ticket has been categorized as ${emoji} **${tagEntry ? tagEntry.name : categoryValue}** ${emoji}.`;
|
||||
await interaction.reply(channelMessage);
|
||||
increment('tag_usage', categoryValue, 'today');
|
||||
increment('tag_usage', categoryValue, 'week');
|
||||
if (ticket.game) increment(`tag_game:${categoryValue}`, ticket.game, 'week');
|
||||
} catch (err) {
|
||||
trackError('tag-command', err, interaction);
|
||||
await interaction.reply({ content: 'Failed to set ticket category.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
// /response – saved response tags (send, create, edit, delete, list)
|
||||
if (interaction.commandName === 'response') {
|
||||
trackInteraction('commands', 'response', interaction.user.tag);
|
||||
@@ -918,12 +798,6 @@ async function handleCommand(interaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// /accountinfo
|
||||
if (interaction.commandName === 'accountinfo') {
|
||||
await handleAccountInfoCommand(interaction);
|
||||
return;
|
||||
}
|
||||
|
||||
// /help
|
||||
if (interaction.commandName === 'help') {
|
||||
const embed = new EmbedBuilder()
|
||||
@@ -936,14 +810,14 @@ async function handleCommand(interaction) {
|
||||
},
|
||||
{
|
||||
name: 'Ticket Management',
|
||||
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <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'
|
||||
},
|
||||
{
|
||||
name: 'Tags & Responses',
|
||||
value: '`/tag` - Set ticket category (dropdown)\n`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
||||
name: '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}`'
|
||||
},
|
||||
{
|
||||
@@ -960,63 +834,6 @@ async function handleCommand(interaction) {
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
// /priority
|
||||
if (interaction.commandName === 'priority') {
|
||||
const level = interaction.options.getString('level');
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
const priorityOrder = ['low', 'normal', 'medium', 'high'];
|
||||
const oldIdx = priorityOrder.indexOf((ticket.priority || 'normal').toLowerCase());
|
||||
const newIdx = priorityOrder.indexOf(level.toLowerCase());
|
||||
const emoji = getPriorityEmoji(level);
|
||||
const levelLabel = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase();
|
||||
|
||||
let channelMessage;
|
||||
if (level === 'normal') {
|
||||
channelMessage = 'Your ticket priority has returned to Normal.';
|
||||
} else if (newIdx > oldIdx) {
|
||||
channelMessage = `Your ticket has been upgraded to ${emoji} **${levelLabel}** ${emoji}.`;
|
||||
} else if (newIdx < oldIdx) {
|
||||
channelMessage = `Your ticket has been downgraded to ${emoji} **${levelLabel}** ${emoji}.`;
|
||||
} else {
|
||||
channelMessage = `Priority set to ${emoji} **${levelLabel}** ${emoji}.`;
|
||||
}
|
||||
|
||||
try {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { priority: level } }
|
||||
);
|
||||
|
||||
const priorityTitle =
|
||||
newIdx === oldIdx
|
||||
? 'Priority Set'
|
||||
: `Priority ${newIdx > oldIdx ? 'Upgraded' : 'Downgraded'} → ${levelLabel}`;
|
||||
const priorityEmbed = new EmbedBuilder()
|
||||
.setTitle(priorityTitle)
|
||||
.setDescription(channelMessage)
|
||||
.setColor(getPriorityColor(level))
|
||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||
await interaction.reply({ embeds: [priorityEmbed] });
|
||||
|
||||
if (level === 'high' && ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-')) {
|
||||
await sendTicketNotificationEmail(
|
||||
ticket,
|
||||
`Priority updated: ${levelLabel}`,
|
||||
channelMessage,
|
||||
interaction.member?.displayName || interaction.user.username
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Priority update error:', err);
|
||||
await interaction.reply({ content: 'Failed to update priority.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
// /panel
|
||||
if (interaction.commandName === 'panel') {
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
|
||||
@@ -7,8 +7,6 @@ const { extractRawEmail } = require('../utils');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { updateTicketActivity } = require('../services/tickets');
|
||||
const { getNotifyDm } = require('../services/staffSettings');
|
||||
const { pingStaffChannel } = require('../services/staffChannel');
|
||||
const { notifyStaffOfReply } = require('../services/staffNotifications');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
@@ -21,12 +19,16 @@ async function handleDiscordReply(m) {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||
if (!ticket) return;
|
||||
|
||||
if (ticket.claimerId && m.author.id !== ticket.claimerId && ticket.staffChannelId) {
|
||||
try {
|
||||
const staffChan = await m.guild.channels.fetch(ticket.staffChannelId).catch(() => null);
|
||||
if (staffChan) {
|
||||
await pingStaffChannel(staffChan, ticket.claimerId, m);
|
||||
}
|
||||
// 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(() => {});
|
||||
|
||||
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||||
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||||
const dmEnabled = await getNotifyDm(ticket.claimerId);
|
||||
if (dmEnabled) {
|
||||
const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null);
|
||||
@@ -39,26 +41,6 @@ async function handleDiscordReply(m) {
|
||||
.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;
|
||||
|
||||
798
models.js
798
models.js
@@ -1,795 +1,5 @@
|
||||
var mongoose = require('mongoose');
|
||||
|
||||
mongoose.model('Host', new mongoose.Schema({
|
||||
hostname: String,
|
||||
ip: String,
|
||||
region: String,
|
||||
provider: String,
|
||||
memory: String,
|
||||
status: String,
|
||||
ipGateway: String,
|
||||
memFree: Number,
|
||||
cpuUsage: Number,
|
||||
diskFree: Number,
|
||||
lastSeen: { type: Number, default: Date.now },
|
||||
lostInUse: { type: [Number], default: [] },
|
||||
statsHistory: [{
|
||||
timestamp: Number,
|
||||
memFree: Number,
|
||||
cpuUsage: Number,
|
||||
diskFree: Number
|
||||
}]
|
||||
}));
|
||||
|
||||
// Update for each new game
|
||||
mongoose.model('User', new mongoose.Schema({
|
||||
email: String,
|
||||
discordID: {type: String, default: ""},
|
||||
customerId: String,
|
||||
usedPaypal: {type: Boolean, default: false},
|
||||
passwordHash: String,
|
||||
resetPasswordToken: String,
|
||||
resetPasswordExpires: Date,
|
||||
sessionToken: {type: String, default: null},
|
||||
indifferentBroccoli: {type: Boolean, default: false},
|
||||
paymentLink: {type: String, default: null},
|
||||
palpocalypseEligible: {type: Boolean, default: false},
|
||||
palpocalypseClaimed: {type: Boolean, default: false},
|
||||
//Admin
|
||||
machineStats: [{
|
||||
name: String,
|
||||
memoryFree: Number,
|
||||
cpuUsagePercentage: Number,
|
||||
diskFree: Number
|
||||
}],
|
||||
|
||||
//Subusers
|
||||
subUserServers: [{
|
||||
linuxUsername: String,
|
||||
permissions: { type: Object, default: {} }
|
||||
}],
|
||||
|
||||
subusers: [{
|
||||
email: String,
|
||||
inviteToken: String,
|
||||
inviteExpires: Date,
|
||||
}],
|
||||
|
||||
// Activity log
|
||||
activities: [{
|
||||
serverId: mongoose.Schema.Types.ObjectId,
|
||||
action: String,
|
||||
timestamp: { type: Number, default: Date.now }
|
||||
}],
|
||||
|
||||
serverOrder: [String],
|
||||
|
||||
servers: [{
|
||||
// Public server page info
|
||||
tags: String,
|
||||
thumbnailImageLink: String,
|
||||
links: [String],
|
||||
|
||||
// Server settings
|
||||
status: {type: String, default: "Setting Up"},
|
||||
isTrial: {type: Boolean, default: false},
|
||||
trialExpiry: {type: Date, default: null},
|
||||
sentExpiryNotification: {type: Boolean, default: false},
|
||||
sentTrialEndedNotification: {type: Boolean, default: false},
|
||||
sentWelcomeFeedbackRequest: {type: Boolean, default: false},
|
||||
sentUpcomingCancellationNotice : {type: Boolean, default: false},
|
||||
linuxUsername: String,
|
||||
linuxPassword: String, //todo: store hash
|
||||
telnetPassword: String,
|
||||
controlPanelPassword: String,
|
||||
subscriptionId: {type:String,default:null},
|
||||
subscriptionId_PayPal: {type:String,default:null},
|
||||
subscriptionId_PayPalFrozen: {type:String,default:null},
|
||||
subscriptionActive: {type: Boolean, default: false},
|
||||
subscriptionStatus: {type: String, default: null},
|
||||
subscriptionScheduledFreeze: {type: String, default: null},
|
||||
subscriptionScheduledFreezeJobId: {type: String, default: null},
|
||||
subscriptionScheduledCancel: {type: String, default: null},
|
||||
subscriptionScheduledCancelJobId: {type: String, default: null},
|
||||
ip: String,
|
||||
ipGateway: {type: String, default: null},
|
||||
serverPort: {type: Number, default: null},
|
||||
serverPortGateway: {type: Number, default: null},
|
||||
region: {type: String, default: "na-east"},
|
||||
game: String,
|
||||
discordAdmins: {type: [String], default: []},
|
||||
// Generic game settings
|
||||
serverName: String,
|
||||
serverPassword: String, //todo: store hash?
|
||||
gameWorld: String, // pre-Alpha 17
|
||||
serverDescription: String,
|
||||
serverMaxPlayerCount: Number,
|
||||
worldName: String,
|
||||
|
||||
// StarRupture settings
|
||||
sessionName: {type: String, default: "IndifferentWorld"},
|
||||
saveGameInterval: {type: Number, default: 300},
|
||||
saveGameName: {type: String, default: "AutoSave0.sav"},
|
||||
loadSavedGame: {type: Boolean, default: true},
|
||||
|
||||
scheduledRestarts: {
|
||||
type: [{
|
||||
command: {type: String, default: "restart"},
|
||||
rconCommand: String,
|
||||
minute: Number,
|
||||
hour: Number,
|
||||
day: Number,
|
||||
intervalValue: Number,
|
||||
intervalUnit: String,
|
||||
intervalMinute: Number
|
||||
}],
|
||||
default: []
|
||||
},
|
||||
admins: [{
|
||||
steamId: String,
|
||||
permissionLevel: Number
|
||||
}],
|
||||
backups: [{
|
||||
timestamp: Number
|
||||
}],
|
||||
ActiveMods: [{
|
||||
modId: String
|
||||
}],
|
||||
playersOnline: [{
|
||||
name: String,
|
||||
raw: {
|
||||
score: Number,
|
||||
time: Number
|
||||
}
|
||||
}],
|
||||
|
||||
//-----7 Day to Die-----//
|
||||
serverIsPublic: String, // pre-Alpha 17
|
||||
adminPassword: String, //todo: store hash?,
|
||||
serverWebsiteUrl: String,
|
||||
gameName: String,
|
||||
gameDifficulty: Number,
|
||||
gameMode: String, // don't make the GameModeSurvival or else anyone can place blocks anywhere
|
||||
zombiesRun: Number,
|
||||
buildCreate: String,
|
||||
dayNightLength: Number,
|
||||
dayLightLength: Number,
|
||||
playerKillingMode: Number,
|
||||
persistentPlayerProfiles: String,
|
||||
playerSafeZoneLevel: Number,
|
||||
playerSafeZoneHours: Number,
|
||||
deathPenalty: Number,
|
||||
dropOnDeath: Number,
|
||||
dropOnQuit: Number,
|
||||
bloodMoonEnemyCount: Number,
|
||||
enemySpawnMode: String,
|
||||
enemyDifficulty: Number,
|
||||
blockDurabilityModifier: Number,
|
||||
lootAbundance: Number,
|
||||
lootRespawnDays: Number,
|
||||
landClaimSize: Number,
|
||||
landClaimDeadZone: Number,
|
||||
landClaimExpiryTime: Number,
|
||||
landClaimDecayMode: Number,
|
||||
landClaimOnlineDurabilityModifier: Number,
|
||||
landClaimOfflineDurabilityModifier: Number,
|
||||
airDropFrequency: Number,
|
||||
airDropMarker: String,
|
||||
maxSpawnedZombies: Number,
|
||||
maxSpawnedAnimals: Number,
|
||||
eacEnabled: String,
|
||||
maxUncoveredMapChunksPerPlayer: Number,
|
||||
bedrollDeadZoneSize: Number,
|
||||
questProgressionDailyLimit: Number,
|
||||
maxChunkAge: Number,
|
||||
serverAllowCrossplay: {type: String, default: "false"},
|
||||
biomeProgression: {type: String, default: "true"},
|
||||
stormFreq: {type: Number, default: 100},
|
||||
allowSpawnNearFriend: {type: Number, default: 2},
|
||||
ignoreEOSSanctions: {type: String, default: "false"},
|
||||
playerCount: Number,
|
||||
jarRefund: {type: Number, default: 0},
|
||||
|
||||
// Alpha >= 17 only
|
||||
version: Number,
|
||||
serverVisibility: Number,
|
||||
serverReservedSlots: Number,
|
||||
serverReservedSlotsPermission: Number,
|
||||
serverDisabledNetworkProtocols: String,
|
||||
worldGenSeed: String,
|
||||
worldGenSize: Number, //todo: figure out max
|
||||
telnetFailedLoginLimit: Number, // todo: figure out max or don't use
|
||||
telnetFailedLoginsBlocktime: Number, // todo: figure out max or don't use
|
||||
terminalWindowEnabled: Boolean, //pre Alpha 17.2 default was false
|
||||
partySharedKillRange: Number,
|
||||
hideCommandExecutionLog: Number,
|
||||
serverLoginConfirmationText: String,
|
||||
zombieFeralSense: Number,
|
||||
zombieMove: Number,
|
||||
zombieMoveNight: Number,
|
||||
zombieFeralMove: Number,
|
||||
zombieBMMove: Number,
|
||||
// Alpha >= 17.2 only
|
||||
bloodMoonFrequency: Number,
|
||||
bloodMoonRange: Number,
|
||||
bloodMoonWarning: Number,
|
||||
xpMultiplier: Number,
|
||||
blockDamagePlayer: Number,
|
||||
blockDamageAI: Number,
|
||||
blockDamageAIBM: Number,
|
||||
landClaimCount: Number,
|
||||
// Alpha >= 18 only
|
||||
serverMaxAllowedViewDistance: Number,
|
||||
serverMaxWorldTransferSpeedKiBs: Number,
|
||||
bedrollExpiryTime: Number,
|
||||
|
||||
sevenDaysRegion: String,
|
||||
language: String,
|
||||
|
||||
//-----Abiotic Factor-----//
|
||||
|
||||
//-----ARK-----//
|
||||
BETA: {type: String, default: "public"},
|
||||
AdminLogging: Boolean,
|
||||
AllowCaveBuildingPvE: Boolean,
|
||||
AllowFlyerCarryPvE: Boolean,
|
||||
AllowHideDamageSourceFromLogs: Boolean,
|
||||
AllowSharedConnections: Boolean,
|
||||
AllowTekSuitPowersInGenesis: Boolean,
|
||||
allowThirdPersonPlayer: Boolean,
|
||||
alwaysNotifyPlayerJoined: Boolean,
|
||||
alwaysNotifyPlayerLeft: Boolean,
|
||||
AutoSavePeriodMinutes: Number,
|
||||
bAllowPlatformSaddleMultiFloors: Boolean,
|
||||
BanListURL: String,
|
||||
bForceCanRideFliers: Boolean,
|
||||
ClampResourceHarvestDamage: Boolean,
|
||||
Cluster: [{
|
||||
serverName: String,
|
||||
gameWorld: String,
|
||||
clusterId: String,
|
||||
serverId: String,
|
||||
serverPort: Number,
|
||||
serverMaxPlayerCount: Number
|
||||
}],
|
||||
CrossARKAllowForeignDinoDownloads: Boolean,
|
||||
Crossplay: {type: Boolean, default: false},
|
||||
NoBattlEye: {type: Boolean, default: false},
|
||||
ForceAllowCaveFlyers: {type: Boolean, default: false},
|
||||
ShowFloatingDamageText: {type: Boolean, default: false},
|
||||
CryopodNerfDamageMult: Number,
|
||||
CryopodNerfDuration: Number,
|
||||
CryopodNerfIncomingDamageMultPercent: Number,
|
||||
CustomDynamicConfigUrl: String,
|
||||
DayCycleSpeedScale: Number,
|
||||
DayTimeSpeedScale: Number,
|
||||
DifficultyOffset: Number,
|
||||
DinoCharacterFoodDrainMultiplier: Number,
|
||||
DinoCharacterHealthRecoveryMultiplier: Number,
|
||||
DinoCharacterStaminaDrainMultiplier: Number,
|
||||
DinoCountMultiplier: Number,
|
||||
DinoDamageMultiplier: Number,
|
||||
DinoResistanceMultiplier: Number,
|
||||
DisableDinoDecayPvE: Boolean,
|
||||
DisablePvEGamma: Boolean,
|
||||
DisableStructureDecayPvE: Boolean,
|
||||
DisableWeatherFog: Boolean,
|
||||
EnableCryopodNerf: Boolean,
|
||||
EnableCryoSicknessPVE: Boolean,
|
||||
EnablePvPGamma: Boolean,
|
||||
GameIniSettings: [{
|
||||
text: String
|
||||
}],
|
||||
globalVoiceChat: Boolean,
|
||||
HarvestAmountMultiplier: Number,
|
||||
HarvestHealthMultiplier: Number,
|
||||
ItemStackSizeMultiplier: Number,
|
||||
MaxGateFrameOnSaddles: Number,
|
||||
MaxPlatformSaddleStructureLimit: Number,
|
||||
MaxPlayers: Number,
|
||||
MaxStructuresInRange: Number,
|
||||
MaxTamedDinos: Number,
|
||||
MaxTributeDinos: Number,
|
||||
MaxTributeItems: Number,
|
||||
NightTimeSpeedScale: Number,
|
||||
noTributeDownloads: Boolean,
|
||||
PerPlatformMaxStructuresMultiplier: Number,
|
||||
PlatformSaddleBuildAreaBoundsMultiplier: Number,
|
||||
PlayerCharacterFoodDrainMultiplier: Number,
|
||||
PlayerCharacterHealthRecoveryMultiplier: Number,
|
||||
PlayerCharacterStaminaDrainMultiplier: Number,
|
||||
PlayerCharacterWaterDrainMultiplier: Number,
|
||||
PlayerDamageMultiplier: Number,
|
||||
PlayerResistanceMultiplier: Number,
|
||||
proximityChat: Boolean,
|
||||
PvEDinoDecayPeriodMultiplier: Number,
|
||||
PvEStructureDecayDestructionPeriod: Number,
|
||||
PvEStructureDecayPeriodMultiplier: Number,
|
||||
PvPStructureDecay: Boolean,
|
||||
RandomSupplyCratePoints: Boolean,
|
||||
ResourcesRespawnPeriodMultiplier: Number,
|
||||
ServerAdminPassword: String,
|
||||
serverForceNoHud: Boolean,
|
||||
serverHardcore: Boolean,
|
||||
serverPVE: Boolean,
|
||||
ShowMapPlayerLocation: Boolean,
|
||||
SpectatorPassword: String,
|
||||
StructureDamageMultiplier: Number,
|
||||
StructureResistanceMultiplier: Number,
|
||||
TamingSpeedMultiplier: Number,
|
||||
TheMaxStructuresInRange: Number,
|
||||
TribeNameChangeCooldown: Number,
|
||||
TributeCharacterExpirationSeconds: Number,
|
||||
TributeDinoExpirationSeconds: Number,
|
||||
TributeItemExpirationSeconds: Number,
|
||||
XPMultiplier: Number,
|
||||
|
||||
gameIni: String,
|
||||
|
||||
//-----Conan Exiles-----//
|
||||
modList: String,
|
||||
|
||||
//-----Core Keeper-----//
|
||||
gameID: String,
|
||||
worldSeed: {type: Number, default: 0},
|
||||
worldIndex: {type: Number, default: 0},
|
||||
worldMode: {type: Number, default: 0},
|
||||
season: {type: Number, default: -1},
|
||||
corekeeperMods: {type: String, default: ""},
|
||||
|
||||
//-----Counter Strike 2 (CS2)-----//
|
||||
|
||||
//-----DayZ-----//
|
||||
enableWhitelist: { type: Boolean, default: false },
|
||||
disable3rdPerson: { type: Boolean, default: false },
|
||||
disableCrosshair: { type: Boolean, default: false },
|
||||
disablePersonalLight: { type: Boolean, default: false },
|
||||
disableVoicechat: { type: Boolean, default: false },
|
||||
modList: {type: String, default: ""},
|
||||
|
||||
//-----ECO-----//
|
||||
|
||||
//-----Enshrouded-----//
|
||||
|
||||
//-----Factorio-----//
|
||||
spaceAgeEnabled: {type: Boolean, default: true},
|
||||
autoUpdateMods: {type: Boolean, default: false},
|
||||
visibilityPublic: {type: Boolean, default: true},
|
||||
factorioUsername: {type: String, default: ""},
|
||||
factorioPassword: {type: String, default: ""},
|
||||
factorioToken: {type: String, default: ""},
|
||||
requireUserVerification: {type: Boolean, default: true},
|
||||
allowCommands: {type: String, default: "admins-only"},
|
||||
afkAutokickInterval: {type: Number, default: 0},
|
||||
autoPause: {type: Boolean, default: true},
|
||||
autoPauseWhenPlayersConnect: {type: Boolean, default: false},
|
||||
onlyAdminsCanPause: {type: Boolean, default: true},
|
||||
|
||||
//-----FiveM-----//
|
||||
licenseKey: {type: String, default: ""},
|
||||
locale: String,
|
||||
|
||||
//-----The Front-----//
|
||||
extraArgs: String,
|
||||
|
||||
//-----Garry's Mod-----//
|
||||
workshopCollection: String,
|
||||
serverCheats: Boolean,
|
||||
customParameters: String,
|
||||
GLST: String,
|
||||
|
||||
//-----Hytale-----//
|
||||
viewDistance: {type: Number, default: 12},
|
||||
MaxViewRadius: {type: Number, default: 32},
|
||||
serverMOTD: {type: String, default: ""},
|
||||
defaultWorld: {type: String, default: "default"},
|
||||
selectedWorld: {type: String, default: "default"},
|
||||
IsPvpEnabled: {type: Boolean, default: false},
|
||||
IsFallDamageEnabled: {type: Boolean, default: true},
|
||||
IsGameTimePaused: {type: Boolean, default: false},
|
||||
IsSpawningNPC: {type: Boolean, default: true},
|
||||
IsSpawnMarkersEnabled: {type: Boolean, default: true},
|
||||
IsAllNPCFrozen: {type: Boolean, default: false},
|
||||
IsCompassUpdating: {type: Boolean, default: true},
|
||||
IsObjectiveMarkersEnabled: {type: Boolean, default: true},
|
||||
itemsLossMode: {type: String, default: "Configured"},
|
||||
itemsAmountLossPercentage: {type: Number, default: 10},
|
||||
itemsDurabilityLossPercentage: {type: Number, default: 10},
|
||||
gameMode: {type: String, default: "Adventure"},
|
||||
hytaleOAuthUrl: String,
|
||||
hytaleAuthLinkClicked: {type: Boolean, default: false},
|
||||
|
||||
//-----Palworld-----//
|
||||
AutoResetGuildTimeNoOnlinePlayers: {type: Number, default: 72},
|
||||
bActiveUNKO: {type: Boolean, default: false},
|
||||
BanListURL: {type: String, default: "https://api.palworldgame.com/api/banlist.txt"},
|
||||
BaseCampMaxNum: {type: Number, default: 128},
|
||||
BaseCampWorkerMaxNum: {type: Number, default: 15},
|
||||
bAutoResetGuildNoOnlinePlayers: {type: Boolean, default: false},
|
||||
bCanPickupOtherGuildDeathPenaltyDrop: {type: Boolean, default: false},
|
||||
bEnableAimAssistKeyboard: {type: Boolean, default: false},
|
||||
bEnableAimAssistPad: {type: Boolean, default: true},
|
||||
bEnableDefenseOtherGuildPlayer: {type: Boolean, default: false},
|
||||
bEnableFastTravel: {type: Boolean, default: true},
|
||||
bEnableFriendlyFire: {type: Boolean, default: false},
|
||||
bEnableInvaderEnemy: {type: Boolean, default: true},
|
||||
bEnableNonLoginPenalty: {type: Boolean, default: true},
|
||||
bEnablePlayerToPlayerDamage: {type: Boolean, default: false},
|
||||
bExistPlayerAfterLogout: {type: Boolean, default: false},
|
||||
bIsMultiplay: {type: Boolean, default: false},
|
||||
bIsPvP: {type: Boolean, default: false},
|
||||
bIsStartLocationSelectByMap: {type: Boolean, default: true},
|
||||
BuildObjectDamageRate: {type: Number, default: 1},
|
||||
BuildObjectDeteriorationDamageRate: {type: Number, default: 1},
|
||||
bUseAuth: {type: Boolean, default: true},
|
||||
CollectionDropRate: {type: Number, default: 1},
|
||||
CollectionObjectHpRate: {type: Number, default: 1},
|
||||
CollectionObjectRespawnSpeedRate: {type: Number, default: 1},
|
||||
CoopPlayerMaxNum: {type: Number, default: 4},
|
||||
DayTimeSpeedRate: {type: Number, default: 1},
|
||||
DeathPenalty: {type: String, default: "All"},
|
||||
Difficulty: {type: String, default: "None"},
|
||||
DropItemAliveMaxHours: {type: Number, default: 1},
|
||||
DropItemMaxNum: {type: Number, default: 3000},
|
||||
DropItemMaxNum_UNKO: {type: Number, default: 100},
|
||||
EnemyDropItemRate: {type: Number, default: 1},
|
||||
ExpRate: {type: Number, default: 1},
|
||||
GuildPlayerMaxNum: {type: Number, default: 20},
|
||||
NightTimeSpeedRate: {type: Number, default: 1},
|
||||
PalAutoHPRegeneRate: {type: Number, default: 1},
|
||||
PalAutoHpRegeneRateInSleep: {type: Number, default: 1},
|
||||
PalCaptureRate: {type: Number, default: 1},
|
||||
PalDamageRateAttack: {type: Number, default: 1},
|
||||
PalDamageRateDefense: {type: Number, default: 1},
|
||||
PalEggDefaultHatchingTime: {type: Number, default: 72},
|
||||
PalSpawnNumRate: {type: Number, default: 1},
|
||||
PalStaminaDecreaceRate: {type: Number, default: 1},
|
||||
PalStomachDecreaceRate: {type: Number, default: 1},
|
||||
PlayerAutoHPRegeneRate: {type: Number, default: 1},
|
||||
PlayerAutoHpRegeneRateInSleep: {type: Number, default: 1},
|
||||
PlayerDamageRateAttack: {type: Number, default: 1},
|
||||
PlayerDamageRateDefense: {type: Number, default: 1},
|
||||
PlayerStaminaDecreaceRate: {type: Number, default: 1},
|
||||
PlayerStomachDecreaceRate: {type: Number, default: 1},
|
||||
palRegion: {type: String, default: ""},
|
||||
WorkSpeedRate: {type: Number, default: 1},
|
||||
Community: {type: Boolean, default: false},
|
||||
BaseCampMaxNumInGuild : {type: Number, default: 3},
|
||||
ConnectPlatform: {type: String, default: "Steam"},
|
||||
SupplyDropSpan : {type: Number, default: 180},
|
||||
palworldVersion: {type: String, default: "Latest"},
|
||||
RandomizerType: {type: String, default: "None"},
|
||||
RandomizerSeed: {type: String, default: ""},
|
||||
ChatPostLimitPerMinute: {type: Number, default: 10},
|
||||
EnablePredatorBossPal: {type: Boolean, default: true},
|
||||
BuildObjectHpRate: {type: Number, default: 1},
|
||||
Hardcore: {type: Boolean, default: false},
|
||||
CharacterRecreateInHardcore: {type: Boolean, default: false},
|
||||
PalLost: {type: Boolean, default: false},
|
||||
BuildAreaLimit: {type: Boolean, default: false},
|
||||
ItemWeightRate: {type: Number, default: 1},
|
||||
MaxBuildingLimitNum: {type: Number, default: 0},
|
||||
CrossplayPlatforms: {type: String, default: "(Steam,Xbox,PS5,Mac)"},
|
||||
AllowGlobalPalboxExport: {type: Boolean, default: true},
|
||||
AllowGlobalPalboxImport: {type: Boolean, default: false},
|
||||
randomPalLevels: {type: Boolean, default: false},
|
||||
equipmentDurabilityDamageRate: {type: Number, default: 1},
|
||||
itemContainerForceMarkDirtyInterval: {type: Number, default: 1},
|
||||
itemCorruptionMultiplier: {type: Number, default: 1},
|
||||
|
||||
//-----Project Zomboid-----//
|
||||
autoRestartEnabled: {type: Boolean, default: false},
|
||||
build42Unstable: {type: Boolean, default: false},
|
||||
PZVersion: {type: String, default: "41.78.16"},
|
||||
|
||||
//-----Rust-----//
|
||||
mapSize: Number,
|
||||
maxMapSize: Number,
|
||||
mapSeed: Number,
|
||||
oxideEnabled: Boolean,
|
||||
|
||||
//-----Valheim-----//
|
||||
valheimPlusEnabled: Boolean,
|
||||
valheimPlusFork: {type: String, default: "valheimPlus"},
|
||||
|
||||
//-----Satisfactory-----//
|
||||
serverVersion: {type: String, default: "public"},
|
||||
satisfactoryAdminPassword: {type: String, default: ''},
|
||||
satisfactoryHealth: {type: String, default: ''},
|
||||
satisfactoryActiveSession: {type: String, default: ''},
|
||||
satisfactoryTechTier: {type: Number, default: 0},
|
||||
satisfactoryTickRate: {type: Number, default: 0},
|
||||
satisfactoryGameDuration: {type: Number, default: 0},
|
||||
satisfactoryActiveSchematic: {type: String, default: ''},
|
||||
satisfactoryIsGamePaused: {type: Boolean, default: false},
|
||||
|
||||
//-----Sons of the Forest-----//
|
||||
|
||||
//-----Soulmask-----//
|
||||
pvMode: {type: String, default: "pvp"},
|
||||
|
||||
//-----Terraria-----//
|
||||
//----Already made/Non-config.json----//
|
||||
//gameDifficulty: Number - 7days
|
||||
//WorldGenSize: Number - 7days
|
||||
MaxSlots: Number, //uses serverMaxPlayerCount
|
||||
MOTD: String,
|
||||
secure: Number,
|
||||
//----Booleans----//
|
||||
UseServerName: Boolean,
|
||||
DebugLogs: Boolean,
|
||||
DisableLoginBeforeJoin: Boolean,
|
||||
IgnoreChestStacksOnLoad: Boolean,
|
||||
Autosave: Boolean,
|
||||
AnnounceSave: Boolean,
|
||||
SaveWorldOnCrash: Boolean,
|
||||
SaveWorldOnLastPlayerExit: Boolean,
|
||||
InfiniteInvasion: Boolean,
|
||||
SpawnProtection: Boolean,
|
||||
RangeChecks: Boolean,
|
||||
HardcoreOnly: Boolean,
|
||||
MediumCoreOnly: Boolean,
|
||||
DisableBuild: Boolean,
|
||||
DisableHardmode: Boolean,
|
||||
DisableDungeonGuardian: Boolean,
|
||||
DisableClownBombs: Boolean,
|
||||
DisableSnowBalls: Boolean,
|
||||
DisableTombstones: Boolean,
|
||||
DisableInvisPvP: Boolean,
|
||||
RegionProtectChests: Boolean,
|
||||
RegionProtectGemLocks: Boolean,
|
||||
IgnoreProjUpdate: Boolean,
|
||||
IgnoreProjKill: Boolean,
|
||||
AllowCutTilesAndBreakables: Boolean,
|
||||
AllowIce: Boolean,
|
||||
AllowCrimsonCreep: Boolean,
|
||||
AllowCorruptionCreep: Boolean,
|
||||
AllowHallowCreep: Boolean,
|
||||
PreventBannedItemSpawn: Boolean,
|
||||
PreventDeadModification: Boolean,
|
||||
PreventInvalidPlaceStyle: Boolean,
|
||||
ForceXmas: Boolean,
|
||||
ForceHalloween: Boolean,
|
||||
AllowAllowedGroupsToSpawnBannedItems: Boolean,
|
||||
AnonymousBossInvasions: Boolean,
|
||||
RememberLeavePos: Boolean,
|
||||
KickOnMediumcoreDeath: Boolean,
|
||||
BanOnMediumCoreDeath: Boolean,
|
||||
KickOnHardcoreDeath: Boolean,
|
||||
BanOnHardcoreDeath: Boolean,
|
||||
EnableWhitelist: Boolean,
|
||||
EnableIPBans: Boolean,
|
||||
EnableUUIDBans: Boolean,
|
||||
EnableBanOnUsernames: Boolean,
|
||||
KickProxyUsers: Boolean,
|
||||
RequireLogin: Boolean,
|
||||
AllowLoginAnyUsername: Boolean,
|
||||
AllowRegisterAnyUsername: Boolean,
|
||||
DisableUUIDLogin: Boolean,
|
||||
KickEmptyUUID: Boolean,
|
||||
KickOnTilePaintThresholdBroken: Boolean,
|
||||
KickOnTileLiquidThresholdBroken: Boolean,
|
||||
KickOnTileKillThresholdBroken: Boolean,
|
||||
KickOnTilePlaceThresholdBroken: Boolean,
|
||||
KickOnDamageThresholdBroken: Boolean,
|
||||
KickOnProjectileThresholdBroken: Boolean,
|
||||
KickOnHealOtherThresholdBroken: Boolean,
|
||||
ProjIgnoreShrapnel: Boolean,
|
||||
DisableSpewLogs: Boolean,
|
||||
DisableSecondUpdateLogs: Boolean,
|
||||
EnableGeoIP: Boolean,
|
||||
DisplayIPToAdmins: Boolean,
|
||||
EnableChatAboveHeads: Boolean,
|
||||
//----Numbers----//
|
||||
ReservedSlots: Number,
|
||||
InvasionMultiplier: Number,
|
||||
DefaultSpawnRate: Number,
|
||||
DefaultMaximumSpawns: Number,
|
||||
SpawnProtectionRadius: Number,
|
||||
MaxRangeForDisabled: Number,
|
||||
StatueSpawn200: Number,
|
||||
StatueSpawn600: Number,
|
||||
StatueSpawnWorld: Number,
|
||||
RespawnSeconds: Number,
|
||||
RespawnBossSeconds: Number,
|
||||
MaxHP: Number,
|
||||
MaxMP: Number,
|
||||
BombExplosionRadius: Number,
|
||||
MaximumLoginAttempts: Number,
|
||||
MinimumPasswordLength: Number,
|
||||
BCryptWorkFactor: Number,
|
||||
TilePaintThreshold: Number,
|
||||
TileKillThreshold: Number,
|
||||
TilePlaceThreshold: Number,
|
||||
TileLiquidThreshold: Number,
|
||||
MaxDamage: Number,
|
||||
MaxProjDamage: Number,
|
||||
ProjectileThreshold: Number,
|
||||
HealOtherThreshold: Number,
|
||||
//----Strings----//
|
||||
PvPMode: String,
|
||||
ForceTime: String,
|
||||
DefaultRegistrationGroupName: String,
|
||||
DefaultGuestGroupName: String,
|
||||
MediumcoreKickReason: String,
|
||||
MediumcoreBanReason: String,
|
||||
HardcoreKickReason: String,
|
||||
HardcoreBanReason: String,
|
||||
WhitelistKickReason: String,
|
||||
ServerFullReason: String,
|
||||
ServerFullNoReservedReason: String,
|
||||
HashAlgorithm: String,
|
||||
CommandSpecifier: String,
|
||||
CommandSilentSpecifier: String,
|
||||
SuperAdminChatPrefix: String,
|
||||
SuperAdminChatSuffix: String,
|
||||
ChatFormat: String,
|
||||
ChatAboveHeadsFormat: String,
|
||||
|
||||
//-----Minecraft-----//
|
||||
saveName: String,
|
||||
enableCommandBlock: Boolean,
|
||||
allowFlight: Boolean,
|
||||
iconLink: String,
|
||||
resourcePackLink: String,
|
||||
resourcePackLinkSHA1: String,
|
||||
requireResourcePack: Boolean,
|
||||
resourcePackPrompt: String,
|
||||
enforceWhitelist: Boolean,
|
||||
maxBuildHeight: Number,
|
||||
allowNether: Boolean,
|
||||
generateStructures: Boolean,
|
||||
spawnAnimals: Boolean,
|
||||
spawnNPCS: Boolean,
|
||||
spawnMonsters: Boolean,
|
||||
forceGamemode: Boolean,
|
||||
enableHardcore: Boolean,
|
||||
enablePvP: Boolean,
|
||||
playerIdleTimeout: Number,
|
||||
serverMaxAllowedViewDistance: Number,
|
||||
levelType: String,
|
||||
generatorSettings: String,
|
||||
enableRcon: Boolean,
|
||||
rconPassword: String,
|
||||
broadcastRconToOps: Boolean,
|
||||
broadcastConsoleToOps: Boolean,
|
||||
opPermissionLevel: Number,
|
||||
functionPermissionLevel: Number,
|
||||
serverType: {type: String, default: "VANILLA"},
|
||||
serverTypeVersion: {type: String, default: ""},
|
||||
gameDifficultyString: String,
|
||||
maxPlayers: Number,
|
||||
modpackurl: String,
|
||||
modpackName: String,
|
||||
modpackVersion: String,
|
||||
mcVersion: {type: String, default: "LATEST"},
|
||||
javaVersion: {type: String, default: "latest"},
|
||||
maxTickTime: Number,
|
||||
spawnProtectionRadius: Number,
|
||||
|
||||
selectedMods: [{
|
||||
// For Minecraft
|
||||
name: String,
|
||||
slug: String,
|
||||
// For Project Zomboid
|
||||
title: String,
|
||||
workshopId: String,
|
||||
modId: String,
|
||||
mapFolder: String,
|
||||
}],
|
||||
|
||||
//-----VRising-----//
|
||||
vrisingBepInExEnabled: Boolean,
|
||||
|
||||
//-----Icarus-----//
|
||||
shutdownIfNotJoinedFor: Number,
|
||||
shutdownIfEmptyFor: Number,
|
||||
|
||||
//-----Vintage Story-----//
|
||||
serverLanguage: String,
|
||||
serverWelcomeMessage: String,
|
||||
whitelistMode: Number,
|
||||
allowPvp: Boolean,
|
||||
verifyPlayerAuth: Boolean,
|
||||
allowFireSpread: Boolean,
|
||||
allowFallingBlocks: Boolean,
|
||||
passTimeWhenEmpty: Boolean,
|
||||
clientConnectionTimeout: Number,
|
||||
maxChunkRadius: Number,
|
||||
chatRateLimit: Number,
|
||||
maxOwnedGroupChannelsPerUser: Number,
|
||||
seed: String,
|
||||
allowCreativeMode: Boolean,
|
||||
playStyle: String,
|
||||
worldType: String,
|
||||
mapSizeX: Number,
|
||||
mapSizeY: Number,
|
||||
mapSizeZ: Number,
|
||||
gameMode: String,
|
||||
startingClimate: String,
|
||||
spawnRadius: Number,
|
||||
graceTimer: Number,
|
||||
deathPunishment: String,
|
||||
droppedItemsTimer: Number,
|
||||
seasons: String,
|
||||
playerlives: Number,
|
||||
lungCapacity: Number,
|
||||
daysPerMonth: Number,
|
||||
harshWinters: Boolean,
|
||||
blockGravity: String,
|
||||
caveIns: String,
|
||||
allowUndergroundFarming: Boolean,
|
||||
noLiquidSourceTransport: Boolean,
|
||||
bodyTemperatureResistance: Number,
|
||||
creatureHostility: String,
|
||||
creatureStrength: Number,
|
||||
creatureSwimSpeed: Number,
|
||||
playerHealthPoints: Number,
|
||||
playerHungerSpeed: Number,
|
||||
playerHealthRegenSpeed: Number,
|
||||
playerMoveSpeed: Number,
|
||||
foodSpoilSpeed: Number,
|
||||
saplingGrowthRate: Number,
|
||||
toolDurability: Number,
|
||||
toolMiningSpeed: Number,
|
||||
propickNodeSearchRadius: Number,
|
||||
microblockChiseling: String,
|
||||
allowCoordinateHud: Boolean,
|
||||
allowMap: Boolean,
|
||||
colorAccurateWorldmap: Boolean,
|
||||
loreContent: Boolean,
|
||||
clutterObtainable: String,
|
||||
lightningFires: Boolean,
|
||||
allowTimeswitch: Boolean,
|
||||
temporalStability: Boolean,
|
||||
temporalStorms: String,
|
||||
tempstormDurationMul: Number,
|
||||
temporalRifts: String,
|
||||
temporalGearRespawnUses: Number,
|
||||
temporalStormSleeping: Number,
|
||||
worldClimate: String,
|
||||
landcover: Number,
|
||||
oceanscale: Number,
|
||||
upheavelCommonness: Number,
|
||||
geologicActivity: Number,
|
||||
landformScale: Number,
|
||||
worldWidth: Number,
|
||||
worldLength: Number,
|
||||
worldEdge: String,
|
||||
polarEquatorDistance: Number,
|
||||
globalTemperature: Number,
|
||||
globalPrecipitation: Number,
|
||||
globalForestation: Number,
|
||||
globalDepositSpawnRate: Number,
|
||||
surfaceCopperDeposits: Number,
|
||||
surfaceTinDeposits: Number,
|
||||
snowAccum: Boolean,
|
||||
allowLandClaiming: Boolean,
|
||||
classExclusiveRecipes: Boolean,
|
||||
auctionHouse: Boolean,
|
||||
vsVersion: String
|
||||
}]
|
||||
}));
|
||||
|
||||
mongoose.model('DashboardMetrics', new mongoose.Schema({
|
||||
timestamp: { type: Date, default: Date.now, expires: 31536000 },
|
||||
activeUsers: Number,
|
||||
workerId: String
|
||||
}));
|
||||
|
||||
mongoose.model('ErrorLog', new mongoose.Schema({
|
||||
timestamp: { type: Date, default: Date.now, expires: 2592000 }, // 30 days
|
||||
statusCode: Number,
|
||||
message: String,
|
||||
stack: String,
|
||||
url: String,
|
||||
method: String,
|
||||
userId: String,
|
||||
userEmail: String,
|
||||
authenticated: Boolean,
|
||||
sessionValid: Boolean
|
||||
}));
|
||||
|
||||
// ===== Broccolini Bot Models =====
|
||||
|
||||
const ticketSchema = new mongoose.Schema({
|
||||
@@ -864,14 +74,6 @@ mongoose.model('StaffSettings', new mongoose.Schema({
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
}));
|
||||
|
||||
mongoose.model('StaffNotification', new mongoose.Schema({
|
||||
userId: { type: String, required: true, unique: true },
|
||||
guildId: String,
|
||||
channelId: String,
|
||||
cooldownHours: { type: Number, default: 1 },
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
}));
|
||||
|
||||
mongoose.model('StaffSignature', new mongoose.Schema({
|
||||
userId: { type: String, required: true, unique: true },
|
||||
guildId: { type: String, required: true },
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* bOSScord API routes for broccolini-bot: ticket list/detail, thread from Discord, send message.
|
||||
* Auth via BOSSCORD_API_KEY. Mount on Express in broccolini-discord.js.
|
||||
*/
|
||||
require('../models'); // ensure Ticket model is registered
|
||||
const express = require('express');
|
||||
const mongoose = require('mongoose');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { getBot } = require('../api/bosscordClient');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { updateTicketActivity } = require('../services/tickets');
|
||||
const { enqueueSend } = require('../services/channelQueue');
|
||||
const { extractRawEmail, safeEqual } = require('../utils');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
const router = express.Router();
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
const CORS_ORIGIN = process.env.BOSSCORD_CLIENT_ORIGIN || 'http://100.114.205.53:3081';
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' }
|
||||
});
|
||||
|
||||
function corsMiddleware(req, res, next) {
|
||||
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Staff-Discord-Id');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const key = process.env.BOSSCORD_API_KEY;
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: 'bOSScord API not configured (BOSSCORD_API_KEY)' });
|
||||
}
|
||||
const auth = req.headers.authorization;
|
||||
const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
|
||||
// Identical response body for missing vs invalid token — don't tell a probe which state it's in.
|
||||
if (!safeEqual(token, key)) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.use(apiLimiter);
|
||||
router.use(corsMiddleware);
|
||||
router.use(authMiddleware);
|
||||
|
||||
function requireDb(req, res, next) {
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
return res.status(503).json({ error: 'Database not ready yet. Wait for the bot to finish starting.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.use(requireDb);
|
||||
|
||||
function resolveTicketId(id) {
|
||||
if (mongoose.Types.ObjectId.isValid(id) && String(new mongoose.Types.ObjectId(id)) === id) {
|
||||
return Ticket.findOne({ _id: id });
|
||||
}
|
||||
const num = parseInt(id, 10);
|
||||
if (!Number.isNaN(num)) {
|
||||
return Ticket.findOne({ ticketNumber: num });
|
||||
}
|
||||
return Ticket.findOne({ gmailThreadId: id });
|
||||
}
|
||||
|
||||
/** GET /api/tickets — list tickets. Query: status, priority, claimedBy, limit */
|
||||
router.get('/tickets', async (req, res, next) => {
|
||||
try {
|
||||
if (!Ticket) return res.status(503).json({ error: 'Ticket model not loaded' });
|
||||
const { status, priority, claimedBy, limit = 50 } = req.query;
|
||||
const filter = {};
|
||||
if (status) filter.status = status;
|
||||
if (priority) filter.priority = priority;
|
||||
if (claimedBy !== undefined && claimedBy !== '') filter.claimedBy = claimedBy === 'null' ? null : claimedBy;
|
||||
const limitNum = Math.min(parseInt(limit, 10) || 50, 100);
|
||||
const tickets = await Ticket.find(filter)
|
||||
.sort({ lastActivity: -1, createdAt: -1 })
|
||||
.limit(limitNum)
|
||||
.lean();
|
||||
return res.json({ tickets });
|
||||
} catch (err) {
|
||||
console.error('GET /api/tickets error:', err.message);
|
||||
console.error(err.stack);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/me/tickets — "my tickets" (claimed by staff). Query: X-Staff-Discord-Id or claimedBy */
|
||||
router.get('/me/tickets', async (req, res) => {
|
||||
try {
|
||||
const claimedBy = req.headers['x-staff-discord-id'] || req.query.claimedBy;
|
||||
if (!claimedBy) {
|
||||
return res.status(400).json({ error: 'Provide X-Staff-Discord-Id header or claimedBy query' });
|
||||
}
|
||||
const tickets = await Ticket.find({ claimedBy, status: 'open' })
|
||||
.sort({ lastActivity: -1, createdAt: -1 })
|
||||
.limit(100)
|
||||
.lean();
|
||||
res.json({ tickets });
|
||||
} catch (err) {
|
||||
console.error('GET /api/me/tickets:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/tickets/:id — single ticket metadata */
|
||||
router.get('/tickets/:id', async (req, res) => {
|
||||
try {
|
||||
const ticket = await resolveTicketId(req.params.id);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Ticket not found' });
|
||||
}
|
||||
const out = ticket.toObject ? ticket.toObject() : { ...ticket };
|
||||
if (CONFIG.DISCORD_GUILD_ID) out.guildId = CONFIG.DISCORD_GUILD_ID;
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
console.error('GET /api/tickets/:id:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/tickets/:id/messages — thread from Discord */
|
||||
router.get('/tickets/:id/messages', async (req, res) => {
|
||||
try {
|
||||
const ticket = await resolveTicketId(req.params.id);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Ticket not found' });
|
||||
}
|
||||
if (!ticket.discordThreadId) {
|
||||
return res.json({ messages: [] });
|
||||
}
|
||||
const client = getBot();
|
||||
if (!client) {
|
||||
return res.status(503).json({ error: 'Discord client not ready' });
|
||||
}
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 100);
|
||||
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (!channel) {
|
||||
return res.status(404).json({ error: 'Discord channel not found' });
|
||||
}
|
||||
const messages = await channel.messages.fetch({ limit });
|
||||
const list = messages
|
||||
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
author: m.author?.username || 'unknown',
|
||||
authorId: m.author?.id,
|
||||
content: m.content,
|
||||
timestamp: m.createdAt?.toISOString?.(),
|
||||
isBot: m.author?.bot ?? false
|
||||
}));
|
||||
res.json({ messages: list });
|
||||
} catch (err) {
|
||||
console.error('GET /api/tickets/:id/messages:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/tickets/:id/messages — send message to Discord; for email tickets, also send via Gmail */
|
||||
router.post('/tickets/:id/messages', express.json(), async (req, res) => {
|
||||
try {
|
||||
const ticket = await resolveTicketId(req.params.id);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'Ticket not found' });
|
||||
}
|
||||
if (!ticket.discordThreadId) {
|
||||
return res.status(400).json({ error: 'Ticket has no Discord channel' });
|
||||
}
|
||||
const content = req.body?.content;
|
||||
if (!content || typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'Body must include content (string)' });
|
||||
}
|
||||
const client = getBot();
|
||||
if (!client) {
|
||||
return res.status(503).json({ error: 'Discord client not ready' });
|
||||
}
|
||||
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (!channel) {
|
||||
return res.status(404).json({ error: 'Discord channel not found' });
|
||||
}
|
||||
const discordUser = req.body.displayName || 'bOSScord';
|
||||
// Content originates from the bOSScord web UI (staff-gated) but still crosses an HTTP boundary —
|
||||
// allow explicit user/role mentions a staff member typed, block @everyone/@here.
|
||||
await enqueueSend(channel, { content, allowedMentions: { parse: ['users', 'roles'] } });
|
||||
|
||||
if (!ticket.gmailThreadId.startsWith('discord-')) {
|
||||
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?.payload?.headers) {
|
||||
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) {
|
||||
await sendGmailReply(
|
||||
ticket.gmailThreadId,
|
||||
content,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
msgId
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('bOSScord Gmail reply error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await updateTicketActivity(ticket.gmailThreadId);
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/tickets/:id/messages:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -5,14 +5,7 @@ const { CONFIG } = require('../config');
|
||||
const { safeEqual } = require('../utils');
|
||||
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
||||
const { logSystem } = require('../services/debugLog');
|
||||
const { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry');
|
||||
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
|
||||
const {
|
||||
getAllState: getNotificationState,
|
||||
setKeyEnabled,
|
||||
setCategoryEnabled,
|
||||
setMasterEnabled
|
||||
} = require('../services/notificationEnabled');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -76,7 +69,7 @@ router.post('/config', express.json(), async (req, res) => {
|
||||
// GET /discord/guild — return guild info for smart dropdowns
|
||||
router.get('/discord/guild', async (req, res) => {
|
||||
try {
|
||||
const client = require('../api/bosscordClient').getBot();
|
||||
const client = require('../api/botClient').getBot();
|
||||
if (!client) return res.status(503).json({ error: 'Bot not ready' });
|
||||
|
||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||
@@ -169,67 +162,6 @@ router.get('/restart/status', (req, res) => {
|
||||
res.json({ scheduledRestart: !!scheduledRestart });
|
||||
});
|
||||
|
||||
// GET /notifications/alerts — canonical bot-side notification alert catalog
|
||||
router.get('/notifications/alerts', (req, res) => {
|
||||
res.json(NOTIFICATION_REGISTRY);
|
||||
});
|
||||
|
||||
// GET /notifications/state — Phase 9: master flag + per-key enable map
|
||||
router.get('/notifications/state', (req, res) => {
|
||||
res.json(getNotificationState());
|
||||
});
|
||||
|
||||
// POST /notifications/toggle — Phase 9: mutate one of {master, category, key}
|
||||
//
|
||||
// Body shapes (exactly one of these must be used):
|
||||
// { master: true, enabled: <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
|
||||
// restarting the process. Reads REFRESH_TOKEN from .env via configPersistence,
|
||||
// probes Google with users.getProfile, and on success clears pollSuspended and
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bulk lookup Discord user information - IMPROVED VERSION
|
||||
*
|
||||
* Features:
|
||||
* - Saves progress incrementally (every 100 users)
|
||||
* - Can resume from where it left off
|
||||
* - Better error handling
|
||||
* - Uses guild member cache when possible
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/bulk-lookup-users-v2.js <input_file> <output_file>
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
// Load environment variables
|
||||
const envPath = path.join(__dirname, '../../.env');
|
||||
const result = require('dotenv').config({ path: envPath });
|
||||
|
||||
const TOKEN = process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('Error: DISCORD_BOT_TOKEN must be set in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error('Usage: node scripts/bulk-lookup-users-v2.js <input_file> <output_file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputFile = args[0];
|
||||
const outputFile = args[1];
|
||||
|
||||
// Read user IDs from input file
|
||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
console.log(`✅ Loaded ${userIds.length} user IDs from ${inputFile}`);
|
||||
|
||||
// Load existing results if any (for resume capability)
|
||||
let results = {};
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
if (fs.existsSync(outputFile)) {
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
||||
results = existing.users || {};
|
||||
processed = Object.keys(results).length;
|
||||
errors = existing.errors || 0;
|
||||
console.log(`📂 Found existing results: ${processed} users already processed`);
|
||||
} catch (e) {
|
||||
console.log(`⚠️ Could not load existing results, starting fresh`);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers
|
||||
]
|
||||
});
|
||||
|
||||
async function lookupUser(userId) {
|
||||
// Skip if already processed
|
||||
if (results[userId]) {
|
||||
return results[userId];
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await client.users.fetch(userId);
|
||||
return {
|
||||
success: true,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.globalName || user.username,
|
||||
tag: user.tag,
|
||||
bot: user.bot,
|
||||
avatar: user.displayAvatarURL()
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
id: userId,
|
||||
error: error.message,
|
||||
username: null,
|
||||
globalName: null,
|
||||
tag: null,
|
||||
bot: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function saveResults() {
|
||||
const output = {
|
||||
timestamp: new Date().toISOString(),
|
||||
total_users: userIds.length,
|
||||
processed: processed,
|
||||
successful: processed - errors,
|
||||
errors: errors,
|
||||
users: results
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
async function processUsers() {
|
||||
console.log('\n🚀 Starting bulk lookup...');
|
||||
console.log(` Progress will be saved every 100 users\n`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const startProcessed = processed;
|
||||
|
||||
// Filter out already processed users
|
||||
const toProcess = userIds.filter(id => !results[id]);
|
||||
console.log(` ${toProcess.length} users remaining to process\n`);
|
||||
|
||||
// Process one at a time (safer and can still be reasonably fast)
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const userId = toProcess[i];
|
||||
|
||||
const result = await lookupUser(userId);
|
||||
results[result.id] = result;
|
||||
|
||||
if (!result.success) {
|
||||
errors++;
|
||||
}
|
||||
processed++;
|
||||
|
||||
// Save every 100 users
|
||||
if (processed % 100 === 0) {
|
||||
saveResults();
|
||||
const elapsed = ((Date.now() - startTime) / 1000);
|
||||
const rate = (processed - startProcessed) / elapsed;
|
||||
const remaining = (toProcess.length - i - 1) / rate;
|
||||
console.log(`💾 Progress: ${processed}/${userIds.length} (${errors} errors) - saved checkpoint - ~${remaining.toFixed(0)}s remaining`);
|
||||
}
|
||||
|
||||
// Slower delay to avoid rate limits (500ms = 2 requests/second - more reliable)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Final save
|
||||
saveResults();
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000);
|
||||
|
||||
console.log(`\n${'='.repeat(70)}`);
|
||||
console.log(`✅ Lookup Complete!`);
|
||||
console.log(`${'='.repeat(70)}`);
|
||||
console.log(` Total time: ${totalTime.toFixed(1)}s`);
|
||||
console.log(` Total processed: ${processed}/${userIds.length}`);
|
||||
console.log(` Successful: ${processed - errors} (${((processed - errors)/userIds.length*100).toFixed(1)}%)`);
|
||||
console.log(` Errors: ${errors}`);
|
||||
console.log(` Rate: ${((processed - startProcessed)/totalTime).toFixed(1)} users/second`);
|
||||
console.log(`\n💾 Saved to: ${outputFile}\n`);
|
||||
|
||||
// Sample successful results
|
||||
const sample = Object.values(results).filter(r => r.success).slice(0, 5);
|
||||
if (sample.length > 0) {
|
||||
console.log('📋 Sample results:');
|
||||
sample.forEach(u => console.log(` ${u.username} (${u.id})`));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
client.once('ready', () => {
|
||||
console.log(`✅ Bot logged in as ${client.user.tag}\n`);
|
||||
processUsers();
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
console.error('❌ Discord client error:', error);
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n⚠️ Interrupted! Saving progress...');
|
||||
saveResults();
|
||||
console.log('✅ Progress saved. You can resume by running the same command again.\n');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
client.login(TOKEN);
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bulk lookup Discord user information
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/bulk-lookup-users.js <input_file> <output_file>
|
||||
*
|
||||
* Input file: Text file with one user ID per line
|
||||
* Output file: JSON file with user lookup results
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
// Load environment variables from repo root
|
||||
const envPath = path.join(__dirname, '../../.env');
|
||||
console.log(`Loading .env from: ${envPath}`);
|
||||
const result = require('dotenv').config({ path: envPath });
|
||||
if (result.error) {
|
||||
console.error(`Error loading .env: ${result.error.message}`);
|
||||
// Try broccolini-bot/.env as fallback
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
}
|
||||
|
||||
const TOKEN = process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN;
|
||||
const GUILD_ID = process.env.GUILD_ID || process.env.SERVER_ID;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('Error: DISCORD_BOT_TOKEN must be set in .env');
|
||||
console.error('Available env vars:', Object.keys(process.env).filter(k => k.includes('DISCORD')));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error('Usage: node scripts/bulk-lookup-users.js <input_file> <output_file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputFile = args[0];
|
||||
const outputFile = args[1];
|
||||
|
||||
// Read user IDs from input file
|
||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
console.log(`Loaded ${userIds.length} user IDs from ${inputFile}`);
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers
|
||||
]
|
||||
});
|
||||
|
||||
const results = {};
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
async function lookupUser(userId) {
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Lookup timeout')), 10000)
|
||||
);
|
||||
|
||||
const fetchPromise = client.users.fetch(userId);
|
||||
const user = await Promise.race([fetchPromise, timeoutPromise]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.globalName || user.username,
|
||||
tag: user.tag,
|
||||
bot: user.bot,
|
||||
avatar: user.displayAvatarURL()
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle errors (not found, timeout, rate limit)
|
||||
if (error.message.includes('429')) {
|
||||
console.log(` ⚠️ Rate limit hit for user ${userId}, will retry`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
id: userId,
|
||||
error: error.message,
|
||||
username: null,
|
||||
globalName: null,
|
||||
tag: null,
|
||||
bot: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function processUsers() {
|
||||
console.log('\nStarting bulk lookup...');
|
||||
console.log('This will take a few minutes for 2,428 users\n');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Process in batches to avoid rate limits
|
||||
const BATCH_SIZE = 3; // Very small batches to avoid rate limits
|
||||
const DELAY_MS = 2000; // 2 seconds between batches
|
||||
|
||||
for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
|
||||
const batch = userIds.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Lookup batch in parallel
|
||||
const promises = batch.map(userId => lookupUser(userId));
|
||||
const batchResults = await Promise.all(promises);
|
||||
|
||||
// Store results
|
||||
batchResults.forEach(result => {
|
||||
if (!result.success) {
|
||||
errors++;
|
||||
}
|
||||
results[result.id] = result;
|
||||
processed++;
|
||||
});
|
||||
|
||||
// Log every batch for debugging
|
||||
if (processed <= 50) {
|
||||
console.log(` Batch complete: ${processed} users processed`);
|
||||
}
|
||||
|
||||
// Progress update every 100 users
|
||||
if (processed % 100 === 0 || processed === userIds.length) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const rate = (processed / elapsed).toFixed(1);
|
||||
const remaining = ((userIds.length - processed) / rate).toFixed(0);
|
||||
console.log(`Progress: ${processed}/${userIds.length} (${errors} errors) - ${elapsed}s elapsed, ~${remaining}s remaining`);
|
||||
}
|
||||
|
||||
// Wait before next batch to avoid rate limits
|
||||
if (i + BATCH_SIZE < userIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\n✅ Completed in ${totalTime}s`);
|
||||
console.log(` Successful: ${processed - errors}`);
|
||||
console.log(` Errors: ${errors}`);
|
||||
|
||||
// Save results
|
||||
const output = {
|
||||
timestamp: new Date().toISOString(),
|
||||
total_users: userIds.length,
|
||||
successful: processed - errors,
|
||||
errors: errors,
|
||||
users: results
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
||||
console.log(`\n💾 Saved results to ${outputFile}`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
client.once('ready', () => {
|
||||
console.log(`✅ Bot logged in as ${client.user.tag}`);
|
||||
processUsers();
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
console.error('Discord client error:', error);
|
||||
});
|
||||
|
||||
client.login(TOKEN);
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Export transcript channel messages with embed "Users in transcript" to JSONL.
|
||||
* Each line: { message_id, created, ticket_name, ticket_owner_id, users: [{ id, count }], total }
|
||||
* Usage: node scripts/export-transcript-embeds.js <channelId> [maxMessages] [outputPath]
|
||||
* If outputPath is omitted, writes to stdout (redirect: node ... > transcript_embeds.jsonl).
|
||||
* If outputPath is given, writes JSONL to that file (avoids dotenv/logs mixing with JSON).
|
||||
*/
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const fs = require('fs');
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const channelId = process.argv[2];
|
||||
const maxMessages = parseInt(process.argv[3], 10) || 10000;
|
||||
const outputPath = process.argv[4];
|
||||
const PAGE = 100;
|
||||
|
||||
// Parse "Users in transcript" value: "5 - <@123> - name#0\n 4 - <@456> - ..."
|
||||
function parseUsersInTranscript(value) {
|
||||
const users = [];
|
||||
let total = 0;
|
||||
const lines = (value || '').split(/\n/).map((s) => s.trim()).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\d+)\s+-\s+<@!?(\d+)>/);
|
||||
if (match) {
|
||||
const count = parseInt(match[1], 10);
|
||||
users.push({ id: match[2], count });
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
return { users, total };
|
||||
}
|
||||
|
||||
if (!TOKEN || !channelId) {
|
||||
console.error('Usage: node scripts/export-transcript-embeds.js <channelId> [maxMessages]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
console.error('Channel not found or bot cannot access it.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (outputPath) {
|
||||
fs.writeFileSync(outputPath, '');
|
||||
}
|
||||
let totalScanned = 0;
|
||||
let before = undefined;
|
||||
while (totalScanned < maxMessages) {
|
||||
const limit = Math.min(PAGE, maxMessages - totalScanned);
|
||||
const options = before ? { limit, before } : { limit };
|
||||
const messages = await channel.messages.fetch(options);
|
||||
if (messages.size === 0) break;
|
||||
totalScanned += messages.size;
|
||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
||||
if (!m.embeds?.length) continue;
|
||||
for (const emb of m.embeds) {
|
||||
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
|
||||
if (!usersField?.value) continue;
|
||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
||||
const ticketName = ticketNameField?.value?.trim() || '';
|
||||
const ownerField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket owner'));
|
||||
const ownerMatch = ownerField?.value?.match(/<@!?(\d+)>/);
|
||||
const ticket_owner_id = ownerMatch ? ownerMatch[1] : null;
|
||||
const { users, total } = parseUsersInTranscript(usersField.value);
|
||||
if (users.length === 0 && !ticket_owner_id) continue;
|
||||
const out = {
|
||||
message_id: m.id,
|
||||
created: m.createdAt.toISOString(),
|
||||
ticket_name: ticketName,
|
||||
ticket_owner_id: ticket_owner_id || undefined,
|
||||
users,
|
||||
total,
|
||||
};
|
||||
const line = JSON.stringify(out) + '\n';
|
||||
if (outputPath) {
|
||||
fs.appendFileSync(outputPath, line);
|
||||
} else {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
const oldestMsg = messages.reduce((a, m) => (m.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? m : a), null);
|
||||
before = oldestMsg?.id;
|
||||
if (messages.size < PAGE) break;
|
||||
}
|
||||
process.stderr.write('Scanned ' + totalScanned + ' messages\n');
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
} finally {
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(TOKEN).catch((e) => {
|
||||
console.error('Login failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetch recent messages from a Discord channel.
|
||||
* Usage: node scripts/fetch-channel-messages.js <channelId> [limit]
|
||||
* Default limit: 10
|
||||
*/
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const channelId = process.argv[2];
|
||||
const limit = Math.min(parseInt(process.argv[3], 10) || 10, 100);
|
||||
|
||||
if (!TOKEN || !channelId) {
|
||||
console.error('Usage: node scripts/fetch-channel-messages.js <channelId> [limit]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
console.log('Channel not found or bot cannot access it.');
|
||||
process.exit(0);
|
||||
}
|
||||
const messages = await channel.messages.fetch({ limit });
|
||||
console.log('Channel:', channel.name, '(' + channel.id + ')');
|
||||
console.log('Messages fetched:', messages.size, '(requested', limit + ')');
|
||||
if (messages.size === 0) {
|
||||
console.log('No messages visible (empty channel or no Read Message History permission).');
|
||||
process.exit(0);
|
||||
}
|
||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
||||
const preview = (m.content || '(embed/attachment only)').slice(0, 80);
|
||||
console.log('---');
|
||||
console.log('ID:', m.id, '| Author:', m.author.tag, '|', m.createdAt.toISOString());
|
||||
console.log(preview + (m.content && m.content.length > 80 ? '...' : ''));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
} finally {
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(TOKEN).catch((e) => {
|
||||
console.error('Login failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetch a Discord channel by ID and print its name and type.
|
||||
* Usage: node scripts/fetch-channel.js <channelId>
|
||||
* Example: node scripts/fetch-channel.js 1335424071227281520
|
||||
*
|
||||
* Uses DISCORD_BOT_TOKEN or MEMBER_BOT_TOKEN from .env (broccolini-bot or parent).
|
||||
*/
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const channelId = process.argv[2];
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('❌ No bot token (DISCORD_BOT_TOKEN or MEMBER_BOT_TOKEN)');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!channelId) {
|
||||
console.error('Usage: node scripts/fetch-channel.js <channelId>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId).catch((err) => null);
|
||||
if (!channel) {
|
||||
console.log('Channel not found or bot cannot access it.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.log('Channel ID:', channel.id);
|
||||
console.log('Name:', channel.name);
|
||||
console.log('Type:', channel.type);
|
||||
if (channel.guild) console.log('Guild:', channel.guild.name, `(${channel.guild.id})`);
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
} finally {
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(TOKEN).catch((e) => {
|
||||
console.error('Login failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetch a Discord message by channel ID and message ID.
|
||||
* Usage: node scripts/fetch-message.js <channelId> <messageId>
|
||||
*/
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const [channelId, messageId] = process.argv.slice(2);
|
||||
|
||||
if (!TOKEN || !channelId || !messageId) {
|
||||
console.error('Usage: node scripts/fetch-message.js <channelId> <messageId>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
console.log('Channel not found or bot cannot access it.');
|
||||
process.exit(0);
|
||||
}
|
||||
const message = await channel.messages.fetch(messageId).catch((err) => null);
|
||||
if (!message) {
|
||||
console.log('Message not found (wrong channel, deleted, or no access).');
|
||||
process.exit(0);
|
||||
}
|
||||
console.log('Channel:', channel.name, '(' + channel.id + ')');
|
||||
console.log('Message ID:', message.id);
|
||||
console.log('Author:', message.author.tag, '(' + message.author.id + ')');
|
||||
console.log('Created:', message.createdAt ? message.createdAt.toISOString() : message.createdTimestamp);
|
||||
console.log('Content:', message.content || '(empty or embed only)');
|
||||
if (message.embeds && message.embeds.length) {
|
||||
message.embeds.forEach((emb, i) => {
|
||||
console.log('\n--- Embed', i + 1, '---');
|
||||
if (emb.title) console.log('Title:', emb.title);
|
||||
if (emb.description) console.log('Description:', emb.description);
|
||||
if (emb.url) console.log('URL:', emb.url);
|
||||
if (emb.fields && emb.fields.length) {
|
||||
emb.fields.forEach((f) => console.log('Field:', f.name, '\n', f.value));
|
||||
}
|
||||
if (emb.footer?.text) console.log('Footer:', emb.footer.text);
|
||||
// Ticket name for display (e.g. "indifferentketchup🍅" from "indifferentketchup🍅-claimed-7235")
|
||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
||||
if (ticketNameField?.value) {
|
||||
const full = ticketNameField.value.trim();
|
||||
const short = full.replace(/-claimed-\d+$/, '').trim();
|
||||
console.log('Ticket (short):', short || full);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
} finally {
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(TOKEN).catch((e) => {
|
||||
console.error('Login failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Find transcript channel messages whose embed "Users in transcript" lists a given member ID.
|
||||
* Usage: node scripts/find-transcript-by-member.js <channelId> <memberId> [maxMessages]
|
||||
* Example: node scripts/find-transcript-by-member.js 1335424071227281520 219276746153787392 500
|
||||
* Fetches in pages of 100; maxMessages limits total (e.g. 500 = 5 pages). Default 100.
|
||||
*/
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const channelId = process.argv[2];
|
||||
const memberId = process.argv[3];
|
||||
const maxMessages = parseInt(process.argv[4], 10) || 100;
|
||||
const PAGE = 100;
|
||||
|
||||
if (!TOKEN || !channelId || !memberId) {
|
||||
console.error('Usage: node scripts/find-transcript-by-member.js <channelId> <memberId> [maxMessages]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
console.log('Channel not found or bot cannot access it.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.log('Channel:', channel.name, '(' + channel.id + ')');
|
||||
console.log('Looking for member ID', memberId, 'in embed "Users in transcript"');
|
||||
const memberRef = `<@${memberId}>`;
|
||||
let totalScanned = 0;
|
||||
let found = 0;
|
||||
let before = undefined;
|
||||
while (totalScanned < maxMessages) {
|
||||
const limit = Math.min(PAGE, maxMessages - totalScanned);
|
||||
const options = before ? { limit, before } : { limit };
|
||||
const messages = await channel.messages.fetch(options);
|
||||
if (messages.size === 0) break;
|
||||
totalScanned += messages.size;
|
||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
||||
if (!m.embeds?.length) continue;
|
||||
for (const emb of m.embeds) {
|
||||
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
|
||||
if (!usersField?.value || !usersField.value.includes(memberRef)) continue;
|
||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
||||
const ticketName = ticketNameField?.value?.trim() || '(no Ticket Name field)';
|
||||
console.log('\n--- Match ---');
|
||||
console.log('Message ID:', m.id);
|
||||
console.log('Created:', m.createdAt.toISOString());
|
||||
console.log('Ticket Name:', ticketName);
|
||||
console.log('Users in transcript:\n' + usersField.value);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
const oldestMsg = messages.reduce((a, m) => (m.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? m : a), null);
|
||||
before = oldestMsg?.id;
|
||||
if (messages.size < PAGE) break;
|
||||
}
|
||||
console.log('\nTotal messages scanned:', totalScanned);
|
||||
console.log('Total messages matching member', memberId, ':', found);
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
} finally {
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(TOKEN).catch((e) => {
|
||||
console.error('Login failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Find transcript messages whose embed "Ticket Owner" is a given user ID.
|
||||
* Usage: node scripts/find-transcript-by-owner.js <channelId> <ownerId> [totalMessages] [maxMessages]
|
||||
* If totalMessages is given, only show messages where "Users in transcript" sum equals that.
|
||||
* Example: node scripts/find-transcript-by-owner.js 1335424071227281520 241129484483297280 5 10000
|
||||
*/
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const channelId = process.argv[2];
|
||||
const ownerId = process.argv[3];
|
||||
const totalMessages = parseInt(process.argv[4], 10) || null;
|
||||
const maxMessages = parseInt(process.argv[5], 10) || 10000;
|
||||
const PAGE = 100;
|
||||
|
||||
function parseUsersTotal(value) {
|
||||
let total = 0;
|
||||
(value || '').split(/\n/).forEach((line) => {
|
||||
const m = line.trim().match(/^(\d+)\s+-\s+<@!?\d+>/);
|
||||
if (m) total += parseInt(m[1], 10);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
if (!TOKEN || !channelId || !ownerId) {
|
||||
console.error('Usage: node scripts/find-transcript-by-owner.js <channelId> <ownerId> [totalMessages] [maxMessages]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ownerRef = `<@${ownerId}>`;
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel) {
|
||||
console.error('Channel not found or bot cannot access it.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('Channel:', channel.name, '(' + channel.id + ')');
|
||||
console.error('Looking for Ticket Owner', ownerId, totalMessages != null ? 'and total=' + totalMessages : '');
|
||||
let totalScanned = 0;
|
||||
let before = undefined;
|
||||
let found = 0;
|
||||
while (totalScanned < maxMessages) {
|
||||
const limit = Math.min(PAGE, maxMessages - totalScanned);
|
||||
const options = before ? { limit, before } : { limit };
|
||||
const messages = await channel.messages.fetch(options);
|
||||
if (messages.size === 0) break;
|
||||
totalScanned += messages.size;
|
||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
||||
if (!m.embeds?.length) continue;
|
||||
for (const emb of m.embeds) {
|
||||
const ownerField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket owner'));
|
||||
if (!ownerField?.value || !ownerField.value.includes(ownerRef)) continue;
|
||||
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
|
||||
const total = usersField?.value ? parseUsersTotal(usersField.value) : 0;
|
||||
if (totalMessages != null && total !== totalMessages) continue;
|
||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
||||
const ticketName = ticketNameField?.value?.trim() || '';
|
||||
console.log('Message ID:', m.id);
|
||||
console.log('Created:', m.createdAt.toISOString());
|
||||
console.log('Ticket Name:', ticketName);
|
||||
console.log('Total messages:', total);
|
||||
console.log('---');
|
||||
found++;
|
||||
}
|
||||
}
|
||||
const oldestMsg = messages.reduce((a, msg) => (msg.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? msg : a), null);
|
||||
before = oldestMsg?.id;
|
||||
if (messages.size < PAGE) break;
|
||||
}
|
||||
console.error('Scanned', totalScanned, 'messages, matches:', found);
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
} finally {
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(TOKEN).catch((e) => {
|
||||
console.error('Login failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Look up a Discord user by ID. Uses repo root .env for token so it works without broccolini-bot config.
|
||||
* Usage: node scripts/lookup-user.js [user_id]
|
||||
* Run from broccolini-bot/ (or use full path to script).
|
||||
*/
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
const token = (process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN || '').trim();
|
||||
if (!token) {
|
||||
console.error('Set DISCORD_BOT_TOKEN or DISCORD_TOKEN in repo root .env (/IB-Discord-Bot/.env)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
const userId = process.argv[2] || '140081819986034688';
|
||||
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const user = await client.users.fetch(userId);
|
||||
console.log('User:', {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.globalName ?? user.username,
|
||||
tag: user.tag,
|
||||
bot: user.bot
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Lookup failed:', err.message);
|
||||
if (err.code === 10013) console.error('Unknown user, or bot does not share a server with this user.');
|
||||
} finally {
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(token);
|
||||
@@ -1,183 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* User lookup using a dedicated minimal-permissions bot
|
||||
*
|
||||
* This bot:
|
||||
* - Has NO server permissions
|
||||
* - Only needs to be in the server
|
||||
* - Uses separate token from main bot
|
||||
* - Won't affect your main bot's rate limits
|
||||
*
|
||||
* Usage:
|
||||
* LOOKUP_BOT_TOKEN=your_token node scripts/lookup-with-dedicated-bot.js <input_file> <output_file>
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
// Load environment
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
// Use dedicated bot token OR fall back to main bot
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('❌ Error: No bot token found');
|
||||
console.error(' Set MEMBER_BOT_TOKEN in .env or use DISCORD_BOT_TOKEN');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error('Usage: node scripts/lookup-with-dedicated-bot.js <input_file> <output_file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputFile = args[0];
|
||||
const outputFile = args[1];
|
||||
|
||||
// Read user IDs
|
||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
console.log(`✅ Loaded ${userIds.length} user IDs`);
|
||||
|
||||
// Load existing results
|
||||
let results = {};
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
if (fs.existsSync(outputFile)) {
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
||||
results = existing.users || {};
|
||||
processed = Object.keys(results).length;
|
||||
errors = existing.errors || 0;
|
||||
console.log(`📂 Found existing: ${processed} users`);
|
||||
} catch (e) {
|
||||
console.log(`⚠️ Starting fresh`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create bot with MINIMAL intents
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds // Only need this to stay in server
|
||||
// NO other intents needed!
|
||||
]
|
||||
});
|
||||
|
||||
async function lookupUser(userId) {
|
||||
if (results[userId]) return results[userId];
|
||||
|
||||
try {
|
||||
const user = await client.users.fetch(userId);
|
||||
return {
|
||||
success: true,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.globalName || user.username,
|
||||
tag: user.tag,
|
||||
bot: user.bot,
|
||||
avatar: user.displayAvatarURL()
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
id: userId,
|
||||
error: error.message,
|
||||
username: null,
|
||||
globalName: null,
|
||||
tag: null,
|
||||
bot: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function saveResults() {
|
||||
const output = {
|
||||
timestamp: new Date().toISOString(),
|
||||
total_users: userIds.length,
|
||||
processed: processed,
|
||||
successful: processed - errors,
|
||||
errors: errors,
|
||||
bot_type: (process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN) ? 'dedicated' : 'main',
|
||||
users: results
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
async function processUsers() {
|
||||
console.log('\n🚀 Starting lookups...');
|
||||
const isDedicated = !!(process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN);
|
||||
console.log(` Bot type: ${isDedicated ? '✅ Dedicated lookup bot' : '⚠️ Main bot'}`);
|
||||
console.log(` Rate: SLOW (1 user/second for safety)`);
|
||||
console.log();
|
||||
|
||||
const startTime = Date.now();
|
||||
const toProcess = userIds.filter(id => !results[id]);
|
||||
console.log(` ${toProcess.length} users remaining\n`);
|
||||
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const userId = toProcess[i];
|
||||
|
||||
const result = await lookupUser(userId);
|
||||
results[result.id] = result;
|
||||
|
||||
if (!result.success) errors++;
|
||||
processed++;
|
||||
|
||||
// Save every 10 users for frequent updates
|
||||
if (processed % 10 === 0) {
|
||||
saveResults();
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const rate = (processed - (userIds.length - toProcess.length)) / elapsed;
|
||||
const remaining = (toProcess.length - i - 1) / rate;
|
||||
console.log(`💾 ${processed}/${userIds.length} (${errors} errors) - saved - ~${remaining.toFixed(0)}s left`);
|
||||
}
|
||||
|
||||
// Very slow to avoid rate limits (1/second)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
saveResults();
|
||||
|
||||
const totalTime = (Date.now() - startTime) / 1000;
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`✅ Complete!`);
|
||||
console.log(`${'='.repeat(60)}`);
|
||||
console.log(` Time: ${totalTime.toFixed(1)}s`);
|
||||
console.log(` Processed: ${processed}/${userIds.length}`);
|
||||
console.log(` Successful: ${processed - errors}`);
|
||||
console.log(` Errors: ${errors}`);
|
||||
console.log(`\n💾 Saved to: ${outputFile}\n`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
client.once('ready', () => {
|
||||
const isDedicated = !!(process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN);
|
||||
const botType = isDedicated ? 'DEDICATED LOOKUP BOT' : 'Main Bot';
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
console.log(` Type: ${botType}`);
|
||||
console.log();
|
||||
processUsers();
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
console.error('❌ Error:', error.message);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n⚠️ Interrupted! Saving...');
|
||||
saveResults();
|
||||
console.log('✅ Saved. Resume by running same command.\n');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
console.log('🔌 Connecting to Discord...');
|
||||
client.login(TOKEN);
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Discord user lookup WITH ROLES
|
||||
*
|
||||
* Fetches:
|
||||
* - User info (username, display name, avatar)
|
||||
* - Guild member info (roles, join date, server nickname)
|
||||
* - All Palpocalypse server roles
|
||||
*
|
||||
* Requires: Server Members Intent enabled in Discord Developer Portal
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const GUILD_ID = '798321161082896395'; // Indifferent Broccoli server
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('❌ Error: No bot token found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.error('Usage: node scripts/lookup-with-roles.js <input_file> <output_file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputFile = args[0];
|
||||
const outputFile = args[1];
|
||||
|
||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
console.log(`✅ Loaded ${userIds.length} user IDs`);
|
||||
|
||||
let results = {};
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
if (fs.existsSync(outputFile)) {
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
||||
results = existing.users || {};
|
||||
processed = Object.keys(results).length;
|
||||
errors = existing.errors || 0;
|
||||
console.log(`📂 Found existing: ${processed} users`);
|
||||
} catch (e) {
|
||||
console.log(`⚠️ Starting fresh`);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers // Required for roles!
|
||||
]
|
||||
});
|
||||
|
||||
let guild = null;
|
||||
|
||||
async function lookupUserWithRoles(userId) {
|
||||
if (results[userId]) return results[userId];
|
||||
|
||||
try {
|
||||
// Fetch basic user info
|
||||
const user = await client.users.fetch(userId);
|
||||
|
||||
// Try to fetch guild member (for roles)
|
||||
let roles = [];
|
||||
let serverNickname = null;
|
||||
let joinedAt = null;
|
||||
let isInServer = false;
|
||||
|
||||
try {
|
||||
const member = await guild.members.fetch(userId);
|
||||
isInServer = true;
|
||||
serverNickname = member.nickname;
|
||||
joinedAt = member.joinedAt ? member.joinedAt.toISOString() : null;
|
||||
|
||||
// Get all roles except @everyone
|
||||
roles = member.roles.cache
|
||||
.filter(role => role.name !== '@everyone')
|
||||
.map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.hexColor,
|
||||
position: role.position
|
||||
}))
|
||||
.sort((a, b) => b.position - a.position); // Highest role first
|
||||
|
||||
} catch (memberError) {
|
||||
// User exists but not in this server
|
||||
isInServer = false;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.globalName || user.username,
|
||||
tag: user.tag,
|
||||
bot: user.bot,
|
||||
avatar: user.displayAvatarURL(),
|
||||
// Server-specific data
|
||||
server_nickname: serverNickname,
|
||||
joined_at: joinedAt,
|
||||
in_server: isInServer,
|
||||
roles: roles,
|
||||
role_names: roles.map(r => r.name),
|
||||
highest_role: roles[0]?.name || null
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
id: userId,
|
||||
error: error.message,
|
||||
username: null,
|
||||
globalName: null,
|
||||
roles: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function saveResults() {
|
||||
const output = {
|
||||
timestamp: new Date().toISOString(),
|
||||
total_users: userIds.length,
|
||||
processed: processed,
|
||||
successful: processed - errors,
|
||||
errors: errors,
|
||||
guild_id: GUILD_ID,
|
||||
includes_roles: true,
|
||||
users: results
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
async function processUsers() {
|
||||
console.log('\n🎭 Starting lookups WITH ROLES...');
|
||||
console.log(` Guild ID: ${GUILD_ID}`);
|
||||
console.log(` Rate: 1 user/second\n`);
|
||||
|
||||
// Fetch guild
|
||||
guild = await client.guilds.fetch(GUILD_ID);
|
||||
console.log(`✅ Connected to: ${guild.name}\n`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const toProcess = userIds.filter(id => !results[id]);
|
||||
console.log(` ${toProcess.length} users remaining\n`);
|
||||
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const userId = toProcess[i];
|
||||
|
||||
const result = await lookupUserWithRoles(userId);
|
||||
results[result.id] = result;
|
||||
|
||||
if (!result.success) errors++;
|
||||
processed++;
|
||||
|
||||
// Save every 10 users
|
||||
if (processed % 10 === 0) {
|
||||
saveResults();
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const rate = (processed - (userIds.length - toProcess.length)) / elapsed;
|
||||
const remaining = (toProcess.length - i - 1) / rate;
|
||||
|
||||
// Show sample with roles
|
||||
if (result.success && result.roles.length > 0) {
|
||||
const rolePreview = result.role_names.slice(0, 2).join(', ');
|
||||
console.log(`💾 ${processed}/${userIds.length} - ${result.globalName} [${rolePreview}] - ~${remaining.toFixed(0)}s left`);
|
||||
} else {
|
||||
console.log(`💾 ${processed}/${userIds.length} (${errors} errors) - ~${remaining.toFixed(0)}s left`);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
saveResults();
|
||||
|
||||
const totalTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
// Stats
|
||||
const usersWithRoles = Object.values(results).filter(u => u.success && u.roles.length > 0).length;
|
||||
const allRoleNames = new Set();
|
||||
Object.values(results).forEach(u => {
|
||||
if (u.success) {
|
||||
u.role_names?.forEach(r => allRoleNames.add(r));
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${'='.repeat(70)}`);
|
||||
console.log(`✅ Complete with Roles!`);
|
||||
console.log(`${'='.repeat(70)}`);
|
||||
console.log(` Time: ${totalTime.toFixed(1)}s`);
|
||||
console.log(` Processed: ${processed}/${userIds.length}`);
|
||||
console.log(` Successful: ${processed - errors}`);
|
||||
console.log(` Users with roles: ${usersWithRoles}`);
|
||||
console.log(` Unique roles found: ${allRoleNames.size}`);
|
||||
console.log(`\n💾 Saved to: ${outputFile}\n`);
|
||||
|
||||
// Show some roles
|
||||
if (allRoleNames.size > 0) {
|
||||
console.log('📋 Sample roles found:');
|
||||
Array.from(allRoleNames).slice(0, 10).forEach(r => console.log(` • ${r}`));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
client.once('ready', () => {
|
||||
console.log(`✅ Logged in as ${client.user.tag}\n`);
|
||||
processUsers();
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
console.error('❌ Error:', error.message);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n⚠️ Interrupted! Saving...');
|
||||
saveResults();
|
||||
console.log('✅ Saved. Resume by running same command.\n');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
console.log('🔌 Connecting to Discord...');
|
||||
client.login(TOKEN);
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Map batch tickets (TICKET: guild_channelId_suffix) to transcript channel messages.
|
||||
*
|
||||
* Connection:
|
||||
* - Batch line: TICKET: 798321161082896395_1423340928588054621_indiffe → channelId = 1423340928588054621.
|
||||
* - Transcript channel (🖥️│transcripts): each message is an embed with "Ticket Name: indifferentketchup🍅-claimed-7235".
|
||||
* - Embed does NOT include channel ID, so we match by (1) ticket name (when known) or (2) time: transcript posted when ticket closes.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/map-batch-to-transcript.js list [limit] -- fetch transcript messages, output CSV (messageId, created, ticket_name)
|
||||
* node scripts/map-batch-to-transcript.js find <channelId> -- find transcript message(s) likely for this ticket (by time window)
|
||||
*
|
||||
* Known mapping (from embed): 1423340928588054621 ↔ message 1423400708769579120 (Ticket: indifferentketchup🍅-claimed-7235).
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
||||
const TRANSCRIPT_CHANNEL_ID = '1335424071227281520';
|
||||
const METRICS_CSV = path.join(__dirname, '../../Discord Ticket Transcripts/transcript_metrics_per_ticket.csv');
|
||||
|
||||
function getTicketNameFromEmbed(emb) {
|
||||
const f = emb.fields?.find((x) => x.name && x.name.toLowerCase().includes('ticket name'));
|
||||
return f ? f.value.trim() : null;
|
||||
}
|
||||
|
||||
async function fetchTranscriptMessages(client, limit = 100) {
|
||||
const channel = await client.channels.fetch(TRANSCRIPT_CHANNEL_ID).catch(() => null);
|
||||
if (!channel) return [];
|
||||
const cap = Math.min(limit, 100); // Discord API max 100 per request
|
||||
const messages = await channel.messages.fetch({ limit: cap });
|
||||
const out = [];
|
||||
for (const [, m] of messages) {
|
||||
const emb = m.embeds?.[0];
|
||||
const ticketName = emb ? getTicketNameFromEmbed(emb) : null;
|
||||
out.push({
|
||||
messageId: m.id,
|
||||
created: m.createdAt ? m.createdAt.toISOString() : m.createdTimestamp,
|
||||
createdTs: m.createdTimestamp,
|
||||
ticketName: ticketName || '',
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.createdTs - a.createdTs);
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadMetricsCsv() {
|
||||
if (!fs.existsSync(METRICS_CSV)) return [];
|
||||
const text = fs.readFileSync(METRICS_CSV, 'utf8');
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
||||
const header = lines[0].split(',');
|
||||
const ticketIdIdx = header.indexOf('ticket_id');
|
||||
const lastTsIdx = header.indexOf('last_message_ts');
|
||||
if (ticketIdIdx === -1 || lastTsIdx === -1) return [];
|
||||
const rows = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const parts = lines[i].split(',');
|
||||
const ticketId = parts[ticketIdIdx];
|
||||
const lastTs = parseInt(parts[lastTsIdx], 10);
|
||||
if (!ticketId || !ticketId.includes('_')) continue;
|
||||
const channelId = ticketId.split('_')[1];
|
||||
if (channelId && !isNaN(lastTs)) rows.push({ ticketId, channelId, last_message_ts: lastTs });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cmd = process.argv[2];
|
||||
const arg = process.argv[3];
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('No bot token');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
client.once('ready', resolve);
|
||||
client.login(TOKEN).catch(reject);
|
||||
});
|
||||
|
||||
try {
|
||||
if (cmd === 'list') {
|
||||
const limit = Math.min(parseInt(arg, 10) || 100, 100);
|
||||
const list = await fetchTranscriptMessages(client, limit);
|
||||
console.log('transcript_message_id,created_iso,ticket_name');
|
||||
list.forEach((r) => console.log([r.messageId, r.created, r.ticketName].map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'find' && arg) {
|
||||
const channelId = arg.trim();
|
||||
const metrics = loadMetricsCsv();
|
||||
const row = metrics.find((r) => r.channelId === channelId);
|
||||
const closeTs = row ? row.last_message_ts : null;
|
||||
const list = await fetchTranscriptMessages(client, 100);
|
||||
const windowMs = 2 * 60 * 60 * 1000; // ±2 hours
|
||||
const candidates = closeTs
|
||||
? list.filter((r) => Math.abs(r.createdTs - closeTs) <= windowMs)
|
||||
: list.slice(0, 20);
|
||||
console.log('Batch ticket channelId:', channelId);
|
||||
if (row) console.log('Ticket close time (last_message_ts):', closeTs, new Date(closeTs).toISOString());
|
||||
console.log('Transcript channel messages (candidates by time or recent):');
|
||||
candidates.forEach((r) => {
|
||||
const delta = closeTs != null ? (r.createdTs - closeTs) / 60000 : null;
|
||||
console.log(' ', r.messageId, r.created, r.ticketName || '(no name)', delta != null ? `delta ${delta.toFixed(0)} min` : '');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Usage: node scripts/map-batch-to-transcript.js list [limit]');
|
||||
console.log(' node scripts/map-batch-to-transcript.js find <channelId>');
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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',
|
||||
// Roles and staff
|
||||
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
||||
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
|
||||
'ADMIN_ID',
|
||||
// Channel IDs
|
||||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
||||
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
|
||||
'BACKUP_EXPORT_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
|
||||
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
|
||||
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
|
||||
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
|
||||
'STAFF_NOTIFICATION_CATEGORY_ID',
|
||||
// Pattern channel IDs
|
||||
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
|
||||
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID',
|
||||
// Messages and labels
|
||||
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
||||
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
||||
@@ -48,36 +43,17 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
||||
// Toggles
|
||||
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
||||
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
|
||||
'ALLOW_CLAIM_OVERWRITE',
|
||||
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
||||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
||||
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||||
'STAFF_DND_COUNTS_AS_AVAILABLE',
|
||||
// Limits and thresholds
|
||||
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
||||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
||||
// Embed colors
|
||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
|
||||
// Pattern thresholds
|
||||
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
|
||||
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
|
||||
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
|
||||
// Surge settings
|
||||
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
|
||||
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
|
||||
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
|
||||
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
|
||||
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
|
||||
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
|
||||
// Chat alerts
|
||||
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
|
||||
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
|
||||
// Notification thresholds
|
||||
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS',
|
||||
// Notification enable state (Phase 9)
|
||||
'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED'
|
||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
|
||||
]);
|
||||
|
||||
// ---------- Regex primitives ----------
|
||||
@@ -207,13 +183,9 @@ const VALIDATORS = {
|
||||
|
||||
function inferType(key) {
|
||||
// 1. Explicit overrides
|
||||
if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json';
|
||||
if (key === 'NOTIFICATION_ENABLED_JSON') return 'json';
|
||||
if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean';
|
||||
if (key === 'LOGO_URL') return 'url';
|
||||
if (/_EMAIL$/.test(key)) return 'email';
|
||||
if (key.includes('COLOR')) return 'hex_color';
|
||||
if (/_EMOJIS$/.test(key)) return 'string_or_json';
|
||||
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
|
||||
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
@@ -185,7 +185,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const serverDisplayName = label;
|
||||
const safeCloseMessage = safeBody;
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
|
||||
@@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,484 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Broccolini Settings</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading" class="loading"><div class="spinner"></div></div>
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="logo">Broccolini Settings</div>
|
||||
<a href="/" class="active">Core</a>
|
||||
<a href="/channels">Channels</a>
|
||||
<a href="/categories">Categories</a>
|
||||
<a href="/gmail">Gmail</a>
|
||||
<a href="/behavior">Ticket Behavior</a>
|
||||
<a href="/threads">Staff Threads</a>
|
||||
<a href="/pins">Pin Messages</a>
|
||||
<a href="/notifications">Notifications</a>
|
||||
<a href="/logging">Logging</a>
|
||||
<a href="/automation">Automation</a>
|
||||
<a href="/appearance">Appearance</a>
|
||||
<a href="/staff">Staff</a>
|
||||
<a href="/advanced">Advanced</a>
|
||||
</nav>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div class="topbar">
|
||||
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="sidebar">
|
||||
<span class="menu-toggle-bars" aria-hidden="true"></span>
|
||||
</button>
|
||||
<h1>Settings</h1>
|
||||
<div class="status">
|
||||
<span class="dot" id="bot-status-dot"></span>
|
||||
<span id="bot-status-text">Checking...</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="logout-btn">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="main">
|
||||
|
||||
<!-- 1. Core -->
|
||||
<div class="section" id="s-core">
|
||||
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Discord Token</label><input type="password" data-key="DISCORD_TOKEN" placeholder="Bot token"></div>
|
||||
<div class="field"><label>Application ID</label><input type="text" data-key="DISCORD_APPLICATION_ID"></div>
|
||||
<div class="field"><label>Guild ID</label><input type="text" data-key="DISCORD_GUILD_ID"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Channels -->
|
||||
<div class="section" id="s-channels">
|
||||
<div class="section-header"><h2>Channels</h2><p>Channel assignments for logging, transcripts, and alerts</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Transcript Channel</label><input type="text" data-key="TRANSCRIPT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Logging Channel</label><input type="text" data-key="LOGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Backup/Export Channel</label><input type="text" data-key="BACKUP_EXPORT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Account Info Channel</label><input type="text" data-key="ACCOUNT_INFO_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>All Staff Channel</label><input type="text" data-key="ALL_STAFF_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Chat Alert Channel</label><input type="text" data-key="ALL_STAFF_CHAT_ALERT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>User Patterns Channel</label><input type="text" data-key="USER_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Game Patterns Channel</label><input type="text" data-key="GAME_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Tag Patterns Channel</label><input type="text" data-key="TAG_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Escalation Patterns Channel</label><input type="text" data-key="ESCALATION_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Staff Patterns Channel</label><input type="text" data-key="STAFF_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Combined Patterns Channel</label><input type="text" data-key="COMBINED_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Categories -->
|
||||
<div class="section" id="s-categories">
|
||||
<div class="section-header"><h2>Categories</h2><p>Ticket category assignments and escalation targets</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Email Ticket Category</label><input type="text" data-key="TICKET_CATEGORY_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Discord Ticket Category</label><input type="text" data-key="DISCORD_TICKET_CATEGORY_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Email T2 Category</label><input type="text" data-key="EMAIL_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Discord T2 Category</label><input type="text" data-key="DISCORD_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Email T3 Category</label><input type="text" data-key="EMAIL_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Discord T3 Category</label><input type="text" data-key="DISCORD_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Staff Notification Category</label><input type="text" data-key="STAFF_NOTIFICATION_CATEGORY_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Category Name</label><input type="text" data-key="TICKET_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>T2 Category Name</label><input type="text" data-key="TICKET_T2_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>T3 Category Name</label><input type="text" data-key="TICKET_T3_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>Discord Thread Channel</label><input type="text" data-key="DISCORD_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Email Thread Channel</label><input type="text" data-key="EMAIL_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Gmail -->
|
||||
<div class="section" id="s-gmail">
|
||||
<div class="section-header"><h2>Gmail</h2><p>Google OAuth credentials and email settings</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Google Client ID</label><input type="text" data-key="GOOGLE_CLIENT_ID"></div>
|
||||
<div class="field"><label>Google Client Secret</label><input type="password" data-key="GOOGLE_CLIENT_SECRET"></div>
|
||||
<div class="field"><label>Refresh Token</label><input type="password" data-key="REFRESH_TOKEN"></div>
|
||||
<div class="field"><label>Support Email</label><input type="email" data-key="MY_EMAIL"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Ticket Behavior -->
|
||||
<div class="section" id="s-behavior">
|
||||
<div class="section-header"><h2>Ticket Behavior</h2><p>Automation, limits, and messages</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Priority System</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PRIORITY_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Claim Timeout</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="CLAIM_TIMEOUT_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Claim Timeout Hours</label><input type="number" data-key="CLAIM_TIMEOUT_HOURS"></div>
|
||||
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Global Ticket Limit</label><input type="number" data-key="GLOBAL_TICKET_LIMIT"></div>
|
||||
<div class="field"><label>Rate Limit (per user)</label><input type="number" data-key="RATE_LIMIT_TICKETS_PER_USER"></div>
|
||||
<div class="field"><label>Rate Limit Window (min)</label><input type="number" data-key="RATE_LIMIT_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Role to Ping</label><input type="text" data-key="ROLE_ID_TO_PING" data-smart="role"></div>
|
||||
<div class="field full-width"><label>Welcome Message</label><textarea data-key="TICKET_WELCOME_MESSAGE" rows="3"></textarea></div>
|
||||
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
|
||||
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
|
||||
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
|
||||
<div class="field full-width"><label>Reminder Message</label><textarea data-key="REMINDER_MESSAGE" rows="2"></textarea><div class="hint">Variables: {ping}, {hours}</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Staff Threads -->
|
||||
<div class="section" id="s-threads">
|
||||
<div class="section-header"><h2>Staff Threads</h2><p>Private staff discussion threads on ticket channels</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Enabled</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Thread Name</label><input type="text" data-key="STAFF_THREAD_NAME"></div>
|
||||
<div class="field"><label>Auto-Add Role</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_AUTO_ADD_ROLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Staff Thread Role</label><input type="text" data-key="STAFF_THREAD_ROLE_ID" data-smart="role"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Pin Messages -->
|
||||
<div class="section" id="s-pins">
|
||||
<div class="section-header"><h2>Pin Messages</h2><p>Auto-pin welcome and escalation messages</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Pin Initial Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_INITIAL_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Pin Escalation Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_ESCALATION_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Suppress Pin Notice</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_SUPPRESS_SYSTEM_MESSAGE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 8. Notifications -->
|
||||
<div class="section" id="s-notifications">
|
||||
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body">
|
||||
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
|
||||
|
||||
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
|
||||
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel" data-notif-panel="surge">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="surge">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="surge"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="surge"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="surge"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
|
||||
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
|
||||
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
|
||||
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
|
||||
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
|
||||
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
|
||||
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="patterns">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="patterns">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="patterns"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="patterns"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
|
||||
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
|
||||
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
|
||||
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
|
||||
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="unclaimed">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="unclaimed">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="unclaimed"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="unclaimed"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="chat">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="chat">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="chat"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="chat"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="chat"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
|
||||
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
|
||||
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Logging -->
|
||||
<div class="section" id="s-logging">
|
||||
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field full-width"><p class="logging-hint">Log channels are configured in the <a href="/channels">Channels</a> section. This section shows which logs are active based on configured channels.</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 11. Automation -->
|
||||
<div class="section" id="s-automation">
|
||||
<div class="section-header"><h2>Automation</h2><p>Polling intervals and timer durations</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Gmail Poll Interval (sec)</label><select data-key="GMAIL_POLL_INTERVAL_SECONDS">
|
||||
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
|
||||
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
|
||||
<option value="300">5m</option><option value="600">10m</option>
|
||||
</select></div>
|
||||
<div class="field"><label>Force-Close Timer (sec)</label><select data-key="FORCE_CLOSE_TIMER_SECONDS">
|
||||
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
|
||||
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
|
||||
<option value="300">5m</option><option value="600">10m</option>
|
||||
</select></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 12. Appearance -->
|
||||
<div class="section" id="s-appearance">
|
||||
<div class="section-header"><h2>Appearance</h2><p>Embed colors, button labels, and emojis</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Open Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_OPEN"><span>Open tickets</span></div></div>
|
||||
<div class="field"><label>Closed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLOSED"><span>Closed tickets</span></div></div>
|
||||
<div class="field"><label>Claimed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLAIMED"><span>Claimed tickets</span></div></div>
|
||||
<div class="field"><label>Escalated Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_ESCALATED"><span>Escalated tickets</span></div></div>
|
||||
<div class="field"><label>Info Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_INFO"><span>Info embeds</span></div></div>
|
||||
<div class="field"><label>Close Button Label</label><input type="text" data-key="BUTTON_LABEL_CLOSE"></div>
|
||||
<div class="field"><label>Claim Button Label</label><input type="text" data-key="BUTTON_LABEL_CLAIM"></div>
|
||||
<div class="field"><label>Unclaim Button Label</label><input type="text" data-key="BUTTON_LABEL_UNCLAIM"></div>
|
||||
<div class="field"><label>Close Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLOSE"></div>
|
||||
<div class="field"><label>Claim Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLAIM"></div>
|
||||
<div class="field"><label>Unclaim Emoji</label><input type="text" data-key="BUTTON_EMOJI_UNCLAIM"></div>
|
||||
<div class="field"><label>High Priority Emoji</label><input type="text" data-key="PRIORITY_HIGH_EMOJI"></div>
|
||||
<div class="field"><label>Medium Priority Emoji</label><input type="text" data-key="PRIORITY_MEDIUM_EMOJI"></div>
|
||||
<div class="field"><label>Low Priority Emoji</label><input type="text" data-key="PRIORITY_LOW_EMOJI"></div>
|
||||
<div class="field"><label>Claimer Emoji Fallback</label><input type="text" data-key="CLAIMER_EMOJI_FALLBACK"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 13. Staff -->
|
||||
<div class="section" id="s-staff">
|
||||
<div class="section-header"><h2>Staff</h2><p>Staff IDs, emojis, and admin settings</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field full-width"><label>Staff IDs (comma-separated)</label><input type="text" data-key="STAFF_IDS" data-smart="multi-member"></div>
|
||||
<div class="field"><label>Admin ID</label><input type="text" data-key="ADMIN_ID" data-smart="member"></div>
|
||||
<div class="field full-width"><label>Staff Emojis (userId:emoji, comma-separated)</label><input type="text" data-key="STAFF_EMOJIS"><div class="hint">Format: 123456:emoji,789012:emoji</div></div>
|
||||
<div class="field full-width"><label>Additional Staff Roles (comma-separated)</label><input type="text" data-key="ADDITIONAL_STAFF_ROLES"><div class="hint">Role IDs with staff permissions</div></div>
|
||||
<div class="field full-width"><label>Blacklisted Roles (comma-separated)</label><input type="text" data-key="BLACKLISTED_ROLES"><div class="hint">Role IDs that cannot open tickets</div></div>
|
||||
<div class="field full-width"><label>Unclaimed Reminder Thresholds (hours, comma-separated)</label><input type="text" data-key="UNCLAIMED_REMINDER_THRESHOLDS"><div class="hint">e.g. 1,2,4</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 14. Advanced -->
|
||||
<div class="section" id="s-advanced">
|
||||
<div class="section-header"><h2>Advanced</h2><p>Ports, URLs, game list, branding</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field"><label>Bot Port</label><input type="number" data-key="DISCORD_ONLY_PORT"></div>
|
||||
<div class="field"><label>Healthcheck Host</label><input type="text" data-key="HEALTHCHECK_HOST" placeholder="leave empty for all interfaces"></div>
|
||||
<div class="field"><label>Settings Port</label><input type="number" data-key="SETTINGS_PORT"></div>
|
||||
<div class="field"><label>Settings Domain</label><input type="text" data-key="SETTINGS_DOMAIN"></div>
|
||||
<div class="field"><label>Internal API Port</label><input type="number" data-key="INTERNAL_API_PORT"></div>
|
||||
<div class="field"><label>Support Name</label><input type="text" data-key="SUPPORT_NAME"></div>
|
||||
<div class="field"><label>Logo URL</label><input type="text" data-key="LOGO_URL"></div>
|
||||
<div class="field full-width"><label>Game List (comma-separated)</label><textarea data-key="GAME_LIST" rows="3"></textarea></div>
|
||||
<div class="field full-width"><label>Email Signature (HTML, use \n for breaks)</label><textarea data-key="EMAIL_SIGNATURE" rows="3"></textarea></div>
|
||||
<div class="field full-width"><label>Close Subject Prefix</label><input type="text" data-key="TICKET_CLOSE_SUBJECT_PREFIX"></div>
|
||||
<div class="field full-width"><label>Close Message (email body)</label><textarea data-key="TICKET_CLOSE_MESSAGE" rows="2"></textarea></div>
|
||||
<div class="field full-width"><label>Discord Close Message</label><textarea data-key="DISCORD_CLOSE_MESSAGE" rows="2"></textarea></div>
|
||||
<div class="field full-width"><label>Transcript Message</label><textarea data-key="DISCORD_TRANSCRIPT_MESSAGE" rows="2"></textarea><div class="hint">Variables: {channel_name}, {email}, {date_opened}, {date_closed}</div></div>
|
||||
<div class="field full-width"><label>Auto-Close Message</label><textarea data-key="DISCORD_AUTO_CLOSE_MESSAGE" rows="2"></textarea></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div id="save-bar" class="save-bar">
|
||||
<span id="change-count">0 unsaved changes</span>
|
||||
<div class="save-actions">
|
||||
<button type="button" id="save-btn">Save</button>
|
||||
<button type="button" id="save-restart-btn" class="danger">Save & Restart Now</button>
|
||||
<button type="button" id="schedule-restart-btn" class="secondary">Schedule restart...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule modal -->
|
||||
<div id="schedule-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="schedule-modal-title">
|
||||
<div class="modal-card">
|
||||
<h3 id="schedule-modal-title">Schedule restart</h3>
|
||||
<input type="datetime-local" id="schedule-datetime" aria-label="Restart date and time">
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="schedule-confirm-btn">Schedule</button>
|
||||
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="/js/util.js"></script>
|
||||
<script defer src="/js/router.js"></script>
|
||||
<script defer src="/js/fields.js"></script>
|
||||
<script defer src="/js/notifications.js"></script>
|
||||
<script defer src="/js/discord.js"></script>
|
||||
<script defer src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,162 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
async function init() {
|
||||
document.getElementById('loading').classList.remove('hidden');
|
||||
try {
|
||||
await Util.fetchCsrfToken();
|
||||
const [config] = await Promise.all([
|
||||
fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()),
|
||||
DiscordFields.fetchGuildData()
|
||||
]);
|
||||
Fields.setSavedConfig(config);
|
||||
document.getElementById('bot-status-dot').className = 'dot online';
|
||||
document.getElementById('bot-status-text').textContent = 'Connected';
|
||||
Fields.populateFields(config);
|
||||
Notifications.initNotificationsEditor(config);
|
||||
Fields.initSmartSelects(config);
|
||||
} catch (e) {
|
||||
document.getElementById('bot-status-dot').className = 'dot offline';
|
||||
document.getElementById('bot-status-text').textContent = 'Unreachable';
|
||||
}
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
setupSectionToggles();
|
||||
Fields.setupSaveBar();
|
||||
}
|
||||
|
||||
function setupSectionToggles() {
|
||||
document.querySelectorAll('.section-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
header.closest('.section').classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openScheduleModal() {
|
||||
const modal = document.getElementById('schedule-modal');
|
||||
const dt = document.getElementById('schedule-datetime');
|
||||
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
|
||||
dt.min = min;
|
||||
dt.value = min;
|
||||
Util.openModal(modal, { initialFocus: '#schedule-datetime' });
|
||||
}
|
||||
|
||||
async function confirmScheduledRestart() {
|
||||
const dt = document.getElementById('schedule-datetime').value;
|
||||
if (!dt) return;
|
||||
await fetch('/api/restart', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
|
||||
});
|
||||
Util.closeModal(document.getElementById('schedule-modal'));
|
||||
Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
try {
|
||||
await fetch('/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: Util.csrfHeaders()
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function setupActionButtons() {
|
||||
document.getElementById('save-btn')?.addEventListener('click', () => Fields.saveConfig('save'));
|
||||
document.getElementById('save-restart-btn')?.addEventListener('click', () => Fields.saveConfig('restart'));
|
||||
document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal);
|
||||
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
|
||||
document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => {
|
||||
Util.closeModal(document.getElementById('schedule-modal'));
|
||||
});
|
||||
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
|
||||
}
|
||||
|
||||
function setupMobileNav() {
|
||||
const toggle = document.getElementById('menu-toggle');
|
||||
const backdrop = document.getElementById('sidebar-backdrop');
|
||||
|
||||
toggle?.addEventListener('click', () => {
|
||||
Util.setSidebarOpen(!document.body.classList.contains('sidebar-open'));
|
||||
});
|
||||
backdrop?.addEventListener('click', () => Util.setSidebarOpen(false));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
|
||||
Util.setSidebarOpen(false);
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) {
|
||||
Util.setSidebarOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let healthPollHandle = null;
|
||||
|
||||
function setBotStatus(online) {
|
||||
const dot = document.getElementById('bot-status-dot');
|
||||
const text = document.getElementById('bot-status-text');
|
||||
if (!dot || !text) return;
|
||||
dot.className = online ? 'dot online' : 'dot offline';
|
||||
text.textContent = online ? 'Connected' : 'Unreachable';
|
||||
}
|
||||
|
||||
async function pollHealth() {
|
||||
try {
|
||||
const res = await fetch('/healthz', { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBotStatus(Boolean(data.bot));
|
||||
} else {
|
||||
setBotStatus(false);
|
||||
}
|
||||
} catch (_) {
|
||||
setBotStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextHealthPoll() {
|
||||
if (document.hidden) return;
|
||||
healthPollHandle = setTimeout(async () => {
|
||||
await pollHealth();
|
||||
scheduleNextHealthPoll();
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
function startHealthPolling() {
|
||||
if (healthPollHandle) clearTimeout(healthPollHandle);
|
||||
scheduleNextHealthPoll();
|
||||
}
|
||||
|
||||
function stopHealthPolling() {
|
||||
if (healthPollHandle) {
|
||||
clearTimeout(healthPollHandle);
|
||||
healthPollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupHealthPolling() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stopHealthPolling();
|
||||
else startHealthPolling();
|
||||
});
|
||||
window.addEventListener('pagehide', stopHealthPolling);
|
||||
startHealthPolling();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
Router.setupSidebarRouting();
|
||||
setupActionButtons();
|
||||
setupMobileNav();
|
||||
await init();
|
||||
Router.navigate(location.pathname, false);
|
||||
setupHealthPolling();
|
||||
});
|
||||
|
||||
window.App = { init };
|
||||
})();
|
||||
@@ -1,52 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const ROUTES = {
|
||||
'/': 's-core',
|
||||
'/channels': 's-channels',
|
||||
'/categories': 's-categories',
|
||||
'/gmail': 's-gmail',
|
||||
'/behavior': 's-behavior',
|
||||
'/threads': 's-threads',
|
||||
'/pins': 's-pins',
|
||||
'/notifications': 's-notifications',
|
||||
'/logging': 's-logging',
|
||||
'/automation': 's-automation',
|
||||
'/appearance': 's-appearance',
|
||||
'/staff': 's-staff',
|
||||
'/advanced': 's-advanced'
|
||||
};
|
||||
|
||||
function navigate(path, updateHistory = true) {
|
||||
const sectionId = ROUTES[path] || ROUTES['/'];
|
||||
const normalizedPath = ROUTES[path] ? path : '/';
|
||||
if (updateHistory) history.pushState({}, '', normalizedPath);
|
||||
|
||||
document.querySelectorAll('.section').forEach(section => {
|
||||
section.classList.toggle('hidden', section.id !== sectionId);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.sidebar a').forEach(link => {
|
||||
link.classList.toggle('active', link.getAttribute('href') === normalizedPath);
|
||||
});
|
||||
}
|
||||
|
||||
function setupSidebarRouting() {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
if (!sidebar) return;
|
||||
|
||||
sidebar.addEventListener('click', e => {
|
||||
const a = e.target.closest('a');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
navigate(a.getAttribute('href'));
|
||||
if (Util.isMobileViewport()) Util.setSidebarOpen(false);
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
navigate(location.pathname, false);
|
||||
});
|
||||
}
|
||||
|
||||
window.Router = { ROUTES, navigate, setupSidebarRouting };
|
||||
})();
|
||||
10
utils.js
10
utils.js
@@ -3,7 +3,7 @@
|
||||
* priority helpers, template variables.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { CONFIG, GAME_NAMES, GAME_ALIASES, TICKET_TAGS } = require('./config');
|
||||
const { CONFIG, GAME_NAMES, GAME_ALIASES } = require('./config');
|
||||
|
||||
/** Constant-time string compare. Returns false for mismatched length or empty/nullish inputs without throwing. */
|
||||
function safeEqual(a, b) {
|
||||
@@ -243,13 +243,6 @@ function getPriorityColor(priority) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns emoji for a ticket-tag key (e.g. server-down → ⬇️). Priority always comes first in channel name, then tag. */
|
||||
function getTicketTagEmoji(tagKey) {
|
||||
if (!tagKey) return '';
|
||||
const t = (TICKET_TAGS || []).find(x => x.value === tagKey);
|
||||
return t ? t.emoji : '';
|
||||
}
|
||||
|
||||
// --- TEMPLATE VARIABLES ---
|
||||
|
||||
function replaceVariables(template, context = {}) {
|
||||
@@ -407,6 +400,5 @@ module.exports = {
|
||||
detectGame,
|
||||
getPriorityEmoji,
|
||||
getPriorityColor,
|
||||
getTicketTagEmoji,
|
||||
replaceVariables
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user