audit week 2 [VIBE-001]: decompose gmail-poll.js poll()
Split the original 309-line poll() into single-responsibility helpers and a thin orchestrator. No behavior change — every Gmail API call, Discord call, DB write, and log line stays in the same order with the same arguments. Helpers extracted (module-private): - locateGuild(client) — DISCORD_GUILD_ID lookup with fallback warning. - parseGmailMessage(email) — header parsing, body decode, dual cleanup (firstBody for new-ticket message, followupBody for thread append). - findOrCreateTicketChannel(guild, parsed, number) — category resolution + channel.create with the existing two-stage error handling. - linkPreviousTranscripts(ticketChan, threadId, client) — best-effort prior-transcript link on reopen. - markGmailMessageRead(gmail, msgRef) — wraps the batchModify call used in five places across the original. - oauthSuspendIfPermanent(err, client) — invalid_grant/invalid_client classify, suspend polling, clear interval, DM admin once. Returns bool. poll() is now the orchestrator: list → locate guild → for each message, parse → look up existing ticket → branch (append-followup vs new-ticket flow) → mark read. The new-ticket branch stays inline in poll() per the "keep poll() as orchestration" intent.
This commit is contained in:
458
gmail-poll.js
458
gmail-poll.js
@@ -1,5 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Gmail polling – fetches unread emails and creates/updates Discord ticket channels.
|
* 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 {
|
const {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
@@ -35,6 +39,213 @@ function setPollSuspended(val) {
|
|||||||
}
|
}
|
||||||
function isPollSuspended() { return pollSuspended; }
|
function isPollSuspended() { return pollSuspended; }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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.
|
||||||
|
*/
|
||||||
|
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 = firstBody
|
||||||
|
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||||
|
.replace(/<\s*$/gm, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
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 = followupBody
|
||||||
|
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||||
|
.replace(/<\s*$/gm, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Orchestrator
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll Gmail for unread primary-inbox messages and route them to Discord.
|
* Poll Gmail for unread primary-inbox messages and route them to Discord.
|
||||||
* @param {import('discord.js').Client} client
|
* @param {import('discord.js').Client} client
|
||||||
@@ -52,88 +263,19 @@ async function poll(client) {
|
|||||||
});
|
});
|
||||||
if (!list.data.messages) return;
|
if (!list.data.messages) return;
|
||||||
|
|
||||||
let guild;
|
const guild = locateGuild(client);
|
||||||
if (CONFIG.DISCORD_GUILD_ID) {
|
if (!guild) return;
|
||||||
guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
|
||||||
if (!guild) {
|
|
||||||
console.warn(
|
|
||||||
'Configured guild not found for DISCORD_GUILD_ID:',
|
|
||||||
CONFIG.DISCORD_GUILD_ID
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
guild = client.guilds.cache.first();
|
|
||||||
if (!guild) {
|
|
||||||
console.warn('No guilds in cache; skipping poll iteration.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const msgRef of list.data.messages) {
|
for (const msgRef of list.data.messages) {
|
||||||
const email = await gmail.users.messages.get({
|
const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
|
||||||
userId: 'me',
|
const parsed = parseGmailMessage(email);
|
||||||
id: msgRef.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const from =
|
if (parsed.isSelf) {
|
||||||
email.data.payload.headers.find(h => h.name === 'From')
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
?.value || '';
|
|
||||||
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
|
|
||||||
await gmail.users.messages.batchModify({
|
|
||||||
userId: 'me',
|
|
||||||
requestBody: {
|
|
||||||
ids: [msgRef.id],
|
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subject =
|
const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
|
||||||
email.data.payload.headers.find(h => h.name === 'Subject')
|
|
||||||
?.value || 'New Ticket';
|
|
||||||
const rawBody = getCleanBody(email.data.payload);
|
|
||||||
|
|
||||||
const sEmail = extractRawEmail(from).toLowerCase();
|
|
||||||
const sName =
|
|
||||||
(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 firstBodyText = rawBody.replace(/\r\n/g, '\n');
|
|
||||||
if (looksLikeReply) {
|
|
||||||
firstBodyText = stripEmailQuotes(firstBodyText);
|
|
||||||
}
|
|
||||||
firstBodyText = stripMobileFooter(firstBodyText);
|
|
||||||
firstBodyText = firstBodyText.replace(/^\s*\n+/g, '');
|
|
||||||
firstBodyText = firstBodyText.replace(/\n{3,}/g, '\n\n');
|
|
||||||
firstBodyText = firstBodyText
|
|
||||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
|
||||||
.replace(/<\s*$/gm, '')
|
|
||||||
.trim();
|
|
||||||
const firstBody = firstBodyText;
|
|
||||||
|
|
||||||
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 = followupBody
|
|
||||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
|
||||||
.replace(/<\s*$/gm, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const existing = await Ticket.findOne({ gmailThreadId: email.data.threadId })
|
|
||||||
.select('gmailThreadId discordThreadId status')
|
.select('gmailThreadId discordThreadId status')
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
@@ -142,86 +284,46 @@ async function poll(client) {
|
|||||||
let isReopened = false;
|
let isReopened = false;
|
||||||
|
|
||||||
if (existing && existing.discordThreadId) {
|
if (existing && existing.discordThreadId) {
|
||||||
ticketChan = await guild.channels
|
ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null);
|
||||||
.fetch(existing.discordThreadId)
|
|
||||||
.catch(() => null);
|
|
||||||
} else if (existing && existing.status === 'closed') {
|
} else if (existing && existing.status === 'closed') {
|
||||||
isReopened = true;
|
isReopened = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticketChan) {
|
if (ticketChan) {
|
||||||
const truncatedFollowup = followupBody.slice(0, 1800);
|
// Append follow-up to existing channel.
|
||||||
|
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
|
||||||
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
||||||
await enqueueSend(
|
await enqueueSend(ticketChan, {
|
||||||
ticketChan,
|
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||||||
{
|
|
||||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
|
|
||||||
allowedMentions: { parse: ['roles'] }
|
allowedMentions: { parse: ['roles'] }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Check ticket limits before creating
|
// Create a new ticket channel.
|
||||||
const limitCheck = await checkTicketLimits(sEmail);
|
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
||||||
if (!limitCheck.ok) {
|
if (!limitCheck.ok) {
|
||||||
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
|
console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
|
||||||
await gmail.users.messages.batchModify({
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
userId: 'me',
|
|
||||||
requestBody: {
|
|
||||||
ids: [msgRef.id],
|
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { number } = await getNextTicketNumber(sEmail);
|
const { number } = await getNextTicketNumber(parsed.senderEmail);
|
||||||
const creatorNickname = getSenderLocal(sEmail);
|
const created = await findOrCreateTicketChannel(guild, parsed, number);
|
||||||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
if (!created) {
|
||||||
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
try {
|
|
||||||
const parentId = await getOrCreateTicketCategory(
|
|
||||||
guild,
|
|
||||||
CONFIG.TICKET_CATEGORY_ID,
|
|
||||||
CONFIG.TICKET_CATEGORY_NAME
|
|
||||||
);
|
|
||||||
parentCategoryIdForTicket = parentId;
|
|
||||||
try {
|
|
||||||
ticketChan = await guild.channels.create({
|
|
||||||
name: chanName,
|
|
||||||
type: ChannelType.GuildText,
|
|
||||||
parent: parentId
|
|
||||||
});
|
|
||||||
} catch (createErr) {
|
|
||||||
console.error('Channel create error (email ticket):', createErr);
|
|
||||||
throw createErr;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Channel create error (payload):', {
|
|
||||||
message: err.message,
|
|
||||||
code: err.code,
|
|
||||||
rawError: err.rawError
|
|
||||||
});
|
|
||||||
await gmail.users.messages.batchModify({
|
|
||||||
userId: 'me',
|
|
||||||
requestBody: {
|
|
||||||
ids: [msgRef.id],
|
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
ticketChan = created.channel;
|
||||||
|
parentCategoryIdForTicket = created.parentCategoryId;
|
||||||
|
|
||||||
const detectedGame = detectGame(subject, rawBody);
|
const detectedGame = detectGame(parsed.subject, parsed.rawBody);
|
||||||
|
|
||||||
const buttons = getTicketActionRow({ escalationTier: 0 });
|
const buttons = getTicketActionRow({ escalationTier: 0 });
|
||||||
|
|
||||||
const ticketInfoEmbed = new EmbedBuilder()
|
const ticketInfoEmbed = new EmbedBuilder()
|
||||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false },
|
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false },
|
||||||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
|
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false },
|
||||||
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||||||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||||
@@ -239,66 +341,29 @@ async function poll(client) {
|
|||||||
await pinMessage(welcomeMsg, client).catch(() => {});
|
await pinMessage(welcomeMsg, client).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// On reopen, link previous transcripts
|
|
||||||
if (isReopened) {
|
if (isReopened) {
|
||||||
try {
|
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
|
||||||
const transcriptRows = await Transcript.find({ gmailThreadId: email.data.threadId })
|
|
||||||
.sort({ createdAt: 1 })
|
|
||||||
.select('transcriptMessageId')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
if (transcriptRows.length > 0) {
|
|
||||||
const transcriptChan = await client.channels
|
|
||||||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
if (transcriptChan) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncated = firstBody.slice(0, 1900);
|
|
||||||
// Email body is attacker-controlled — no mentions may fire from its content.
|
// Email body is attacker-controlled — no mentions may fire from its content.
|
||||||
await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } });
|
const truncated = parsed.firstBody.slice(0, 1900);
|
||||||
|
await enqueueSend(ticketChan, {
|
||||||
|
content: `**Message:**\n${truncated}`,
|
||||||
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
|
|
||||||
// Welcome message skipped for email tickets – the email body speaks for itself.
|
// 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.
|
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||||
|
|
||||||
await withRetry(() => Ticket.findOneAndUpdate(
|
await withRetry(() => Ticket.findOneAndUpdate(
|
||||||
{ gmailThreadId: email.data.threadId },
|
{ gmailThreadId: parsed.threadId },
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
discordThreadId: ticketChan.id,
|
discordThreadId: ticketChan.id,
|
||||||
senderEmail: sEmail,
|
senderEmail: parsed.senderEmail,
|
||||||
subject,
|
subject: parsed.subject,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
ticketNumber: number,
|
ticketNumber: number,
|
||||||
@@ -310,36 +375,13 @@ async function poll(client) {
|
|||||||
{ upsert: true, new: true }
|
{ upsert: true, new: true }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||||
await gmail.users.messages.batchModify({
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
userId: 'me',
|
|
||||||
requestBody: {
|
|
||||||
ids: [msgRef.id],
|
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
authErrorNotified = false;
|
authErrorNotified = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Only treat Google-reported permanent-grant failures as reasons to suspend
|
oauthSuspendIfPermanent(e, client);
|
||||||
// the loop. Transient 401/403/429/5xx/network errors fall through to the
|
|
||||||
// next interval tick naturally. The OAuth error codes come back on the
|
|
||||||
// response body, not the message string.
|
|
||||||
const oauthError = e && e.response && e.response.data && e.response.data.error;
|
|
||||||
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
|
|
||||||
|
|
||||||
if (isPermanentAuth) {
|
|
||||||
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: e.stack || e.message || String(e) }, 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(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('POLL ERROR:', e);
|
console.error('POLL ERROR:', e);
|
||||||
logError('Gmail poll', e, null, client).catch(() => {});
|
logError('Gmail poll', e, null, client).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user