213 lines
8.7 KiB
JavaScript
213 lines
8.7 KiB
JavaScript
const express = require('express');
|
|
const rateLimit = require('express-rate-limit');
|
|
const { ChannelType } = require('discord.js');
|
|
const { CONFIG } = require('../config');
|
|
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
|
const { logSystem } = require('../services/debugLog');
|
|
|
|
const router = express.Router();
|
|
|
|
const internalLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many requests, please try again later.' }
|
|
});
|
|
|
|
router.use(internalLimiter);
|
|
|
|
// 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 (allowlisted keys only)
|
|
const ALLOWED_CONFIG_KEYS = new Set([
|
|
// Ticket settings
|
|
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
|
|
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
|
|
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
|
|
// Escalation categories
|
|
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
|
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
|
// Roles and staff
|
|
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
|
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
|
|
// Channel IDs
|
|
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
|
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
|
|
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
|
|
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
|
|
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
|
|
'STAFF_NOTIFICATION_CATEGORY_ID',
|
|
// Pattern channel IDs
|
|
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
|
|
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID',
|
|
// Messages and labels
|
|
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
|
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
|
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
|
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
|
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
|
// Branding
|
|
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
|
// Toggles
|
|
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
|
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
|
|
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
|
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
|
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
|
'STAFF_DND_COUNTS_AS_AVAILABLE',
|
|
// Limits and thresholds
|
|
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
|
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
|
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
|
// Embed colors
|
|
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
|
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
|
|
// Pattern thresholds
|
|
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
|
|
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
|
|
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
|
|
// Surge settings
|
|
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
|
|
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
|
|
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
|
|
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
|
|
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
|
|
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
|
|
// Chat alerts
|
|
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
|
|
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
|
|
// Notification thresholds
|
|
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS'
|
|
]);
|
|
|
|
router.post('/config', express.json(), async (req, res) => {
|
|
const updates = req.body;
|
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
|
return res.status(400).json({ error: 'Invalid body' });
|
|
}
|
|
const rejected = Object.keys(updates).filter(k => !ALLOWED_CONFIG_KEYS.has(k));
|
|
if (rejected.length > 0) {
|
|
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
|
|
}
|
|
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 CHANNEL_TYPES = [
|
|
ChannelType.GuildText,
|
|
ChannelType.GuildCategory,
|
|
ChannelType.GuildAnnouncement,
|
|
ChannelType.GuildForum
|
|
];
|
|
const channels = guild.channels.cache
|
|
.filter(c => CHANNEL_TYPES.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 === ChannelType.GuildCategory)
|
|
.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;
|