Files
broccolini-bot/utils.js
indifferentketchup 69c247ed1b huge changes
2026-04-07 01:43:06 -05:00

395 lines
11 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 { CONFIG, GAME_NAMES, GAME_ALIASES, TICKET_TAGS } = require('./config');
// --- 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();
}
function stripMobileFooter(text) {
if (!text) return text;
const patterns = [
/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
];
let result = text;
for (const re of patterns) {
const rx = new RegExp(`\\n*${re.source}\\s*`, 'i');
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 ---
const detectGame = (subject, body) => {
const txt = `${subject} ${body}`.toLowerCase();
for (const game of GAME_NAMES) {
const g = game.toLowerCase();
const re = new RegExp(`\\b${escapeRegex(g)}\\b`, 'i');
if (re.test(txt)) return game;
}
for (const [alias, fullName] of Object.entries(GAME_ALIASES)) {
const a = alias.toLowerCase();
const re = new RegExp(`\\b${escapeRegex(a)}\\b`, 'i');
if (re.test(txt)) return fullName;
}
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;
}
}
/** Returns emoji for a ticket-tag key (e.g. server-down → ⬇️). Priority always comes first in channel name, then tag. */
function getTicketTagEmoji(tagKey) {
if (!tagKey) return '';
const t = (TICKET_TAGS || []).find(x => x.value === tagKey);
return t ? t.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 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,
decodeHtmlEntities,
htmlToTextWithBlocks,
decodeGmailData,
getCleanBody,
stripEmailQuotes,
stripMobileFooter,
extractRawEmail,
getFormattedDate,
detectGame,
getPriorityEmoji,
getPriorityColor,
getTicketTagEmoji,
replaceVariables
};