bot: ticket config command; modal registry & handling
vi did:web:vt3e.cat
Sat, 06 Jun 2026 02:52:17 +0100
12 files changed,
438 insertions(+),
220 deletions(-)
jump to
A
apps/bot/src/commands/tickets/actions.ts
@@ -0,0 +1,104 @@
+import { randomUUID } from "node:crypto"; +import { TextChannel, ChannelType } from "discord.js"; +import { container, Result } from "@sapphire/framework"; +import type { Subcommand } from "@sapphire/plugin-subcommands"; + +import db, { desc, eq, tickets } from "@stealth-developers/db"; +import config from "@stealth-developers/config"; + +export * from "./config"; + +import { errorMessage, upsertUser, getCategory, PublicError } from "@/lib"; + +export async function createTicket(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) container.logger.error(error.message, error.stack); + + return errorMessage(interaction, message); + } + + const channelId = txResult.unwrap(); + interaction.reply({ + content: `Ticket created! See <#${channelId}>`, + flags: ["Ephemeral"], + }); +}
D
apps/bot/src/commands/tickets/command.ts
@@ -1,214 +0,0 @@
-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<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")) - .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", -});
M
apps/bot/src/commands/tickets/index.ts
→
apps/bot/src/commands/tickets/index.ts
@@ -1,1 +1,191 @@
-export * from "./command"; +import { Subcommand } from "@sapphire/plugin-subcommands"; +import { ApplyOptions } from "@sapphire/decorators"; +import { container } from "@sapphire/framework"; + +import * as actions from "./actions"; +import { ChannelType } from "discord.js"; + +@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", preconditions: ["ModeratorCheck"] }, + { + 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" }, + ], + }, + { + name: "manage", + type: "group", + entries: [ + { + name: "transcript-channel", + chatInputRun: "manageConfig", + preconditions: ["ModeratorCheck"], + }, + { + name: "entrypoint-channel", + chatInputRun: "manageConfig", + preconditions: ["ModeratorCheck"], + }, + { + name: "ticket-category", + chatInputRun: "manageConfig", + preconditions: ["ModeratorCheck"], + }, + { + name: "prompt", + chatInputRun: "manageConfig", + preconditions: ["ModeratorCheck"], + }, + { + name: "greeting", + chatInputRun: "manageConfig", + preconditions: ["ModeratorCheck"], + }, + ], + }, + ], + }); + } + + 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((subcommandGroup) => + subcommandGroup + .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"), + ), + ), + ) + .addSubcommandGroup((subcommandGroup) => + subcommandGroup + .setName("manage") + .setDescription("ticket management commands") + .addSubcommand((command) => + command + .setName("transcript-channel") + .setDescription("Sets the channel for ticket transcripts") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel to send ticket transcripts to") + .setRequired(true) + .addChannelTypes(ChannelType.GuildText), + ), + ) + .addSubcommand((command) => + command + .setName("entrypoint-channel") + .setDescription("Sets the entry point channel for ticket creation") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel to set as the entry point") + .setRequired(true) + .addChannelTypes(ChannelType.GuildText), + ), + ) + .addSubcommand((command) => + command + .setName("ticket-category") + .setDescription("Sets the category for new tickets") + .addChannelOption((option) => + option + .setName("category") + .setDescription("The category to create new tickets in") + .setRequired(true) + .addChannelTypes(ChannelType.GuildCategory), + ), + ) + .addSubcommand((command) => + command + .setName("prompt") + .setDescription("Set the message sent in the entry point channel"), + ) + .addSubcommand((command) => + command.setName("greeting").setDescription("Set the message sent in new tickets"), + ), + ), + ); + } + + public async createTicket(interaction: Subcommand.ChatInputCommandInteraction) { + await actions.createTicket(interaction); + } + public async createTicketFor(interaction: Subcommand.ChatInputCommandInteraction) { + await actions.createTicket(interaction); + } + + public async manageConfig(interaction: Subcommand.ChatInputCommandInteraction) { + await actions.manageConfig(interaction); + } +} + +container.stores.loadPiece({ + piece: TicketCommand, + name: "ticket", + store: "commands", +});
A
apps/bot/src/handlers/modals.ts
@@ -0,0 +1,43 @@
+import { InteractionHandler, InteractionHandlerTypes, container } from "@sapphire/framework"; +import type { ModalSubmitInteraction } from "discord.js"; +import { modalRegistry } from "$/structures/ModalRegistry"; + +export class ModalHandler extends InteractionHandler { + public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { + super(ctx, { + ...options, + interactionHandlerType: InteractionHandlerTypes.ModalSubmit, + }); + } + + public override parse(interaction: ModalSubmitInteraction) { + const [id] = interaction.customId.split(":"); + if (!id) return this.none(); + const registered = modalRegistry.get(id); + + if (!registered) return this.none(); + + const args = registered.definition.parse(interaction.customId); + if (!args) return this.none(); + + return this.some({ registered, args }); + } + + public async run( + interaction: ModalSubmitInteraction, + { registered, args }: { registered: any; args: string[] }, + ) { + console.log("Running modal interaction with ID:", registered.definition.id); + try { + await registered.run(interaction, args); + } catch (error) { + this.container.logger.error("Failed to run modal interaction:", error); + } + } +} + +container.stores.loadPiece({ + name: "ModalHandler", + piece: ModalHandler, + store: "interaction-handlers", +});
M
apps/bot/src/index.ts
→
apps/bot/src/index.ts
@@ -3,9 +3,10 @@ import { LogLevel, SapphireClient } from "@sapphire/framework";
import config from "@stealth-developers/config"; import { PinoSapphireLogger } from "$/logger"; +import "./preconditions"; +import "./listeners"; import "./commands"; -import "./listeners"; -import "./preconditions"; +import "./handlers"; const client = new SapphireClient({ logger: {
M
apps/bot/src/lib/db.ts
→
apps/bot/src/lib/db.ts
@@ -14,20 +14,20 @@
if ("user" in input) { const member = input; const user = member.user; - discordId = user.id; username = user.username; + const nick = member instanceof GuildMember ? member.nickname : member.nick; const globalName = user instanceof User ? user.globalName : user.global_name; displayName = nick || globalName || user.username; avatar = member.avatar || user.avatar; if (member instanceof GuildMember) { - isModerator = member.permissions.has("Administrator"); + isModerator = member.permissions.has("ModerateMembers"); } else if (member.permissions) { const { PermissionsBitField } = await import("discord.js"); const perms = new PermissionsBitField(BigInt(member.permissions)); - isModerator = perms.has("Administrator"); + isModerator = perms.has("ModerateMembers"); } } else { const user = input;
M
apps/bot/src/lib/index.ts
→
apps/bot/src/lib/index.ts
@@ -3,6 +3,7 @@ export * from "./constants";
export * from "./error"; export * from "./logger"; export * from "./permissions"; +export * from "./interactions"; export * from "./setup"; export * from "./utils"; export * from "./db";
A
apps/bot/src/lib/structures/ModalDefinition.ts
@@ -0,0 +1,17 @@
+export class ModalDefinition<Id extends string, Args extends string[]> { + public readonly id: Id; + + public constructor(id: Id) { + this.id = id; + } + public create(...args: Args): string { + return args.length > 0 ? `${this.id}:${args.join(":")}` : this.id; + } + + public parse(customId: string): Args | null { + const parts = customId.split(":"); + if (parts[0] !== this.id) return null; + + return parts.slice(1) as unknown as Args; + } +}
A
apps/bot/src/lib/structures/ModalRegistry.ts
@@ -0,0 +1,24 @@
+import type { ModalSubmitInteraction } from "discord.js"; +import type { ModalDefinition } from "./ModalDefinition"; + +export interface RegisteredModal<Args extends string[]> { + definition: ModalDefinition<any, Args>; + run(interaction: ModalSubmitInteraction, args: Args): unknown | Promise<unknown>; +} + +class ModalRegistry { + private modals = new Map<string, RegisteredModal<any>>(); + + public register<Args extends string[]>( + definition: ModalDefinition<any, Args>, + run: (interaction: ModalSubmitInteraction, args: Args) => unknown | Promise<unknown>, + ) { + this.modals.set(definition.id, { definition, run }); + } + + public get(id: string) { + return this.modals.get(id); + } +} + +export const modalRegistry = new ModalRegistry();
M
apps/bot/src/preconditions/index.ts
→
apps/bot/src/preconditions/index.ts
@@ -1,1 +1,2 @@
import "./guild"; +import "./moderator";
A
apps/bot/src/preconditions/moderator.ts
@@ -0,0 +1,50 @@
+import type { ChatInputCommandInteraction, ContextMenuCommandInteraction } from "discord.js"; +import { container, Precondition } from "@sapphire/framework"; +import { getActorByDiscordIdAndGuildDiscordId } from "@stealth-developers/db"; + +export class ModeratorCheckPrecondition extends Precondition { + public override async chatInputRun(interaction: ChatInputCommandInteraction) { + if (!interaction.inGuild()) + return this.error({ message: "This command can only be used in a server." }); + + const actor = await getActorByDiscordIdAndGuildDiscordId( + interaction.user.id, + interaction.guildId, + ); + if (actor?.moderator) return this.ok(); + + return this.error({ + message: "This command can only be usedby moderators.", + identifier: "MODERATOR_ONLY", + }); + } + + public override async contextMenuRun(interaction: ContextMenuCommandInteraction) { + if (!interaction.inGuild()) + return this.error({ message: "This command can only be used in a server." }); + + const actor = await getActorByDiscordIdAndGuildDiscordId( + interaction.user.id, + interaction.guildId, + ); + + if (actor?.moderator) return this.ok(); + + return this.error({ + message: "This option can only be usedby moderators.", + identifier: "MODERATOR_ONLY", + }); + } +} + +declare module "@sapphire/framework" { + interface Preconditions { + ModeratorCheck: never; + } +} + +container.stores.loadPiece({ + name: "ModeratorCheck", + piece: ModeratorCheckPrecondition, + store: "preconditions", +});