Files
broccolini-bot/utils.js
indifferentketchup 840b6bfcf8 simplify: prune dead code, dedup gmail send, drop neutered log stubs
- 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>
2026-05-07 18:37:14 +00:00

356 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function decodeHtmlEntities(str) {
if (!str) return '';
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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
};