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.
166 lines
5.9 KiB
JavaScript
166 lines
5.9 KiB
JavaScript
/**
|
|
* /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 };
|