Files
broccolini-bot/gmail-poll.js

322 lines
10 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.
*/
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, pickTicketCategoryId, 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 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);
} else {
const emailCategoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
const parentId = pickTicketCategoryId(guild, emailCategoryIds);
if (!parentId) {
throw new Error('Email ticket category not found or all categories full (50 channels max)');
}
ticketChan = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentId
});
}
} 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
}
},
{ 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 };