Files
broccolini-bot/gmail-poll.js
indifferentketchup 840b6bfcf8 simplify: prune dead code, dedup gmail send, drop neutered log stubs
- Remove no-op log stubs (logGmail, logAutomation, logSecurity, logSystem)
  and ~17 callsites; dead counters in tickets.js and gmail-poll.js go too
- Dedup three near-identical Gmail send paths into sendThreadedEmail helper
- Drop dead Mongoose fields: broccoliniTicketId, lastSyncedBroccoliniArticleId,
  renameCount, renameWindowStart, reminderSent, staffChannelId,
  unclaimedRemindersSent, lastMessageAuthorIsStaff
- Drop dead config fields and their .env.example entries
- Inline api/botClient.js (3-line wrapper, 2 callers)
- Trim unused exports across utils.js, tickets.js, configSchema.js, debugLog.js
- Fix handlers/messages.js to use isStaff() — old partial check ignored
  ADDITIONAL_STAFF_ROLES, so those members were treated as customers
- Drop unused deps p-queue + dotenv-expand; move mongodb to devDependencies

Net: -583 LOC source + -57 LOC lockfile. All 23 modules load clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:37:14 +00:00

354 lines
12 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,
EmbedBuilder
} = require('discord.js');
const { mongoose, withRetry } = require('./db-connection');
const { CONFIG } = 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 } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
let isPolling = false;
let authErrorNotified = false;
let pollSuspended = false;
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 {
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).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) {
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 buttons = getTicketActionRow({ escalationTier: 0 });
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 }
));
}
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(() => {});
}
}
console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {});
}
} finally {
isPolling = false;
}
}
module.exports = { poll, setPollSuspended, isPollSuspended };