- delete services/{patternChecker,patternStore,surgeChecker,chatAlertChecker,staffNotifications,staffChannel,notificationRegistry,notificationEnabled,staffPresence}.js
- remove /notification, /staffnotification, /tag, /priority
- /escalate: drop action param, always unclaim
- purge PATTERN_*, SURGE_*, CHAT_ALERT_*, STAFF_* env vars from config + .env.example
- drop StaffNotification model
- ~2500 LOC removed
- settings-site /internal/notifications/* endpoints gone (UI will 404 until trimmed)
676 lines
23 KiB
Plaintext
676 lines
23 KiB
Plaintext
/**
|
||
* Ticket database helpers – counters, rename, limits, auto-close,
|
||
* reminders, auto-unclaim, channel creation.
|
||
*/
|
||
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
||
const { mongoose, withRetry } = require('../db-connection');
|
||
const { CONFIG } = require('../config');
|
||
const { getPriorityEmoji } = require('../utils');
|
||
const { logAutomation } = require('../services/debugLog');
|
||
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.
|
||
|
||
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
|
||
const RENAME_LIMIT = 2;
|
||
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
// Retained for external callers (bOSScord, scripts). The gate now lives in
|
||
// the secondary bot's rate bucket; this helper no longer touches Mongo.
|
||
async function canRename(_ticket) {
|
||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||
}
|
||
|
||
function minutesFromMs(ms) {
|
||
return Math.max(1, Math.ceil(ms / 60000));
|
||
}
|
||
|
||
// --- 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, '\\$&');
|
||
}
|
||
|
||
/**
|
||
* @deprecated Use getOrCreateTicketCategory instead.
|
||
* @returns {null}
|
||
*/
|
||
function pickTicketCategoryId(guild, categoryIds) {
|
||
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
|
||
return null;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
||
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
||
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
||
if (!parentChannel) {
|
||
throw new Error('Thread parent channel not found');
|
||
}
|
||
|
||
const thread = await parentChannel.threads.create({
|
||
name: `🎫・ticket-${ticketNumber}`,
|
||
autoArchiveDuration: 1440,
|
||
type: ChannelType.PrivateThread,
|
||
invitable: false,
|
||
reason: `Ticket #${ticketNumber}`
|
||
});
|
||
|
||
await thread.members.add(userId);
|
||
// Add all members with the support role so they can see and reply in the thread
|
||
if (CONFIG.ROLE_ID_TO_PING) {
|
||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||
if (role?.members?.size) {
|
||
for (const [memberId] of role.members) {
|
||
if (memberId === userId) continue; // already added
|
||
await thread.members.add(memberId).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
return thread;
|
||
} else {
|
||
let parentId;
|
||
try {
|
||
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||
} catch (e) {
|
||
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||
throw new Error('Ticket category not found or could not be allocated');
|
||
}
|
||
|
||
let channel;
|
||
try {
|
||
channel = await guild.channels.create({
|
||
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||
type: ChannelType.GuildText,
|
||
parent: parentId,
|
||
permissionOverwrites: [
|
||
{
|
||
id: guild.id,
|
||
deny: [PermissionFlagsBits.ViewChannel]
|
||
},
|
||
{
|
||
id: userId,
|
||
allow: [
|
||
PermissionFlagsBits.ViewChannel,
|
||
PermissionFlagsBits.SendMessages,
|
||
PermissionFlagsBits.ReadMessageHistory
|
||
]
|
||
},
|
||
{
|
||
id: CONFIG.ROLE_ID_TO_PING,
|
||
allow: [
|
||
PermissionFlagsBits.ViewChannel,
|
||
PermissionFlagsBits.SendMessages,
|
||
PermissionFlagsBits.ReadMessageHistory
|
||
]
|
||
}
|
||
]
|
||
});
|
||
} catch (e) {
|
||
console.error('guild.channels.create (createTicketChannel):', e);
|
||
throw e;
|
||
}
|
||
|
||
return channel;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
|
||
* Adds creator and all members with ROLE_ID_TO_PING.
|
||
* @param {import('discord.js').Guild} guild
|
||
* @param {number} ticketNumber
|
||
* @param {string} creatorUserId
|
||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||
*/
|
||
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
|
||
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
|
||
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
|
||
const parentChannel = guild.channels.cache.get(parentId);
|
||
if (!parentChannel) throw new Error('Discord thread parent channel not found');
|
||
|
||
const thread = await parentChannel.threads.create({
|
||
name: `🎫・ticket-${ticketNumber}`,
|
||
autoArchiveDuration: 1440,
|
||
type: ChannelType.PrivateThread,
|
||
invitable: false,
|
||
reason: `Ticket #${ticketNumber}`
|
||
});
|
||
|
||
await thread.members.add(creatorUserId);
|
||
if (CONFIG.ROLE_ID_TO_PING) {
|
||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||
if (role?.members?.size) {
|
||
for (const [memberId] of role.members) {
|
||
if (memberId === creatorUserId) continue;
|
||
await thread.members.add(memberId).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
return thread;
|
||
}
|
||
|
||
/**
|
||
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
|
||
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
|
||
* @param {import('discord.js').Guild} guild
|
||
* @param {number} ticketNumber
|
||
* @param {string} chanName
|
||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||
*/
|
||
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
|
||
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
|
||
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
|
||
const parentChannel = guild.channels.cache.get(parentId);
|
||
if (!parentChannel) throw new Error('Email thread parent channel not found');
|
||
|
||
const thread = await parentChannel.threads.create({
|
||
name: chanName || `🎫・ticket-${ticketNumber}`,
|
||
autoArchiveDuration: 1440,
|
||
type: ChannelType.PrivateThread,
|
||
invitable: false,
|
||
reason: `Ticket #${ticketNumber}`
|
||
});
|
||
|
||
if (CONFIG.ROLE_ID_TO_PING) {
|
||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||
if (role?.members?.size) {
|
||
for (const [memberId] of role.members) {
|
||
await thread.members.add(memberId).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
return thread;
|
||
}
|
||
|
||
// --- 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 };
|
||
}
|
||
|
||
function hasBlacklistedRole(member) {
|
||
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
|
||
return false;
|
||
}
|
||
return member.roles.cache.some(role =>
|
||
CONFIG.BLACKLISTED_ROLES.includes(role.id)
|
||
);
|
||
}
|
||
|
||
// --- ACTIVITY ---
|
||
|
||
async function updateTicketActivity(gmailThreadId) {
|
||
const now = new Date();
|
||
await Ticket.updateOne(
|
||
{ gmailThreadId },
|
||
{ $set: { lastActivity: now, reminderSent: false } }
|
||
);
|
||
}
|
||
|
||
// --- 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());
|
||
|
||
let checked = 0, closed = 0;
|
||
for (const ticket of staleTickets) {
|
||
checked++;
|
||
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');
|
||
|
||
setTimeout(() => {
|
||
enqueueDelete(channel).then(() => {
|
||
withRetry(() => Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $unset: { pendingDelete: '' } }
|
||
)).catch(() => {});
|
||
}).catch(() => {});
|
||
}, 5000);
|
||
closed++;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||
}
|
||
}
|
||
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
|
||
}
|
||
|
||
async function checkReminders(client) {
|
||
if (!CONFIG.REMINDER_ENABLED) return;
|
||
|
||
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
|
||
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
|
||
status: 'open',
|
||
lastActivity: { $lt: reminderTime, $ne: null },
|
||
reminderSent: false
|
||
}).lean());
|
||
|
||
let checked = 0, reminded = 0;
|
||
for (const ticket of ticketsNeedingReminder) {
|
||
checked++;
|
||
try {
|
||
const guild = client.guilds.cache.first();
|
||
if (!guild) continue;
|
||
|
||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||
if (channel) {
|
||
const ping = ticket.claimedBy
|
||
? `<@${ticket.claimedBy}>`
|
||
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
|
||
const message = CONFIG.REMINDER_MESSAGE
|
||
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
|
||
.replace(/\{ping\}/g, ping);
|
||
await enqueueSend(channel, message);
|
||
|
||
await withRetry(() => Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $set: { reminderSent: true } }
|
||
));
|
||
reminded++;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
|
||
}
|
||
}
|
||
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
|
||
}
|
||
|
||
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());
|
||
|
||
let checked = 0, unclaimed = 0;
|
||
for (const ticket of staleClaimedTickets) {
|
||
checked++;
|
||
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}`);
|
||
unclaimed++;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
|
||
}
|
||
}
|
||
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
|
||
}
|
||
|
||
async function reconcileDeletedTicketChannels(client) {
|
||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
|
||
if (!guild) return { checked: 0, reconciled: 0 };
|
||
|
||
// 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();
|
||
|
||
let checked = 0, reconciled = 0;
|
||
for (const ticket of openTickets) {
|
||
checked++;
|
||
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 } }
|
||
);
|
||
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
|
||
reconciled++;
|
||
}
|
||
} catch (err) {
|
||
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
|
||
}
|
||
}
|
||
if (reconciled > 0) {
|
||
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
|
||
}
|
||
return { checked, reconciled };
|
||
}
|
||
|
||
/**
|
||
* 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 0;
|
||
let resumed = 0;
|
||
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(() => {});
|
||
resumed++;
|
||
}
|
||
}
|
||
Ticket.updateOne(
|
||
{ gmailThreadId: ticket.gmailThreadId },
|
||
{ $unset: { pendingDelete: '' } }
|
||
).catch(() => {});
|
||
} catch (e) {
|
||
console.error('resumePendingDeletes error:', e);
|
||
}
|
||
}
|
||
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
|
||
return resumed;
|
||
}
|
||
|
||
module.exports = {
|
||
getNextTicketNumber,
|
||
getOrCreateTicketCategory,
|
||
cleanupEmptyOverflowCategory,
|
||
createDiscordTicketAsThread,
|
||
createEmailTicketAsThread,
|
||
RENAME_WINDOW_MS,
|
||
RENAME_LIMIT,
|
||
getSenderLocal,
|
||
toDiscordSafeName,
|
||
resolveCreatorNickname,
|
||
makeTicketName,
|
||
canRename,
|
||
minutesFromMs,
|
||
checkTicketCreationRateLimit,
|
||
createTicketChannel,
|
||
checkTicketLimits,
|
||
hasBlacklistedRole,
|
||
updateTicketActivity,
|
||
checkAutoClose,
|
||
checkReminders,
|
||
checkAutoUnclaim,
|
||
reconcileDeletedTicketChannels,
|
||
resumePendingDeletes,
|
||
startTicketsSweeps,
|
||
sweepTicketCreationByUser,
|
||
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
|
||
};
|