diff --git a/.env.example b/.env.example index 8147bcb..fc7622a 100644 --- a/.env.example +++ b/.env.example @@ -14,10 +14,8 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels TICKET_CATEGORY_ID= # Category for email-originated ticket channels -# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.) +# Category display name (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.) TICKET_CATEGORY_NAME=Open Tickets -TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets -TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets # Escalation categories (tier 2 and tier 3) DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord) @@ -32,7 +30,6 @@ ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close LOGGING_CHANNEL_ID= # Channel for lifecycle log messages DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional -DISCORD_CHANNEL_ID= # General Discord channel (if used) # --- Discord: Ticket copy & buttons --- # ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME @@ -50,8 +47,7 @@ GOOGLE_CLIENT_SECRET= # OAuth2 Client Secret REFRESH_TOKEN= # OAuth2 refresh token for the support inbox MY_EMAIL= # Support inbox email address -# --- Server & URLs --- -NGROK_URL= # Public URL (optional); run ngrok outside this repo +# --- Server --- DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server # HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only) @@ -74,7 +70,6 @@ DISCORD_AUTO_CLOSE_MESSAGE= # Message in ticket when auto-closed (e.g. ... # --- Ticket limits & permissions --- GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally -TICKET_LIMIT_PER_CATEGORY=3 # Max tickets per category RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disabled) RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets @@ -83,7 +78,6 @@ ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissi # --- Auto-close --- AUTO_CLOSE_ENABLED=false AUTO_CLOSE_AFTER_HOURS=72 -AUTO_CLOSE_MESSAGE= # Message when ticket is auto-closed # --- Reminders --- REMINDER_ENABLED=false @@ -140,7 +134,6 @@ GAME_LIST=Project Zomboid, Minecraft, ... # --- Embed colors (hex with 0x prefix) --- EMBED_COLOR_OPEN=0x00FF00 -EMBED_COLOR_CLOSED=0xFF0000 EMBED_COLOR_CLAIMED=0xFFFF00 EMBED_COLOR_ESCALATED=0xFF6600 EMBED_COLOR_INFO=0x1e2124 diff --git a/api/botClient.js b/api/botClient.js deleted file mode 100644 index 0fcbabf..0000000 --- a/api/botClient.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * bOSScord API: reference to the Discord bot client. - * Set in broccolini-discord.js when client fires "ready"; read by bosscord routes. - */ -let botClient = null; - -function setBot(client) { - botClient = client; -} - -function getBot() { - return botClient; -} - -module.exports = { setBot, getBot }; diff --git a/broccolini-discord.js b/broccolini-discord.js index 626be6c..a6773d8 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -17,15 +17,8 @@ const { handleDiscordReply } = require('./handlers/messages'); const { sendTicketClosedEmail } = require('./services/gmail'); const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets'); const { registerCommands } = require('./commands/register'); -// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup. -const { setBot } = require('./api/botClient'); const { poll } = require('./gmail-poll'); -const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog'); - -// 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'); +const { setClient: setDebugClient, logError } = require('./services/debugLog'); let gmailPollInterval = null; // Track all background setInterval handles so shutdown can clear them. @@ -179,7 +172,6 @@ client.once('ready', async () => { } await connectMongoDB(process.env.MONGODB_URI); setDebugClient(client); - setBot(client); const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined; httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => { console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); @@ -229,18 +221,6 @@ client.once('ready', async () => { console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)'); 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: '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); @@ -284,10 +264,7 @@ let shuttingDown = false; async function handleShutdown(signal) { if (shuttingDown) return; shuttingDown = true; - await Promise.race([ - logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]), - new Promise(r => setTimeout(r, 2000)) - ]); + console.log(`Bot shutting down (${signal})`); for (const handle of activeIntervals) { try { clearInterval(handle); } catch (_) {} } @@ -313,13 +290,5 @@ module.exports = { client, setGmailPollInterval, clearGmailPollInterval, - trackTimeout, - sendGmailReply, - sendTicketClosedEmail, - getNextTicketNumber, - getCleanBody, - detectGame, - stripEmailQuotes, - stripMobileFooter, - htmlToTextWithBlocks + trackTimeout }; diff --git a/config.js b/config.js index 0c4467a..6608657 100644 --- a/config.js +++ b/config.js @@ -1,25 +1,9 @@ /** * 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. * * Never commit .env; agents must not modify .env without explicit user confirmation. */ -const path = require('path'); -const dotenv = require('dotenv'); -const dotenvExpand = require('dotenv-expand'); - -const parsed = dotenv.config({ debug: process.env.NODE_ENV === 'development' }); -dotenvExpand.expand(parsed); -// Also load repo-root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env) -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); -} +require('dotenv').config({ debug: process.env.NODE_ENV === 'development' }); function toInt(v, fallback) { const n = parseInt(v, 10); @@ -31,23 +15,12 @@ const CONFIG = { 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, - 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(), @@ -75,9 +48,7 @@ const CONFIG = { 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: toInt(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: toInt(process.env.GLOBAL_TICKET_LIMIT, 5), - TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3), RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0), RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60), BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), @@ -103,7 +74,6 @@ const CONFIG = { BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌', BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓', EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00), - EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000), EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00), EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600), EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124), @@ -142,42 +112,8 @@ const GAME_ALIASES = { 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, GAME_NAMES, - GAME_ALIASES, - GAME_NAME_TO_KEY + GAME_ALIASES }; diff --git a/db-connection.js b/db-connection.js index 4d4ab67..bf34a19 100644 --- a/db-connection.js +++ b/db-connection.js @@ -22,23 +22,16 @@ async function connectMongoDB(uri, options = {}) { await mongoose.connect(uri, defaultOptions); console.log('✓ Connected to MongoDB'); - // 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) { diff --git a/gmail-poll.js b/gmail-poll.js index 0cbeb09..77f11a6 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -7,7 +7,7 @@ const { EmbedBuilder } = require('discord.js'); const { mongoose, withRetry } = require('./db-connection'); -const { CONFIG, GAME_NAME_TO_KEY } = require('./config'); +const { CONFIG } = require('./config'); const { getCleanBody, extractRawEmail, @@ -19,7 +19,7 @@ const { } = require('./utils'); const { getGmailClient } = require('./services/gmail'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); -const { logError, logGmail, logAutomation } = require('./services/debugLog'); +const { logError } = require('./services/debugLog'); const { enqueueSend } = require('./services/channelQueue'); const { getTicketActionRow } = require('./utils/ticketComponents'); @@ -29,7 +29,6 @@ const Transcript = mongoose.model('Transcript'); let isPolling = false; let authErrorNotified = false; let pollSuspended = false; -let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0; function setPollSuspended(val) { pollSuspended = !!val; @@ -45,13 +44,6 @@ async function poll(client) { if (isPolling || pollSuspended) return; isPolling = true; try { - pollCount++; - if (pollCount % 10 === 0) { - if (totalProcessed > 0 || totalSkipped > 0 || totalErrors > 0) { - logAutomation('Gmail poll summary', null, `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`).catch(() => {}); - } - pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0; - } console.log('Running poll()...'); try { const gmail = getGmailClient(); @@ -89,7 +81,6 @@ 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: { @@ -173,7 +164,6 @@ 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', @@ -224,11 +214,6 @@ async function poll(client) { const detectedGame = detectGame(subject, rawBody); - const gameKey = - detectedGame && detectedGame !== 'Not Mentioned' - ? GAME_NAME_TO_KEY[detectedGame] || null - : null; - const buttons = getTicketActionRow({ escalationTier: 0 }); const ticketInfoEmbed = new EmbedBuilder() @@ -326,8 +311,6 @@ async function poll(client) { }, { upsert: true, new: true } )); - totalProcessed++; - logGmail(subject, sEmail, number, detectedGame).catch(() => {}); } console.log('Archiving/reading Gmail message', msgRef.id); await gmail.users.messages.batchModify({ @@ -359,7 +342,6 @@ async function poll(client) { } } - totalErrors++; console.error('POLL ERROR:', e); logError('Gmail poll', e, null, client).catch(() => {}); } diff --git a/handlers/buttons.js b/handlers/buttons.js index cc0fa44..3b3ab78 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -16,14 +16,14 @@ const { } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); -const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets'); +const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets'); const { sendTicketClosedEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); -const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils'); +const { sanitizeEmbedText, truncateEmbedDescription, enforceEmbedLimit } = require('../utils'); const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { runEscalation, runDeescalation } = require('./commands'); const { pendingCloses } = require('./pendingCloses'); -const { logError, logSystem } = require('../services/debugLog'); +const { logError } = require('../services/debugLog'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); @@ -328,8 +328,6 @@ 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 @@ -510,13 +508,8 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) { files: [dmFile] }); } catch (dmErr) { - // 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only. - if (dmErr?.code === 50007) { - logSystem('Transcript DM skipped (recipient has DMs disabled)', [ - { name: 'User', value: creatorId }, - { name: 'Channel', value: channelName } - ]).catch(() => {}); - } else { + // 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore. + if (dmErr?.code !== 50007) { logError('transcript-dm', dmErr).catch(() => {}); } } @@ -662,8 +655,6 @@ async function handleTicketModal(interaction) { parentCategoryId: parentCategoryIdForTicket }); - const displayName = interaction.member?.displayName || interaction.user.username; - const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description; const welcomeEmbed = new EmbedBuilder() diff --git a/handlers/commands.js b/handlers/commands.js index f4c7425..2c73d99 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -3,7 +3,6 @@ */ const { ChannelType, - ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, @@ -18,7 +17,7 @@ const { sendTicketNotificationEmail } = require('../services/gmail'); const { getTicketActionRow } = require('../utils/ticketComponents'); const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); const { setNotifyDm } = require('../services/staffSettings'); -const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'); +const { logError } = require('../services/debugLog'); const { pendingCloses } = require('./pendingCloses'); const Ticket = mongoose.model('Ticket'); @@ -51,7 +50,6 @@ 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; } @@ -130,7 +128,6 @@ async function runEscalation(interaction, ticket, nextTier, reason) { ticket, null, emailBody, - escalatorName, interaction.user.id ); } catch (emailErr) { diff --git a/handlers/messages.js b/handlers/messages.js index b2e4b59..92cb3a1 100644 --- a/handlers/messages.js +++ b/handlers/messages.js @@ -3,7 +3,7 @@ */ const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); -const { extractRawEmail } = require('../utils'); +const { extractRawEmail, isStaff } = require('../utils'); const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { updateTicketActivity } = require('../services/tickets'); const { getNotifyDm } = require('../services/staffSettings'); @@ -19,12 +19,11 @@ async function handleDiscordReply(m) { const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); if (!ticket) return; - // 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); + const isStaffMember = isStaff(memberForCheck); Ticket.updateOne( { discordThreadId: m.channel.id }, - { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } + { $set: { lastActivity: new Date() } } ).catch(() => {}); // DM the claimer if they have notifydm on and a non-staff user replied. diff --git a/models.js b/models.js index fbd7eb9..f09aa0a 100644 --- a/models.js +++ b/models.js @@ -5,8 +5,6 @@ var mongoose = require('mongoose'); const ticketSchema = new mongoose.Schema({ gmailThreadId: { type: String, required: true, unique: true, index: true }, discordThreadId: String, - broccoliniTicketId: Number, - lastSyncedBroccoliniArticleId: Number, senderEmail: { type: String, required: true }, subject: String, createdAt: { type: Date, default: Date.now }, @@ -16,18 +14,12 @@ const ticketSchema = new mongoose.Schema({ escalated: { type: Boolean, default: false }, escalationTier: { type: Number, default: 0 }, ticketNumber: Number, - renameCount: { type: Number, default: 0 }, - renameWindowStart: Date, priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] }, ticketTag: String, lastActivity: Date, - reminderSent: { type: Boolean, default: false }, welcomeMessageId: String, claimerId: String, - staffChannelId: String, parentCategoryId: String, - unclaimedRemindersSent: { type: [Number], default: [] }, - lastMessageAuthorIsStaff: { type: Boolean, default: false }, pendingDelete: { type: Boolean, default: false } }); ticketSchema.index({ status: 1, lastActivity: 1 }); diff --git a/package-lock.json b/package-lock.json index 52451c0..dd15998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,13 @@ "dependencies": { "discord.js": "^14.25.1", "dotenv": "^17.2.4", - "dotenv-expand": "^11.0.6", "express": "^5.2.1", "express-rate-limit": "^8.3.2", "googleapis": "^171.4.0", - "mongodb": "^7.1.0", - "mongoose": "^6.12.0", - "p-queue": "^6.6.2" + "mongoose": "^6.12.0" + }, + "devDependencies": { + "mongodb": "^7.1.0" } }, "node_modules/@aws-crypto/sha256-browser": { @@ -1277,6 +1277,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz", "integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==", + "devOptional": true, "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" @@ -1964,6 +1965,7 @@ "version": "13.0.0", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/webidl-conversions": "*" @@ -2344,33 +2346,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2466,11 +2441,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -3068,6 +3038,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "devOptional": true, "license": "MIT" }, "node_modules/merge-descriptors": { @@ -3135,6 +3106,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.3.0", @@ -3181,6 +3153,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@types/whatwg-url": "^13.0.0", @@ -3194,6 +3167,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20.19.0" @@ -3391,40 +3365,6 @@ "wrappy": "1" } }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "engines": { - "node": ">=4" - } - }, - "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3783,6 +3723,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "devOptional": true, "license": "MIT", "dependencies": { "memory-pager": "^1.0.2" @@ -3919,6 +3860,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -4014,6 +3956,7 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, "license": "MIT", "dependencies": { "tr46": "^5.1.0", diff --git a/package.json b/package.json index a9d933a..a73f59f 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "dependencies": { "discord.js": "^14.25.1", "dotenv": "^17.2.4", - "dotenv-expand": "^11.0.6", "express": "^5.2.1", "express-rate-limit": "^8.3.2", "googleapis": "^171.4.0", - "mongodb": "^7.1.0", - "mongoose": "^6.12.0", - "p-queue": "^6.6.2" + "mongoose": "^6.12.0" + }, + "devDependencies": { + "mongodb": "^7.1.0" }, "name": "broccolini-bot", "version": "1.0.0", diff --git a/routes/internalApi.js b/routes/internalApi.js index beddf0f..019d19e 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -4,7 +4,6 @@ const { ChannelType } = require('discord.js'); const { CONFIG } = require('../config'); const { safeEqual } = require('../utils'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); -const { logSystem } = require('../services/debugLog'); const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema'); const router = express.Router(); @@ -54,11 +53,6 @@ router.post('/config', express.json(), async (req, res) => { return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` }); } const result = applyConfigUpdates(updates); - const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', '); - await logSystem('Config updated via settings UI', [ - { name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false }, - { name: 'Errors', value: errorSummary || 'none', inline: false } - ]).catch(() => {}); // Partial success stays 200 so the client can still apply the successful keys. // Only 400 when every submitted key failed validation (i.e. the save did nothing). const totalSubmitted = Object.keys(updates).length; @@ -69,7 +63,7 @@ router.post('/config', express.json(), async (req, res) => { // GET /discord/guild — return guild info for smart dropdowns router.get('/discord/guild', async (req, res) => { try { - const client = require('../api/botClient').getBot(); + const client = require('../broccolini-discord').client; if (!client) return res.status(503).json({ error: 'Bot not ready' }); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); @@ -178,9 +172,6 @@ router.post('/gmail/reload', express.json(), async (req, res) => { if (parent.setGmailPollInterval) { parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS); } - await logSystem('Gmail OAuth reloaded', [ - { name: 'Account', value: emailAddress, inline: false } - ]).catch(() => {}); res.json({ ok: true, email: emailAddress }); } catch (err) { const oauthError = err && err.response && err.response.data && err.response.data.error; diff --git a/services/configSchema.js b/services/configSchema.js index 93e00a9..b5111b2 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -19,8 +19,7 @@ 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', + 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID', // Escalation categories 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', @@ -29,12 +28,11 @@ const ALLOWED_CONFIG_KEYS = new Set([ 'ADMIN_ID', // Channel IDs 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', - 'DISCORD_CHANNEL_ID', 'RENAME_LOG_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', + '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 @@ -46,11 +44,11 @@ const ALLOWED_CONFIG_KEYS = new Set([ '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', // Limits and thresholds - 'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY', + 'GLOBAL_TICKET_LIMIT', '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', + 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI' ]); @@ -201,32 +199,7 @@ function getValidator(key) { return VALIDATORS[inferType(key)]; } -// Pre-build per-key validator map for callers that want O(1) lookup -// (and for the smoke test / boot log). -const ALL_VALIDATORS = {}; -for (const key of ALLOWED_CONFIG_KEYS) { - ALL_VALIDATORS[key] = getValidator(key); -} - -// ---------- Startup log (no-op if console.log is suppressed) ---------- - -(function logDistribution() { - const dist = {}; - const fallback = []; - for (const [key, v] of Object.entries(ALL_VALIDATORS)) { - dist[v.type] = (dist[v.type] || 0) + 1; - if (v.type === 'string') fallback.push(key); - } - console.log('[configSchema] type distribution:', JSON.stringify(dist)); - if (fallback.length) { - console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', ')); - } -})(); - module.exports = { ALLOWED_CONFIG_KEYS, - VALIDATORS, - ALL_VALIDATORS, - getValidator, - inferType + getValidator }; diff --git a/services/debugLog.js b/services/debugLog.js index 3e3e686..2b9a465 100644 --- a/services/debugLog.js +++ b/services/debugLog.js @@ -58,13 +58,6 @@ async function logWarn(context, message, overrideClient = null) { 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) { @@ -79,46 +72,9 @@ async function logTicketEvent(action, fields, interaction = null) { await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client); } -// --- logGmail --- - -async function logGmail(...args) { return; } - -// --- logAutomation --- - -async function logAutomation(...args) { return; } - -// --- logSecurity --- - -async function logSecurity(...args) { return; } - -// --- 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(...args) { return; } - module.exports = { setClient, logError, logWarn, - logEvent, - logTicketEvent, - logGmail, - logAutomation, - logSecurity, - logIntegrity, - logSystem + logTicketEvent }; diff --git a/services/gmail.js b/services/gmail.js index 091ead1..8551218 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -1,5 +1,5 @@ /** - * Gmail service – OAuth client, send reply, send ticket-closed email. + * Gmail service – OAuth client, send reply, send ticket-closed/notification emails. */ const { google } = require('googleapis'); const { CONFIG } = require('../config'); @@ -56,8 +56,6 @@ function getGmailClient() { * * Throws if the env file is missing the token, or if the probe call (getProfile) * fails — the caller surfaces the error so the UI can see why. - * - * @returns {Promise<{emailAddress: string}>} */ async function reloadGmailClient() { const envMap = readEnvFile(); @@ -74,276 +72,53 @@ async function reloadGmailClient() { return { emailAddress: profile.data.emailAddress }; } -async function sendTicketClosedEmail(ticket, closerName, userId = null) { +// Fetch the first message's Subject + Message-ID from a Gmail thread, used to +// derive a faithful Re: subject and a proper In-Reply-To/References header. +async function fetchThreadSubjectAndMsgId(gmail, threadId) { try { - const gmail = getGmailClient(); - - // Send to the ticket sender (customer), not derived from thread (which can be support) - const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); - if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; - if (!EMAIL_RE.test(recipientEmail)) { - logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); - return; - } - - let originalSubject = null; - let msgId = null; - try { - const thread = await gmail.users.threads.get({ - userId: 'me', - id: ticket.gmailThreadId - }); - const messages = thread.data.messages || []; - const firstMsg = messages[0]; - if (firstMsg?.payload?.headers) { - const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value; - if (subj) originalSubject = subj; - msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); - } - } catch (_) {} - - const baseSubject = originalSubject || ticket.subject || 'Support'; - const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, ''); - const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`); - const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`; - - const messageBody = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`; - - let signatureBlocks = { text: '', html: '' }; - if (userId) { - signatureBlocks = await getStaffSignatureBlocks(userId); - } - // signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks. - const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; - const safeStaffSigText = signatureBlocks.text; - - const htmlBody = ` -
-

${escapeHtml(messageBody).replace(/\n/g, '
')}

- ${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} - ${buildCompanySigHtml()} -
`; - - const boundary = '000000000000' + Date.now().toString(16); - - const plainBody = []; - plainBody.push(messageBody); - if (safeStaffSigText) { - plainBody.push(''); - plainBody.push(safeStaffSigText); - } - plainBody.push(''); - plainBody.push(...buildCompanySigText().split('\n')); - - const headers = [ - `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, - `To: ${recipientEmail}`, - `Subject: ${utf8Subject}`, - msgId && `In-Reply-To: ${msgId}`, - msgId && `References: ${msgId}`, - 'MIME-Version: 1.0', - `Content-Type: multipart/alternative; boundary="${boundary}"` - ].filter(Boolean); - - const raw = Buffer.from([ - ...headers, - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="UTF-8"', - '', - ...plainBody, - '', - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - '', - htmlBody, - '', - `--${boundary}--` - ].join('\r\n')) - .toString('base64') - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw, threadId: ticket.gmailThreadId } - }); - } catch (err) { - console.error('Ticket closed email error:', err); + const thread = await gmail.users.threads.get({ userId: 'me', id: threadId }); + const firstMsg = (thread.data.messages || [])[0]; + const headers = firstMsg?.payload?.headers || []; + return { + subject: headers.find(h => h.name === 'Subject')?.value || null, + msgId: sanitizeHeaderValue(headers.find(h => h.name === 'Message-ID')?.value) || null + }; + } catch (_) { + return { subject: null, msgId: null }; } } -// StaffSignature model is registered in models.js; re-import here for use in this file -const { mongoose } = require('../db-connection'); -const StaffSignature = mongoose.model('StaffSignature'); - -/** - * Send a notification email in the ticket thread (e.g. escalation, high-priority). - * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject - * @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated") - * @param {string} messageBody - Plain or HTML message body - * @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord") - * @param {string} [userId] - Discord user ID for signature (optional) - */ -async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) { - try { - const gmail = getGmailClient(); - const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); - if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; - if (!EMAIL_RE.test(recipientEmail)) { - logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); - return; - } - - let originalSubject = null; - let msgId = null; - try { - const thread = await gmail.users.threads.get({ - userId: 'me', - id: ticket.gmailThreadId - }); - const messages = thread.data.messages || []; - const firstMsg = messages[0]; - if (firstMsg?.payload?.headers) { - const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value; - if (subj) originalSubject = subj; - msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); - } - } catch (_) {} - - // Thread-safety: derive Subject from the last thread message; strip any leading - // Re:/RE:/Re : variants and re-prepend a single "Re: ". Fall back to subjectLine - // (legacy param) only if the thread lookup gave us nothing. - const baseSubject = originalSubject || subjectLine || ticket.subject || 'Support'; - const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, ''); - const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`); - const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`; - - let signatureBlocks = { text: '', html: '' }; - if (userId) { - signatureBlocks = await getStaffSignatureBlocks(userId); - } - // signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks. - const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; - const safeStaffSigText = signatureBlocks.text; - - const htmlBody = ` -
-

${escapeHtml(messageBody || '').replace(/\n/g, '
')}

- ${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} - ${buildCompanySigHtml()} -
`; - - const boundary = '000000000000' + Date.now().toString(16); - - const plainBody = []; - plainBody.push(messageBody || ''); - if (safeStaffSigText) { - plainBody.push(''); - plainBody.push(safeStaffSigText); - } - plainBody.push(''); - plainBody.push(...buildCompanySigText().split('\n')); - - const headers = [ - `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, - `To: ${recipientEmail}`, - `Subject: ${utf8Subject}`, - msgId && `In-Reply-To: ${msgId}`, - msgId && `References: ${msgId}`, - 'MIME-Version: 1.0', - `Content-Type: multipart/alternative; boundary="${boundary}"` - ].filter(Boolean); - - const raw = Buffer.from([ - ...headers, - '', - `--${boundary}`, - 'Content-Type: text/plain; charset="UTF-8"', - '', - ...plainBody, - '', - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - '', - htmlBody, - '', - `--${boundary}--` - ].join('\r\n')) - .toString('base64') - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw, threadId: ticket.gmailThreadId } - }); - } catch (err) { - console.error('Ticket notification email error:', err); - } +// Strip leading "Re:" variants and re-prepend a single one, then RFC 2047 encode. +function encodeReplySubject(baseSubject) { + const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, ''); + const safe = sanitizeHeaderValue(`Re: ${stripped}`); + return `=?utf-8?B?${Buffer.from(safe).toString('base64')}?=`; } -/** - * Send a Gmail reply to a ticket - * @param {string} threadId - Gmail thread ID - * @param {string} replyText - Reply text - * @param {string} recipientEmail - Recipient email - * @param {string} subject - Subject line - * @param {string} messageId - Message ID (optional) - * @param {string} userId - Discord user ID for optional personal valediction/tagline (optional) - */ -async function sendGmailReply( - threadId, - replyText, - recipientEmail, - subject, - messageId, - userId = null -) { - const gmail = getGmailClient(); - - const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); - if (!EMAIL_RE.test(safeRecipient)) { - logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); - return null; - } - const safeMessageId = sanitizeHeaderValue(messageId); - const safeSubject = sanitizeHeaderValue(`Re: ${subject}`); - - const utf8Subject = `=?utf-8?B?${Buffer.from( - safeSubject - ).toString('base64')}?=`; - - let signatureBlocks = { text: '', html: '' }; - if (userId) { - signatureBlocks = await getStaffSignatureBlocks(userId); - } - // signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here. - const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; - const safeStaffSigText = signatureBlocks.text; +// Compose and send a multipart/alternative reply on an existing Gmail thread. +async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) { + const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' }; + const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '
') : ''; + const safeStaffSigText = sigBlocks.text; const htmlBody = `
-

${escapeHtml(replyText).replace(/\n/g, '
')}

+

${escapeHtml(messageText || '').replace(/\n/g, '
')}

${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} ${buildCompanySigHtml()}
`; + const plainBody = [messageText || '']; + if (safeStaffSigText) plainBody.push('', safeStaffSigText); + plainBody.push('', ...buildCompanySigText().split('\n')); + const boundary = '000000000000' + Date.now().toString(16); - - const plainBody = []; - plainBody.push(replyText); - if (safeStaffSigText) { - plainBody.push(''); - plainBody.push(safeStaffSigText); - } - plainBody.push(''); - plainBody.push(...buildCompanySigText().split('\n')); - const headers = [ `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, - `To: ${safeRecipient}`, - `Subject: ${utf8Subject}`, - safeMessageId && `In-Reply-To: ${safeMessageId}`, - safeMessageId && `References: ${safeMessageId}`, + `To: ${recipient}`, + `Subject: ${encodedSubject}`, + msgId && `In-Reply-To: ${msgId}`, + msgId && `References: ${msgId}`, 'MIME-Version: 1.0', `Content-Type: multipart/alternative; boundary="${boundary}"` ].filter(Boolean); @@ -366,9 +141,92 @@ async function sendGmailReply( .toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw, threadId } + await gmail.users.messages.send({ userId: 'me', requestBody: { raw, threadId } }); +} + +// Resolve and validate a customer recipient from a ticket's senderEmail. +// Returns null and logs if invalid or self-addressed. +function resolveCustomerRecipient(ticket, context) { + const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); + if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return null; + if (!EMAIL_RE.test(recipientEmail)) { + logError(`${context}: invalid recipient`, new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); + return null; + } + return recipientEmail; +} + +async function sendTicketClosedEmail(ticket, closerName, userId = null) { + try { + const recipient = resolveCustomerRecipient(ticket, 'sendTicketClosedEmail'); + if (!recipient) return; + + const gmail = getGmailClient(); + const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); + const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support'); + const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`; + + await sendThreadedEmail(gmail, { + threadId: ticket.gmailThreadId, + recipient, + encodedSubject, + msgId, + messageText, + userId + }); + } catch (err) { + console.error('Ticket closed email error:', err); + } +} + +/** + * Send a notification email in the ticket thread (e.g. escalation, high-priority). + * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject + * @param {string} subjectLine - Fallback subject if the thread can't be queried + * @param {string} messageBody - Plain or HTML message body + * @param {string} [userId] - Discord user ID for signature (optional) + */ +async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) { + try { + const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail'); + if (!recipient) return; + + const gmail = getGmailClient(); + const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); + const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support'); + + await sendThreadedEmail(gmail, { + threadId: ticket.gmailThreadId, + recipient, + encodedSubject, + msgId, + messageText: messageBody, + userId + }); + } catch (err) { + console.error('Ticket notification email error:', err); + } +} + +/** + * Send a Gmail reply on an existing thread. Caller supplies subject + messageId + * (typically pulled from the latest non-self message in the thread). + */ +async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) { + const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); + if (!EMAIL_RE.test(safeRecipient)) { + logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); + return null; + } + + const gmail = getGmailClient(); + await sendThreadedEmail(gmail, { + threadId, + recipient: safeRecipient, + encodedSubject: encodeReplySubject(subject || 'Support'), + msgId: sanitizeHeaderValue(messageId) || null, + messageText: replyText, + userId }); } diff --git a/services/tickets.js b/services/tickets.js index b9b1b7a..c170e23 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -2,11 +2,9 @@ * Ticket database helpers – counters, rename, limits, auto-close, * reminders, auto-unclaim, channel creation. */ -const { ChannelType, PermissionFlagsBits } = require('discord.js'); +const { ChannelType } = require('discord.js'); const { mongoose, withRetry } = require('../db-connection'); const { CONFIG } = require('../config'); -const { getPriorityEmoji } = require('../utils'); -const { logAutomation } = require('../services/debugLog'); const { enqueueSend, enqueueDelete } = require('./channelQueue'); const Ticket = mongoose.model('Ticket'); @@ -30,9 +28,6 @@ async function getNextTicketNumber(senderEmail) { // primary bot's 2/10min per-channel budget here; 429s from the secondary // bot surface via utils/renamer.js instead. -const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat) -const RENAME_LIMIT = 2; - function getSenderLocal(senderEmail) { return (senderEmail || 'unknown').split('@')[0].toLowerCase(); } @@ -90,16 +85,6 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) { } } -// Retained for external callers (bOSScord, scripts). The gate now lives in -// the secondary bot's rate bucket; this helper no longer touches Mongo. -async function canRename(_ticket) { - return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 }; -} - -function minutesFromMs(ms) { - return Math.max(1, Math.ceil(ms / 60000)); -} - // --- RATE LIMIT (per-user ticket creation) --- const ticketCreationByUser = new Map(); // userId -> { count, resetAt } @@ -156,15 +141,6 @@ function escapeCategoryNameForRegex(name) { return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -/** - * @deprecated Use getOrCreateTicketCategory instead. - * @returns {null} - */ -function pickTicketCategoryId(guild, categoryIds) { - console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead'); - return null; -} - function countChannelsInCategory(guild, categoryId) { return guild.channels.cache.filter(c => c.parentId === categoryId).size; } @@ -272,52 +248,6 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) { } } -async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) { - let parentId; - try { - parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME); - } catch (e) { - console.error('getOrCreateTicketCategory (createTicketChannel):', e); - throw new Error('Ticket category not found or could not be allocated'); - } - - let channel; - try { - channel = await guild.channels.create({ - name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`, - type: ChannelType.GuildText, - parent: parentId, - permissionOverwrites: [ - { - id: guild.id, - deny: [PermissionFlagsBits.ViewChannel] - }, - { - id: userId, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.ReadMessageHistory - ] - }, - { - id: CONFIG.ROLE_ID_TO_PING, - allow: [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.ReadMessageHistory - ] - } - ] - }); - } catch (e) { - console.error('guild.channels.create (createTicketChannel):', e); - throw e; - } - - return channel; -} - // --- LIMITS & PERMISSIONS --- async function checkTicketLimits(senderEmail) { @@ -334,22 +264,12 @@ async function checkTicketLimits(senderEmail) { return { ok: true }; } -function hasBlacklistedRole(member) { - if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) { - return false; - } - return member.roles.cache.some(role => - CONFIG.BLACKLISTED_ROLES.includes(role.id) - ); -} - // --- ACTIVITY --- async function updateTicketActivity(gmailThreadId) { - const now = new Date(); await Ticket.updateOne( { gmailThreadId }, - { $set: { lastActivity: now, reminderSent: false } } + { $set: { lastActivity: new Date() } } ); } @@ -366,9 +286,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) { lastActivity: { $lt: cutoffTime, $ne: null } }).sort({ createdAt: 1 }).limit(500).lean()); - let checked = 0, closed = 0; for (const ticket of staleTickets) { - checked++; try { const guild = client.guilds.cache.first(); if (!guild) continue; @@ -395,53 +313,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) { )).catch(() => {}); }).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 withRetry(() => Ticket.find({ - status: 'open', - lastActivity: { $lt: reminderTime, $ne: null }, - reminderSent: false - }).lean()); - - let checked = 0, reminded = 0; - for (const ticket of ticketsNeedingReminder) { - checked++; - try { - const guild = client.guilds.cache.first(); - if (!guild) continue; - - const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); - if (channel) { - const ping = ticket.claimedBy - ? `<@${ticket.claimedBy}>` - : (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone'); - const message = CONFIG.REMINDER_MESSAGE - .replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS)) - .replace(/\{ping\}/g, ping); - await enqueueSend(channel, message); - - 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) { @@ -454,9 +330,7 @@ async function checkAutoUnclaim(client) { lastActivity: { $lt: unclaimTime, $ne: null } }).lean()); - let checked = 0, unclaimed = 0; for (const ticket of staleClaimedTickets) { - checked++; try { const guild = client.guilds.cache.first(); if (!guild) continue; @@ -473,18 +347,16 @@ async function checkAutoUnclaim(client) { ); 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(() => {}); } async function reconcileDeletedTicketChannels(client) { const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); - if (!guild) return { checked: 0, reconciled: 0 }; + if (!guild) return; // Bounded per-tick; a larger backlog drains in subsequent hourly runs. const openTickets = await Ticket.find({ @@ -492,9 +364,7 @@ async function reconcileDeletedTicketChannels(client) { discordThreadId: { $ne: null } }).sort({ createdAt: 1 }).limit(500).lean(); - let checked = 0, reconciled = 0; for (const ticket of openTickets) { - checked++; try { let channel = guild.channels.cache.get(ticket.discordThreadId); if (!channel) { @@ -505,17 +375,11 @@ async function reconcileDeletedTicketChannels(client) { { gmailThreadId: ticket.gmailThreadId }, { $set: { status: 'closed', discordThreadId: null } } ); - logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {}); - reconciled++; } } catch (err) { console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err); } } - if (reconciled > 0) { - logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {}); - } - return { checked, reconciled }; } /** @@ -525,8 +389,7 @@ async function reconcileDeletedTicketChannels(client) { */ async function resumePendingDeletes(client) { const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []); - if (!pending.length) return 0; - let resumed = 0; + if (!pending.length) return; for (const ticket of pending) { try { const guild = client.guilds.cache.first(); @@ -534,7 +397,6 @@ async function resumePendingDeletes(client) { const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); if (channel) { enqueueDelete(channel).catch(() => {}); - resumed++; } } Ticket.updateOne( @@ -545,33 +407,22 @@ async function resumePendingDeletes(client) { console.error('resumePendingDeletes error:', e); } } - logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {}); - return resumed; } module.exports = { getNextTicketNumber, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, - RENAME_WINDOW_MS, - RENAME_LIMIT, getSenderLocal, toDiscordSafeName, resolveCreatorNickname, makeTicketName, - canRename, - minutesFromMs, checkTicketCreationRateLimit, - createTicketChannel, checkTicketLimits, - hasBlacklistedRole, updateTicketActivity, checkAutoClose, - checkReminders, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes, - startTicketsSweeps, - sweepTicketCreationByUser, - _internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS } + startTicketsSweeps }; diff --git a/utils.js b/utils.js index ce46793..12467c4 100644 --- a/utils.js +++ b/utils.js @@ -173,31 +173,6 @@ function extractRawEmail(headerValue) { return match ? match[1].trim() : headerValue.trim(); } -// --- DATE --- - -const getFormattedDate = () => { - const now = new Date(); - const datePart = now - .toLocaleDateString('en-US', { - month: '2-digit', - day: '2-digit', - year: 'numeric' - }) - .replace(/\//g, '-'); - const timePart = now.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: true - }); - const tzPart = new Intl.DateTimeFormat('en-US', { - timeZoneName: 'short' - }) - .formatToParts(now) - .find(p => p.type === 'timeZoneName').value; - return `${datePart} ${timePart} ${tzPart}`; -}; - // --- GAME DETECTION --- // Map built once at module load so detectGame // doesn't allocate a fresh RegExp per game/alias per call. @@ -233,16 +208,6 @@ function getPriorityEmoji(priority) { } } -function getPriorityColor(priority) { - switch (priority) { - case 'high': return 0xFF0000; - case 'low': return 0x00FF00; - case 'normal': - case 'medium': - default: return CONFIG.EMBED_COLOR_INFO; - } -} - // --- TEMPLATE VARIABLES --- function replaceVariables(template, context = {}) { @@ -292,13 +257,6 @@ function sanitizeEmbedText(str) { // --- 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 ''; @@ -381,24 +339,17 @@ function enforceEmbedLimit(embeds) { module.exports = { sanitizeEmbedText, - truncateEmbedField, truncateEmbedDescription, enforceEmbedLimit, - BLOCK_TAG_REGEX, - escapeRegex, escapeHtml, safeEqual, isStaff, - decodeHtmlEntities, htmlToTextWithBlocks, - decodeGmailData, getCleanBody, stripEmailQuotes, stripMobileFooter, extractRawEmail, - getFormattedDate, detectGame, getPriorityEmoji, - getPriorityColor, replaceVariables };