import { randomUUID } from "node:crypto"; import { ChannelType, type TextChannel } from "discord.js"; import { Subcommand } from "@sapphire/plugin-subcommands"; import { ApplyOptions } from "@sapphire/decorators"; import { container, Result } from "@sapphire/framework"; import db, { eq, desc, tickets } from "@stealth-developers/db"; import config from "@stealth-developers/config"; import { getCategory, PublicError } from "@/lib"; import { upsertUser } from "$/db"; import { errorMessage } from "@/lib/interactions"; @ApplyOptions({ description: "ticket-related commands", }) export class TicketCommand extends Subcommand { public constructor(context: Subcommand.LoaderContext, options: Subcommand.Options) { super(context, { ...options, name: "ticket", subcommands: [ { name: "new", chatInputRun: "createTicket" }, { name: "for", chatInputRun: "createTicketFor" }, { name: "actions", type: "group", entries: [ { name: "open", chatInputRun: "openTicket" }, { name: "close", chatInputRun: "closeTicket" }, { name: "add", chatInputRun: "addToTicket" }, { name: "claim", chatInputRun: "claimTicket" }, { name: "unclaim", chatInputRun: "unclaimTicket" }, { name: "delete", chatInputRun: "deleteTicket" }, ], }, { name: "view", type: "group", entries: [ { name: "info", chatInputRun: "viewTicketInfo" }, { name: "transcript", chatInputRun: "viewTicketTranscript" }, { name: "list", chatInputRun: "listTickets" }, { name: "export", chatInputRun: "exportTickets" }, ], }, ], }); } public override registerApplicationCommands(registry: Subcommand.Registry) { registry.registerChatInputCommand((builder) => builder .setName("ticket") .setDescription("root ticket command") .addSubcommand((command) => command.setName("new").setDescription("Open a new ticket")) .addSubcommand((command) => command .setName("for") .setDescription("Open a ticket for a user") .addUserOption((option) => option .setName("user") .setDescription("The user to open the ticket for") .setRequired(true), ), ) .addSubcommandGroup((group) => group .setName("action") .setDescription("ticket actions") .addSubcommand((command) => command.setName("open").setDescription("Reopen the ticket in the current channel"), ) .addSubcommand((command) => command .setName("close") .setDescription("Close the ticket in the current channel") .addStringOption((option) => option .setName("reason") .setDescription("Public reason for closing the ticket") .setRequired(true), ) .addStringOption((option) => option .setName("private") .setDescription( "Private reason for closing the ticket, only visible to moderators", ) .setRequired(false), ) .addBooleanOption((option) => option.setName("delete-channel").setDescription("Delete the channel immediately"), ), ), ), ); } private async doCreateTicket(interaction: Subcommand.ChatInputCommandInteraction) { if (!interaction.guild) return errorMessage(interaction, "Failed to create ticket: Guild not found"); const isOnBehalf = interaction.options.getSubcommand(true) === "for"; const publicId = randomUUID(); if (!interaction.member) return errorMessage(interaction, "Failed to create ticket: Member not found"); const creator = await upsertUser(interaction.member, interaction.guild); if (!creator) return errorMessage(interaction, "Failed to create ticket: Creator not found"); const subject = isOnBehalf ? interaction.options.getUser("user") : interaction.user; const subjectUser = subject ? await upsertUser(subject, interaction.guild!) : creator; if (!subjectUser) return errorMessage(interaction, "Failed to create ticket: Subject user not found"); if (isOnBehalf && !creator.moderator) return errorMessage( interaction, "You don't have permission to create a ticket for another user.", ); const ticketCategory = await getCategory(config.discord.server.ticket_category); let createdChannel: TextChannel | undefined = undefined; const txResult = await Result.fromAsync(async () => { return await db.transaction(async (tx) => { const [maxTicket] = await tx .select({ localId: tickets.localId }) .from(tickets) .where(eq(tickets.guildId, creator.guildId)) .orderBy(desc(tickets.localId)) .limit(1); const localId = (maxTicket?.localId ?? 0) + 1; const [ticket] = await tx .insert(tickets) .values({ id: publicId, guildId: creator.guildId, localId: localId, status: "open", openedAt: new Date(), openedBy: creator.id, subject: subjectUser.id, }) .returning(); if (!ticket) throw new PublicError("failed to add ticket to database."); const ticketNumber = ticket.localId; const paddedTicketNumber = String(ticketNumber).padStart(4, "0"); const channel = await interaction.guild?.channels.create({ name: `ticket-${paddedTicketNumber}`, type: ChannelType.GuildText, parent: ticketCategory, }); if (!channel) throw new PublicError("failed to create Discord channel."); createdChannel = channel; await tx.update(tickets).set({ channelId: channel.id }).where(eq(tickets.id, ticket.id)); await channel.send({ content: `Hi! Thanks for submitting a report.` }); return channel.id; }); }); if (txResult.isErr()) { if (createdChannel) await Result.fromAsync(() => createdChannel?.delete()); const error = txResult.unwrapErr(); const message = error instanceof PublicError ? `Failed to create ticket: ${error.message}` : "There was an error creating the ticket."; if (error instanceof Error) this.container.logger.error(error.message, error.stack); return errorMessage(interaction, message); } const channelId = txResult.unwrap(); interaction.reply({ content: `Ticket created! See <#${channelId}>`, flags: ["Ephemeral"], }); } public async createTicket(interaction: Subcommand.ChatInputCommandInteraction) { await this.doCreateTicket(interaction); } public async createTicketFor(interaction: Subcommand.ChatInputCommandInteraction) { await this.doCreateTicket(interaction); } public async openTicket(interaction: Subcommand.ChatInputCommandInteraction) { interaction.reply({ content: "Ticket opened!" }); } public async closeTicket(interaction: Subcommand.ChatInputCommandInteraction) { interaction.reply({ content: "Ticket closed!" }); } } container.stores.loadPiece({ piece: TicketCommand, name: "ticket", store: "commands", });