bot: button presets
vi did:web:vt3e.cat
Mon, 22 Jun 2026 02:03:08 +0100
8 files changed,
173 insertions(+),
334 deletions(-)
A
apps/bot/src/commands/tickets/README.md
@@ -0,0 +1,24 @@
+commands: + +- [ ] /ticket + - [x] new + - [x] for + - [ ] actions + - [ ] open + - [ ] close + - [ ] add + - [ ] claim + - [ ] unclaim + - [ ] delete + - [ ] view + - [ ] info + - [ ] transcript + - [ ] list + - [ ] export + - [x] manage + - [x] transcript-channel + - [x] entrypoint-channel + - [x] category + - [x] prompt + - [x] send-prompt + - [x] greeting
D
apps/bot/src/commands/tickets/_index.ts
@@ -1,267 +0,0 @@
-import { Subcommand } from "@sapphire/plugin-subcommands"; -import { ApplyOptions } from "@sapphire/decorators"; -import { container } from "@sapphire/framework"; - -import * as actions from "./actions"; -import { - ButtonStyle, - ButtonBuilder, - ChannelType, - ContainerBuilder, - TextDisplayBuilder, -} from "discord.js"; -import { getGuildByDiscordId } from "@stealth-developers/db"; -import { errorMessage, getTextChannel } from "@/lib"; -import { Result } from "@sapphire/framework"; -import { ActionRowBuilder } from "discord.js"; -import { newTicketButton } from "@/components/NewTicketButton"; - -@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: "category", - chatInputRun: "manageConfig", - preconditions: ["ModeratorCheck"], - }, - { - name: "prompt", - chatInputRun: "manageConfig", - preconditions: ["ModeratorCheck"], - }, - { - name: "send-prompt", - chatInputRun: "sendPrompt", - 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("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("send-prompt") - .setDescription("Send the prompt to 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); - } - - public async sendPrompt(interaction: Subcommand.ChatInputCommandInteraction) { - if (!interaction.guildId) - return errorMessage(interaction, "This command can only be used in a server."); - const guild = await getGuildByDiscordId(interaction.guildId); - if (!guild) return errorMessage(interaction, "Guild not configured."); - - const commandString = (sub: string) => `</ticket manage ${sub}:${interaction.commandId}>`; - - 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 button = new ButtonBuilder() - .setLabel("Open a Ticket") - .setStyle(ButtonStyle.Success) - .setCustomId(newTicketButton.id()); - - const container = new ContainerBuilder() - .addTextDisplayComponents(new TextDisplayBuilder().setContent(promptText)) - .addActionRowComponents(new ActionRowBuilder<ButtonBuilder>().addComponents(button)); - - await channel.send({ flags: ["IsComponentsV2"], components: [container] }); - return interaction.reply({ - content: "Prompt sent!", - flags: ["Ephemeral"], - }); - } -} - -container.stores.loadPiece({ - piece: TicketCommand, - name: "ticket", - store: "commands", -});
M
apps/bot/src/commands/tickets/actions.ts
→
apps/bot/src/commands/tickets/actions.ts
@@ -1,13 +1,22 @@
import { randomUUID } from "node:crypto"; -import { TextChannel, ChannelType, type Guild } from "discord.js"; -import { container, Result } from "@sapphire/framework"; +import { + TextChannel, + ChannelType, + type Guild, + ContainerBuilder, + TextDisplayBuilder, + ActionRowBuilder, +} from "discord.js"; +import { Result } from "@sapphire/framework"; import db, { desc, eq, getGuildByDiscordId, tickets } from "@stealth-developers/db"; export * from "./config"; -import { upsertUser, getCategory, PublicError } from "@/lib"; +import { upsertUser, getCategory, PublicError, logger } from "@/lib"; import type { AnyUser } from "@/types"; +import { ButtonBuilder } from "discord.js"; +import TicketButtonPresets from "@/components/buttons/tickets"; type CreateTicketArgs = | undefined@@ -82,10 +91,26 @@
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.` }); + const greeting = dbGuild.ticketGreeting; + if (greeting) { + const container = new ContainerBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder({ + content: greeting.replaceAll("{user}", `<@${subjectUser.discordId}>`), + }), + ) + .addActionRowComponents( + new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.claim(publicId), + TicketButtonPresets.close(publicId), + ), + ); - return channel.id; + channel.send({ components: [container], flags: ["IsComponentsV2"] }); + } + + await tx.update(tickets).set({ channelId: channel.id }).where(eq(tickets.id, ticket.id)); + return channel; }); });@@ -98,11 +123,11 @@ 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); + if (error instanceof Error) logger.error(error.stack, error.message); throw new PublicError(message); } - const channelId = txResult.unwrap(); - return { channelId, publicId }; + const channel = txResult.unwrap(); + return { channelId: channel.id, publicId }; }
M
apps/bot/src/commands/tickets/command.ts
→
apps/bot/src/commands/tickets/command.ts
@@ -1,18 +1,12 @@
-import { kitten } from "@/client"; import { option } from "@purrkit/router"; import { Result } from "@sapphire/result"; +import { ButtonBuilder, ContainerBuilder, TextDisplayBuilder, ActionRowBuilder } from "discord.js"; +import { getGuildByDiscordId } from "@stealth-developers/db"; -import * as actions from "./actions"; +import { kitten } from "@/client"; import { errorMessage, getTextChannel, PublicError } from "@/lib"; -import { getGuildByDiscordId } from "@stealth-developers/db"; -import { newTicketButton } from "@/components"; -import { - ButtonBuilder, - ButtonStyle, - ContainerBuilder, - TextDisplayBuilder, - ActionRowBuilder, -} from "discord.js"; +import { TicketButtonPresets } from "@/components/buttons"; +import * as actions from "./actions"; const ticketCommand = kitten.command("ticket", { description: "Ticket-related commands",@@ -126,6 +120,14 @@ await actions.manageConfig(interaction);
}, }); + group.subcommand("greeting", { + description: "Set the message sent in new tickets", + async run(interaction) { + await actions.manageConfig(interaction); + }, + }); + + // actions group.subcommand("send-prompt", { description: "Send the prompt to the entry point channel", async run(interaction) {@@ -166,27 +168,17 @@ "I don't have permission to send messages in the prompt channel.",
); } - const button = new ButtonBuilder() - .setLabel("Open a Ticket") - .setStyle(ButtonStyle.Success) - .setCustomId(newTicketButton.id()); - const container = new ContainerBuilder() .addTextDisplayComponents(new TextDisplayBuilder().setContent(promptText)) - .addActionRowComponents(new ActionRowBuilder<ButtonBuilder>().addComponents(button)); + .addActionRowComponents( + new ActionRowBuilder<ButtonBuilder>().addComponents(TicketButtonPresets.create()), + ); await channel.send({ flags: ["IsComponentsV2"], components: [container] }); return interaction.reply({ content: "Prompt sent!", flags: ["Ephemeral"], }); - }, - }); - - group.subcommand("greeting", { - description: "Set the message sent in new tickets", - async run(interaction) { - await actions.manageConfig(interaction); }, }); },
M
apps/bot/src/index.ts
→
apps/bot/src/index.ts
@@ -2,8 +2,9 @@ import config from "@stealth-developers/config";
import { logger } from "$/logger"; import { client, kitten } from "./client"; -import { configTextModal, newTicketButton } from "./components"; +import { configTextModal } from "./components"; import commands from "./commands"; +import { TicketButtons } from "./components/buttons"; const main = async () => { try {@@ -18,7 +19,7 @@
client.once("clientReady", async (client) => { kitten.register({ commands: commands, - components: [configTextModal, newTicketButton], + components: [configTextModal, ...TicketButtons], }); await kitten.sync();