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>
This commit is contained in:
@@ -19,8 +19,7 @@
|
||||
|
||||
const ALLOWED_CONFIG_KEYS = new Set([
|
||||
// Ticket settings
|
||||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
|
||||
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
|
||||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID',
|
||||
// Escalation categories
|
||||
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
||||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
||||
@@ -29,12 +28,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'ADMIN_ID',
|
||||
// Channel IDs
|
||||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
||||
'DISCORD_CHANNEL_ID',
|
||||
'RENAME_LOG_CHANNEL_ID',
|
||||
// Messages and labels
|
||||
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
||||
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
||||
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
||||
'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
||||
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
||||
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
||||
// Branding
|
||||
@@ -46,11 +44,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
||||
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||||
// Limits and thresholds
|
||||
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
||||
'GLOBAL_TICKET_LIMIT',
|
||||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
||||
// Embed colors
|
||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
|
||||
]);
|
||||
|
||||
@@ -201,32 +199,7 @@ function getValidator(key) {
|
||||
return VALIDATORS[inferType(key)];
|
||||
}
|
||||
|
||||
// Pre-build per-key validator map for callers that want O(1) lookup
|
||||
// (and for the smoke test / boot log).
|
||||
const ALL_VALIDATORS = {};
|
||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
||||
ALL_VALIDATORS[key] = getValidator(key);
|
||||
}
|
||||
|
||||
// ---------- Startup log (no-op if console.log is suppressed) ----------
|
||||
|
||||
(function logDistribution() {
|
||||
const dist = {};
|
||||
const fallback = [];
|
||||
for (const [key, v] of Object.entries(ALL_VALIDATORS)) {
|
||||
dist[v.type] = (dist[v.type] || 0) + 1;
|
||||
if (v.type === 'string') fallback.push(key);
|
||||
}
|
||||
console.log('[configSchema] type distribution:', JSON.stringify(dist));
|
||||
if (fallback.length) {
|
||||
console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', '));
|
||||
}
|
||||
})();
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_CONFIG_KEYS,
|
||||
VALIDATORS,
|
||||
ALL_VALIDATORS,
|
||||
getValidator,
|
||||
inferType
|
||||
getValidator
|
||||
};
|
||||
|
||||
@@ -58,13 +58,6 @@ async function logWarn(context, message, overrideClient = null) {
|
||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logEvent (generic – posts to any configured channel) ---
|
||||
|
||||
async function logEvent(channelConfigKey, embed, overrideClient = null) {
|
||||
const channelId = CONFIG[channelConfigKey];
|
||||
await sendToChannel(channelId, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logTicketEvent ---
|
||||
|
||||
async function logTicketEvent(action, fields, interaction = null) {
|
||||
@@ -79,46 +72,9 @@ async function logTicketEvent(action, fields, interaction = null) {
|
||||
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
|
||||
}
|
||||
|
||||
// --- logGmail ---
|
||||
|
||||
async function logGmail(...args) { return; }
|
||||
|
||||
// --- logAutomation ---
|
||||
|
||||
async function logAutomation(...args) { return; }
|
||||
|
||||
// --- logSecurity ---
|
||||
|
||||
async function logSecurity(...args) { return; }
|
||||
|
||||
// --- logIntegrity ---
|
||||
|
||||
async function logIntegrity(issue, detail, overrideClient = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket Integrity Issue')
|
||||
.setColor(0xFF0000)
|
||||
.addFields(
|
||||
{ name: 'Issue', value: String(issue).slice(0, 256), inline: false },
|
||||
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logSystem ---
|
||||
|
||||
async function logSystem(...args) { return; }
|
||||
|
||||
module.exports = {
|
||||
setClient,
|
||||
logError,
|
||||
logWarn,
|
||||
logEvent,
|
||||
logTicketEvent,
|
||||
logGmail,
|
||||
logAutomation,
|
||||
logSecurity,
|
||||
logIntegrity,
|
||||
logSystem
|
||||
logTicketEvent
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Gmail service – OAuth client, send reply, send ticket-closed email.
|
||||
* Gmail service – OAuth client, send reply, send ticket-closed/notification emails.
|
||||
*/
|
||||
const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
@@ -56,8 +56,6 @@ function getGmailClient() {
|
||||
*
|
||||
* Throws if the env file is missing the token, or if the probe call (getProfile)
|
||||
* fails — the caller surfaces the error so the UI can see why.
|
||||
*
|
||||
* @returns {Promise<{emailAddress: string}>}
|
||||
*/
|
||||
async function reloadGmailClient() {
|
||||
const envMap = readEnvFile();
|
||||
@@ -74,276 +72,53 @@ async function reloadGmailClient() {
|
||||
return { emailAddress: profile.data.emailAddress };
|
||||
}
|
||||
|
||||
async function sendTicketClosedEmail(ticket, closerName, userId = null) {
|
||||
// Fetch the first message's Subject + Message-ID from a Gmail thread, used to
|
||||
// derive a faithful Re: subject and a proper In-Reply-To/References header.
|
||||
async function fetchThreadSubjectAndMsgId(gmail, threadId) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
// Send to the ticket sender (customer), not derived from thread (which can be support)
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let originalSubject = null;
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const firstMsg = messages[0];
|
||||
if (firstMsg?.payload?.headers) {
|
||||
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) originalSubject = subj;
|
||||
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const baseSubject = originalSubject || ticket.subject || 'Support';
|
||||
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
|
||||
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
|
||||
|
||||
const messageBody = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
|
||||
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
|
||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = signatureBlocks.text;
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(messageBody).replace(/\n/g, '<br>')}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
${buildCompanySigHtml()}
|
||||
</div>`;
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
|
||||
const plainBody = [];
|
||||
plainBody.push(messageBody);
|
||||
if (safeStaffSigText) {
|
||||
plainBody.push('');
|
||||
plainBody.push(safeStaffSigText);
|
||||
}
|
||||
plainBody.push('');
|
||||
plainBody.push(...buildCompanySigText().split('\n'));
|
||||
|
||||
const headers = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId && `In-Reply-To: ${msgId}`,
|
||||
msgId && `References: ${msgId}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from([
|
||||
...headers,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
...plainBody,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody,
|
||||
'',
|
||||
`--${boundary}--`
|
||||
].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket closed email error:', err);
|
||||
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId });
|
||||
const firstMsg = (thread.data.messages || [])[0];
|
||||
const headers = firstMsg?.payload?.headers || [];
|
||||
return {
|
||||
subject: headers.find(h => h.name === 'Subject')?.value || null,
|
||||
msgId: sanitizeHeaderValue(headers.find(h => h.name === 'Message-ID')?.value) || null
|
||||
};
|
||||
} catch (_) {
|
||||
return { subject: null, msgId: null };
|
||||
}
|
||||
}
|
||||
|
||||
// StaffSignature model is registered in models.js; re-import here for use in this file
|
||||
const { mongoose } = require('../db-connection');
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
|
||||
/**
|
||||
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
|
||||
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
|
||||
* @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated")
|
||||
* @param {string} messageBody - Plain or HTML message body
|
||||
* @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord")
|
||||
* @param {string} [userId] - Discord user ID for signature (optional)
|
||||
*/
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let originalSubject = null;
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const firstMsg = messages[0];
|
||||
if (firstMsg?.payload?.headers) {
|
||||
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) originalSubject = subj;
|
||||
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Thread-safety: derive Subject from the last thread message; strip any leading
|
||||
// Re:/RE:/Re : variants and re-prepend a single "Re: ". Fall back to subjectLine
|
||||
// (legacy param) only if the thread lookup gave us nothing.
|
||||
const baseSubject = originalSubject || subjectLine || ticket.subject || 'Support';
|
||||
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
|
||||
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
|
||||
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
|
||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = signatureBlocks.text;
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(messageBody || '').replace(/\n/g, '<br>')}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
${buildCompanySigHtml()}
|
||||
</div>`;
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
|
||||
const plainBody = [];
|
||||
plainBody.push(messageBody || '');
|
||||
if (safeStaffSigText) {
|
||||
plainBody.push('');
|
||||
plainBody.push(safeStaffSigText);
|
||||
}
|
||||
plainBody.push('');
|
||||
plainBody.push(...buildCompanySigText().split('\n'));
|
||||
|
||||
const headers = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId && `In-Reply-To: ${msgId}`,
|
||||
msgId && `References: ${msgId}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from([
|
||||
...headers,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
...plainBody,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody,
|
||||
'',
|
||||
`--${boundary}--`
|
||||
].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket notification email error:', err);
|
||||
}
|
||||
// Strip leading "Re:" variants and re-prepend a single one, then RFC 2047 encode.
|
||||
function encodeReplySubject(baseSubject) {
|
||||
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
|
||||
const safe = sanitizeHeaderValue(`Re: ${stripped}`);
|
||||
return `=?utf-8?B?${Buffer.from(safe).toString('base64')}?=`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Gmail reply to a ticket
|
||||
* @param {string} threadId - Gmail thread ID
|
||||
* @param {string} replyText - Reply text
|
||||
* @param {string} recipientEmail - Recipient email
|
||||
* @param {string} subject - Subject line
|
||||
* @param {string} messageId - Message ID (optional)
|
||||
* @param {string} userId - Discord user ID for optional personal valediction/tagline (optional)
|
||||
*/
|
||||
async function sendGmailReply(
|
||||
threadId,
|
||||
replyText,
|
||||
recipientEmail,
|
||||
subject,
|
||||
messageId,
|
||||
userId = null
|
||||
) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeRecipient)) {
|
||||
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
const safeMessageId = sanitizeHeaderValue(messageId);
|
||||
const safeSubject = sanitizeHeaderValue(`Re: ${subject}`);
|
||||
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
safeSubject
|
||||
).toString('base64')}?=`;
|
||||
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
|
||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = signatureBlocks.text;
|
||||
// Compose and send a multipart/alternative reply on an existing Gmail thread.
|
||||
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) {
|
||||
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
|
||||
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = sigBlocks.text;
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
|
||||
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
${buildCompanySigHtml()}
|
||||
</div>`;
|
||||
|
||||
const plainBody = [messageText || ''];
|
||||
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
|
||||
plainBody.push('', ...buildCompanySigText().split('\n'));
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
|
||||
const plainBody = [];
|
||||
plainBody.push(replyText);
|
||||
if (safeStaffSigText) {
|
||||
plainBody.push('');
|
||||
plainBody.push(safeStaffSigText);
|
||||
}
|
||||
plainBody.push('');
|
||||
plainBody.push(...buildCompanySigText().split('\n'));
|
||||
|
||||
const headers = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${safeRecipient}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
safeMessageId && `In-Reply-To: ${safeMessageId}`,
|
||||
safeMessageId && `References: ${safeMessageId}`,
|
||||
`To: ${recipient}`,
|
||||
`Subject: ${encodedSubject}`,
|
||||
msgId && `In-Reply-To: ${msgId}`,
|
||||
msgId && `References: ${msgId}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
].filter(Boolean);
|
||||
@@ -366,9 +141,92 @@ async function sendGmailReply(
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId }
|
||||
await gmail.users.messages.send({ userId: 'me', requestBody: { raw, threadId } });
|
||||
}
|
||||
|
||||
// Resolve and validate a customer recipient from a ticket's senderEmail.
|
||||
// Returns null and logs if invalid or self-addressed.
|
||||
function resolveCustomerRecipient(ticket, context) {
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return null;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError(`${context}: invalid recipient`, new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return recipientEmail;
|
||||
}
|
||||
|
||||
async function sendTicketClosedEmail(ticket, closerName, userId = null) {
|
||||
try {
|
||||
const recipient = resolveCustomerRecipient(ticket, 'sendTicketClosedEmail');
|
||||
if (!recipient) return;
|
||||
|
||||
const gmail = getGmailClient();
|
||||
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
||||
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
|
||||
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
|
||||
|
||||
await sendThreadedEmail(gmail, {
|
||||
threadId: ticket.gmailThreadId,
|
||||
recipient,
|
||||
encodedSubject,
|
||||
msgId,
|
||||
messageText,
|
||||
userId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket closed email error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
|
||||
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
|
||||
* @param {string} subjectLine - Fallback subject if the thread can't be queried
|
||||
* @param {string} messageBody - Plain or HTML message body
|
||||
* @param {string} [userId] - Discord user ID for signature (optional)
|
||||
*/
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) {
|
||||
try {
|
||||
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
|
||||
if (!recipient) return;
|
||||
|
||||
const gmail = getGmailClient();
|
||||
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
||||
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support');
|
||||
|
||||
await sendThreadedEmail(gmail, {
|
||||
threadId: ticket.gmailThreadId,
|
||||
recipient,
|
||||
encodedSubject,
|
||||
msgId,
|
||||
messageText: messageBody,
|
||||
userId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket notification email error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId
|
||||
* (typically pulled from the latest non-self message in the thread).
|
||||
*/
|
||||
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) {
|
||||
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeRecipient)) {
|
||||
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
const gmail = getGmailClient();
|
||||
await sendThreadedEmail(gmail, {
|
||||
threadId,
|
||||
recipient: safeRecipient,
|
||||
encodedSubject: encodeReplySubject(subject || 'Support'),
|
||||
msgId: sanitizeHeaderValue(messageId) || null,
|
||||
messageText: replyText,
|
||||
userId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* Ticket database helpers – counters, rename, limits, auto-close,
|
||||
* reminders, auto-unclaim, channel creation.
|
||||
*/
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
const { ChannelType } = require('discord.js');
|
||||
const { mongoose, withRetry } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { getPriorityEmoji } = require('../utils');
|
||||
const { logAutomation } = require('../services/debugLog');
|
||||
const { enqueueSend, enqueueDelete } = require('./channelQueue');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
@@ -30,9 +28,6 @@ async function getNextTicketNumber(senderEmail) {
|
||||
// primary bot's 2/10min per-channel budget here; 429s from the secondary
|
||||
// bot surface via utils/renamer.js instead.
|
||||
|
||||
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
|
||||
const RENAME_LIMIT = 2;
|
||||
|
||||
function getSenderLocal(senderEmail) {
|
||||
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
|
||||
}
|
||||
@@ -90,16 +85,6 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
|
||||
}
|
||||
}
|
||||
|
||||
// Retained for external callers (bOSScord, scripts). The gate now lives in
|
||||
// the secondary bot's rate bucket; this helper no longer touches Mongo.
|
||||
async function canRename(_ticket) {
|
||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||
}
|
||||
|
||||
function minutesFromMs(ms) {
|
||||
return Math.max(1, Math.ceil(ms / 60000));
|
||||
}
|
||||
|
||||
// --- RATE LIMIT (per-user ticket creation) ---
|
||||
|
||||
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
|
||||
@@ -156,15 +141,6 @@ function escapeCategoryNameForRegex(name) {
|
||||
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getOrCreateTicketCategory instead.
|
||||
* @returns {null}
|
||||
*/
|
||||
function pickTicketCategoryId(guild, categoryIds) {
|
||||
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
|
||||
return null;
|
||||
}
|
||||
|
||||
function countChannelsInCategory(guild, categoryId) {
|
||||
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
|
||||
}
|
||||
@@ -272,52 +248,6 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||
} catch (e) {
|
||||
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||||
throw new Error('Ticket category not found or could not be allocated');
|
||||
}
|
||||
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: userId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('guild.channels.create (createTicketChannel):', e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
// --- LIMITS & PERMISSIONS ---
|
||||
|
||||
async function checkTicketLimits(senderEmail) {
|
||||
@@ -334,22 +264,12 @@ async function checkTicketLimits(senderEmail) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function hasBlacklistedRole(member) {
|
||||
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return member.roles.cache.some(role =>
|
||||
CONFIG.BLACKLISTED_ROLES.includes(role.id)
|
||||
);
|
||||
}
|
||||
|
||||
// --- ACTIVITY ---
|
||||
|
||||
async function updateTicketActivity(gmailThreadId) {
|
||||
const now = new Date();
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId },
|
||||
{ $set: { lastActivity: now, reminderSent: false } }
|
||||
{ $set: { lastActivity: new Date() } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,9 +286,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
lastActivity: { $lt: cutoffTime, $ne: null }
|
||||
}).sort({ createdAt: 1 }).limit(500).lean());
|
||||
|
||||
let checked = 0, closed = 0;
|
||||
for (const ticket of staleTickets) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
@@ -395,53 +313,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
)).catch(() => {});
|
||||
}).catch(() => {});
|
||||
}, 5000);
|
||||
closed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function checkReminders(client) {
|
||||
if (!CONFIG.REMINDER_ENABLED) return;
|
||||
|
||||
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
|
||||
status: 'open',
|
||||
lastActivity: { $lt: reminderTime, $ne: null },
|
||||
reminderSent: false
|
||||
}).lean());
|
||||
|
||||
let checked = 0, reminded = 0;
|
||||
for (const ticket of ticketsNeedingReminder) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
const ping = ticket.claimedBy
|
||||
? `<@${ticket.claimedBy}>`
|
||||
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
|
||||
const message = CONFIG.REMINDER_MESSAGE
|
||||
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
|
||||
.replace(/\{ping\}/g, ping);
|
||||
await enqueueSend(channel, message);
|
||||
|
||||
await withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { reminderSent: true } }
|
||||
));
|
||||
reminded++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function checkAutoUnclaim(client) {
|
||||
@@ -454,9 +330,7 @@ async function checkAutoUnclaim(client) {
|
||||
lastActivity: { $lt: unclaimTime, $ne: null }
|
||||
}).lean());
|
||||
|
||||
let checked = 0, unclaimed = 0;
|
||||
for (const ticket of staleClaimedTickets) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
@@ -473,18 +347,16 @@ async function checkAutoUnclaim(client) {
|
||||
);
|
||||
|
||||
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
|
||||
unclaimed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function reconcileDeletedTicketChannels(client) {
|
||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
|
||||
if (!guild) return { checked: 0, reconciled: 0 };
|
||||
if (!guild) return;
|
||||
|
||||
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
|
||||
const openTickets = await Ticket.find({
|
||||
@@ -492,9 +364,7 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
discordThreadId: { $ne: null }
|
||||
}).sort({ createdAt: 1 }).limit(500).lean();
|
||||
|
||||
let checked = 0, reconciled = 0;
|
||||
for (const ticket of openTickets) {
|
||||
checked++;
|
||||
try {
|
||||
let channel = guild.channels.cache.get(ticket.discordThreadId);
|
||||
if (!channel) {
|
||||
@@ -505,17 +375,11 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { status: 'closed', discordThreadId: null } }
|
||||
);
|
||||
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
|
||||
reconciled++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
|
||||
}
|
||||
}
|
||||
if (reconciled > 0) {
|
||||
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
|
||||
}
|
||||
return { checked, reconciled };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -525,8 +389,7 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
*/
|
||||
async function resumePendingDeletes(client) {
|
||||
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
|
||||
if (!pending.length) return 0;
|
||||
let resumed = 0;
|
||||
if (!pending.length) return;
|
||||
for (const ticket of pending) {
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
@@ -534,7 +397,6 @@ async function resumePendingDeletes(client) {
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
enqueueDelete(channel).catch(() => {});
|
||||
resumed++;
|
||||
}
|
||||
}
|
||||
Ticket.updateOne(
|
||||
@@ -545,33 +407,22 @@ async function resumePendingDeletes(client) {
|
||||
console.error('resumePendingDeletes error:', e);
|
||||
}
|
||||
}
|
||||
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
|
||||
return resumed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNextTicketNumber,
|
||||
getOrCreateTicketCategory,
|
||||
cleanupEmptyOverflowCategory,
|
||||
RENAME_WINDOW_MS,
|
||||
RENAME_LIMIT,
|
||||
getSenderLocal,
|
||||
toDiscordSafeName,
|
||||
resolveCreatorNickname,
|
||||
makeTicketName,
|
||||
canRename,
|
||||
minutesFromMs,
|
||||
checkTicketCreationRateLimit,
|
||||
createTicketChannel,
|
||||
checkTicketLimits,
|
||||
hasBlacklistedRole,
|
||||
updateTicketActivity,
|
||||
checkAutoClose,
|
||||
checkReminders,
|
||||
checkAutoUnclaim,
|
||||
reconcileDeletedTicketChannels,
|
||||
resumePendingDeletes,
|
||||
startTicketsSweeps,
|
||||
sweepTicketCreationByUser,
|
||||
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
|
||||
startTicketsSweeps
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user