QUAL-004 handlers/messages.js — DM-on-customer-reply now reads guild.members.cache.get(claimerId) first and only falls back to guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff reply on busy tickets. GuildMembers intent already keeps the cache warm. QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js (finalizeForceClose) — close paths now $unset welcomeMessageId alongside the status: 'closed' write. Stops a stale message-ID from carrying into a future reopen on the same Gmail thread, where escalation's "edit welcome buttons" path would silently fail trying to fetch a message in a deleted channel. QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now includes the missing/extra key sets, not just count vs count. Saves the operator from guessing which key vanished after a partial write. QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match loop with an earliest-match-across-all-markers scan. The previous code could truncate at a late "_____" signature underline even when an earlier "On X wrote:" reply header was the real cutoff. New test in tests/utils.test.js exercises the dual-marker case. QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer / appReady` declarations from after the ready handler to before it. Same runtime behavior (module-load completes before ready fires asynchronously), but the read order now matches the assignment order. SEC-002 routes/internalApi.js — POST /restart now goes through a tighter 2/min limiter on top of the shared 10/min internalLimiter. Defense in depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret can no longer crash-loop the container. Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already idempotent — the audit finding was incorrect). vitest run: 88/88 (one new test for QUAL-008).
289 lines
8.5 KiB
JavaScript
289 lines
8.5 KiB
JavaScript
/**
|
||
* 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, '"')
|
||
.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(/<br\s*\/?>/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');
|
||
|
||
// Pick the earliest match across all markers, not just the first marker that
|
||
// matches anywhere. The previous order-dependent loop could truncate at a
|
||
// late "_____" signature underline even when an earlier "On X wrote:" reply
|
||
// header was the real cutoff.
|
||
const markers = [
|
||
/\nOn .* wrote:/i,
|
||
/\nFrom:\s.*<.*@.*>/i,
|
||
/\nSent:\s.*$/i,
|
||
/\nTo:\s.*$/i,
|
||
/\nSubject:\s.*$/i,
|
||
/\n_{5,}\s*$/m
|
||
];
|
||
|
||
let earliest = -1;
|
||
for (const m of markers) {
|
||
const match = cleaned.match(m);
|
||
if (match && (earliest === -1 || match.index < earliest)) {
|
||
earliest = match.index;
|
||
}
|
||
}
|
||
if (earliest !== -1) {
|
||
cleaned = cleaned.substring(0, earliest);
|
||
}
|
||
|
||
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<lowercase-alias, { canonical, re }> 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
|
||
};
|