Compare commits
7 Commits
e3b3b8d48c
...
3c13e55dad
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c13e55dad | |||
| 3e9ad658d0 | |||
| 952b22ac12 | |||
| d89ac65823 | |||
| adcd9dd9c9 | |||
| d0cf8fd915 | |||
| cdf85f6364 |
@@ -2,7 +2,7 @@
|
||||
* Entry point – initializes the Discord bot, wires event handlers,
|
||||
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck.
|
||||
*/
|
||||
const { Client, GatewayIntentBits, Partials } = require('discord.js');
|
||||
const { Client, GatewayIntentBits, Partials, MessageFlags } = require('discord.js');
|
||||
const express = require('express');
|
||||
const { connectMongoDB, closeMongoDB } = require('./db-connection');
|
||||
const { CONFIG } = require('./config');
|
||||
@@ -86,7 +86,7 @@ const client = new Client({
|
||||
|
||||
// --- EVENT: interactionCreate ---
|
||||
async function safeReplyError(interaction) {
|
||||
const payload = { content: 'Something went wrong.', ephemeral: true };
|
||||
const payload = { content: 'Something went wrong.', flags: MessageFlags.Ephemeral };
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.followUp(payload).catch(() => {});
|
||||
} else {
|
||||
@@ -132,13 +132,13 @@ client.on('interactionCreate', async interaction => {
|
||||
|
||||
await interaction.reply({
|
||||
content: 'Signature settings saved successfully!',
|
||||
ephemeral: true
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Signature modal submit error:', err);
|
||||
await interaction.reply({
|
||||
content: 'Failed to save signature settings.',
|
||||
ephemeral: true
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -165,6 +165,14 @@ client.on('messageCreate', async msg => {
|
||||
await handleDiscordReply(msg);
|
||||
});
|
||||
|
||||
// HTTP server handles + readiness flag. Assigned inside the ready callback
|
||||
// (httpServer, appReady) and the INTERNAL_API_SECRET branch below
|
||||
// (internalServer); declared here so they're visible to the ready callback,
|
||||
// the express middleware below, and the shutdown handler at the bottom.
|
||||
let httpServer = null;
|
||||
let internalServer = null;
|
||||
let appReady = false;
|
||||
|
||||
client.once('ready', async () => {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
|
||||
@@ -228,7 +236,7 @@ client.login(CONFIG.DISCORD_TOKEN);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Reject API traffic with 503 until ready event has fired and routes are mounted.
|
||||
let appReady = false;
|
||||
// (appReady is declared at module top so the ready callback can flip it.)
|
||||
app.use((req, res, next) => {
|
||||
if (!appReady && req.path.startsWith('/api')) {
|
||||
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
|
||||
@@ -243,8 +251,6 @@ const internalApi = require('./routes/internalApi');
|
||||
const internalApp = express();
|
||||
internalApp.use('/internal', internalApi);
|
||||
|
||||
let httpServer = null;
|
||||
let internalServer = null;
|
||||
if (CONFIG.INTERNAL_API_SECRET) {
|
||||
// Must bind all-interfaces inside the bot container: the settings-site is a
|
||||
// separate container on broccoli-net and reaches this API over the docker
|
||||
|
||||
@@ -357,8 +357,6 @@ async function registerCommands() {
|
||||
.setDescription('Poll interval')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: '5s', value: '5' },
|
||||
{ name: '10s', value: '10' },
|
||||
{ name: '30s', value: '30' },
|
||||
{ name: '45s', value: '45' },
|
||||
{ name: '1m', value: '60' },
|
||||
|
||||
458
gmail-poll.js
458
gmail-poll.js
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* 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,
|
||||
@@ -35,6 +39,213 @@ function setPollSuspended(val) {
|
||||
}
|
||||
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
|
||||
});
|
||||
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
|
||||
@@ -52,88 +263,19 @@ async function poll(client) {
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
|
||||
const parsed = parseGmailMessage(email);
|
||||
|
||||
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']
|
||||
}
|
||||
});
|
||||
if (parsed.isSelf) {
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
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 })
|
||||
const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
|
||||
.select('gmailThreadId discordThreadId status')
|
||||
.lean();
|
||||
|
||||
@@ -142,86 +284,46 @@ async function poll(client) {
|
||||
let isReopened = false;
|
||||
|
||||
if (existing && existing.discordThreadId) {
|
||||
ticketChan = await guild.channels
|
||||
.fetch(existing.discordThreadId)
|
||||
.catch(() => null);
|
||||
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);
|
||||
// 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 ${sEmail}:**\n${truncatedFollowup}`,
|
||||
await enqueueSend(ticketChan, {
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||||
allowedMentions: { parse: ['roles'] }
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Check ticket limits before creating
|
||||
const limitCheck = await checkTicketLimits(sEmail);
|
||||
// Create a new ticket channel.
|
||||
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
||||
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']
|
||||
}
|
||||
});
|
||||
console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
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']
|
||||
}
|
||||
});
|
||||
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(subject, rawBody);
|
||||
|
||||
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(sName)}\n\`\`\``, inline: false },
|
||||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
|
||||
{ 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(subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||
);
|
||||
|
||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||
@@ -239,66 +341,29 @@ async function poll(client) {
|
||||
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_CHANNEL_ID)
|
||||
.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);
|
||||
}
|
||||
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
|
||||
}
|
||||
|
||||
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: [] } });
|
||||
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: email.data.threadId },
|
||||
{ gmailThreadId: parsed.threadId },
|
||||
{
|
||||
$set: {
|
||||
discordThreadId: ticketChan.id,
|
||||
senderEmail: sEmail,
|
||||
subject,
|
||||
senderEmail: parsed.senderEmail,
|
||||
subject: parsed.subject,
|
||||
createdAt: now,
|
||||
status: 'open',
|
||||
ticketNumber: number,
|
||||
@@ -310,36 +375,13 @@ async function poll(client) {
|
||||
{ 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']
|
||||
}
|
||||
});
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
}
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
oauthSuspendIfPermanent(e, client);
|
||||
console.error('POLL ERROR:', e);
|
||||
logError('Gmail poll', e, null, client).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
ButtonStyle,
|
||||
AttachmentBuilder,
|
||||
EmbedBuilder,
|
||||
MessageFlags,
|
||||
PermissionFlagsBits,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
@@ -121,7 +122,7 @@ async function handleTagDeleteConfirm(interaction) {
|
||||
async function handleClaimButton(interaction, ticket) {
|
||||
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
||||
if (!freshTicket) {
|
||||
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
|
||||
return interaction.reply({ content: 'Ticket data missing.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const isClaimed = !!freshTicket.claimedBy;
|
||||
@@ -131,19 +132,19 @@ async function handleClaimButton(interaction, ticket) {
|
||||
|
||||
const [row0] = interaction.message.components;
|
||||
if (!row0) {
|
||||
return interaction.reply({ content: 'No components to update.', ephemeral: true });
|
||||
return interaction.reply({ content: 'No components to update.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const row = ActionRowBuilder.from(row0);
|
||||
const [btnClose, btnClaim] = row.components;
|
||||
if (!btnClose || !btnClaim) {
|
||||
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
|
||||
return interaction.reply({ content: 'Buttons missing.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
|
||||
return interaction.reply({
|
||||
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
|
||||
ephemeral: true
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,7 +278,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true });
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const cancelRow = new ActionRowBuilder().addComponents(
|
||||
@@ -289,7 +290,9 @@ async function handleConfirmCloseRequest(interaction, ticket) {
|
||||
const channelName = interaction.channel.name;
|
||||
const userTag = interaction.user.tag;
|
||||
|
||||
const timerId = setTimeout(async () => {
|
||||
// Lazy require — broccolini-discord re-exports trackTimeout and we'd otherwise cycle.
|
||||
const { trackTimeout } = require('../broccolini-discord');
|
||||
const timerId = trackTimeout(setTimeout(async () => {
|
||||
const pending = pendingCloses.get(channelId);
|
||||
pendingCloses.delete(channelId);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
|
||||
@@ -303,7 +306,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
|
||||
|
||||
const effectiveSendEmail = pending?.sendEmail ?? true;
|
||||
await runFinalClose(interaction, freshTicket, effectiveSendEmail);
|
||||
}, timerSeconds * 1000);
|
||||
}, timerSeconds * 1000));
|
||||
|
||||
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail });
|
||||
}
|
||||
@@ -324,7 +327,7 @@ async function handleCancelCloseRequest(interaction) {
|
||||
async function handleEscalatePrompt(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
@@ -338,7 +341,7 @@ async function handleEscalatePrompt(interaction, ticket) {
|
||||
return interaction.reply({
|
||||
content: 'Escalate to which tier?',
|
||||
components: [new ActionRowBuilder().addComponents(buttons)],
|
||||
ephemeral: true
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,7 +354,7 @@ async function handleEscalateButton(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
|
||||
if (currentTier >= tier) {
|
||||
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, ephemeral: true });
|
||||
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
@@ -362,7 +365,7 @@ async function handleEscalateButton(interaction, ticket) {
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({
|
||||
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`,
|
||||
ephemeral: true
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,12 +375,12 @@ async function handleEscalateButton(interaction, ticket) {
|
||||
async function handleDeescalateButton(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier === 0) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'deescalate',
|
||||
() => runDeescalation(interaction, ticket),
|
||||
{ ephemeral: true }
|
||||
{ flags: MessageFlags.Ephemeral }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,9 +442,11 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
|
||||
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
|
||||
}
|
||||
|
||||
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
|
||||
// a stale message ID pointing into the now-deleted channel.
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
||||
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||||
);
|
||||
|
||||
if (transcriptMsg?.id) {
|
||||
@@ -455,12 +460,14 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
|
||||
const parentCatId = ticket.parentCategoryId;
|
||||
const guildRef = interaction.guild;
|
||||
|
||||
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000);
|
||||
setTimeout(() => {
|
||||
// Lazy require — same cycle reason as in handleConfirmCloseRequest above.
|
||||
const { trackTimeout } = require('../broccolini-discord');
|
||||
trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000));
|
||||
trackTimeout(setTimeout(() => {
|
||||
if (parentCatId && guildRef) {
|
||||
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
|
||||
}
|
||||
}, 6000);
|
||||
}, 6000));
|
||||
} catch (e) {
|
||||
console.error('Close ticket error:', e);
|
||||
}
|
||||
@@ -494,7 +501,12 @@ function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr)
|
||||
}
|
||||
|
||||
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
|
||||
// pre-creatorId modal tickets only — split-pop returns the wrong value for
|
||||
// discord-msg-* tickets (it yields the message ID, not the user ID).
|
||||
const creatorId = ticket.creatorId
|
||||
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||||
if (!creatorId) return;
|
||||
try {
|
||||
const creator = await client.users.fetch(creatorId);
|
||||
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
|
||||
@@ -524,13 +536,15 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
|
||||
|
||||
let logMsg;
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
} catch {
|
||||
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
const creatorId = ticket.creatorId
|
||||
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||||
let creator = null;
|
||||
if (creatorId) {
|
||||
creator = await interaction.client.users.fetch(creatorId).catch(() => null);
|
||||
}
|
||||
logMsg = creator
|
||||
? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`
|
||||
: `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
} else {
|
||||
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
||||
}
|
||||
@@ -542,7 +556,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
|
||||
// ============================================================
|
||||
|
||||
async function handleTicketModal(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
|
||||
const game = interaction.fields.getTextInputValue('ticket_game').trim();
|
||||
@@ -578,7 +592,10 @@ async function handleTicketModal(interaction) {
|
||||
|
||||
let channel;
|
||||
try {
|
||||
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
|
||||
// Initial permissionOverwrites on guild.channels.create are safe-by-construction:
|
||||
// the channel doesn't exist yet, so there's no in-flight rename/send/move to race
|
||||
// against. Any *subsequent* mutation on this channel (add/remove user, move,
|
||||
// topic, rename) must go through services/channelQueue.js.
|
||||
channel = await guild.channels.create({
|
||||
name: unclaimedName,
|
||||
type: ChannelType.GuildText,
|
||||
@@ -613,6 +630,7 @@ async function handleTicketModal(interaction) {
|
||||
ticketNumber,
|
||||
priority,
|
||||
lastActivity: now,
|
||||
creatorId: interaction.user.id,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
});
|
||||
|
||||
|
||||
1025
handlers/commands.js
1025
handlers/commands.js
File diff suppressed because it is too large
Load Diff
128
handlers/commands/close.js
Normal file
128
handlers/commands/close.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Force-close flow: /force-close, /cancel-close, /closetimer, plus the
|
||||
* countdown-elapses finalize step and transcript renderer that the
|
||||
* countdown's setTimeout calls back into.
|
||||
*
|
||||
* Note: the button-driven close path lives in handlers/buttons.js
|
||||
* (handleCloseButton / handleConfirmCloseRequest / runFinalClose).
|
||||
* This module covers the slash-command-driven path only.
|
||||
*/
|
||||
const { AttachmentBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
const { logTicketEvent } = require('../../services/debugLog');
|
||||
const { pendingCloses } = require('../pendingCloses');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleCloseTimer(interaction) {
|
||||
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
||||
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
||||
logTicketEvent('Close timer updated', [
|
||||
{ name: 'Duration', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleCancelClose(interaction) {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (!pending) {
|
||||
return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
logTicketEvent('Force-close cancelled', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Cancelled by', value: interaction.user.tag },
|
||||
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
||||
], interaction).catch(() => {});
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleForceClose(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
|
||||
|
||||
const channelRef = interaction.channel;
|
||||
const clientRef = interaction.client;
|
||||
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
}
|
||||
|
||||
/** Performs the actual force-close work after the countdown elapses. */
|
||||
async function finalizeForceClose(channelRef, clientRef) {
|
||||
pendingCloses.delete(channelRef.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
|
||||
try {
|
||||
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
|
||||
// a stale message ID pointing into the now-deleted channel.
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||||
);
|
||||
|
||||
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
||||
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
||||
console.error('Transcript error (force-close):', tErr)
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
channelRef.delete('Ticket force-closed').catch(e =>
|
||||
console.error('Failed to delete channel:', e)
|
||||
);
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render and post a closing transcript for a ticket. */
|
||||
async function postTranscript(channelRef, clientRef, freshTicket) {
|
||||
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
|
||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
|
||||
messages
|
||||
.reverse()
|
||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||
.join('\n');
|
||||
|
||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${channelRef.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await clientRef.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||
.catch(() => null);
|
||||
if (!transcriptChan) return;
|
||||
|
||||
const fmt = (d) => new Date(d).toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const openedStr = fmt(freshTicket.createdAt);
|
||||
const closedStr = fmt(new Date());
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelRef.name)
|
||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
|
||||
}
|
||||
|
||||
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
|
||||
168
handlers/commands/contextMenu.js
Normal file
168
handlers/commands/contextMenu.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Right-click "Apps" menu commands:
|
||||
* - "Create Ticket From Message" — turn a Discord message into a ticket.
|
||||
* - "View User Tickets" — show last 10 tickets for the targeted user.
|
||||
*/
|
||||
const {
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
MessageFlags,
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { getPriorityEmoji } = require('../../utils');
|
||||
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets');
|
||||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleCreateTicketFromMessage(interaction) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||
if (!rateLimit.allowed) {
|
||||
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
||||
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = interaction.targetMessage;
|
||||
const subject = `Message from ${message.author.tag}`;
|
||||
const description = message.content || 'No content';
|
||||
|
||||
const guild = interaction.guild;
|
||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||
|
||||
let parentCategoryIdForTicket;
|
||||
try {
|
||||
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
||||
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
||||
}
|
||||
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentCategoryIdForTicket,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
id: message.author.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('guild.channels.create (context menu ticket):', err);
|
||||
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
|
||||
}
|
||||
|
||||
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
|
||||
const now = new Date();
|
||||
await Ticket.create({
|
||||
gmailThreadId,
|
||||
discordThreadId: channel.id,
|
||||
senderEmail: message.author.tag,
|
||||
subject,
|
||||
createdAt: now,
|
||||
status: 'open',
|
||||
ticketNumber,
|
||||
priority: 'normal',
|
||||
lastActivity: now,
|
||||
creatorId: message.author.id,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
});
|
||||
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
|
||||
const infoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields(
|
||||
{ name: 'From message', value: `[Jump to message](${message.url})` },
|
||||
{ name: 'Creator', value: message.author.toString(), inline: true },
|
||||
{ name: 'Created by Staff', value: interaction.user.toString(), inline: true },
|
||||
{ name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false }
|
||||
);
|
||||
|
||||
const row = getTicketActionRow({ escalationTier: 0 });
|
||||
|
||||
try {
|
||||
const welcomeMsg = await enqueueSend(channel, {
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [row]
|
||||
});
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('welcomeMessageId-save', err);
|
||||
}
|
||||
|
||||
await interaction.editReply(`✅ Ticket created: ${channel}`);
|
||||
} catch (err) {
|
||||
logError('create-ticket-from-message', err, interaction).catch(() => {});
|
||||
await interaction.editReply('❌ Failed to create ticket from message.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewUserTickets(interaction) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.targetUser;
|
||||
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(10)
|
||||
.lean();
|
||||
|
||||
if (!tickets || tickets.length === 0) {
|
||||
return interaction.editReply(`📋 No tickets found for ${targetUser.tag}`);
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`📋 Tickets for ${targetUser.tag}`)
|
||||
.setDescription(`Found ${tickets.length} ticket(s)`)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
|
||||
for (const ticket of tickets.slice(0, 5)) {
|
||||
const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal');
|
||||
const statusEmoji = ticket.status === 'open' ? '🟢' : '🔴';
|
||||
embed.addFields({
|
||||
name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`,
|
||||
value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (tickets.length > 5) {
|
||||
embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (err) {
|
||||
logError('view-user-tickets', err, interaction).catch(() => {});
|
||||
await interaction.editReply('❌ Failed to fetch user tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleCreateTicketFromMessage, handleViewUserTickets };
|
||||
214
handlers/commands/escalation.js
Normal file
214
handlers/commands/escalation.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Escalation flows.
|
||||
*
|
||||
* runEscalation / runDeescalation are exported for handlers/buttons.js
|
||||
* (the tier-pick buttons share this code path). handleEscalate /
|
||||
* handleDeescalate are the slash-command entry points.
|
||||
*/
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||
const { sendTicketNotificationEmail } = require('../../services/gmail');
|
||||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
|
||||
const { pinMessage } = require('../../services/pinMessage');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
|
||||
const { fetchLoggingChannel } = require('./helpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
|
||||
* validate ticket and currentTier < nextTier, and have already deferred.
|
||||
*/
|
||||
async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = nextTier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||
|
||||
// Clear claim on escalation
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = true;
|
||||
ticket.escalationTier = nextTier;
|
||||
ticket.claimedBy = null;
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
||||
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
if (!interaction.channel.isThread() && categoryId) {
|
||||
await enqueueMove(interaction.channel, categoryId);
|
||||
}
|
||||
|
||||
const pendingEmbed = new EmbedBuilder()
|
||||
.setDescription('Ticket will be escalated in a few seconds.')
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
await interaction.editReply({ embeds: [pendingEmbed] });
|
||||
|
||||
const creatorId = isDiscordTicket
|
||||
? (ticket.gmailThreadId.split('-').pop() || '').trim()
|
||||
: null;
|
||||
const creatorMention = creatorId ? `<@${creatorId}>` : '';
|
||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
|
||||
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
|
||||
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
|
||||
await enqueueSend(interaction.channel, {
|
||||
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
|
||||
allowedMentions: { parse: ['users', 'roles'] }
|
||||
});
|
||||
|
||||
const escalationBody = CONFIG.ESCALATION_MESSAGE
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
|
||||
const escalatedEmbed = new EmbedBuilder()
|
||||
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
|
||||
.setDescription(escalationBody)
|
||||
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
|
||||
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
|
||||
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
|
||||
const escalationRow = getTicketActionRow(updatedTicketForRow);
|
||||
const escalationMsg = await enqueueSend(interaction.channel, {
|
||||
content: null,
|
||||
embeds: [escalatedEmbed],
|
||||
components: [escalationRow]
|
||||
});
|
||||
|
||||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||||
try {
|
||||
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
|
||||
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
||||
} catch (emailErr) {
|
||||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
} catch (e) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
await enqueueSend(logChan,
|
||||
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
|
||||
async function runDeescalation(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const newTier = currentTier - 1;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = newTier > 0;
|
||||
ticket.escalationTier = newTier;
|
||||
ticket.claimedBy = null;
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const state = newTier === 0 ? 'unclaimed' : 'escalated';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
if (!interaction.channel.isThread()) {
|
||||
try {
|
||||
if (newTier === 0) {
|
||||
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
|
||||
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
|
||||
} else if (newTier === 1) {
|
||||
const t2Category = isDiscordTicket
|
||||
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
|
||||
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
||||
if (t2Category) await enqueueMove(interaction.channel, t2Category);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Move error (deescalate):', e);
|
||||
}
|
||||
}
|
||||
|
||||
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
|
||||
const deescalateEmbed = new EmbedBuilder()
|
||||
.setColor(0x00BFFF)
|
||||
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
|
||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
await enqueueSend(logChan,
|
||||
`${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEscalate(interaction) {
|
||||
const reason = null;
|
||||
const level = interaction.options.getString('level');
|
||||
const nextTier = level === '3' ? 2 : 1;
|
||||
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (nextTier <= currentTier) {
|
||||
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = nextTier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({
|
||||
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'escalate', () =>
|
||||
runEscalation(interaction, ticket, nextTier, reason)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeescalate(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier === 0) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'de-escalate',
|
||||
() => runDeescalation(interaction, ticket),
|
||||
{ flags: MessageFlags.Ephemeral }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate };
|
||||
33
handlers/commands/helpers.js
Normal file
33
handlers/commands/helpers.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cross-submodule helpers for handlers/commands/*.
|
||||
*
|
||||
* Lives at this level (not in index.js) so escalation.js, close.js, etc. can
|
||||
* import without creating circular dependencies with index.js.
|
||||
*/
|
||||
const { MessageFlags } = require('discord.js');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { isStaff } = require('../../utils');
|
||||
|
||||
/**
|
||||
* Reply ephemeral and return true if the interaction is in a guild and the
|
||||
* user is not staff (so the caller should bail).
|
||||
*/
|
||||
async function requireStaffRole(interaction) {
|
||||
if (!interaction.guild) return false;
|
||||
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
|
||||
if (isStaff(interaction.member)) return false;
|
||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
||||
await interaction.reply({
|
||||
content: `This command is only available to the support team (${roleMention}).`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fetch the configured logging channel, or null if unset/missing. */
|
||||
async function fetchLoggingChannel(client) {
|
||||
if (!CONFIG.LOGGING_CHANNEL_ID) return null;
|
||||
return client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
}
|
||||
|
||||
module.exports = { requireStaffRole, fetchLoggingChannel };
|
||||
299
handlers/commands/index.js
Normal file
299
handlers/commands/index.js
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Slash command, context menu, and autocomplete dispatcher.
|
||||
*
|
||||
* Submodules own command handlers by topic:
|
||||
* helpers.js — requireStaffRole, fetchLoggingChannel
|
||||
* escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
|
||||
* close.js — handleForceClose, handleCancelClose, handleCloseTimer (+ finalize/transcript)
|
||||
* response.js — /response subcommands + handleAutocomplete
|
||||
* panel.js — handlePanel, handleSignature
|
||||
* contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
|
||||
*
|
||||
* This file holds the dispatchers, the small "remainder" handlers
|
||||
* (channel-mod, settings toggles, /help, /notifydm), and the public
|
||||
* module.exports surface that handlers/buttons.js + broccolini-discord.js
|
||||
* import from `require('./commands')`.
|
||||
*/
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { setNotifyDm } = require('../../services/staffSettings');
|
||||
const { enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||
const { logTicketEvent } = require('../../services/debugLog');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
||||
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation');
|
||||
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
||||
const { handleResponse, handleAutocomplete } = require('./response');
|
||||
const { handlePanel, handleSignature } = require('./panel');
|
||||
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
// ============================================================
|
||||
// Remainder handlers — small enough not to deserve their own module.
|
||||
// ============================================================
|
||||
|
||||
async function handleNotifyDm(interaction) {
|
||||
try {
|
||||
const setting = interaction.options.getString('setting') === 'on';
|
||||
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
||||
await interaction.reply({
|
||||
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('notifydm error:', err);
|
||||
await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, {
|
||||
ViewChannel: true,
|
||||
SendMessages: true,
|
||||
ReadMessageHistory: true
|
||||
});
|
||||
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Add user error:', err);
|
||||
await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
||||
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Remove user error:', err);
|
||||
await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransfer(interaction) {
|
||||
const member = interaction.options.getUser('member');
|
||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
||||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||
|
||||
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
|
||||
return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
try {
|
||||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel } }
|
||||
);
|
||||
|
||||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||
await interaction.reply({
|
||||
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transfer error:', err);
|
||||
await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(interaction) {
|
||||
const category = interaction.options.getChannel('category');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueMove(interaction.channel, category.id);
|
||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan,
|
||||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Move error:', err);
|
||||
await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTopic(interaction) {
|
||||
const text = interaction.options.getString('text');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueTopic(interaction.channel, text);
|
||||
await interaction.reply('Topic updated successfully.');
|
||||
} catch (err) {
|
||||
console.error('Topic error:', err);
|
||||
await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStaffThread(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'toggle') {
|
||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'name') {
|
||||
const name = interaction.options.getString('thread_name').slice(0, 100);
|
||||
CONFIG.STAFF_THREAD_NAME = name;
|
||||
return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'autorole') {
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
||||
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePinMessages(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
if (sub === 'initial') {
|
||||
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'escalation') {
|
||||
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'suppress') {
|
||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGmailPoll(interaction) {
|
||||
const requested = parseInt(interaction.options.getString('interval'), 10);
|
||||
// Defense-in-depth: the slash command's addChoices already floors at 30s, but
|
||||
// clamp the resolved ms here too so any future caller (or skewed input) can't
|
||||
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
||||
const ms = Math.max(30000, requested * 1000);
|
||||
const seconds = ms / 1000;
|
||||
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||
const { setGmailPollInterval } = require('../../broccolini-discord');
|
||||
setGmailPollInterval(ms);
|
||||
logTicketEvent('Gmail poll interval updated', [
|
||||
{ name: 'Interval', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleHelp(interaction) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket System - Commands')
|
||||
.setColor(CONFIG.EMBED_COLOR_OPEN)
|
||||
.addFields([
|
||||
{
|
||||
name: 'User Management',
|
||||
value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket'
|
||||
},
|
||||
{
|
||||
name: 'Ticket Management',
|
||||
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description'
|
||||
},
|
||||
{
|
||||
name: 'Saved Responses',
|
||||
value: '`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
||||
},
|
||||
{
|
||||
name: 'Variables (for responses)',
|
||||
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
|
||||
},
|
||||
{
|
||||
name: 'Panel System',
|
||||
value: '`/panel #channel` - Create a ticket panel for Discord-side tickets'
|
||||
},
|
||||
{
|
||||
name: 'Escalation',
|
||||
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
|
||||
}
|
||||
])
|
||||
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dispatch tables
|
||||
// ============================================================
|
||||
|
||||
const COMMAND_HANDLERS = {
|
||||
escalate: handleEscalate,
|
||||
deescalate: handleDeescalate,
|
||||
notifydm: handleNotifyDm,
|
||||
add: handleAdd,
|
||||
remove: handleRemove,
|
||||
transfer: handleTransfer,
|
||||
move: handleMove,
|
||||
staffthread: handleStaffThread,
|
||||
pinmessages: handlePinMessages,
|
||||
gmailpoll: handleGmailPoll,
|
||||
closetimer: handleCloseTimer,
|
||||
'cancel-close': handleCancelClose,
|
||||
'force-close': handleForceClose,
|
||||
topic: handleTopic,
|
||||
response: handleResponse,
|
||||
signature: handleSignature,
|
||||
help: handleHelp,
|
||||
panel: handlePanel
|
||||
};
|
||||
|
||||
const CONTEXT_MENU_HANDLERS = {
|
||||
'Create Ticket From Message': handleCreateTicketFromMessage,
|
||||
'View User Tickets': handleViewUserTickets
|
||||
};
|
||||
|
||||
/**
|
||||
* Slash-command dispatcher. /help is open to everyone; everything else
|
||||
* requires the staff role.
|
||||
*/
|
||||
async function handleCommand(interaction) {
|
||||
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
||||
const handler = COMMAND_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
/** Context-menu dispatcher. All entries are staff-only. */
|
||||
async function handleContextMenu(interaction) {
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
const handler = CONTEXT_MENU_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleCommand,
|
||||
handleContextMenu,
|
||||
handleAutocomplete,
|
||||
runEscalation,
|
||||
runDeescalation
|
||||
};
|
||||
133
handlers/commands/panel.js
Normal file
133
handlers/commands/panel.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* /panel — create a ticket-creation panel embed in a chosen channel.
|
||||
* Also hosts /signature (modal for staff personal email signature) since
|
||||
* both are user-facing UX-flow commands without their own dedicated module.
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
MessageFlags,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
|
||||
async function handlePanel(interaction) {
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null
|
||||
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
|
||||
const description = interaction.options.getString('description') ||
|
||||
'Need help? Click below to create a ticket. 🎟';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(0x2ecc71)
|
||||
.setThumbnail(CONFIG.LOGO_URL || null)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
const row = buildPanelButtonRow(panelType);
|
||||
|
||||
try {
|
||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||
await interaction.reply({ content: `Panel created in ${channel}!`, flags: MessageFlags.Ephemeral });
|
||||
} catch (err) {
|
||||
console.error('Panel creation error:', err);
|
||||
await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
function buildPanelButtonRow(panelType) {
|
||||
if (panelType === 'both') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket (channel)')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
}
|
||||
if (panelType === 'thread') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵')
|
||||
);
|
||||
}
|
||||
if (panelType === 'category') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
}
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('✅')
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSignature(interaction) {
|
||||
try {
|
||||
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`signature_modal_${interaction.user.id}`)
|
||||
.setTitle('Staff Signature Settings');
|
||||
|
||||
const valedictionInput = new TextInputBuilder()
|
||||
.setCustomId('valediction')
|
||||
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.valediction || '');
|
||||
|
||||
const displayNameInput = new TextInputBuilder()
|
||||
.setCustomId('display_name')
|
||||
.setLabel('Display Name (e.g. "Support Team")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.displayName || '');
|
||||
|
||||
const taglineInput = new TextInputBuilder()
|
||||
.setCustomId('tagline')
|
||||
.setLabel('Tagline (e.g. "Technical Support Specialist")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.tagline || '');
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder().addComponents(valedictionInput),
|
||||
new ActionRowBuilder().addComponents(displayNameInput),
|
||||
new ActionRowBuilder().addComponents(taglineInput)
|
||||
);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
} catch (err) {
|
||||
console.error('Signature command error:', err);
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({ content: 'Failed to open signature settings.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handlePanel, handleSignature };
|
||||
165
handlers/commands/response.js
Normal file
165
handlers/commands/response.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* /response (saved tags) and its autocomplete.
|
||||
*
|
||||
* /response is itself a router over its subcommands:
|
||||
* send / create / edit / delete / list
|
||||
* The autocomplete handler also lives here since the only autocompleting
|
||||
* slash command is /response.
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
MessageFlags
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { replaceVariables } = require('../../utils');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
|
||||
const Tag = mongoose.model('Tag');
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleResponse(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const handler = RESPONSE_SUBCOMMANDS[subcommand];
|
||||
if (!handler) return;
|
||||
try {
|
||||
await handler(interaction);
|
||||
} catch (err) {
|
||||
logError('response-command', err, interaction).catch(() => {});
|
||||
const errorMsg = '❌ An error occurred while processing the response command.';
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply(errorMsg);
|
||||
} else {
|
||||
await interaction.reply({ content: errorMsg, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseSend(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const tag = await Tag.findOne({ name }).lean();
|
||||
if (!tag) {
|
||||
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
const context = {
|
||||
ticket: ticket || {},
|
||||
staff: {
|
||||
username: interaction.user.username,
|
||||
displayName: interaction.member?.displayName,
|
||||
mention: interaction.user.toString()
|
||||
},
|
||||
guild: interaction.guild
|
||||
};
|
||||
|
||||
const content = replaceVariables(tag.content, context);
|
||||
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
|
||||
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
|
||||
// Disable mention parsing so a `@everyone` in a tag body never pings.
|
||||
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
||||
}
|
||||
|
||||
async function handleResponseCreate(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
try {
|
||||
await Tag.create({ name, content, createdBy: interaction.user.id });
|
||||
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, flags: MessageFlags.Ephemeral });
|
||||
} catch (err) {
|
||||
if (err.code === 11000 || err.message?.includes('duplicate')) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
logError('tag-create', err, interaction).catch(() => {});
|
||||
await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseEdit(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
try {
|
||||
const result = await Tag.updateOne({ name }, { $set: { content } });
|
||||
if (result.matchedCount === 0) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
} catch (err) {
|
||||
logError('tag-edit', err, interaction).catch(() => {});
|
||||
await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseDelete(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
// Use :: delimiter so tag names with underscores parse correctly (Discord customId max 100 chars).
|
||||
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
|
||||
const confirmRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(customId)
|
||||
.setLabel('Yes, Delete Tag')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('cancel_delete_tag')
|
||||
.setLabel('Cancel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return interaction.reply({
|
||||
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
|
||||
components: [confirmRow],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
async function handleResponseList(interaction) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
||||
if (!tags || tags.length === 0) {
|
||||
return interaction.editReply({ content: '📋 No tags available.' });
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('📋 Available Saved Responses')
|
||||
.setDescription(
|
||||
tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n')
|
||||
)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setFooter({ text: `Total: ${tags.length} tags` });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
const RESPONSE_SUBCOMMANDS = {
|
||||
send: handleResponseSend,
|
||||
create: handleResponseCreate,
|
||||
edit: handleResponseEdit,
|
||||
delete: handleResponseDelete,
|
||||
list: handleResponseList
|
||||
};
|
||||
|
||||
/** Autocomplete handler. Currently only /response uses it. */
|
||||
async function handleAutocomplete(interaction) {
|
||||
if (interaction.commandName !== 'response') return;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
|
||||
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
|
||||
const filtered = tags
|
||||
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
|
||||
.slice(0, 25)
|
||||
.map(t => ({ name: t.name, value: t.name }));
|
||||
|
||||
await interaction.respond(filtered);
|
||||
}
|
||||
|
||||
module.exports = { handleResponse, handleAutocomplete };
|
||||
@@ -7,6 +7,7 @@ const { extractRawEmail, isStaff } = require('../utils');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { updateTicketActivity } = require('../services/tickets');
|
||||
const { getNotifyDm } = require('../services/staffSettings');
|
||||
const { logError } = require('../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
@@ -24,13 +25,17 @@ async function handleDiscordReply(m) {
|
||||
Ticket.updateOne(
|
||||
{ discordThreadId: m.channel.id },
|
||||
{ $set: { lastActivity: new Date() } }
|
||||
).catch(() => {});
|
||||
).catch(err => logError('updateActivity', err).catch(() => {}));
|
||||
|
||||
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||||
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||||
const dmEnabled = await getNotifyDm(ticket.claimerId);
|
||||
if (dmEnabled) {
|
||||
const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null);
|
||||
// Cache-first: GuildMembers intent keeps the cache populated; only fetch
|
||||
// on miss (e.g. cold cache after restart). Avoids a REST round-trip on
|
||||
// every customer reply in a busy ticket.
|
||||
const staffMember = m.guild.members.cache.get(ticket.claimerId)
|
||||
|| await m.guild.members.fetch(ticket.claimerId).catch(() => null);
|
||||
if (staffMember) {
|
||||
const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`;
|
||||
await staffMember
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Both handlers/commands.js and handlers/buttons.js use these to avoid
|
||||
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
|
||||
*/
|
||||
const { MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { logError } = require('../services/debugLog');
|
||||
|
||||
@@ -20,7 +21,7 @@ const Ticket = mongoose.model('Ticket');
|
||||
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: missingMessage, ephemeral: true });
|
||||
await interaction.reply({ content: missingMessage, flags: MessageFlags.Ephemeral });
|
||||
return null;
|
||||
}
|
||||
return ticket;
|
||||
@@ -34,18 +35,18 @@ async function findTicketForChannel(interaction, missingMessage = 'This channel
|
||||
* @param {import('discord.js').Interaction} interaction
|
||||
* @param {string} verb
|
||||
* @param {() => Promise<void>} fn
|
||||
* @param {{ ephemeral?: boolean }} [opts]
|
||||
* @param {{ flags?: number }} [opts] - pass `MessageFlags.Ephemeral` for ephemeral defer
|
||||
*/
|
||||
async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) {
|
||||
async function runDeferred(interaction, verb, fn, { flags } = {}) {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral });
|
||||
await interaction.deferReply(flags ? { flags } : {});
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(`${verb} error:`, err);
|
||||
logError(verb, err, interaction).catch(() => {});
|
||||
const msg = `Failed to ${verb} this ticket.`;
|
||||
await interaction.editReply({ content: msg }).catch(() =>
|
||||
interaction.followUp({ content: msg, ephemeral: true }).catch(() => {})
|
||||
interaction.followUp({ content: msg, flags: MessageFlags.Ephemeral }).catch(() => {})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const ticketSchema = new mongoose.Schema({
|
||||
lastActivity: Date,
|
||||
welcomeMessageId: String,
|
||||
claimerId: String,
|
||||
creatorId: String,
|
||||
parentCategoryId: String,
|
||||
pendingDelete: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
1531
package-lock.json
generated
1531
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,11 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"googleapis": "^171.4.0",
|
||||
"mongoose": "^6.12.0"
|
||||
"mongoose": "^8.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mongodb": "^7.1.0"
|
||||
"mongodb": "^7.1.0",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"name": "broccolini-bot",
|
||||
"version": "1.0.0",
|
||||
@@ -16,6 +17,7 @@
|
||||
"main": "broccolini-discord.js",
|
||||
"scripts": {
|
||||
"start": "node broccolini-discord.js",
|
||||
"test": "vitest run",
|
||||
"test-mongodb": "node scripts/test-mongodb.js"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -19,6 +19,17 @@ const internalLimiter = rateLimit({
|
||||
message: { error: 'Too many requests, please try again later.' }
|
||||
});
|
||||
|
||||
// /restart calls process.exit; defense-in-depth tighter floor in case the
|
||||
// shared INTERNAL_API_SECRET ever leaks. 2/min is enough for an operator-
|
||||
// driven retry but not enough to crash-loop the container.
|
||||
const restartLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 2,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many restart attempts.' }
|
||||
});
|
||||
|
||||
router.use(internalLimiter);
|
||||
|
||||
// Middleware: verify internal secret
|
||||
@@ -111,7 +122,7 @@ router.get('/discord/guild', async (req, res) => {
|
||||
// POST /restart — restart the bot process
|
||||
let scheduledRestart = null;
|
||||
|
||||
router.post('/restart', express.json(), (req, res) => {
|
||||
router.post('/restart', restartLimiter, express.json(), (req, res) => {
|
||||
const { mode, scheduledFor } = req.body;
|
||||
|
||||
if (mode === 'immediate') {
|
||||
|
||||
88
scripts/backfill-creatorId.js
Normal file
88
scripts/backfill-creatorId.js
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-shot backfill for Ticket.creatorId on Discord-originated tickets.
|
||||
*
|
||||
* Modal-created tickets (`discord-${ts}-${userId}`): tail segment is the user ID — extract it.
|
||||
* Context-menu tickets (`discord-msg-${ts}-${msgId}`): tail segment is the *message* ID, not the
|
||||
* user ID. Set creatorId = null and let runtime code fall through to the default-name path.
|
||||
* Recovering these would require a Discord API fetch per message, which is unreliable for
|
||||
* already-deleted ticket channels.
|
||||
*
|
||||
* Idempotent: skips tickets that already have creatorId set.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/backfill-creatorId.js # dry-run, prints summary only
|
||||
* node scripts/backfill-creatorId.js --apply # writes
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { connectMongoDB, closeMongoDB, mongoose } = require('../db-connection');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const MODAL_RE = /^discord-\d+-(\d{17,20})$/;
|
||||
|
||||
async function main() {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
console.error('MONGODB_URI not set');
|
||||
process.exit(1);
|
||||
}
|
||||
await connectMongoDB(process.env.MONGODB_URI);
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
const candidates = await Ticket.find({
|
||||
gmailThreadId: /^discord-/,
|
||||
creatorId: { $in: [null, undefined, ''] }
|
||||
}).select('gmailThreadId creatorId').lean();
|
||||
|
||||
let modalHits = 0;
|
||||
let msgSkipped = 0;
|
||||
let unknown = 0;
|
||||
const ops = [];
|
||||
|
||||
for (const t of candidates) {
|
||||
const id = t.gmailThreadId;
|
||||
const modalMatch = id.match(MODAL_RE);
|
||||
if (modalMatch) {
|
||||
modalHits++;
|
||||
ops.push({
|
||||
updateOne: {
|
||||
filter: { _id: t._id },
|
||||
update: { $set: { creatorId: modalMatch[1] } }
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (id.startsWith('discord-msg-')) {
|
||||
msgSkipped++;
|
||||
continue;
|
||||
}
|
||||
unknown++;
|
||||
}
|
||||
|
||||
console.log(`Scanned ${candidates.length} Discord-originated tickets without creatorId.`);
|
||||
console.log(` Modal-pattern recoverable: ${modalHits}`);
|
||||
console.log(` Context-menu (unrecoverable, leaving null): ${msgSkipped}`);
|
||||
console.log(` Unknown shape: ${unknown}`);
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('\nDry-run only. Re-run with --apply to write changes.');
|
||||
await closeMongoDB();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ops.length === 0) {
|
||||
console.log('Nothing to write.');
|
||||
await closeMongoDB();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await Ticket.bulkWrite(ops, { ordered: false });
|
||||
console.log(`Wrote ${res.modifiedCount} updates.`);
|
||||
await closeMongoDB();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -113,6 +113,81 @@ function enqueueMove(channel, categoryId) {
|
||||
return next;
|
||||
}
|
||||
|
||||
// Shares renameChains so a permissionOverwrite mutation serializes with pending
|
||||
// renames/moves on the same channel. Mode 'create' calls
|
||||
// `channel.permissionOverwrites.create(id, perms)`; 'delete' calls
|
||||
// `channel.permissionOverwrites.delete(id)`. No coalescing.
|
||||
function enqueueOverwrite(channel, id, perms, mode = 'create') {
|
||||
let entry = renameChains.get(channel.id);
|
||||
if (!entry) {
|
||||
entry = { chain: Promise.resolve(), pendingName: null };
|
||||
renameChains.set(channel.id, entry);
|
||||
}
|
||||
|
||||
const next = entry.chain.catch(() => {}).then(() =>
|
||||
mode === 'delete'
|
||||
? channel.permissionOverwrites.delete(id)
|
||||
: channel.permissionOverwrites.create(id, perms)
|
||||
);
|
||||
entry.chain = next;
|
||||
|
||||
next.catch((err) => {
|
||||
logWarn('overwriteQueue', `Overwrite ${mode} failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
|
||||
const status = err && err.status;
|
||||
const msg = (err && err.message) || String(err);
|
||||
if (status === 401 || status === 403) {
|
||||
logError(
|
||||
'overwriteQueue:token/permission',
|
||||
new Error(`${status} channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
|
||||
).catch(() => {});
|
||||
} else if (status === 429) {
|
||||
logError(
|
||||
'overwriteQueue:ratelimited',
|
||||
new Error(`429 channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
|
||||
).catch(() => {});
|
||||
}
|
||||
}).finally(() => {
|
||||
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
|
||||
renameChains.delete(channel.id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// Shares renameChains so setTopic serializes with pending renames/moves.
|
||||
function enqueueTopic(channel, text) {
|
||||
let entry = renameChains.get(channel.id);
|
||||
if (!entry) {
|
||||
entry = { chain: Promise.resolve(), pendingName: null };
|
||||
renameChains.set(channel.id, entry);
|
||||
}
|
||||
|
||||
const next = entry.chain.catch(() => {}).then(() => channel.setTopic(text));
|
||||
entry.chain = next;
|
||||
|
||||
next.catch((err) => {
|
||||
logWarn('topicQueue', `Topic set failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
|
||||
const status = err && err.status;
|
||||
const msg = (err && err.message) || String(err);
|
||||
if (status === 401 || status === 403) {
|
||||
logError(
|
||||
'topicQueue:token/permission',
|
||||
new Error(`${status} channel=${channel.id}: ${msg}`)
|
||||
).catch(() => {});
|
||||
} else if (status === 429) {
|
||||
logError(
|
||||
'topicQueue:ratelimited',
|
||||
new Error(`429 channel=${channel.id}: ${msg}`)
|
||||
).catch(() => {});
|
||||
}
|
||||
}).finally(() => {
|
||||
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
|
||||
renameChains.delete(channel.id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// Per-channel promise chain for send ordering and to prevent interleaving.
|
||||
const sendChains = new Map();
|
||||
|
||||
@@ -157,4 +232,4 @@ function enqueueDelete(channel) {
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete };
|
||||
module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };
|
||||
|
||||
@@ -150,7 +150,16 @@ function writeEnvFile(updates) {
|
||||
|
||||
const roundtrip = readEnvFile();
|
||||
if (roundtrip.size !== expected) {
|
||||
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
|
||||
const expectedKeys = new Set(updates.keys());
|
||||
const actualKeys = new Set(roundtrip.keys());
|
||||
const missing = [...expectedKeys].filter(k => !actualKeys.has(k));
|
||||
const extra = [...actualKeys].filter(k => !expectedKeys.has(k));
|
||||
throw new Error(
|
||||
`writeEnvFile: key count mismatch after write ` +
|
||||
`(expected ${expected}, got ${roundtrip.size})` +
|
||||
(missing.length ? `. Missing: [${missing.join(', ')}]` : '') +
|
||||
(extra.length ? `. Extra: [${extra.join(', ')}]` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,24 @@ function setClient(c) {
|
||||
client = c;
|
||||
}
|
||||
|
||||
// --- PII redaction ---
|
||||
|
||||
// Email addresses (loose regex — covers most RFC 5321 local parts that show up
|
||||
// in support traffic) and Discord snowflakes (18–20 digit numeric IDs) get
|
||||
// redacted before stack/message text reaches the debug channel. Both can land
|
||||
// in error stacks via senderEmail interpolation, channel IDs in error
|
||||
// messages, etc. — redacting at the boundary keeps the debug channel useful
|
||||
// for triage without leaking customer addresses or staff member IDs.
|
||||
const EMAIL_REDACT_RE = /[\w.+-]+@[\w.-]+\.\w+/g;
|
||||
const SNOWFLAKE_REDACT_RE = /\b\d{18,20}\b/g;
|
||||
|
||||
function redactPII(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(EMAIL_REDACT_RE, '[EMAIL_REDACTED]')
|
||||
.replace(SNOWFLAKE_REDACT_RE, '[ID_REDACTED]');
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async function sendToChannel(channelId, embed, overrideClient) {
|
||||
@@ -38,9 +56,10 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
||||
const commandLine = (interaction?.commandName || interaction?.customId)
|
||||
? `Command/Button: ${interaction.commandName || interaction.customId}\n`
|
||||
: '';
|
||||
const stack = (error.stack || error.message || String(error)).slice(0, 1500);
|
||||
const message = redactPII(error.message || String(error));
|
||||
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
|
||||
await channel.send({
|
||||
content: `\`[${context}]\` ${error.message || String(error)}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
||||
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore send failures
|
||||
@@ -52,7 +71,7 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
||||
async function logWarn(context, message, overrideClient = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Warning: ${context}`)
|
||||
.setDescription(String(message).slice(0, 4000))
|
||||
.setDescription(redactPII(String(message)).slice(0, 4000))
|
||||
.setColor(0xFFFF00)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||
|
||||
@@ -31,9 +31,9 @@ async function pinMessage(message, client) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code === 30003) {
|
||||
await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
|
||||
logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
|
||||
} else {
|
||||
await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
|
||||
logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
* is logged via logWarn.
|
||||
* - invitable: false means only staff with MANAGE_THREADS can add additional
|
||||
* members — this is intentional for privacy.
|
||||
* - guild.members.fetch() in addRoleMembersToThread can be slow on large
|
||||
* servers. The 300ms delay between adds avoids the thread member add rate
|
||||
* limit (approximately 5/second).
|
||||
* - addRoleMembersToThread reads from role.members (cache-derived) and only
|
||||
* falls back to a scoped guild.members.fetch on cache miss. The 300ms
|
||||
* delay between adds avoids the thread member add rate limit (~5/sec).
|
||||
* It runs via setImmediate so it doesn't block ticket creation.
|
||||
*/
|
||||
const { ChannelType } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
@@ -39,7 +40,11 @@ async function createStaffThread(channel, client) {
|
||||
});
|
||||
|
||||
if (CONFIG.STAFF_THREAD_AUTO_ADD_ROLE && CONFIG.STAFF_THREAD_ROLE_ID) {
|
||||
await addRoleMembersToThread(thread, channel.guild, client);
|
||||
// Run off the critical path — the add loop is rate-limited at 300ms per
|
||||
// member and would block ticket creation for ~15s on a 50-member role.
|
||||
setImmediate(() => {
|
||||
addRoleMembersToThread(thread, channel.guild, client).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
return thread;
|
||||
@@ -48,30 +53,40 @@ async function createStaffThread(channel, client) {
|
||||
if (err.code === 50024 || err.code === 160004) {
|
||||
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
|
||||
}
|
||||
await logError('staffThread:create', err, null, client).catch(() => {});
|
||||
logError('staffThread:create', err, null, client).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all members of the staff role to the thread.
|
||||
*
|
||||
* Prefers role.members (computed from guild.members.cache, kept in sync via
|
||||
* the GuildMembers gateway intent — see broccolini-discord.js intents). Only
|
||||
* falls back to a scoped guild.members.fetch on cache miss (e.g. cold cache
|
||||
* just after restart). Previously called the unscoped guild.members.fetch()
|
||||
* on every ticket creation, which chunked all members of the guild — wasted
|
||||
* gateway/REST budget and added ~15s to ticket creation on busy guilds.
|
||||
*/
|
||||
async function addRoleMembersToThread(thread, guild, client) {
|
||||
try {
|
||||
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
|
||||
if (!role) return;
|
||||
|
||||
await guild.members.fetch();
|
||||
const members = guild.members.cache.filter(m =>
|
||||
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot
|
||||
);
|
||||
let members = role.members.filter(m => !m.user.bot);
|
||||
if (members.size === 0) {
|
||||
// Cache cold (first ticket after restart). withPresences: false skips
|
||||
// the presence sync, which is irrelevant for thread-add and expensive.
|
||||
await guild.members.fetch({ withPresences: false }).catch(() => {});
|
||||
members = role.members.filter(m => !m.user.bot);
|
||||
}
|
||||
|
||||
for (const [, member] of members) {
|
||||
await thread.members.add(member.id).catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
} catch (err) {
|
||||
await logError('staffThread:addMembers', err, null, client).catch(() => {});
|
||||
logError('staffThread:addMembers', err, null, client).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,12 @@ function toDiscordSafeName(str) {
|
||||
*/
|
||||
async function resolveCreatorNickname(guild, ticket) {
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
const creatorUserId = ticket.gmailThreadId.split('-').pop();
|
||||
// Prefer ticket.creatorId (stored on creation). Legacy fallback parses the
|
||||
// tail segment, which is correct for discord-${ts}-${userId} but returns
|
||||
// the message ID for discord-msg-${ts}-${msgId} — skip the parse for those.
|
||||
const creatorUserId = ticket.creatorId
|
||||
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||||
if (!creatorUserId) return getSenderLocal(ticket.senderEmail);
|
||||
try {
|
||||
const member = await guild.members.fetch(creatorUserId);
|
||||
return member.displayName;
|
||||
@@ -305,14 +310,16 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
|
||||
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
|
||||
|
||||
setTimeout(() => {
|
||||
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
|
||||
const { trackTimeout } = require('../broccolini-discord');
|
||||
trackTimeout(setTimeout(() => {
|
||||
enqueueDelete(channel).then(() => {
|
||||
withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $unset: { pendingDelete: '' } }
|
||||
)).catch(() => {});
|
||||
}).catch(() => {});
|
||||
}, 5000);
|
||||
}, 5000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
|
||||
263
tests/configSchema.test.js
Normal file
263
tests/configSchema.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ALLOWED_CONFIG_KEYS, getValidator } from '../services/configSchema.js';
|
||||
|
||||
describe('ALLOWED_CONFIG_KEYS', () => {
|
||||
it('is a non-empty Set', () => {
|
||||
expect(ALLOWED_CONFIG_KEYS).toBeInstanceOf(Set);
|
||||
expect(ALLOWED_CONFIG_KEYS.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes well-known runtime config keys', () => {
|
||||
for (const k of [
|
||||
'TICKET_CATEGORY_ID',
|
||||
'AUTO_CLOSE_ENABLED',
|
||||
'GMAIL_POLL_INTERVAL_SECONDS',
|
||||
'EMBED_COLOR_OPEN',
|
||||
'GAME_LIST'
|
||||
]) {
|
||||
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not contain stale removed keys', () => {
|
||||
for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) {
|
||||
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidator: type inference', () => {
|
||||
it('treats *_ENABLED as boolean', () => {
|
||||
const v = getValidator('AUTO_CLOSE_ENABLED');
|
||||
expect(v.type).toBe('boolean');
|
||||
});
|
||||
|
||||
it('treats *_ID as discord_id', () => {
|
||||
expect(getValidator('TICKET_CATEGORY_ID').type).toBe('discord_id');
|
||||
});
|
||||
|
||||
it('overrides ROLE_ID_TO_PING (mid-key _ID) as discord_id', () => {
|
||||
expect(getValidator('ROLE_ID_TO_PING').type).toBe('discord_id');
|
||||
});
|
||||
|
||||
it('treats *_HOURS / *_MINUTES / *_SECONDS as integer', () => {
|
||||
expect(getValidator('AUTO_CLOSE_AFTER_HOURS').type).toBe('integer');
|
||||
expect(getValidator('RATE_LIMIT_WINDOW_MINUTES').type).toBe('integer');
|
||||
expect(getValidator('GMAIL_POLL_INTERVAL_SECONDS').type).toBe('integer');
|
||||
});
|
||||
|
||||
it('treats *_COLOR as hex_color', () => {
|
||||
expect(getValidator('EMBED_COLOR_OPEN').type).toBe('hex_color');
|
||||
});
|
||||
|
||||
it('treats LOGO_URL as url', () => {
|
||||
expect(getValidator('LOGO_URL').type).toBe('url');
|
||||
});
|
||||
|
||||
it('treats *_EMAIL as email', () => {
|
||||
expect(getValidator('SUPPORT_EMAIL').type).toBe('email');
|
||||
});
|
||||
|
||||
it('falls back to string for unknown shapes', () => {
|
||||
expect(getValidator('TICKET_CATEGORY_NAME').type).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean validator', () => {
|
||||
const v = getValidator('AUTO_CLOSE_ENABLED');
|
||||
|
||||
it('accepts the literal true/false', () => {
|
||||
expect(v.validate(true)).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate(false)).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('accepts string "true"/"false"', () => {
|
||||
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('rejects garbage', () => {
|
||||
const res = v.validate('maybe');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/true or false/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integer validator', () => {
|
||||
const v = getValidator('AUTO_CLOSE_AFTER_HOURS');
|
||||
|
||||
it('coerces a numeric string to a number', () => {
|
||||
expect(v.validate('72')).toEqual({ ok: true, coerced: 72 });
|
||||
});
|
||||
|
||||
it('accepts zero', () => {
|
||||
expect(v.validate('0')).toEqual({ ok: true, coerced: 0 });
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
const res = v.validate('abc');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/whole number/);
|
||||
});
|
||||
|
||||
it('rejects floats', () => {
|
||||
expect(v.validate('1.5').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative integers', () => {
|
||||
expect(v.validate('-5').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty input as ok with empty coerced value', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
expect(v.validate(null)).toEqual({ ok: true, coerced: '' });
|
||||
expect(v.validate(undefined)).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hex_color validator', () => {
|
||||
const v = getValidator('EMBED_COLOR_OPEN');
|
||||
|
||||
it('accepts 0xRRGGBB form', () => {
|
||||
expect(v.validate('0xFF00AA')).toEqual({ ok: true, coerced: '0xFF00AA' });
|
||||
});
|
||||
|
||||
it('accepts #RRGGBB form and normalizes to 0xRRGGBB', () => {
|
||||
expect(v.validate('#ff00aa')).toEqual({ ok: true, coerced: '0xFF00AA' });
|
||||
});
|
||||
|
||||
it('accepts bare RRGGBB and normalizes', () => {
|
||||
expect(v.validate('00ff00')).toEqual({ ok: true, coerced: '0x00FF00' });
|
||||
});
|
||||
|
||||
it('rejects 3-digit shorthand', () => {
|
||||
expect(v.validate('#abc').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects garbage', () => {
|
||||
expect(v.validate('purple').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('url validator (LOGO_URL)', () => {
|
||||
const v = getValidator('LOGO_URL');
|
||||
|
||||
it('accepts a full URL', () => {
|
||||
expect(v.validate('https://example.com/logo.png')).toEqual({
|
||||
ok: true,
|
||||
coerced: 'https://example.com/logo.png'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bare hostnames', () => {
|
||||
expect(v.validate('example.com').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('discord_id validator', () => {
|
||||
const v = getValidator('TICKET_CATEGORY_ID');
|
||||
|
||||
it('accepts an 18-digit snowflake', () => {
|
||||
expect(v.validate('123456789012345678')).toEqual({
|
||||
ok: true,
|
||||
coerced: '123456789012345678'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a 20-digit snowflake', () => {
|
||||
const id = '12345678901234567890';
|
||||
expect(v.validate(id)).toEqual({ ok: true, coerced: id });
|
||||
});
|
||||
|
||||
it('rejects too-short IDs', () => {
|
||||
expect(v.validate('12345').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
expect(v.validate('not-an-id').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('discord_id_list validator', () => {
|
||||
// ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it
|
||||
// hits the string fallback. discord_id_list only fires for `*_IDS` keys, so
|
||||
// exercise it with a hypothetical name.
|
||||
const v = getValidator('STAFF_USER_IDS');
|
||||
|
||||
it('infers type discord_id_list for *_IDS keys', () => {
|
||||
expect(v.type).toBe('discord_id_list');
|
||||
});
|
||||
|
||||
it('accepts a single ID', () => {
|
||||
expect(v.validate('123456789012345678'))
|
||||
.toEqual({ ok: true, coerced: '123456789012345678' });
|
||||
});
|
||||
|
||||
it('accepts a comma-separated list and trims spaces', () => {
|
||||
expect(v.validate('123456789012345678, 987654321098765432'))
|
||||
.toEqual({ ok: true, coerced: '123456789012345678,987654321098765432' });
|
||||
});
|
||||
|
||||
it('rejects if any segment is not a snowflake', () => {
|
||||
const res = v.validate('123456789012345678,nope');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/not a Discord ID/);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('string validator (fallback)', () => {
|
||||
const v = getValidator('TICKET_CATEGORY_NAME');
|
||||
|
||||
it('coerces "true"/"false" to booleans (legacy)', () => {
|
||||
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('coerces numeric-looking strings to numbers (legacy)', () => {
|
||||
expect(v.validate('42')).toEqual({ ok: true, coerced: 42 });
|
||||
expect(v.validate('3.14')).toEqual({ ok: true, coerced: 3.14 });
|
||||
});
|
||||
|
||||
it('passes plain strings through', () => {
|
||||
expect(v.validate('Open Tickets')).toEqual({ ok: true, coerced: 'Open Tickets' });
|
||||
});
|
||||
|
||||
it('passes empty string through unchanged', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
|
||||
it('rejects null', () => {
|
||||
expect(v.validate(null).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('email validator', () => {
|
||||
const v = getValidator('SUPPORT_EMAIL');
|
||||
|
||||
it('accepts valid email', () => {
|
||||
expect(v.validate('support@example.com'))
|
||||
.toEqual({ ok: true, coerced: 'support@example.com' });
|
||||
});
|
||||
|
||||
it('rejects malformed strings', () => {
|
||||
expect(v.validate('not-an-email').ok).toBe(false);
|
||||
expect(v.validate('a@').ok).toBe(false);
|
||||
expect(v.validate('@b').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
241
tests/utils.test.js
Normal file
241
tests/utils.test.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
extractRawEmail,
|
||||
escapeHtml,
|
||||
sanitizeEmbedText,
|
||||
truncateEmbedDescription,
|
||||
replaceVariables,
|
||||
getPriorityEmoji,
|
||||
safeEqual,
|
||||
isStaff
|
||||
} from '../utils.js';
|
||||
|
||||
describe('stripEmailQuotes', () => {
|
||||
it('strips "On X wrote:" reply quote', () => {
|
||||
const input = 'My reply.\nOn Mon, May 5, 2025 at 1:00 PM Bob <bob@x.com> wrote:\n> previous message';
|
||||
expect(stripEmailQuotes(input)).toBe('My reply.');
|
||||
});
|
||||
|
||||
it('strips "From: …" reply header block', () => {
|
||||
const input = 'New reply text.\nFrom: Bob <bob@x.com>\nSent: Monday\nSubject: Re: foo';
|
||||
expect(stripEmailQuotes(input)).toBe('New reply text.');
|
||||
});
|
||||
|
||||
it('strips "_____" signature underline', () => {
|
||||
const input = 'My message.\n_____\nold thread content';
|
||||
expect(stripEmailQuotes(input)).toBe('My message.');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripEmailQuotes('')).toBe('');
|
||||
});
|
||||
|
||||
it('trims whitespace when no marker is found', () => {
|
||||
expect(stripEmailQuotes(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('keeps body intact when "On" appears mid-text without "wrote:"', () => {
|
||||
expect(stripEmailQuotes('I clicked On the button.')).toBe('I clicked On the button.');
|
||||
});
|
||||
|
||||
it('normalizes CRLF before scanning', () => {
|
||||
const input = 'New reply.\r\nOn Monday Bob <b@x.com> wrote:\r\n> quoted';
|
||||
expect(stripEmailQuotes(input)).toBe('New reply.');
|
||||
});
|
||||
|
||||
it('picks earliest cutoff when multiple markers match', () => {
|
||||
// Earlier in the body: "On X wrote:". Later: "_____" underline.
|
||||
// The earliest cutoff is the reply marker, not the underline.
|
||||
const input = 'My new reply.\nOn Mon Bob wrote:\n> quoted text\n_____\nsignature';
|
||||
expect(stripEmailQuotes(input)).toBe('My new reply.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMobileFooter', () => {
|
||||
it('removes "Sent from my iPhone"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my iPhone').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Sent from my Android"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my Android').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Sent from my Galaxy"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my Galaxy').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Get Outlook for iOS"', () => {
|
||||
expect(stripMobileFooter('Hi\nGet Outlook for iOS').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('returns input unchanged when no footer present', () => {
|
||||
expect(stripMobileFooter('Just a normal message')).toBe('Just a normal message');
|
||||
});
|
||||
|
||||
it('returns null/undefined unchanged', () => {
|
||||
expect(stripMobileFooter(null)).toBe(null);
|
||||
expect(stripMobileFooter(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(stripMobileFooter('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRawEmail', () => {
|
||||
it('extracts address from "Name <email>" form', () => {
|
||||
expect(extractRawEmail('Bob <bob@example.com>')).toBe('bob@example.com');
|
||||
});
|
||||
|
||||
it('returns trimmed input when angle brackets absent', () => {
|
||||
expect(extractRawEmail(' bob@example.com ')).toBe('bob@example.com');
|
||||
});
|
||||
|
||||
it('handles quoted name', () => {
|
||||
expect(extractRawEmail('"Bob, the Developer" <bob@example.com>')).toBe('bob@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes <, >, &, ", \'', () => {
|
||||
expect(escapeHtml('<script>alert("xss")</script>'))
|
||||
.toBe('<script>alert("xss")</script>');
|
||||
expect(escapeHtml("a & b's <foo>")).toBe('a & b's <foo>');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('passes through plain text unchanged', () => {
|
||||
expect(escapeHtml('plain text')).toBe('plain text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeEmbedText', () => {
|
||||
it('replaces triple-backticks to prevent code-block escape', () => {
|
||||
expect(sanitizeEmbedText('```injected```')).toBe("'''injected'''");
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitizeEmbedText(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(sanitizeEmbedText(null)).toBe('');
|
||||
expect(sanitizeEmbedText(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateEmbedDescription', () => {
|
||||
it('returns short strings unchanged', () => {
|
||||
expect(truncateEmbedDescription('hi')).toBe('hi');
|
||||
});
|
||||
|
||||
it('truncates at default 4096 with ellipsis', () => {
|
||||
const big = 'a'.repeat(5000);
|
||||
const out = truncateEmbedDescription(big);
|
||||
expect(out.length).toBe(4096);
|
||||
expect(out.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('respects custom max', () => {
|
||||
expect(truncateEmbedDescription('abcdef', 5)).toBe('ab...');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(truncateEmbedDescription(null)).toBe('');
|
||||
expect(truncateEmbedDescription(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceVariables', () => {
|
||||
it('substitutes ticket fields', () => {
|
||||
const ctx = {
|
||||
ticket: {
|
||||
sender_name: 'Alice',
|
||||
senderEmail: 'alice@x.com',
|
||||
ticketNumber: 42,
|
||||
subject: 'Help'
|
||||
}
|
||||
};
|
||||
const out = replaceVariables('User {ticket.user} ({ticket.email}) #{ticket.number} - {ticket.subject}', ctx);
|
||||
expect(out).toBe('User Alice (alice@x.com) #42 - Help');
|
||||
});
|
||||
|
||||
it('falls back when fields missing', () => {
|
||||
const out = replaceVariables('{ticket.user} {ticket.email} {ticket.subject}', { ticket: {} });
|
||||
expect(out).toBe('Unknown No subject');
|
||||
});
|
||||
|
||||
it('substitutes staff fields', () => {
|
||||
const ctx = {
|
||||
staff: { username: 'bob', displayName: 'Bob the Builder', mention: '<@123>' }
|
||||
};
|
||||
expect(replaceVariables('{staff.user} / {staff.name} / {staff.mention}', ctx))
|
||||
.toBe('bob / Bob the Builder / <@123>');
|
||||
});
|
||||
|
||||
it('returns empty string for empty template', () => {
|
||||
expect(replaceVariables('')).toBe('');
|
||||
expect(replaceVariables(null)).toBe('');
|
||||
});
|
||||
|
||||
it('substitutes hours when provided', () => {
|
||||
expect(replaceVariables('after {hours} hours', { hours: 24 })).toBe('after 24 hours');
|
||||
});
|
||||
|
||||
it('substitutes {date} and {time} from current time', () => {
|
||||
const out = replaceVariables('on {date}', {});
|
||||
expect(out).toMatch(/^on \S+/);
|
||||
expect(out).not.toContain('{date}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriorityEmoji', () => {
|
||||
it('maps high/medium/low/normal to CONFIG values', () => {
|
||||
expect(typeof getPriorityEmoji('high')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('low')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('medium')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('normal')).toBe('string');
|
||||
});
|
||||
|
||||
it('falls back for unknown priority', () => {
|
||||
expect(typeof getPriorityEmoji('weird')).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeEqual', () => {
|
||||
it('returns true for matching strings', () => {
|
||||
expect(safeEqual('hello', 'hello')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for mismatched strings', () => {
|
||||
expect(safeEqual('hello', 'world')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for length mismatch (no throw)', () => {
|
||||
expect(safeEqual('a', 'abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined inputs', () => {
|
||||
expect(safeEqual(null, 'abc')).toBe(false);
|
||||
expect(safeEqual(undefined, undefined)).toBe(true);
|
||||
expect(safeEqual('', '')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStaff', () => {
|
||||
it('returns false for null/undefined member', () => {
|
||||
expect(isStaff(null)).toBe(false);
|
||||
expect(isStaff(undefined)).toBe(false);
|
||||
expect(isStaff({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for member with no roles cache', () => {
|
||||
expect(isStaff({ roles: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
17
utils.js
17
utils.js
@@ -112,22 +112,29 @@ function getCleanBody(payload) {
|
||||
function stripEmailQuotes(text) {
|
||||
let cleaned = text.replace(/\r\n/g, '\n');
|
||||
|
||||
// Pick the earliest match across all markers, not just the first marker that
|
||||
// matches anywhere. The previous order-dependent loop could truncate at a
|
||||
// late "_____" signature underline even when an earlier "On X wrote:" reply
|
||||
// header was the real cutoff.
|
||||
const markers = [
|
||||
/\n_{5,}\s*$/m,
|
||||
/\nOn .* wrote:/i,
|
||||
/\nFrom:\s.*<.*@.*>/i,
|
||||
/\nSent:\s.*$/i,
|
||||
/\nTo:\s.*$/i,
|
||||
/\nSubject:\s.*$/i,
|
||||
/\nOn .* wrote:/i
|
||||
/\n_{5,}\s*$/m
|
||||
];
|
||||
|
||||
let earliest = -1;
|
||||
for (const m of markers) {
|
||||
const match = cleaned.match(m);
|
||||
if (match) {
|
||||
cleaned = cleaned.substring(0, match.index);
|
||||
break;
|
||||
if (match && (earliest === -1 || match.index < earliest)) {
|
||||
earliest = match.index;
|
||||
}
|
||||
}
|
||||
if (earliest !== -1) {
|
||||
cleaned = cleaned.substring(0, earliest);
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
10
vitest.config.mjs
Normal file
10
vitest.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.js'],
|
||||
globals: false,
|
||||
testTimeout: 10000
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user