180
handlers/accountinfo.js
Normal file
180
handlers/accountinfo.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Account info command: look up website User by email or Discord ID,
|
||||
* show ephemeral embed with option to send transcript to account info channel.
|
||||
*/
|
||||
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { mongoose } = require('../db-connection');
|
||||
|
||||
const User = mongoose.model('User');
|
||||
|
||||
const BUTTON_PREFIX = 'send_account_info_';
|
||||
const MAX_CUSTOM_ID_LENGTH = 100;
|
||||
|
||||
function buildAccountInfoEmbed(user, requestedBy = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Account Info')
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setTimestamp();
|
||||
|
||||
embed.addFields({
|
||||
name: 'Email',
|
||||
value: user.email || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: 'Discord ID',
|
||||
value: user.discordID ? `<@${user.discordID}>` : '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: 'Customer ID',
|
||||
value: user.customerId || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
|
||||
const servers = user.servers || [];
|
||||
const serverOrder = user.serverOrder || [];
|
||||
const ordered = serverOrder.length
|
||||
? serverOrder.map(id => servers.find(s => s._id && s._id.toString() === id) || servers[serverOrder.indexOf(id)]).filter(Boolean)
|
||||
: servers;
|
||||
|
||||
if (ordered.length === 0) {
|
||||
embed.addFields({
|
||||
name: 'Servers',
|
||||
value: '*No servers*',
|
||||
inline: false
|
||||
});
|
||||
} else {
|
||||
ordered.forEach((server, i) => {
|
||||
const n = i + 1;
|
||||
embed.addFields({
|
||||
name: `Server ${n} – Game`,
|
||||
value: server.game || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: `Server ${n} – IP`,
|
||||
value: server.ip || '*not set*',
|
||||
inline: true
|
||||
});
|
||||
embed.addFields({
|
||||
name: `Server ${n} – Port`,
|
||||
value: server.serverPort != null ? String(server.serverPort) : '*not set*',
|
||||
inline: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (requestedBy) {
|
||||
embed.setFooter({ text: `Requested by ${requestedBy}` });
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
async function handleAccountInfoCommand(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
let user = null;
|
||||
|
||||
if (subcommand === 'email') {
|
||||
const email = (interaction.options.getString('email') || '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
return interaction.reply({ content: 'Please provide an email.', ephemeral: true });
|
||||
}
|
||||
user = await User.findOne({ email }).lean();
|
||||
} else if (subcommand === 'discord') {
|
||||
const target = interaction.options.getUser('user');
|
||||
if (!target) {
|
||||
return interaction.reply({ content: 'Please provide a Discord user.', ephemeral: true });
|
||||
}
|
||||
user = await User.findOne({ discordID: target.id }).lean();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return interaction.reply({
|
||||
content: subcommand === 'email' ? 'No account found for that email.' : 'No account found for that Discord user/ID.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const embed = buildAccountInfoEmbed(user, interaction.user.tag);
|
||||
const components = [];
|
||||
|
||||
if (CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
|
||||
const safeEmail = (user.email || '').slice(0, 50);
|
||||
const safeDiscordId = (user.discordID || '').slice(0, 50);
|
||||
const customId = `${BUTTON_PREFIX}discord:${safeDiscordId}`;
|
||||
if (customId.length <= MAX_CUSTOM_ID_LENGTH) {
|
||||
components.push(
|
||||
new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(customId)
|
||||
.setLabel('Send to account info channel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
embeds: [embed],
|
||||
components,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSendAccountInfoToChannel(interaction) {
|
||||
if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false;
|
||||
|
||||
const payload = interaction.customId.slice(BUTTON_PREFIX.length);
|
||||
const [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];
|
||||
|
||||
let user = null;
|
||||
if (type === 'email') {
|
||||
const email = Buffer.from(value, 'base64').toString('utf8').toLowerCase();
|
||||
user = await User.findOne({ email }).lean();
|
||||
} else if (type === 'discord' && value) {
|
||||
user = await User.findOne({ discordID: value }).lean();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
await interaction.update({ content: 'Account no longer found.', components: [] }).catch(() =>
|
||||
interaction.followUp({ content: 'Account no longer found.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
|
||||
await interaction.update({ content: 'Account info channel is not configured.', components: [] }).catch(() =>
|
||||
interaction.followUp({ content: 'Account info channel is not configured.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const channel = await interaction.client.channels.fetch(CONFIG.ACCOUNT_INFO_CHANNEL_ID).catch(() => null);
|
||||
if (!channel) {
|
||||
await interaction.update({ content: 'Could not find account info channel.', components: [] }).catch(() =>
|
||||
interaction.followUp({ content: 'Could not find account info channel.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const embed = buildAccountInfoEmbed(user, `${interaction.user.tag} (from ticket)`);
|
||||
await channel.send({ embeds: [embed] });
|
||||
|
||||
await interaction.update({
|
||||
content: 'Account info sent to account transcript channel.',
|
||||
components: []
|
||||
}).catch(() =>
|
||||
interaction.followUp({ content: 'Account info sent to account transcript channel.', ephemeral: true })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAccountInfoEmbed,
|
||||
handleAccountInfoCommand,
|
||||
handleSendAccountInfoToChannel,
|
||||
BUTTON_PREFIX
|
||||
};
|
||||
89
handlers/analytics.js
Normal file
89
handlers/analytics.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* In-memory analytics and error tracking.
|
||||
*/
|
||||
const { logError } = require('../services/debugLog');
|
||||
|
||||
const analytics = {
|
||||
commands: {},
|
||||
buttons: {},
|
||||
modals: {},
|
||||
contextMenus: {},
|
||||
errors: [],
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
function trackInteraction(type, name, userId = 'unknown') {
|
||||
analytics[type][name] = (analytics[type][name] || 0) + 1;
|
||||
console.log(`📊 Analytics: ${type}/${name} by ${userId}`);
|
||||
}
|
||||
|
||||
function getTotalInteractions() {
|
||||
let total = 0;
|
||||
for (const type of ['commands', 'buttons', 'modals', 'contextMenus']) {
|
||||
for (const key in analytics[type]) {
|
||||
total += analytics[type][key];
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function trackError(context, error, interaction = null) {
|
||||
const errorEntry = {
|
||||
context,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: Date.now(),
|
||||
user: interaction?.user?.tag || 'system',
|
||||
command: interaction?.commandName || 'N/A'
|
||||
};
|
||||
|
||||
analytics.errors.push(errorEntry);
|
||||
|
||||
if (analytics.errors.length > 100) {
|
||||
analytics.errors.shift();
|
||||
}
|
||||
|
||||
console.error(`❌ Error tracked: ${context}:`, error.message);
|
||||
|
||||
logError(context, error, interaction);
|
||||
|
||||
const recentErrors = analytics.errors.filter(e =>
|
||||
Date.now() - e.timestamp < 3600000
|
||||
);
|
||||
|
||||
const errorRate = recentErrors.length / Math.max(1, getTotalInteractions());
|
||||
|
||||
if (errorRate > 0.05) {
|
||||
console.warn(`⚠️ HIGH ERROR RATE: ${(errorRate * 100).toFixed(2)}% in last hour`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAnalyticsSummary() {
|
||||
const uptime = Math.floor((Date.now() - analytics.startTime) / 1000);
|
||||
const totalInteractions = getTotalInteractions();
|
||||
const recentErrors = analytics.errors.filter(e =>
|
||||
Date.now() - e.timestamp < 3600000
|
||||
);
|
||||
|
||||
return {
|
||||
uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
|
||||
totalInteractions,
|
||||
commandsUsed: Object.keys(analytics.commands).length,
|
||||
mostUsedCommand: Object.entries(analytics.commands)
|
||||
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'None',
|
||||
errorsLastHour: recentErrors.length,
|
||||
errorRate: `${((recentErrors.length / Math.max(1, totalInteractions)) * 100).toFixed(2)}%`,
|
||||
topCommands: Object.entries(analytics.commands)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([cmd, count]) => `${cmd}: ${count}`)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analytics,
|
||||
trackInteraction,
|
||||
trackError,
|
||||
getTotalInteractions,
|
||||
getAnalyticsSummary
|
||||
};
|
||||
689
handlers/buttons.js
Normal file
689
handlers/buttons.js
Normal file
@@ -0,0 +1,689 @@
|
||||
/**
|
||||
* Button interaction handlers – claim, close, priority, tag delete,
|
||||
* open-ticket panel button, and ticket_modal submission.
|
||||
*/
|
||||
const {
|
||||
ChannelType,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
AttachmentBuilder,
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG, ZAMMAD } = require('../config');
|
||||
const { canRename, makeTicketName, minutesFromMs, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||
const { createZammadTicket, closeZammadTicket, ensureZammadUserForDiscordUser, updateZammadUser } = require('../services/zammad');
|
||||
const { saveZammadId } = require('../services/tickets');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { setEmailRouting } = require('../services/guildSettings');
|
||||
const { runEscalation, runDeescalation } = require('./commands');
|
||||
const { trackInteraction, trackError } = require('./analytics');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
const Tag = mongoose.model('Tag');
|
||||
const User = mongoose.model('User');
|
||||
|
||||
/**
|
||||
* Main button/modal handler – called from interactionCreate.
|
||||
*/
|
||||
async function handleButton(interaction) {
|
||||
// --- "Open Ticket" panel buttons → show modal ---
|
||||
if (interaction.customId === 'open_ticket' || interaction.customId === 'open_ticket_thread' || interaction.customId === 'open_ticket_channel') {
|
||||
const modalCustomId = interaction.customId === 'open_ticket'
|
||||
? 'ticket_modal'
|
||||
: interaction.customId === 'open_ticket_thread'
|
||||
? 'ticket_modal_thread'
|
||||
: 'ticket_modal_channel';
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(modalCustomId)
|
||||
.setTitle('Please Enter Your Information');
|
||||
|
||||
const emailInput = new TextInputBuilder()
|
||||
.setCustomId('ticket_email')
|
||||
.setLabel('Account Email:')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('Example: broccoli@indifferentbroccoli.com')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100);
|
||||
|
||||
const gameInput = new TextInputBuilder()
|
||||
.setCustomId('ticket_game')
|
||||
.setLabel('What game do you need help with?')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('Example: Project Zomboid, Minecraft')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100);
|
||||
|
||||
const descriptionInput = new TextInputBuilder()
|
||||
.setCustomId('ticket_description')
|
||||
.setLabel('What do you need help with?')
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setPlaceholder("Example: I can't connect to my server.")
|
||||
.setRequired(true)
|
||||
.setMaxLength(1000);
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder().addComponents(emailInput),
|
||||
new ActionRowBuilder().addComponents(gameInput),
|
||||
new ActionRowBuilder().addComponents(descriptionInput)
|
||||
);
|
||||
|
||||
return await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
// --- Email routing (no ticket required) ---
|
||||
if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') {
|
||||
const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category';
|
||||
try {
|
||||
await setEmailRouting(interaction.guild.id, value);
|
||||
const label = value === 'thread' ? '**threads**' : '**channels in a category**';
|
||||
await interaction.reply({
|
||||
content: `Done. New email tickets will now be created as ${label}.`,
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (err) {
|
||||
trackError('email-routing-button', err, interaction);
|
||||
await interaction.reply({
|
||||
content: 'Failed to update email routing.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ticket-scoped buttons (need ticket lookup) ---
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
return interaction.reply({
|
||||
content: 'This channel is not linked to a ticket, or the ticket could not be found.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
// --- CLAIM / UNCLAIM ---
|
||||
if (interaction.customId === 'claim_ticket') {
|
||||
return handleClaim(interaction, ticket);
|
||||
}
|
||||
|
||||
// --- CLOSE ---
|
||||
if (interaction.customId === 'close_ticket') {
|
||||
const confirmRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('confirm_close')
|
||||
.setLabel('Confirm Close')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('cancel_close')
|
||||
.setLabel('Cancel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return interaction.reply({
|
||||
content: 'Are you sure you want to close this ticket?',
|
||||
components: [confirmRow]
|
||||
});
|
||||
}
|
||||
|
||||
if (interaction.customId === 'confirm_close') {
|
||||
return handleConfirmClose(interaction, ticket);
|
||||
}
|
||||
|
||||
if (interaction.customId === 'cancel_close') {
|
||||
return interaction.update({ content: 'Close cancelled.', components: [] });
|
||||
}
|
||||
|
||||
// --- ESCALATE (prompt for tier 2 or 3) ---
|
||||
if (interaction.customId === 'escalate_ticket') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
||||
}
|
||||
const choiceRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('escalate_to_tier2')
|
||||
.setLabel('To Tier 2')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('escalate_to_tier3')
|
||||
.setLabel('To Tier 3')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
return interaction.reply({
|
||||
content: 'Escalate to which tier?',
|
||||
components: [choiceRow],
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (interaction.customId === 'escalate_to_tier2') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 1) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 2.', ephemeral: true });
|
||||
}
|
||||
const categoryId = ticket.gmailThreadId.startsWith('discord-')
|
||||
? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID)
|
||||
: (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID);
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await runEscalation(interaction, ticket, 1, 'Escalated via button (Tier 2)');
|
||||
} catch (err) {
|
||||
trackError('escalate-button-tier2', err, interaction);
|
||||
await interaction.reply({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.customId === 'escalate_to_tier3') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3.', ephemeral: true });
|
||||
}
|
||||
const categoryId = ticket.gmailThreadId.startsWith('discord-')
|
||||
? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID
|
||||
: CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await runEscalation(interaction, ticket, 2, 'Escalated via button (Tier 3)');
|
||||
} catch (err) {
|
||||
trackError('escalate-button-tier3', err, interaction);
|
||||
await interaction.reply({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- DEESCALATE ---
|
||||
if (interaction.customId === 'deescalate_ticket') {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier === 0) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await runDeescalation(interaction, ticket);
|
||||
} catch (err) {
|
||||
trackError('deescalate-button', err, interaction);
|
||||
await interaction.reply({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- TAG DELETE CONFIRM ---
|
||||
if (interaction.customId.startsWith('confirm_delete_tag_')) {
|
||||
trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag);
|
||||
const tagName = interaction.customId.replace('confirm_delete_tag_', '');
|
||||
|
||||
try {
|
||||
const result = await Tag.deleteOne({ name: tagName });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
await interaction.update({
|
||||
content: `❌ Tag "${tagName}" not found.`,
|
||||
components: []
|
||||
});
|
||||
} else {
|
||||
await interaction.update({
|
||||
content: `✅ Tag "${tagName}" deleted successfully.`,
|
||||
components: []
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
trackError('tag-delete-confirm', err, interaction);
|
||||
await interaction.update({
|
||||
content: '❌ Failed to delete tag.',
|
||||
components: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.customId === 'cancel_delete_tag') {
|
||||
return interaction.update({ content: 'Tag deletion cancelled.', components: [] });
|
||||
}
|
||||
|
||||
// Priority is set via /priority slash command only; no priority buttons in tickets.
|
||||
}
|
||||
|
||||
// --- CLAIM LOGIC ---
|
||||
async function handleClaim(interaction, ticket) {
|
||||
const freshTicket = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId }).lean();
|
||||
if (!freshTicket) {
|
||||
return interaction.reply({ content: 'Ticket data missing.', ephemeral: true });
|
||||
}
|
||||
|
||||
const isClaimed = !!freshTicket.claimedBy;
|
||||
const claimerLabel =
|
||||
interaction.member?.displayName || interaction.user.username;
|
||||
const guild = interaction.guild;
|
||||
const isClaimedByMe = freshTicket.claimedBy === claimerLabel;
|
||||
|
||||
const [row0] = interaction.message.components;
|
||||
if (!row0) {
|
||||
return interaction.reply({ content: 'No components to update.', ephemeral: true });
|
||||
}
|
||||
|
||||
const row = ActionRowBuilder.from(row0);
|
||||
const [btnClose, btnClaim] = row.components;
|
||||
|
||||
if (!btnClose || !btnClaim) {
|
||||
return interaction.reply({ content: 'Buttons missing.', ephemeral: true });
|
||||
}
|
||||
|
||||
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
|
||||
return interaction.reply({
|
||||
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!isClaimed || (isClaimed && !isClaimedByMe && CONFIG.ALLOW_CLAIM_OVERWRITE)) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel } }
|
||||
);
|
||||
freshTicket.claimedBy = claimerLabel;
|
||||
|
||||
const renameInfo = await canRename(freshTicket);
|
||||
if (renameInfo.ok) {
|
||||
const newName = makeTicketName(
|
||||
{ escalated: !!freshTicket.escalated, claimed: true },
|
||||
freshTicket,
|
||||
guild
|
||||
);
|
||||
try {
|
||||
await interaction.channel.setName(newName);
|
||||
} catch (e) {
|
||||
console.error('Rename error (claim):', e);
|
||||
}
|
||||
} else {
|
||||
const unlockAtMs = Date.now() + renameInfo.waitMs;
|
||||
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
|
||||
await interaction.channel.send(
|
||||
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
|
||||
);
|
||||
}
|
||||
|
||||
const baseLabel = `Unclaim (${claimerLabel})`;
|
||||
const label = renameInfo.ok
|
||||
? baseLabel
|
||||
: `${baseLabel} – rename in ${minutesFromMs(renameInfo.waitMs)}m`;
|
||||
|
||||
btnClose
|
||||
.setCustomId('close_ticket')
|
||||
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
|
||||
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(false);
|
||||
|
||||
btnClaim
|
||||
.setCustomId('claim_ticket')
|
||||
.setEmoji(CONFIG.BUTTON_EMOJI_UNCLAIM)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(false)
|
||||
.setLabel(label);
|
||||
|
||||
await interaction.update({ components: [row] });
|
||||
const claimEmbed = new EmbedBuilder()
|
||||
.setDescription(`Ticket claimed by ${interaction.user.toString()}`)
|
||||
.setColor(0x2ecc71);
|
||||
await interaction.followUp({ embeds: [claimEmbed] });
|
||||
} else {
|
||||
// Unclaim
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { claimedBy: null } }
|
||||
);
|
||||
freshTicket.claimedBy = null;
|
||||
|
||||
const renameInfo = await canRename(freshTicket);
|
||||
if (renameInfo.ok) {
|
||||
const newName = makeTicketName(
|
||||
{ escalated: !!freshTicket.escalated, claimed: false },
|
||||
freshTicket,
|
||||
guild
|
||||
);
|
||||
try {
|
||||
await interaction.channel.setName(newName);
|
||||
} catch (e) {
|
||||
console.error('Rename error (unclaim):', e);
|
||||
}
|
||||
} else {
|
||||
const unlockAtMs = Date.now() + renameInfo.waitMs;
|
||||
const unlockAtUnix = Math.floor(unlockAtMs / 1000);
|
||||
await interaction.channel.send(
|
||||
`Channel renamed too quickly. Try again <t:${unlockAtUnix}:R>.`
|
||||
);
|
||||
}
|
||||
|
||||
btnClose
|
||||
.setCustomId('close_ticket')
|
||||
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
|
||||
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(false);
|
||||
|
||||
btnClaim
|
||||
.setCustomId('claim_ticket')
|
||||
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(false)
|
||||
.setLabel(CONFIG.BUTTON_LABEL_CLAIM);
|
||||
|
||||
await interaction.update({ components: [row] });
|
||||
const unclaimEmbed = new EmbedBuilder()
|
||||
.setDescription(`Ticket unclaimed by ${interaction.user.toString()}`)
|
||||
.setColor(0xf1c40f);
|
||||
await interaction.followUp({ embeds: [unclaimEmbed] });
|
||||
}
|
||||
}
|
||||
|
||||
// --- CONFIRM CLOSE ---
|
||||
async function handleConfirmClose(interaction, ticket) {
|
||||
const closedAt = new Date();
|
||||
try {
|
||||
await interaction.update({ content: 'Archiving and closing...', components: [] });
|
||||
} catch {
|
||||
// Already acknowledged – fall back to editReply
|
||||
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
|
||||
}
|
||||
try {
|
||||
const messages = await interaction.channel.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.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-${interaction.channel.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await interaction.client.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
||||
.catch(() => null);
|
||||
let transcriptMsg = null;
|
||||
|
||||
if (transcriptChan) {
|
||||
const opened = new Date(ticket.createdAt);
|
||||
const openedStr = opened.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 closedStr = closedAt.toLocaleString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
transcriptMsg = await transcriptChan.send({
|
||||
content:
|
||||
`Transcript: \`${ticket.senderEmail}\`\n` +
|
||||
`Date Opened: ${openedStr}\n` +
|
||||
`Date Closed: ${closedStr}`,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
|
||||
// DM the transcript to the ticket creator (Discord-originated tickets)
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
const dmFile = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${interaction.channel.name}.txt`
|
||||
});
|
||||
await creator.send({
|
||||
content: `Your ticket **${interaction.channel.name}** has been closed. Here is your transcript:`,
|
||||
files: [dmFile]
|
||||
});
|
||||
} catch (dmErr) {
|
||||
console.warn(`Could not DM transcript to user ${creatorId}:`, dmErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const logChan = await interaction.client.channels
|
||||
.fetch(CONFIG.LOG_CHAN)
|
||||
.catch(() => null);
|
||||
if (logChan) {
|
||||
const closerMention = interaction.user.toString();
|
||||
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
||||
const channelName = interaction.channel.name;
|
||||
|
||||
let logMsg;
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
const creatorId = ticket.gmailThreadId.split('-').pop();
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
const creatorMention = creator.toString();
|
||||
logMsg = `Closed ${creatorMention}'s **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
} catch {
|
||||
logMsg = `Closed **${channelName}** by ${closerMention} (${closerDisplayName})`;
|
||||
}
|
||||
} else {
|
||||
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
||||
}
|
||||
await logChan.send(logMsg);
|
||||
}
|
||||
|
||||
const closerDisplayName =
|
||||
interaction.member?.displayName || interaction.user.username;
|
||||
|
||||
if (!ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
await sendTicketClosedEmail(ticket, closerDisplayName);
|
||||
}
|
||||
if (ticket.zammadTicketId && ZAMMAD?.URL && ZAMMAD?.TOKEN) {
|
||||
await closeZammadTicket(ticket.zammadTicketId).catch(zErr =>
|
||||
console.error('Zammad close failed:', zErr.message)
|
||||
);
|
||||
}
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
||||
);
|
||||
|
||||
if (transcriptMsg?.id) {
|
||||
await Transcript.create({
|
||||
gmailThreadId: ticket.gmailThreadId,
|
||||
transcriptMessageId: transcriptMsg.id,
|
||||
createdAt: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() => interaction.channel.delete().catch(() => {}),
|
||||
5000
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Close ticket error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ticket_modal submission (from the open-ticket panel button).
|
||||
*/
|
||||
async function handleTicketModal(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const email = interaction.fields.getTextInputValue('ticket_email').trim();
|
||||
const game = interaction.fields.getTextInputValue('ticket_game').trim();
|
||||
const description = interaction.fields.getTextInputValue('ticket_description');
|
||||
const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80);
|
||||
const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||
|
||||
const useThread =
|
||||
interaction.customId === 'ticket_modal_thread' ||
|
||||
(interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID);
|
||||
|
||||
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 guild = interaction.guild;
|
||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||
|
||||
let channel;
|
||||
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||
try {
|
||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
|
||||
} catch (err) {
|
||||
console.error('Discord ticket thread create failed:', err.message);
|
||||
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
|
||||
}
|
||||
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
|
||||
} else {
|
||||
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
||||
if (!parentId) {
|
||||
return interaction.editReply('Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
|
||||
}
|
||||
channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
id: interaction.user.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
|
||||
const now = new Date();
|
||||
await Ticket.create({
|
||||
gmailThreadId,
|
||||
discordThreadId: channel.id,
|
||||
senderEmail: email,
|
||||
subject,
|
||||
game: game || null,
|
||||
createdAt: now,
|
||||
status: 'open',
|
||||
ticketNumber,
|
||||
priority,
|
||||
lastActivity: now
|
||||
});
|
||||
|
||||
// Create Zammad ticket for Discord-originated ticket
|
||||
const displayName = interaction.member?.displayName || interaction.user.username;
|
||||
try {
|
||||
const zammadTicket = await createZammadTicket({
|
||||
subject,
|
||||
body: description,
|
||||
email,
|
||||
name: displayName,
|
||||
gameName: game || 'Not specified',
|
||||
gameKey: null,
|
||||
group: ZAMMAD.DISCORD_GROUP,
|
||||
discordUsername: displayName
|
||||
});
|
||||
if (zammadTicket?.id) {
|
||||
await saveZammadId(gmailThreadId, zammadTicket.id);
|
||||
}
|
||||
// Update Zammad customer with Discord username and ID so they show in user/ticket views
|
||||
if (zammadTicket?.customer_id) {
|
||||
try {
|
||||
await updateZammadUser(zammadTicket.customer_id, {
|
||||
discord_username: displayName,
|
||||
discord_id: interaction.user.id
|
||||
});
|
||||
} catch (_) {
|
||||
/* custom attributes may not exist in Zammad */
|
||||
}
|
||||
}
|
||||
} catch (zErr) {
|
||||
console.error('Zammad ticket create (Discord ticket) failed:', zErr.response?.data || zErr.message);
|
||||
}
|
||||
|
||||
// Ensure Zammad user if creator has a website account (keeps discord_id/discord_username in sync)
|
||||
try {
|
||||
const websiteUser = await User.findOne({ discordID: String(interaction.user.id) })
|
||||
.select('email discordID firstname lastname')
|
||||
.lean();
|
||||
if (websiteUser?.email) {
|
||||
await ensureZammadUserForDiscordUser(websiteUser, { discordUsername: displayName });
|
||||
}
|
||||
} catch (zErr) {
|
||||
console.error('Zammad user ensure (Discord ticket) failed:', zErr.message);
|
||||
}
|
||||
|
||||
// Welcome embed (green)
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setDescription("We got your ticket. We'll be with you as soon as possible.\nFeel free to add any additional information to your ticket.")
|
||||
.setColor(0x2ecc71)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
// Ticket details embed (dark) – short labels, trimmed description
|
||||
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
|
||||
const infoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields(
|
||||
{ name: 'Email', value: email, inline: true },
|
||||
{ name: 'Game', value: game || 'Not specified', inline: true },
|
||||
{ name: 'Description', value: descTrimmed, inline: false }
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
const actionRow = getTicketActionRow({ escalationTier: 0 });
|
||||
|
||||
const welcomeMsg = await channel.send({
|
||||
content: `Hey There ${interaction.user} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [actionRow]
|
||||
});
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
|
||||
await interaction.deleteReply().catch(() => {});
|
||||
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
||||
if (logChan) {
|
||||
await logChan.send(
|
||||
`📝 ${channel.name} created by ${interaction.user.tag}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ticket creation error:', err);
|
||||
await interaction.editReply('Failed to create ticket. Please contact an administrator.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleButton, handleTicketModal };
|
||||
1102
handlers/commands.js
Normal file
1102
handlers/commands.js
Normal file
File diff suppressed because it is too large
Load Diff
94
handlers/messages.js
Normal file
94
handlers/messages.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Discord messageCreate handler – forwards staff replies to Gmail and Zammad.
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { ZAMMAD } = require('../config');
|
||||
const { extractRawEmail } = require('../utils');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { addZammadArticle } = require('../services/zammad');
|
||||
const { updateTicketActivity } = require('../services/tickets');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only) + Zammad.
|
||||
*/
|
||||
async function handleDiscordReply(m) {
|
||||
if (m.author.bot || m.interaction) return;
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||
if (!ticket) return;
|
||||
|
||||
const discordUser = m.member?.displayName || m.author.username;
|
||||
|
||||
// Discord-originated tickets: no Gmail thread; only add reply to Zammad.
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
if (ticket.zammadTicketId && ZAMMAD.URL && ZAMMAD.TOKEN) {
|
||||
try {
|
||||
await addZammadArticle(ticket.zammadTicketId, m.content, { from: discordUser });
|
||||
} catch (zErr) {
|
||||
console.error('Zammad article (Discord ticket reply) failed:', zErr.response?.data || zErr.message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Email tickets: send reply via Gmail and add to Zammad.
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
|
||||
const last = [...thread.data.messages].reverse().find(msg => {
|
||||
const from =
|
||||
msg.payload.headers.find(h => h.name === 'From')?.value || '';
|
||||
return !from.toLowerCase().includes(CONFIG.MY_EMAIL);
|
||||
});
|
||||
|
||||
if (!last) return;
|
||||
|
||||
let recipient =
|
||||
last.payload.headers.find(h => h.name === 'From')?.value || '';
|
||||
const replyTo =
|
||||
last.payload.headers.find(h => h.name === 'Reply-To')?.value;
|
||||
if (replyTo) recipient = replyTo;
|
||||
|
||||
const subject =
|
||||
last.payload.headers.find(h => h.name === 'Subject')?.value ||
|
||||
'Support';
|
||||
const msgId =
|
||||
last.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
||||
|
||||
const recipientEmail = extractRawEmail(recipient).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
|
||||
console.warn('Bad recipient for reply:', recipientEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendGmailReply(
|
||||
ticket.gmailThreadId,
|
||||
m.content,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
msgId
|
||||
);
|
||||
|
||||
await updateTicketActivity(ticket.gmailThreadId);
|
||||
|
||||
if (ticket.zammadTicketId && ZAMMAD.URL && ZAMMAD.TOKEN) {
|
||||
try {
|
||||
await addZammadArticle(ticket.zammadTicketId, m.content, { from: discordUser });
|
||||
} catch (zErr) {
|
||||
console.error('Zammad article (Discord reply) failed:', zErr.response?.data || zErr.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('REPLY ERROR:', e);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleDiscordReply };
|
||||
655
handlers/setup.js
Normal file
655
handlers/setup.js
Normal file
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* /setup wizard – multi-step panel configuration (panel name, support role,
|
||||
* ticket category, transcript channel, panel channel).
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
ChannelType,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
RoleSelectMenuBuilder,
|
||||
ChannelSelectMenuBuilder
|
||||
} = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
/** @type {Map<string, { step: number, panelName?: string, roleIds?: string[], ticketType?: 'channel'|'thread', categoryId?: string, categoryName?: string, threadChannelId?: string, threadChannelName?: string, transcriptChannelId?: string, panelChannelId?: string, createdAt: number }>} */
|
||||
const setupState = new Map();
|
||||
|
||||
const PREFIX = 'setup_';
|
||||
const PREFIX_BUTTON = PREFIX;
|
||||
const PREFIX_MODAL = PREFIX + 'modal_';
|
||||
const PREFIX_SELECT = PREFIX + 'select_';
|
||||
|
||||
function getState(userId) {
|
||||
const s = setupState.get(userId);
|
||||
if (!s) return null;
|
||||
if (Date.now() - s.createdAt > WIZARD_TIMEOUT_MS) {
|
||||
setupState.delete(userId);
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setState(userId, data) {
|
||||
const existing = setupState.get(userId) || { createdAt: Date.now() };
|
||||
setupState.set(userId, { ...existing, ...data });
|
||||
}
|
||||
|
||||
function clearState(userId) {
|
||||
setupState.delete(userId);
|
||||
}
|
||||
|
||||
function step1Embed(panelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 1/5 Set the panel name')
|
||||
.setDescription(
|
||||
'Use the button to set the panel name and continue.\n(This can be changed later.)'
|
||||
)
|
||||
.addFields({ name: 'Current Name', value: panelName ? `\`${panelName}\`` : 'Not set' });
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'setname')
|
||||
.setLabel('Set name')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('⚙️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_1')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!panelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
function step2Embed(roleLabels) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 2/5 Select the support team role(s)')
|
||||
.setDescription(
|
||||
'The support roles will be automatically added to this panel\'s tickets so they can assist people as needed.\n' +
|
||||
'Use the dropdown to select roles.\n' +
|
||||
'Not seeing your role? Try searching for it inside the dropdown.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Role(s)',
|
||||
value: roleLabels && roleLabels.length ? roleLabels.join(', ') : 'None selected'
|
||||
});
|
||||
|
||||
const select = new RoleSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'roles')
|
||||
.setPlaceholder('Select all the roles for your support team')
|
||||
.setMinValues(1)
|
||||
.setMaxValues(5);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_2')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_2')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!roleLabels || roleLabels.length === 0)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step3Embed(state) {
|
||||
const ticketType = state.ticketType;
|
||||
const categoryName = state.categoryName;
|
||||
const threadChannelName = state.threadChannelName;
|
||||
|
||||
if (!ticketType) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 How should tickets be created?')
|
||||
.setDescription(
|
||||
'**Channels:** Each ticket is a channel in a category (classic layout).\n' +
|
||||
'**Threads:** Each ticket is a private thread under a text channel (compact).\n' +
|
||||
'**Both:** Create one panel with two buttons (thread + category).'
|
||||
)
|
||||
.addFields({ name: 'Choice', value: 'Select below' });
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_channel')
|
||||
.setLabel('Channels in category')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📁'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_thread')
|
||||
.setLabel('Private threads')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_both')
|
||||
.setLabel('Both (thread + category)')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📋'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️')
|
||||
);
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
if (ticketType === 'both') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select category and thread channel (both)')
|
||||
.setDescription(
|
||||
'The panel will have two buttons: one creates ticket **threads**, one creates ticket **channels**.\n' +
|
||||
'Select the category for channels and the text channel for threads.'
|
||||
)
|
||||
.addFields(
|
||||
{ name: 'Category (for channels)', value: categoryName ? `\`${categoryName}\`` : 'None selected', inline: true },
|
||||
{ name: 'Channel (for threads)', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected', inline: true }
|
||||
);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(
|
||||
new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'category')
|
||||
.setPlaceholder('Select category for channels')
|
||||
.addChannelTypes(ChannelType.GuildCategory)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'thread_channel')
|
||||
.setPlaceholder('Select channel for threads')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
const row3 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_both_channel')
|
||||
.setLabel('Channels only')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_thread')
|
||||
.setLabel('Threads only')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!(categoryName && threadChannelName))
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2, row3] };
|
||||
}
|
||||
|
||||
if (ticketType === 'channel') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select the ticket category')
|
||||
.setDescription(
|
||||
'The selected category is where ticket **channels** will be created.\n' +
|
||||
'Use the dropdown to select the category.'
|
||||
)
|
||||
.addFields({ name: 'Selected Category', value: categoryName ? `\`${categoryName}\`` : 'None selected' });
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'category')
|
||||
.setPlaceholder('Select a category')
|
||||
.addChannelTypes(ChannelType.GuildCategory)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
|
||||
.setLabel('Change to Threads')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!categoryName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
// ticketType === 'thread'
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select the channel for ticket threads')
|
||||
.setDescription(
|
||||
'Ticket **threads** will be created as private threads under the selected text channel.\n' +
|
||||
'Use the dropdown to select the channel.'
|
||||
)
|
||||
.addFields({ name: 'Selected Channel', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected' });
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'thread_channel')
|
||||
.setPlaceholder('Select a text channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
|
||||
.setLabel('Change to Channels')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!threadChannelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step4Embed(channelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 4/5 Select the transcript channel')
|
||||
.setDescription(
|
||||
'The selected channel is where transcripts will be saved when tickets are closed.\n' +
|
||||
'Use the dropdown to select the channel.\n' +
|
||||
'Not seeing your channel? Try searching for it inside the dropdown.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Channel',
|
||||
value: channelName ? `\`${channelName}\`` : 'Not selected'
|
||||
});
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'transcript')
|
||||
.setPlaceholder('Select a channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_4')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_4')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!channelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step5Embed(channelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 5/5 Send the panel into a channel')
|
||||
.setDescription(
|
||||
'The ticket creation panel is what the community will use to create tickets.\n' +
|
||||
'Use the dropdown to select the channel to send the panel into.\n' +
|
||||
'Not seeing your channel? Try searching for it inside the dropdown.\n' +
|
||||
'Sending not working? Run `/panel` in the channel directly.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Channel',
|
||||
value: channelName ? `\`${channelName}\`` : 'Not selected'
|
||||
});
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'panel_channel')
|
||||
.setPlaceholder('Select a channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_5')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'finish')
|
||||
.setLabel('Finish')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!channelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /setup slash command – send Step 1.
|
||||
*/
|
||||
async function handleSetupCommand(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
setState(interaction.user.id, { step: 1, panelName: null });
|
||||
const payload = step1Embed(null);
|
||||
await interaction.editReply(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup button (Set name, Back, Save & Continue, Finish).
|
||||
*/
|
||||
async function handleSetupButton(interaction) {
|
||||
const customId = interaction.customId;
|
||||
if (!customId.startsWith(PREFIX_BUTTON)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Set name → show modal
|
||||
if (customId === PREFIX_BUTTON + 'setname') {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(PREFIX_MODAL + 'name')
|
||||
.setTitle('Panel name');
|
||||
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId('panel_name')
|
||||
.setLabel('Panel name')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('e.g. New Panel')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100);
|
||||
if (state.panelName) input.setValue(state.panelName);
|
||||
modal.addComponents(new ActionRowBuilder().addComponents(input));
|
||||
await interaction.showModal(modal);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Back
|
||||
if (customId.startsWith(PREFIX_BUTTON + 'back_')) {
|
||||
const step = parseInt(customId.replace(PREFIX_BUTTON + 'back_', ''), 10);
|
||||
const nextStep = step - 1;
|
||||
setState(userId, { step: nextStep });
|
||||
let payload;
|
||||
if (nextStep === 1) payload = step1Embed(state.panelName);
|
||||
else if (nextStep === 2) payload = step2Embed(state.roleLabels);
|
||||
else if (nextStep === 3) payload = step3Embed(state);
|
||||
else if (nextStep === 4) payload = step4Embed(state.transcriptChannelName);
|
||||
else payload = step5Embed(state.panelChannelName);
|
||||
await interaction.update(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save & Continue (steps 1–4)
|
||||
if (customId === PREFIX_BUTTON + 'continue_1') {
|
||||
setState(userId, { step: 2 });
|
||||
await interaction.update(step2Embed(state.roleLabels));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_2') {
|
||||
setState(userId, { step: 3 });
|
||||
await interaction.update(step3Embed({ ...state, step: 3 }));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_channel') {
|
||||
setState(userId, { ticketType: 'channel', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_thread') {
|
||||
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_both') {
|
||||
setState(userId, { ticketType: 'both', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear') {
|
||||
setState(userId, { ticketType: null, categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear_thread') {
|
||||
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear_both_channel') {
|
||||
setState(userId, { ticketType: 'channel', threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_3') {
|
||||
setState(userId, { step: 4 });
|
||||
await interaction.update(step4Embed(state.transcriptChannelName));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_4') {
|
||||
setState(userId, { step: 5 });
|
||||
await interaction.update(step5Embed(state.panelChannelName));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Finish
|
||||
if (customId === PREFIX_BUTTON + 'finish') {
|
||||
const hasTicketTarget =
|
||||
(state.ticketType === 'channel' && state.categoryId) ||
|
||||
(state.ticketType === 'thread' && state.threadChannelId) ||
|
||||
(state.ticketType === 'both' && state.categoryId && state.threadChannelId);
|
||||
if (!state.panelChannelId || !hasTicketTarget || !state.roleIds?.length) {
|
||||
await interaction.reply({
|
||||
content: 'Please complete all steps (panel name, support role, ticket type + category/channel, transcript channel, panel channel).',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await interaction.client.channels.fetch(state.panelChannelId);
|
||||
const title = state.panelName || 'Indifferent Broccoli Tickets';
|
||||
const 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' });
|
||||
|
||||
let row;
|
||||
if (state.ticketType === 'both') {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket (channel)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
} else {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('✅')
|
||||
);
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed], components: [row] });
|
||||
|
||||
const envLines = state.ticketType === 'both'
|
||||
? [`DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`, `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`]
|
||||
: [state.ticketType === 'thread'
|
||||
? `DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`
|
||||
: `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`];
|
||||
const envSnippet = [
|
||||
'**Add these to your `.env` file** (optional – only if you want to use these values for new Discord tickets):',
|
||||
'```',
|
||||
...envLines,
|
||||
`ROLE_ID_TO_PING=${state.roleIds[0]}`,
|
||||
`TRANSCRIPT_CHANNEL_ID=${state.transcriptChannelId}`,
|
||||
`LOGGING_CHANNEL_ID=${state.transcriptChannelId}`,
|
||||
'```'
|
||||
].join('\n');
|
||||
|
||||
await interaction.update({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Setup complete')
|
||||
.setDescription(
|
||||
`Panel **${title}** has been sent to ${channel}.\n\n` +
|
||||
envSnippet
|
||||
)
|
||||
],
|
||||
components: []
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Setup finish error:', err);
|
||||
await interaction.reply({
|
||||
content: `Failed to send panel: ${err.message}`,
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
}
|
||||
clearState(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup modal submit (panel name).
|
||||
*/
|
||||
async function handleSetupModal(interaction) {
|
||||
if (!interaction.customId.startsWith(PREFIX_MODAL)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interaction.customId === PREFIX_MODAL + 'name') {
|
||||
const panelName = interaction.fields.getTextInputValue('panel_name').trim();
|
||||
setState(userId, { panelName, step: 1 });
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const payload = step1Embed(panelName);
|
||||
await interaction.editReply(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup select menus (roles, category, transcript channel, panel channel).
|
||||
*/
|
||||
async function handleSetupSelect(interaction) {
|
||||
const customId = interaction.customId;
|
||||
if (!customId.startsWith(PREFIX_SELECT)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'roles') {
|
||||
const roles = interaction.roles;
|
||||
const roleIds = [...roles.keys()];
|
||||
const roleLabels = [...roles.values()].map(r => r.name);
|
||||
setState(userId, { roleIds, roleLabels });
|
||||
await interaction.update(step2Embed(roleLabels));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'category') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
categoryId: channel?.id,
|
||||
categoryName: channel?.name
|
||||
});
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_SELECT + 'thread_channel') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
threadChannelId: channel?.id,
|
||||
threadChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'transcript') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
transcriptChannelId: channel?.id,
|
||||
transcriptChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step4Embed(channel?.name));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'panel_channel') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
panelChannelId: channel?.id,
|
||||
panelChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step5Embed(channel?.name));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PREFIX_BUTTON,
|
||||
PREFIX_MODAL,
|
||||
PREFIX_SELECT,
|
||||
handleSetupCommand,
|
||||
handleSetupButton,
|
||||
handleSetupModal,
|
||||
handleSetupSelect
|
||||
};
|
||||
Reference in New Issue
Block a user