bot: send ticket prompt
vi did:web:vt3e.cat
Sat, 06 Jun 2026 21:12:33 +0100
3 files changed,
205 insertions(+),
7 deletions(-)
M
apps/bot/.gitignore
→
apps/bot/.gitignore
@@ -13,9 +13,6 @@ .DS_Store
dist/ -# Ignore the config file (contains sensitive information such as tokens) -config.ts - # Ignore heapsnapshot and log files *.heapsnapshot *.log@@ -27,4 +24,4 @@ # Environment variables
.env.local .env.development.local .env.test.local -.env.production.local+.env.production.local
A
apps/bot/src/commands/tickets/config.ts
@@ -0,0 +1,126 @@
+import { + ModalBuilder, + TextInputBuilder, + TextInputStyle, + LabelBuilder, + MessageFlags, +} from "discord.js"; +import type { Subcommand } from "@sapphire/plugin-subcommands"; +import db, { eq, getGuildByDiscordId, guilds } from "@stealth-developers/db"; + +import { errorMessage } from "@/lib"; +import { modalRegistry } from "$/structures/ModalRegistry"; +import { ModalDefinition } from "$/structures/ModalDefinition"; + +type GuildKeys = keyof typeof guilds.$inferSelect; +type ConfigOptionBase = { + key: GuildKeys; + type: "channel" | "category" | "text"; +}; + +type ChannelConfigOption = ConfigOptionBase & { type: "channel" }; +type CategoryConfigOption = ConfigOptionBase & { type: "category" }; +type TextConfigOption = ConfigOptionBase & { type: "text"; title: string; description: string }; +type ConfigOption = ChannelConfigOption | CategoryConfigOption | TextConfigOption; + +const configOptions = { + category: { type: "category", key: "ticketCategory" }, + transcripts: { type: "channel", key: "ticketLogChannel" }, + "entrypoint-channel": { type: "channel", key: "ticketPromptChannel" }, + prompt: { + type: "text", + key: "ticketPrompt", + title: "Ticket Prompt", + description: "Set the message sent in the entry point channel", + }, + greeting: { + type: "text", + key: "ticketGreeting", + title: "Ticket Greeting", + description: "Set the message sent in new tickets", + }, +} satisfies Record<string, ConfigOption>; + +type ConfigSubcommand = keyof typeof configOptions; + +export async function manageConfig(interaction: Subcommand.ChatInputCommandInteraction) { + if (!interaction.inGuild()) + return errorMessage(interaction, "This command can only be used in a server."); + + const sub = interaction.options.getSubcommand(true); + const guild = await getGuildByDiscordId(interaction.guildId); + if (!guild) return errorMessage(interaction, "Failed to update configuration: Guild not found."); + + if (!(sub in configOptions)) return errorMessage(interaction, "Invalid subcommand."); + const subcommand = sub as ConfigSubcommand; + const option = configOptions[subcommand]; + if (!option) return errorMessage(interaction, "Invalid configuration option."); + + if (option.type === "text") { + const targetKey = option.key; + + const customId = ConfigTextModal.create(targetKey); + const currentValue = guild[targetKey] ?? ""; + + const modal = new ModalBuilder().setCustomId(customId).setTitle(option.title); + + modal.addLabelComponents( + new LabelBuilder() + .setLabel(option.title) + .setDescription(option.description) + .setTextInputComponent( + new TextInputBuilder() + .setCustomId("text-value") + .setStyle(TextInputStyle.Paragraph) + .setValue(currentValue) + .setRequired(true), + ), + ); + + return interaction.showModal(modal, { withResponse: true }); + } + + const channel = interaction.options.getChannel("channel", option.type === "channel"); + const category = interaction.options.getChannel("category", option.type === "category"); + + const [res] = await db + .update(guilds) + .set({ + [option.key]: option.type === "channel" ? channel?.id : category?.id, + }) + .where(eq(guilds.id, guild.id)) + .returning(); + if (!res) return errorMessage(interaction, "Failed to update configuration."); + + return interaction.reply({ + content: "Configuration updated successfully!", + flags: ["Ephemeral"], + }); +} + +export const ConfigTextModal = new ModalDefinition< + "config-text", + [key: "ticketPrompt" | "ticketGreeting"] +>("config-text"); + +modalRegistry.register(ConfigTextModal, async (interaction, [key]) => { + const value = interaction.fields.getTextInputValue("text-value"); + + if (!interaction.guildId) + return errorMessage(interaction, "This interaction can only be used in a server."); + const guild = await getGuildByDiscordId(interaction.guildId); + if (!guild) return errorMessage(interaction, "Failed to update configuration: Guild not found."); + + const [res] = await db + .update(guilds) + .set({ [key]: value }) + .where(eq(guilds.id, guild.id)) + .returning(); + + if (!res) return errorMessage(interaction, "Failed to update configuration."); + + return interaction.reply({ + content: "Configuration updated successfully!", + flags: [MessageFlags.Ephemeral], + }); +});
M
apps/bot/src/commands/tickets/index.ts
→
apps/bot/src/commands/tickets/index.ts
@@ -3,7 +3,17 @@ import { ApplyOptions } from "@sapphire/decorators";
import { container } from "@sapphire/framework"; import * as actions from "./actions"; -import { ChannelType } from "discord.js"; +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"; @ApplyOptions<Subcommand.Options>({ description: "ticket-related commands",@@ -53,13 +63,18 @@ chatInputRun: "manageConfig",
preconditions: ["ModeratorCheck"], }, { - name: "ticket-category", + name: "category", chatInputRun: "manageConfig", preconditions: ["ModeratorCheck"], }, { name: "prompt", chatInputRun: "manageConfig", + preconditions: ["ModeratorCheck"], + }, + { + name: "send-prompt", + chatInputRun: "sendPrompt", preconditions: ["ModeratorCheck"], }, {@@ -150,7 +165,7 @@ ),
) .addSubcommand((command) => command - .setName("ticket-category") + .setName("category") .setDescription("Sets the category for new tickets") .addChannelOption((option) => option@@ -166,6 +181,11 @@ .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"), ), ),@@ -181,6 +201,61 @@ }
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("open-ticket"); + + 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"], + }); } }