Files
broccolini-bot/handlers/buttons.js
root 519788c633 Initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 08:22:19 -06:00

690 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };