huge changes
This commit is contained in:
@@ -6,7 +6,9 @@
|
||||
"Bash(node --check config.js)",
|
||||
"Bash(node --check handlers/commands.js)",
|
||||
"Bash(node --check handlers/buttons.js)",
|
||||
"Bash(node --check gmail-poll.js)"
|
||||
"Bash(node --check gmail-poll.js)",
|
||||
"Bash(node --check handlers/pendingCloses.js)",
|
||||
"Bash(node --check commands/register.js)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
65
.env.example
65
.env.example
@@ -122,6 +122,71 @@ CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry i
|
||||
ADMIN_ID= # Discord user ID of the bot admin (for /staffnotification)
|
||||
STAFF_NOTIFICATION_CATEGORY_ID= # Category for staff notification channels (created by /notification add)
|
||||
UNCLAIMED_REMINDER_THRESHOLDS=1,2,4 # Comma-separated hour thresholds for unclaimed ticket alerts
|
||||
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
|
||||
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
|
||||
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
|
||||
AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs
|
||||
RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs
|
||||
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
|
||||
STAFF_THREAD_AUTO_ADD_ROLE=false # Auto-add all members of STAFF_THREAD_ROLE_ID to thread on creation
|
||||
STAFF_THREAD_ROLE_ID= # Role whose members are added to the thread (defaults to ROLE_ID_TO_PING)
|
||||
|
||||
# --- Message pinning ---
|
||||
PIN_INITIAL_MESSAGE_ENABLED=false # Auto-pin the welcome message on ticket creation
|
||||
PIN_ESCALATION_MESSAGE_ENABLED=false # Auto-pin escalation messages
|
||||
PIN_SUPPRESS_SYSTEM_MESSAGE=false # Delete the "X pinned a message" system message after pinning
|
||||
|
||||
# --- Settings site & internal API ---
|
||||
SETTINGS_PORT=12752 # Port for the settings web UI
|
||||
SETTINGS_ADMIN_PASSWORD= # Password to access the settings UI
|
||||
SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (update when domain changes)
|
||||
INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally)
|
||||
INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string)
|
||||
|
||||
# --- Thread-style tickets (legacy) ---
|
||||
USE_THREADS=false
|
||||
|
||||
42
README.md
42
README.md
@@ -59,16 +59,18 @@ Built for game-server hosting support (Indifferent Broccoli), with game detectio
|
||||
- **Transcripts** posted to a configured channel; closure email for email tickets.
|
||||
- **Auto-close**, **inactivity reminders**, **auto-unclaim** (all optional via env).
|
||||
|
||||
### Staff notifications (optional)
|
||||
### Staff notifications & alerts (optional)
|
||||
|
||||
- **`/notification add`** creates a **dedicated text channel** per staff member under `STAFF_NOTIFICATION_CATEGORY_ID` and stores it in **`StaffNotification`** (MongoDB).
|
||||
- When a **non-staff** user replies in a ticket **claimed** by someone who has a notification channel, the bot posts an alert there (subject to **per-ticket cooldown** hours, configurable via `/notification set` or admin **`/staffnotification`**).
|
||||
- A background job runs **every 30 minutes** and, if `UNCLAIMED_REMINDER_THRESHOLDS` is set, posts **unclaimed ticket** digests to those same channels when tickets cross age thresholds.
|
||||
- **`/notifydm`** toggles optional **DM** alerts to the claimer on customer reply (separate from the notification channel); stored in **`StaffSettings`**.
|
||||
- **Per-staff notification channels**: **`/notification add`** creates a **dedicated text channel** per staff member under `STAFF_NOTIFICATION_CATEGORY_ID` and stores it in **`StaffNotification`** (MongoDB). When a **non-staff** user replies in a ticket claimed by someone with a notification channel, the bot posts an alert there (subject to **per-ticket cooldown** via `/notification set` or admin **`/staffnotification`**).
|
||||
- **Unclaimed digests**: a background job runs **every 30 minutes** and, if `UNCLAIMED_REMINDER_THRESHOLDS` is set, posts **unclaimed ticket** digests to those same channels when tickets cross age thresholds.
|
||||
- **DM reply alerts**: **`/notifydm`** toggles optional **DM** alerts to the claimer on customer reply (separate from the notification channel); stored in **`StaffSettings`**.
|
||||
- **Staff threads** (optional): when `STAFF_THREAD_ENABLED` is true, each ticket channel can get a private **staff-only thread** named `STAFF_THREAD_NAME`; on claim, the claimer can be added to that thread, and (optionally) all members of `STAFF_THREAD_ROLE_ID` are auto-added.
|
||||
- **Pins** (optional): `PIN_INITIAL_MESSAGE_ENABLED` and `PIN_ESCALATION_MESSAGE_ENABLED` enable auto-pinning of the ticket welcome message and escalation messages; `PIN_SUPPRESS_SYSTEM_MESSAGE` hides the default “X pinned a message” system notice.
|
||||
- **Chat monitoring & surge detection**: see [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for automatic alerts about busy chats, surging games, backlogs, and no-staff situations.
|
||||
|
||||
See [Staff notification channels](#staff-notification-channels--reply-alerts).
|
||||
See [Staff notification channels](#staff-notification-channels--reply-alerts) and [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for details.
|
||||
|
||||
**Note:** Older docs referred to per-staffer **mirror** channels driven by `STAFF_CATEGORIES`. In current `config.js` that map is **deprecated and always empty**, and **`createStaffChannel` is not called** from the claim flow—**`staffChannelId` on tickets is effectively unused.** Reply alerts use **`StaffNotification`** channels instead.
|
||||
**Note:** Older docs referred to per-staffer **mirror** channels driven by `STAFF_CATEGORIES`. In current `config.js` that map is **deprecated and always empty**, and **`createStaffChannel` is not called** from the claim flow—**`staffChannelId` on tickets is effectively unused.** Reply alerts use **`StaffNotification`** channels instead, and staff discussion happens in optional **staff threads**.
|
||||
|
||||
### Extras
|
||||
|
||||
@@ -201,6 +203,32 @@ Slash `/escalate` and buttons require the appropriate tier IDs for **non-thread*
|
||||
| `ADMIN_ID` | Discord user ID allowed to use **`/staffnotification`** (override cooldown for another member). |
|
||||
| `UNCLAIMED_REMINDER_THRESHOLDS` | Comma-separated **hours** (e.g. `1,2,4`); drives unclaimed ticket alerts into notification channels. |
|
||||
|
||||
### Logging & observability
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GMAIL_LOG_CHANNEL_ID` | Channel for Gmail poll activity logs. |
|
||||
| `AUTOMATION_LOG_CHANNEL_ID` | Channel for auto-close/auto-unclaim/reminder logs. |
|
||||
| `RENAME_LOG_CHANNEL_ID` | Channel for channel rename queue logs. |
|
||||
| `SECURITY_LOG_CHANNEL_ID` | Channel for security/audit logs. |
|
||||
| `SYSTEM_LOG_CHANNEL_ID` | Channel for bot lifecycle logs (startup, shutdown, DB events). |
|
||||
|
||||
### Pattern detection & surge/chat alerts
|
||||
|
||||
Core behaviour is configured via `.env.example`; high level:
|
||||
|
||||
- **Pattern detection** (`patternStore.js`, `patternChecker.js`):
|
||||
- `USER_PATTERNS_CHANNEL_ID`, `GAME_PATTERNS_CHANNEL_ID`, `TAG_PATTERNS_CHANNEL_ID`, `ESCALATION_PATTERNS_CHANNEL_ID`, `STAFF_PATTERNS_CHANNEL_ID`, `COMBINED_PATTERNS_CHANNEL_ID` select where pattern embeds are posted.
|
||||
- Threshold envs like `PATTERN_USER_TICKET_THRESHOLD`, `PATTERN_GAME_TICKET_THRESHOLD`, `PATTERN_UNCLAIMED_HOURS`, `PATTERN_ESCALATION_THRESHOLD`, `PATTERN_RAPID_CLOSE_SECONDS` tune when alerts fire.
|
||||
- Windows (`today`, `week`, `month`) reset automatically via scheduled timers in `patternStore.scheduleResets()`.
|
||||
- **Surge detection** (`surgeChecker.js`):
|
||||
- `ALL_STAFF_CHANNEL_ID` is the primary surge-alert channel; `SURGE_ROLE_ID` is pinged when set.
|
||||
- `SURGE_TICKET_COUNT` / `SURGE_TICKET_WINDOW_MINUTES`, `SURGE_GAME_TICKET_COUNT` / `SURGE_GAME_TICKET_WINDOW_MINUTES`, `SURGE_STALE_*`, `SURGE_NEEDS_RESPONSE_*`, `SURGE_UNCLAIMED_*`, `SURGE_TIER3_UNCLAIMED_MINUTES`, `SURGE_COOLDOWN_MINUTES` control volume/backlog alerts.
|
||||
- `SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD`, `SURGE_NO_STAFF_COOLDOWN_MINUTES`, `STAFF_IDS`, and `STAFF_DND_COUNTS_AS_AVAILABLE` drive “no staff available” alerts (presence-based with message activity fallback).
|
||||
- **Chat monitoring** (`chatAlertChecker.js`):
|
||||
- `CHAT_ALERT_CHANNEL_IDS` lists channels to monitor.
|
||||
- `CHAT_ALERT_MESSAGE_COUNT`, `CHAT_ALERT_HOURS_WITHOUT_RESPONSE`, `CHAT_ALERT_COOLDOWN_MINUTES` configure when to send chat-attention alerts to `ALL_STAFF_CHAT_ALERT_CHANNEL_ID`.
|
||||
|
||||
### Google / Gmail
|
||||
|
||||
| Variable | Required | Description |
|
||||
|
||||
@@ -22,13 +22,25 @@ const { registerCommands } = require('./commands/register');
|
||||
const bosscordRoutes = require('./routes/bosscord');
|
||||
const { setBot } = require('./api/bosscordClient');
|
||||
const { poll } = require('./gmail-poll');
|
||||
const { setClient: setDebugClient } = require('./services/debugLog');
|
||||
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog');
|
||||
|
||||
// Re-export utilities for any external consumers
|
||||
const { sendGmailReply } = require('./services/gmail');
|
||||
const { getNextTicketNumber } = require('./services/tickets');
|
||||
const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils');
|
||||
|
||||
let gmailPollInterval = null;
|
||||
|
||||
/**
|
||||
* Update the Gmail poll interval at runtime.
|
||||
* @param {number} ms - new interval in milliseconds
|
||||
*/
|
||||
function setGmailPollInterval(ms) {
|
||||
if (gmailPollInterval) clearInterval(gmailPollInterval);
|
||||
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
|
||||
gmailPollInterval = setInterval(() => poll(client), ms);
|
||||
}
|
||||
|
||||
// --- VALIDATE CONFIG ---
|
||||
if (!CONFIG.DISCORD_TOKEN) {
|
||||
console.error('DISCORD_TOKEN or DISCORD_BOT_TOKEN is not set in .env');
|
||||
@@ -51,7 +63,8 @@ const client = new Client({
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMembers
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal
|
||||
],
|
||||
partials: [Partials.Channel]
|
||||
});
|
||||
@@ -108,7 +121,18 @@ client.on('interactionCreate', async interaction => {
|
||||
}
|
||||
});
|
||||
|
||||
client.on('messageCreate', handleDiscordReply);
|
||||
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);
|
||||
});
|
||||
|
||||
client.once('ready', async () => {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
@@ -144,7 +168,7 @@ client.once('ready', async () => {
|
||||
|
||||
registerCommands().catch(console.error);
|
||||
|
||||
setInterval(() => poll(client), 30000);
|
||||
gmailPollInterval = setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS);
|
||||
poll(client);
|
||||
|
||||
if (CONFIG.AUTO_CLOSE_ENABLED) {
|
||||
@@ -163,7 +187,40 @@ client.once('ready', async () => {
|
||||
console.log('✓ Auto-unclaim enabled: checking every hour');
|
||||
}
|
||||
|
||||
const { runPatternChecks } = require('./services/patternChecker');
|
||||
const { scheduleResets } = require('./services/patternStore');
|
||||
scheduleResets();
|
||||
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');
|
||||
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);
|
||||
setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000);
|
||||
console.log('✓ Chat alert monitoring: every 5 minutes');
|
||||
|
||||
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', [
|
||||
{ name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' },
|
||||
{ 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' },
|
||||
{ name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' },
|
||||
{ name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' }
|
||||
]).catch(() => {});
|
||||
});
|
||||
|
||||
client.login(CONFIG.DISCORD_TOKEN);
|
||||
@@ -177,8 +234,33 @@ app.listen(CONFIG.PORT, healthcheckHost, () => {
|
||||
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
||||
});
|
||||
|
||||
// --- Internal API for settings site ---
|
||||
const internalApi = require('./routes/internalApi');
|
||||
const internalApp = express();
|
||||
internalApp.use('/internal', internalApi);
|
||||
|
||||
if (CONFIG.INTERNAL_API_SECRET) {
|
||||
internalApp.listen(CONFIG.INTERNAL_API_PORT, '127.0.0.1', () => {
|
||||
console.log(`[internalApi] listening on 127.0.0.1:${CONFIG.INTERNAL_API_PORT}`);
|
||||
});
|
||||
} else {
|
||||
console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.');
|
||||
}
|
||||
|
||||
// --- Shutdown & error handlers ---
|
||||
async function handleShutdown(signal) {
|
||||
await Promise.race([logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]), new Promise(r => setTimeout(r, 2000))]);
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))).catch(() => {});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
client,
|
||||
setGmailPollInterval,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
getNextTicketNumber,
|
||||
|
||||
@@ -430,6 +430,120 @@ async function registerCommands() {
|
||||
.setRequired(true)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('closetimer')
|
||||
.setDescription('Set the force-close countdown duration')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('seconds')
|
||||
.setDescription('Countdown duration')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: '5s', value: '5' },
|
||||
{ name: '10s', value: '10' },
|
||||
{ name: '30s', value: '30' },
|
||||
{ name: '45s', value: '45' },
|
||||
{ name: '1m', value: '60' },
|
||||
{ name: '2m', value: '120' },
|
||||
{ name: '3m', value: '180' },
|
||||
{ name: '4m', value: '240' },
|
||||
{ name: '5m', value: '300' },
|
||||
{ name: '10m', value: '600' }
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('staffthread')
|
||||
.setDescription('Manage staff discussion threads on ticket channels')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('toggle').setDescription('Toggle staff threads on/off')
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('name')
|
||||
.setDescription('Set the staff thread name')
|
||||
.addStringOption(opt =>
|
||||
opt.setName('thread_name').setDescription('Thread name').setMaxLength(100).setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('autorole')
|
||||
.setDescription('Toggle auto-adding role members to staff thread')
|
||||
.addBooleanOption(opt =>
|
||||
opt.setName('enabled').setDescription('Enable or disable').setRequired(true)
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('pinmessages')
|
||||
.setDescription('Manage auto-pinning of ticket messages')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('initial')
|
||||
.setDescription('Toggle auto-pin of welcome message')
|
||||
.addBooleanOption(opt =>
|
||||
opt.setName('enabled').setDescription('Enable or disable').setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('escalation')
|
||||
.setDescription('Toggle auto-pin of escalation messages')
|
||||
.addBooleanOption(opt =>
|
||||
opt.setName('enabled').setDescription('Enable or disable').setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('suppress')
|
||||
.setDescription('Toggle suppression of pin system messages')
|
||||
.addBooleanOption(opt =>
|
||||
opt.setName('enabled').setDescription('Enable or disable').setRequired(true)
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('gmailpoll')
|
||||
.setDescription('Set the Gmail poll interval')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('interval')
|
||||
.setDescription('Poll interval')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: '5s', value: '5' },
|
||||
{ name: '10s', value: '10' },
|
||||
{ name: '30s', value: '30' },
|
||||
{ name: '45s', value: '45' },
|
||||
{ name: '1m', value: '60' },
|
||||
{ name: '2m', value: '120' },
|
||||
{ name: '3m', value: '180' },
|
||||
{ name: '4m', value: '240' },
|
||||
{ name: '5m', value: '300' },
|
||||
{ name: '10m', value: '600' }
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('cancel-close')
|
||||
.setDescription('Cancel a pending force-close countdown')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('accountinfo')
|
||||
.setDescription('Look up website account info by email or Discord user')
|
||||
|
||||
55
config.js
55
config.js
@@ -138,6 +138,61 @@ const CONFIG = {
|
||||
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: parseInt(process.env.FORCE_CLOSE_TIMER_SECONDS) || 60,
|
||||
GMAIL_POLL_INTERVAL_MS: parseInt(process.env.GMAIL_POLL_INTERVAL_SECONDS || '30') * 1000,
|
||||
GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null,
|
||||
AUTOMATION_LOG_CHANNEL_ID: process.env.AUTOMATION_LOG_CHANNEL_ID || null,
|
||||
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
|
||||
SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_LOG_CHANNEL_ID || null,
|
||||
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: parseInt(process.env.PATTERN_USER_TICKET_THRESHOLD) || 3,
|
||||
PATTERN_GAME_TICKET_THRESHOLD: parseInt(process.env.PATTERN_GAME_TICKET_THRESHOLD) || 10,
|
||||
PATTERN_STAFF_STALE_PING_THRESHOLD: parseInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD) || 5,
|
||||
PATTERN_ESCALATION_THRESHOLD: parseInt(process.env.PATTERN_ESCALATION_THRESHOLD) || 3,
|
||||
PATTERN_RAPID_CLOSE_SECONDS: parseInt(process.env.PATTERN_RAPID_CLOSE_SECONDS) || 120,
|
||||
PATTERN_UNCLAIMED_HOURS: parseInt(process.env.PATTERN_UNCLAIMED_HOURS) || 4,
|
||||
PATTERN_CHECK_INTERVAL_MINUTES: parseInt(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: parseInt(process.env.SURGE_TICKET_COUNT) || 10,
|
||||
SURGE_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_TICKET_WINDOW_MINUTES) || 30,
|
||||
SURGE_GAME_TICKET_COUNT: parseInt(process.env.SURGE_GAME_TICKET_COUNT) || 5,
|
||||
SURGE_GAME_TICKET_WINDOW_MINUTES: parseInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES) || 30,
|
||||
SURGE_STALE_COUNT: parseInt(process.env.SURGE_STALE_COUNT) || 8,
|
||||
SURGE_STALE_HOURS: parseInt(process.env.SURGE_STALE_HOURS) || 2,
|
||||
SURGE_NEEDS_RESPONSE_COUNT: parseInt(process.env.SURGE_NEEDS_RESPONSE_COUNT) || 5,
|
||||
SURGE_NEEDS_RESPONSE_HOURS: parseInt(process.env.SURGE_NEEDS_RESPONSE_HOURS) || 1,
|
||||
SURGE_UNCLAIMED_COUNT: parseInt(process.env.SURGE_UNCLAIMED_COUNT) || 5,
|
||||
SURGE_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_UNCLAIMED_MINUTES) || 30,
|
||||
SURGE_TIER3_UNCLAIMED_MINUTES: parseInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES) || 15,
|
||||
SURGE_COOLDOWN_MINUTES: parseInt(process.env.SURGE_COOLDOWN_MINUTES) || 60,
|
||||
CHAT_ALERT_CHANNEL_IDS: (process.env.CHAT_ALERT_CHANNEL_IDS || '').split(',').filter(Boolean),
|
||||
CHAT_ALERT_MESSAGE_COUNT: parseInt(process.env.CHAT_ALERT_MESSAGE_COUNT) || 5,
|
||||
CHAT_ALERT_HOURS_WITHOUT_RESPONSE: parseInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE) || 2,
|
||||
CHAT_ALERT_COOLDOWN_MINUTES: parseInt(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: parseInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES) || 30,
|
||||
SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: parseInt(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',
|
||||
STAFF_THREAD_ROLE_ID: process.env.STAFF_THREAD_ROLE_ID || process.env.ROLE_ID_TO_PING || null,
|
||||
PIN_INITIAL_MESSAGE_ENABLED: process.env.PIN_INITIAL_MESSAGE_ENABLED === 'true',
|
||||
PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true',
|
||||
PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true',
|
||||
SETTINGS_PORT: parseInt(process.env.SETTINGS_PORT) || 12752,
|
||||
SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
|
||||
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
|
||||
INTERNAL_API_PORT: parseInt(process.env.INTERNAL_API_PORT) || 12753,
|
||||
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null,
|
||||
UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4')
|
||||
.split(',')
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
|
||||
@@ -25,14 +25,20 @@ async function connectMongoDB(uri, options = {}) {
|
||||
// Handle connection events
|
||||
mongoose.connection.on('error', (err) => {
|
||||
console.error('MongoDB connection error:', err);
|
||||
const { logSystem: ls } = require('./services/debugLog');
|
||||
ls('MongoDB error', [{ name: 'Error', value: err.message }], null, 0xFF0000).catch(() => {});
|
||||
});
|
||||
|
||||
mongoose.connection.on('disconnected', () => {
|
||||
console.warn('MongoDB disconnected. Attempting to reconnect...');
|
||||
const { logSystem: ls } = require('./services/debugLog');
|
||||
ls('MongoDB disconnected', [], null, 0xFFFF00).catch(() => {});
|
||||
});
|
||||
|
||||
mongoose.connection.on('reconnected', () => {
|
||||
console.log('✓ MongoDB reconnected');
|
||||
const { logSystem: ls } = require('./services/debugLog');
|
||||
ls('MongoDB reconnected', []).catch(() => {});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
@@ -55,8 +61,32 @@ async function closeMongoDB() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function on Mongoose connection errors.
|
||||
* @param {Function} fn - async function to execute
|
||||
* @param {object} options - { retries: 3, delayMs: 500 }
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
async function withRetry(fn, options = {}) {
|
||||
const { retries = 3, delayMs = 500 } = options;
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const isConnectionError = err.name === 'MongoNetworkError' ||
|
||||
mongoose.connection.readyState !== 1;
|
||||
if (!isConnectionError || attempt >= retries) throw err;
|
||||
await new Promise(r => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
connectMongoDB,
|
||||
closeMongoDB,
|
||||
withRetry,
|
||||
mongoose
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
ButtonStyle,
|
||||
EmbedBuilder
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('./db-connection');
|
||||
const { mongoose, withRetry } = require('./db-connection');
|
||||
const { CONFIG, GAME_NAME_TO_KEY } = require('./config');
|
||||
const {
|
||||
getCleanBody,
|
||||
@@ -16,23 +16,38 @@ const {
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
detectGame,
|
||||
getFormattedDate
|
||||
getFormattedDate,
|
||||
truncateEmbedField,
|
||||
enforceEmbedLimit
|
||||
} = require('./utils');
|
||||
const { getGmailClient } = require('./services/gmail');
|
||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||
const { getEmailRouting } = require('./services/guildSettings');
|
||||
const { logError } = require('./services/debugLog');
|
||||
const { logError, logGmail } = require('./services/debugLog');
|
||||
const { increment } = require('./services/patternStore');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
|
||||
let isPolling = false;
|
||||
let authErrorNotified = false;
|
||||
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
|
||||
|
||||
/**
|
||||
* Poll Gmail for unread primary-inbox messages and route them to Discord.
|
||||
* @param {import('discord.js').Client} client
|
||||
*/
|
||||
async function poll(client) {
|
||||
console.log('Running poll()...');
|
||||
if (isPolling) return;
|
||||
isPolling = true;
|
||||
try {
|
||||
pollCount++;
|
||||
if (pollCount % 10 === 0) {
|
||||
logGmail('Poll summary', `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`, null, null).catch(() => {});
|
||||
pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0;
|
||||
}
|
||||
console.log('Running poll()...');
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const list = await gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
@@ -68,6 +83,7 @@ async function poll(client) {
|
||||
email.data.payload.headers.find(h => h.name === 'From')
|
||||
?.value || '';
|
||||
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
|
||||
totalSkipped++;
|
||||
await gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
@@ -146,6 +162,7 @@ async function poll(client) {
|
||||
// Check ticket limits before creating
|
||||
const limitCheck = await checkTicketLimits(sEmail);
|
||||
if (!limitCheck.ok) {
|
||||
totalSkipped++;
|
||||
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
|
||||
await gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
@@ -227,20 +244,29 @@ async function poll(client) {
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields({
|
||||
name: 'Ticket Info',
|
||||
value:
|
||||
value: truncateEmbedField(
|
||||
`**Name:** ${sName}\n` +
|
||||
`**Email:** ${sEmail}\n` +
|
||||
`**Date:** ${getFormattedDate()}\n` +
|
||||
`**Game:** ${detectedGame}\n` +
|
||||
`**Subject:** ${subject || 'No subject'}`
|
||||
`**Subject:** ${subject || 'No subject'}`)
|
||||
});
|
||||
|
||||
await ticketChan.send({
|
||||
enforceEmbedLimit([welcomeEmbed, ticketInfoEmbed]);
|
||||
const welcomeMsg = await ticketChan.send({
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||||
embeds: [welcomeEmbed, ticketInfoEmbed],
|
||||
components: [buttons]
|
||||
});
|
||||
|
||||
const { createStaffThread } = require('./services/staffThread');
|
||||
await createStaffThread(ticketChan, client).catch(() => {});
|
||||
|
||||
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||
const { pinMessage } = require('./services/pinMessage');
|
||||
await pinMessage(welcomeMsg, client).catch(() => {});
|
||||
}
|
||||
|
||||
// On reopen, link previous transcripts
|
||||
if (isReopened) {
|
||||
try {
|
||||
@@ -292,7 +318,7 @@ async function poll(client) {
|
||||
const now = new Date();
|
||||
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||
|
||||
await Ticket.findOneAndUpdate(
|
||||
await withRetry(() => Ticket.findOneAndUpdate(
|
||||
{ gmailThreadId: email.data.threadId },
|
||||
{
|
||||
$set: {
|
||||
@@ -308,7 +334,15 @@ async function poll(client) {
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
));
|
||||
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({
|
||||
@@ -319,10 +353,32 @@ async function poll(client) {
|
||||
}
|
||||
});
|
||||
}
|
||||
authErrorNotified = false;
|
||||
} catch (e) {
|
||||
const isAuthError =
|
||||
(e.message && (
|
||||
e.message.includes('invalid_grant') ||
|
||||
e.message.includes('unauthorized') ||
|
||||
e.message.includes('Invalid Credentials')
|
||||
)) ||
|
||||
e.status === 401 ||
|
||||
e.code === 401;
|
||||
|
||||
if (isAuthError) {
|
||||
logError('Gmail OAuth', { message: 'Gmail OAuth token invalid or expired. Re-authentication required.', stack: e.stack || e.message || String(e) }, null, client);
|
||||
if (CONFIG.ADMIN_ID && !authErrorNotified) {
|
||||
authErrorNotified = true;
|
||||
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send('Gmail OAuth token invalid or expired. Re-authentication required.')).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
totalErrors++;
|
||||
console.error('POLL ERROR:', e);
|
||||
logError('Gmail poll', e, null, client);
|
||||
}
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { poll };
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { logSecurity } = require('../services/debugLog');
|
||||
|
||||
const User = mongoose.model('User');
|
||||
|
||||
@@ -98,6 +99,11 @@ async function handleAccountInfoCommand(interaction) {
|
||||
});
|
||||
}
|
||||
|
||||
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 = [];
|
||||
|
||||
|
||||
@@ -19,10 +19,13 @@ const { CONFIG } = require('../config');
|
||||
const { canRename, makeTicketName, resolveCreatorNickname, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
|
||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
|
||||
const { setEmailRouting } = require('../services/guildSettings');
|
||||
const { enqueueRename } = require('../services/channelQueue');
|
||||
const { runEscalation, runDeescalation } = require('./commands');
|
||||
const { trackInteraction, trackError } = require('./analytics');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
const { increment } = require('../services/patternStore');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
@@ -131,7 +134,25 @@ async function handleButton(interaction) {
|
||||
}
|
||||
|
||||
if (interaction.customId === 'confirm_close') {
|
||||
return handleConfirmClose(interaction, ticket);
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||
}
|
||||
await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`, components: [] });
|
||||
const timerId = setTimeout(async () => {
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
const { logTicketEvent } = require('../services/debugLog');
|
||||
logTicketEvent('Force-close timer fired', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Set by', value: interaction.user.tag },
|
||||
{ name: 'Duration', value: `${timerSeconds}s` }
|
||||
]).catch(() => {});
|
||||
await handleConfirmClose(interaction, freshTicket);
|
||||
}, timerSeconds * 1000);
|
||||
pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === 'cancel_close') {
|
||||
@@ -294,6 +315,8 @@ async function handleClaim(interaction, ticket) {
|
||||
}
|
||||
|
||||
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
|
||||
const { logSecurity } = require('../services/debugLog');
|
||||
logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {});
|
||||
return interaction.reply({
|
||||
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
|
||||
ephemeral: true
|
||||
@@ -307,6 +330,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;
|
||||
@@ -358,6 +383,8 @@ async function handleClaim(interaction, ticket) {
|
||||
.setColor(CONFIG.EMBED_COLOR_CLAIMED)
|
||||
.setFooter({ text: `Claimed by ${claimerLabel}` });
|
||||
await interaction.followUp({ embeds: [claimEmbed] });
|
||||
const { addMemberToStaffThread } = require('../services/staffThread');
|
||||
await addMemberToStaffThread(interaction.channel, interaction.user.id).catch(() => {});
|
||||
} else {
|
||||
// Unclaim
|
||||
await Ticket.updateOne(
|
||||
@@ -415,6 +442,10 @@ 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 {
|
||||
@@ -669,35 +700,64 @@ async function handleTicketModal(interaction) {
|
||||
|
||||
const displayName = interaction.member?.displayName || interaction.user.username;
|
||||
|
||||
// Welcome embed (dark grey #1e2124)
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
// Ticket details embed (dark) – short labels, trimmed description
|
||||
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
|
||||
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setTitle("We got your ticket.")
|
||||
.setDescription("We'll be with you as soon as possible.")
|
||||
.setColor(5763719)
|
||||
.setThumbnail("https://indifferentbroccoli.com/img/broccoli_shadow_square.png")
|
||||
.setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" });
|
||||
|
||||
const infoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setColor(5763719)
|
||||
.setDescription(truncateEmbedDescription(
|
||||
`**Account Email:**\n\`\`\`\n${sanitizeEmbedText(email)}\n\`\`\`\n` +
|
||||
`**Game:**\n\`\`\`\n${sanitizeEmbedText(game) || "Not specified"}\n\`\`\`\n` +
|
||||
`**What do you need help with?**\n\`\`\`\n${sanitizeEmbedText(descTrimmed)}\n\`\`\``
|
||||
));
|
||||
|
||||
const resourcesEmbed = new EmbedBuilder()
|
||||
.setTitle("We're ~~happy~~ indifferent to help. :indifferentbroccoli:")
|
||||
.setDescription("Please feel free to add any additional information to the ticket, including recent changes to the server, if any.")
|
||||
.setColor(5763719)
|
||||
.addFields(
|
||||
{ name: 'Email', value: email, inline: true },
|
||||
{ name: 'Game', value: game || 'Not specified', inline: true },
|
||||
{ name: 'Description', value: descTrimmed, inline: false }
|
||||
{ name: "Check out our wiki for guides:", value: "[Indifferent Broccolipedia](https://wiki.indifferentbroccoli.com)", inline: false }
|
||||
)
|
||||
.setTimestamp();
|
||||
.setFooter({ text: "indifferent broccoli tickets (:|)", iconURL: "https://i.ibb.co/sJdytfFM/Untitled-design-6.png" });
|
||||
|
||||
const actionRow = getTicketActionRow({ escalationTier: 0 });
|
||||
|
||||
const welcomeMsg = await channel.send({
|
||||
content: `Hey There ${interaction.user} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [actionRow]
|
||||
});
|
||||
enforceEmbedLimit([welcomeEmbed, infoEmbed, resourcesEmbed]);
|
||||
try {
|
||||
const welcomeMsg = await channel.send({
|
||||
content: `Hey There ${interaction.user} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed, resourcesEmbed],
|
||||
components: [actionRow]
|
||||
});
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('welcomeMessageId-save', err);
|
||||
}
|
||||
|
||||
const { createStaffThread } = require('../services/staffThread');
|
||||
await createStaffThread(channel, interaction.client).catch(() => {});
|
||||
|
||||
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
await pinMessage(welcomeMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
increment('user_tickets', interaction.user.id, 'today');
|
||||
increment('user_tickets', interaction.user.id, 'week');
|
||||
if (game) {
|
||||
increment('game_tickets', game, 'today');
|
||||
increment('game_tickets', game, 'week');
|
||||
}
|
||||
|
||||
await interaction.deleteReply().catch(() => {});
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ const { getEmailRouting } = require('../services/guildSettings');
|
||||
const { enqueueRename, enqueueMove } = require('../services/channelQueue');
|
||||
const { setNotifyDm } = require('../services/staffSettings');
|
||||
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
||||
const { logTicketEvent, logSecurity } = 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');
|
||||
@@ -55,6 +58,7 @@ async function requireStaffRole(interaction) {
|
||||
content: `This command is only available to the support team (${roleMention}).`,
|
||||
ephemeral: true
|
||||
});
|
||||
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -75,6 +79,12 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
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 renameInfo = await canRename(ticket);
|
||||
@@ -124,12 +134,17 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
|
||||
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
|
||||
const escalationRow = getTicketActionRow(updatedTicketForRow);
|
||||
await interaction.channel.send({
|
||||
const escalationMsg = await interaction.channel.send({
|
||||
content: null,
|
||||
embeds: [escalatedEmbed],
|
||||
components: [escalationRow]
|
||||
});
|
||||
|
||||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||||
const { pinMessage } = require('../services/pinMessage');
|
||||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||||
try {
|
||||
const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (reason ? `\n\nReason: ${reason}` : '');
|
||||
@@ -144,12 +159,16 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
} catch (e) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
if (nextTier === 2) {
|
||||
if (!ticket.welcomeMessageId) {
|
||||
console.warn('welcomeMessageId is null/undefined; skipping welcome-message update for escalation');
|
||||
} else {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
} catch (e) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +395,7 @@ async function handleCommand(interaction) {
|
||||
// /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');
|
||||
@@ -540,6 +560,79 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
}
|
||||
|
||||
// /gmailpoll
|
||||
// /staffthread
|
||||
if (interaction.commandName === 'staffthread') {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'toggle') {
|
||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'name') {
|
||||
const name = interaction.options.getString('thread_name').slice(0, 100);
|
||||
CONFIG.STAFF_THREAD_NAME = name;
|
||||
return interaction.reply({ content: `Staff thread name set to **${name}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'autorole') {
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
||||
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /pinmessages
|
||||
if (interaction.commandName === 'pinmessages') {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
if (sub === 'initial') {
|
||||
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'escalation') {
|
||||
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
if (sub === 'suppress') {
|
||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, ephemeral: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.commandName === 'gmailpoll') {
|
||||
const seconds = parseInt(interaction.options.getString('interval'), 10);
|
||||
const { setGmailPollInterval } = require('../broccolini-discord');
|
||||
setGmailPollInterval(seconds * 1000);
|
||||
logTicketEvent('Gmail poll interval updated', [{ name: 'Interval', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, ephemeral: true });
|
||||
}
|
||||
|
||||
// /closetimer
|
||||
if (interaction.commandName === 'closetimer') {
|
||||
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
||||
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
||||
logTicketEvent('Close timer updated', [{ name: 'Duration', value: `${seconds}s` }, { name: 'Set by', value: interaction.user.tag }], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, ephemeral: true });
|
||||
}
|
||||
|
||||
// /cancel-close
|
||||
if (interaction.commandName === 'cancel-close') {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (!pending) {
|
||||
return interaction.reply({ content: 'No pending close for this channel.', ephemeral: true });
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
const { logTicketEvent } = require('../services/debugLog');
|
||||
logTicketEvent('Force-close cancelled', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Cancelled by', value: interaction.user.tag },
|
||||
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
||||
], interaction).catch(() => {});
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
return interaction.reply({ content: 'Close cancelled.', ephemeral: true });
|
||||
}
|
||||
|
||||
// /force-close
|
||||
if (interaction.commandName === 'force-close') {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
@@ -547,71 +640,86 @@ async function handleCommand(interaction) {
|
||||
return interaction.reply({ content: 'This channel is not linked to a ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
await interaction.reply('Ticket force-closed. Archiving...');
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
|
||||
|
||||
const channelRef = interaction.channel;
|
||||
const clientRef = interaction.client;
|
||||
const timerId = setTimeout(async () => {
|
||||
pendingCloses.delete(channelRef.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
|
||||
try {
|
||||
await interaction.channel.send(CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
|
||||
const messages = await interaction.channel.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
|
||||
messages
|
||||
.reverse()
|
||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||
.join('\n');
|
||||
await channelRef.send('Ticket force-closed. Archiving...');
|
||||
|
||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${interaction.channel.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await interaction.client.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
||||
.catch(() => null);
|
||||
|
||||
if (transcriptChan) {
|
||||
const closedAt = new Date();
|
||||
const openedStr = new Date(ticket.createdAt).toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const closedStr = closedAt.toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, interaction.channel.name)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await transcriptChan.send({
|
||||
content: transcriptContent,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
} catch (tErr) {
|
||||
console.error('Transcript error (force-close):', tErr);
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await interaction.channel.delete('Ticket force-closed');
|
||||
} catch (e) {
|
||||
console.error('Failed to delete channel:', e);
|
||||
await channelRef.send(CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
|
||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
|
||||
messages
|
||||
.reverse()
|
||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||
.join('\n');
|
||||
|
||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${channelRef.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await clientRef.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
||||
.catch(() => null);
|
||||
|
||||
if (transcriptChan) {
|
||||
const closedAt = new Date();
|
||||
const openedStr = new Date(freshTicket.createdAt).toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const closedStr = closedAt.toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelRef.name)
|
||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await transcriptChan.send({
|
||||
content: transcriptContent,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
} catch (tErr) {
|
||||
console.error('Transcript error (force-close):', tErr);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
await interaction.reply({ content: 'Failed to close ticket.', ephemeral: true });
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await channelRef.delete('Ticket force-closed');
|
||||
} catch (e) {
|
||||
console.error('Failed to delete channel:', e);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
}
|
||||
}, timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
}
|
||||
|
||||
// /topic
|
||||
@@ -649,6 +757,9 @@ async function handleCommand(interaction) {
|
||||
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 });
|
||||
@@ -1189,16 +1300,20 @@ async function handleContextMenu(interaction) {
|
||||
|
||||
const row = getTicketActionRow({ escalationTier: 0 });
|
||||
|
||||
const welcomeMsg = await channel.send({
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [row]
|
||||
});
|
||||
try {
|
||||
const welcomeMsg = await channel.send({
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [row]
|
||||
});
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('welcomeMessageId-save', err);
|
||||
}
|
||||
|
||||
await interaction.editReply(`✅ Ticket created: ${channel}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -44,16 +44,20 @@ async function handleDiscordReply(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(() => {});
|
||||
|
||||
// Notify claiming staff if a non-staff user replied (works for both Discord and email tickets)
|
||||
if (ticket.claimerId) {
|
||||
if (ticket.claimerId && !isStaffMember) {
|
||||
const guild = m.guild;
|
||||
const member = await guild.members.fetch(m.author.id).catch(() => null);
|
||||
const isStaff = member && CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
|
||||
if (!isStaff) {
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||
if (freshTicket) {
|
||||
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
|
||||
}
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||
if (freshTicket) {
|
||||
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
8
handlers/pendingCloses.js
Normal file
8
handlers/pendingCloses.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Shared pending-close timer map.
|
||||
* Keyed by channel.id → { timeout, userId, username }.
|
||||
* Used by buttons.js (sets timers) and commands.js (cancel-close clears them).
|
||||
*/
|
||||
const pendingCloses = new Map();
|
||||
|
||||
module.exports = { pendingCloses };
|
||||
@@ -816,7 +816,8 @@ mongoose.model('Ticket', new mongoose.Schema({
|
||||
claimerId: String,
|
||||
staffChannelId: String,
|
||||
parentCategoryId: String,
|
||||
unclaimedReminderssent: { type: [Number], default: [] }
|
||||
unclaimedReminderssent: { type: [Number], default: [] },
|
||||
lastMessageAuthorIsStaff: { type: Boolean, default: false }
|
||||
}));
|
||||
|
||||
mongoose.model('TicketCounter', new mongoose.Schema({
|
||||
|
||||
129
routes/internalApi.js
Normal file
129
routes/internalApi.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const express = require('express');
|
||||
const { CONFIG } = require('../config');
|
||||
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
||||
const { logSystem } = require('../services/debugLog');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Middleware: verify internal secret
|
||||
router.use((req, res, next) => {
|
||||
const secret = req.headers['x-internal-secret'];
|
||||
if (!CONFIG.INTERNAL_API_SECRET || secret !== CONFIG.INTERNAL_API_SECRET) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// GET /config — return all current .env values (redacted secrets)
|
||||
router.get('/config', (req, res) => {
|
||||
const map = readAllConfig();
|
||||
const obj = {};
|
||||
const REDACTED = ['DISCORD_TOKEN', 'REFRESH_TOKEN', 'GOOGLE_CLIENT_SECRET', 'MONGODB_URI', 'INTERNAL_API_SECRET', 'SETTINGS_ADMIN_PASSWORD'];
|
||||
for (const [k, v] of map) {
|
||||
obj[k] = REDACTED.includes(k) ? '••••••••' : v;
|
||||
}
|
||||
res.json(obj);
|
||||
});
|
||||
|
||||
// POST /config — apply config updates
|
||||
router.post('/config', express.json(), async (req, res) => {
|
||||
const updates = req.body;
|
||||
if (!updates || typeof updates !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid body' });
|
||||
}
|
||||
const result = applyConfigUpdates(updates);
|
||||
await logSystem('Config updated via settings UI', [
|
||||
{ name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false },
|
||||
{ name: 'Errors', value: result.errors.join(', ') || 'none', inline: false }
|
||||
]).catch(() => {});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// GET /discord/guild — return guild info for smart dropdowns
|
||||
router.get('/discord/guild', async (req, res) => {
|
||||
try {
|
||||
const client = require('../api/bosscordClient').getBot();
|
||||
if (!client) return res.status(503).json({ error: 'Bot not ready' });
|
||||
|
||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||
if (!guild) return res.status(404).json({ error: 'Guild not found' });
|
||||
|
||||
await guild.members.fetch().catch(() => {});
|
||||
|
||||
const channels = guild.channels.cache
|
||||
.filter(c => [0, 4, 5, 15].includes(c.type))
|
||||
.map(c => ({ id: c.id, name: c.name, type: c.type, parentId: c.parentId }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const roles = guild.roles.cache
|
||||
.filter(r => !r.managed && r.id !== guild.id)
|
||||
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }))
|
||||
.sort((a, b) => b.position - a.position);
|
||||
|
||||
const members = guild.members.cache
|
||||
.filter(m => !m.user.bot)
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
username: m.user.username,
|
||||
displayName: m.displayName,
|
||||
avatar: m.user.displayAvatarURL({ size: 32 })
|
||||
}))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
const categories = guild.channels.cache
|
||||
.filter(c => c.type === 4)
|
||||
.map(c => ({ id: c.id, name: c.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
res.json({ channels, roles, members, categories });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /restart — restart the bot process
|
||||
let scheduledRestart = null;
|
||||
|
||||
router.post('/restart', express.json(), (req, res) => {
|
||||
const { mode, scheduledFor } = req.body;
|
||||
|
||||
if (mode === 'immediate') {
|
||||
res.json({ ok: true, mode });
|
||||
setTimeout(() => {
|
||||
console.log('[restart] Restarting bot process...');
|
||||
process.exit(0); // Docker/systemd will restart
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'scheduled' && scheduledFor) {
|
||||
const delay = new Date(scheduledFor).getTime() - Date.now();
|
||||
if (delay <= 0) return res.status(400).json({ error: 'Scheduled time is in the past' });
|
||||
if (scheduledRestart) clearTimeout(scheduledRestart);
|
||||
scheduledRestart = setTimeout(() => {
|
||||
console.log('[restart] Scheduled restart firing...');
|
||||
process.exit(0);
|
||||
}, delay);
|
||||
res.json({ ok: true, mode, scheduledFor, delayMs: delay });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'cancel_scheduled') {
|
||||
if (scheduledRestart) { clearTimeout(scheduledRestart); scheduledRestart = null; }
|
||||
res.json({ ok: true, cancelled: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'pending') {
|
||||
res.json({ ok: true, mode: 'pending', note: 'Restart required on next manual restart' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).json({ error: 'Invalid mode' });
|
||||
});
|
||||
|
||||
router.get('/restart/status', (req, res) => {
|
||||
res.json({ scheduledRestart: !!scheduledRestart });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,32 +1,87 @@
|
||||
/**
|
||||
* Serialized channel renames/moves to avoid Discord rate limits (e.g. 2 renames / 10 min per channel).
|
||||
* Per-channel rename rate limiting with queue.
|
||||
* Discord allows 2 channel renames per 10 minutes per channel.
|
||||
* We use a 9-minute window for safety margin.
|
||||
*/
|
||||
const PQueue = require('p-queue').default;
|
||||
|
||||
const channelQueue = new PQueue({
|
||||
concurrency: 1,
|
||||
intervalCap: 2,
|
||||
interval: 10000
|
||||
});
|
||||
const RENAME_WINDOW_MS = 9 * 60 * 1000;
|
||||
const RENAME_LIMIT = 2;
|
||||
|
||||
// Per-channel state: { count, windowStart, queue: [{newName, resolve, reject}], processing }
|
||||
const renameState = new Map();
|
||||
|
||||
function getOrInitState(channelId) {
|
||||
let state = renameState.get(channelId);
|
||||
if (!state) {
|
||||
state = { count: 0, windowStart: 0, queue: [], processing: false };
|
||||
renameState.set(channelId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function executeRename(channel, newName) {
|
||||
await channel.setName(newName);
|
||||
}
|
||||
|
||||
function processQueue(channel, state) {
|
||||
if (state.queue.length === 0 || state.processing) return;
|
||||
|
||||
const now = Date.now();
|
||||
const timeUntilExpiry = (state.windowStart + RENAME_WINDOW_MS) - now;
|
||||
|
||||
if (timeUntilExpiry > 0) {
|
||||
state.processing = true;
|
||||
setTimeout(async () => {
|
||||
state.processing = false;
|
||||
// New window
|
||||
if (state.queue.length > 3) {
|
||||
const { logWarn } = require('../services/debugLog');
|
||||
logWarn('renameQueue', `Channel ${channel.name} has ${state.queue.length} renames queued`).catch(() => {});
|
||||
}
|
||||
const item = state.queue.shift();
|
||||
if (!item) return;
|
||||
state.count = 1;
|
||||
state.windowStart = Date.now();
|
||||
try {
|
||||
await executeRename(channel, item.newName);
|
||||
item.resolve();
|
||||
} catch (err) {
|
||||
item.reject(err);
|
||||
}
|
||||
// Continue processing remaining queue items
|
||||
processQueue(channel, state);
|
||||
}, timeUntilExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueRename(channel, newName) {
|
||||
return channelQueue.add(async () => {
|
||||
try {
|
||||
await channel.setName(newName);
|
||||
} catch (err) {
|
||||
const msg = err?.message || String(err);
|
||||
if (msg.includes('429') || msg.toLowerCase().includes('rate limit')) {
|
||||
console.warn(`enqueueRename: rate limit renaming channel "${channel.name}"`);
|
||||
return;
|
||||
}
|
||||
console.error('enqueueRename:', err);
|
||||
throw err;
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getOrInitState(channel.id);
|
||||
const now = Date.now();
|
||||
|
||||
// Window expired — reset
|
||||
if (now - state.windowStart >= RENAME_WINDOW_MS) {
|
||||
state.count = 1;
|
||||
state.windowStart = now;
|
||||
executeRename(channel, newName).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
// Within window and under limit
|
||||
if (state.count < RENAME_LIMIT) {
|
||||
state.count++;
|
||||
executeRename(channel, newName).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
// At limit — queue it
|
||||
state.queue.push({ newName, resolve, reject });
|
||||
processQueue(channel, state);
|
||||
});
|
||||
}
|
||||
|
||||
function enqueueMove(channel, categoryId) {
|
||||
return channelQueue.add(() => channel.setParent(categoryId, { lockPermissions: true }));
|
||||
return channel.setParent(categoryId, { lockPermissions: true });
|
||||
}
|
||||
|
||||
module.exports = { channelQueue, enqueueRename, enqueueMove };
|
||||
module.exports = { enqueueRename, enqueueMove };
|
||||
|
||||
86
services/chatAlertChecker.js
Normal file
86
services/chatAlertChecker.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Chat monitoring — tracks unresponded messages in configured channels
|
||||
* and alerts staff when thresholds are crossed.
|
||||
*/
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { setCooldown, isOnCooldown } = require('./patternStore');
|
||||
|
||||
// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt }
|
||||
const chatState = new Map();
|
||||
|
||||
function initChatMonitoring(client) {
|
||||
for (const channelId of CONFIG.CHAT_ALERT_CHANNEL_IDS) {
|
||||
chatState.set(channelId, {
|
||||
lastStaffMessageAt: new Date(),
|
||||
unrespondedCount: 0,
|
||||
lastAlertAt: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isStaff(member) {
|
||||
if (!member?.roles?.cache) return false;
|
||||
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
|
||||
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
|
||||
return additional.some(roleId => member.roles.cache.has(roleId));
|
||||
}
|
||||
|
||||
async function handleChatMessage(msg, client) {
|
||||
if (msg.author.bot) return;
|
||||
if (!chatState.has(msg.channel.id)) return;
|
||||
|
||||
const state = chatState.get(msg.channel.id);
|
||||
if (isStaff(msg.member)) {
|
||||
state.lastStaffMessageAt = new Date();
|
||||
state.unrespondedCount = 0;
|
||||
} else {
|
||||
state.unrespondedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
async function runChatAlertChecks(client) {
|
||||
const alertChannelId = CONFIG.ALL_STAFF_CHAT_ALERT_CHANNEL_ID;
|
||||
if (!alertChannelId || !client) return;
|
||||
|
||||
for (const [channelId, state] of chatState) {
|
||||
// Message count threshold
|
||||
if (state.unrespondedCount >= CONFIG.CHAT_ALERT_MESSAGE_COUNT) {
|
||||
const cooldownKey = `chat:messages:${channelId}`;
|
||||
if (!isOnCooldown(cooldownKey, CONFIG.CHAT_ALERT_COOLDOWN_MINUTES)) {
|
||||
setCooldown(cooldownKey);
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Chat needs attention')
|
||||
.setDescription(`<#${channelId}> has ${state.unrespondedCount} unresponded messages.`)
|
||||
.setColor(0xFF8800)
|
||||
.setTimestamp();
|
||||
try {
|
||||
const alertChan = await client.channels.fetch(alertChannelId);
|
||||
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
|
||||
if (alertChan) await alertChan.send({ content, embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Time threshold
|
||||
const hoursSinceStaff = (Date.now() - state.lastStaffMessageAt.getTime()) / 3600000;
|
||||
if (hoursSinceStaff >= CONFIG.CHAT_ALERT_HOURS_WITHOUT_RESPONSE && state.unrespondedCount > 0) {
|
||||
const cooldownKey = `chat:time:${channelId}`;
|
||||
if (!isOnCooldown(cooldownKey, CONFIG.CHAT_ALERT_COOLDOWN_MINUTES)) {
|
||||
setCooldown(cooldownKey);
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Chat without staff response')
|
||||
.setDescription(`<#${channelId}> has had no staff response for ${Math.floor(hoursSinceStaff)} hour(s) with ${state.unrespondedCount} pending message(s).`)
|
||||
.setColor(0xFF8800)
|
||||
.setTimestamp();
|
||||
try {
|
||||
const alertChan = await client.channels.fetch(alertChannelId);
|
||||
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
|
||||
if (alertChan) await alertChan.send({ content, embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initChatMonitoring, handleChatMessage, runChatAlertChecks };
|
||||
105
services/configPersistence.js
Normal file
105
services/configPersistence.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
const ENV_PATH = process.env.ENV_FILE
|
||||
? path.resolve(process.env.ENV_FILE)
|
||||
: path.resolve(process.cwd(), '.env');
|
||||
|
||||
/**
|
||||
* Read the current .env file and parse into a key->value Map.
|
||||
*/
|
||||
function readEnvFile() {
|
||||
if (!fs.existsSync(ENV_PATH)) return new Map();
|
||||
const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n');
|
||||
const map = new Map();
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
map.set(key, value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Map of key->value back to the .env file,
|
||||
* preserving comments and blank lines.
|
||||
*/
|
||||
function writeEnvFile(updates) {
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
const lines = [];
|
||||
for (const [k, v] of updates) lines.push(`${k}=${v}`);
|
||||
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(ENV_PATH, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
const written = new Set();
|
||||
|
||||
const result = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx === -1) return line;
|
||||
const key = line.slice(0, idx).trim();
|
||||
if (updates.has(key)) {
|
||||
written.add(key);
|
||||
return `${key}=${updates.get(key)}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
// Append any new keys not already in the file
|
||||
for (const [k, v] of updates) {
|
||||
if (!written.has(k)) result.push(`${k}=${v}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a flat object of { KEY: value } to both CONFIG and .env.
|
||||
* Returns { applied: string[], errors: string[] }
|
||||
*/
|
||||
function applyConfigUpdates(updates) {
|
||||
const applied = [];
|
||||
const errors = [];
|
||||
|
||||
for (const [key, rawValue] of Object.entries(updates)) {
|
||||
try {
|
||||
if (rawValue === 'true' || rawValue === 'false') {
|
||||
CONFIG[key] = rawValue === 'true';
|
||||
} else if (!isNaN(rawValue) && rawValue !== '') {
|
||||
CONFIG[key] = Number(rawValue);
|
||||
} else {
|
||||
CONFIG[key] = rawValue;
|
||||
}
|
||||
applied.push(key);
|
||||
} catch (err) {
|
||||
errors.push(`${key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write to .env
|
||||
const envMap = readEnvFile();
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
envMap.set(key, String(value));
|
||||
}
|
||||
writeEnvFile(envMap);
|
||||
|
||||
return { applied, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all current env values for the settings UI.
|
||||
*/
|
||||
function readAllConfig() {
|
||||
return readEnvFile();
|
||||
}
|
||||
|
||||
module.exports = { applyConfigUpdates, readAllConfig, readEnvFile, writeEnvFile };
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Send error details to DEBUGGING_CHANNEL_ID when set.
|
||||
* Call setClient(client) from the main bot on ready so errors can be posted.
|
||||
* Structured logging service – posts embeds to dedicated Discord channels.
|
||||
* Call setClient(client) from the main bot on ready so logs can be posted.
|
||||
*/
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
let client = null;
|
||||
@@ -10,13 +11,21 @@ function setClient(c) {
|
||||
client = c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post an error to the debugging channel (if DEBUGGING_CHANNEL_ID and client are set).
|
||||
* @param {string} context - e.g. 'escalate', 'deescalate', 'email-routing', 'Gmail poll'
|
||||
* @param {Error} error
|
||||
* @param {import('discord.js').Interaction} [interaction]
|
||||
* @param {import('discord.js').Client} [overrideClient] - use this client instead of stored (e.g. from gmail-poll)
|
||||
*/
|
||||
// --- Helpers ---
|
||||
|
||||
async function sendToChannel(channelId, embed, overrideClient) {
|
||||
const c = overrideClient || client;
|
||||
if (!c || !channelId) return;
|
||||
try {
|
||||
const channel = await c.channels.fetch(channelId);
|
||||
if (channel) await channel.send({ embeds: [embed] });
|
||||
} catch (_) {
|
||||
// ignore send failures
|
||||
}
|
||||
}
|
||||
|
||||
// --- logError (backwards-compatible) ---
|
||||
|
||||
async function logError(context, error, interaction = null, overrideClient = null) {
|
||||
const c = overrideClient || client;
|
||||
if (!c || !CONFIG.DEBUGGING_CHANNEL_ID) return;
|
||||
@@ -38,4 +47,124 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setClient, logError };
|
||||
// --- logWarn ---
|
||||
|
||||
async function logWarn(context, message, overrideClient = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Warning: ${context}`)
|
||||
.setDescription(String(message).slice(0, 4000))
|
||||
.setColor(0xFFFF00)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logEvent (generic – posts to any configured channel) ---
|
||||
|
||||
async function logEvent(channelConfigKey, embed, overrideClient = null) {
|
||||
const channelId = CONFIG[channelConfigKey];
|
||||
await sendToChannel(channelId, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logTicketEvent ---
|
||||
|
||||
async function logTicketEvent(action, fields, interaction = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(action)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO || 0x1e2124)
|
||||
.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true })))
|
||||
.setTimestamp();
|
||||
if (interaction?.user?.tag) {
|
||||
embed.setFooter({ text: interaction.user.tag });
|
||||
}
|
||||
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
|
||||
}
|
||||
|
||||
// --- logGmail ---
|
||||
|
||||
async function logGmail(subject, sender, ticketNumber, game) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Email Ticket Created')
|
||||
.setColor(0x00BFFF)
|
||||
.addFields(
|
||||
{ name: 'Subject', value: String(subject || 'No subject').slice(0, 256), inline: false },
|
||||
{ name: 'Sender', value: String(sender || 'unknown'), inline: true },
|
||||
{ name: 'Ticket #', value: String(ticketNumber || '?'), inline: true },
|
||||
{ name: 'Game', value: String(game || 'Not detected'), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.GMAIL_LOG_CHANNEL_ID, embed);
|
||||
}
|
||||
|
||||
// --- logAutomation ---
|
||||
|
||||
async function logAutomation(action, ticketChannelName, detail) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(action)
|
||||
.setColor(0x9B59B6)
|
||||
.setTimestamp();
|
||||
if (ticketChannelName) {
|
||||
embed.addFields({ name: 'Ticket', value: String(ticketChannelName), inline: true });
|
||||
}
|
||||
if (detail) {
|
||||
embed.addFields({ name: 'Detail', value: String(detail).slice(0, 1024), inline: false });
|
||||
}
|
||||
await sendToChannel(CONFIG.AUTOMATION_LOG_CHANNEL_ID, embed);
|
||||
}
|
||||
|
||||
// --- logSecurity ---
|
||||
|
||||
async function logSecurity(action, user, detail, overrideClient = null, color = 0xFF6600) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Security Event')
|
||||
.setColor(color)
|
||||
.addFields(
|
||||
{ name: 'Action', value: String(action).slice(0, 256), inline: false },
|
||||
{ name: 'User', value: user ? `${user.tag} (${user.id})` : 'Unknown', inline: true },
|
||||
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.SECURITY_LOG_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logIntegrity ---
|
||||
|
||||
async function logIntegrity(issue, detail, overrideClient = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket Integrity Issue')
|
||||
.setColor(0xFF0000)
|
||||
.addFields(
|
||||
{ name: 'Issue', value: String(issue).slice(0, 256), inline: false },
|
||||
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logSystem ---
|
||||
|
||||
async function logSystem(message, fields = [], overrideClient = null, color = 0x0099ff) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(message)
|
||||
.setColor(color)
|
||||
.setTimestamp();
|
||||
if (fields.length > 0) {
|
||||
embed.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true })));
|
||||
}
|
||||
embed.addFields({ name: 'Timestamp', value: new Date().toISOString(), inline: true });
|
||||
await sendToChannel(CONFIG.SYSTEM_LOG_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setClient,
|
||||
logError,
|
||||
logWarn,
|
||||
logEvent,
|
||||
logTicketEvent,
|
||||
logGmail,
|
||||
logAutomation,
|
||||
logSecurity,
|
||||
logIntegrity,
|
||||
logSystem
|
||||
};
|
||||
|
||||
535
services/patternChecker.js
Normal file
535
services/patternChecker.js
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* Pattern detection — scheduled checks that analyze ticket trends and post
|
||||
* alerts to dedicated Discord channels.
|
||||
*/
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { getAll, get } = require('./patternStore');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
// Deduplication: keys that have already fired today
|
||||
const firedToday = new Set();
|
||||
|
||||
// Register daily reset
|
||||
const { onDailyReset } = require('./patternStore');
|
||||
onDailyReset(() => firedToday.clear());
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function buildEmbed(title, description, color = 0xFFAA00) {
|
||||
return new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(String(description).slice(0, 4000))
|
||||
.setColor(color)
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
async function postPattern(client, channelConfigKey, embed) {
|
||||
const channelId = CONFIG[channelConfigKey];
|
||||
if (!channelId || !client) return;
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (channel) await channel.send({ embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function shouldFire(key) {
|
||||
if (firedToday.has(key)) return false;
|
||||
firedToday.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getThisWeekStart() {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const diff = day === 0 ? 6 : day - 1;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() - diff);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
return monday;
|
||||
}
|
||||
|
||||
// --- Check functions ---
|
||||
|
||||
async function checkUserPatterns(client) {
|
||||
// Surge: users with tickets >= threshold today
|
||||
const todayCounts = getAll('user_tickets', 'today');
|
||||
for (const [userId, count] of todayCounts) {
|
||||
if (count >= CONFIG.PATTERN_USER_TICKET_THRESHOLD) {
|
||||
const key = `user_tickets:${userId}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Repeat ticket user',
|
||||
`User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reopens this week
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
try {
|
||||
const reopens = await Ticket.aggregate([
|
||||
{ $match: { reopenedAt: { $gte: since } } },
|
||||
{ $group: { _id: '$senderEmail', count: { $sum: 1 } } },
|
||||
{ $match: { count: { $gte: 2 } } }
|
||||
]);
|
||||
for (const r of reopens) {
|
||||
const key = `user_reopen:${r._id}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'High reopen rate',
|
||||
`${r._id} reopened tickets ${r.count}x this week`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Cross-game: users with tickets across 3+ games this week
|
||||
try {
|
||||
const crossGame = await Ticket.aggregate([
|
||||
{ $match: { createdAt: { $gte: since }, status: { $ne: 'closed' } } },
|
||||
{ $group: { _id: '$senderEmail', games: { $addToSet: '$game' } } },
|
||||
{ $match: { 'games.2': { $exists: true } } }
|
||||
]);
|
||||
for (const c of crossGame) {
|
||||
const key = `user_crossgame:${c._id}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Cross-game user',
|
||||
`${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`,
|
||||
0x00AAFF
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function checkGamePatterns(client) {
|
||||
// Surge: games with tickets >= threshold today
|
||||
const todayCounts = getAll('game_tickets', 'today');
|
||||
for (const [game, count] of todayCounts) {
|
||||
if (count >= CONFIG.PATTERN_GAME_TICKET_THRESHOLD) {
|
||||
const key = `game_surge:${game}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Game ticket surge',
|
||||
`**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`,
|
||||
0xFF6600
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backlog: unclaimed tickets older than threshold
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - CONFIG.PATTERN_UNCLAIMED_HOURS * 3600000);
|
||||
const backlog = await Ticket.aggregate([
|
||||
{ $match: { status: 'open', claimedBy: null, createdAt: { $lte: cutoff } } },
|
||||
{ $group: { _id: '$game', count: { $sum: 1 } } },
|
||||
{ $match: { count: { $gte: 3 } } }
|
||||
]);
|
||||
for (const b of backlog) {
|
||||
const gameName = b._id || 'Unknown';
|
||||
const key = `game_backlog:${gameName}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Game backlog alert',
|
||||
`**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`,
|
||||
0xFF0000
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Resolution time trending: this week vs last week
|
||||
try {
|
||||
const thisWeekStart = getThisWeekStart();
|
||||
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const thisWeek = await Ticket.aggregate([
|
||||
{ $match: { status: 'closed', closedAt: { $gte: thisWeekStart }, game: { $ne: null } } },
|
||||
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
|
||||
]);
|
||||
const lastWeek = await Ticket.aggregate([
|
||||
{ $match: { status: 'closed', closedAt: { $gte: lastWeekStart, $lt: thisWeekStart }, game: { $ne: null } } },
|
||||
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
|
||||
]);
|
||||
const lastWeekMap = new Map(lastWeek.map(l => [l._id, l.avg]));
|
||||
for (const tw of thisWeek) {
|
||||
const lw = lastWeekMap.get(tw._id);
|
||||
if (lw && tw.avg > lw * 1.2) {
|
||||
const key = `game_resolution:${tw._id}:week`;
|
||||
if (shouldFire(key)) {
|
||||
const twHrs = (tw.avg / 3600000).toFixed(1);
|
||||
const lwHrs = (lw / 3600000).toFixed(1);
|
||||
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Resolution time increasing',
|
||||
`**${tw._id}**: ${twHrs}h avg this week vs ${lwHrs}h last week (+${((tw.avg / lw - 1) * 100).toFixed(0)}%).`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Spike after silence: games with 0 tickets in last 3 days but 3+ today
|
||||
try {
|
||||
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
|
||||
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
|
||||
const recentByGame = await Ticket.aggregate([
|
||||
{ $match: { createdAt: { $gte: threeDaysAgo, $lt: todayStart }, game: { $ne: null } } },
|
||||
{ $group: { _id: '$game', count: { $sum: 1 } } }
|
||||
]);
|
||||
const recentGames = new Set(recentByGame.map(r => r._id));
|
||||
for (const [game, count] of todayCounts) {
|
||||
if (count >= 3 && !recentGames.has(game)) {
|
||||
const key = `game_spike:${game}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Possible outage',
|
||||
`**${game}**: ${count} tickets today after 0 in the last 3 days.`,
|
||||
0xFF0000
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function checkTagPatterns(client) {
|
||||
// Most common tag today
|
||||
const todayTags = getAll('tag_usage', 'today');
|
||||
let topTag = null, topCount = 0;
|
||||
for (const [tag, count] of todayTags) {
|
||||
if (count > topCount) { topTag = tag; topCount = count; }
|
||||
}
|
||||
if (topTag && topCount >= 5) {
|
||||
const key = `tag_top:${topTag}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Top issue tag today',
|
||||
`**${topTag}** used ${topCount} times today.`,
|
||||
0x00AAFF
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Tag→escalation correlation
|
||||
try {
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const tagEscalations = await Ticket.aggregate([
|
||||
{ $match: { createdAt: { $gte: since }, escalationTier: { $gte: 1 }, ticketTag: { $ne: null } } },
|
||||
{ $group: { _id: '$ticketTag', count: { $sum: 1 } } },
|
||||
{ $match: { count: { $gte: 3 } } }
|
||||
]);
|
||||
for (const te of tagEscalations) {
|
||||
const key = `tag_escalation:${te._id}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Tag frequently leads to escalation',
|
||||
`**${te._id}**: ${te.count} escalated tickets this week.`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Untagged closes
|
||||
const untaggedCount = get('untagged_closes', 'total', 'today');
|
||||
if (untaggedCount >= 5) {
|
||||
const key = 'untagged_closes:today';
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'High untagged close rate',
|
||||
`${untaggedCount} tickets closed today without a tag.`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Tag↔game correlation: for each tag this week, check if one game dominates
|
||||
const weekTags = getAll('tag_usage', 'week');
|
||||
for (const [tag] of weekTags) {
|
||||
const tagGameCounts = getAll(`tag_game:${tag}`, 'week');
|
||||
let total = 0, maxGame = null, maxCount = 0;
|
||||
for (const [game, count] of tagGameCounts) {
|
||||
total += count;
|
||||
if (count > maxCount) { maxGame = game; maxCount = count; }
|
||||
}
|
||||
if (total >= 5 && maxGame && maxCount / total > 0.8) {
|
||||
const key = `tag_game_corr:${tag}:${maxGame}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Auto-tagging opportunity',
|
||||
`**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`,
|
||||
0x00AAFF
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEscalationPatterns(client) {
|
||||
// User escalation rate
|
||||
const userEscalations = getAll('user_escalations', 'week');
|
||||
for (const [user, count] of userEscalations) {
|
||||
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
|
||||
const key = `user_esc:${user}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Frequent escalation user',
|
||||
`\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game escalation rate vs baseline
|
||||
try {
|
||||
const thisWeekStart = getThisWeekStart();
|
||||
const thisWeek = await Ticket.aggregate([
|
||||
{ $match: { escalationTier: { $gte: 1 }, createdAt: { $gte: thisWeekStart } } },
|
||||
{ $group: { _id: '$game', count: { $sum: 1 } } }
|
||||
]);
|
||||
const totalThisWeek = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart } });
|
||||
for (const tw of thisWeek) {
|
||||
if (!tw._id) continue;
|
||||
const gameTotal = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart }, game: tw._id });
|
||||
if (gameTotal > 0 && tw.count / gameTotal > 0.5) {
|
||||
const key = `game_esc_rate:${tw._id}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'High escalation rate for game',
|
||||
`**${tw._id}**: ${tw.count}/${gameTotal} tickets escalated (${Math.round(tw.count / gameTotal * 100)}%) this week.`,
|
||||
0xFF6600
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Rapid tier 2→3
|
||||
try {
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const rapid = await Ticket.find({
|
||||
escalationTier: 2,
|
||||
escalatedAt: { $gte: since }
|
||||
}).lean();
|
||||
// Count tickets where escalation happened very quickly (approximate: check if tier was changed recently)
|
||||
const rapidCount = rapid.length;
|
||||
if (rapidCount >= 3) {
|
||||
const key = 'rapid_t2_t3:week';
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Tier 2 unable to handle issue type',
|
||||
`${rapidCount} tickets reached tier 3 this week.`,
|
||||
0xFF0000
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function checkStaffPatterns(client) {
|
||||
// Claims without closes
|
||||
const todayClaims = getAll('staff_claims', 'today');
|
||||
for (const [staffId, claims] of todayClaims) {
|
||||
if (claims >= 3 && get('staff_closes', staffId, 'today') === 0) {
|
||||
const key = `staff_no_close:${staffId}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Claims without closes',
|
||||
`Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overloaded: open tickets per claimer
|
||||
try {
|
||||
const overloaded = await Ticket.aggregate([
|
||||
{ $match: { status: 'open', claimerId: { $ne: null } } },
|
||||
{ $group: { _id: '$claimerId', count: { $sum: 1 } } },
|
||||
{ $match: { count: { $gte: 5 } } }
|
||||
]);
|
||||
for (const o of overloaded) {
|
||||
const key = `staff_overloaded:${o._id}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Staff overloaded',
|
||||
`Staff \`${o._id}\` has ${o.count} open claimed tickets.`,
|
||||
0xFF6600
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Stale ping threshold
|
||||
const stalePings = getAll('staff_stale_pings', 'today');
|
||||
for (const [staffId, count] of stalePings) {
|
||||
if (count >= CONFIG.PATTERN_STAFF_STALE_PING_THRESHOLD) {
|
||||
const key = `staff_stale:${staffId}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Staff stale ping threshold',
|
||||
`Staff \`${staffId}\` received ${count} stale pings today.`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer rate
|
||||
const todayTransfers = getAll('staff_transfers', 'today');
|
||||
for (const [staffId, transfers] of todayTransfers) {
|
||||
const claims = get('staff_claims', staffId, 'today');
|
||||
if (claims > 0 && transfers >= claims) {
|
||||
const key = `staff_transfer_rate:${staffId}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'High transfer rate',
|
||||
`Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escalations per staff
|
||||
const weekEscalations = getAll('staff_escalations', 'week');
|
||||
for (const [staffId, count] of weekEscalations) {
|
||||
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
|
||||
const key = `staff_esc:${staffId}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Staff frequent escalator',
|
||||
`Staff \`${staffId}\` escalated ${count} tickets this week.`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCombinedPatterns(client) {
|
||||
// Staff+game escalation correlation
|
||||
const weekEscStaff = getAll('staff_escalations', 'week');
|
||||
for (const [staffId] of weekEscStaff) {
|
||||
const gameEsc = getAll(`staff_game_escalations:${staffId}`, 'week');
|
||||
for (const [game, count] of gameEsc) {
|
||||
if (count >= 3) {
|
||||
const key = `staff_game_esc:${staffId}:${game}:week`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Staff may need training for this game',
|
||||
`Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`,
|
||||
0xFFAA00
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game+tag spike: specific game+tag combo >= 5 today
|
||||
const todayGames = getAll('game_tickets', 'today');
|
||||
const todayTags = getAll('tag_usage', 'today');
|
||||
for (const [game] of todayGames) {
|
||||
for (const [tag] of todayTags) {
|
||||
const tagGameCount = get(`tag_game:${tag}`, game, 'week');
|
||||
if (tagGameCount >= 5) {
|
||||
const key = `game_tag_spike:${game}:${tag}:today`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Specific feature of specific game spiking',
|
||||
`**${game}** + **${tag}**: ${tagGameCount} tickets this week.`,
|
||||
0xFF6600
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overnight escalation gap: compare 00:00-06:00 vs daytime escalation rates
|
||||
try {
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const overnight = await Ticket.countDocuments({
|
||||
createdAt: { $gte: since },
|
||||
escalationTier: { $gte: 1 },
|
||||
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
|
||||
});
|
||||
const daytime = await Ticket.countDocuments({
|
||||
createdAt: { $gte: since },
|
||||
escalationTier: { $gte: 1 },
|
||||
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
|
||||
});
|
||||
const overnightTotal = await Ticket.countDocuments({
|
||||
createdAt: { $gte: since },
|
||||
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
|
||||
});
|
||||
const daytimeTotal = await Ticket.countDocuments({
|
||||
createdAt: { $gte: since },
|
||||
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
|
||||
});
|
||||
if (overnightTotal > 0 && daytimeTotal > 0) {
|
||||
const overnightRate = overnight / overnightTotal;
|
||||
const daytimeRate = daytime / daytimeTotal;
|
||||
if (overnightRate > daytimeRate * 2 && overnight >= 3) {
|
||||
const key = 'overnight_gap:week';
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Overnight coverage gap',
|
||||
`Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`,
|
||||
0xFF0000
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Staff never resolves game X without escalating
|
||||
try {
|
||||
const monthStart = new Date();
|
||||
monthStart.setDate(1);
|
||||
monthStart.setHours(0, 0, 0, 0);
|
||||
const staffGameStats = await Ticket.aggregate([
|
||||
{ $match: { claimerId: { $ne: null }, game: { $ne: null }, createdAt: { $gte: monthStart } } },
|
||||
{ $group: {
|
||||
_id: { staff: '$claimerId', game: '$game' },
|
||||
total: { $sum: 1 },
|
||||
escalated: { $sum: { $cond: [{ $gte: ['$escalationTier', 1] }, 1, 0] } }
|
||||
}},
|
||||
{ $match: { total: { $gte: 3 } } }
|
||||
]);
|
||||
for (const s of staffGameStats) {
|
||||
if (s.escalated / s.total >= 0.9) {
|
||||
const key = `staff_always_esc:${s._id.staff}:${s._id.game}:month`;
|
||||
if (shouldFire(key)) {
|
||||
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
|
||||
'Staff always escalates this game',
|
||||
`Staff \`${s._id.staff}\` escalated ${s.escalated}/${s.total} **${s._id.game}** tickets this month.`,
|
||||
0xFF6600
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// --- Main entry point ---
|
||||
|
||||
async function runPatternChecks(client) {
|
||||
try { await checkUserPatterns(client); } catch (e) { console.error('checkUserPatterns:', e); }
|
||||
try { await checkGamePatterns(client); } catch (e) { console.error('checkGamePatterns:', e); }
|
||||
try { await checkTagPatterns(client); } catch (e) { console.error('checkTagPatterns:', e); }
|
||||
try { await checkEscalationPatterns(client); } catch (e) { console.error('checkEscalationPatterns:', e); }
|
||||
try { await checkStaffPatterns(client); } catch (e) { console.error('checkStaffPatterns:', e); }
|
||||
try { await checkCombinedPatterns(client); } catch (e) { console.error('checkCombinedPatterns:', e); }
|
||||
}
|
||||
|
||||
module.exports = { runPatternChecks };
|
||||
148
services/patternStore.js
Normal file
148
services/patternStore.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* In-memory counter store with TTL windows for pattern detection.
|
||||
* Windows: 'today' resets at midnight, 'week' resets Monday 00:00, 'month' resets 1st 00:00.
|
||||
*/
|
||||
|
||||
// store[window][namespace][key] = count
|
||||
const store = {
|
||||
today: new Map(),
|
||||
week: new Map(),
|
||||
month: new Map()
|
||||
};
|
||||
|
||||
function getNamespaceMap(window, namespace) {
|
||||
const windowMap = store[window];
|
||||
if (!windowMap) return null;
|
||||
if (!windowMap.has(namespace)) windowMap.set(namespace, new Map());
|
||||
return windowMap.get(namespace);
|
||||
}
|
||||
|
||||
function increment(namespace, key, window) {
|
||||
const map = getNamespaceMap(window, namespace);
|
||||
if (!map) return;
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
function get(namespace, key, window) {
|
||||
const map = getNamespaceMap(window, namespace);
|
||||
if (!map) return 0;
|
||||
return map.get(key) || 0;
|
||||
}
|
||||
|
||||
function reset(namespace, window) {
|
||||
const windowMap = store[window];
|
||||
if (!windowMap) return;
|
||||
windowMap.delete(namespace);
|
||||
}
|
||||
|
||||
function getAll(namespace, window) {
|
||||
const map = getNamespaceMap(window, namespace);
|
||||
if (!map) return new Map();
|
||||
return new Map(map);
|
||||
}
|
||||
|
||||
// --- Scheduled resets ---
|
||||
|
||||
function msUntilNextMidnight() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(24, 0, 0, 0);
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
function msUntilNextMonday() {
|
||||
const now = new Date();
|
||||
const day = now.getDay(); // 0=Sun
|
||||
const daysUntilMonday = day === 0 ? 1 : (8 - day);
|
||||
const next = new Date(now);
|
||||
next.setDate(now.getDate() + daysUntilMonday);
|
||||
next.setHours(0, 0, 0, 0);
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
function msUntilNextMonth() {
|
||||
const now = new Date();
|
||||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
// Callbacks to run on daily reset (e.g. clear firedToday in patternChecker)
|
||||
const dailyResetCallbacks = [];
|
||||
|
||||
function onDailyReset(fn) {
|
||||
dailyResetCallbacks.push(fn);
|
||||
}
|
||||
|
||||
function scheduleDailyReset() {
|
||||
setTimeout(() => {
|
||||
store.today = new Map();
|
||||
for (const fn of dailyResetCallbacks) {
|
||||
try { fn(); } catch (_) {}
|
||||
}
|
||||
scheduleDailyReset();
|
||||
}, msUntilNextMidnight());
|
||||
}
|
||||
|
||||
function scheduleWeeklyReset() {
|
||||
setTimeout(() => {
|
||||
store.week = new Map();
|
||||
scheduleWeeklyReset();
|
||||
}, msUntilNextMonday());
|
||||
}
|
||||
|
||||
function scheduleMonthlyReset() {
|
||||
setTimeout(() => {
|
||||
store.month = new Map();
|
||||
scheduleMonthlyReset();
|
||||
}, msUntilNextMonth());
|
||||
}
|
||||
|
||||
function scheduleResets() {
|
||||
scheduleDailyReset();
|
||||
scheduleWeeklyReset();
|
||||
scheduleMonthlyReset();
|
||||
}
|
||||
|
||||
// --- Cooldown store ---
|
||||
const cooldowns = new Map();
|
||||
|
||||
function setCooldown(key) {
|
||||
cooldowns.set(key, Date.now());
|
||||
}
|
||||
|
||||
function isOnCooldown(key, cooldownMinutes) {
|
||||
const last = cooldowns.get(key);
|
||||
if (!last) return false;
|
||||
return (Date.now() - last) < cooldownMinutes * 60 * 1000;
|
||||
}
|
||||
|
||||
// --- Staff last-seen tracker (fallback for missing presence intent) ---
|
||||
const staffLastSeen = new Map();
|
||||
|
||||
function updateStaffLastSeen(staffId) {
|
||||
staffLastSeen.set(staffId, Date.now());
|
||||
}
|
||||
|
||||
function getStaffLastSeen(staffId) {
|
||||
return staffLastSeen.get(staffId) || null;
|
||||
}
|
||||
|
||||
function isStaffRecentlyActive(staffId, withinMinutes = 60) {
|
||||
const last = staffLastSeen.get(staffId);
|
||||
if (!last) return false;
|
||||
return (Date.now() - last) < withinMinutes * 60 * 1000;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
increment,
|
||||
get,
|
||||
reset,
|
||||
getAll,
|
||||
scheduleResets,
|
||||
onDailyReset,
|
||||
setCooldown,
|
||||
isOnCooldown,
|
||||
updateStaffLastSeen,
|
||||
getStaffLastSeen,
|
||||
isStaffRecentlyActive
|
||||
};
|
||||
41
services/pinMessage.js
Normal file
41
services/pinMessage.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Auto-pin utility — pins a message with error handling and optional
|
||||
* system message suppression.
|
||||
*
|
||||
* Discord rate-limits pin operations to approximately 5 per second per
|
||||
* channel. Since pins only happen on ticket creation and escalation (low
|
||||
* frequency), no additional rate limiting is needed. The bot requires
|
||||
* MANAGE_MESSAGES permission to pin — if this is missing, the pin will
|
||||
* fail with code 50013 and be caught by the catch block.
|
||||
*/
|
||||
const { CONFIG } = require('../config');
|
||||
const { logWarn } = require('./debugLog');
|
||||
|
||||
/**
|
||||
* Pin a message in a channel.
|
||||
* @param {import('discord.js').Message} message
|
||||
* @param {import('discord.js').Client} client
|
||||
*/
|
||||
async function pinMessage(message, client) {
|
||||
try {
|
||||
await message.pin();
|
||||
|
||||
if (CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const systemMessages = await message.channel.messages.fetch({ limit: 5 });
|
||||
const pinNotice = systemMessages.find(m =>
|
||||
m.type === 6 && // MessageType.ChannelPinnedMessage
|
||||
Date.now() - m.createdTimestamp < 10000
|
||||
);
|
||||
if (pinNotice) await pinNotice.delete().catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code === 30003) {
|
||||
await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
|
||||
} else {
|
||||
await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { pinMessage };
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { increment } = require('./patternStore');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const StaffNotification = mongoose.model('StaffNotification');
|
||||
@@ -96,6 +97,8 @@ async function notifyAllStaffUnclaimed(client) {
|
||||
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
|
||||
if (chan) {
|
||||
await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e));
|
||||
increment('staff_stale_pings', rec.userId, 'today');
|
||||
increment('staff_stale_pings', rec.userId, 'week');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
services/staffPresence.js
Normal file
48
services/staffPresence.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Staff presence detection — checks Discord presence status for staff members.
|
||||
* Requires GuildPresences intent enabled in Discord Developer Portal.
|
||||
*/
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
/**
|
||||
* Get categorized availability of all configured staff members.
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @returns {{ online: string[], dnd: string[], offline: string[], unknown: string[] }}
|
||||
*/
|
||||
function getStaffAvailability(guild) {
|
||||
const results = {
|
||||
online: [],
|
||||
dnd: [],
|
||||
offline: [],
|
||||
unknown: []
|
||||
};
|
||||
|
||||
for (const staffId of CONFIG.STAFF_IDS) {
|
||||
const member = guild.members.cache.get(staffId);
|
||||
if (!member) { results.offline.push(staffId); continue; }
|
||||
|
||||
const status = member.presence?.status;
|
||||
if (!status) { results.unknown.push(staffId); continue; }
|
||||
|
||||
if (status === 'online' || status === 'idle') results.online.push(staffId);
|
||||
else if (status === 'dnd') results.dnd.push(staffId);
|
||||
else results.offline.push(staffId);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any staff member is currently available.
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @returns {{ available: boolean|null, source: string }}
|
||||
*/
|
||||
function isAnyStaffAvailable(guild) {
|
||||
const { online, dnd, unknown } = getStaffAvailability(guild);
|
||||
if (online.length > 0) return { available: true, source: 'presence' };
|
||||
if (CONFIG.STAFF_DND_COUNTS_AS_AVAILABLE && dnd.length > 0) return { available: true, source: 'presence_dnd' };
|
||||
if (unknown.length === CONFIG.STAFF_IDS.length) return { available: null, source: 'unknown' };
|
||||
return { available: false, source: 'presence' };
|
||||
}
|
||||
|
||||
module.exports = { getStaffAvailability, isAnyStaffAvailable };
|
||||
96
services/staffThread.js
Normal file
96
services/staffThread.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Staff discussion threads — creates a private thread on each ticket channel
|
||||
* for staff-only communication.
|
||||
*
|
||||
* Notes:
|
||||
* - The bot requires CREATE_PRIVATE_THREADS and SEND_MESSAGES_IN_THREADS
|
||||
* permissions on every ticket category.
|
||||
* - Private threads (type: 12) require the server to have Community features
|
||||
* OR the channel to be in a server with Boost level that unlocks private
|
||||
* threads. If thread creation fails with code 50024 or 160004, a warning
|
||||
* is logged via logWarn.
|
||||
* - invitable: false means only staff with MANAGE_THREADS can add additional
|
||||
* members — this is intentional for privacy.
|
||||
* - guild.members.fetch() in addRoleMembersToThread can be slow on large
|
||||
* servers. The 300ms delay between adds avoids the thread member add rate
|
||||
* limit (approximately 5/second).
|
||||
*/
|
||||
const { CONFIG } = require('../config');
|
||||
const { logError, logWarn } = require('./debugLog');
|
||||
|
||||
/**
|
||||
* Create a private staff thread on a ticket channel.
|
||||
* @param {import('discord.js').TextChannel} channel
|
||||
* @param {import('discord.js').Client} client
|
||||
* @returns {Promise<import('discord.js').ThreadChannel|null>}
|
||||
*/
|
||||
async function createStaffThread(channel, client) {
|
||||
if (!CONFIG.STAFF_THREAD_ENABLED) return null;
|
||||
|
||||
try {
|
||||
const threadName = CONFIG.STAFF_THREAD_NAME.slice(0, 100);
|
||||
|
||||
const thread = await channel.threads.create({
|
||||
name: threadName,
|
||||
type: 12, // ChannelType.PrivateThread
|
||||
invitable: false,
|
||||
reason: 'Staff discussion thread for ticket'
|
||||
});
|
||||
|
||||
if (CONFIG.STAFF_THREAD_AUTO_ADD_ROLE && CONFIG.STAFF_THREAD_ROLE_ID) {
|
||||
await addRoleMembersToThread(thread, channel.guild, client);
|
||||
}
|
||||
|
||||
return thread;
|
||||
} catch (err) {
|
||||
// Detect permission / channel type errors
|
||||
if (err.code === 50024 || err.code === 160004) {
|
||||
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
|
||||
}
|
||||
await logError('staffThread:create', err, null, client).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all members of the staff role to the thread.
|
||||
*/
|
||||
async function addRoleMembersToThread(thread, guild, client) {
|
||||
try {
|
||||
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
|
||||
if (!role) return;
|
||||
|
||||
await guild.members.fetch();
|
||||
const members = guild.members.cache.filter(m =>
|
||||
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot
|
||||
);
|
||||
|
||||
for (const [, member] of members) {
|
||||
await thread.members.add(member.id).catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
} catch (err) {
|
||||
await logError('staffThread:addMembers', err, null, client).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single member to the staff thread for a ticket channel.
|
||||
* Call this when a ticket is claimed.
|
||||
*/
|
||||
async function addMemberToStaffThread(channel, memberId) {
|
||||
if (!CONFIG.STAFF_THREAD_ENABLED) return;
|
||||
|
||||
try {
|
||||
const threads = await channel.threads.fetchActive();
|
||||
const staffThread = threads.threads.find(t =>
|
||||
t.name === CONFIG.STAFF_THREAD_NAME && t.type === 12
|
||||
);
|
||||
if (!staffThread) return;
|
||||
await staffThread.members.add(memberId);
|
||||
} catch {
|
||||
// non-critical, ignore
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createStaffThread, addMemberToStaffThread };
|
||||
191
services/surgeChecker.js
Normal file
191
services/surgeChecker.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Surge detection — checks for critical ticket volume/staffing conditions
|
||||
* and pings ALL_STAFF_CHANNEL_ID with role mention.
|
||||
*/
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { setCooldown, isOnCooldown, isStaffRecentlyActive } = require('./patternStore');
|
||||
const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function pingStaff(client, message, embedFields) {
|
||||
const channelId = CONFIG.ALL_STAFF_CHANNEL_ID;
|
||||
if (!channelId || !client) return;
|
||||
try {
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel) return;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Staff Alert')
|
||||
.setDescription(message)
|
||||
.setColor(0xFF4400)
|
||||
.setTimestamp();
|
||||
if (embedFields.length > 0) {
|
||||
embed.addFields(embedFields.map(f => ({
|
||||
name: f.name,
|
||||
value: String(f.value).slice(0, 1024),
|
||||
inline: f.inline ?? true
|
||||
})));
|
||||
}
|
||||
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
|
||||
await channel.send({ content, embeds: [embed] });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function checkTicketSurge(client) {
|
||||
if (isOnCooldown('surge:tickets', CONFIG.SURGE_COOLDOWN_MINUTES)) return;
|
||||
const since = new Date(Date.now() - CONFIG.SURGE_TICKET_WINDOW_MINUTES * 60000);
|
||||
const count = await Ticket.countDocuments({ createdAt: { $gte: since } });
|
||||
if (count >= CONFIG.SURGE_TICKET_COUNT) {
|
||||
setCooldown('surge:tickets');
|
||||
await pingStaff(client,
|
||||
`${count} tickets created in the past ${CONFIG.SURGE_TICKET_WINDOW_MINUTES} minutes.`,
|
||||
[{ name: 'Action needed', value: 'Check open tickets and claim.', inline: false }]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGameSurge(client) {
|
||||
if (isOnCooldown('surge:game', CONFIG.SURGE_COOLDOWN_MINUTES)) return;
|
||||
const since = new Date(Date.now() - CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES * 60000);
|
||||
const gameCounts = await Ticket.aggregate([
|
||||
{ $match: { createdAt: { $gte: since }, game: { $ne: null } } },
|
||||
{ $group: { _id: '$game', count: { $sum: 1 } } },
|
||||
{ $match: { count: { $gte: CONFIG.SURGE_GAME_TICKET_COUNT } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]);
|
||||
if (gameCounts.length > 0) {
|
||||
setCooldown('surge:game');
|
||||
const fields = gameCounts.map(g => ({
|
||||
name: g._id,
|
||||
value: `${g.count} tickets in ${CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES} min`,
|
||||
inline: true
|
||||
}));
|
||||
await pingStaff(client, 'Game ticket surge detected.', fields);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStaleSurge(client) {
|
||||
if (isOnCooldown('surge:stale', CONFIG.SURGE_COOLDOWN_MINUTES)) return;
|
||||
const cutoff = new Date(Date.now() - CONFIG.SURGE_STALE_HOURS * 3600000);
|
||||
const count = await Ticket.countDocuments({
|
||||
status: 'open',
|
||||
lastActivity: { $lte: cutoff }
|
||||
});
|
||||
if (count >= CONFIG.SURGE_STALE_COUNT) {
|
||||
setCooldown('surge:stale');
|
||||
await pingStaff(client,
|
||||
`${count} tickets have had no activity in the past ${CONFIG.SURGE_STALE_HOURS} hours.`,
|
||||
[{ name: 'Action needed', value: 'Review and respond to stale tickets.', inline: false }]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNeedsResponseSurge(client) {
|
||||
if (isOnCooldown('surge:needs_response', CONFIG.SURGE_COOLDOWN_MINUTES)) return;
|
||||
const cutoff = new Date(Date.now() - CONFIG.SURGE_NEEDS_RESPONSE_HOURS * 3600000);
|
||||
const count = await Ticket.countDocuments({
|
||||
status: 'open',
|
||||
lastMessageAuthorIsStaff: false,
|
||||
lastActivity: { $lte: cutoff }
|
||||
});
|
||||
if (count >= CONFIG.SURGE_NEEDS_RESPONSE_COUNT) {
|
||||
setCooldown('surge:needs_response');
|
||||
await pingStaff(client,
|
||||
`${count} tickets are waiting on a staff response for over ${CONFIG.SURGE_NEEDS_RESPONSE_HOURS} hour(s).`,
|
||||
[]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUnclaimedSurge(client) {
|
||||
if (isOnCooldown('surge:unclaimed', CONFIG.SURGE_COOLDOWN_MINUTES)) return;
|
||||
const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000);
|
||||
const count = await Ticket.countDocuments({
|
||||
status: 'open',
|
||||
claimedBy: null,
|
||||
createdAt: { $lte: cutoff }
|
||||
});
|
||||
if (count >= CONFIG.SURGE_UNCLAIMED_COUNT) {
|
||||
setCooldown('surge:unclaimed');
|
||||
await pingStaff(client,
|
||||
`${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`,
|
||||
[]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTier3UnclaimedSurge(client) {
|
||||
if (isOnCooldown('surge:tier3_unclaimed', 30)) return;
|
||||
const cutoff = new Date(Date.now() - CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES * 60000);
|
||||
const tickets = await Ticket.find({
|
||||
status: 'open',
|
||||
escalationTier: 2,
|
||||
claimedBy: null,
|
||||
createdAt: { $lte: cutoff }
|
||||
}).lean();
|
||||
if (tickets.length > 0) {
|
||||
setCooldown('surge:tier3_unclaimed');
|
||||
await pingStaff(client,
|
||||
`${tickets.length} Tier 3 ticket(s) unclaimed for over ${CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES} minutes.`,
|
||||
tickets.map(t => ({ name: t.subject || 'No subject', value: `<#${t.discordThreadId}>`, inline: true }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkZeroStaffSurge(client) {
|
||||
if (isOnCooldown('surge:no_staff', CONFIG.SURGE_NO_STAFF_COOLDOWN_MINUTES)) return;
|
||||
if (!CONFIG.STAFF_IDS.length) return;
|
||||
|
||||
const openCount = await Ticket.countDocuments({ status: 'open' });
|
||||
if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) return;
|
||||
|
||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||
if (!guild) return;
|
||||
|
||||
const { available, source } = isAnyStaffAvailable(guild);
|
||||
|
||||
let noStaff = false;
|
||||
let detailLine = '';
|
||||
const { online, dnd, offline } = getStaffAvailability(guild);
|
||||
|
||||
if (source === 'unknown') {
|
||||
const recentlyActive = CONFIG.STAFF_IDS.filter(id => isStaffRecentlyActive(id, 60));
|
||||
if (recentlyActive.length === 0) {
|
||||
noStaff = true;
|
||||
detailLine = 'No staff active in the last 60 minutes (presence intent unavailable, using message activity fallback).';
|
||||
}
|
||||
} else if (!available) {
|
||||
noStaff = true;
|
||||
const dndNote = dnd.length > 0 ? ` (${dnd.length} on DND)` : '';
|
||||
detailLine = `${offline.length} staff offline/invisible${dndNote}. ${online.length} online.`;
|
||||
}
|
||||
|
||||
if (!noStaff) return;
|
||||
|
||||
setCooldown('surge:no_staff');
|
||||
|
||||
const fields = [
|
||||
{ name: 'Open tickets', value: String(openCount), inline: true },
|
||||
{ name: 'Detection method', value: source === 'unknown' ? 'Message activity' : 'Presence', inline: true },
|
||||
{ name: source === 'unknown' ? 'Note' : 'Staff status', value: detailLine, inline: false }
|
||||
];
|
||||
|
||||
await pingStaff(client,
|
||||
`${openCount} open ticket(s) with no staff available to respond.`,
|
||||
fields
|
||||
);
|
||||
}
|
||||
|
||||
async function runSurgeChecks(client) {
|
||||
try { await checkTicketSurge(client); } catch (e) { console.error('checkTicketSurge:', e); }
|
||||
try { await checkGameSurge(client); } catch (e) { console.error('checkGameSurge:', e); }
|
||||
try { await checkStaleSurge(client); } catch (e) { console.error('checkStaleSurge:', e); }
|
||||
try { await checkNeedsResponseSurge(client); } catch (e) { console.error('checkNeedsResponseSurge:', e); }
|
||||
try { await checkUnclaimedSurge(client); } catch (e) { console.error('checkUnclaimedSurge:', e); }
|
||||
try { await checkTier3UnclaimedSurge(client); } catch (e) { console.error('checkTier3UnclaimedSurge:', e); }
|
||||
try { await checkZeroStaffSurge(client); } catch (e) { console.error('checkZeroStaffSurge:', e); }
|
||||
}
|
||||
|
||||
module.exports = { runSurgeChecks };
|
||||
@@ -3,9 +3,10 @@
|
||||
* reminders, auto-unclaim, channel creation.
|
||||
*/
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { mongoose, withRetry } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { getPriorityEmoji } = require('../utils');
|
||||
const { logAutomation } = require('../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const TicketCounter = mongoose.model('TicketCounter');
|
||||
@@ -472,12 +473,14 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
|
||||
|
||||
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const staleTickets = await Ticket.find({
|
||||
const staleTickets = await withRetry(() => Ticket.find({
|
||||
status: 'open',
|
||||
lastActivity: { $lt: cutoffTime, $ne: null }
|
||||
}).lean();
|
||||
}).lean());
|
||||
|
||||
let checked = 0, closed = 0;
|
||||
for (const ticket of staleTickets) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
@@ -486,32 +489,36 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
if (channel) {
|
||||
await channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
||||
|
||||
await Ticket.updateOne(
|
||||
await withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
));
|
||||
|
||||
await sendTicketClosedEmail(ticket, 'Auto-Close System');
|
||||
|
||||
setTimeout(() => channel.delete().catch(() => {}), 5000);
|
||||
closed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function checkReminders(client) {
|
||||
if (!CONFIG.REMINDER_ENABLED) return;
|
||||
|
||||
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const ticketsNeedingReminder = await Ticket.find({
|
||||
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
|
||||
status: 'open',
|
||||
lastActivity: { $lt: reminderTime, $ne: null },
|
||||
reminderSent: false
|
||||
}).lean();
|
||||
}).lean());
|
||||
|
||||
let checked = 0, reminded = 0;
|
||||
for (const ticket of ticketsNeedingReminder) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
@@ -526,49 +533,55 @@ async function checkReminders(client) {
|
||||
.replace(/\{ping\}/g, ping);
|
||||
await channel.send(message);
|
||||
|
||||
await Ticket.updateOne(
|
||||
await withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { reminderSent: true } }
|
||||
);
|
||||
));
|
||||
reminded++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function checkAutoUnclaim(client) {
|
||||
if (!CONFIG.AUTO_UNCLAIM_ENABLED) return;
|
||||
|
||||
const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const staleClaimedTickets = await Ticket.find({
|
||||
const staleClaimedTickets = await withRetry(() => Ticket.find({
|
||||
status: 'open',
|
||||
claimedBy: { $ne: null },
|
||||
lastActivity: { $lt: unclaimTime, $ne: null }
|
||||
}).lean();
|
||||
}).lean());
|
||||
|
||||
let checked = 0, unclaimed = 0;
|
||||
for (const ticket of staleClaimedTickets) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
await Ticket.updateOne(
|
||||
await withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: null } }
|
||||
);
|
||||
));
|
||||
|
||||
await channel.send(
|
||||
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
|
||||
);
|
||||
|
||||
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
|
||||
unclaimed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
5
settings-site/.env.example
Normal file
5
settings-site/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
SETTINGS_PORT=12752
|
||||
SETTINGS_ADMIN_PASSWORD=
|
||||
SETTINGS_DOMAIN=tickets.indifferentketchup.com
|
||||
INTERNAL_API_PORT=12753
|
||||
INTERNAL_API_SECRET=
|
||||
13
settings-site/docker-compose.yml
Normal file
13
settings-site/docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
broccolini-settings:
|
||||
build: .
|
||||
container_name: broccolini-settings
|
||||
restart: unless-stopped
|
||||
env_file: ../.env
|
||||
ports:
|
||||
- "100.114.205.53:12752:12752"
|
||||
network_mode: host
|
||||
# network_mode: host is needed so the settings site can reach
|
||||
# 127.0.0.1:12753 (the bot's internal API). If running both as
|
||||
# Docker containers on the same host, use a shared Docker network
|
||||
# instead and reference the bot container by name.
|
||||
15
settings-site/package.json
Normal file
15
settings-site/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "broccolini-settings",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"express-session": "^1.17.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
}
|
||||
134
settings-site/public/css/main.css
Normal file
134
settings-site/public/css/main.css
Normal file
@@ -0,0 +1,134 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--card: #1e2235;
|
||||
--border: #2a2d3e;
|
||||
--accent: #5865f2;
|
||||
--accent-hover: #4752c4;
|
||||
--success: #57f287;
|
||||
--warning: #fee75c;
|
||||
--danger: #ed4245;
|
||||
--text: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--sidebar-width: 260px;
|
||||
}
|
||||
|
||||
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); display: flex; min-height: 100vh; }
|
||||
|
||||
/* Top bar */
|
||||
.topbar { position: fixed; top: 0; left: var(--sidebar-width); right: 0; height: 56px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 24px; z-index: 100; }
|
||||
.topbar h1 { font-size: 16px; font-weight: 600; }
|
||||
.topbar .status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-muted); }
|
||||
.topbar .status .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.topbar .status .dot.online { background: var(--success); }
|
||||
.topbar .status .dot.offline { background: var(--danger); }
|
||||
.topbar .actions { display: flex; gap: 12px; align-items: center; }
|
||||
.topbar .actions button { background: none; border: 1px solid var(--border); color: var(--text-muted); padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: all 200ms; }
|
||||
.topbar .actions button:hover { border-color: var(--accent); color: var(--text); }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar { position: fixed; top: 0; left: 0; width: var(--sidebar-width); height: 100vh; background: var(--surface); border-right: 1px solid var(--border); padding: 16px 0; overflow-y: auto; z-index: 101; }
|
||||
.sidebar .logo { padding: 12px 20px 24px; font-size: 18px; font-weight: 700; }
|
||||
.sidebar a { display: flex; align-items: center; gap: 10px; padding: 10px 20px; color: var(--text-muted); text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; transition: all 200ms; }
|
||||
.sidebar a:hover { color: var(--text); background: rgba(88,101,242,0.08); }
|
||||
.sidebar a.active { color: var(--accent); border-left-color: var(--accent); background: rgba(88,101,242,0.1); }
|
||||
|
||||
/* Main */
|
||||
.main { margin-left: var(--sidebar-width); margin-top: 56px; padding: 24px; flex: 1; padding-bottom: 100px; }
|
||||
|
||||
/* Section cards */
|
||||
.section { margin-bottom: 24px; }
|
||||
.section-header { background: var(--card); border: 1px solid var(--border); border-radius: 12px 12px 0 0; padding: 20px 24px; cursor: pointer; display: flex; align-items: center; gap: 12px; transition: background 200ms; }
|
||||
.section-header:hover { background: #232740; }
|
||||
.section-header h2 { font-size: 15px; font-weight: 600; flex: 1; }
|
||||
.section-header p { font-size: 12px; color: var(--text-muted); }
|
||||
.section-header .chevron { transition: transform 200ms; font-size: 18px; color: var(--text-muted); }
|
||||
.section.collapsed .section-header { border-radius: 12px; }
|
||||
.section.collapsed .section-body { display: none; }
|
||||
.section.collapsed .chevron { transform: rotate(-90deg); }
|
||||
.section-body { background: var(--card); border: 1px solid var(--border); border-top: none; border-radius: 0 0 12px 12px; padding: 24px; }
|
||||
|
||||
/* Field grid */
|
||||
.field-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field.full-width { grid-column: 1 / -1; }
|
||||
.field label { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.field input, .field select, .field textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text); font-size: 14px; font-family: inherit; outline: none; transition: border-color 200ms; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(88,101,242,0.2); }
|
||||
.field textarea { min-height: 80px; resize: vertical; }
|
||||
.field input.changed, .field select.changed, .field textarea.changed { border-color: var(--warning); }
|
||||
.field .hint { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle-wrap { display: flex; align-items: center; gap: 12px; }
|
||||
.toggle { position: relative; width: 44px; height: 24px; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 12px; cursor: pointer; transition: background 200ms; }
|
||||
.toggle .slider::before { content: ''; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; background: #fff; border-radius: 50%; transition: transform 200ms; }
|
||||
.toggle input:checked + .slider { background: var(--accent); }
|
||||
.toggle input:checked + .slider::before { transform: translateX(20px); }
|
||||
|
||||
/* Color picker */
|
||||
.color-field { display: flex; align-items: center; gap: 10px; }
|
||||
.color-field input[type="color"] { width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; background: none; padding: 2px; }
|
||||
|
||||
/* Smart select */
|
||||
.smart-select { position: relative; }
|
||||
.smart-select-display { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; min-height: 42px; transition: border-color 200ms; }
|
||||
.smart-select-display:hover { border-color: var(--accent); }
|
||||
.smart-select-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 10px; margin-top: 4px; z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.5); max-height: 300px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.smart-select-dropdown.hidden { display: none; }
|
||||
.ss-search { background: var(--bg); border: none; border-bottom: 1px solid var(--border); padding: 10px 14px; color: var(--text); font-size: 13px; outline: none; }
|
||||
.ss-list { overflow-y: auto; max-height: 250px; padding: 4px; }
|
||||
.ss-option { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 13px; transition: background 200ms; }
|
||||
.ss-option:hover { background: rgba(88,101,242,0.15); }
|
||||
.ss-option.selected { background: rgba(88,101,242,0.2); }
|
||||
.ss-option.ss-clear { color: var(--text-muted); font-style: italic; }
|
||||
.ss-label { flex: 1; }
|
||||
.ss-sub { font-size: 11px; color: var(--text-muted); }
|
||||
.ss-id { font-size: 11px; color: var(--text-muted); font-family: monospace; }
|
||||
.ss-placeholder { color: var(--text-muted); }
|
||||
.ss-avatar { width: 20px; height: 20px; border-radius: 50%; }
|
||||
.ss-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* Save bar */
|
||||
.save-bar { position: fixed; bottom: 0; left: var(--sidebar-width); right: 0; background: var(--surface); border-top: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; transform: translateY(100%); transition: transform 300ms ease; z-index: 100; }
|
||||
.save-bar.visible { transform: translateY(0); }
|
||||
.save-bar span { font-size: 13px; color: var(--warning); font-weight: 500; }
|
||||
.save-actions { display: flex; gap: 8px; }
|
||||
.save-actions button { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: all 200ms; }
|
||||
.save-actions button:first-child { background: var(--accent); color: #fff; }
|
||||
.save-actions button:first-child:hover { background: var(--accent-hover); }
|
||||
.save-actions button.secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
|
||||
.save-actions button.secondary:hover { border-color: var(--accent); }
|
||||
.save-actions button.danger { background: var(--danger); color: #fff; }
|
||||
.save-actions button.danger:hover { background: #c9363a; }
|
||||
|
||||
/* Toast */
|
||||
#toast-container { position: fixed; top: 72px; right: 24px; z-index: 300; display: flex; flex-direction: column; gap: 8px; }
|
||||
.toast { padding: 12px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; animation: toast-in 300ms ease; }
|
||||
.toast-success { background: rgba(87,242,135,0.15); color: var(--success); border: 1px solid rgba(87,242,135,0.3); }
|
||||
.toast-warning { background: rgba(254,231,92,0.15); color: var(--warning); border: 1px solid rgba(254,231,92,0.3); }
|
||||
.toast-error { background: rgba(237,66,69,0.15); color: var(--danger); border: 1px solid rgba(237,66,69,0.3); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
|
||||
|
||||
/* Modal */
|
||||
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 400; }
|
||||
.modal.hidden { display: none; }
|
||||
.modal-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; min-width: 340px; }
|
||||
.modal-card h3 { margin-bottom: 16px; font-size: 16px; }
|
||||
.modal-card input { width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 14px; margin-bottom: 16px; }
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal-actions button { padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; }
|
||||
.modal-actions button:first-child { background: var(--accent); color: #fff; }
|
||||
.modal-actions button.secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
|
||||
|
||||
/* Loading */
|
||||
.loading { position: fixed; inset: 0; background: var(--bg); display: flex; align-items: center; justify-content: center; z-index: 500; }
|
||||
.loading.hidden { display: none; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
311
settings-site/public/index.html
Normal file
311
settings-site/public/index.html
Normal file
@@ -0,0 +1,311 @@
|
||||
<!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 -->
|
||||
<nav class="sidebar">
|
||||
<div class="logo">Broccolini Settings</div>
|
||||
<a href="#s-core" class="active">Core</a>
|
||||
<a href="#s-channels">Channels</a>
|
||||
<a href="#s-categories">Categories</a>
|
||||
<a href="#s-gmail">Gmail</a>
|
||||
<a href="#s-behavior">Ticket Behavior</a>
|
||||
<a href="#s-threads">Staff Threads</a>
|
||||
<a href="#s-pins">Pin Messages</a>
|
||||
<a href="#s-surge">Surge Alerts</a>
|
||||
<a href="#s-patterns">Pattern Detection</a>
|
||||
<a href="#s-logging">Logging</a>
|
||||
<a href="#s-automation">Automation</a>
|
||||
<a href="#s-appearance">Appearance</a>
|
||||
<a href="#s-staff">Staff</a>
|
||||
<a href="#s-advanced">Advanced</a>
|
||||
</nav>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div class="topbar">
|
||||
<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">
|
||||
<form action="/logout" method="POST" style="display:inline"><button type="submit">Logout</button></form>
|
||||
</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 Escalation Category</label><input type="text" data-key="EMAIL_ESCALATED_CATEGORY_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Discord Escalation Category</label><input type="text" data-key="DISCORD_ESCALATED_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. Surge Alerts -->
|
||||
<div class="section" id="s-surge">
|
||||
<div class="section-header"><h2>Surge Alerts</h2><p>Ticket volume and staffing alerts</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><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 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></div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Pattern Detection -->
|
||||
<div class="section" id="s-patterns">
|
||||
<div class="section-header"><h2>Pattern Detection</h2><p>Thresholds for automated pattern alerts</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><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></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 style="color:var(--text-muted);font-size:13px;">Log channels are configured in the <a href="#s-channels" style="color:var(--accent);">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>Internal API Secret</label><input type="password" data-key="INTERNAL_API_SECRET"></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 onclick="saveConfig('apply')">Save & Apply</button>
|
||||
<button onclick="saveConfig('pending')" class="secondary">Save (pending restart)</button>
|
||||
<button onclick="saveConfig('restart')" class="danger">Save & Restart Now</button>
|
||||
<button onclick="openScheduleModal()" class="secondary">Schedule restart...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule modal -->
|
||||
<div id="schedule-modal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<h3>Schedule restart</h3>
|
||||
<input type="datetime-local" id="schedule-datetime">
|
||||
<div class="modal-actions">
|
||||
<button onclick="confirmScheduledRestart()">Schedule</button>
|
||||
<button onclick="document.getElementById('schedule-modal').classList.add('hidden')" class="secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/discord.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
175
settings-site/public/js/app.js
Normal file
175
settings-site/public/js/app.js
Normal file
@@ -0,0 +1,175 @@
|
||||
let savedConfig = {};
|
||||
let pendingChanges = {};
|
||||
|
||||
async function init() {
|
||||
document.getElementById('loading').classList.remove('hidden');
|
||||
try {
|
||||
const [config] = await Promise.all([
|
||||
fetch('/api/config').then(r => r.json()),
|
||||
DiscordFields.fetchGuildData()
|
||||
]);
|
||||
savedConfig = config;
|
||||
document.getElementById('bot-status-dot').className = 'dot online';
|
||||
document.getElementById('bot-status-text').textContent = 'Connected';
|
||||
populateFields(config);
|
||||
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();
|
||||
setupSaveBar();
|
||||
}
|
||||
|
||||
function populateFields(config) {
|
||||
document.querySelectorAll('[data-key]').forEach(el => {
|
||||
const key = el.dataset.key;
|
||||
const value = config[key] || '';
|
||||
if (el.type === 'checkbox') {
|
||||
el.checked = value === 'true' || value === true;
|
||||
} else if (el.type === 'color') {
|
||||
// Convert 0xRRGGBB to #RRGGBB
|
||||
const num = parseInt(value) || 0;
|
||||
el.value = '#' + num.toString(16).padStart(6, '0');
|
||||
} else {
|
||||
el.value = value;
|
||||
}
|
||||
el.addEventListener('change', () => handleFieldChange(el, key));
|
||||
el.addEventListener('input', () => {
|
||||
if (el.type === 'text' || el.type === 'number' || el.type === 'password' || el.tagName === 'TEXTAREA') {
|
||||
handleFieldChange(el, key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleFieldChange(el, key) {
|
||||
let value;
|
||||
if (el.type === 'checkbox') {
|
||||
value = el.checked ? 'true' : 'false';
|
||||
} else if (el.type === 'color') {
|
||||
value = '0x' + el.value.slice(1).toUpperCase();
|
||||
} else {
|
||||
value = el.value;
|
||||
}
|
||||
markChanged(key, value);
|
||||
el.classList.toggle('changed', key in pendingChanges);
|
||||
}
|
||||
|
||||
function initSmartSelects(config) {
|
||||
document.querySelectorAll('[data-smart]').forEach(el => {
|
||||
const key = el.dataset.key;
|
||||
const type = el.dataset.smart;
|
||||
const value = config[key] || '';
|
||||
if (type === 'channel') DiscordFields.renderChannelSelect(el, value);
|
||||
else if (type === 'category') DiscordFields.renderCategorySelect(el, value);
|
||||
else if (type === 'role') DiscordFields.renderRoleSelect(el, value);
|
||||
else if (type === 'member') DiscordFields.renderMemberSelect(el, value);
|
||||
else if (type === 'multi-member') DiscordFields.renderMultiMemberSelect(el, value);
|
||||
});
|
||||
}
|
||||
|
||||
function setupSectionToggles() {
|
||||
document.querySelectorAll('.section-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
header.closest('.section').classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
// Sidebar navigation
|
||||
document.querySelectorAll('.sidebar a').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = document.getElementById(link.getAttribute('href').slice(1));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.classList.remove('collapsed');
|
||||
}
|
||||
document.querySelectorAll('.sidebar a').forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function markChanged(key, value) {
|
||||
if (String(value) === String(savedConfig[key] || '')) {
|
||||
delete pendingChanges[key];
|
||||
} else {
|
||||
pendingChanges[key] = value;
|
||||
}
|
||||
updateSaveBar();
|
||||
}
|
||||
|
||||
function setupSaveBar() {
|
||||
updateSaveBar();
|
||||
}
|
||||
|
||||
function updateSaveBar() {
|
||||
const bar = document.getElementById('save-bar');
|
||||
const count = Object.keys(pendingChanges).length;
|
||||
bar.classList.toggle('visible', count > 0);
|
||||
document.getElementById('change-count').textContent =
|
||||
`${count} unsaved change${count !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
async function saveConfig(mode) {
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pendingChanges)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.applied) {
|
||||
for (const key of data.applied) savedConfig[key] = pendingChanges[key];
|
||||
pendingChanges = {};
|
||||
updateSaveBar();
|
||||
document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed'));
|
||||
showToast(`${data.applied.length} settings saved.`, 'success');
|
||||
}
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
showToast(`Errors: ${data.errors.join(', ')}`, 'error');
|
||||
}
|
||||
if (mode === 'restart') {
|
||||
await fetch('/api/restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'immediate' })
|
||||
});
|
||||
showToast('Restart initiated.', 'warning');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Failed to save. Bot may be unreachable.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openScheduleModal() {
|
||||
const modal = document.getElementById('schedule-modal');
|
||||
modal.classList.remove('hidden');
|
||||
const dt = document.getElementById('schedule-datetime');
|
||||
const min = new Date(Date.now() + 60000).toISOString().slice(0, 16);
|
||||
dt.min = min;
|
||||
dt.value = min;
|
||||
}
|
||||
|
||||
async function confirmScheduledRestart() {
|
||||
const dt = document.getElementById('schedule-datetime').value;
|
||||
if (!dt) return;
|
||||
await fetch('/api/restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
|
||||
});
|
||||
document.getElementById('schedule-modal').classList.add('hidden');
|
||||
showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.getElementById('toast-container').appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3500);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
195
settings-site/public/js/discord.js
Normal file
195
settings-site/public/js/discord.js
Normal file
@@ -0,0 +1,195 @@
|
||||
// Discord guild data cache
|
||||
let guildData = null;
|
||||
let guildDataPromise = null;
|
||||
|
||||
async function fetchGuildData() {
|
||||
if (guildData) return guildData;
|
||||
if (guildDataPromise) return guildDataPromise;
|
||||
guildDataPromise = fetch('/api/discord/guild')
|
||||
.then(r => r.json())
|
||||
.then(data => { guildData = data; return data; })
|
||||
.catch(() => ({ channels: [], roles: [], members: [], categories: [] }));
|
||||
return guildDataPromise;
|
||||
}
|
||||
|
||||
async function renderChannelSelect(el, currentValue, filter) {
|
||||
const data = await fetchGuildData();
|
||||
const channels = filter ? data.channels.filter(filter) : data.channels;
|
||||
renderSmartSelect(el, channels.map(c => ({
|
||||
id: c.id,
|
||||
label: `#${c.name}`,
|
||||
sub: c.parentId ? (data.categories.find(cat => cat.id === c.parentId)?.name || null) : null
|
||||
})), currentValue);
|
||||
}
|
||||
|
||||
async function renderCategorySelect(el, currentValue) {
|
||||
const data = await fetchGuildData();
|
||||
renderSmartSelect(el, data.categories.map(c => ({ id: c.id, label: c.name })), currentValue);
|
||||
}
|
||||
|
||||
async function renderRoleSelect(el, currentValue) {
|
||||
const data = await fetchGuildData();
|
||||
renderSmartSelect(el, data.roles.map(r => ({ id: r.id, label: `@${r.name}`, color: r.color })), currentValue);
|
||||
}
|
||||
|
||||
async function renderMemberSelect(el, currentValue) {
|
||||
const data = await fetchGuildData();
|
||||
renderSmartSelect(el, data.members.map(m => ({
|
||||
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
||||
})), currentValue);
|
||||
}
|
||||
|
||||
async function renderMultiMemberSelect(el, currentValue) {
|
||||
const data = await fetchGuildData();
|
||||
const currentIds = (currentValue || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
renderMultiSelect(el, data.members.map(m => ({
|
||||
id: m.id, label: m.displayName, sub: `@${m.username}`, avatar: m.avatar
|
||||
})), currentIds);
|
||||
}
|
||||
|
||||
function renderSmartSelect(inputEl, options, currentValue) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'smart-select';
|
||||
|
||||
const current = options.find(o => o.id === currentValue);
|
||||
const display = document.createElement('div');
|
||||
display.className = 'smart-select-display';
|
||||
display.innerHTML = current
|
||||
? `<span class="ss-label">${current.label}</span><span class="ss-id">${current.id}</span>`
|
||||
: `<span class="ss-placeholder">Not set</span>`;
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'smart-select-dropdown hidden';
|
||||
|
||||
const search = document.createElement('input');
|
||||
search.type = 'text';
|
||||
search.placeholder = 'Search...';
|
||||
search.className = 'ss-search';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ss-list';
|
||||
|
||||
const clearOpt = document.createElement('div');
|
||||
clearOpt.className = 'ss-option ss-clear';
|
||||
clearOpt.textContent = 'Clear (not set)';
|
||||
clearOpt.addEventListener('click', () => {
|
||||
inputEl.value = '';
|
||||
display.innerHTML = `<span class="ss-placeholder">Not set</span>`;
|
||||
dropdown.classList.add('hidden');
|
||||
inputEl.dispatchEvent(new Event('change'));
|
||||
});
|
||||
list.appendChild(clearOpt);
|
||||
|
||||
function renderOptions(filter = '') {
|
||||
while (list.children.length > 1) list.removeChild(list.lastChild);
|
||||
const filtered = filter
|
||||
? options.filter(o => o.label.toLowerCase().includes(filter.toLowerCase()) || (o.sub || '').toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter))
|
||||
: options;
|
||||
for (const opt of filtered.slice(0, 50)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ss-option' + (opt.id === inputEl.value ? ' selected' : '');
|
||||
let inner = '';
|
||||
if (opt.avatar) inner += `<img class="ss-avatar" src="${opt.avatar}" alt="">`;
|
||||
if (opt.color && opt.color !== '#000000') inner += `<span class="ss-dot" style="background:${opt.color}"></span>`;
|
||||
inner += `<span class="ss-label">${opt.label}</span>`;
|
||||
if (opt.sub) inner += `<span class="ss-sub">${opt.sub}</span>`;
|
||||
item.innerHTML = inner;
|
||||
item.addEventListener('click', () => {
|
||||
inputEl.value = opt.id;
|
||||
display.innerHTML = `<span class="ss-label">${opt.label}</span><span class="ss-id">${opt.id}</span>`;
|
||||
dropdown.classList.add('hidden');
|
||||
inputEl.dispatchEvent(new Event('change'));
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
renderOptions();
|
||||
search.addEventListener('input', () => renderOptions(search.value));
|
||||
display.addEventListener('click', () => {
|
||||
dropdown.classList.toggle('hidden');
|
||||
if (!dropdown.classList.contains('hidden')) search.focus();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!wrapper.contains(e.target)) dropdown.classList.add('hidden');
|
||||
});
|
||||
|
||||
dropdown.appendChild(search);
|
||||
dropdown.appendChild(list);
|
||||
wrapper.appendChild(display);
|
||||
wrapper.appendChild(dropdown);
|
||||
inputEl.style.display = 'none';
|
||||
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
||||
}
|
||||
|
||||
function renderMultiSelect(inputEl, options, currentIds) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'smart-select';
|
||||
const selected = new Set(currentIds);
|
||||
|
||||
function updateInput() {
|
||||
inputEl.value = [...selected].join(',');
|
||||
inputEl.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
chipsEl.innerHTML = '';
|
||||
for (const id of selected) {
|
||||
const opt = options.find(o => o.id === id);
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'ss-option selected';
|
||||
chip.style.cssText = 'display:inline-flex;padding:4px 8px;margin:2px;border-radius:12px;font-size:12px;cursor:pointer;';
|
||||
chip.textContent = opt ? opt.label : id;
|
||||
chip.title = 'Click to remove';
|
||||
chip.addEventListener('click', () => { selected.delete(id); renderChips(); updateInput(); });
|
||||
chipsEl.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
const chipsEl = document.createElement('div');
|
||||
chipsEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;';
|
||||
renderChips();
|
||||
|
||||
const addBtn = document.createElement('div');
|
||||
addBtn.className = 'smart-select-display';
|
||||
addBtn.innerHTML = '<span class="ss-placeholder">+ Add</span>';
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'smart-select-dropdown hidden';
|
||||
const search = document.createElement('input');
|
||||
search.type = 'text'; search.placeholder = 'Search...'; search.className = 'ss-search';
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ss-list';
|
||||
|
||||
function renderOptions(filter = '') {
|
||||
list.innerHTML = '';
|
||||
const filtered = filter
|
||||
? options.filter(o => !selected.has(o.id) && (o.label.toLowerCase().includes(filter.toLowerCase()) || o.id.includes(filter)))
|
||||
: options.filter(o => !selected.has(o.id));
|
||||
for (const opt of filtered.slice(0, 50)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ss-option';
|
||||
let inner = '';
|
||||
if (opt.avatar) inner += `<img class="ss-avatar" src="${opt.avatar}" alt="">`;
|
||||
inner += `<span class="ss-label">${opt.label}</span>`;
|
||||
if (opt.sub) inner += `<span class="ss-sub">${opt.sub}</span>`;
|
||||
item.innerHTML = inner;
|
||||
item.addEventListener('click', () => { selected.add(opt.id); renderChips(); renderOptions(search.value); updateInput(); });
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
renderOptions();
|
||||
search.addEventListener('input', () => renderOptions(search.value));
|
||||
addBtn.addEventListener('click', () => { dropdown.classList.toggle('hidden'); if (!dropdown.classList.contains('hidden')) search.focus(); });
|
||||
document.addEventListener('click', (e) => { if (!wrapper.contains(e.target)) dropdown.classList.add('hidden'); });
|
||||
|
||||
dropdown.appendChild(search);
|
||||
dropdown.appendChild(list);
|
||||
wrapper.appendChild(chipsEl);
|
||||
wrapper.appendChild(addBtn);
|
||||
wrapper.appendChild(dropdown);
|
||||
inputEl.style.display = 'none';
|
||||
inputEl.parentNode.insertBefore(wrapper, inputEl.nextSibling);
|
||||
}
|
||||
|
||||
window.DiscordFields = { fetchGuildData, renderChannelSelect, renderCategorySelect, renderRoleSelect, renderMemberSelect, renderMultiMemberSelect };
|
||||
49
settings-site/public/login.html
Normal file
49
settings-site/public/login.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Broccolini Settings - Login</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', sans-serif; background: #0f1117; color: #e0e0e0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-card { background: #1e2235; border: 1px solid #2a2d3e; border-radius: 16px; padding: 48px 40px; width: 380px; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
|
||||
.login-card h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
|
||||
.login-card p { font-size: 14px; color: #888; margin-bottom: 32px; }
|
||||
.login-card input { width: 100%; padding: 12px 16px; background: #0f1117; border: 1px solid #2a2d3e; border-radius: 8px; color: #e0e0e0; font-size: 14px; margin-bottom: 16px; outline: none; transition: border-color 200ms; }
|
||||
.login-card input:focus { border-color: #5865f2; }
|
||||
.login-card button { width: 100%; padding: 12px; background: #5865f2; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 200ms; }
|
||||
.login-card button:hover { background: #4752c4; }
|
||||
.error { color: #ed4245; font-size: 13px; margin-top: 8px; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>Broccolini Settings</h1>
|
||||
<p>Enter the admin password to continue.</p>
|
||||
<form id="login-form">
|
||||
<input type="password" name="password" id="password" placeholder="Password" autofocus required>
|
||||
<button type="submit">Sign in</button>
|
||||
<div class="error" id="error">Invalid password</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
document.getElementById('error').style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
98
settings-site/server.js
Normal file
98
settings-site/server.js
Normal file
@@ -0,0 +1,98 @@
|
||||
require('dotenv').config({ path: process.env.ENV_FILE || '../.env' });
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const path = require('path');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.SETTINGS_PORT) || 12752;
|
||||
const INTERNAL_URL = `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`;
|
||||
const SECRET = process.env.INTERNAL_API_SECRET;
|
||||
const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(session({
|
||||
secret: SECRET || 'fallback-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: false, // set true if behind HTTPS proxy
|
||||
maxAge: 8 * 60 * 60 * 1000 // 8 hours
|
||||
}
|
||||
}));
|
||||
|
||||
// Auth middleware
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session?.authed) return next();
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
// Internal API proxy helper
|
||||
async function callBot(method, apiPath, body) {
|
||||
const res = await fetch(`${INTERNAL_URL}${apiPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-internal-secret': SECRET
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Routes
|
||||
app.get('/login', (req, res) => {
|
||||
if (req.session?.authed) return res.redirect('/');
|
||||
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
||||
});
|
||||
|
||||
app.post('/login', (req, res) => {
|
||||
if (!ADMIN_PASSWORD) return res.status(503).json({ error: 'SETTINGS_ADMIN_PASSWORD not set' });
|
||||
if (req.body.password === ADMIN_PASSWORD) {
|
||||
req.session.authed = true;
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
res.status(401).json({ error: 'Invalid password' });
|
||||
});
|
||||
|
||||
app.post('/logout', (req, res) => {
|
||||
req.session.destroy();
|
||||
res.redirect('/login');
|
||||
});
|
||||
|
||||
app.get('/', requireAuth, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Proxy to bot internal API
|
||||
app.get('/api/config', requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('GET', '/config')); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.post('/api/config', requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('POST', '/config', req.body)); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.get('/api/discord/guild', requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('GET', '/discord/guild')); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.post('/api/restart', requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('POST', '/restart', req.body)); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.get('/api/restart/status', requireAuth, async (req, res) => {
|
||||
try { res.json(await callBot('GET', '/restart/status')); }
|
||||
catch (e) { res.status(502).json({ error: 'Bot unreachable' }); }
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[settings] running on port ${PORT}`);
|
||||
});
|
||||
99
utils.js
99
utils.js
@@ -275,7 +275,106 @@ function replaceVariables(template, context = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Sanitize user input for safe embedding in Discord code blocks. */
|
||||
function sanitizeEmbedText(str) {
|
||||
if (str == null) return '';
|
||||
return String(str).replace(/```/g, "'''").trim();
|
||||
}
|
||||
|
||||
// --- EMBED TRUNCATION ---
|
||||
|
||||
/** Truncate a string for use as an embed field value (max 1024). */
|
||||
function truncateEmbedField(str, max = 1024) {
|
||||
if (str == null) return '';
|
||||
const s = String(str);
|
||||
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
||||
}
|
||||
|
||||
/** Truncate a string for use as an embed description (max 4096). */
|
||||
function truncateEmbedDescription(str, max = 4096) {
|
||||
if (str == null) return '';
|
||||
const s = String(str);
|
||||
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the 6 000 char total embed limit across an array of EmbedBuilder
|
||||
* instances. Mutates in place: trims the largest description first, then
|
||||
* largest field values, until the total is under 6 000 chars.
|
||||
* Returns the same array for chaining.
|
||||
*/
|
||||
function enforceEmbedLimit(embeds) {
|
||||
const charCount = (e) => {
|
||||
const d = e.data || {};
|
||||
let total = 0;
|
||||
if (d.title) total += d.title.length;
|
||||
if (d.description) total += d.description.length;
|
||||
if (d.footer?.text) total += d.footer.text.length;
|
||||
if (d.author?.name) total += d.author.name.length;
|
||||
if (d.fields) {
|
||||
for (const f of d.fields) {
|
||||
if (f.name) total += f.name.length;
|
||||
if (f.value) total += f.value.length;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const LIMIT = 6000;
|
||||
|
||||
const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0);
|
||||
|
||||
// Trim largest descriptions first
|
||||
while (totalChars() > LIMIT) {
|
||||
let largestIdx = -1;
|
||||
let largestLen = 0;
|
||||
for (let i = 0; i < embeds.length; i++) {
|
||||
const desc = embeds[i].data?.description;
|
||||
if (desc && desc.length > largestLen) {
|
||||
largestLen = desc.length;
|
||||
largestIdx = i;
|
||||
}
|
||||
}
|
||||
if (largestIdx === -1 || largestLen <= 4) break;
|
||||
const excess = totalChars() - LIMIT;
|
||||
const newLen = Math.max(1, largestLen - excess - 3);
|
||||
embeds[largestIdx].setDescription(
|
||||
embeds[largestIdx].data.description.slice(0, newLen) + '...'
|
||||
);
|
||||
if (totalChars() <= LIMIT) break;
|
||||
// If still over, loop will pick next largest
|
||||
}
|
||||
|
||||
// Trim largest field values
|
||||
while (totalChars() > LIMIT) {
|
||||
let targetEmbed = null;
|
||||
let targetFieldIdx = -1;
|
||||
let targetLen = 0;
|
||||
for (const e of embeds) {
|
||||
const fields = e.data?.fields || [];
|
||||
for (let fi = 0; fi < fields.length; fi++) {
|
||||
if (fields[fi].value && fields[fi].value.length > targetLen) {
|
||||
targetLen = fields[fi].value.length;
|
||||
targetEmbed = e;
|
||||
targetFieldIdx = fi;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!targetEmbed || targetLen <= 4) break;
|
||||
const excess = totalChars() - LIMIT;
|
||||
const newLen = Math.max(1, targetLen - excess - 3);
|
||||
targetEmbed.data.fields[targetFieldIdx].value =
|
||||
targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...';
|
||||
}
|
||||
|
||||
return embeds;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeEmbedText,
|
||||
truncateEmbedField,
|
||||
truncateEmbedDescription,
|
||||
enforceEmbedLimit,
|
||||
BLOCK_TAG_REGEX,
|
||||
escapeRegex,
|
||||
escapeHtml,
|
||||
|
||||
Reference in New Issue
Block a user