audit week 2 [ARCH-001]: split handlers/commands.js into submodules
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.
This commit is contained in:
1028
handlers/commands.js
1028
handlers/commands.js
File diff suppressed because it is too large
Load Diff
126
handlers/commands/close.js
Normal file
126
handlers/commands/close.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Force-close flow: /force-close, /cancel-close, /closetimer, plus the
|
||||
* countdown-elapses finalize step and transcript renderer that the
|
||||
* countdown's setTimeout calls back into.
|
||||
*
|
||||
* Note: the button-driven close path lives in handlers/buttons.js
|
||||
* (handleCloseButton / handleConfirmCloseRequest / runFinalClose).
|
||||
* This module covers the slash-command-driven path only.
|
||||
*/
|
||||
const { AttachmentBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
const { logTicketEvent } = require('../../services/debugLog');
|
||||
const { pendingCloses } = require('../pendingCloses');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleCloseTimer(interaction) {
|
||||
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
||||
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
||||
logTicketEvent('Close timer updated', [
|
||||
{ name: 'Duration', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleCancelClose(interaction) {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (!pending) {
|
||||
return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
logTicketEvent('Force-close cancelled', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Cancelled by', value: interaction.user.tag },
|
||||
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
||||
], interaction).catch(() => {});
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleForceClose(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
|
||||
|
||||
const channelRef = interaction.channel;
|
||||
const clientRef = interaction.client;
|
||||
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
}
|
||||
|
||||
/** Performs the actual force-close work after the countdown elapses. */
|
||||
async function finalizeForceClose(channelRef, clientRef) {
|
||||
pendingCloses.delete(channelRef.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
|
||||
try {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
|
||||
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
||||
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
||||
console.error('Transcript error (force-close):', tErr)
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
channelRef.delete('Ticket force-closed').catch(e =>
|
||||
console.error('Failed to delete channel:', e)
|
||||
);
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render and post a closing transcript for a ticket. */
|
||||
async function postTranscript(channelRef, clientRef, freshTicket) {
|
||||
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
|
||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
|
||||
messages
|
||||
.reverse()
|
||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||
.join('\n');
|
||||
|
||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${channelRef.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await clientRef.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||
.catch(() => null);
|
||||
if (!transcriptChan) return;
|
||||
|
||||
const fmt = (d) => new Date(d).toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const openedStr = fmt(freshTicket.createdAt);
|
||||
const closedStr = fmt(new Date());
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelRef.name)
|
||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
|
||||
}
|
||||
|
||||
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
|
||||
168
handlers/commands/contextMenu.js
Normal file
168
handlers/commands/contextMenu.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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 };
|
||||
214
handlers/commands/escalation.js
Normal file
214
handlers/commands/escalation.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Escalation flows.
|
||||
*
|
||||
* runEscalation / runDeescalation are exported for handlers/buttons.js
|
||||
* (the tier-pick buttons share this code path). handleEscalate /
|
||||
* handleDeescalate are the slash-command entry points.
|
||||
*/
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||
const { sendTicketNotificationEmail } = require('../../services/gmail');
|
||||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
|
||||
const { pinMessage } = require('../../services/pinMessage');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
|
||||
const { fetchLoggingChannel } = require('./helpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
|
||||
* validate ticket and currentTier < nextTier, and have already deferred.
|
||||
*/
|
||||
async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = nextTier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||
|
||||
// Clear claim on escalation
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = true;
|
||||
ticket.escalationTier = nextTier;
|
||||
ticket.claimedBy = null;
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
||||
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
if (!interaction.channel.isThread() && categoryId) {
|
||||
await enqueueMove(interaction.channel, categoryId);
|
||||
}
|
||||
|
||||
const pendingEmbed = new EmbedBuilder()
|
||||
.setDescription('Ticket will be escalated in a few seconds.')
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
await interaction.editReply({ embeds: [pendingEmbed] });
|
||||
|
||||
const creatorId = isDiscordTicket
|
||||
? (ticket.gmailThreadId.split('-').pop() || '').trim()
|
||||
: null;
|
||||
const creatorMention = creatorId ? `<@${creatorId}>` : '';
|
||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
|
||||
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
|
||||
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
|
||||
await enqueueSend(interaction.channel, {
|
||||
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
|
||||
allowedMentions: { parse: ['users', 'roles'] }
|
||||
});
|
||||
|
||||
const escalationBody = CONFIG.ESCALATION_MESSAGE
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
|
||||
const escalatedEmbed = new EmbedBuilder()
|
||||
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
|
||||
.setDescription(escalationBody)
|
||||
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
|
||||
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
|
||||
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
|
||||
const escalationRow = getTicketActionRow(updatedTicketForRow);
|
||||
const escalationMsg = await enqueueSend(interaction.channel, {
|
||||
content: null,
|
||||
embeds: [escalatedEmbed],
|
||||
components: [escalationRow]
|
||||
});
|
||||
|
||||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||||
try {
|
||||
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
|
||||
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
||||
} catch (emailErr) {
|
||||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
} catch (e) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
await enqueueSend(logChan,
|
||||
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
|
||||
async function runDeescalation(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const newTier = currentTier - 1;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = newTier > 0;
|
||||
ticket.escalationTier = newTier;
|
||||
ticket.claimedBy = null;
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const state = newTier === 0 ? 'unclaimed' : 'escalated';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
if (!interaction.channel.isThread()) {
|
||||
try {
|
||||
if (newTier === 0) {
|
||||
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
|
||||
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
|
||||
} else if (newTier === 1) {
|
||||
const t2Category = isDiscordTicket
|
||||
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
|
||||
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
||||
if (t2Category) await enqueueMove(interaction.channel, t2Category);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Move error (deescalate):', e);
|
||||
}
|
||||
}
|
||||
|
||||
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
|
||||
const deescalateEmbed = new EmbedBuilder()
|
||||
.setColor(0x00BFFF)
|
||||
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
|
||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
await enqueueSend(logChan,
|
||||
`${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEscalate(interaction) {
|
||||
const reason = null;
|
||||
const level = interaction.options.getString('level');
|
||||
const nextTier = level === '3' ? 2 : 1;
|
||||
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (nextTier <= currentTier) {
|
||||
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = nextTier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({
|
||||
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'escalate', () =>
|
||||
runEscalation(interaction, ticket, nextTier, reason)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeescalate(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier === 0) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'de-escalate',
|
||||
() => runDeescalation(interaction, ticket),
|
||||
{ flags: MessageFlags.Ephemeral }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate };
|
||||
33
handlers/commands/helpers.js
Normal file
33
handlers/commands/helpers.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cross-submodule helpers for handlers/commands/*.
|
||||
*
|
||||
* Lives at this level (not in index.js) so escalation.js, close.js, etc. can
|
||||
* import without creating circular dependencies with index.js.
|
||||
*/
|
||||
const { MessageFlags } = require('discord.js');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { isStaff } = require('../../utils');
|
||||
|
||||
/**
|
||||
* Reply ephemeral and return true if the interaction is in a guild and the
|
||||
* user is not staff (so the caller should bail).
|
||||
*/
|
||||
async function requireStaffRole(interaction) {
|
||||
if (!interaction.guild) return false;
|
||||
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
|
||||
if (isStaff(interaction.member)) return false;
|
||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
||||
await interaction.reply({
|
||||
content: `This command is only available to the support team (${roleMention}).`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fetch the configured logging channel, or null if unset/missing. */
|
||||
async function fetchLoggingChannel(client) {
|
||||
if (!CONFIG.LOGGING_CHANNEL_ID) return null;
|
||||
return client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
}
|
||||
|
||||
module.exports = { requireStaffRole, fetchLoggingChannel };
|
||||
299
handlers/commands/index.js
Normal file
299
handlers/commands/index.js
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Slash command, context menu, and autocomplete dispatcher.
|
||||
*
|
||||
* Submodules own command handlers by topic:
|
||||
* helpers.js — requireStaffRole, fetchLoggingChannel
|
||||
* escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
|
||||
* close.js — handleForceClose, handleCancelClose, handleCloseTimer (+ finalize/transcript)
|
||||
* response.js — /response subcommands + handleAutocomplete
|
||||
* panel.js — handlePanel, handleSignature
|
||||
* contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
|
||||
*
|
||||
* This file holds the dispatchers, the small "remainder" handlers
|
||||
* (channel-mod, settings toggles, /help, /notifydm), and the public
|
||||
* module.exports surface that handlers/buttons.js + broccolini-discord.js
|
||||
* import from `require('./commands')`.
|
||||
*/
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { setNotifyDm } = require('../../services/staffSettings');
|
||||
const { enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||
const { logTicketEvent } = require('../../services/debugLog');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
||||
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation');
|
||||
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
||||
const { handleResponse, handleAutocomplete } = require('./response');
|
||||
const { handlePanel, handleSignature } = require('./panel');
|
||||
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
// ============================================================
|
||||
// Remainder handlers — small enough not to deserve their own module.
|
||||
// ============================================================
|
||||
|
||||
async function handleNotifyDm(interaction) {
|
||||
try {
|
||||
const setting = interaction.options.getString('setting') === 'on';
|
||||
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
||||
await interaction.reply({
|
||||
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('notifydm error:', err);
|
||||
await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, {
|
||||
ViewChannel: true,
|
||||
SendMessages: true,
|
||||
ReadMessageHistory: true
|
||||
});
|
||||
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Add user error:', err);
|
||||
await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
||||
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Remove user error:', err);
|
||||
await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransfer(interaction) {
|
||||
const member = interaction.options.getUser('member');
|
||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
||||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||
|
||||
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
|
||||
return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
try {
|
||||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel } }
|
||||
);
|
||||
|
||||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||
await interaction.reply({
|
||||
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transfer error:', err);
|
||||
await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(interaction) {
|
||||
const category = interaction.options.getChannel('category');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueMove(interaction.channel, category.id);
|
||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan,
|
||||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Move error:', err);
|
||||
await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTopic(interaction) {
|
||||
const text = interaction.options.getString('text');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
try {
|
||||
await enqueueTopic(interaction.channel, text);
|
||||
await interaction.reply('Topic updated successfully.');
|
||||
} catch (err) {
|
||||
console.error('Topic error:', err);
|
||||
await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStaffThread(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'toggle') {
|
||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'name') {
|
||||
const name = interaction.options.getString('thread_name').slice(0, 100);
|
||||
CONFIG.STAFF_THREAD_NAME = name;
|
||||
return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'autorole') {
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
||||
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePinMessages(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
if (sub === 'initial') {
|
||||
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'escalation') {
|
||||
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'suppress') {
|
||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGmailPoll(interaction) {
|
||||
const requested = parseInt(interaction.options.getString('interval'), 10);
|
||||
// Defense-in-depth: the slash command's addChoices already floors at 30s, but
|
||||
// clamp the resolved ms here too so any future caller (or skewed input) can't
|
||||
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
||||
const ms = Math.max(30000, requested * 1000);
|
||||
const seconds = ms / 1000;
|
||||
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||
const { setGmailPollInterval } = require('../../broccolini-discord');
|
||||
setGmailPollInterval(ms);
|
||||
logTicketEvent('Gmail poll interval updated', [
|
||||
{ name: 'Interval', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleHelp(interaction) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket System - Commands')
|
||||
.setColor(CONFIG.EMBED_COLOR_OPEN)
|
||||
.addFields([
|
||||
{
|
||||
name: 'User Management',
|
||||
value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket'
|
||||
},
|
||||
{
|
||||
name: 'Ticket Management',
|
||||
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description'
|
||||
},
|
||||
{
|
||||
name: 'Saved Responses',
|
||||
value: '`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
||||
},
|
||||
{
|
||||
name: 'Variables (for responses)',
|
||||
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
|
||||
},
|
||||
{
|
||||
name: 'Panel System',
|
||||
value: '`/panel #channel` - Create a ticket panel for Discord-side tickets'
|
||||
},
|
||||
{
|
||||
name: 'Escalation',
|
||||
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
|
||||
}
|
||||
])
|
||||
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dispatch tables
|
||||
// ============================================================
|
||||
|
||||
const COMMAND_HANDLERS = {
|
||||
escalate: handleEscalate,
|
||||
deescalate: handleDeescalate,
|
||||
notifydm: handleNotifyDm,
|
||||
add: handleAdd,
|
||||
remove: handleRemove,
|
||||
transfer: handleTransfer,
|
||||
move: handleMove,
|
||||
staffthread: handleStaffThread,
|
||||
pinmessages: handlePinMessages,
|
||||
gmailpoll: handleGmailPoll,
|
||||
closetimer: handleCloseTimer,
|
||||
'cancel-close': handleCancelClose,
|
||||
'force-close': handleForceClose,
|
||||
topic: handleTopic,
|
||||
response: handleResponse,
|
||||
signature: handleSignature,
|
||||
help: handleHelp,
|
||||
panel: handlePanel
|
||||
};
|
||||
|
||||
const CONTEXT_MENU_HANDLERS = {
|
||||
'Create Ticket From Message': handleCreateTicketFromMessage,
|
||||
'View User Tickets': handleViewUserTickets
|
||||
};
|
||||
|
||||
/**
|
||||
* Slash-command dispatcher. /help is open to everyone; everything else
|
||||
* requires the staff role.
|
||||
*/
|
||||
async function handleCommand(interaction) {
|
||||
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
||||
const handler = COMMAND_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
/** Context-menu dispatcher. All entries are staff-only. */
|
||||
async function handleContextMenu(interaction) {
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
const handler = CONTEXT_MENU_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleCommand,
|
||||
handleContextMenu,
|
||||
handleAutocomplete,
|
||||
runEscalation,
|
||||
runDeescalation
|
||||
};
|
||||
133
handlers/commands/panel.js
Normal file
133
handlers/commands/panel.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* /panel — create a ticket-creation panel embed in a chosen channel.
|
||||
* Also hosts /signature (modal for staff personal email signature) since
|
||||
* both are user-facing UX-flow commands without their own dedicated module.
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
MessageFlags,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
|
||||
async function handlePanel(interaction) {
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null
|
||||
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
|
||||
const description = interaction.options.getString('description') ||
|
||||
'Need help? Click below to create a ticket. 🎟';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(0x2ecc71)
|
||||
.setThumbnail(CONFIG.LOGO_URL || null)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
const row = buildPanelButtonRow(panelType);
|
||||
|
||||
try {
|
||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||
await interaction.reply({ content: `Panel created in ${channel}!`, flags: MessageFlags.Ephemeral });
|
||||
} catch (err) {
|
||||
console.error('Panel creation error:', err);
|
||||
await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
function buildPanelButtonRow(panelType) {
|
||||
if (panelType === 'both') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket (channel)')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
}
|
||||
if (panelType === 'thread') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵')
|
||||
);
|
||||
}
|
||||
if (panelType === 'category') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
}
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('✅')
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSignature(interaction) {
|
||||
try {
|
||||
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`signature_modal_${interaction.user.id}`)
|
||||
.setTitle('Staff Signature Settings');
|
||||
|
||||
const valedictionInput = new TextInputBuilder()
|
||||
.setCustomId('valediction')
|
||||
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.valediction || '');
|
||||
|
||||
const displayNameInput = new TextInputBuilder()
|
||||
.setCustomId('display_name')
|
||||
.setLabel('Display Name (e.g. "Support Team")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.displayName || '');
|
||||
|
||||
const taglineInput = new TextInputBuilder()
|
||||
.setCustomId('tagline')
|
||||
.setLabel('Tagline (e.g. "Technical Support Specialist")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.tagline || '');
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder().addComponents(valedictionInput),
|
||||
new ActionRowBuilder().addComponents(displayNameInput),
|
||||
new ActionRowBuilder().addComponents(taglineInput)
|
||||
);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
} catch (err) {
|
||||
console.error('Signature command error:', err);
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({ content: 'Failed to open signature settings.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handlePanel, handleSignature };
|
||||
165
handlers/commands/response.js
Normal file
165
handlers/commands/response.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* /response (saved tags) and its autocomplete.
|
||||
*
|
||||
* /response is itself a router over its subcommands:
|
||||
* send / create / edit / delete / list
|
||||
* The autocomplete handler also lives here since the only autocompleting
|
||||
* slash command is /response.
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
MessageFlags
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { replaceVariables } = require('../../utils');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
|
||||
const Tag = mongoose.model('Tag');
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleResponse(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const handler = RESPONSE_SUBCOMMANDS[subcommand];
|
||||
if (!handler) return;
|
||||
try {
|
||||
await handler(interaction);
|
||||
} catch (err) {
|
||||
logError('response-command', err, interaction).catch(() => {});
|
||||
const errorMsg = '❌ An error occurred while processing the response command.';
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply(errorMsg);
|
||||
} else {
|
||||
await interaction.reply({ content: errorMsg, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseSend(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const tag = await Tag.findOne({ name }).lean();
|
||||
if (!tag) {
|
||||
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
const context = {
|
||||
ticket: ticket || {},
|
||||
staff: {
|
||||
username: interaction.user.username,
|
||||
displayName: interaction.member?.displayName,
|
||||
mention: interaction.user.toString()
|
||||
},
|
||||
guild: interaction.guild
|
||||
};
|
||||
|
||||
const content = replaceVariables(tag.content, context);
|
||||
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
|
||||
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
|
||||
// Disable mention parsing so a `@everyone` in a tag body never pings.
|
||||
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
||||
}
|
||||
|
||||
async function handleResponseCreate(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
try {
|
||||
await Tag.create({ name, content, createdBy: interaction.user.id });
|
||||
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, flags: MessageFlags.Ephemeral });
|
||||
} catch (err) {
|
||||
if (err.code === 11000 || err.message?.includes('duplicate')) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
logError('tag-create', err, interaction).catch(() => {});
|
||||
await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseEdit(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
try {
|
||||
const result = await Tag.updateOne({ name }, { $set: { content } });
|
||||
if (result.matchedCount === 0) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
} catch (err) {
|
||||
logError('tag-edit', err, interaction).catch(() => {});
|
||||
await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseDelete(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
// Use :: delimiter so tag names with underscores parse correctly (Discord customId max 100 chars).
|
||||
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
|
||||
const confirmRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(customId)
|
||||
.setLabel('Yes, Delete Tag')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('cancel_delete_tag')
|
||||
.setLabel('Cancel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return interaction.reply({
|
||||
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
|
||||
components: [confirmRow],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
async function handleResponseList(interaction) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
||||
if (!tags || tags.length === 0) {
|
||||
return interaction.editReply({ content: '📋 No tags available.' });
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('📋 Available Saved Responses')
|
||||
.setDescription(
|
||||
tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n')
|
||||
)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setFooter({ text: `Total: ${tags.length} tags` });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
const RESPONSE_SUBCOMMANDS = {
|
||||
send: handleResponseSend,
|
||||
create: handleResponseCreate,
|
||||
edit: handleResponseEdit,
|
||||
delete: handleResponseDelete,
|
||||
list: handleResponseList
|
||||
};
|
||||
|
||||
/** Autocomplete handler. Currently only /response uses it. */
|
||||
async function handleAutocomplete(interaction) {
|
||||
if (interaction.commandName !== 'response') return;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
|
||||
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
|
||||
const filtered = tags
|
||||
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
|
||||
.slice(0, 25)
|
||||
.map(t => ({ name: t.name, value: t.name }));
|
||||
|
||||
await interaction.respond(filtered);
|
||||
}
|
||||
|
||||
module.exports = { handleResponse, handleAutocomplete };
|
||||
Reference in New Issue
Block a user