Files
broccolini-bot/utils.js
indifferentketchup 2fab3b97bf Remove dead/stale code, dedup close+escalation paths
Dead/stale removals (grep-confirmed no consumers):
- config: drop 9 unread CONFIG keys (ROLE_TO_PING_ID, SIGNATURE,
  REMINDER_*, RENAME_LOG_CHANNEL_ID, SETTINGS_*); remove their
  ALLOWED_CONFIG_KEYS entries and the orphaned settings-site UI fields
- configSchema: delete unreachable json/string_or_json validators
- models: drop unused ticketTag field
- gmail-poll: remove unused isPollSuspended export
- utils: remove dead htmlToTextWithBlocks/decodeHtmlEntities/BLOCK_TAG_REGEX
- internalApi: remove router._allowedKeys (test it served is gone)
- discord client: drop unused GuildPresences privileged intent
- broccolini-discord: remove dormant /api 503 gate (no /api routes)

Fixes:
- context-menu ticket create now uses makeTicketName('unclaimed', ...)
  instead of the contract-violating ticket-<n> name
- drop write-only pending.userId from both close paths

Dedup / simplify:
- new services/transcript.js shares the transcript text/date/header
  builders between the button and force-close paths (had drifted)
- resolveEscalationCategoryId() replaces 3 copies of the category logic
- ticketChannelOverwrites() shares the create-permission array between
  the two interactive ticket-create paths
- finalizeBody() shares the email-cleanup tail in parseGmailMessage
- getTicketActionRow drops its never-passed options arg;
  sendTicketNotificationEmail drops its always-null subjectLine arg
- hoist invariant guild lookup out of the auto-close/unclaim loops
- drop redundant lastActivity write (and now-dead updateTicketActivity)
- /help lists all current commands and the right-click apps
2026-06-02 19:59:14 +00:00

263 lines
7.9 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 ---
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;');
}
// --- 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,
getCleanBody,
stripEmailQuotes,
stripMobileFooter,
extractRawEmail,
detectGame,
getPriorityEmoji,
replaceVariables
};