- Remove no-op log stubs (logGmail, logAutomation, logSecurity, logSystem) and ~17 callsites; dead counters in tickets.js and gmail-poll.js go too - Dedup three near-identical Gmail send paths into sendThreadedEmail helper - Drop dead Mongoose fields: broccoliniTicketId, lastSyncedBroccoliniArticleId, renameCount, renameWindowStart, reminderSent, staffChannelId, unclaimedRemindersSent, lastMessageAuthorIsStaff - Drop dead config fields and their .env.example entries - Inline api/botClient.js (3-line wrapper, 2 callers) - Trim unused exports across utils.js, tickets.js, configSchema.js, debugLog.js - Fix handlers/messages.js to use isStaff() — old partial check ignored ADDITIONAL_STAFF_ROLES, so those members were treated as customers - Drop unused deps p-queue + dotenv-expand; move mongodb to devDependencies Net: -583 LOC source + -57 LOC lockfile. All 23 modules load clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
356 lines
10 KiB
JavaScript
356 lines
10 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');
|
||
|
||
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<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;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
truncateEmbedDescription,
|
||
enforceEmbedLimit,
|
||
escapeHtml,
|
||
safeEqual,
|
||
isStaff,
|
||
htmlToTextWithBlocks,
|
||
getCleanBody,
|
||
stripEmailQuotes,
|
||
stripMobileFooter,
|
||
extractRawEmail,
|
||
detectGame,
|
||
getPriorityEmoji,
|
||
replaceVariables
|
||
};
|