/** * Gmail polling – fetches unread emails and creates/updates Discord ticket channels. */ const { ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js'); const { mongoose } = require('./db-connection'); const { CONFIG, GAME_NAME_TO_KEY } = require('./config'); const { getCleanBody, extractRawEmail, stripEmailQuotes, stripMobileFooter, detectGame, getFormattedDate } = require('./utils'); const { getGmailClient } = require('./services/gmail'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread } = require('./services/tickets'); const { getEmailRouting } = require('./services/guildSettings'); const { logError } = require('./services/debugLog'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); /** * Poll Gmail for unread primary-inbox messages and route them to Discord. * @param {import('discord.js').Client} client */ async function poll(client) { 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)) { 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); const sName = (from.match(/^(.*?)\s*<.*>$/) || [null, from])[1] ?.replace(/"/g, '') .trim() || 'Unknown'; const looksLikeReply = /\nOn .+wrote:/i.test(rawBody) || /\nFrom:\s.*<.*@.*>/i.test(rawBody); 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(/https?:\/\/\S+/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(/https?:\/\/\S+/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); await ticketChan.send( `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}` ); } else { // Check ticket limits before creating const limitCheck = await checkTicketLimits(sEmail); if (!limitCheck.ok) { 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 { local, number } = await getNextTicketNumber(sEmail); const safeLocal = local .replace(/[^a-z0-9-]/gi, '') .substring(0, 50); const chanName = `ticket-${safeLocal}-${number}`; try { const routing = await getEmailRouting(guild.id); if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) { ticketChan = await createEmailTicketAsThread(guild, number, chanName); parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null; } else { 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 welcomeEmbed = new EmbedBuilder() .setDescription(CONFIG.TICKET_WELCOME_MESSAGE) .setColor(CONFIG.EMBED_COLOR_INFO); const ticketInfoEmbed = new EmbedBuilder() .setColor(CONFIG.EMBED_COLOR_INFO) .addFields({ name: 'Ticket Info', value: `**Name:** ${sName}\n` + `**Email:** ${sEmail}\n` + `**Date:** ${getFormattedDate()}\n` + `**Game:** ${detectedGame}\n` + `**Subject:** ${subject || 'No subject'}` }); await ticketChan.send({ content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, embeds: [welcomeEmbed, ticketInfoEmbed], components: [buttons] }); // 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 ticketChan.send( `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 ticketChan.send(`Transcript: ${transcriptMsg.url}`); const originalAttachment = transcriptMsg.attachments.first(); if (originalAttachment) { await ticketChan.send({ content: 'Transcript file:', files: [originalAttachment.url] }); } } } } } catch (err) { console.error('Error linking previous transcripts:', err); } } const truncated = firstBody.slice(0, 1900); await ticketChan.send(`**Message:**\n${truncated}`); // 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 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 } ); } console.log('Archiving/reading Gmail message', msgRef.id); await gmail.users.messages.batchModify({ userId: 'me', requestBody: { ids: [msgRef.id], removeLabelIds: ['UNREAD', 'INBOX'] } }); } } catch (e) { console.error('POLL ERROR:', e); logError('Gmail poll', e, null, client); } } module.exports = { poll };