Files
broccolini-bot/services/tickets.js
indifferentketchup 2fab3b97bf Remove dead/stale code, dedup close+escalation paths
Dead/stale removals (grep-confirmed no consumers):
- config: drop 9 unread CONFIG keys (ROLE_TO_PING_ID, SIGNATURE,
  REMINDER_*, RENAME_LOG_CHANNEL_ID, SETTINGS_*); remove their
  ALLOWED_CONFIG_KEYS entries and the orphaned settings-site UI fields
- configSchema: delete unreachable json/string_or_json validators
- models: drop unused ticketTag field
- gmail-poll: remove unused isPollSuspended export
- utils: remove dead htmlToTextWithBlocks/decodeHtmlEntities/BLOCK_TAG_REGEX
- internalApi: remove router._allowedKeys (test it served is gone)
- discord client: drop unused GuildPresences privileged intent
- broccolini-discord: remove dormant /api 503 gate (no /api routes)

Fixes:
- context-menu ticket create now uses makeTicketName('unclaimed', ...)
  instead of the contract-violating ticket-<n> name
- drop write-only pending.userId from both close paths

Dedup / simplify:
- new services/transcript.js shares the transcript text/date/header
  builders between the button and force-close paths (had drifted)
- resolveEscalationCategoryId() replaces 3 copies of the category logic
- ticketChannelOverwrites() shares the create-permission array between
  the two interactive ticket-create paths
- finalizeBody() shares the email-cleanup tail in parseGmailMessage
- getTicketActionRow drops its never-passed options arg;
  sendTicketNotificationEmail drops its always-null subjectLine arg
- hoist invariant guild lookup out of the auto-close/unclaim loops
- drop redundant lastActivity write (and now-dead updateTicketActivity)
- /help lists all current commands and the right-click apps
2026-06-02 19:59:14 +00:00

426 lines
15 KiB
JavaScript
Raw 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,
* 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 };
}
// --- 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());
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.
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());
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) {
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,
checkAutoClose,
checkAutoUnclaim,
reconcileDeletedTicketChannels,
resumePendingDeletes,
startTicketsSweeps
};