Initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-10 08:22:19 -06:00
commit 519788c633
39 changed files with 17121 additions and 0 deletions

427
services/tickets.js Normal file
View File

@@ -0,0 +1,427 @@
/**
* 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 };
}
async function saveZammadId(gmailThreadId, zammadId) {
await Ticket.updateOne(
{ gmailThreadId },
{ $set: { zammadTicketId: zammadId } }
);
}
// --- 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 <t:unlock:R>."
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<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));
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.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 message = CONFIG.REMINDER_MESSAGE.replace('{hours}', CONFIG.REMINDER_AFTER_HOURS);
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,
saveZammadId,
pickTicketCategoryId,
createDiscordTicketAsThread,
createEmailTicketAsThread,
RENAME_WINDOW_MS,
RENAME_LIMIT,
getSenderLocal,
makeTicketName,
canRename,
minutesFromMs,
checkTicketCreationRateLimit,
createTicketChannel,
checkTicketLimits,
hasBlacklistedRole,
updateTicketActivity,
checkAutoClose,
checkReminders,
checkAutoUnclaim
};