bot/tickets: ticket creation
vi did:web:vt3e.cat
Wed, 27 May 2026 02:08:51 +0100
7 files changed,
240 insertions(+),
99 deletions(-)
M
apps/bot/src/commands/index.ts
→
apps/bot/src/commands/index.ts
@@ -1,2 +1,2 @@
export * from "./meow.ts"; -export * from "./ticket.ts"; +export * from "./tickets";
D
apps/bot/src/commands/ticket.ts
@@ -1,98 +0,0 @@
-import { Subcommand } from "@sapphire/plugin-subcommands"; -import { ApplyOptions } from "@sapphire/decorators"; -import { container } from "@sapphire/framework"; - -@ApplyOptions<Subcommand.Options>({ - 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")) - .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"), - ), - ), - ), - ); - } - - public async createTicket(interaction: Subcommand.ChatInputCommandInteraction) { - interaction.reply({ content: "Ticket created!" }); - } - - 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", -});
A
apps/bot/src/commands/tickets/command.ts
@@ -0,0 +1,203 @@
+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, tickets, upsertUser } from "@stealth-developers/db"; +import config from "@stealth-developers/config"; +import { getCategory, PublicError } from "@/lib"; + +@ApplyOptions<Subcommand.Options>({ + 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")) + .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"), + ), + ), + ), + ); + } + + public async createTicket(interaction: Subcommand.ChatInputCommandInteraction) { + const publicId = randomUUID(); + + const userResult = await Result.fromAsync(() => + upsertUser({ + id: interaction.user.id, + avatar: interaction.member?.avatar || interaction.user.avatar, + username: interaction.user.username, + displayName: interaction.user.displayName, + }), + ); + + if (userResult.isErr()) { + const err = userResult.unwrapErr(); + const message = err instanceof Error ? err.message : String(err); + return interaction.reply({ + content: `Failed to create ticket: ${message}`, + flags: ["Ephemeral"], + }); + } + + const user = userResult.unwrap(); + if (!user) { + return interaction.reply({ + content: "Failed to create ticket: User not found", + flags: ["Ephemeral"], + }); + } + + const catResult = await Result.fromAsync(() => + getCategory(config.discord.server.ticket_category), + ); + if (catResult.isErr()) { + const err = catResult.unwrapErr(); + const message = err instanceof Error ? err.message : String(err); + return interaction.reply({ + content: `Failed to create ticket: ${message}`, + flags: ["Ephemeral"], + }); + } + + const ticketCategory = catResult.unwrap(); + let createdChannel: TextChannel | undefined = undefined; + + const txResult = await Result.fromAsync(async () => { + return await db.transaction(async (tx) => { + const [ticket] = await tx + .insert(tickets) + .values({ + anonymousId: publicId, + status: "open", + openedAt: new Date(), + openedBy: user.id, + subject: user.id, + }) + .returning(); + + if (!ticket) throw new PublicError("failed to add ticket to database."); + + const ticketNumber = ticket.id; + 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 interaction.reply({ + content: message, + flags: ["Ephemeral"], + }); + } + + const channelId = txResult.unwrap(); + return interaction.reply({ + content: `Ticket created! See <#${channelId}>`, + flags: ["Ephemeral"], + }); + } + + 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", +});
A
apps/bot/src/lib/channels.ts
@@ -0,0 +1,22 @@
+import { container } from "@sapphire/framework"; +import { ChannelType } from "discord.js"; + +export async function getTextChannel(id: string, mustBeSendable = true) { + const channel = await container.client.channels.fetch(id); + if (!channel) throw new Error(`Channel not found: ${id}`); + if (!channel.isTextBased()) throw new Error(`Channel is not text-based: ${id}`); + + if (!mustBeSendable) return channel; + if (!channel.isSendable()) throw new Error(`Channel is not sendable: ${id}`); + + return channel; +} + +export async function getCategory(id: string) { + const channel = await container.client.channels.fetch(id); + if (!channel) throw new Error(`Category not found: ${id}`); + if (channel.type !== ChannelType.GuildCategory) + throw new Error(`Channel is not a category: ${id}`); + + return channel; +}
A
apps/bot/src/lib/error.ts
@@ -0,0 +1,6 @@
+export class PublicError extends Error { + constructor(message: string) { + super(message); + this.name = "PublicError"; + } +}
A
apps/bot/src/lib/index.ts
@@ -0,0 +1,7 @@
+export * from "./channels"; +export * from "./constants"; +export * from "./error"; +export * from "./logger"; +export * from "./permissions"; +export * from "./setup"; +export * from "./utils";