import { option } from "@purrkit/router"; import { Result } from "@sapphire/result"; import { getTicketByGuild, getTicketByUser, getTicketsByUser, getTicketsByUserGDPR, purgeTicketData, purgeUserTicketData, removeTicketParticipant, upsertActor, } from "@stealth-developers/db"; import { ActionRowBuilder, ButtonBuilder, ContainerBuilder, TextDisplayBuilder } from "discord.js"; import { ChannelType } from "discord.js"; import { OverwriteType } from "discord.js"; import { PublicError, errorMessage, getTextChannel, handleError, logger } from "@/lib"; import { useGetTicketData } from "@/middleware"; import { getGuildActor } from "../guild-actor"; import * as actions from "./actions"; import { TicketButtonPresets, getTicketsListPayload } from "./buttons"; import { hasAccessToTicket } from "./lib"; import { manageConfig } from "./lib/config"; import { TicketModalPresets } from "./modals"; const ticketCommand = useGetTicketData.command("ticket", { description: "Ticket-related commands", }); ticketCommand.subcommand("new", { description: "Open a new ticket.", async run(interaction) { if (!interaction.guild) return errorMessage(interaction, "You must run this command in a guild"); const func = actions.createTicket(interaction.member ?? interaction.user, interaction.guild); const ticketResult = await Result.fromAsync(async () => func); if (ticketResult.isErr()) { const error = ticketResult.unwrapErr(); if (error instanceof PublicError) { return errorMessage(interaction, error.message); } else { console.error(error); return errorMessage(interaction, "An unexpected error occurred while creating the ticket."); } } const { channelId } = ticketResult.unwrap(); return interaction.reply({ content: `Opened ticket <#${channelId}>.`, flags: ["Ephemeral"], }); }, }); ticketCommand.subcommand("for", { description: "Open a new ticket on behalf of someone else", options: { user: option.user("The user to open the ticket for", { required: true }), reason: option.string("The reason for opening the ticket"), }, async run(interaction, args) { if (!interaction.guild) return errorMessage(interaction, "You must run this command in a guild"); const func = actions.createTicket(interaction.member ?? interaction.user, interaction.guild, { for: args.user, reason: args.reason, }); const ticketResult = await Result.fromAsync(async () => func); if (ticketResult.isErr()) { const error = ticketResult.unwrapErr(); if (error instanceof PublicError) { return errorMessage(interaction, error.message); } else { logger.error(error); return errorMessage(interaction, "An unexpected error occurred while creating the ticket."); } } const { channelId } = ticketResult.unwrap(); return interaction.reply({ content: `Opened ticket <#${channelId}>.`, flags: ["Ephemeral"], }); }, }); ticketCommand.subcommand("view", { description: "View a ticket", options: { ticket: option.number("The ticket's ID", { required: true }), }, async run(interaction, args, { guild, actor, user }) { if (guild && actor) { const ticket = await getTicketByGuild(guild.id, args.ticket); if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.ticket} not found.`); if (!hasAccessToTicket(actor, guild, ticket)) return errorMessage(interaction, "You don't have permission to view this ticket."); const container = actions.getTicketContainer(ticket, !actor.moderator); await interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"] }); return; } if (user) { const ticket = await getTicketByUser(user.id, args.ticket); if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.ticket} not found.`); const container = actions.getTicketContainer(ticket, false); await interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"] }); return; } await errorMessage(interaction, "We couldn't find a ticket with that ID."); }, }); ticketCommand.group( "actions", { description: "Perform ticket actions", }, (group) => { group.subcommand("close", { description: "Close the ticket", async run(interaction) { if (!interaction.guild) return errorMessage(interaction, "This command can only be used in a server."); const modal = await actions.triggerCloseTicket( interaction.user, interaction.guild, interaction.channelId, ); await interaction.showModal(modal); }, }); group.subcommand("reopen", { description: "Reopen a ticket", 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"], }); }, }); group.subcommand("claim", { description: "Claim the ticket", 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.claimTicket({ ticket, ticketChannel, actor, guild }), ); if (result.isErr()) return handleError(interaction, result.unwrapErr()); await interaction.reply({ content: "Ticket claimed!", flags: ["Ephemeral"], }); }, }); group.subcommand("unclaim", { description: "Unclaim a ticket", 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"], }); }, }); group.subcommand("comment", { description: "Add a comment to the ticket", options: { ticket: option.number("The ID of the ticket", { required: false }), comment: option.string("The comment to add", { required: false }), }, async run(interaction, { comment }, { 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"); if (comment) { const result = await Result.fromAsync( actions.addComment({ actor, ticket, guild }, comment), ); if (result.isErr()) return handleError(interaction, result.unwrapErr()); await interaction.reply({ content: `Comment added to ticket.`, flags: ["Ephemeral"], }); return; } await interaction.showModal(TicketModalPresets.comment(ticket.id)); }, }); group.subcommand("edit", { description: "Edit the reasons for closing a ticket", options: { ticket: option.number("The ticket to edit", { required: false }), }, 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, }), ); }, }); group.subcommand("delete", { description: "Delete the ticket channel", 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"); }, }); }, ); ticketCommand.group( "participants", { description: "Manage your ticket data", }, (group) => { group.subcommand("list", { description: "List participants of the ticket", options: { ticket: option.number("The ticket ID", { required: true }), }, async run(interaction, _, { ticket }) { if (!ticket) return errorMessage(interaction, "No ticket found"); const { participants } = ticket; if (participants.length === 0) return errorMessage(interaction, "No participants found"); const container = new ContainerBuilder(); for (const participant of participants) { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `<@${participant.actor?.discordId}> (${participant.role}) - ${participant.reason ?? "No reason provided"}`, ), ); } return interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"], allowedMentions: { parse: [] }, }); }, }); group.subcommand("add", { description: "Add a participant to the ticket", options: { user: option.user("The user to add", { required: true }), reason: option.string("The reason for adding the user", { required: false }), }, async run(interaction, args, { actor, ticket, ticketChannel }) { if (!actor?.moderator) { return errorMessage(interaction, "You must be a moderator to run this command"); } else if (!ticket) { return errorMessage(interaction, "You must be in a ticket to run this command"); } else if (!interaction.guild) { return errorMessage(interaction, "This command can only be used in a guild"); } const participant = await upsertActor({ guild: { discordId: interaction.guild.id, name: interaction.guild.name, icon: interaction.guild.icon, }, user: { discordId: args.user.id, username: args.user.username, displayName: args.user.displayName, avatar: args.user.avatar, }, moderator: false, }); if (!participant) { return errorMessage(interaction, "Failed to add participant"); } await actions.addParticipant( interaction.guild.id, { actor: participant, ticketId: ticket.id, role: "participant", reason: args.reason, }, true, ); await interaction.reply({ content: `Added participant <@${args.user.id}> to the ticket - ${args.reason ?? "no reason provided"}`, }); if (ticketChannel?.type === ChannelType.GuildText) { ticketChannel.edit({ permissionOverwrites: [ { id: args.user.id, type: OverwriteType.Member, allow: ["ViewChannel", "SendVoiceMessages", "AttachFiles"], }, ], }); } }, }); group.subcommand("remove", { description: "Remove a participant from the ticket", options: { user: option.user("The user to add", { required: true }), }, async run(interaction, args, { actor, ticket, ticketChannel }) { if (!actor?.moderator) { return errorMessage(interaction, "You must be a moderator to run this command"); } else if (!ticket) { return errorMessage(interaction, "You must be in a ticket to run this command"); } else if (!interaction.guild) { return errorMessage(interaction, "This command can only be used in a guild"); } const { participants } = ticket; if (!participants) return errorMessage(interaction, "No participants in the ticket"); const participant = participants.find((p) => p.actor?.discordId === args.user.id); if (!participant) return errorMessage(interaction, "Participant not found"); if (participant.role === "subject") return errorMessage(interaction, "You cannot remove the subject from the ticket"); await removeTicketParticipant(ticket.id, participant.id); await interaction.reply({ content: `<@${args.user.id}> was removed from the ticket` }); if (ticketChannel?.type === ChannelType.GuildText) { ticketChannel.edit({ permissionOverwrites: [ { id: args.user.id, type: OverwriteType.Member, deny: ["ViewChannel", "SendVoiceMessages", "AttachFiles"], }, ], }); } }, }); }, ); ticketCommand.group( "manage", { description: "Manage your ticket data", }, (group) => { group.subcommand("list", { description: "List your tickets", async run(interaction, _, { actor }) { if (!actor) return errorMessage(interaction, "Failed to retrieve your user data"); const tickets = await getTicketsByUser(actor.id); if (tickets.length === 0) return errorMessage(interaction, "You have no tickets"); const payload = getTicketsListPayload(actor.id, 1, tickets); return interaction.reply({ ...payload, flags: ["Ephemeral", "IsComponentsV2"], }); }, }); group.subcommand("export", { description: "Export your tickets", async run(interaction, _, { actor }) { if (!actor) return errorMessage(interaction, "Failed to retrieve your user data"); const tickets = await getTicketsByUserGDPR(actor.id); if (tickets.length === 0) return errorMessage(interaction, "You have no tickets"); const fileContent = JSON.stringify(tickets, null, 2); const fileBuffer = Buffer.from(fileContent, "utf-8"); return interaction.reply({ flags: ["Ephemeral"], files: [ { attachment: fileBuffer, name: `tickets_${actor.id}.json`, }, ], }); }, }); group.subcommand("purge", { description: "Purge all your ticket data", async run(interaction, _, { actor, guild }) { if (!actor || !guild) return errorMessage(interaction, "You must be in a server to run this command"); const gActor = getGuildActor(guild.discordId); if (!gActor) return errorMessage(interaction, "Failed to get guild actor"); await purgeUserTicketData(actor.id, gActor.id); return interaction.reply({ content: "Successfully purged your ticket data.", flags: ["Ephemeral"], }); }, }); group.subcommand("delete", { description: "Delete an individual ticket", options: { ticket: option.number("The ID of the ticket to delete", { required: true }), }, async run(interaction, args, { actor, guild }) { if (!actor || !guild) return errorMessage(interaction, "You must be in a server to run this command"); const ticket = await getTicketByUser(actor.id, args.ticket); if (!ticket) return errorMessage( interaction, "Ticket not found or you don't have permission to delete it.", ); const gActor = getGuildActor(guild.discordId); if (!gActor) return errorMessage(interaction, "Failed to get guild actor"); await purgeTicketData(ticket.id, gActor.id); return interaction.reply({ content: `Successfully deleted ticket #${args.ticket}'s data.`, flags: ["Ephemeral"], }); }, }); }, ); ticketCommand.group( "settings", { description: "Manage ticket settings", }, (group) => { // channels & categories group.subcommand("transcript-channel", { description: "Set the channel where transcripts will be sent", options: { channel: option.channel("The channel to send transcripts to", { required: true }), }, async run(interaction, _, { actor }) { if (!actor?.moderator) return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, }); group.subcommand("entrypoint-channel", { description: "Set the channel where the ticket prompt will be sent", options: { channel: option.channel("The channel to send the ticket prompt to", { required: true }), }, async run(interaction, _, { actor }) { if (!actor?.moderator) return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, }); group.subcommand("category", { description: "Sets the category for new tickets", options: { category: option.channel("The category to create new tickets in", { required: true }), }, async run(interaction, _, { actor }) { if (!actor?.moderator) return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, }); // text group.subcommand("prompt", { description: "Set the message sent in the entry point channel", async run(interaction, _, { actor }) { if (!actor?.moderator) return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, }); group.subcommand("greeting", { description: "Set the message sent in new tickets", async run(interaction, _, { actor }) { if (!actor?.moderator) return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, }); // actions group.subcommand("send-prompt", { description: "Send the prompt to the entry point channel", async run(interaction, _, { guild, actor }) { if (!interaction.guild || !guild) return errorMessage(interaction, "You must run this command in a guild"); if (!actor?.moderator) return errorMessage(interaction, "You must be a moderator to run this command"); const commandString = (sub: string) => ``; const channelId = guild.ticketPromptChannel; const promptText = guild.ticketPrompt; if (!channelId) return errorMessage( interaction, `Prompt chnanel not configured. Use ${commandString("entrypoint-channel")} to set it up.`, ); if (!promptText) return errorMessage( interaction, `Ticket prompt not configured. Use ${commandString("prompt")} to set it up.`, ); const channelRes = await Result.fromAsync(() => getTextChannel(channelId)); if (channelRes.isErr()) { const err = channelRes.unwrapErr(); const errorMessageText = err instanceof Error ? err.message : "Unknown error"; return errorMessage(interaction, `Failed to fetch prompt channel: ${errorMessageText}`); } const channel = channelRes.unwrap(); if (!channel.isSendable()) { return errorMessage( interaction, "I don't have permission to send messages in the prompt channel.", ); } const container = new ContainerBuilder() .addTextDisplayComponents(new TextDisplayBuilder().setContent(promptText)) .addActionRowComponents( new ActionRowBuilder().addComponents( TicketButtonPresets.newTicketButton(), ), ); await channel.send({ flags: ["IsComponentsV2"], components: [container] }); return interaction.reply({ content: "Prompt sent!", flags: ["Ephemeral"], }); }, }); }, ); export default ticketCommand;