import { ActionRowBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle, ChannelType, type ChatInputCommandInteraction, type Client, ContainerBuilder, LabelBuilder, type ModalSubmitInteraction, PermissionFlagsBits, SlashCommandBuilder, SlashCommandSubcommandBuilder, TextDisplayBuilder, TextInputBuilder, TextInputStyle, } from "discord.js"; import { ModalBuilder } from "discord.js"; import { eq } from "drizzle-orm"; import { type Guild, type Ticket, db, guild, type ticketAttachment, tickets, } from "@/database"; import { formatMessage } from "@/utils/formatting"; import { loggers } from "@/utils/logging"; import { countTickets, getGuild, getManagerRoleIds, getTicket, getTicketMessage, } from "@/utils/queries"; import { getPublicUrl } from "@/utils/s3"; import { generateTranscript } from "@/utils/transcripts"; const logger = loggers.interactions.child({ name: "ticket" }); const commandData = new SlashCommandBuilder() .setName("ticket") .setDescription("Manage and create new tickets") .addSubcommand( new SlashCommandSubcommandBuilder() .setName("create") .setDescription("Create a new ticket"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("message") .setDescription("Set the initial message sent in new tickets"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("close") .setDescription("Close the ticket in the current channel") .addStringOption((option) => option .setName("reason") .setDescription("Reason for closing the ticket") .setRequired(false), ), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("reopen") .setDescription("Reopen the ticket in the current channel"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("delete") .setDescription("Delete the ticket in the current channel"), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("transcript") .setDescription( "Get the transcript of the ticket in the current channel", ), ); async function getModal(guildId: string) { const { data: message } = await getTicketMessage(guildId); const modal = new ModalBuilder() .setCustomId("ticket:message") .setTitle("Edit Report Message"); const label = new LabelBuilder() .setLabel("Report Message Content") .setDescription("This will be the message shown to first time reporters.") .setTextInputComponent( new TextInputBuilder() .setCustomId("ticket:input") .setPlaceholder("meow meow :3") .setStyle(TextInputStyle.Paragraph) .setMaxLength(2000) .setRequired(true) .setValue(message ?? ""), ); modal.addLabelComponents(label); return modal; } // ==================================== // interaction handlers // ==================================== async function execute( client: Client, interaction: ChatInputCommandInteraction, ) { const action = interaction.options.getSubcommand(); if (!interaction.channelId || !interaction.guildId) { await interaction.reply({ content: "This command can only be used in a server.", flags: ["Ephemeral"], }); return; } if (action === "message") { const modal = await getModal(interaction.guildId); await interaction.showModal(modal); return; } await interaction.deferReply({ flags: ["Ephemeral"] }); if (action === "create") { await handleCreate(client, interaction); return; } const { data: guild } = await getGuild(interaction.guildId); const { data: ticket } = await getTicket( interaction.guildId, interaction.channelId, ); if (!ticket) { await interaction.editReply({ content: "❌ This channel is not a valid ticket.", }); return; } if (action === "close") await handleClose(client, interaction, ticket, guild); else if (action === "reopen") await handleReopen(client, interaction, ticket); else if (action === "delete") await handleDelete(client, interaction, ticket); else if (action === "transcript") await handleTranscript(client, interaction, ticket); } export async function buttonExecute( client: Client, interaction: ButtonInteraction, ) { console.log("recd"); await interaction.deferReply({ flags: ["Ephemeral"] }); const [action] = interaction.customId.split(":").slice(1); if (!interaction.channelId || !interaction.guildId) { await interaction.editReply({ content: "This command can only be used in a server.", }); return; } if (action === "create") { await handleCreate(client, interaction); return; } const { data: guild } = await getGuild(interaction.guildId); const { data: ticket } = await getTicket( interaction.guildId, interaction.channelId, ); if (!ticket) { await interaction.editReply({ content: "❌ This channel is not a valid ticket.", }); return; } if (action === "close") await handleClose(client, interaction, ticket, guild); else if (action === "reopen") await handleReopen(client, interaction, ticket); else if (action === "delete") await handleDelete(client, interaction, ticket); else if (action === "transcript") await handleTranscript(client, interaction, ticket); } async function modalExecute( _client: Client, interaction: ModalSubmitInteraction, ) { await interaction.deferReply({ flags: ["Ephemeral"] }); if (!interaction.guildId) { await interaction.editReply({ content: "This command can only be used in a server.", }); return; } const action = interaction.customId.split(":")[1]; if (action === "message") { const message = interaction.fields.getTextInputValue("ticket:input"); await db .update(guild) .set({ ticket_message: message }) .where(eq(guild.guildId, interaction.guildId)); await interaction.editReply({ content: "Ticket message updated successfully!", }); } // TODO)) show modal for entering a close reason } // ==================================== // action handlers // ==================================== async function handleCreate( client: Client, interaction: ChatInputCommandInteraction | ButtonInteraction, ) { if (!interaction.guild) throw new Error("Guild is not available"); if (!client.user) throw new Error("Client user is not available"); const { data: guildData, exists } = await getGuild(interaction.guild.id); if (!exists) { await interaction.editReply({ content: "This server hasn't been setup yet.", }); return; } const anonymousId = crypto.randomUUID().split("-")[0]; const managerRoleIds = await getManagerRoleIds(interaction.guild.id); const parent = guildData.ticket_category_id ?? undefined; const channel = await interaction.guild.channels.create({ name: `ticket-${anonymousId}`, type: ChannelType.GuildText, parent, permissionOverwrites: [ { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel], }, { id: interaction.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.AttachFiles, ], }, { id: client.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels, ], }, ...managerRoleIds.map((roleId) => ({ id: roleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.AttachFiles, ], })), ], }); await db .insert(tickets) .values({ guildId: interaction.guild.id, channelId: channel.id, authorId: interaction.user.id, anonymousId: anonymousId, type: "general", status: "open", }) .returning(); const formatted = formatMessage(guildData.ticket_message ?? "", interaction); const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:close") .setLabel("Close") .setStyle(ButtonStyle.Danger) .setEmoji("🔒"), ); const container = new ContainerBuilder() .setAccentColor(0xff5a00) .addTextDisplayComponents( new TextDisplayBuilder().setContent("# Ticket created!"), new TextDisplayBuilder().setContent(formatted), ) .addActionRowComponents(controls); await channel.send({ components: [container], flags: ["IsComponentsV2"], }); await interaction.editReply({ content: `:white_check_mark: Ticket created ${channel.toString()}`, }); } async function handleClose( client: Client, interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, guild: Guild, ) { if (ticket.status === "closed") { await interaction.editReply("❌ This ticket is already closed."); return; } const channel = interaction.channel; if (!channel || !("permissionOverwrites" in channel)) return; const transcriptChannelId = guild.ticket_channel_id; await db .update(tickets) .set({ status: "closed", closedAt: new Date(), }) .where(eq(tickets.id, ticket.id)); try { const member = await interaction.guild?.members .fetch(ticket.authorId) .catch(() => null); const isAdmin = member?.permissions.has(PermissionFlagsBits.Administrator); const isOwner = interaction.guild?.ownerId === ticket.authorId; if (!isAdmin && !isOwner) { await channel.permissionOverwrites.edit(ticket.authorId, { ViewChannel: false, }); } } catch (error) { logger.error(error, `could not remove permissions for ${ticket.authorId}`); } const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:reopen") .setLabel("Reopen") .setStyle(ButtonStyle.Success) .setEmoji("🔓"), new ButtonBuilder() .setCustomId("ticket:delete") .setLabel("Delete") .setStyle(ButtonStyle.Danger) .setEmoji("🗑️"), ); await channel.send({ content: `Ticket closed by <@${interaction.user.id}>.`, components: [controls], }); await interaction.editReply("✅ Ticket closed."); const [transcriptText, attachments] = await generateTranscript(ticket); const author = await client.users.fetch(ticket.authorId); const attachmentsString = attachments .map( (att: ticketAttachment, idx: number) => `[Attachment #${idx + 1} (${att.fileName.split(".").slice(-1)[0].toUpperCase()})](<${getPublicUrl(att.key)}>)`, ) .join(", "); try { await author.send({ files: [ { attachment: Buffer.from(transcriptText, "utf-8"), name: `transcript-${ticket.anonymousId}.txt`, }, ], content: `Your ticket #${ticket.anonymousId} has been closed by <@${interaction.user.id}>. The transcript of the ticket is attached.${attachments.length > 0 ? ` The ticket also had the following attachments: ${attachmentsString}` : ""}`, }); } catch (error) { logger.error( error, `Could not send DM to user ${ticket.authorId} for closed ticket ${ticket.id}`, ); } if (!transcriptChannelId) { logger.warn( `No transcript channel configured for guild ${interaction.guildId}, skipping transcript post`, ); return; } const transcriptChannel = await client.channels.fetch(transcriptChannelId); if (!transcriptChannel || !("send" in transcriptChannel)) { logger.warn( `Transcript channel with ID ${transcriptChannelId} not found or is not a text channel`, ); return; } try { const closeString = [ `Transcript for ticket #${ticket.anonymousId} by <@${ticket.authorId}>.`, attachments.length > 0 ? `-# Attachments: ${attachmentsString}` : null, ] .filter(Boolean) .join("\n"); await transcriptChannel.send({ content: closeString, files: [ { attachment: Buffer.from(transcriptText, "utf-8"), name: `transcript-${ticket.anonymousId}.txt`, }, ], }); } catch (error) { logger.error( error, `Could not send ticket transcript to transcript channel ${transcriptChannelId} for ticket ${ticket.id}`, ); await interaction.followUp({ content: "⚠️ Ticket closed but failed to send transcript to transcript channel.", }); } } async function handleReopen( _client: Client, interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, ) { if (ticket.status === "open") { await interaction.editReply("❌ This ticket is already open."); return; } const channel = interaction.channel; if (!channel || !("permissionOverwrites" in channel)) return; await db .update(tickets) .set({ status: "open", closedAt: null, }) .where(eq(tickets.id, ticket.id)); try { await channel.permissionOverwrites.edit(ticket.authorId, { ViewChannel: true, SendMessages: true, AttachFiles: true, }); } catch (error) { logger.error(error, `could not restore permissions for ${ticket.authorId}`); } const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("ticket:close") .setLabel("Close") .setStyle(ButtonStyle.Danger) .setEmoji("🔒"), ); await channel.send({ content: `Ticket reopened by <@${interaction.user.id}>`, components: [controls], }); await interaction.editReply("✅ Ticket reopened."); } async function handleDelete( _client: Client, interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, ) { await db .update(tickets) .set({ status: "archived" }) .where(eq(tickets.id, ticket.id)); await interaction.editReply("⚠️ Deleting ticket channel in 5 seconds..."); const channel = interaction.channel; if (channel && "delete" in channel) { setTimeout(() => channel.delete(), 5000); } } async function handleTranscript( _client: Client, interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, ) { const [transcriptText] = await generateTranscript(ticket); if (interaction.channel?.isSendable()) await interaction.channel?.send({ files: [ { attachment: Buffer.from(transcriptText, "utf-8"), name: `transcript-${ticket.anonymousId}.txt`, }, ], }); await interaction.editReply("✅ Transcript sent."); } export default { data: commandData, execute, buttonExecute, modalExecute, };