import { option } from "@purrkit/router"; import { Result } from "@sapphire/framework"; import { type TicketWithRelations, getTicketsByUser } from "@stealth-developers/db"; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ContainerBuilder, TextDisplayBuilder, } from "discord.js"; import { kitten } from "@/client"; import { PublicError, errorMessage, handleError } from "@/lib"; import { useGetTicketData } from "@/middleware"; import * as actions from "./actions"; import { TicketModalPresets } from "./modals"; export const newTicketButton = useGetTicketData.button("b-open-ticket", { async run(interaction, _, { actor, guild }) { if (!guild || !interaction.guild) { return errorMessage(interaction, "You must run this command in a guild"); } else if (!actor) { return errorMessage(interaction, "Failed to retrieve your user data"); } const func = actions.createTicket({ creatorActor: actor, creatorUser: interaction.member ?? interaction.user, guild: interaction.guild, dbGuild: guild, }); const ticketResult = await Result.fromAsync(async () => func); if (ticketResult.isErr()) { const error = ticketResult.unwrapErr(); return errorMessage( interaction, error instanceof PublicError ? error.message : "An unexpected error occurred while creating the ticket.", ); } const { channelId, setupPromise } = ticketResult.unwrap(); await interaction.reply({ content: `Opened ticket <#${channelId}>.`, flags: ["Ephemeral"], }); try { await setupPromise; } catch { await interaction .editReply({ content: "An error occurred during ticket setup, and the ticket was closed. Please try again.", }) .catch(() => {}); } }, }); // export const closeTicketButton = useGetTicketData.button("b-close-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction) { if (!interaction.guild) return errorMessage(interaction, "You must run this command in a guild"); const modal = await actions.triggerCloseTicket( interaction.user, interaction.guild, interaction.channelId, ); await interaction.showModal(modal); }, }); export const reopenTicketButton = useGetTicketData.button("b-reopen-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction, _, { actor, ticket, ticketChannel, guild }) { if (!ticket) return errorMessage(interaction, "Failed to find ticket in the database"); else if (!ticketChannel) return errorMessage(interaction, "Failed to find ticket channel, may not have access"); else if (!actor) return errorMessage(interaction, "Failed to find actor"); else if (!guild) return errorMessage(interaction, "Failed to find guild"); const result = await Result.fromAsync( actions.reopenTicket({ ticket, ticketChannel, actor, guild }), ); if (result.isErr()) return handleError(interaction, result.unwrapErr()); await interaction.reply({ content: `Ticket has been reopened.`, flags: ["Ephemeral"], }); }, }); // export const claimTicketButton = useGetTicketData.button("b-claim-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction, _, ctx) { const { actor, guild, ticket, ticketChannel, user } = ctx; if (!actor || !guild || !ticket || !ticketChannel || !user) { await errorMessage(interaction, "Invalid ticket"); return; } const result = await Result.fromAsync( actions.claimTicket({ ticket, ticketChannel, actor, guild }), ); if (result.isErr()) return handleError(interaction, result.unwrapErr()); await interaction.reply({ content: "Ticket claimed!", flags: ["Ephemeral"], }); }, }); export const unclaimTicketButton = useGetTicketData.button("b-unclaim-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction, _, { actor, ticket, ticketChannel, guild }) { if (!ticket) return errorMessage(interaction, "Failed to find ticket in the database"); else if (!ticketChannel) return errorMessage(interaction, "Failed to find ticket channel, may not have access"); else if (!actor) return errorMessage(interaction, "Failed to find actor"); else if (!guild) return errorMessage(interaction, "Failed to find guild"); const result = await Result.fromAsync( actions.unclaimTicket({ ticket, ticketChannel, actor, guild }), ); if (result.isErr()) return handleError(interaction, result.unwrapErr()); await interaction.reply({ content: `You have unclaimed ticket #${ticket.localId}.`, flags: ["Ephemeral"], }); }, }); // export const commentOnTicketButton = useGetTicketData.button("b-comment-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction, _, { actor, ticket, ticketChannel, guild }) { if (!ticket) return errorMessage(interaction, "Failed to find ticket in the database"); else if (!ticketChannel) return errorMessage(interaction, "Failed to find ticket channel, may not have access"); else if (!actor) return errorMessage(interaction, "Failed to find actor"); else if (!guild) return errorMessage(interaction, "Failed to find guild"); if (!actor.moderator) return errorMessage(interaction, "You must be a moderator to use this command"); await interaction.showModal(TicketModalPresets.comment(ticket.id)); }, }); export const viewCommentsButton = useGetTicketData.button("b-comment-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction, _, { ticket, actor }) { if (!ticket || !actor) return errorMessage(interaction, "Failed to find ticket"); if (!actor.moderator) return errorMessage(interaction, "You must be a moderator to use this command"); const { comments } = ticket; const container = actions.getCommentContainer(ticket.localId, comments); await interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"], allowedMentions: { parse: [] }, }); }, }); // export const editTicketButton = useGetTicketData.button("b-edit-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction, _, { actor, ticket }) { if (!actor) { return errorMessage(interaction, "This command can only be used in a server."); } else if (!actor.moderator) { return errorMessage(interaction, "You do not have permission to use this command."); } else if (!ticket) { return errorMessage(interaction, "Ticket not found."); } await interaction.showModal( TicketModalPresets.edit(ticket.id, { public: ticket.closeReason, private: ticket.privateReason, }), ); }, }); export const cleanTicketButton = useGetTicketData.button("b-delete-ticket", { options: { ticketId: option.string({ required: true }), }, async run(interaction, _, ctx) { const { actor, ticket, ticketChannel, guild } = ctx; if (!ticket) return errorMessage(interaction, "Failed to find ticket in the database"); else if (!ticketChannel) return errorMessage(interaction, "Failed to find ticket channel, may not have access"); else if (!actor) return errorMessage(interaction, "Failed to find actor"); else if (!guild) return errorMessage(interaction, "Failed to find guild"); const result = await Result.fromAsync(actions.cleanTicket(ctx)); if (result.isErr()) return errorMessage(interaction, "Failed to delete ticket channel"); }, }); export const thankButton = kitten.button("b-thank-ticket", { options: { ticketId: option.string({ required: true }), }, async run() {}, }); // export const listTicketsPageButton = kitten.button("b-list-tickets-page", { options: { userId: option.number({ required: true }), page: option.number({ required: true }), }, async run(interaction, { userId, page }) { const tickets = await getTicketsByUser(userId); if (tickets.length === 0) { return errorMessage(interaction, "You have no tickets."); } const payload = getTicketsListPayload(userId, page, tickets); await interaction.update({ ...payload, flags: ["IsComponentsV2"], }); }, }); export function getTicketsListPayload( userId: number, page: number, tickets: TicketWithRelations[], ) { const TICKETS_PER_PAGE = 5; const totalPages = Math.ceil(tickets.length / TICKETS_PER_PAGE); const currentPage = Math.max(1, Math.min(page, totalPages)); const pageTickets = tickets.slice( (currentPage - 1) * TICKETS_PER_PAGE, currentPage * TICKETS_PER_PAGE, ); const container = new ContainerBuilder(); container.addTextDisplayComponents( new TextDisplayBuilder().setContent(`### Your Tickets (Page ${currentPage}/${totalPages})`), ); for (const ticket of pageTickets) { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( [ `* **Ticket #${ticket.localId}** (${ticket.id}) - ${ticket.status}`, `\t* opened `, ticket.closedAt ? ` * closed ` : "", ].join("\n"), ), ); } const row = new ActionRowBuilder(); if (currentPage > 1) row.addComponents( TicketButtonPresets.listTicketsPageButton(userId, currentPage - 1, "Previous"), ); if (currentPage < totalPages) row.addComponents(TicketButtonPresets.listTicketsPageButton(userId, currentPage + 1, "Next")); if (row.components.length > 0) container.addActionRowComponents(row); return { components: row.components.length > 0 ? [container] : [], }; } export const TicketButtonsMap = { newTicketButton, closeTicketButton, reopenTicketButton, claimTicketButton, unclaimTicketButton, commentOnTicketButton, viewCommentsButton, editTicketButton, cleanTicketButton, thankButton, listTicketsPageButton, } as const; export const TicketButtons = Object.values(TicketButtonsMap); export type TicketButtonKeys = keyof typeof TicketButtonsMap | "transcriptsLink" | "uploadLink"; export const TicketButtonPresets: Record ButtonBuilder> = { newTicketButton: () => new ButtonBuilder() .setLabel("Open a Ticket") .setStyle(ButtonStyle.Success) .setCustomId(newTicketButton.id()), // closeTicketButton: (id: string) => new ButtonBuilder() .setCustomId(closeTicketButton.id({ ticketId: id })) .setLabel("Close Ticket") .setStyle(ButtonStyle.Danger), reopenTicketButton: (id: string) => new ButtonBuilder() .setCustomId(reopenTicketButton.id({ ticketId: id })) .setLabel("Reopen Ticket") .setStyle(ButtonStyle.Primary), // claimTicketButton: (id: string) => new ButtonBuilder() .setCustomId(claimTicketButton.id({ ticketId: id })) .setLabel("Claim Ticket") .setStyle(ButtonStyle.Primary), unclaimTicketButton: (id: string) => new ButtonBuilder() .setCustomId(unclaimTicketButton.id({ ticketId: id })) .setLabel("Unclaim Ticket") .setStyle(ButtonStyle.Secondary), // commentOnTicketButton: (id: string) => new ButtonBuilder() .setCustomId(commentOnTicketButton.id({ ticketId: id })) .setLabel(`Add Comment`) .setStyle(ButtonStyle.Secondary), viewCommentsButton: (id: string, quantity: number) => new ButtonBuilder() .setCustomId(viewCommentsButton.id({ ticketId: id })) .setLabel(`View Comments (≥${quantity})`) .setStyle(ButtonStyle.Secondary), // editTicketButton: (id: string) => new ButtonBuilder() .setCustomId(editTicketButton.id({ ticketId: id })) .setLabel("Edit Ticket") .setStyle(ButtonStyle.Primary), // cleanTicketButton: (id: string) => new ButtonBuilder() .setCustomId(cleanTicketButton.id({ ticketId: id })) .setLabel("Delete Channel") .setStyle(ButtonStyle.Danger), // transcriptsLink: (url: string) => new ButtonBuilder().setLabel("View on Web").setURL(url).setStyle(ButtonStyle.Link), uploadLink: (id: string) => new ButtonBuilder() .setLabel("Upload a video") .setStyle(ButtonStyle.Link) .setURL(`https://tickets.vt3e.cat/upload/${id}`), thankButton: (id: string) => new ButtonBuilder() .setCustomId(thankButton.id({ ticketId: id })) .setLabel("Thank your moderator") .setEmoji("💖") .setStyle(ButtonStyle.Success), // listTicketsPageButton: (userId: number, page: number, label: string) => new ButtonBuilder() .setCustomId(listTicketsPageButton.id({ userId, page })) .setLabel(label) .setStyle(ButtonStyle.Secondary), }; export default TicketButtonPresets;