/** * Pure utility functions – text processing, date formatting, game detection, * priority helpers, template variables. */ const crypto = require('crypto'); const { CONFIG, GAME_NAMES, GAME_ALIASES } = require('./config'); /** Constant-time string compare. Returns false for mismatched length or empty/nullish inputs without throwing. */ function safeEqual(a, b) { const ab = Buffer.from(String(a || ''), 'utf8'); const bb = Buffer.from(String(b || ''), 'utf8'); return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); } /** True if the member holds ROLE_ID_TO_PING or any ADDITIONAL_STAFF_ROLES. Safe for null/undefined members. */ function isStaff(member) { if (!member?.roles?.cache) return false; if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true; const additional = CONFIG.ADDITIONAL_STAFF_ROLES || []; return additional.some(roleId => member.roles.cache.has(roleId)); } // --- TEXT PROCESSING --- const BLOCK_TAG_REGEX = /<\/(p|div|li|h[1-6]|tr|table|section|article|blockquote)>/gi; function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** Escape for safe use in HTML body (prevents XSS in outgoing emails). */ function escapeHtml(str) { if (str == null) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function decodeHtmlEntities(str) { if (!str) return ''; return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, ' '); } function htmlToTextWithBlocks(html) { return decodeHtmlEntities( html .replace(/\r\n/g, '\n') .replace(//gi, '\n') .replace(BLOCK_TAG_REGEX, '\n\n') .replace(/<(ul|ol)[^>]*>/gi, '\n') .replace(/<[^>]*>?/gm, '') ); } // --- EMAIL BODY EXTRACTION --- function decodeGmailData(p) { if (!p.body?.data) return ''; let data = Buffer.from(p.body.data, 'base64').toString('utf8'); const isQuotedPrintable = p.headers?.some( h => h.name.toLowerCase() === 'content-transfer-encoding' && h.value.toLowerCase() === 'quoted-printable' ); if (isQuotedPrintable) { data = data .replace(/=\r?\n/g, '') .replace(/=([0-9A-F]{2})/gi, (m, hex) => String.fromCharCode(parseInt(hex, 16)) ); } return data; } function getCleanBody(payload) { let body = ''; const findParts = parts => { for (const part of parts) { if (part.mimeType === 'text/plain' && part.body?.data && !body) { body = decodeGmailData(part); } if (part.mimeType === 'text/html' && part.body?.data && !body) { body = decodeGmailData(part); } if (part.parts) findParts(part.parts); } }; if (payload.parts) { findParts(payload.parts); } else if (payload.body?.data) { body = decodeGmailData(payload); } return body || payload.snippet || ''; } // --- QUOTE / FOOTER STRIPPING --- function stripEmailQuotes(text) { let cleaned = text.replace(/\r\n/g, '\n'); const markers = [ /\n_{5,}\s*$/m, /\nFrom:\s.*<.*@.*>/i, /\nSent:\s.*$/i, /\nTo:\s.*$/i, /\nSubject:\s.*$/i, /\nOn .* wrote:/i ]; for (const m of markers) { const match = cleaned.match(m); if (match) { cleaned = cleaned.substring(0, match.index); break; } } return cleaned.trim(); } // Hoisted to module scope: constructed once at load, not per-call. const MOBILE_FOOTER_REGEXES = [ /Sent from my iPhone/i, /Sent from my iPad/i, /Sent from my Apple Watch/i, /Sent from my Mac/i, /Sent from my mobile device/i, /Sent from my phone/i, /Sent from my smartphone/i, /Sent from my Android(?: phone| device)?/i, /Sent from my Samsung Galaxy smartphone/i, /Sent from Samsung Mobile/i, /Sent from my Galaxy/i, /Sent from my BlackBerry/i, /Sent from my Windows Phone/i, /Sent from Outlook for iOS/i, /Sent from Outlook for Android/i, /Sent from Yahoo Mail for iPhone(?: \/ Android)?/i, /Sent from Yahoo Mail for Android/i, /Sent from my Amazon Fire/i, /Get\s+Outlook\s+for\s+iOS/i, /Get\s+Outlook\s+for\s+Android/i, /Sent with Proton Mail secure email\./i ].map(re => new RegExp(`\\n*${re.source}\\s*`, 'i')); function stripMobileFooter(text) { if (!text) return text; let result = text; for (const rx of MOBILE_FOOTER_REGEXES) { result = result.replace(rx, ''); } return result; } // --- EMAIL HELPERS --- function extractRawEmail(headerValue) { const match = headerValue.match(/<([^>]+)>/); return match ? match[1].trim() : headerValue.trim(); } // --- GAME DETECTION --- // Map built once at module load so detectGame // doesn't allocate a fresh RegExp per game/alias per call. const GAME_DETECTION = (() => { const m = new Map(); const add = (key, canonical) => { const lower = String(key).toLowerCase(); if (m.has(lower)) return; m.set(lower, { canonical, re: new RegExp(`\\b${escapeRegex(lower)}\\b`, 'i') }); }; for (const game of GAME_NAMES) add(game, game); for (const [alias, fullName] of Object.entries(GAME_ALIASES)) add(alias, fullName); return m; })(); const detectGame = (subject, body) => { const txt = `${subject} ${body}`.toLowerCase(); for (const { re, canonical } of GAME_DETECTION.values()) { if (re.test(txt)) return canonical; } return 'Not Mentioned'; }; // --- PRIORITY --- function getPriorityEmoji(priority) { switch (priority) { case 'high': return CONFIG.PRIORITY_HIGH_EMOJI; case 'low': return CONFIG.PRIORITY_LOW_EMOJI; case 'normal': case 'medium': default: return CONFIG.PRIORITY_MEDIUM_EMOJI; } } // --- TEMPLATE VARIABLES --- function replaceVariables(template, context = {}) { if (!template) return ''; let result = template; if (context.ticket) { result = result.replace(/{ticket\.user}/g, context.ticket.sender_name || 'Unknown'); result = result.replace(/{ticket\.creator}/g, context.ticket.sender_name || 'Unknown'); result = result.replace(/{ticket\.email}/g, context.ticket.senderEmail || ''); result = result.replace(/{ticket\.number}/g, context.ticket.ticketNumber != null ? context.ticket.ticketNumber : 'N/A'); result = result.replace(/{ticket\.subject}/g, context.ticket.subject || 'No subject'); result = result.replace(/{ticket\.claimed}/g, context.ticket.claimedBy ? 'Yes' : 'No'); result = result.replace(/{ticket\.claimedby}/g, context.ticket.claimedBy || 'Unclaimed'); result = result.replace(/{ticket\.priority}/g, context.ticket.priority || 'normal'); result = result.replace(/{ticket\.id}/g, context.ticket.gmailThreadId || ''); } if (context.staff) { result = result.replace(/{staff\.user}/g, context.staff.username || ''); result = result.replace(/{staff\.name}/g, context.staff.displayName || context.staff.username || ''); result = result.replace(/{staff\.mention}/g, context.staff.mention || ''); } if (context.guild) { result = result.replace(/{server\.name}/g, context.guild.name || ''); result = result.replace(/{server\.membercount}/g, context.guild.memberCount?.toString() || '0'); } if (context.hours !== undefined) { result = result.replace(/{hours}/g, context.hours.toString()); } const now = new Date(); result = result.replace(/{date}/g, now.toLocaleDateString()); result = result.replace(/{time}/g, now.toLocaleTimeString()); return result; } /** Sanitize user input for safe embedding in Discord code blocks. */ function sanitizeEmbedText(str) { if (str == null) return ''; return String(str).replace(/```/g, "'''").trim(); } // --- EMBED TRUNCATION --- /** Truncate a string for use as an embed description (max 4096). */ function truncateEmbedDescription(str, max = 4096) { if (str == null) return ''; const s = String(str); return s.length > max ? s.slice(0, max - 3) + '...' : s; } module.exports = { sanitizeEmbedText, truncateEmbedDescription, escapeHtml, safeEqual, isStaff, htmlToTextWithBlocks, getCleanBody, stripEmailQuotes, stripMobileFooter, extractRawEmail, detectGame, getPriorityEmoji, replaceVariables };