let savedConfig = {}; let pendingChanges = {}; let notificationThresholdsState = {}; let csrfToken = ''; async function fetchCsrfToken() { const res = await fetch('/api/csrf-token', { credentials: 'same-origin' }); if (!res.ok) throw new Error('Failed to fetch CSRF token'); const data = await res.json(); csrfToken = data.csrfToken; return csrfToken; } function csrfHeaders(base = {}) { return { ...base, 'x-csrf-token': csrfToken }; } const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d']; const NOTIFICATION_TAB_KEYS = { surge: [ 'surge_tickets', 'surge_game', 'surge_stale', 'surge_needs_response', 'surge_unclaimed', 'surge_tier3_unclaimed', 'surge_no_staff' ], patterns: [ 'user_tickets', 'user_reopen', 'user_crossgame', 'game_surge', 'game_backlog', 'game_resolution', 'game_spike', 'tag_top', 'tag_escalation', 'untagged_closes', 'tag_game_corr', 'user_esc', 'game_esc_rate', 'rapid_t2_t3', 'staff_no_close', 'staff_overloaded', 'staff_stale', 'staff_transfer_rate', 'staff_esc', 'staff_game_esc', 'game_tag_spike', 'overnight_gap', 'staff_always_esc' ], unclaimed: ['unclaimed_reminder'], chat: ['chat_messages', 'chat_time'] }; const NOTIFICATION_ALERT_DESCRIPTIONS = { surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.', surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.', surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.', surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.', surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.', surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.", surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.', user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.', user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.', user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.', game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.', game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.', game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.', game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.', tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.', tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.', untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.', tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.', user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.', game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.', rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.', staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.', staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.', staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.', staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.', staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.', staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.', game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.', overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.', staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.', unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.', chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.', chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.' }; async function init() { document.getElementById('loading').classList.remove('hidden'); try { await fetchCsrfToken(); const [config] = await Promise.all([ fetch('/api/config', { credentials: 'same-origin' }).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); initNotificationsEditor(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'); }); }); } 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) { const buttons = document.querySelectorAll('#save-bar button'); buttons.forEach(b => b.disabled = true); try { if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) { return; } const res = await fetch('/api/config', { method: 'POST', credentials: 'same-origin', headers: csrfHeaders({ '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'); } const hasErrors = data.errors && data.errors.length > 0; if (hasErrors) { showToast(`Errors: ${data.errors.join(', ')}`, 'error'); } if (mode === 'restart' && !hasErrors) { await fetch('/api/restart', { method: 'POST', credentials: 'same-origin', headers: csrfHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ mode: 'immediate' }) }); showToast('Restart initiated.', 'warning'); } else if (mode === 'restart' && hasErrors) { showToast('Restart cancelled due to save errors.', 'warning'); } } catch (e) { showToast('Failed to save. Bot may be unreachable.', 'error'); } finally { buttons.forEach(b => b.disabled = false); } } 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', credentials: 'same-origin', headers: csrfHeaders({ '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'); } async function doLogout() { try { await fetch('/logout', { method: 'POST', credentials: 'same-origin', headers: csrfHeaders() }); } catch (e) { /* ignore */ } window.location.href = '/login'; } function setupActionButtons() { document.getElementById('save-btn')?.addEventListener('click', () => saveConfig('save')); document.getElementById('save-restart-btn')?.addEventListener('click', () => saveConfig('restart')); document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal); document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart); document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => { document.getElementById('schedule-modal').classList.add('hidden'); }); document.getElementById('logout-btn')?.addEventListener('click', doLogout); } 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); } function initNotificationsEditor(config) { const section = document.getElementById('s-notifications'); if (!section) return; const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]'); if (!hiddenField) return; notificationThresholdsState = parseNotificationThresholdsConfig(config); hiddenField.value = serializeNotificationThresholds(notificationThresholdsState); section.querySelectorAll('.notif-tab-btn').forEach(btn => { btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab)); }); Object.entries(NOTIFICATION_TAB_KEYS).forEach(([category, keys]) => { const select = section.querySelector(`[data-notif-category="${category}"]`); const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`); const input = section.querySelector(`[data-notif-input="${category}"]`); const addBtn = section.querySelector(`[data-notif-add="${category}"]`); const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`); if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return; keys.forEach(key => { const option = document.createElement('option'); option.value = key; option.textContent = toHumanLabel(key); select.appendChild(option); }); if (keys.length) select.value = keys[0]; select.addEventListener('change', () => { renderThresholdChips(category); renderAlertDescription(category); }); addBtn.addEventListener('click', () => addThresholdFromInput(category)); input.addEventListener('keydown', (evt) => { if (evt.key === 'Enter') { evt.preventDefault(); addThresholdFromInput(category); } }); NOTIFICATION_PRESETS.forEach(preset => { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = preset; btn.addEventListener('click', () => addThresholdValue(category, preset)); presetsWrap.appendChild(btn); }); renderThresholdChips(category); renderAlertDescription(category); }); setNotificationTab('surge'); } function parseNotificationThresholdsConfig(config) { const rawJson = config.NOTIFICATION_THRESHOLDS_JSON; if (rawJson && String(rawJson).trim()) { try { const parsed = JSON.parse(rawJson); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) {} } if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) { return config.NOTIFICATION_THRESHOLDS; } return {}; } function serializeNotificationThresholds(obj) { const ordered = {}; Object.keys(obj).sort().forEach(key => { const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : []; ordered[key] = arr; }); return JSON.stringify(ordered); } function setNotificationTab(category) { document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.notifTab === category); }); document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => { panel.classList.toggle('hidden', panel.dataset.notifPanel !== category); }); } function addThresholdFromInput(category) { const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`); if (!input) return; const value = input.value.trim().toLowerCase(); if (addThresholdValue(category, value)) input.value = ''; } function addThresholdValue(category, rawValue) { const value = String(rawValue || '').trim().toLowerCase(); if (!isValidThresholdValue(value)) { showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error'); return false; } const alertKey = getSelectedAlertKey(category); if (!alertKey) return false; const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : []; if (current.includes(value)) return false; current.push(value); notificationThresholdsState[alertKey] = current; syncNotificationThresholdsField(); renderThresholdChips(category); return true; } function removeThresholdValue(category, valueToRemove) { const alertKey = getSelectedAlertKey(category); if (!alertKey) return; const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : []; notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove)); syncNotificationThresholdsField(); renderThresholdChips(category); } function renderThresholdChips(category) { const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`); if (!chipsWrap) return; const alertKey = getSelectedAlertKey(category); const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey]) ? notificationThresholdsState[alertKey] : []; chipsWrap.innerHTML = ''; thresholds.forEach(value => { const chip = document.createElement('span'); chip.className = 'notif-chip'; chip.textContent = value; const remove = document.createElement('button'); remove.type = 'button'; remove.title = `Remove ${value}`; remove.textContent = '×'; remove.addEventListener('click', () => removeThresholdValue(category, value)); chip.appendChild(remove); chipsWrap.appendChild(chip); }); } function renderAlertDescription(category) { const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`); if (!descriptionEl) return; const alertKey = getSelectedAlertKey(category); descriptionEl.textContent = NOTIFICATION_ALERT_DESCRIPTIONS[alertKey] || 'No description available for this alert key yet.'; } function syncNotificationThresholdsField() { const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]'); if (!hiddenField) return; const serialized = serializeNotificationThresholds(notificationThresholdsState); hiddenField.value = serialized; markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized); hiddenField.classList.toggle('changed', 'NOTIFICATION_THRESHOLDS_JSON' in pendingChanges); } function getSelectedAlertKey(category) { const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`); return select ? select.value : ''; } function isValidThresholdValue(value) { if (!value) return false; if (/^\d+$/.test(value)) return true; return /^(\d+[mhd])+$/.test(value); } function toHumanLabel(key) { return String(key) .split('_') .map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3' ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } const ROUTES = { '/': 's-core', '/channels': 's-channels', '/categories': 's-categories', '/gmail': 's-gmail', '/behavior': 's-behavior', '/threads': 's-threads', '/pins': 's-pins', '/notifications': 's-notifications', '/logging': 's-logging', '/automation': 's-automation', '/appearance': 's-appearance', '/staff': 's-staff', '/advanced': 's-advanced' }; function navigate(path, updateHistory = true) { const sectionId = ROUTES[path] || ROUTES['/']; const normalizedPath = ROUTES[path] ? path : '/'; if (updateHistory) history.pushState({}, '', normalizedPath); document.querySelectorAll('.section').forEach(section => { section.classList.toggle('hidden', section.id !== sectionId); }); document.querySelectorAll('.sidebar a').forEach(link => { link.classList.toggle('active', link.getAttribute('href') === normalizedPath); }); } function setupSidebarRouting() { const sidebar = document.querySelector('.sidebar'); if (!sidebar) return; sidebar.addEventListener('click', e => { const a = e.target.closest('a'); if (!a) return; e.preventDefault(); navigate(a.getAttribute('href')); }); window.addEventListener('popstate', () => { navigate(location.pathname, false); }); } document.addEventListener('DOMContentLoaded', async () => { setupSidebarRouting(); setupActionButtons(); await init(); navigate(location.pathname, false); });