/** * 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(); } // --- 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. 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; } } 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 = {}) { 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 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 ''; const s = String(str); return s.length > max ? s.slice(0, max - 3) + '...' : s; } /** * Enforce the 6 000 char total embed limit across an array of EmbedBuilder * instances. Mutates in place: trims the largest description first, then * largest field values, until the total is under 6 000 chars. * Returns the same array for chaining. */ function enforceEmbedLimit(embeds) { const charCount = (e) => { const d = e.data || {}; let total = 0; if (d.title) total += d.title.length; if (d.description) total += d.description.length; if (d.footer?.text) total += d.footer.text.length; if (d.author?.name) total += d.author.name.length; if (d.fields) { for (const f of d.fields) { if (f.name) total += f.name.length; if (f.value) total += f.value.length; } } return total; }; const LIMIT = 6000; const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0); // Trim largest descriptions first while (totalChars() > LIMIT) { let largestIdx = -1; let largestLen = 0; for (let i = 0; i < embeds.length; i++) { const desc = embeds[i].data?.description; if (desc && desc.length > largestLen) { largestLen = desc.length; largestIdx = i; } } if (largestIdx === -1 || largestLen <= 4) break; const excess = totalChars() - LIMIT; const newLen = Math.max(1, largestLen - excess - 3); embeds[largestIdx].setDescription( embeds[largestIdx].data.description.slice(0, newLen) + '...' ); if (totalChars() <= LIMIT) break; // If still over, loop will pick next largest } // Trim largest field values while (totalChars() > LIMIT) { let targetEmbed = null; let targetFieldIdx = -1; let targetLen = 0; for (const e of embeds) { const fields = e.data?.fields || []; for (let fi = 0; fi < fields.length; fi++) { if (fields[fi].value && fields[fi].value.length > targetLen) { targetLen = fields[fi].value.length; targetEmbed = e; targetFieldIdx = fi; } } } if (!targetEmbed || targetLen <= 4) break; const excess = totalChars() - LIMIT; const newLen = Math.max(1, targetLen - excess - 3); targetEmbed.data.fields[targetFieldIdx].value = targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...'; } return 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 };