The 1028-line handlers/commands.js bundled escalation logic + force-close
flow + /response tag CRUD + /panel + /signature + context-menu handlers +
several config-toggle slash commands. After the dispatch-table refactor it
was still a god module. Split into handlers/commands/ with one file per
topic; require('./commands') resolves to handlers/commands/index.js
(handlers/commands.js is removed).
Layout:
helpers.js — requireStaffRole, fetchLoggingChannel
(cross-submodule, kept here to avoid cycles with index.js)
escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
(run* are still exported via index.js for handlers/buttons.js)
close.js — handleForceClose, handleCancelClose, handleCloseTimer
+ finalizeForceClose / postTranscript (timer callback)
response.js — handleResponse + send/create/edit/delete/list subcommands
+ handleAutocomplete (only /response autocompletes)
panel.js — handlePanel, buildPanelButtonRow, handleSignature
contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
index.js — dispatch tables, handleCommand/handleContextMenu, plus the
short-and-not-thematic handlers (notifydm, add, remove,
transfer, move, topic, staffthread, pinmessages, gmailpoll,
help) and the public re-exports.
No behavior change — every imported name, every Discord call, every DB
write, every embed, every reply payload preserved verbatim. Public surface
of require('./commands') is still { handleCommand, handleContextMenu,
handleAutocomplete, runEscalation, runDeescalation }.
Largest single module is now index.js at 299 lines; others are 33–214.
169 lines
6.1 KiB
JavaScript
169 lines
6.1 KiB
JavaScript
/**
|
|
* Right-click "Apps" menu commands:
|
|
* - "Create Ticket From Message" — turn a Discord message into a ticket.
|
|
* - "View User Tickets" — show last 10 tickets for the targeted user.
|
|
*/
|
|
const {
|
|
ChannelType,
|
|
EmbedBuilder,
|
|
MessageFlags,
|
|
PermissionFlagsBits
|
|
} = require('discord.js');
|
|
const { mongoose } = require('../../db-connection');
|
|
const { CONFIG } = require('../../config');
|
|
const { getPriorityEmoji } = require('../../utils');
|
|
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets');
|
|
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
|
const { enqueueSend } = require('../../services/channelQueue');
|
|
const { logError } = require('../../services/debugLog');
|
|
|
|
const Ticket = mongoose.model('Ticket');
|
|
|
|
async function handleCreateTicketFromMessage(interaction) {
|
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
|
|
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
|
if (!rateLimit.allowed) {
|
|
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
|
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
|
|
}
|
|
|
|
try {
|
|
const message = interaction.targetMessage;
|
|
const subject = `Message from ${message.author.tag}`;
|
|
const description = message.content || 'No content';
|
|
|
|
const guild = interaction.guild;
|
|
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
|
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
|
|
|
let parentCategoryIdForTicket;
|
|
try {
|
|
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
|
guild,
|
|
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
|
CONFIG.TICKET_CATEGORY_NAME
|
|
);
|
|
} catch (err) {
|
|
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
|
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
|
}
|
|
|
|
let channel;
|
|
try {
|
|
channel = await guild.channels.create({
|
|
name: `ticket-${ticketNumber}`,
|
|
type: ChannelType.GuildText,
|
|
parent: parentCategoryIdForTicket,
|
|
permissionOverwrites: [
|
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
|
{
|
|
id: message.author.id,
|
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
},
|
|
{
|
|
id: CONFIG.ROLE_ID_TO_PING,
|
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
}
|
|
]
|
|
});
|
|
} catch (err) {
|
|
console.error('guild.channels.create (context menu ticket):', err);
|
|
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
|
|
}
|
|
|
|
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
|
|
const now = new Date();
|
|
await Ticket.create({
|
|
gmailThreadId,
|
|
discordThreadId: channel.id,
|
|
senderEmail: message.author.tag,
|
|
subject,
|
|
createdAt: now,
|
|
status: 'open',
|
|
ticketNumber,
|
|
priority: 'normal',
|
|
lastActivity: now,
|
|
creatorId: message.author.id,
|
|
parentCategoryId: parentCategoryIdForTicket
|
|
});
|
|
|
|
const welcomeEmbed = new EmbedBuilder()
|
|
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
|
.setColor(CONFIG.EMBED_COLOR_INFO);
|
|
|
|
const infoEmbed = new EmbedBuilder()
|
|
.setColor(CONFIG.EMBED_COLOR_INFO)
|
|
.addFields(
|
|
{ name: 'From message', value: `[Jump to message](${message.url})` },
|
|
{ name: 'Creator', value: message.author.toString(), inline: true },
|
|
{ name: 'Created by Staff', value: interaction.user.toString(), inline: true },
|
|
{ name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false }
|
|
);
|
|
|
|
const row = getTicketActionRow({ escalationTier: 0 });
|
|
|
|
try {
|
|
const welcomeMsg = await enqueueSend(channel, {
|
|
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
|
embeds: [welcomeEmbed, infoEmbed],
|
|
components: [row]
|
|
});
|
|
|
|
await Ticket.updateOne(
|
|
{ discordThreadId: channel.id },
|
|
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
|
);
|
|
} catch (err) {
|
|
console.error('welcomeMessageId-save', err);
|
|
}
|
|
|
|
await interaction.editReply(`✅ Ticket created: ${channel}`);
|
|
} catch (err) {
|
|
logError('create-ticket-from-message', err, interaction).catch(() => {});
|
|
await interaction.editReply('❌ Failed to create ticket from message.');
|
|
}
|
|
}
|
|
|
|
async function handleViewUserTickets(interaction) {
|
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
|
|
try {
|
|
const targetUser = interaction.targetUser;
|
|
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
|
|
.sort({ createdAt: -1 })
|
|
.limit(10)
|
|
.lean();
|
|
|
|
if (!tickets || tickets.length === 0) {
|
|
return interaction.editReply(`📋 No tickets found for ${targetUser.tag}`);
|
|
}
|
|
|
|
const embed = new EmbedBuilder()
|
|
.setTitle(`📋 Tickets for ${targetUser.tag}`)
|
|
.setDescription(`Found ${tickets.length} ticket(s)`)
|
|
.setColor(CONFIG.EMBED_COLOR_INFO);
|
|
|
|
for (const ticket of tickets.slice(0, 5)) {
|
|
const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal');
|
|
const statusEmoji = ticket.status === 'open' ? '🟢' : '🔴';
|
|
embed.addFields({
|
|
name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`,
|
|
value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
|
|
inline: false
|
|
});
|
|
}
|
|
|
|
if (tickets.length > 5) {
|
|
embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` });
|
|
}
|
|
|
|
await interaction.editReply({ embeds: [embed] });
|
|
} catch (err) {
|
|
logError('view-user-tickets', err, interaction).catch(() => {});
|
|
await interaction.editReply('❌ Failed to fetch user tickets.');
|
|
}
|
|
}
|
|
|
|
module.exports = { handleCreateTicketFromMessage, handleViewUserTickets };
|