Compare commits

...

7 Commits

Author SHA1 Message Date
3c13e55dad audit week 3 quality batch: QUAL-004/005/007/008/010 + SEC-002
QUAL-004 handlers/messages.js — DM-on-customer-reply now reads
guild.members.cache.get(claimerId) first and only falls back to
guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff
reply on busy tickets. GuildMembers intent already keeps the cache warm.

QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js
(finalizeForceClose) — close paths now $unset welcomeMessageId alongside
the status: 'closed' write. Stops a stale message-ID from carrying into a
future reopen on the same Gmail thread, where escalation's "edit welcome
buttons" path would silently fail trying to fetch a message in a deleted
channel.

QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now
includes the missing/extra key sets, not just count vs count. Saves the
operator from guessing which key vanished after a partial write.

QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match
loop with an earliest-match-across-all-markers scan. The previous code
could truncate at a late "_____" signature underline even when an earlier
"On X wrote:" reply header was the real cutoff. New test in
tests/utils.test.js exercises the dual-marker case.

QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer /
appReady` declarations from after the ready handler to before it. Same
runtime behavior (module-load completes before ready fires asynchronously),
but the read order now matches the assignment order.

SEC-002 routes/internalApi.js — POST /restart now goes through a tighter
2/min limiter on top of the shared 10/min internalLimiter. Defense in
depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret
can no longer crash-loop the container.

Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already
idempotent — the audit finding was incorrect).

vitest run: 88/88 (one new test for QUAL-008).
2026-05-08 20:46:04 +00:00
3e9ad658d0 audit week 3 [SEC-004 + SEC-005]: scope members.fetch + redact PII in debug logs
[SEC-004] services/staffThread.js — addRoleMembersToThread previously called
the unscoped guild.members.fetch() on every ticket creation, chunking every
member of the guild. With STAFF_THREAD_AUTO_ADD_ROLE on and a 50-member
staff role, the 300ms-per-add loop also blocked ticket creation for ~15s.

  - Read role.members directly (computed from guild.members.cache, kept in
    sync via the GuildMembers gateway intent set on the client). Skip the
    explicit unscoped fetch in the hot path.
  - Cache-cold fallback: one scoped guild.members.fetch({ withPresences:
    false }) — irrelevant presence sync stays off the wire.
  - createStaffThread no longer awaits the add-loop. Wraps the call in
    setImmediate(...) so ticket creation returns immediately while the
    rate-limited add-loop runs in the background.

[SEC-005] services/debugLog.js — stacks/messages posted to the debug
channel could leak customer email addresses (interpolated through ticket
errors) and Discord member/channel IDs. Add a redactPII helper applied to
both logError's message + stack and logWarn's body:

  - Email regex /[\w.+-]+@[\w.-]+\.\w+/g → [EMAIL_REDACTED]
  - Discord snowflake /\b\d{18,20}\b/g → [ID_REDACTED]

interaction.user.tag in the User: line is intentionally not redacted —
it's needed for triage and is not PII (Discord usernames are public).
2026-05-08 20:42:48 +00:00
952b22ac12 audit week 3 [DEP-001]: upgrade mongoose 6.12 → 8.23
Mongoose 6 entered maintenance/EOL in 2023; current major is 8.x. No source
changes required — every API the codebase uses is identical between v6 and
v8:

- models.js schema DSL (Schema, default: Date.now function refs, enum,
  unique, required, index) is unchanged.
- db-connection.js connect options (serverSelectionTimeoutMS,
  socketTimeoutMS) and connection event names (error/disconnected/
  reconnected) are unchanged.
- All queries already use the v7+-required APIs: countDocuments (not the
  removed count()), updateOne/findOneAndUpdate (not the removed update()),
  bulkWrite, .lean() — no callback-based queries, no Model.remove(),
  no findOneAndRemove.
- findOneAndUpdate sites all explicitly pass { new: true } so the v7
  default-flip from old-doc to new-doc doesn't change behavior.
- strictQuery default flipped to false in v7+; codebase only filters on
  schema-defined fields, so the change is a no-op.

Verification on this commit:
- node -e "require('./db-connection')" loads cleanly; modelNames() returns
  the expected six models.
- Every source file under handlers/, services/, routes/, gmail-poll.js,
  broccolini-discord.js requires cleanly.
- new Ticket(...).validateSync() accepts valid docs, rejects invalid enum
  values, and Date defaults still fire.
- vitest run still passes (87/87) — pure-function suite is unaffected by
  the upgrade but confirms no regression in the dependency-shared modules.

Production verification (live DB CRUD: create/find/updateOne/deleteOne/
bulkWrite) still owed via docker compose up --build -d on the homelab.
2026-05-08 20:40:28 +00:00
d89ac65823 audit week 3 [TEST-001]: bootstrap vitest + utils & configSchema smoke tests
Adds vitest@^4.1.5 as a devDependency, an `npm test` script (runs once,
non-watch), and tests/ with 87 smoke tests across two suites:

- tests/utils.test.js (42 tests) — pure functions in utils.js:
  stripEmailQuotes, stripMobileFooter, extractRawEmail, escapeHtml,
  sanitizeEmbedText, truncateEmbedDescription, replaceVariables,
  getPriorityEmoji, safeEqual, isStaff. Covers normal input, empty input,
  null/undefined, edge cases (CRLF normalization, oversize truncation,
  triple-backtick escape, code-block injection).

- tests/configSchema.test.js (45 tests) — getValidator type inference and
  per-validator validate() behavior for boolean / integer / hex_color /
  url / email / discord_id / discord_id_list / string fallback. Covers
  ALLOWED_CONFIG_KEYS membership, the ROLE_ID_TO_PING mid-key override,
  legacy "true"/"false"/numeric coercion in the string fallback, empty
  input as ok-with-empty, garbage rejection.

vitest.config.mjs sets `environment: 'node'`, `globals: false`, and
`include: ['tests/**/*.test.js']`. Foundation for the mongoose 6→8
upgrade — these tests don't touch the DB but confirm pure-function
behavior is preserved across dependency moves.
2026-05-08 20:38:41 +00:00
adcd9dd9c9 audit week 2 [ARCH-001]: split handlers/commands.js into submodules
The 1028-line handlers/commands.js bundled escalation logic + force-close
flow + /response tag CRUD + /panel + /signature + context-menu handlers +
several config-toggle slash commands. After the dispatch-table refactor it
was still a god module. Split into handlers/commands/ with one file per
topic; require('./commands') resolves to handlers/commands/index.js
(handlers/commands.js is removed).

Layout:
  helpers.js     — requireStaffRole, fetchLoggingChannel
                   (cross-submodule, kept here to avoid cycles with index.js)
  escalation.js  — runEscalation, runDeescalation, handleEscalate, handleDeescalate
                   (run* are still exported via index.js for handlers/buttons.js)
  close.js       — handleForceClose, handleCancelClose, handleCloseTimer
                   + finalizeForceClose / postTranscript (timer callback)
  response.js    — handleResponse + send/create/edit/delete/list subcommands
                   + handleAutocomplete (only /response autocompletes)
  panel.js       — handlePanel, buildPanelButtonRow, handleSignature
  contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
  index.js       — dispatch tables, handleCommand/handleContextMenu, plus the
                   short-and-not-thematic handlers (notifydm, add, remove,
                   transfer, move, topic, staffthread, pinmessages, gmailpoll,
                   help) and the public re-exports.

No behavior change — every imported name, every Discord call, every DB
write, every embed, every reply payload preserved verbatim. Public surface
of require('./commands') is still { handleCommand, handleContextMenu,
handleAutocomplete, runEscalation, runDeescalation }.

Largest single module is now index.js at 299 lines; others are 33–214.
2026-05-08 20:29:44 +00:00
d0cf8fd915 audit week 2 [VIBE-001]: decompose gmail-poll.js poll()
Split the original 309-line poll() into single-responsibility helpers and a
thin orchestrator. No behavior change — every Gmail API call, Discord call,
DB write, and log line stays in the same order with the same arguments.

Helpers extracted (module-private):
- locateGuild(client) — DISCORD_GUILD_ID lookup with fallback warning.
- parseGmailMessage(email) — header parsing, body decode, dual cleanup
  (firstBody for new-ticket message, followupBody for thread append).
- findOrCreateTicketChannel(guild, parsed, number) — category resolution
  + channel.create with the existing two-stage error handling.
- linkPreviousTranscripts(ticketChan, threadId, client) — best-effort
  prior-transcript link on reopen.
- markGmailMessageRead(gmail, msgRef) — wraps the batchModify call used
  in five places across the original.
- oauthSuspendIfPermanent(err, client) — invalid_grant/invalid_client
  classify, suspend polling, clear interval, DM admin once. Returns bool.

poll() is now the orchestrator: list → locate guild → for each message,
parse → look up existing ticket → branch (append-followup vs new-ticket
flow) → mark read. The new-ticket branch stays inline in poll() per the
"keep poll() as orchestration" intent.
2026-05-08 20:23:30 +00:00
cdf85f6364 audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup
QUAL-006  store ticket.creatorId on creation; legacy split-pop returned the
          message ID for discord-msg-* tickets, breaking transcript DM, close
          log, and channel rename for context-menu-created tickets. Adds the
          field to the Ticket schema and writes a one-shot backfill script
          (scripts/backfill-creatorId.js, dry-run by default).

QUEUE-001 add enqueueOverwrite + enqueueTopic to services/channelQueue.js
          (chain on renameChains alongside enqueueMove). Migrate handleAdd /
          handleRemove / handleMove / handleTopic so permissionOverwrites,
          setParent, and setTopic no longer race pending renames or sends.
          handleMove now uses the existing enqueueMove. Initial overwrites in
          handleTicketModal stay inline; channel doesn't exist yet so no race.

DISCORD-001 replace ephemeral: true with flags: MessageFlags.Ephemeral across
            broccolini-discord.js, handlers/sharedHelpers.js, handlers/buttons.js,
            handlers/commands.js. runDeferred opts now take { flags } directly.

SEC-003   /gmailpoll min interval is 30s. Drop the 5s/10s slash-command
          choices and clamp Math.max(30000, ms) in handleGmailPoll for
          defense in depth.

QUAL-001  upgrade silent .catch(() => {}) on the lastActivity updateOne in
          handlers/messages.js to log via logError, so transient Mongo errors
          surface in the debug channel instead of disappearing.

QUAL-002  drop await from logError/logWarn calls in services/staffThread.js
          and services/pinMessage.js — fire-and-forget per CLAUDE.md hard rule.

QUAL-003  wrap stray setTimeouts (handleConfirmCloseRequest force-close timer,
          runFinalClose channel-delete + overflow-cleanup, checkAutoClose
          delete-after-email) in trackTimeout via lazy require so they clear
          on shutdown.
2026-05-08 20:19:14 +00:00
29 changed files with 3710 additions and 1492 deletions

View File

@@ -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');
@@ -86,7 +86,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 {
@@ -132,13 +132,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 +165,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 +236,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 +251,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

View File

@@ -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' },

View File

@@ -1,9 +1,13 @@
/** /**
* 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
} = require('discord.js'); } = require('discord.js');
const { mongoose, withRetry } = require('./db-connection'); const { mongoose, withRetry } = require('./db-connection');
@@ -35,6 +39,213 @@ 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
});
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
@@ -45,304 +256,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',
const welcomeMsg = await enqueueSend(ticketChan, { ticketNumber: number,
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, priority: defaultPriority,
embeds: [ticketInfoEmbed], lastActivity: now,
components: [buttons], parentCategoryId: parentCategoryIdForTicket
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_CHANNEL_ID)
.catch(() => null);
if (transcriptChan) {
await enqueueSend(
ticketChan,
`This email thread has ${transcriptRows.length} previous transcript(s):`
);
for (const row of transcriptRows) {
const transcriptMsg = await transcriptChan.messages
.fetch(row.transcriptMessageId)
.catch(() => null);
if (!transcriptMsg) continue;
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
const originalAttachment = transcriptMsg.attachments.first();
if (originalAttachment) {
await enqueueSend(ticketChan, {
content: 'Transcript file:',
files: [originalAttachment.url]
});
}
}
} }
} },
} catch (err) { { 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;
} }

View File

@@ -15,6 +15,7 @@ const {
ButtonStyle, ButtonStyle,
AttachmentBuilder, AttachmentBuilder,
EmbedBuilder, EmbedBuilder,
MessageFlags,
PermissionFlagsBits, PermissionFlagsBits,
ModalBuilder, ModalBuilder,
TextInputBuilder, TextInputBuilder,
@@ -121,7 +122,7 @@ async function handleTagDeleteConfirm(interaction) {
async function handleClaimButton(interaction, ticket) { async function handleClaimButton(interaction, ticket) {
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean(); const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
if (!freshTicket) { if (!freshTicket) {
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true }); return interaction.reply({ content: 'Ticket data missing.', flags: MessageFlags.Ephemeral });
} }
const isClaimed = !!freshTicket.claimedBy; const isClaimed = !!freshTicket.claimedBy;
@@ -131,19 +132,19 @@ async function handleClaimButton(interaction, ticket) {
const [row0] = interaction.message.components; const [row0] = interaction.message.components;
if (!row0) { if (!row0) {
return interaction.reply({ content: 'No components to update.', ephemeral: true }); return interaction.reply({ content: 'No components to update.', flags: MessageFlags.Ephemeral });
} }
const row = ActionRowBuilder.from(row0); const row = ActionRowBuilder.from(row0);
const [btnClose, btnClaim] = row.components; const [btnClose, btnClaim] = row.components;
if (!btnClose || !btnClaim) { if (!btnClose || !btnClaim) {
return interaction.reply({ content: 'Buttons missing.', ephemeral: true }); return interaction.reply({ content: 'Buttons missing.', flags: MessageFlags.Ephemeral });
} }
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
return interaction.reply({ return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} }
@@ -277,7 +278,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
if (pendingCloses.has(interaction.channel.id)) { if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
} }
const cancelRow = new ActionRowBuilder().addComponents( const cancelRow = new ActionRowBuilder().addComponents(
@@ -289,7 +290,9 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const channelName = interaction.channel.name; const channelName = interaction.channel.name;
const userTag = interaction.user.tag; const userTag = interaction.user.tag;
const timerId = setTimeout(async () => { // Lazy require — broccolini-discord re-exports trackTimeout and we'd otherwise cycle.
const { trackTimeout } = require('../broccolini-discord');
const timerId = trackTimeout(setTimeout(async () => {
const pending = pendingCloses.get(channelId); const pending = pendingCloses.get(channelId);
pendingCloses.delete(channelId); pendingCloses.delete(channelId);
const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean(); const freshTicket = await Ticket.findOne({ discordThreadId: channelId }).lean();
@@ -303,7 +306,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
const effectiveSendEmail = pending?.sendEmail ?? true; const effectiveSendEmail = pending?.sendEmail ?? true;
await runFinalClose(interaction, freshTicket, effectiveSendEmail); await runFinalClose(interaction, freshTicket, effectiveSendEmail);
}, timerSeconds * 1000); }, timerSeconds * 1000));
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail }); pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail });
} }
@@ -324,7 +327,7 @@ async function handleCancelCloseRequest(interaction) {
async function handleEscalatePrompt(interaction, ticket) { async function handleEscalatePrompt(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) { if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true }); return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
} }
const buttons = []; const buttons = [];
@@ -338,7 +341,7 @@ async function handleEscalatePrompt(interaction, ticket) {
return interaction.reply({ return interaction.reply({
content: 'Escalate to which tier?', content: 'Escalate to which tier?',
components: [new ActionRowBuilder().addComponents(buttons)], components: [new ActionRowBuilder().addComponents(buttons)],
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} }
@@ -351,7 +354,7 @@ async function handleEscalateButton(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= tier) { if (currentTier >= tier) {
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, ephemeral: true }); return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
} }
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
@@ -362,7 +365,7 @@ async function handleEscalateButton(interaction, ticket) {
if (!categoryId && !interaction.channel.isThread()) { if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ return interaction.reply({
content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`, content: `Tier ${tier + 1} (ESCALATED${tier + 1}) is not configured for this ticket type.`,
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} }
@@ -372,12 +375,12 @@ async function handleEscalateButton(interaction, ticket) {
async function handleDeescalateButton(interaction, ticket) { async function handleDeescalateButton(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) { if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true }); return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
} }
await runDeferred(interaction, 'deescalate', await runDeferred(interaction, 'deescalate',
() => runDeescalation(interaction, ticket), () => runDeescalation(interaction, ticket),
{ ephemeral: true } { flags: MessageFlags.Ephemeral }
); );
} }
@@ -439,9 +442,11 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id); await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
} }
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
// a stale message ID pointing into the now-deleted channel.
await Ticket.updateOne( await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $set: { discordThreadId: null, status: 'closed' } } { $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
); );
if (transcriptMsg?.id) { if (transcriptMsg?.id) {
@@ -455,12 +460,14 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
const parentCatId = ticket.parentCategoryId; const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild; const guildRef = interaction.guild;
setTimeout(() => interaction.channel.delete().catch(() => {}), 5000); // Lazy require — same cycle reason as in handleConfirmCloseRequest above.
setTimeout(() => { const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000));
trackTimeout(setTimeout(() => {
if (parentCatId && guildRef) { if (parentCatId && guildRef) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
} }
}, 6000); }, 6000));
} catch (e) { } catch (e) {
console.error('Close ticket error:', e); console.error('Close ticket error:', e);
} }
@@ -494,7 +501,12 @@ function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr)
} }
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
const creatorId = ticket.gmailThreadId.split('-').pop(); // Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
// pre-creatorId modal tickets only — split-pop returns the wrong value for
// discord-msg-* tickets (it yields the message ID, not the user ID).
const creatorId = ticket.creatorId
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
if (!creatorId) return;
try { try {
const creator = await client.users.fetch(creatorId); const creator = await client.users.fetch(creatorId);
const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), { const dmFile = new AttachmentBuilder(Buffer.from(transcriptText), {
@@ -524,13 +536,15 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
let logMsg; let logMsg;
if (ticket.gmailThreadId?.startsWith('discord-')) { if (ticket.gmailThreadId?.startsWith('discord-')) {
const creatorId = ticket.gmailThreadId.split('-').pop(); const creatorId = ticket.creatorId
try { || (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
const creator = await interaction.client.users.fetch(creatorId); let creator = null;
logMsg = `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`; if (creatorId) {
} catch { creator = await interaction.client.users.fetch(creatorId).catch(() => null);
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
} }
logMsg = creator
? `Closed ${creator.toString()}'s **${channelName}** by ${closerMention} (${closerDisplayName})`
: `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
} else { } else {
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
} }
@@ -542,7 +556,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
// ============================================================ // ============================================================
async function handleTicketModal(interaction) { async function handleTicketModal(interaction) {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase(); const email = interaction.fields.getTextInputValue('ticket_email').trim().toLowerCase();
const game = interaction.fields.getTextInputValue('ticket_game').trim(); const game = interaction.fields.getTextInputValue('ticket_game').trim();
@@ -578,7 +592,10 @@ async function handleTicketModal(interaction) {
let channel; let channel;
try { try {
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue. // Initial permissionOverwrites on guild.channels.create are safe-by-construction:
// the channel doesn't exist yet, so there's no in-flight rename/send/move to race
// against. Any *subsequent* mutation on this channel (add/remove user, move,
// topic, rename) must go through services/channelQueue.js.
channel = await guild.channels.create({ channel = await guild.channels.create({
name: unclaimedName, name: unclaimedName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
@@ -613,6 +630,7 @@ async function handleTicketModal(interaction) {
ticketNumber, ticketNumber,
priority, priority,
lastActivity: now, lastActivity: now,
creatorId: interaction.user.id,
parentCategoryId: parentCategoryIdForTicket parentCategoryId: parentCategoryIdForTicket
}); });

File diff suppressed because it is too large Load Diff

128
handlers/commands/close.js Normal file
View 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 };

View 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 };

View File

@@ -0,0 +1,214 @@
/**
* Escalation flows.
*
* runEscalation / runDeescalation are exported for handlers/buttons.js
* (the tier-pick buttons share this code path). handleEscalate /
* handleDeescalate are the slash-command entry points.
*/
const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { sendTicketNotificationEmail } = require('../../services/gmail');
const { getTicketActionRow } = require('../../utils/ticketComponents');
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
const { pinMessage } = require('../../services/pinMessage');
const { logError } = require('../../services/debugLog');
const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
const { fetchLoggingChannel } = require('./helpers');
const Ticket = mongoose.model('Ticket');
/**
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred.
*/
async function runEscalation(interaction, ticket, nextTier, reason) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
// Clear claim on escalation
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread() && categoryId) {
await enqueueMove(interaction.channel, categoryId);
}
const pendingEmbed = new EmbedBuilder()
.setDescription('Ticket will be escalated in a few seconds.')
.setColor(CONFIG.EMBED_COLOR_INFO);
await interaction.editReply({ embeds: [pendingEmbed] });
const creatorId = isDiscordTicket
? (ticket.gmailThreadId.split('-').pop() || '').trim()
: null;
const creatorMention = creatorId ? `<@${creatorId}>` : '';
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
await enqueueSend(interaction.channel, {
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
allowedMentions: { parse: ['users', 'roles'] }
});
const escalationBody = CONFIG.ESCALATION_MESSAGE
.replace(/\\n/g, '\n')
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
const escalatedEmbed = new EmbedBuilder()
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
.setDescription(escalationBody)
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
const escalationRow = getTicketActionRow(updatedTicketForRow);
const escalationMsg = await enqueueSend(interaction.channel, {
content: null,
embeds: [escalatedEmbed],
components: [escalationRow]
});
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
await pinMessage(escalationMsg, interaction.client).catch(() => {});
}
if (!isDiscordTicket && ticket.gmailThreadId) {
try {
const escalatorName = interaction.member?.displayName || interaction.user.username;
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
} catch (emailErr) {
console.error('Escalation email failed (non-fatal):', emailErr.message);
}
}
if (nextTier === 2 && ticket.welcomeMessageId) {
try {
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
} catch (e) {
console.error('Failed to update welcome message after escalate:', e.message);
}
}
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}`
);
}
}
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
async function runDeescalation(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const newTier = currentTier - 1;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = newTier > 0;
ticket.escalationTier = newTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread()) {
try {
if (newTier === 0) {
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
} else if (newTier === 1) {
const t2Category = isDiscordTicket
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
if (t2Category) await enqueueMove(interaction.channel, t2Category);
}
} catch (e) {
console.error('Move error (deescalate):', e);
}
}
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
const deescalateEmbed = new EmbedBuilder()
.setColor(0x00BFFF)
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
await interaction.editReply({ embeds: [deescalateEmbed] });
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.`
);
}
}
async function handleEscalate(interaction) {
const reason = null;
const level = interaction.options.getString('level');
const nextTier = level === '3' ? 2 : 1;
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
}
if (nextTier <= currentTier) {
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
}
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
flags: MessageFlags.Ephemeral
});
}
await runDeferred(interaction, 'escalate', () =>
runEscalation(interaction, ticket, nextTier, reason)
);
}
async function handleDeescalate(interaction) {
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
}
await runDeferred(interaction, 'de-escalate',
() => runDeescalation(interaction, ticket),
{ flags: MessageFlags.Ephemeral }
);
}
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate };

View File

@@ -0,0 +1,33 @@
/**
* Cross-submodule helpers for handlers/commands/*.
*
* Lives at this level (not in index.js) so escalation.js, close.js, etc. can
* import without creating circular dependencies with index.js.
*/
const { MessageFlags } = require('discord.js');
const { CONFIG } = require('../../config');
const { isStaff } = require('../../utils');
/**
* Reply ephemeral and return true if the interaction is in a guild and the
* user is not staff (so the caller should bail).
*/
async function requireStaffRole(interaction) {
if (!interaction.guild) return false;
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
if (isStaff(interaction.member)) return false;
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
await interaction.reply({
content: `This command is only available to the support team (${roleMention}).`,
flags: MessageFlags.Ephemeral
});
return true;
}
/** Fetch the configured logging channel, or null if unset/missing. */
async function fetchLoggingChannel(client) {
if (!CONFIG.LOGGING_CHANNEL_ID) return null;
return client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
}
module.exports = { requireStaffRole, fetchLoggingChannel };

299
handlers/commands/index.js Normal file
View File

@@ -0,0 +1,299 @@
/**
* Slash command, context menu, and autocomplete dispatcher.
*
* Submodules own command handlers by topic:
* helpers.js — requireStaffRole, fetchLoggingChannel
* escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
* close.js — handleForceClose, handleCancelClose, handleCloseTimer (+ finalize/transcript)
* response.js — /response subcommands + handleAutocomplete
* panel.js — handlePanel, handleSignature
* contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
*
* This file holds the dispatchers, the small "remainder" handlers
* (channel-mod, settings toggles, /help, /notifydm), and the public
* module.exports surface that handlers/buttons.js + broccolini-discord.js
* import from `require('./commands')`.
*/
const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { setNotifyDm } = require('../../services/staffSettings');
const { enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
const { logTicketEvent } = require('../../services/debugLog');
const { findTicketForChannel } = require('../sharedHelpers');
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation');
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel');
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
const Ticket = mongoose.model('Ticket');
// ============================================================
// Remainder handlers — small enough not to deserve their own module.
// ============================================================
async function handleNotifyDm(interaction) {
try {
const setting = interaction.options.getString('setting') === 'on';
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
await interaction.reply({
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
flags: MessageFlags.Ephemeral
});
} catch (err) {
console.error('notifydm error:', err);
await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {});
}
}
async function handleAdd(interaction) {
const user = interaction.options.getUser('user');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
try {
await enqueueOverwrite(interaction.channel, user.id, {
ViewChannel: true,
SendMessages: true,
ReadMessageHistory: true
});
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) {
console.error('Add user error:', err);
await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral });
}
}
async function handleRemove(interaction) {
const user = interaction.options.getUser('user');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
try {
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) {
console.error('Remove user error:', err);
await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral });
}
}
async function handleTransfer(interaction) {
const member = interaction.options.getUser('member');
const reason = interaction.options.getString('reason') || 'No reason provided';
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral });
}
try {
const claimerLabel = guildMember.displayName || guildMember.user.username;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: claimerLabel } }
);
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
await interaction.reply({
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
}
} catch (err) {
console.error('Transfer error:', err);
await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral });
}
}
async function handleMove(interaction) {
const category = interaction.options.getChannel('category');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
try {
await enqueueMove(interaction.channel, category.id);
await interaction.reply(`Moved ticket to **${category.name}**.`);
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan,
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
);
}
} catch (err) {
console.error('Move error:', err);
await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral });
}
}
async function handleTopic(interaction) {
const text = interaction.options.getString('text');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
try {
await enqueueTopic(interaction.channel, text);
await interaction.reply('Topic updated successfully.');
} catch (err) {
console.error('Topic error:', err);
await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral });
}
}
async function handleStaffThread(interaction) {
const sub = interaction.options.getSubcommand();
if (sub === 'toggle') {
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'name') {
const name = interaction.options.getString('thread_name').slice(0, 100);
CONFIG.STAFF_THREAD_NAME = name;
return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'autorole') {
const enabled = interaction.options.getBoolean('enabled');
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
}
async function handlePinMessages(interaction) {
const sub = interaction.options.getSubcommand();
const enabled = interaction.options.getBoolean('enabled');
if (sub === 'initial') {
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'escalation') {
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'suppress') {
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
}
async function handleGmailPoll(interaction) {
const requested = parseInt(interaction.options.getString('interval'), 10);
// Defense-in-depth: the slash command's addChoices already floors at 30s, but
// clamp the resolved ms here too so any future caller (or skewed input) can't
// drop below 30s and trip Gmail's per-user quota under sustained load.
const ms = Math.max(30000, requested * 1000);
const seconds = ms / 1000;
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
const { setGmailPollInterval } = require('../../broccolini-discord');
setGmailPollInterval(ms);
logTicketEvent('Gmail poll interval updated', [
{ name: 'Interval', value: `${seconds}s` },
{ name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
}
async function handleHelp(interaction) {
const embed = new EmbedBuilder()
.setTitle('Ticket System - Commands')
.setColor(CONFIG.EMBED_COLOR_OPEN)
.addFields([
{
name: 'User Management',
value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket'
},
{
name: 'Ticket Management',
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description'
},
{
name: 'Saved Responses',
value: '`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
},
{
name: 'Variables (for responses)',
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
},
{
name: 'Panel System',
value: '`/panel #channel` - Create a ticket panel for Discord-side tickets'
},
{
name: 'Escalation',
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
}
])
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
}
// ============================================================
// Dispatch tables
// ============================================================
const COMMAND_HANDLERS = {
escalate: handleEscalate,
deescalate: handleDeescalate,
notifydm: handleNotifyDm,
add: handleAdd,
remove: handleRemove,
transfer: handleTransfer,
move: handleMove,
staffthread: handleStaffThread,
pinmessages: handlePinMessages,
gmailpoll: handleGmailPoll,
closetimer: handleCloseTimer,
'cancel-close': handleCancelClose,
'force-close': handleForceClose,
topic: handleTopic,
response: handleResponse,
signature: handleSignature,
help: handleHelp,
panel: handlePanel
};
const CONTEXT_MENU_HANDLERS = {
'Create Ticket From Message': handleCreateTicketFromMessage,
'View User Tickets': handleViewUserTickets
};
/**
* Slash-command dispatcher. /help is open to everyone; everything else
* requires the staff role.
*/
async function handleCommand(interaction) {
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
const handler = COMMAND_HANDLERS[interaction.commandName];
if (handler) await handler(interaction);
}
/** Context-menu dispatcher. All entries are staff-only. */
async function handleContextMenu(interaction) {
if (await requireStaffRole(interaction)) return;
const handler = CONTEXT_MENU_HANDLERS[interaction.commandName];
if (handler) await handler(interaction);
}
module.exports = {
handleCommand,
handleContextMenu,
handleAutocomplete,
runEscalation,
runDeescalation
};

133
handlers/commands/panel.js Normal file
View 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 };

View 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 };

View File

@@ -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

View File

@@ -4,6 +4,7 @@
* Both handlers/commands.js and handlers/buttons.js use these to avoid * Both handlers/commands.js and handlers/buttons.js use these to avoid
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches. * repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
*/ */
const { MessageFlags } = require('discord.js');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { logError } = require('../services/debugLog'); const { logError } = require('../services/debugLog');
@@ -20,7 +21,7 @@ const Ticket = mongoose.model('Ticket');
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') { async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) { if (!ticket) {
await interaction.reply({ content: missingMessage, ephemeral: true }); await interaction.reply({ content: missingMessage, flags: MessageFlags.Ephemeral });
return null; return null;
} }
return ticket; return ticket;
@@ -34,18 +35,18 @@ async function findTicketForChannel(interaction, missingMessage = 'This channel
* @param {import('discord.js').Interaction} interaction * @param {import('discord.js').Interaction} interaction
* @param {string} verb * @param {string} verb
* @param {() => Promise<void>} fn * @param {() => Promise<void>} fn
* @param {{ ephemeral?: boolean }} [opts] * @param {{ flags?: number }} [opts] - pass `MessageFlags.Ephemeral` for ephemeral defer
*/ */
async function runDeferred(interaction, verb, fn, { ephemeral = false } = {}) { async function runDeferred(interaction, verb, fn, { flags } = {}) {
try { try {
await interaction.deferReply({ ephemeral }); await interaction.deferReply(flags ? { flags } : {});
await fn(); await fn();
} catch (err) { } catch (err) {
console.error(`${verb} error:`, err); console.error(`${verb} error:`, err);
logError(verb, err, interaction).catch(() => {}); logError(verb, err, interaction).catch(() => {});
const msg = `Failed to ${verb} this ticket.`; const msg = `Failed to ${verb} this ticket.`;
await interaction.editReply({ content: msg }).catch(() => await interaction.editReply({ content: msg }).catch(() =>
interaction.followUp({ content: msg, ephemeral: true }).catch(() => {}) interaction.followUp({ content: msg, flags: MessageFlags.Ephemeral }).catch(() => {})
); );
} }
} }

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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": [],

View File

@@ -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') {

View 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);
});

View File

@@ -113,6 +113,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 +232,4 @@ function enqueueDelete(channel) {
return next; return next;
} }
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete }; module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };

View File

@@ -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(', ')}]` : '')
);
} }
} }

View File

@@ -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 (1820 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);

View File

@@ -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(() => {});
} }
} }
} }

View File

@@ -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(() => {});
} }
} }

View File

@@ -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
View 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
View 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('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
expect(escapeHtml("a & b's <foo>")).toBe('a &amp; b&#39;s &lt;foo&gt;');
});
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);
});
});

View File

@@ -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();
} }

10
vitest.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.js'],
globals: false,
testTimeout: 10000
}
});