Initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-10 08:22:19 -06:00
commit 519788c633
39 changed files with 17121 additions and 0 deletions

362
gmail-poll.js Normal file
View File

@@ -0,0 +1,362 @@
/**
* 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, ZAMMAD, GAME_NAME_TO_KEY } = require('./config');
const {
getCleanBody,
extractRawEmail,
stripEmailQuotes,
stripMobileFooter,
detectGame,
getFormattedDate
} = require('./utils');
const { getGmailClient } = require('./services/gmail');
const { createZammadTicket, updateZammadUserDiscordId } = require('./services/zammad');
const { getNextTicketNumber, saveZammadId, 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');
const User = mongoose.model('User');
/**
* 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 embed = new EmbedBuilder().setColor(CONFIG.EMBED_COLOR_OPEN).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: [embed],
components: [buttons]
});
// Look up website User by email for discordID
let discordIdFromUser = null;
try {
const websiteUser = await User.findOne({ email: sEmail.toLowerCase() }).select('discordID').lean();
if (websiteUser?.discordID) discordIdFromUser = websiteUser.discordID;
} catch (_) { /* ignore */ }
// Create Zammad ticket
try {
const zammadTicket = await createZammadTicket({
subject,
body: firstBody,
email: sEmail,
name: sName,
gameName: detectedGame,
gameKey: gameKey,
group: ZAMMAD.EMAIL_GROUP
});
if (zammadTicket?.id) {
await saveZammadId(email.data.threadId, zammadTicket.id);
}
if (zammadTicket?.customer_id && discordIdFromUser) {
try {
await updateZammadUserDiscordId(zammadTicket.customer_id, discordIdFromUser);
} catch (zErr) {
console.error('Zammad user discord_id update failed (add discord_id on User in Object Manager?):', zErr.response?.data || zErr.message);
}
}
} catch (e) {
console.error('--- ZAMMAD API ERROR DETAILS ---');
if (e.response) {
console.error(`Status: ${e.response.status}`);
console.error('Response Data:', JSON.stringify(e.response.data, null, 2));
console.error('Headers:', e.response.headers);
} else if (e.request) {
console.error('No response received. Request details:', e.request);
} else {
console.error('Error setting up request:', e.message);
}
console.error('--------------------------------');
}
// 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 };