Files
broccolini-bot/services/tickets.js

425 lines
13 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, 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 <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.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
};