Files
broccolini-bot/services/tickets.js
indifferentketchup 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

436 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Ticket database helpers counters, rename, limits, auto-close,
* reminders, auto-unclaim, channel creation.
*/
const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config');
const { enqueueSend, enqueueDelete } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter');
// --- TICKET NUMBER ---
async function getNextTicketNumber(senderEmail) {
const senderLocal = senderEmail.split('@')[0].toLowerCase();
const counter = await TicketCounter.findOneAndUpdate(
{ senderLocal },
{ $inc: { counter: 1 } },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
return { local: senderLocal, number: counter.counter };
}
// --- RENAME + NAMING ---
// Renames flow through utils/renamer.js (RENAMER_BOT secondary token),
// which has its own Discord rate-limit bucket. We no longer gate on the
// primary bot's 2/10min per-channel budget here; 429s from the secondary
// bot surface via utils/renamer.js instead.
function getSenderLocal(senderEmail) {
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
}
function toDiscordSafeName(str) {
return str
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}\p{Emoji_Presentation}-]/gu, '')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100);
}
/**
* Resolve a human-friendly creator nickname for channel naming.
* Discord tickets: guild member displayName. Email tickets: senderLocal.
* @param {import('discord.js').Guild} guild
* @param {object} ticket
* @returns {Promise<string>}
*/
async function resolveCreatorNickname(guild, ticket) {
if (ticket.gmailThreadId.startsWith('discord-')) {
// Prefer ticket.creatorId (stored on creation). Legacy fallback parses the
// tail segment, which is correct for discord-${ts}-${userId} but returns
// the message ID for discord-msg-${ts}-${msgId} — skip the parse for those.
const creatorUserId = ticket.creatorId
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
if (!creatorUserId) return getSenderLocal(ticket.senderEmail);
try {
const member = await guild.members.fetch(creatorUserId);
return member.displayName;
} catch {
return getSenderLocal(ticket.senderEmail);
}
}
return getSenderLocal(ticket.senderEmail);
}
/**
* Build a channel name from ticket state.
* @param {'unclaimed'|'claimed'|'escalated'|'escalated-claimed'} state
* @param {object} ticket
* @param {string} creatorNickname - pre-resolved via resolveCreatorNickname
* @param {string} [claimerEmoji] - required for claimed / escalated-claimed
* @returns {string}
*/
function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
const num = ticket.ticketNumber || 1;
switch (state) {
case 'claimed':
return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
case 'escalated':
return toDiscordSafeName(`escalated-${creatorNickname}-${num}`);
case 'escalated-claimed':
return toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`);
case 'unclaimed':
default:
return toDiscordSafeName(`unclaimed-${creatorNickname}-${num}`);
}
}
// --- RATE LIMIT (per-user ticket creation) ---
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
const TICKET_CREATION_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const TICKET_CREATION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function sweepTicketCreationByUser(now = Date.now()) {
// An entry is stale when its window has been expired long enough that no
// legitimate rate-limit decision would still consult it. resetAt is a future
// ms timestamp when the window ends; cutoff is 48h past that.
const cutoff = now - TICKET_CREATION_SWEEP_TTL_MS;
for (const [key, entry] of ticketCreationByUser.entries()) {
if ((entry?.resetAt ?? 0) < cutoff) ticketCreationByUser.delete(key);
}
}
function startTicketsSweeps(trackInterval) {
const handle = setInterval(() => sweepTicketCreationByUser(), TICKET_CREATION_SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
/**
* Check if the user can create a ticket (rate limit). If allowed, consumes one slot.
* @param {string} userId - Discord user ID
* @returns {{ allowed: boolean, retryAfterMs?: number }}
*/
function checkTicketCreationRateLimit(userId) {
const limit = CONFIG.RATE_LIMIT_TICKETS_PER_USER;
const windowMs = (CONFIG.RATE_LIMIT_WINDOW_MINUTES || 60) * 60 * 1000;
if (!limit || limit <= 0) return { allowed: true };
const now = Date.now();
let entry = ticketCreationByUser.get(userId);
if (!entry || now >= entry.resetAt) {
entry = { count: 1, resetAt: now + windowMs };
ticketCreationByUser.set(userId, entry);
return { allowed: true };
}
if (entry.count >= limit) {
return { allowed: false, retryAfterMs: entry.resetAt - now };
}
entry.count++;
return { allowed: true };
}
// --- CHANNEL CREATION (overflow: Discord limit 50 channels per category) ---
const CHANNELS_PER_CATEGORY_LIMIT = 50;
function escapeCategoryNameForRegex(name) {
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function countChannelsInCategory(guild, categoryId) {
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
}
/**
* Resolve or create a ticket category with dynamic overflow (Discord max 50 channels per category).
* @param {import('discord.js').Guild} guild
* @param {string} primaryCategoryId
* @param {string} categoryName Display base name (primary category should match; overflows are "(Overflow N)")
* @returns {Promise<string>}
*/
async function getOrCreateTicketCategory(guild, primaryCategoryId, categoryName) {
if (!guild) {
throw new Error('getOrCreateTicketCategory: guild is required');
}
if (!primaryCategoryId || !String(primaryCategoryId).trim()) {
throw new Error('getOrCreateTicketCategory: primaryCategoryId is required');
}
try {
let primary = guild.channels.cache.get(primaryCategoryId);
if (!primary) {
primary = await guild.channels.fetch(primaryCategoryId).catch(() => null);
}
if (!primary || primary.type !== ChannelType.GuildCategory) {
throw new Error(`getOrCreateTicketCategory: primary category ${primaryCategoryId} not found or not a category`);
}
const escaped = escapeCategoryNameForRegex(categoryName);
const overflowRe = new RegExp(`^${escaped} \\(Overflow (\\d+)\\)$`);
const overflowMatches = [];
for (const ch of guild.channels.cache.values()) {
if (!ch || ch.type !== ChannelType.GuildCategory) continue;
if (ch.id === primaryCategoryId) continue;
const m = ch.name.match(overflowRe);
if (m) overflowMatches.push({ ch, n: parseInt(m[1], 10) });
}
overflowMatches.sort((a, b) => a.n - b.n);
const existingCategories = [primary, ...overflowMatches.map(x => x.ch)];
for (const cat of existingCategories) {
if (countChannelsInCategory(guild, cat.id) < CHANNELS_PER_CATEGORY_LIMIT) {
return cat.id;
}
}
const highestN = overflowMatches.length > 0 ? Math.max(...overflowMatches.map(x => x.n)) : 0;
const nextN = highestN + 1;
const newName = `${categoryName} (Overflow ${nextN})`;
const lastCat = existingCategories[existingCategories.length - 1];
const position = (lastCat?.rawPosition ?? lastCat?.position ?? 0) + 1;
let newCat;
try {
newCat = await guild.channels.create({
name: newName,
type: ChannelType.GuildCategory,
position
});
} catch (createErr) {
console.error('getOrCreateTicketCategory: failed to create overflow category:', createErr);
throw createErr;
}
return newCat.id;
} catch (err) {
console.error('getOrCreateTicketCategory:', err);
const fallback = guild.channels.cache.get(primaryCategoryId);
if (fallback?.type === ChannelType.GuildCategory) {
return primaryCategoryId;
}
throw err;
}
}
/**
* Delete an overflow category if it is empty and its name matches "${categoryName} (Overflow N)".
* Never deletes the primary category (exact name match).
* @param {import('discord.js').Guild} guild
* @param {string} categoryId
* @param {string} categoryName
*/
async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
try {
if (!guild || !categoryId) return;
const cached = guild.channels.cache.filter(c => c.parentId === categoryId);
if (cached.size !== 0) return;
let cat = guild.channels.cache.get(categoryId);
if (!cat) {
cat = await guild.channels.fetch(categoryId).catch(() => null);
}
if (!cat || cat.type !== ChannelType.GuildCategory) return;
if (cat.name === categoryName) return;
const escaped = escapeCategoryNameForRegex(categoryName);
const overflowRe = new RegExp(`^${escaped} \\(Overflow \\d+\\)$`);
if (!overflowRe.test(cat.name)) return;
await cat.delete().catch(deleteErr => {
console.error('cleanupEmptyOverflowCategory: delete failed:', deleteErr);
});
} catch (err) {
console.error('cleanupEmptyOverflowCategory:', err);
}
}
// --- LIMITS & PERMISSIONS ---
async function checkTicketLimits(senderEmail) {
if (!CONFIG.GLOBAL_TICKET_LIMIT) return { ok: true };
const currentCount = await Ticket.countDocuments({ senderEmail, status: 'open' });
if (currentCount >= CONFIG.GLOBAL_TICKET_LIMIT) {
return {
ok: false,
reason: `You have reached the maximum limit of ${CONFIG.GLOBAL_TICKET_LIMIT} open tickets.`
};
}
return { ok: true };
}
// --- ACTIVITY ---
async function updateTicketActivity(gmailThreadId) {
await Ticket.updateOne(
{ gmailThreadId },
{ $set: { lastActivity: new Date() } }
);
}
// --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
async function checkAutoClose(client, sendTicketClosedEmail) {
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
// Bounded per-tick so a huge backlog drains across successive hourly runs.
const staleTickets = await withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean());
for (const ticket of staleTickets) {
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
// Persist pendingDelete BEFORE the delay so a shutdown mid-delay can be
// resumed on boot via resumePendingDeletes(). Cleared after enqueueDelete
// resolves; if the doc is gone the unset is a no-op.
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', pendingDelete: true } }
));
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000));
}
} catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
}
}
}
async function checkAutoUnclaim(client) {
if (!CONFIG.AUTO_UNCLAIM_ENABLED) return;
const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000));
const staleClaimedTickets = await withRetry(() => Ticket.find({
status: 'open',
claimedBy: { $ne: null },
lastActivity: { $lt: unclaimTime, $ne: null }
}).lean());
for (const ticket of staleClaimedTickets) {
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: null } }
));
await enqueueSend(channel,
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
);
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
}
} catch (error) {
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
}
}
}
async function reconcileDeletedTicketChannels(client) {
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return;
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({
status: 'open',
discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean();
for (const ticket of openTickets) {
try {
let channel = guild.channels.cache.get(ticket.discordThreadId);
if (!channel) {
channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
}
if (!channel) {
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', discordThreadId: null } }
);
}
} catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
}
}
}
/**
* Resume deletes that were pending when the bot last shut down. Called once
* from the ready handler. Clears the flag regardless of fetch result so a
* stale flag (e.g. channel already gone) can't loop.
*/
async function resumePendingDeletes(client) {
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
if (!pending.length) return;
for (const ticket of pending) {
try {
const guild = client.guilds.cache.first();
if (guild && ticket.discordThreadId) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
enqueueDelete(channel).catch(() => {});
}
}
Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
).catch(() => {});
} catch (e) {
console.error('resumePendingDeletes error:', e);
}
}
}
module.exports = {
getNextTicketNumber,
getOrCreateTicketCategory,
cleanupEmptyOverflowCategory,
getSenderLocal,
toDiscordSafeName,
resolveCreatorNickname,
makeTicketName,
checkTicketCreationRateLimit,
checkTicketLimits,
updateTicketActivity,
checkAutoClose,
checkAutoUnclaim,
reconcileDeletedTicketChannels,
resumePendingDeletes,
startTicketsSweeps
};