Files
broccolini-bot/gmail-poll.js
indifferentketchup 61e8ea32e1 Post inbound email attachments to the ticket channel
fetchMessageAttachments downloads a Gmail message's downloadable parts as
discord.js file descriptors, skipping parts over Discord's 25 MB ceiling and
capping at 10 files per message. Nameless inline parts (CID screenshots) get a
synthesized name; nameless text/* parts (the email body Gmail serves as an
attachmentId-backed part) are skipped. The poll posts these on both new tickets
and follow-ups, naming any skipped parts so staff know to check Gmail.
2026-06-05 03:08:21 +00:00

500 lines
18 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.
/**
* Gmail polling fetches unread emails and creates/updates Discord ticket channels.
*
* `poll()` is the orchestrator: list → locate guild → for each message,
* parse → look up existing → branch (append-followup vs create-ticket) → mark read.
* Each step delegates to a single-responsibility helper below.
*/
const {
ChannelType,
EmbedBuilder,
PermissionFlagsBits
} = require('discord.js');
const { mongoose, withRetry } = require('./db-connection');
const { CONFIG } = require('./config');
const {
getCleanBody,
extractRawEmail,
stripEmailQuotes,
stripMobileFooter,
detectGame,
sanitizeEmbedText
} = require('./utils');
const { getGmailClient, fetchMessageAttachments } = require('./services/gmail');
const { moveThreadToFolder, autoAdvanceFolder } = require('./services/gmailLabels');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents');
const { recordAction } = require('./services/staffStats');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
let isPolling = false;
let authErrorNotified = false;
let pollSuspended = false;
function setPollSuspended(val) {
pollSuspended = !!val;
if (!pollSuspended) authErrorNotified = false;
}
// ============================================================
// Helpers (extracted from the original 309-line poll()).
// ============================================================
/**
* Pick the guild for this poll iteration. Honors DISCORD_GUILD_ID when set,
* otherwise falls back to the first guild in the cache. Returns null with a
* warning if no usable guild is available; caller should bail.
*/
function locateGuild(client) {
if (CONFIG.DISCORD_GUILD_ID) {
const g = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!g) {
console.warn('Configured guild not found for DISCORD_GUILD_ID:', CONFIG.DISCORD_GUILD_ID);
}
return g || null;
}
const g = client.guilds.cache.first();
if (!g) {
console.warn('No guilds in cache; skipping poll iteration.');
}
return g || null;
}
/**
* Parse a Gmail message payload into normalized fields.
*
* Body cleanup runs twice with different rules:
* - firstBody: aggressive — strip quotes if it looks like a reply, strip
* mobile footers, collapse newlines. Used as the first message in a new
* ticket channel where we want only the user's actual message.
* - followupBody: defensive — strip quotes but fall back to raw text if
* stripping leaves nothing. Used for follow-up posts on an existing thread.
*/
// Shared final cleanup for both the first-message and follow-up body paths:
// drop the "Get Outlook for ..." mobile-signature line, strip a dangling
// trailing "<" left by truncated HTML, and trim.
function finalizeBody(text) {
return text
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
}
function parseGmailMessage(email) {
const headers = email.data.payload.headers;
const from = headers.find(h => h.name === 'From')?.value || '';
const isSelf = from.toLowerCase().includes(CONFIG.MY_EMAIL);
const subject = headers.find(h => h.name === 'Subject')?.value || 'New Ticket';
const rawBody = getCleanBody(email.data.payload);
const senderEmail = extractRawEmail(from).toLowerCase();
const senderName = (from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
?.replace(/"/g, '')
.trim() || 'Unknown';
const hasReplyHeaderFrom = /(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
const looksLikeReply = /\nOn .+wrote:/i.test(rawBody) || hasReplyHeaderFrom;
let firstBody = rawBody.replace(/\r\n/g, '\n');
if (looksLikeReply) firstBody = stripEmailQuotes(firstBody);
firstBody = stripMobileFooter(firstBody);
firstBody = firstBody.replace(/^\s*\n+/g, '');
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
firstBody = finalizeBody(firstBody);
const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText);
if (!followupBody.trim()) followupBody = rawText;
followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody);
followupBody = finalizeBody(followupBody);
return {
isSelf,
threadId: email.data.threadId,
from,
subject,
rawBody,
senderEmail,
senderName,
firstBody,
followupBody
};
}
/**
* Resolve the parent category and create a fresh ticket channel under it.
* Returns { channel, parentCategoryId } on success, or null on failure (caller
* should mark the message read and skip — same behavior as the original inline path).
*/
async function findOrCreateTicketChannel(guild, parsed, number) {
const creatorNickname = getSenderLocal(parsed.senderEmail);
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
let parentCategoryId;
try {
parentCategoryId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('Channel create error (payload):', {
message: err.message,
code: err.code,
rawError: err.rawError
});
return null;
}
try {
const channel = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentCategoryId
// Permissions are inherited from the ticket category — configure that
// category to deny @everyone View Channel and allow the staff role, so
// tickets stay staff-only. Inheriting (rather than setting per-channel
// overwrites here) means the bot does not need the Manage Roles permission.
});
return { channel, parentCategoryId };
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
return null;
}
}
/**
* Post links + attachments for prior transcripts of a reopened thread.
* Best-effort: any failure is logged and swallowed so the new ticket flow
* continues unaffected.
*/
async function linkPreviousTranscripts(ticketChan, threadId, client) {
try {
const transcriptRows = await Transcript.find({ gmailThreadId: threadId })
.sort({ createdAt: 1 })
.select('transcriptMessageId')
.lean();
if (transcriptRows.length === 0) return;
const transcriptChan = await client.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (!transcriptChan) return;
await enqueueSend(
ticketChan,
`This email thread has ${transcriptRows.length} previous transcript(s):`
);
for (const row of transcriptRows) {
const transcriptMsg = await transcriptChan.messages
.fetch(row.transcriptMessageId)
.catch(() => null);
if (!transcriptMsg) continue;
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
const originalAttachment = transcriptMsg.attachments.first();
if (originalAttachment) {
await enqueueSend(ticketChan, {
content: 'Transcript file:',
files: [originalAttachment.url]
});
}
}
} catch (err) {
console.error('Error linking previous transcripts:', err);
}
}
/**
* Best-effort: fetch the email's attachments and post them to the ticket channel.
* Files go out in one enqueued message (up to Discord's 10-file limit); any part
* that is too large or fails to download is named in a follow-up note so staff
* know to check Gmail. Never throws — attachment delivery must not break the
* ticket flow.
*/
async function postEmailAttachments(channel, gmail, email, client) {
try {
const { files, skipped } = await fetchMessageAttachments(email.data.id, email.data.payload, gmail);
if (files.length) {
await enqueueSend(channel, {
content: '**Email attachments:**',
files,
allowedMentions: { parse: [] }
});
}
if (skipped.length) {
await enqueueSend(channel, {
content: `⚠️ ${skipped.length} attachment(s) could not be posted (too large or failed to download) — check Gmail: ${skipped.map(s => `\`${s}\``).join(', ')}`,
allowedMentions: { parse: [] }
});
}
} catch (err) {
logError('postEmailAttachments', err, null, client).catch(() => {});
}
}
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
async function markGmailMessageRead(gmail, msgRef) {
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
}
/**
* If the error indicates a permanent OAuth-grant failure (invalid_grant /
* invalid_client), suspend polling, clear the recurring poll interval, log,
* and DM the admin once. Returns true iff polling was suspended (caller
* should not treat as a transient retry-on-next-tick error).
*
* Transient 401/403/429/5xx and network errors are NOT considered permanent —
* they fall through to the next interval naturally. The OAuth code lives on
* `err.response.data.error`, not the message string.
*/
function oauthSuspendIfPermanent(err, client) {
const oauthError = err && err.response && err.response.data && err.response.data.error;
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
if (!isPermanentAuth) return false;
pollSuspended = true;
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
console.error('[gmail-poll]', suspendMsg);
logError('Gmail OAuth', { message: suspendMsg, stack: err.stack || err.message || String(err) }, null, client).catch(() => {});
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
if (CONFIG.ADMIN_ID && !authErrorNotified) {
authErrorNotified = true;
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
}
return true;
}
// ============================================================
// Email ticket persistence (Part A: game; Part B: reopen recording)
// ============================================================
/**
* Upsert the email ticket record and, when wasReopened is true, fire-and-forget
* a 'reopen' StaffAction with resolverId = the prior claimerId from the
* returned doc (claimerId is never cleared by any close path).
*
* Injectables: _Ticket (Ticket model), _recordAction (staffStats.recordAction).
* Exported for unit testing.
*/
async function persistEmailTicket(fields, guildId, wasReopened, _Ticket, _recordAction) {
const {
threadId, discordThreadId, senderEmail, subject, createdAt,
ticketNumber, priority, parentCategoryId, game
} = fields;
const doc = await withRetry(() => _Ticket.findOneAndUpdate(
{ gmailThreadId: threadId },
{
$set: {
discordThreadId,
senderEmail,
subject,
createdAt,
status: 'open',
ticketNumber,
priority,
lastActivity: createdAt,
parentCategoryId,
game
}
},
{ upsert: true, new: true }
));
if (wasReopened && doc) {
_recordAction('system', 'reopen', {
ticket: doc,
guildId,
resolverId: doc.claimerId ?? null
});
}
return doc;
}
// ============================================================
// Orchestrator
// ============================================================
/**
* Poll Gmail for unread primary-inbox messages and route them to Discord.
* @param {import('discord.js').Client} client
*/
async function poll(client) {
if (isPolling || pollSuspended) return;
isPolling = true;
try {
console.log('Running poll()...');
try {
const gmail = getGmailClient();
const list = await gmail.users.messages.list({
userId: 'me',
q: 'is:unread in:inbox'
});
if (!list.data.messages) return;
const guild = locateGuild(client);
if (!guild) return;
for (const msgRef of list.data.messages) {
const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
const parsed = parseGmailMessage(email);
if (parsed.isSelf) {
await markGmailMessageRead(gmail, msgRef);
continue;
}
const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
.select('gmailThreadId discordThreadId status')
.lean();
const wasClosedTicket = !!existing && existing.status === 'closed';
let ticketChan = null;
let parentCategoryIdForTicket = null;
let isReopened = false;
if (existing && existing.discordThreadId) {
ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null);
} else if (existing && existing.status === 'closed') {
isReopened = true;
}
if (ticketChan) {
// Append follow-up to existing channel.
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
// No staff role ping; body is attacker-controlled email content — suppress all mentions.
await enqueueSend(ticketChan, {
content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: [] }
});
await postEmailAttachments(ticketChan, gmail, email, client);
// Customer responded → advance the thread to Needs Response. A
// successful move strips INBOX+UNREAD (archives + marks read like
// markGmailMessageRead did). If the thread is manually filed (For Jake,
// Spam, …) autoAdvanceFolder leaves it put and returns false — or the
// move may fail — so in either case fall back to marking just the new
// message read, preserving the manual filing and avoiding reprocessing.
let advanced = false;
try {
advanced = await autoAdvanceFolder(parsed.threadId, 'NEEDS_RESPONSE', gmail);
} catch (err) {
logError('autoAdvanceFolder(NEEDS_RESPONSE)', err, null, client).catch(() => {});
}
if (!advanced) {
console.log('Archiving/reading Gmail message', msgRef.id);
await markGmailMessageRead(gmail, msgRef);
}
} else {
// Create a new ticket channel.
const limitCheck = await checkTicketLimits(parsed.senderEmail);
if (!limitCheck.ok) {
console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
await markGmailMessageRead(gmail, msgRef);
continue;
}
const { number } = await getNextTicketNumber(parsed.senderEmail);
const created = await findOrCreateTicketChannel(guild, parsed, number);
if (!created) {
await markGmailMessageRead(gmail, msgRef);
continue;
}
ticketChan = created.channel;
parentCategoryIdForTicket = created.parentCategoryId;
const detectedGame = detectGame(parsed.subject, parsed.rawBody);
const buttons = getTicketActionRow({ escalationTier: 0 });
const ticketInfoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields(
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false },
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false },
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
);
const welcomeMsg = await enqueueSend(ticketChan, {
embeds: [ticketInfoEmbed],
components: [buttons],
allowedMentions: { parse: [] }
});
const { createStaffThread } = require('./services/staffThread');
await createStaffThread(ticketChan, client).catch(() => {});
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
const { pinMessage } = require('./services/pinMessage');
await pinMessage(welcomeMsg, client).catch(() => {});
}
if (isReopened) {
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
}
// Email body is attacker-controlled — no mentions may fire from its content.
const truncated = parsed.firstBody.slice(0, 1900);
await enqueueSend(ticketChan, {
content: `**Message:**\n${truncated}`,
allowedMentions: { parse: [] }
});
await postEmailAttachments(ticketChan, gmail, email, client);
// Welcome message skipped for email tickets the email body speaks for itself.
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
const now = new Date();
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
await persistEmailTicket(
{
threadId: parsed.threadId,
discordThreadId: ticketChan.id,
senderEmail: parsed.senderEmail,
subject: parsed.subject,
createdAt: now,
ticketNumber: number,
priority: defaultPriority,
parentCategoryId: parentCategoryIdForTicket,
game: detectedGame
},
guild.id,
wasClosedTicket,
Ticket,
recordAction
);
// New (or reopened) ticket: file the email thread into Triage — out of
// the inbox, marked read, awaiting staff action. The threads.modify also
// clears UNREAD, so a success archives it like markGmailMessageRead did.
console.log('Filing Gmail thread into Triage', parsed.threadId);
await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail);
}
}
authErrorNotified = false;
} catch (e) {
oauthSuspendIfPermanent(e, client);
console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {});
}
} finally {
isPolling = false;
}
}
module.exports = { poll, setPollSuspended, persistEmailTicket };