/** * Ticket database helpers – counters, rename, limits, auto-close, * reminders, auto-unclaim, channel creation. */ const { ChannelType, PermissionFlagsBits } = require('discord.js'); const { mongoose } = require('../db-connection'); const { CONFIG } = require('../config'); const { getPriorityEmoji } = require('../utils'); 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 --- // Discord rate limit: 2 channel renames per 10 minutes per channel (see https://discord.com/developers/docs/topics/rate-limits). // When limit is reached we skip the rename and post: "Channel renamed too quickly. Try again ." const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes const RENAME_LIMIT = 2; function getSenderLocal(senderEmail) { return (senderEmail || 'unknown').split('@')[0].toLowerCase(); } function makeTicketName({ escalated, claimed }, ticket, guild) { const senderLocal = getSenderLocal(ticket.senderEmail); const num = ticket.ticketNumber || 1; if (escalated) { return claimed ? `e-ticket-${senderLocal}-${num}` : `escalated-ticket-${senderLocal}-${num}`; } return `ticket-${senderLocal}-${num}`; } async function canRename(ticket) { const now = Date.now(); const windowStart = (ticket.renameWindowStart && new Date(ticket.renameWindowStart).getTime()) || 0; let count = ticket.renameCount || 0; if (now - windowStart >= RENAME_WINDOW_MS) { await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { renameWindowStart: new Date(now), renameCount: 0 } } ); ticket.renameWindowStart = new Date(now); ticket.renameCount = 0; return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 }; } const remaining = RENAME_LIMIT - count; if (remaining <= 0) { const waitMs = RENAME_WINDOW_MS - (now - windowStart); return { ok: false, remaining: 0, waitMs }; } await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $inc: { renameCount: 1 } } ); ticket.renameCount = count + 1; return { ok: true, remaining: RENAME_LIMIT - (count + 1), 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 } /** * 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; /** * Pick the first category that has room (< 50 channels). Main + overflow IDs in order. * @param {import('discord.js').Guild} guild * @param {string[]} categoryIds [mainId, ...overflowIds] * @returns {string|null} category id to use as parent, or null */ function pickTicketCategoryId(guild, categoryIds) { if (!guild || !Array.isArray(categoryIds)) return null; const list = categoryIds.filter(Boolean); for (const id of list) { const cat = guild.channels.cache.get(id); if (!cat || cat.type !== ChannelType.GuildCategory) continue; const count = guild.channels.cache.filter(c => c.parentId === id).size; if (count < CHANNELS_PER_CATEGORY_LIMIT) return id; } return list[0] || null; } async function createTicketChannel(guild, ticketNumber, userId, subject) { 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 { const categoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])]; const parentId = pickTicketCategoryId(guild, categoryIds); if (!parentId) { throw new Error('Ticket category not found or all categories full (50 channels max per category)'); } const channel = await guild.channels.create({ name: `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 ] } ] }); 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} */ 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} */ 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)); const staleTickets = await Ticket.find({ status: 'open', lastActivity: { $lt: cutoffTime, $ne: null } }).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 channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { status: 'closed' } } ); await sendTicketClosedEmail(ticket, 'Auto-Close System'); setTimeout(() => channel.delete().catch(() => {}), 5000); } } catch (error) { console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error); } } } 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 Ticket.find({ status: 'open', lastActivity: { $lt: reminderTime, $ne: null }, reminderSent: false }).lean(); for (const ticket of ticketsNeedingReminder) { 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 channel.send(message); await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { reminderSent: true } } ); } } catch (error) { console.error(`Reminder 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 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 Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $set: { claimedBy: null } } ); await channel.send( `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); } } } module.exports = { getNextTicketNumber, pickTicketCategoryId, createDiscordTicketAsThread, createEmailTicketAsThread, RENAME_WINDOW_MS, RENAME_LIMIT, getSenderLocal, makeTicketName, canRename, minutesFromMs, checkTicketCreationRateLimit, createTicketChannel, checkTicketLimits, hasBlacklistedRole, updateTicketActivity, checkAutoClose, checkReminders, checkAutoUnclaim };