guild.channels.create in findOrCreateTicketChannel previously had no permissionOverwrites — newly created email-ticket channels inherited whatever the parent category granted. If the category ever had @everyone View Channel allowed (or undefined → default-allow), every server member could read every email ticket. Add explicit overrides on creation: - @everyone (guild.id): deny ViewChannel - ROLE_ID_TO_PING: allow ViewChannel + SendMessages + ReadMessageHistory (gated on ROLE_ID_TO_PING being set — empty string skips the entry rather than creating a malformed overwrite). Email tickets have no Discord creator (the customer reaches the bot via email, not as a guild member) so the only "allow" entry is the staff role. Modal-created and context-menu-created tickets already set creator+role overrides on creation; this change brings the third path into line. Pairs with category-level Discord config: TICKET_CATEGORY_ID and the ESCALATED2/3 categories should still deny @everyone and allow ROLE_ID_TO_PING at the category level for defense in depth.
409 lines
15 KiB
JavaScript
409 lines
15 KiB
JavaScript
/**
|
||
* Gmail polling – fetches unread emails and creates/updates Discord ticket channels.
|
||
*
|
||
* `poll()` is the orchestrator: list → locate guild → for each message,
|
||
* parse → look up existing → branch (append-followup vs create-ticket) → mark read.
|
||
* Each step delegates to a single-responsibility helper below.
|
||
*/
|
||
const {
|
||
ChannelType,
|
||
EmbedBuilder,
|
||
PermissionFlagsBits
|
||
} = require('discord.js');
|
||
const { mongoose, withRetry } = require('./db-connection');
|
||
const { CONFIG } = require('./config');
|
||
const {
|
||
getCleanBody,
|
||
extractRawEmail,
|
||
stripEmailQuotes,
|
||
stripMobileFooter,
|
||
detectGame,
|
||
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; }
|
||
|
||
// ============================================================
|
||
// Helpers (extracted from the original 309-line poll()).
|
||
// ============================================================
|
||
|
||
/**
|
||
* Pick the guild for this poll iteration. Honors DISCORD_GUILD_ID when set,
|
||
* otherwise falls back to the first guild in the cache. Returns null with a
|
||
* warning if no usable guild is available; caller should bail.
|
||
*/
|
||
function locateGuild(client) {
|
||
if (CONFIG.DISCORD_GUILD_ID) {
|
||
const g = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||
if (!g) {
|
||
console.warn('Configured guild not found for DISCORD_GUILD_ID:', CONFIG.DISCORD_GUILD_ID);
|
||
}
|
||
return g || null;
|
||
}
|
||
const g = client.guilds.cache.first();
|
||
if (!g) {
|
||
console.warn('No guilds in cache; skipping poll iteration.');
|
||
}
|
||
return g || null;
|
||
}
|
||
|
||
/**
|
||
* Parse a Gmail message payload into normalized fields.
|
||
*
|
||
* Body cleanup runs twice with different rules:
|
||
* - firstBody: aggressive — strip quotes if it looks like a reply, strip
|
||
* mobile footers, collapse newlines. Used as the first message in a new
|
||
* ticket channel where we want only the user's actual message.
|
||
* - followupBody: defensive — strip quotes but fall back to raw text if
|
||
* stripping leaves nothing. Used for follow-up posts on an existing thread.
|
||
*/
|
||
function parseGmailMessage(email) {
|
||
const headers = email.data.payload.headers;
|
||
const from = headers.find(h => h.name === 'From')?.value || '';
|
||
const isSelf = from.toLowerCase().includes(CONFIG.MY_EMAIL);
|
||
const subject = headers.find(h => h.name === 'Subject')?.value || 'New Ticket';
|
||
const rawBody = getCleanBody(email.data.payload);
|
||
const senderEmail = extractRawEmail(from).toLowerCase();
|
||
const senderName = (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 firstBody = rawBody.replace(/\r\n/g, '\n');
|
||
if (looksLikeReply) firstBody = stripEmailQuotes(firstBody);
|
||
firstBody = stripMobileFooter(firstBody);
|
||
firstBody = firstBody.replace(/^\s*\n+/g, '');
|
||
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
|
||
firstBody = firstBody
|
||
.replace(/Get Outlook for [^\n]+/gi, '')
|
||
.replace(/<\s*$/gm, '')
|
||
.trim();
|
||
|
||
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();
|
||
|
||
return {
|
||
isSelf,
|
||
threadId: email.data.threadId,
|
||
from,
|
||
subject,
|
||
rawBody,
|
||
senderEmail,
|
||
senderName,
|
||
firstBody,
|
||
followupBody
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve the parent category and create a fresh ticket channel under it.
|
||
* Returns { channel, parentCategoryId } on success, or null on failure (caller
|
||
* should mark the message read and skip — same behavior as the original inline path).
|
||
*/
|
||
async function findOrCreateTicketChannel(guild, parsed, number) {
|
||
const creatorNickname = getSenderLocal(parsed.senderEmail);
|
||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||
|
||
let parentCategoryId;
|
||
try {
|
||
parentCategoryId = await getOrCreateTicketCategory(
|
||
guild,
|
||
CONFIG.TICKET_CATEGORY_ID,
|
||
CONFIG.TICKET_CATEGORY_NAME
|
||
);
|
||
} catch (err) {
|
||
console.error('Channel create error (payload):', {
|
||
message: err.message,
|
||
code: err.code,
|
||
rawError: err.rawError
|
||
});
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const channel = await guild.channels.create({
|
||
name: chanName,
|
||
type: ChannelType.GuildText,
|
||
parent: parentCategoryId,
|
||
// Email tickets have no Discord creator — the customer is reachable
|
||
// only by email. So the only per-channel allow is the staff role; we
|
||
// still explicitly deny @everyone in case the category permissions
|
||
// are ever misconfigured to grant View Channel server-wide.
|
||
permissionOverwrites: [
|
||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||
...(CONFIG.ROLE_ID_TO_PING ? [{
|
||
id: CONFIG.ROLE_ID_TO_PING,
|
||
allow: [
|
||
PermissionFlagsBits.ViewChannel,
|
||
PermissionFlagsBits.SendMessages,
|
||
PermissionFlagsBits.ReadMessageHistory
|
||
]
|
||
}] : [])
|
||
]
|
||
});
|
||
return { channel, parentCategoryId };
|
||
} catch (createErr) {
|
||
console.error('Channel create error (email ticket):', createErr);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Post links + attachments for prior transcripts of a reopened thread.
|
||
* Best-effort: any failure is logged and swallowed so the new ticket flow
|
||
* continues unaffected.
|
||
*/
|
||
async function linkPreviousTranscripts(ticketChan, threadId, client) {
|
||
try {
|
||
const transcriptRows = await Transcript.find({ gmailThreadId: threadId })
|
||
.sort({ createdAt: 1 })
|
||
.select('transcriptMessageId')
|
||
.lean();
|
||
|
||
if (transcriptRows.length === 0) return;
|
||
|
||
const transcriptChan = await client.channels
|
||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||
.catch(() => null);
|
||
if (!transcriptChan) return;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
|
||
async function markGmailMessageRead(gmail, msgRef) {
|
||
await gmail.users.messages.batchModify({
|
||
userId: 'me',
|
||
requestBody: {
|
||
ids: [msgRef.id],
|
||
removeLabelIds: ['UNREAD', 'INBOX']
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* If the error indicates a permanent OAuth-grant failure (invalid_grant /
|
||
* invalid_client), suspend polling, clear the recurring poll interval, log,
|
||
* and DM the admin once. Returns true iff polling was suspended (caller
|
||
* should not treat as a transient retry-on-next-tick error).
|
||
*
|
||
* Transient 401/403/429/5xx and network errors are NOT considered permanent —
|
||
* they fall through to the next interval naturally. The OAuth code lives on
|
||
* `err.response.data.error`, not the message string.
|
||
*/
|
||
function oauthSuspendIfPermanent(err, client) {
|
||
const oauthError = err && err.response && err.response.data && err.response.data.error;
|
||
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
|
||
if (!isPermanentAuth) return false;
|
||
|
||
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: err.stack || err.message || String(err) }, 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(() => {});
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// Orchestrator
|
||
// ============================================================
|
||
|
||
/**
|
||
* 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;
|
||
|
||
const guild = locateGuild(client);
|
||
if (!guild) return;
|
||
|
||
for (const msgRef of list.data.messages) {
|
||
const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
|
||
const parsed = parseGmailMessage(email);
|
||
|
||
if (parsed.isSelf) {
|
||
await markGmailMessageRead(gmail, msgRef);
|
||
continue;
|
||
}
|
||
|
||
const existing = await Ticket.findOne({ gmailThreadId: parsed.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) {
|
||
// Append follow-up to existing channel.
|
||
const truncatedFollowup = parsed.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 ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||
allowedMentions: { parse: ['roles'] }
|
||
});
|
||
} else {
|
||
// Create a new ticket channel.
|
||
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
||
if (!limitCheck.ok) {
|
||
console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
|
||
await markGmailMessageRead(gmail, msgRef);
|
||
continue;
|
||
}
|
||
|
||
const { number } = await getNextTicketNumber(parsed.senderEmail);
|
||
const created = await findOrCreateTicketChannel(guild, parsed, number);
|
||
if (!created) {
|
||
await markGmailMessageRead(gmail, msgRef);
|
||
continue;
|
||
}
|
||
ticketChan = created.channel;
|
||
parentCategoryIdForTicket = created.parentCategoryId;
|
||
|
||
const detectedGame = detectGame(parsed.subject, parsed.rawBody);
|
||
const buttons = getTicketActionRow({ escalationTier: 0 });
|
||
const ticketInfoEmbed = new EmbedBuilder()
|
||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||
.addFields(
|
||
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false },
|
||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false },
|
||
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
|
||
);
|
||
|
||
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(() => {});
|
||
}
|
||
|
||
if (isReopened) {
|
||
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
|
||
}
|
||
|
||
// Email body is attacker-controlled — no mentions may fire from its content.
|
||
const truncated = parsed.firstBody.slice(0, 1900);
|
||
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: parsed.threadId },
|
||
{
|
||
$set: {
|
||
discordThreadId: ticketChan.id,
|
||
senderEmail: parsed.senderEmail,
|
||
subject: parsed.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 markGmailMessageRead(gmail, msgRef);
|
||
}
|
||
authErrorNotified = false;
|
||
} catch (e) {
|
||
oauthSuspendIfPermanent(e, client);
|
||
console.error('POLL ERROR:', e);
|
||
logError('Gmail poll', e, null, client).catch(() => {});
|
||
}
|
||
} finally {
|
||
isPolling = false;
|
||
}
|
||
}
|
||
|
||
module.exports = { poll, setPollSuspended, isPollSuspended };
|