/** * Ticket database helpers – counters, rename, limits, auto-close, * 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 { recordAction } = require('./staffStats'); 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} */ 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} */ 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 }; } // --- CLOSE TRANSITION --- /** * Atomic conditional close: updates the ticket only when status is 'open'. * Sets status:'closed', closedAt, and any caller-supplied extra $set/$unset * fields in ONE update so all side-writes land atomically. * Returns { transitioned: true, ticket } when an open ticket was just closed, * { transitioned: false, ticket: null } when the ticket was already closed * (modifiedCount was 0 — the status filter did not match). */ async function attemptCloseTransition(gmailThreadId, extraSet = {}, extraUnset = {}, _TicketModel) { const T = _TicketModel || Ticket; const closedAt = new Date(); const update = { $set: { status: 'closed', closedAt, ...extraSet } }; if (Object.keys(extraUnset).length > 0) { update.$unset = extraUnset; } const result = await T.updateOne({ gmailThreadId, status: 'open' }, update); const transitioned = result.modifiedCount === 1; const ticket = transitioned ? await T.findOne({ gmailThreadId }).lean() : null; return { transitioned, ticket }; } /** * Schedule the final ticket-channel delete after a short grace period (so staff * read the close message first), routed through the channel queue. * * The delete is guarded by the `pendingDelete` flag: the caller MUST have already * set `pendingDelete: true` on the ticket AND left `discordThreadId` populated, so * that a restart during the grace window is recovered on boot by * resumePendingDeletes() (which re-fetches the channel and deletes it). The flag * is cleared once enqueueDelete resolves; if the doc is gone the unset is a no-op. * * Shared by all three close paths (auto-close, button, slash) so they behave * identically and none can orphan a channel on a mid-close restart. */ function scheduleTicketChannelDelete(channel, gmailThreadId, delayMs = 5000) { // Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe at call time. const { trackTimeout } = require('../broccolini-discord'); trackTimeout(setTimeout(() => { enqueueDelete(channel).then(() => { withRetry(() => Ticket.updateOne( { gmailThreadId }, { $unset: { pendingDelete: '' } } )).catch(() => {}); }).catch(() => {}); }, delayMs)); } // --- SCHEDULED CHECKS --- // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. async function checkAutoClose(client, sendTicketClosedEmail, _TicketModel, _recordAction, _deps) { const cfg = (_deps && _deps.config) || CONFIG; if (!cfg.AUTO_CLOSE_ENABLED) return; const T = _TicketModel || Ticket; const record = _recordAction || recordAction; const _withRetry = (_deps && _deps.withRetry) || withRetry; const _enqueueSend = (_deps && _deps.enqueueSend) || enqueueSend; const cutoffTime = new Date(Date.now() - (cfg.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000)); // Bounded per-tick so a huge backlog drains across successive hourly runs. const staleTickets = await _withRetry(() => T.find({ status: 'open', lastActivity: { $lt: cutoffTime, $ne: null } }).sort({ createdAt: 1 }).limit(500).lean()); const guild = client.guilds.cache.first(); if (!guild) return; for (const ticket of staleTickets) { try { 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. const { transitioned: autoTransitioned, ticket: autoClosedTicket } = await _withRetry(() => attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, {}, T)); if (autoTransitioned) { record('system', 'close', { ticket: autoClosedTicket, guildId: guild.id, closerType: 'system', resolverId: autoClosedTicket.claimerId ?? null, wasClaimed: Boolean(autoClosedTicket.claimerId) }); } await sendTicketClosedEmail(ticket, 'Auto-Close System', null); if (_deps && _deps.scheduleDelete) { _deps.scheduleDelete(channel, ticket); } else { scheduleTicketChannelDelete(channel, ticket.gmailThreadId); } } } 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()); const guild = client.guilds.cache.first(); if (!guild) return; for (const ticket of staleClaimedTickets) { try { 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, _TicketModel, _recordAction) { const T = _TicketModel || Ticket; const record = _recordAction || recordAction; 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 T.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) { const { transitioned: reconTransitioned, ticket: reconClosedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, {}, T); if (reconTransitioned) { record('system', 'close', { ticket: reconClosedTicket, guildId: guild.id, closerType: 'system', resolverId: reconClosedTicket.claimerId ?? null, wasClaimed: Boolean(reconClosedTicket.claimerId) }); } } } 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, attemptCloseTransition, scheduleTicketChannelDelete, checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes, startTicketsSweeps };