Files
broccolini-bot/services/tickets.js
indifferentketchup 840b6bfcf8 simplify: prune dead code, dedup gmail send, drop neutered log stubs
- Remove no-op log stubs (logGmail, logAutomation, logSecurity, logSystem)
  and ~17 callsites; dead counters in tickets.js and gmail-poll.js go too
- Dedup three near-identical Gmail send paths into sendThreadedEmail helper
- Drop dead Mongoose fields: broccoliniTicketId, lastSyncedBroccoliniArticleId,
  renameCount, renameWindowStart, reminderSent, staffChannelId,
  unclaimedRemindersSent, lastMessageAuthorIsStaff
- Drop dead config fields and their .env.example entries
- Inline api/botClient.js (3-line wrapper, 2 callers)
- Trim unused exports across utils.js, tickets.js, configSchema.js, debugLog.js
- Fix handlers/messages.js to use isStaff() — old partial check ignored
  ADDITIONAL_STAFF_ROLES, so those members were treated as customers
- Drop unused deps p-queue + dotenv-expand; move mongodb to devDependencies

Net: -583 LOC source + -57 LOC lockfile. All 23 modules load clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:37:14 +00:00

429 lines
14 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,
* 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-')) {
const creatorUserId = ticket.gmailThreadId.split('-').pop();
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);
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
};