384 lines
13 KiB
JavaScript
384 lines
13 KiB
JavaScript
/**
|
||
* Gmail polling – fetches unread emails and creates/updates Discord ticket channels.
|
||
*/
|
||
const {
|
||
ChannelType,
|
||
ActionRowBuilder,
|
||
ButtonBuilder,
|
||
ButtonStyle,
|
||
EmbedBuilder
|
||
} = require('discord.js');
|
||
const { mongoose, withRetry } = require('./db-connection');
|
||
const { CONFIG, GAME_NAME_TO_KEY } = require('./config');
|
||
const {
|
||
getCleanBody,
|
||
extractRawEmail,
|
||
stripEmailQuotes,
|
||
stripMobileFooter,
|
||
detectGame,
|
||
enforceEmbedLimit,
|
||
sanitizeEmbedText
|
||
} = require('./utils');
|
||
const { getGmailClient } = require('./services/gmail');
|
||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
||
const { enqueueSend } = require('./services/channelQueue');
|
||
|
||
const Ticket = mongoose.model('Ticket');
|
||
const Transcript = mongoose.model('Transcript');
|
||
|
||
let isPolling = false;
|
||
let authErrorNotified = false;
|
||
let pollSuspended = false;
|
||
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
|
||
|
||
function setPollSuspended(val) {
|
||
pollSuspended = !!val;
|
||
if (!pollSuspended) authErrorNotified = false;
|
||
}
|
||
function isPollSuspended() { return pollSuspended; }
|
||
|
||
/**
|
||
* 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 {
|
||
pollCount++;
|
||
if (pollCount % 10 === 0) {
|
||
if (totalProcessed > 0 || totalSkipped > 0 || totalErrors > 0) {
|
||
logAutomation('Gmail poll summary', null, `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`).catch(() => {});
|
||
}
|
||
pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0;
|
||
}
|
||
console.log('Running poll()...');
|
||
try {
|
||
const gmail = getGmailClient();
|
||
const list = await gmail.users.messages.list({
|
||
userId: 'me',
|
||
q: 'is:unread category:primary'
|
||
});
|
||
if (!list.data.messages) return;
|
||
|
||
let guild;
|
||
if (CONFIG.DISCORD_GUILD_ID) {
|
||
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) {
|
||
const email = await gmail.users.messages.get({
|
||
userId: 'me',
|
||
id: msgRef.id
|
||
});
|
||
|
||
const from =
|
||
email.data.payload.headers.find(h => h.name === 'From')
|
||
?.value || '';
|
||
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
|
||
totalSkipped++;
|
||
await gmail.users.messages.batchModify({
|
||
userId: 'me',
|
||
requestBody: {
|
||
ids: [msgRef.id],
|
||
removeLabelIds: ['UNREAD', 'INBOX']
|
||
}
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const subject =
|
||
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')
|
||
.lean();
|
||
|
||
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) {
|
||
const truncatedFollowup = followupBody.slice(0, 1800);
|
||
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
||
await enqueueSend(
|
||
ticketChan,
|
||
{
|
||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
|
||
allowedMentions: { parse: ['roles'] }
|
||
}
|
||
);
|
||
} else {
|
||
// Check ticket limits before creating
|
||
const limitCheck = await checkTicketLimits(sEmail);
|
||
if (!limitCheck.ok) {
|
||
totalSkipped++;
|
||
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
|
||
await gmail.users.messages.batchModify({
|
||
userId: 'me',
|
||
requestBody: {
|
||
ids: [msgRef.id],
|
||
removeLabelIds: ['UNREAD', 'INBOX']
|
||
}
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const { number } = await getNextTicketNumber(sEmail);
|
||
const creatorNickname = getSenderLocal(sEmail);
|
||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||
|
||
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;
|
||
}
|
||
|
||
const detectedGame = detectGame(subject, rawBody);
|
||
|
||
const gameKey =
|
||
detectedGame && detectedGame !== 'Not Mentioned'
|
||
? GAME_NAME_TO_KEY[detectedGame] || null
|
||
: null;
|
||
|
||
const buttons = new ActionRowBuilder().addComponents(
|
||
new ButtonBuilder()
|
||
.setCustomId('close_ticket')
|
||
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
|
||
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
|
||
.setStyle(ButtonStyle.Secondary),
|
||
new ButtonBuilder()
|
||
.setCustomId('claim_ticket')
|
||
.setLabel(CONFIG.BUTTON_LABEL_CLAIM)
|
||
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
|
||
.setStyle(ButtonStyle.Secondary)
|
||
);
|
||
|
||
const ticketInfoEmbed = new EmbedBuilder()
|
||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||
.addFields(
|
||
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false },
|
||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
|
||
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
||
);
|
||
|
||
enforceEmbedLimit([ticketInfoEmbed]);
|
||
const welcomeMsg = await enqueueSend(ticketChan, {
|
||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||
embeds: [ticketInfoEmbed],
|
||
components: [buttons],
|
||
allowedMentions: { parse: ['roles'] }
|
||
});
|
||
|
||
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(() => {});
|
||
}
|
||
|
||
// On reopen, link previous transcripts
|
||
if (isReopened) {
|
||
try {
|
||
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_CHAN)
|
||
.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.
|
||
await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } });
|
||
|
||
// 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 withRetry(() => Ticket.findOneAndUpdate(
|
||
{ gmailThreadId: email.data.threadId },
|
||
{
|
||
$set: {
|
||
discordThreadId: ticketChan.id,
|
||
senderEmail: sEmail,
|
||
subject,
|
||
createdAt: now,
|
||
status: 'open',
|
||
ticketNumber: number,
|
||
priority: defaultPriority,
|
||
lastActivity: now,
|
||
parentCategoryId: parentCategoryIdForTicket
|
||
}
|
||
},
|
||
{ upsert: true, new: true }
|
||
));
|
||
totalProcessed++;
|
||
logGmail(subject, sEmail, number, detectedGame).catch(() => {});
|
||
}
|
||
console.log('Archiving/reading Gmail message', msgRef.id);
|
||
await gmail.users.messages.batchModify({
|
||
userId: 'me',
|
||
requestBody: {
|
||
ids: [msgRef.id],
|
||
removeLabelIds: ['UNREAD', 'INBOX']
|
||
}
|
||
});
|
||
}
|
||
authErrorNotified = false;
|
||
} catch (e) {
|
||
// Only treat Google-reported permanent-grant failures as reasons to suspend
|
||
// 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(() => {});
|
||
}
|
||
}
|
||
|
||
totalErrors++;
|
||
console.error('POLL ERROR:', e);
|
||
logError('Gmail poll', e, null, client).catch(() => {});
|
||
}
|
||
} finally {
|
||
isPolling = false;
|
||
}
|
||
}
|
||
|
||
module.exports = { poll, setPollSuspended, isPollSuspended };
|