Compare commits
19 Commits
840b6bfcf8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a388d99fdf | |||
| 3212004fc9 | |||
| a565450e2d | |||
| 837fd10984 | |||
| 2152544d09 | |||
| c79463fc2a | |||
| e8e114e4ad | |||
| 452f005aea | |||
| 76279b703a | |||
| 3c13e55dad | |||
| 3e9ad658d0 | |||
| 952b22ac12 | |||
| d89ac65823 | |||
| adcd9dd9c9 | |||
| d0cf8fd915 | |||
| cdf85f6364 | |||
| e3b3b8d48c | |||
| 3ac23466b2 | |||
| 83b6b4ae0c |
@@ -2,7 +2,7 @@
|
|||||||
* Entry point – initializes the Discord bot, wires event handlers,
|
* Entry point – initializes the Discord bot, wires event handlers,
|
||||||
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck.
|
* 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 express = require('express');
|
||||||
const { connectMongoDB, closeMongoDB } = require('./db-connection');
|
const { connectMongoDB, closeMongoDB } = require('./db-connection');
|
||||||
const { CONFIG } = require('./config');
|
const { CONFIG } = require('./config');
|
||||||
@@ -11,6 +11,7 @@ const { mongoose } = require('./db-connection');
|
|||||||
// Handlers
|
// Handlers
|
||||||
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
||||||
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
||||||
|
const { requireStaffRole } = require('./handlers/commands/helpers');
|
||||||
const { handleDiscordReply } = require('./handlers/messages');
|
const { handleDiscordReply } = require('./handlers/messages');
|
||||||
|
|
||||||
// Services & jobs
|
// Services & jobs
|
||||||
@@ -86,7 +87,7 @@ const client = new Client({
|
|||||||
|
|
||||||
// --- EVENT: interactionCreate ---
|
// --- EVENT: interactionCreate ---
|
||||||
async function safeReplyError(interaction) {
|
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) {
|
if (interaction.deferred || interaction.replied) {
|
||||||
await interaction.followUp(payload).catch(() => {});
|
await interaction.followUp(payload).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
@@ -110,6 +111,9 @@ client.on('interactionCreate', async interaction => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
|
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
|
||||||
|
// Staff-only: /signature shows this modal, which is gated; double-gate the
|
||||||
|
// submit path in case an attacker crafts the submission directly.
|
||||||
|
if (await requireStaffRole(interaction)) return;
|
||||||
// Handle signature modal submit
|
// Handle signature modal submit
|
||||||
try {
|
try {
|
||||||
const valediction = interaction.fields.getTextInputValue('valediction');
|
const valediction = interaction.fields.getTextInputValue('valediction');
|
||||||
@@ -132,13 +136,13 @@ client.on('interactionCreate', async interaction => {
|
|||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: 'Signature settings saved successfully!',
|
content: 'Signature settings saved successfully!',
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Signature modal submit error:', err);
|
console.error('Signature modal submit error:', err);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: 'Failed to save signature settings.',
|
content: 'Failed to save signature settings.',
|
||||||
ephemeral: true
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -165,6 +169,14 @@ client.on('messageCreate', async msg => {
|
|||||||
await handleDiscordReply(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 () => {
|
client.once('ready', async () => {
|
||||||
if (!process.env.MONGODB_URI) {
|
if (!process.env.MONGODB_URI) {
|
||||||
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
|
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
|
||||||
@@ -228,7 +240,7 @@ client.login(CONFIG.DISCORD_TOKEN);
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
// Reject API traffic with 503 until ready event has fired and routes are mounted.
|
// 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) => {
|
app.use((req, res, next) => {
|
||||||
if (!appReady && req.path.startsWith('/api')) {
|
if (!appReady && req.path.startsWith('/api')) {
|
||||||
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
|
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
|
||||||
@@ -243,8 +255,6 @@ const internalApi = require('./routes/internalApi');
|
|||||||
const internalApp = express();
|
const internalApp = express();
|
||||||
internalApp.use('/internal', internalApi);
|
internalApp.use('/internal', internalApi);
|
||||||
|
|
||||||
let httpServer = null;
|
|
||||||
let internalServer = null;
|
|
||||||
if (CONFIG.INTERNAL_API_SECRET) {
|
if (CONFIG.INTERNAL_API_SECRET) {
|
||||||
// Must bind all-interfaces inside the bot container: the settings-site is a
|
// 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
|
// separate container on broccoli-net and reaches this API over the docker
|
||||||
|
|||||||
@@ -357,8 +357,6 @@ async function registerCommands() {
|
|||||||
.setDescription('Poll interval')
|
.setDescription('Poll interval')
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
.addChoices(
|
.addChoices(
|
||||||
{ name: '5s', value: '5' },
|
|
||||||
{ name: '10s', value: '10' },
|
|
||||||
{ name: '30s', value: '30' },
|
{ name: '30s', value: '30' },
|
||||||
{ name: '45s', value: '45' },
|
{ name: '45s', value: '45' },
|
||||||
{ name: '1m', value: '60' },
|
{ name: '1m', value: '60' },
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const CONFIG = {
|
|||||||
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
|
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
|
||||||
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
|
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
|
||||||
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
|
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
|
||||||
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
|
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
|
||||||
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
|
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
|
||||||
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
||||||
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
||||||
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
||||||
|
|||||||
609
gmail-poll.js
609
gmail-poll.js
@@ -1,10 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Gmail polling – fetches unread emails and creates/updates Discord ticket channels.
|
* 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 {
|
const {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
|
EmbedBuilder,
|
||||||
EmbedBuilder
|
PermissionFlagsBits
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const { mongoose, withRetry } = require('./db-connection');
|
const { mongoose, withRetry } = require('./db-connection');
|
||||||
const { CONFIG } = require('./config');
|
const { CONFIG } = require('./config');
|
||||||
@@ -14,7 +18,6 @@ const {
|
|||||||
stripEmailQuotes,
|
stripEmailQuotes,
|
||||||
stripMobileFooter,
|
stripMobileFooter,
|
||||||
detectGame,
|
detectGame,
|
||||||
enforceEmbedLimit,
|
|
||||||
sanitizeEmbedText
|
sanitizeEmbedText
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
const { getGmailClient } = require('./services/gmail');
|
||||||
@@ -36,6 +39,228 @@ function setPollSuspended(val) {
|
|||||||
}
|
}
|
||||||
function isPollSuspended() { return pollSuspended; }
|
function isPollSuspended() { return pollSuspended; }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers (extracted from the original 309-line poll()).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the guild for this poll iteration. Honors DISCORD_GUILD_ID when set,
|
||||||
|
* otherwise falls back to the first guild in the cache. Returns null with a
|
||||||
|
* warning if no usable guild is available; caller should bail.
|
||||||
|
*/
|
||||||
|
function locateGuild(client) {
|
||||||
|
if (CONFIG.DISCORD_GUILD_ID) {
|
||||||
|
const g = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||||
|
if (!g) {
|
||||||
|
console.warn('Configured guild not found for DISCORD_GUILD_ID:', CONFIG.DISCORD_GUILD_ID);
|
||||||
|
}
|
||||||
|
return g || null;
|
||||||
|
}
|
||||||
|
const g = client.guilds.cache.first();
|
||||||
|
if (!g) {
|
||||||
|
console.warn('No guilds in cache; skipping poll iteration.');
|
||||||
|
}
|
||||||
|
return g || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Gmail message payload into normalized fields.
|
||||||
|
*
|
||||||
|
* Body cleanup runs twice with different rules:
|
||||||
|
* - firstBody: aggressive — strip quotes if it looks like a reply, strip
|
||||||
|
* mobile footers, collapse newlines. Used as the first message in a new
|
||||||
|
* ticket channel where we want only the user's actual message.
|
||||||
|
* - followupBody: defensive — strip quotes but fall back to raw text if
|
||||||
|
* stripping leaves nothing. Used for follow-up posts on an existing thread.
|
||||||
|
*/
|
||||||
|
function parseGmailMessage(email) {
|
||||||
|
const headers = email.data.payload.headers;
|
||||||
|
const from = headers.find(h => h.name === 'From')?.value || '';
|
||||||
|
const isSelf = from.toLowerCase().includes(CONFIG.MY_EMAIL);
|
||||||
|
const subject = headers.find(h => h.name === 'Subject')?.value || 'New Ticket';
|
||||||
|
const rawBody = getCleanBody(email.data.payload);
|
||||||
|
const senderEmail = extractRawEmail(from).toLowerCase();
|
||||||
|
const senderName = (from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
|
||||||
|
?.replace(/"/g, '')
|
||||||
|
.trim() || 'Unknown';
|
||||||
|
|
||||||
|
const hasReplyHeaderFrom = /(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
|
||||||
|
const looksLikeReply = /\nOn .+wrote:/i.test(rawBody) || hasReplyHeaderFrom;
|
||||||
|
|
||||||
|
let firstBody = rawBody.replace(/\r\n/g, '\n');
|
||||||
|
if (looksLikeReply) firstBody = stripEmailQuotes(firstBody);
|
||||||
|
firstBody = stripMobileFooter(firstBody);
|
||||||
|
firstBody = firstBody.replace(/^\s*\n+/g, '');
|
||||||
|
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
|
||||||
|
firstBody = firstBody
|
||||||
|
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||||
|
.replace(/<\s*$/gm, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const rawText = rawBody.replace(/\r\n/g, '\n');
|
||||||
|
let followupBody = stripEmailQuotes(rawText);
|
||||||
|
if (!followupBody.trim()) followupBody = rawText;
|
||||||
|
followupBody = followupBody.replace(/^\s*\n*/, '\n');
|
||||||
|
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
|
||||||
|
followupBody = stripMobileFooter(followupBody);
|
||||||
|
followupBody = followupBody
|
||||||
|
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||||
|
.replace(/<\s*$/gm, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSelf,
|
||||||
|
threadId: email.data.threadId,
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
rawBody,
|
||||||
|
senderEmail,
|
||||||
|
senderName,
|
||||||
|
firstBody,
|
||||||
|
followupBody
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the parent category and create a fresh ticket channel under it.
|
||||||
|
* Returns { channel, parentCategoryId } on success, or null on failure (caller
|
||||||
|
* should mark the message read and skip — same behavior as the original inline path).
|
||||||
|
*/
|
||||||
|
async function findOrCreateTicketChannel(guild, parsed, number) {
|
||||||
|
const creatorNickname = getSenderLocal(parsed.senderEmail);
|
||||||
|
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||||||
|
|
||||||
|
let parentCategoryId;
|
||||||
|
try {
|
||||||
|
parentCategoryId = await getOrCreateTicketCategory(
|
||||||
|
guild,
|
||||||
|
CONFIG.TICKET_CATEGORY_ID,
|
||||||
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Channel create error (payload):', {
|
||||||
|
message: err.message,
|
||||||
|
code: err.code,
|
||||||
|
rawError: err.rawError
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await guild.channels.create({
|
||||||
|
name: chanName,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: parentCategoryId,
|
||||||
|
// Email tickets have no Discord creator — the customer is reachable
|
||||||
|
// only by email. So the only per-channel allow is the staff role; we
|
||||||
|
// still explicitly deny @everyone in case the category permissions
|
||||||
|
// are ever misconfigured to grant View Channel server-wide.
|
||||||
|
permissionOverwrites: [
|
||||||
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
|
...(CONFIG.ROLE_ID_TO_PING ? [{
|
||||||
|
id: CONFIG.ROLE_ID_TO_PING,
|
||||||
|
allow: [
|
||||||
|
PermissionFlagsBits.ViewChannel,
|
||||||
|
PermissionFlagsBits.SendMessages,
|
||||||
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
|
]
|
||||||
|
}] : [])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return { channel, parentCategoryId };
|
||||||
|
} catch (createErr) {
|
||||||
|
console.error('Channel create error (email ticket):', createErr);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post links + attachments for prior transcripts of a reopened thread.
|
||||||
|
* Best-effort: any failure is logged and swallowed so the new ticket flow
|
||||||
|
* continues unaffected.
|
||||||
|
*/
|
||||||
|
async function linkPreviousTranscripts(ticketChan, threadId, client) {
|
||||||
|
try {
|
||||||
|
const transcriptRows = await Transcript.find({ gmailThreadId: threadId })
|
||||||
|
.sort({ createdAt: 1 })
|
||||||
|
.select('transcriptMessageId')
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (transcriptRows.length === 0) return;
|
||||||
|
|
||||||
|
const transcriptChan = await client.channels
|
||||||
|
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!transcriptChan) return;
|
||||||
|
|
||||||
|
await enqueueSend(
|
||||||
|
ticketChan,
|
||||||
|
`This email thread has ${transcriptRows.length} previous transcript(s):`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of transcriptRows) {
|
||||||
|
const transcriptMsg = await transcriptChan.messages
|
||||||
|
.fetch(row.transcriptMessageId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!transcriptMsg) continue;
|
||||||
|
|
||||||
|
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
|
||||||
|
|
||||||
|
const originalAttachment = transcriptMsg.attachments.first();
|
||||||
|
if (originalAttachment) {
|
||||||
|
await enqueueSend(ticketChan, {
|
||||||
|
content: 'Transcript file:',
|
||||||
|
files: [originalAttachment.url]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error linking previous transcripts:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
|
||||||
|
async function markGmailMessageRead(gmail, msgRef) {
|
||||||
|
await gmail.users.messages.batchModify({
|
||||||
|
userId: 'me',
|
||||||
|
requestBody: {
|
||||||
|
ids: [msgRef.id],
|
||||||
|
removeLabelIds: ['UNREAD', 'INBOX']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the error indicates a permanent OAuth-grant failure (invalid_grant /
|
||||||
|
* invalid_client), suspend polling, clear the recurring poll interval, log,
|
||||||
|
* and DM the admin once. Returns true iff polling was suspended (caller
|
||||||
|
* should not treat as a transient retry-on-next-tick error).
|
||||||
|
*
|
||||||
|
* Transient 401/403/429/5xx and network errors are NOT considered permanent —
|
||||||
|
* they fall through to the next interval naturally. The OAuth code lives on
|
||||||
|
* `err.response.data.error`, not the message string.
|
||||||
|
*/
|
||||||
|
function oauthSuspendIfPermanent(err, client) {
|
||||||
|
const oauthError = err && err.response && err.response.data && err.response.data.error;
|
||||||
|
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
|
||||||
|
if (!isPermanentAuth) return false;
|
||||||
|
|
||||||
|
pollSuspended = true;
|
||||||
|
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
|
||||||
|
console.error('[gmail-poll]', suspendMsg);
|
||||||
|
logError('Gmail OAuth', { message: suspendMsg, stack: err.stack || err.message || String(err) }, null, client).catch(() => {});
|
||||||
|
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
|
||||||
|
if (CONFIG.ADMIN_ID && !authErrorNotified) {
|
||||||
|
authErrorNotified = true;
|
||||||
|
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Orchestrator
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll Gmail for unread primary-inbox messages and route them to Discord.
|
* Poll Gmail for unread primary-inbox messages and route them to Discord.
|
||||||
* @param {import('discord.js').Client} client
|
* @param {import('discord.js').Client} client
|
||||||
@@ -46,305 +271,135 @@ async function poll(client) {
|
|||||||
try {
|
try {
|
||||||
console.log('Running poll()...');
|
console.log('Running poll()...');
|
||||||
try {
|
try {
|
||||||
const gmail = getGmailClient();
|
const gmail = getGmailClient();
|
||||||
const list = await gmail.users.messages.list({
|
const list = await gmail.users.messages.list({
|
||||||
userId: 'me',
|
|
||||||
q: 'is:unread category:primary'
|
|
||||||
});
|
|
||||||
if (!list.data.messages) return;
|
|
||||||
|
|
||||||
let guild;
|
|
||||||
if (CONFIG.DISCORD_GUILD_ID) {
|
|
||||||
guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
|
||||||
if (!guild) {
|
|
||||||
console.warn(
|
|
||||||
'Configured guild not found for DISCORD_GUILD_ID:',
|
|
||||||
CONFIG.DISCORD_GUILD_ID
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
guild = client.guilds.cache.first();
|
|
||||||
if (!guild) {
|
|
||||||
console.warn('No guilds in cache; skipping poll iteration.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const msgRef of list.data.messages) {
|
|
||||||
const email = await gmail.users.messages.get({
|
|
||||||
userId: 'me',
|
userId: 'me',
|
||||||
id: msgRef.id
|
q: 'is:unread category:primary'
|
||||||
});
|
});
|
||||||
|
if (!list.data.messages) return;
|
||||||
|
|
||||||
const from =
|
const guild = locateGuild(client);
|
||||||
email.data.payload.headers.find(h => h.name === 'From')
|
if (!guild) return;
|
||||||
?.value || '';
|
|
||||||
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
|
|
||||||
await gmail.users.messages.batchModify({
|
|
||||||
userId: 'me',
|
|
||||||
requestBody: {
|
|
||||||
ids: [msgRef.id],
|
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject =
|
for (const msgRef of list.data.messages) {
|
||||||
email.data.payload.headers.find(h => h.name === 'Subject')
|
const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
|
||||||
?.value || 'New Ticket';
|
const parsed = parseGmailMessage(email);
|
||||||
const rawBody = getCleanBody(email.data.payload);
|
|
||||||
|
|
||||||
const sEmail = extractRawEmail(from).toLowerCase();
|
if (parsed.isSelf) {
|
||||||
const sName =
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
(from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
|
continue;
|
||||||
?.replace(/"/g, '')
|
}
|
||||||
.trim() || 'Unknown';
|
|
||||||
|
|
||||||
const hasReplyHeaderFrom =
|
const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
|
||||||
/(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
|
.select('gmailThreadId discordThreadId status')
|
||||||
const looksLikeReply =
|
.lean();
|
||||||
/\nOn .+wrote:/i.test(rawBody) ||
|
|
||||||
hasReplyHeaderFrom;
|
|
||||||
|
|
||||||
let firstBodyText = rawBody.replace(/\r\n/g, '\n');
|
|
||||||
if (looksLikeReply) {
|
|
||||||
firstBodyText = stripEmailQuotes(firstBodyText);
|
|
||||||
}
|
|
||||||
firstBodyText = stripMobileFooter(firstBodyText);
|
|
||||||
firstBodyText = firstBodyText.replace(/^\s*\n+/g, '');
|
|
||||||
firstBodyText = firstBodyText.replace(/\n{3,}/g, '\n\n');
|
|
||||||
firstBodyText = firstBodyText
|
|
||||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
|
||||||
.replace(/<\s*$/gm, '')
|
|
||||||
.trim();
|
|
||||||
const firstBody = firstBodyText;
|
|
||||||
|
|
||||||
const rawText = rawBody.replace(/\r\n/g, '\n');
|
|
||||||
let followupBody = stripEmailQuotes(rawText);
|
|
||||||
if (!followupBody.trim()) {
|
|
||||||
followupBody = rawText;
|
|
||||||
}
|
|
||||||
followupBody = followupBody.replace(/^\s*\n*/, '\n');
|
|
||||||
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
|
|
||||||
followupBody = stripMobileFooter(followupBody);
|
|
||||||
followupBody = followupBody
|
|
||||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
|
||||||
.replace(/<\s*$/gm, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const existing = await Ticket.findOne({ gmailThreadId: email.data.threadId })
|
|
||||||
.select('gmailThreadId discordThreadId status')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
let ticketChan = null;
|
let ticketChan = null;
|
||||||
let parentCategoryIdForTicket = null;
|
let parentCategoryIdForTicket = null;
|
||||||
let isReopened = false;
|
let isReopened = false;
|
||||||
|
|
||||||
if (existing && existing.discordThreadId) {
|
if (existing && existing.discordThreadId) {
|
||||||
ticketChan = await guild.channels
|
ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null);
|
||||||
.fetch(existing.discordThreadId)
|
} else if (existing && existing.status === 'closed') {
|
||||||
.catch(() => null);
|
isReopened = true;
|
||||||
} else if (existing && existing.status === 'closed') {
|
}
|
||||||
isReopened = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketChan) {
|
if (ticketChan) {
|
||||||
const truncatedFollowup = followupBody.slice(0, 1800);
|
// Append follow-up to existing channel.
|
||||||
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
|
||||||
await enqueueSend(
|
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
||||||
ticketChan,
|
await enqueueSend(ticketChan, {
|
||||||
{
|
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
|
|
||||||
allowedMentions: { parse: ['roles'] }
|
allowedMentions: { parse: ['roles'] }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create a new ticket channel.
|
||||||
|
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
||||||
|
if (!limitCheck.ok) {
|
||||||
|
console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
|
||||||
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Check ticket limits before creating
|
|
||||||
const limitCheck = await checkTicketLimits(sEmail);
|
|
||||||
if (!limitCheck.ok) {
|
|
||||||
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
|
|
||||||
await gmail.users.messages.batchModify({
|
|
||||||
userId: 'me',
|
|
||||||
requestBody: {
|
|
||||||
ids: [msgRef.id],
|
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { number } = await getNextTicketNumber(sEmail);
|
const { number } = await getNextTicketNumber(parsed.senderEmail);
|
||||||
const creatorNickname = getSenderLocal(sEmail);
|
const created = await findOrCreateTicketChannel(guild, parsed, number);
|
||||||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
if (!created) {
|
||||||
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
try {
|
continue;
|
||||||
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) {
|
ticketChan = created.channel;
|
||||||
console.error('Channel create error (payload):', {
|
parentCategoryIdForTicket = created.parentCategoryId;
|
||||||
message: err.message,
|
|
||||||
code: err.code,
|
const detectedGame = detectGame(parsed.subject, parsed.rawBody);
|
||||||
rawError: err.rawError
|
const buttons = getTicketActionRow({ escalationTier: 0 });
|
||||||
|
const ticketInfoEmbed = new EmbedBuilder()
|
||||||
|
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false },
|
||||||
|
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false },
|
||||||
|
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||||||
|
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||||
|
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||||||
|
embeds: [ticketInfoEmbed],
|
||||||
|
components: [buttons],
|
||||||
|
allowedMentions: { parse: ['roles'] }
|
||||||
});
|
});
|
||||||
await gmail.users.messages.batchModify({
|
|
||||||
userId: 'me',
|
const { createStaffThread } = require('./services/staffThread');
|
||||||
requestBody: {
|
await createStaffThread(ticketChan, client).catch(() => {});
|
||||||
ids: [msgRef.id],
|
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||||
}
|
const { pinMessage } = require('./services/pinMessage');
|
||||||
|
await pinMessage(welcomeMsg, client).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReopened) {
|
||||||
|
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email body is attacker-controlled — no mentions may fire from its content.
|
||||||
|
const truncated = parsed.firstBody.slice(0, 1900);
|
||||||
|
await enqueueSend(ticketChan, {
|
||||||
|
content: `**Message:**\n${truncated}`,
|
||||||
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detectedGame = detectGame(subject, rawBody);
|
// 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 buttons = getTicketActionRow({ escalationTier: 0 });
|
const now = new Date();
|
||||||
|
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||||
const ticketInfoEmbed = new EmbedBuilder()
|
await withRetry(() => Ticket.findOneAndUpdate(
|
||||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
{ gmailThreadId: parsed.threadId },
|
||||||
.addFields(
|
{
|
||||||
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false },
|
$set: {
|
||||||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
|
discordThreadId: ticketChan.id,
|
||||||
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
senderEmail: parsed.senderEmail,
|
||||||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
subject: parsed.subject,
|
||||||
);
|
createdAt: now,
|
||||||
|
status: 'open',
|
||||||
enforceEmbedLimit([ticketInfoEmbed]);
|
ticketNumber: number,
|
||||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
priority: defaultPriority,
|
||||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
lastActivity: now,
|
||||||
embeds: [ticketInfoEmbed],
|
parentCategoryId: parentCategoryIdForTicket
|
||||||
components: [buttons],
|
|
||||||
allowedMentions: { parse: ['roles'] }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { createStaffThread } = require('./services/staffThread');
|
|
||||||
await createStaffThread(ticketChan, client).catch(() => {});
|
|
||||||
|
|
||||||
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
|
||||||
const { pinMessage } = require('./services/pinMessage');
|
|
||||||
await pinMessage(welcomeMsg, client).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// On reopen, link previous transcripts
|
|
||||||
if (isReopened) {
|
|
||||||
try {
|
|
||||||
const transcriptRows = await Transcript.find({ gmailThreadId: email.data.threadId })
|
|
||||||
.sort({ createdAt: 1 })
|
|
||||||
.select('transcriptMessageId')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
if (transcriptRows.length > 0) {
|
|
||||||
const transcriptChan = await client.channels
|
|
||||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
if (transcriptChan) {
|
|
||||||
await enqueueSend(
|
|
||||||
ticketChan,
|
|
||||||
`This email thread has ${transcriptRows.length} previous transcript(s):`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of transcriptRows) {
|
|
||||||
const transcriptMsg = await transcriptChan.messages
|
|
||||||
.fetch(row.transcriptMessageId)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
if (!transcriptMsg) continue;
|
|
||||||
|
|
||||||
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
|
|
||||||
|
|
||||||
const originalAttachment = transcriptMsg.attachments.first();
|
|
||||||
if (originalAttachment) {
|
|
||||||
await enqueueSend(ticketChan, {
|
|
||||||
content: 'Transcript file:',
|
|
||||||
files: [originalAttachment.url]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
} catch (err) {
|
{ upsert: true, new: true }
|
||||||
console.error('Error linking previous transcripts:', err);
|
));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncated = firstBody.slice(0, 1900);
|
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||||
// Email body is attacker-controlled — no mentions may fire from its content.
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } });
|
|
||||||
|
|
||||||
// Welcome message skipped for email tickets – the email body speaks for itself.
|
|
||||||
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
|
||||||
|
|
||||||
await withRetry(() => Ticket.findOneAndUpdate(
|
|
||||||
{ gmailThreadId: email.data.threadId },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
discordThreadId: ticketChan.id,
|
|
||||||
senderEmail: sEmail,
|
|
||||||
subject,
|
|
||||||
createdAt: now,
|
|
||||||
status: 'open',
|
|
||||||
ticketNumber: number,
|
|
||||||
priority: defaultPriority,
|
|
||||||
lastActivity: now,
|
|
||||||
parentCategoryId: parentCategoryIdForTicket
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ upsert: true, new: true }
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
authErrorNotified = false;
|
||||||
await gmail.users.messages.batchModify({
|
} catch (e) {
|
||||||
userId: 'me',
|
oauthSuspendIfPermanent(e, client);
|
||||||
requestBody: {
|
console.error('POLL ERROR:', e);
|
||||||
ids: [msgRef.id],
|
logError('Gmail poll', e, null, client).catch(() => {});
|
||||||
removeLabelIds: ['UNREAD', 'INBOX']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
authErrorNotified = false;
|
|
||||||
} catch (e) {
|
|
||||||
// Only treat Google-reported permanent-grant failures as reasons to suspend
|
|
||||||
// the loop. Transient 401/403/429/5xx/network errors fall through to the
|
|
||||||
// next interval tick naturally. The OAuth error codes come back on the
|
|
||||||
// response body, not the message string.
|
|
||||||
const oauthError = e && e.response && e.response.data && e.response.data.error;
|
|
||||||
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
|
|
||||||
|
|
||||||
if (isPermanentAuth) {
|
|
||||||
pollSuspended = true;
|
|
||||||
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
|
|
||||||
console.error('[gmail-poll]', suspendMsg);
|
|
||||||
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
|
|
||||||
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
|
|
||||||
if (CONFIG.ADMIN_ID && !authErrorNotified) {
|
|
||||||
authErrorNotified = true;
|
|
||||||
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('POLL ERROR:', e);
|
|
||||||
logError('Gmail poll', e, null, client).catch(() => {});
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
isPolling = false;
|
isPolling = false;
|
||||||
}
|
}
|
||||||
|
|||||||
1139
handlers/buttons.js
1139
handlers/buttons.js
File diff suppressed because it is too large
Load Diff
1043
handlers/commands.js
1043
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 };
|
||||||
213
handlers/commands/escalation.js
Normal file
213
handlers/commands/escalation.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
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}.`;
|
||||||
|
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}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
346
handlers/commands/index.js
Normal file
346
handlers/commands/index.js
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* 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 { isStaff } = require('../../utils');
|
||||||
|
const { setNotifyDm } = require('../../services/staffSettings');
|
||||||
|
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||||
|
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||||
|
const { logError, 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;
|
||||||
|
|
||||||
|
// Defer up front: enqueueOverwrite serializes behind any pending rename/move
|
||||||
|
// on this channel and can exceed Discord's 3s interaction-token window.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enqueueOverwrite(interaction.channel, user.id, {
|
||||||
|
ViewChannel: true,
|
||||||
|
SendMessages: true,
|
||||||
|
ReadMessageHistory: true
|
||||||
|
});
|
||||||
|
await interaction.editReply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Add user error:', err);
|
||||||
|
await interaction.editReply({ content: 'Failed to add user.' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(interaction) {
|
||||||
|
const user = interaction.options.getUser('user');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Defer up front — same reason as handleAdd.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
||||||
|
await interaction.editReply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remove user error:', err);
|
||||||
|
await interaction.editReply({ content: 'Failed to remove user.' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Cache-first member resolution; falls back to a fetch if not in cache.
|
||||||
|
// GuildMembers intent keeps the cache warm in normal operation.
|
||||||
|
const guildMember = interaction.guild.members.cache.get(member.id)
|
||||||
|
|| await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||||
|
|
||||||
|
// Reject self-transfers and bots; require the target to satisfy isStaff(),
|
||||||
|
// which covers ROLE_ID_TO_PING + ADDITIONAL_STAFF_ROLES — the same staff
|
||||||
|
// definition used by every other gate in the bot. The previous check only
|
||||||
|
// looked at ROLE_TO_PING_ID, missing additional staff roles.
|
||||||
|
if (!guildMember || guildMember.user.bot || !isStaff(guildMember)) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'The target member must have the staff role.',
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (guildMember.id === interaction.user.id) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'You cannot transfer the ticket to yourself.',
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer before the DB write + rename so the interaction token survives.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||||
|
|
||||||
|
await Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
|
||||||
|
);
|
||||||
|
ticket.claimedBy = claimerLabel;
|
||||||
|
ticket.claimerId = guildMember.id;
|
||||||
|
|
||||||
|
// Rename the channel to reflect the new claimer — mirrors the /claim
|
||||||
|
// button flow (applyClaim in handlers/buttons.js). Picks the new
|
||||||
|
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
|
||||||
|
// variant when tier >= 1.
|
||||||
|
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||||
|
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||||
|
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
|
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
|
||||||
|
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
|
||||||
|
.catch(err => logError('rename', err).catch(() => {}));
|
||||||
|
|
||||||
|
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||||
|
await interaction.editReply({
|
||||||
|
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.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMove(interaction) {
|
||||||
|
const category = interaction.options.getChannel('category');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Defer up front — enqueueMove serializes behind any pending rename and
|
||||||
|
// setParent itself can take a moment on busy channels.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enqueueMove(interaction.channel, category.id);
|
||||||
|
await interaction.editReply(`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.editReply({ content: 'Failed to move ticket.' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTopic(interaction) {
|
||||||
|
const text = interaction.options.getString('text');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Defer up front — enqueueTopic serializes behind any pending rename/move.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enqueueTopic(interaction.channel, text);
|
||||||
|
await interaction.editReply('Topic updated successfully.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Topic error:', err);
|
||||||
|
await interaction.editReply({ content: 'Failed to update topic.' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Every command is staff-only — including /help,
|
||||||
|
* which previously bypassed the role check.
|
||||||
|
*/
|
||||||
|
async function handleCommand(interaction) {
|
||||||
|
if (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 { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||||
const { updateTicketActivity } = require('../services/tickets');
|
const { updateTicketActivity } = require('../services/tickets');
|
||||||
const { getNotifyDm } = require('../services/staffSettings');
|
const { getNotifyDm } = require('../services/staffSettings');
|
||||||
|
const { logError } = require('../services/debugLog');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
@@ -24,13 +25,17 @@ async function handleDiscordReply(m) {
|
|||||||
Ticket.updateOne(
|
Ticket.updateOne(
|
||||||
{ discordThreadId: m.channel.id },
|
{ discordThreadId: m.channel.id },
|
||||||
{ $set: { lastActivity: new Date() } }
|
{ $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.
|
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||||||
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||||||
const dmEnabled = await getNotifyDm(ticket.claimerId);
|
const dmEnabled = await getNotifyDm(ticket.claimerId);
|
||||||
if (dmEnabled) {
|
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) {
|
if (staffMember) {
|
||||||
const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`;
|
const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`;
|
||||||
await staffMember
|
await staffMember
|
||||||
|
|||||||
54
handlers/sharedHelpers.js
Normal file
54
handlers/sharedHelpers.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for slash-command and button handlers.
|
||||||
|
*
|
||||||
|
* 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');
|
||||||
|
|
||||||
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the ticket linked to this channel; reply with `missingMessage`
|
||||||
|
* (default: "This channel is not linked to a ticket.") and return null if
|
||||||
|
* the channel is not a ticket. Returns the ticket on success.
|
||||||
|
*
|
||||||
|
* @param {import('discord.js').Interaction} interaction
|
||||||
|
* @param {string} [missingMessage]
|
||||||
|
*/
|
||||||
|
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, flags: MessageFlags.Ephemeral });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer + run + log + reply on error. `verb` is the user-facing verb
|
||||||
|
* (e.g. "escalate"); error messages render as "Failed to <verb> this ticket."
|
||||||
|
* Errors are logged to console + DEBUGGING_CHANNEL_ID via logError(verb, ...).
|
||||||
|
*
|
||||||
|
* @param {import('discord.js').Interaction} interaction
|
||||||
|
* @param {string} verb
|
||||||
|
* @param {() => Promise<void>} fn
|
||||||
|
* @param {{ flags?: number }} [opts] - pass `MessageFlags.Ephemeral` for ephemeral defer
|
||||||
|
*/
|
||||||
|
async function runDeferred(interaction, verb, fn, { flags } = {}) {
|
||||||
|
try {
|
||||||
|
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, flags: MessageFlags.Ephemeral }).catch(() => {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { findTicketForChannel, runDeferred };
|
||||||
@@ -19,6 +19,7 @@ const ticketSchema = new mongoose.Schema({
|
|||||||
lastActivity: Date,
|
lastActivity: Date,
|
||||||
welcomeMessageId: String,
|
welcomeMessageId: String,
|
||||||
claimerId: String,
|
claimerId: String,
|
||||||
|
creatorId: String,
|
||||||
parentCategoryId: String,
|
parentCategoryId: String,
|
||||||
pendingDelete: { type: Boolean, default: false }
|
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": "^5.2.1",
|
||||||
"express-rate-limit": "^8.3.2",
|
"express-rate-limit": "^8.3.2",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"mongoose": "^6.12.0"
|
"mongoose": "^8.23.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"mongodb": "^7.1.0"
|
"mongodb": "^7.1.0",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"name": "broccolini-bot",
|
"name": "broccolini-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"main": "broccolini-discord.js",
|
"main": "broccolini-discord.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node broccolini-discord.js",
|
"start": "node broccolini-discord.js",
|
||||||
|
"test": "vitest run",
|
||||||
"test-mongodb": "node scripts/test-mongodb.js"
|
"test-mongodb": "node scripts/test-mongodb.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ const internalLimiter = rateLimit({
|
|||||||
message: { error: 'Too many requests, please try again later.' }
|
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);
|
router.use(internalLimiter);
|
||||||
|
|
||||||
// Middleware: verify internal secret
|
// Middleware: verify internal secret
|
||||||
@@ -111,7 +122,7 @@ router.get('/discord/guild', async (req, res) => {
|
|||||||
// POST /restart — restart the bot process
|
// POST /restart — restart the bot process
|
||||||
let scheduledRestart = null;
|
let scheduledRestart = null;
|
||||||
|
|
||||||
router.post('/restart', express.json(), (req, res) => {
|
router.post('/restart', restartLimiter, express.json(), (req, res) => {
|
||||||
const { mode, scheduledFor } = req.body;
|
const { mode, scheduledFor } = req.body;
|
||||||
|
|
||||||
if (mode === 'immediate') {
|
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);
|
||||||
|
});
|
||||||
@@ -25,10 +25,13 @@ async function executeRename(channel, entry) {
|
|||||||
// (403), or no token configured — fall back to the primary Discord.js client.
|
// (403), or no token configured — fall back to the primary Discord.js client.
|
||||||
// Non-fallback errors rethrow so enqueueRename's catch can classify/log.
|
// Non-fallback errors rethrow so enqueueRename's catch can classify/log.
|
||||||
if (err && err.fallback === true && channel && typeof channel.setName === 'function') {
|
if (err && err.fallback === true && channel && typeof channel.setName === 'function') {
|
||||||
logWarn(
|
// Local log only; discord.js's REST client transparently handles 429s
|
||||||
'renameQueue',
|
// on the primary fallback, so this used to post a paired warning to
|
||||||
`secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
// the debug channel for every secondary-bot quota event with no
|
||||||
).catch(() => {});
|
// operator action required. Keep the visibility in container logs.
|
||||||
|
console.warn(
|
||||||
|
`[renameQueue] secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
||||||
|
);
|
||||||
await channel.setName(currentName);
|
await channel.setName(currentName);
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -80,6 +83,12 @@ function enqueueRename(channel, newName) {
|
|||||||
|
|
||||||
// Shares renameChains so a move+rename pair on the same channel executes in
|
// Shares renameChains so a move+rename pair on the same channel executes in
|
||||||
// call order. No coalescing: every move is a distinct chain link.
|
// call order. No coalescing: every move is a distinct chain link.
|
||||||
|
//
|
||||||
|
// lockPermissions: false preserves the channel's existing permission overwrites
|
||||||
|
// across the parent change. With the default (true), Discord re-syncs the
|
||||||
|
// channel's overwrites to match the new category and wipes per-user grants —
|
||||||
|
// in practice that kicked the ticket creator and any /add'd users off the
|
||||||
|
// channel on every escalate / de-escalate / /move.
|
||||||
function enqueueMove(channel, categoryId) {
|
function enqueueMove(channel, categoryId) {
|
||||||
let entry = renameChains.get(channel.id);
|
let entry = renameChains.get(channel.id);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -87,7 +96,7 @@ function enqueueMove(channel, categoryId) {
|
|||||||
renameChains.set(channel.id, entry);
|
renameChains.set(channel.id, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: true }));
|
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: false }));
|
||||||
entry.chain = next;
|
entry.chain = next;
|
||||||
|
|
||||||
next.catch((err) => {
|
next.catch((err) => {
|
||||||
@@ -113,6 +122,81 @@ function enqueueMove(channel, categoryId) {
|
|||||||
return next;
|
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.
|
// Per-channel promise chain for send ordering and to prevent interleaving.
|
||||||
const sendChains = new Map();
|
const sendChains = new Map();
|
||||||
|
|
||||||
@@ -157,4 +241,4 @@ function enqueueDelete(channel) {
|
|||||||
return next;
|
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();
|
const roundtrip = readEnvFile();
|
||||||
if (roundtrip.size !== expected) {
|
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;
|
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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
async function sendToChannel(channelId, embed, overrideClient) {
|
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)
|
const commandLine = (interaction?.commandName || interaction?.customId)
|
||||||
? `Command/Button: ${interaction.commandName || interaction.customId}\n`
|
? `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({
|
await channel.send({
|
||||||
content: `\`[${context}]\` ${error.message || String(error)}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore send failures
|
// ignore send failures
|
||||||
@@ -52,7 +71,7 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
|||||||
async function logWarn(context, message, overrideClient = null) {
|
async function logWarn(context, message, overrideClient = null) {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`Warning: ${context}`)
|
.setTitle(`Warning: ${context}`)
|
||||||
.setDescription(String(message).slice(0, 4000))
|
.setDescription(redactPII(String(message)).slice(0, 4000))
|
||||||
.setColor(0xFFFF00)
|
.setColor(0xFFFF00)
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||||
@@ -69,7 +88,7 @@ async function logTicketEvent(action, fields, interaction = null) {
|
|||||||
if (interaction?.user?.tag) {
|
if (interaction?.user?.tag) {
|
||||||
embed.setFooter({ text: interaction.user.tag });
|
embed.setFooter({ text: interaction.user.tag });
|
||||||
}
|
}
|
||||||
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
|
await sendToChannel(CONFIG.LOGGING_CHANNEL_ID, embed, interaction?.client);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ async function pinMessage(message, client) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 30003) {
|
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 {
|
} 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.
|
* is logged via logWarn.
|
||||||
* - invitable: false means only staff with MANAGE_THREADS can add additional
|
* - invitable: false means only staff with MANAGE_THREADS can add additional
|
||||||
* members — this is intentional for privacy.
|
* members — this is intentional for privacy.
|
||||||
* - guild.members.fetch() in addRoleMembersToThread can be slow on large
|
* - addRoleMembersToThread reads from role.members (cache-derived) and only
|
||||||
* servers. The 300ms delay between adds avoids the thread member add rate
|
* falls back to a scoped guild.members.fetch on cache miss. The 300ms
|
||||||
* limit (approximately 5/second).
|
* 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 { ChannelType } = require('discord.js');
|
||||||
const { CONFIG } = require('../config');
|
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) {
|
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;
|
return thread;
|
||||||
@@ -48,30 +53,40 @@ async function createStaffThread(channel, client) {
|
|||||||
if (err.code === 50024 || err.code === 160004) {
|
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(() => {});
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add all members of the staff role to the thread.
|
* 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) {
|
async function addRoleMembersToThread(thread, guild, client) {
|
||||||
try {
|
try {
|
||||||
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
|
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
|
||||||
if (!role) return;
|
if (!role) return;
|
||||||
|
|
||||||
await guild.members.fetch();
|
let members = role.members.filter(m => !m.user.bot);
|
||||||
const members = guild.members.cache.filter(m =>
|
if (members.size === 0) {
|
||||||
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot
|
// 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) {
|
for (const [, member] of members) {
|
||||||
await thread.members.add(member.id).catch(() => {});
|
await thread.members.add(member.id).catch(() => {});
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) {
|
async function resolveCreatorNickname(guild, ticket) {
|
||||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
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 {
|
try {
|
||||||
const member = await guild.members.fetch(creatorUserId);
|
const member = await guild.members.fetch(creatorUserId);
|
||||||
return member.displayName;
|
return member.displayName;
|
||||||
@@ -305,14 +310,16 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
|||||||
|
|
||||||
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
|
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(() => {
|
enqueueDelete(channel).then(() => {
|
||||||
withRetry(() => Ticket.updateOne(
|
withRetry(() => Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $unset: { pendingDelete: '' } }
|
{ $unset: { pendingDelete: '' } }
|
||||||
)).catch(() => {});
|
)).catch(() => {});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, 5000);
|
}, 5000));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
utils.js
91
utils.js
@@ -112,22 +112,29 @@ function getCleanBody(payload) {
|
|||||||
function stripEmailQuotes(text) {
|
function stripEmailQuotes(text) {
|
||||||
let cleaned = text.replace(/\r\n/g, '\n');
|
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 = [
|
const markers = [
|
||||||
/\n_{5,}\s*$/m,
|
/\nOn .* wrote:/i,
|
||||||
/\nFrom:\s.*<.*@.*>/i,
|
/\nFrom:\s.*<.*@.*>/i,
|
||||||
/\nSent:\s.*$/i,
|
/\nSent:\s.*$/i,
|
||||||
/\nTo:\s.*$/i,
|
/\nTo:\s.*$/i,
|
||||||
/\nSubject:\s.*$/i,
|
/\nSubject:\s.*$/i,
|
||||||
/\nOn .* wrote:/i
|
/\n_{5,}\s*$/m
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let earliest = -1;
|
||||||
for (const m of markers) {
|
for (const m of markers) {
|
||||||
const match = cleaned.match(m);
|
const match = cleaned.match(m);
|
||||||
if (match) {
|
if (match && (earliest === -1 || match.index < earliest)) {
|
||||||
cleaned = cleaned.substring(0, match.index);
|
earliest = match.index;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (earliest !== -1) {
|
||||||
|
cleaned = cleaned.substring(0, earliest);
|
||||||
|
}
|
||||||
|
|
||||||
return cleaned.trim();
|
return cleaned.trim();
|
||||||
}
|
}
|
||||||
@@ -264,83 +271,9 @@ function truncateEmbedDescription(str, max = 4096) {
|
|||||||
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforce the 6 000 char total embed limit across an array of EmbedBuilder
|
|
||||||
* instances. Mutates in place: trims the largest description first, then
|
|
||||||
* largest field values, until the total is under 6 000 chars.
|
|
||||||
* Returns the same array for chaining.
|
|
||||||
*/
|
|
||||||
function enforceEmbedLimit(embeds) {
|
|
||||||
const charCount = (e) => {
|
|
||||||
const d = e.data || {};
|
|
||||||
let total = 0;
|
|
||||||
if (d.title) total += d.title.length;
|
|
||||||
if (d.description) total += d.description.length;
|
|
||||||
if (d.footer?.text) total += d.footer.text.length;
|
|
||||||
if (d.author?.name) total += d.author.name.length;
|
|
||||||
if (d.fields) {
|
|
||||||
for (const f of d.fields) {
|
|
||||||
if (f.name) total += f.name.length;
|
|
||||||
if (f.value) total += f.value.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIMIT = 6000;
|
|
||||||
|
|
||||||
const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0);
|
|
||||||
|
|
||||||
// Trim largest descriptions first
|
|
||||||
while (totalChars() > LIMIT) {
|
|
||||||
let largestIdx = -1;
|
|
||||||
let largestLen = 0;
|
|
||||||
for (let i = 0; i < embeds.length; i++) {
|
|
||||||
const desc = embeds[i].data?.description;
|
|
||||||
if (desc && desc.length > largestLen) {
|
|
||||||
largestLen = desc.length;
|
|
||||||
largestIdx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (largestIdx === -1 || largestLen <= 4) break;
|
|
||||||
const excess = totalChars() - LIMIT;
|
|
||||||
const newLen = Math.max(1, largestLen - excess - 3);
|
|
||||||
embeds[largestIdx].setDescription(
|
|
||||||
embeds[largestIdx].data.description.slice(0, newLen) + '...'
|
|
||||||
);
|
|
||||||
if (totalChars() <= LIMIT) break;
|
|
||||||
// If still over, loop will pick next largest
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim largest field values
|
|
||||||
while (totalChars() > LIMIT) {
|
|
||||||
let targetEmbed = null;
|
|
||||||
let targetFieldIdx = -1;
|
|
||||||
let targetLen = 0;
|
|
||||||
for (const e of embeds) {
|
|
||||||
const fields = e.data?.fields || [];
|
|
||||||
for (let fi = 0; fi < fields.length; fi++) {
|
|
||||||
if (fields[fi].value && fields[fi].value.length > targetLen) {
|
|
||||||
targetLen = fields[fi].value.length;
|
|
||||||
targetEmbed = e;
|
|
||||||
targetFieldIdx = fi;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!targetEmbed || targetLen <= 4) break;
|
|
||||||
const excess = totalChars() - LIMIT;
|
|
||||||
const newLen = Math.max(1, targetLen - excess - 3);
|
|
||||||
targetEmbed.data.fields[targetFieldIdx].value =
|
|
||||||
targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
return embeds;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sanitizeEmbedText,
|
sanitizeEmbedText,
|
||||||
truncateEmbedDescription,
|
truncateEmbedDescription,
|
||||||
enforceEmbedLimit,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
safeEqual,
|
safeEqual,
|
||||||
isStaff,
|
isStaff,
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ async function renameChannel(channelId, newName) {
|
|||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const retryAfterSec = (body && typeof body === 'object' && body.retry_after) || null;
|
const retryAfterSec = (body && typeof body === 'object' && body.retry_after) || null;
|
||||||
const retryAfterMs = retryAfterSec != null ? Math.ceil(Number(retryAfterSec) * 1000) : null;
|
const retryAfterMs = retryAfterSec != null ? Math.ceil(Number(retryAfterSec) * 1000) : null;
|
||||||
logWarn('renamer', `429 rename channel=${channelId} retry_after=${retryAfterSec}`).catch(() => {});
|
// Local log only; the channelQueue fallback path handles recovery
|
||||||
|
// transparently via discord.js's built-in 429 retry. Posting these to
|
||||||
|
// the debug channel was non-actionable noise.
|
||||||
|
console.warn(`[renamer] 429 rename channel=${channelId} retry_after=${retryAfterSec}`);
|
||||||
|
|
||||||
// Respect retry_after up to 2000ms; otherwise fail over immediately.
|
// Respect retry_after up to 2000ms; otherwise fail over immediately.
|
||||||
if (retryAfterMs != null && retryAfterMs > 0 && retryAfterMs <= 2000) {
|
if (retryAfterMs != null && retryAfterMs > 0 && retryAfterMs <= 2000) {
|
||||||
|
|||||||
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