/** * Broccolini Bot configuration and game lists. * Load dotenv so env is available when this module is required first. * dotenv-expand resolves ${NGROK_URL} etc. in .env. * * Test env: set ENV_FILE=.env.test to load .env.test instead of .env (see ENV_AND_SECURITY.md). * Never commit .env or .env.test; agents must not modify .env without explicit user confirmation. */ const path = require('path'); const dotenv = require('dotenv'); const dotenvExpand = require('dotenv-expand'); const envPath = process.env.ENV_FILE ? path.resolve(process.cwd(), process.env.ENV_FILE) : undefined; let parsed = dotenv.config({ path: envPath, debug: process.env.NODE_ENV === 'development' }); if (envPath && parsed.error) { console.warn(`[config] ENV_FILE=${process.env.ENV_FILE} not found or unreadable:`, parsed.error.message); } dotenvExpand.expand(parsed); // If no ENV_FILE, also load repo root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env) if (!envPath) { const rootEnv = path.resolve(process.cwd(), '..', '.env'); const rootParsed = dotenv.config({ path: rootEnv }); if (!rootParsed.error && rootParsed.parsed) { for (const [k, v] of Object.entries(rootParsed.parsed)) { if (v != null && String(v).trim() !== '') process.env[k] = v; } dotenvExpand.expand(rootParsed); } } const CONFIG = { DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(), DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null, TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID, TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets', TICKET_T2_CATEGORY_NAME: process.env.TICKET_T2_CATEGORY_NAME || 'Tier 2 Escalated Tickets', TICKET_T3_CATEGORY_NAME: process.env.TICKET_T3_CATEGORY_NAME || 'Tier 3 Escalated Tickets', EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '') .split(',') .map(s => s.trim()) .filter(Boolean), DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID, DISCORD_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || '') .split(',') .map(s => s.trim()) .filter(Boolean), ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING, ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID, TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID, LOG_CHAN: process.env.LOGGING_CHANNEL_ID, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null, ACCOUNT_INFO_CHANNEL_ID: process.env.ACCOUNT_INFO_CHANNEL_ID || null, DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null, CLIENT_ID: process.env.DISCORD_APPLICATION_ID, REFRESH_TOKEN: process.env.REFRESH_TOKEN, MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(), LOGO_URL: process.env.LOGO_URL, SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support', PORT: process.env.DISCORD_ONLY_PORT || 5000, HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '
'), GAME_LIST: process.env.GAME_LIST || '', DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null, EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null, EMAIL_ESCALATED_CATEGORY_ID: process.env.EMAIL_ESCALATED_CATEGORY_ID || process.env.ESCALATED_CATEGORY_ID, DISCORD_ESCALATED_CATEGORY_ID: process.env.DISCORD_ESCALATED_CATEGORY_ID, // Tier 2/3 email escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming). EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null, DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_ESCALATED2_CHANNEL_ID || null, EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null, DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null, ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.', TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]', // Email tickets only (closure email body): TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || 'This ticket has been marked as resolved. If you would like to re-open this issue, please reply to this email.', TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.', // Discord ticket closure (in-channel and transcript): DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.', DISCORD_TRANSCRIPT_MESSAGE: process.env.DISCORD_TRANSCRIPT_MESSAGE || 'Your ticket **{channel_name}** has been closed. Here is your transcript. If you still need assistance, please open a new ticket.', DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.', AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true', AUTO_CLOSE_AFTER_HOURS: parseInt(process.env.AUTO_CLOSE_AFTER_HOURS) || 72, AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.', GLOBAL_TICKET_LIMIT: parseInt(process.env.GLOBAL_TICKET_LIMIT) || 5, TICKET_LIMIT_PER_CATEGORY: parseInt(process.env.TICKET_LIMIT_PER_CATEGORY) || 3, RATE_LIMIT_TICKETS_PER_USER: parseInt(process.env.RATE_LIMIT_TICKETS_PER_USER) || 0, RATE_LIMIT_WINDOW_MINUTES: parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES) || 60, BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️', REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true', REMINDER_AFTER_HOURS: parseInt(process.env.REMINDER_AFTER_HOURS) || 24, REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.', PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true', DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal', PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴', PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡', PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟢', CLAIM_TIMEOUT_ENABLED: process.env.CLAIM_TIMEOUT_ENABLED === 'true', CLAIM_TIMEOUT_HOURS: parseInt(process.env.CLAIM_TIMEOUT_HOURS) || 48, AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true', AUTO_UNCLAIM_AFTER_HOURS: parseInt(process.env.AUTO_UNCLAIM_AFTER_HOURS) || 24, ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true', USE_THREADS: process.env.USE_THREADS === 'true', THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null, BUTTON_LABEL_CLOSE: process.env.BUTTON_LABEL_CLOSE || 'Close Ticket', BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim', BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim', BUTTON_EMOJI_CLOSE: process.env.BUTTON_EMOJI_CLOSE || '🔒', BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌', BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓', EMBED_COLOR_OPEN: parseInt(process.env.EMBED_COLOR_OPEN) || 0x00FF00, EMBED_COLOR_CLOSED: parseInt(process.env.EMBED_COLOR_CLOSED) || 0xFF0000, EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00, EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600, EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124, STAFF_CATEGORIES: (() => { const raw = process.env.STAFF_CATEGORIES; const map = new Map(); if (!raw || !String(raw).trim()) return map; for (const part of String(raw).split(',')) { const seg = part.trim(); if (!seg) continue; const idx = seg.indexOf(':'); if (idx === -1) continue; const userId = seg.slice(0, idx).trim(); const categoryId = seg.slice(idx + 1).trim(); if (userId && categoryId) map.set(userId, categoryId); } return map; })(), STAFF_EMOJIS: (() => { const raw = process.env.STAFF_EMOJIS; const map = new Map(); if (!raw || !String(raw).trim()) return map; for (const part of String(raw).split(',')) { const seg = part.trim(); if (!seg) continue; const idx = seg.indexOf(':'); if (idx === -1) continue; const userId = seg.slice(0, idx).trim(); const emoji = seg.slice(idx + 1).trim(); if (userId && emoji) map.set(userId, emoji); } return map; })(), CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', STAFF_T1_CATEGORY: process.env.STAFF_T1_CATEGORY || null, STAFF_T2_CATEGORY: process.env.STAFF_T2_CATEGORY || null, STAFF_T3_CATEGORY: process.env.STAFF_T3_CATEGORY || null, UNCLAIMED_CATEGORY_ID: process.env.UNCLAIMED_CATEGORY_ID || null }; /** Ticket category tags for /tag set – [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */ const TICKET_TAGS = [ { value: 'server-down', emoji: '⬇️', name: 'Server Down' }, { value: 'stuck-restarting', emoji: '⏳', name: 'Stuck Restarting' }, { value: 'cant-connect', emoji: '📵', name: "Can't Connect" }, { value: 'server-lag', emoji: '🐌', name: 'Server Lag' }, { value: 'billing', emoji: '💳', name: 'Billing' }, { value: 'refund-request', emoji: '💸', name: 'Refund Request' }, { value: 'mod-help', emoji: '🔧', name: 'Mod Help' }, { value: 'backup-restore', emoji: '💾', name: 'Backup Restore' }, { value: 'world-save', emoji: '🌍', name: 'World / Save' }, { value: 'server-config', emoji: '⚙️', name: 'Server Config' } ]; const GAME_NAMES = (CONFIG.GAME_LIST || '') .split(',') .map(g => g.trim()) .filter(Boolean); const GAME_ALIASES = { '7D2D': '7 Days to Die', '7 days': '7 Days to Die', PZ: 'Project Zomboid', zomboid: 'Project Zomboid', MC: 'Minecraft', Ark: 'ARK: Survival Evolved', SOTF: 'Sons of the Forest', CS2: 'Counter-Strike 2' }; const GAME_NAME_TO_KEY = { 'Project Zomboid': 'project_zomboid', 'Satisfactory': 'satisfactory', 'Palworld': 'palworld', 'Minecraft': 'minecraft', 'Valheim': 'valheim', 'Enshrouded': 'enshrouded', '7 Days to Die': '7_days_to_die', 'Hytale': 'hytale', 'ICARUS': 'icarus', 'Abiotic Factor': 'abiotic_factor', 'ARK: Survival Evolved': 'ark_survival_evolved', 'Conan Exiles': 'conan_exiles', 'Core Keeper': 'core_keeper', 'Counter-Strike 2': 'counter_strike_2', 'DayZ': 'dayz', 'ECO': 'eco', 'Factorio': 'factorio', 'FiveM': 'fivem', 'The Front': 'the_front', "Garry's Mod": 'garrys_mod', 'Necesse': 'necesse', 'Rust': 'rust', 'Sons of the Forest': 'sons_of_the_forest', 'Soulmask': 'soulmask', 'Star Rupture': 'star_rupture', 'Terraria': 'terraria', 'VEIN': 'vein', 'Vintage Story': 'vintage_story', 'Voyagers of Nera': 'voyagers_of_nera', 'V Rising': 'v_rising' }; module.exports = { CONFIG, TICKET_TAGS, GAME_NAMES, GAME_ALIASES, GAME_NAME_TO_KEY };