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:
2026-05-08 20:29:44 +00:00
parent d0cf8fd915
commit adcd9dd9c9
8 changed files with 1138 additions and 1028 deletions

File diff suppressed because it is too large Load Diff

126
handlers/commands/close.js Normal file
View 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 };

View 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 };

View 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} deescalated 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 };

View 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
View 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
View 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 };

View 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 };