all repos — stealth-developers @ 887aac5ef1dba4a82a93fdda22415f76f5416da3

bot: ticket closing skeleton
vi did:web:vt3e.cat
Tue, 23 Jun 2026 00:39:38 +0100
commit

887aac5ef1dba4a82a93fdda22415f76f5416da3

parent

445c1b86b7f22d43c98d7b37912e42660b90dd13

M apps/bot/src/commands/tickets/actions.tsapps/bot/src/commands/tickets/actions.ts

@@ -9,7 +9,13 @@ ActionRowBuilder,

} from "discord.js"; import { Result } from "@sapphire/framework"; -import db, { desc, eq, getGuildByDiscordId, tickets } from "@stealth-developers/db"; +import db, { + desc, + eq, + getGuildByDiscordId, + getTicketByChannelId, + tickets, +} from "@stealth-developers/db"; export * from "./config";

@@ -17,6 +23,7 @@ import { upsertUser, getCategory, PublicError, logger } from "@/lib";

import type { AnyUser } from "@/types"; import { ButtonBuilder } from "discord.js"; import TicketButtonPresets from "@/components/buttons/tickets"; +import { TicketModalPresets } from "@/components/modals"; type CreateTicketArgs = | undefined

@@ -131,3 +138,16 @@

const channel = txResult.unwrap(); return { channelId: channel.id, publicId }; } + +export async function triggerCloseTicket(user: AnyUser, guild: Guild, channelId: string) { + const dbGuild = await getGuildByDiscordId(guild.id); + if (!dbGuild) throw new PublicError("Failed to create ticket: Guild not found in database"); + + const dbUser = await upsertUser(user, guild); + if (!dbUser) throw new PublicError("Failed to create ticket: User not found in database"); + + const ticket = await getTicketByChannelId(channelId); + if (!ticket) throw new PublicError("Failed to close ticket: Ticket not found in database"); + + return TicketModalPresets.close(ticket.id, Boolean(dbUser.moderator)); +}
M apps/bot/src/commands/tickets/command.tsapps/bot/src/commands/tickets/command.ts

@@ -76,6 +76,30 @@ },

}); ticketCommand.group( + "actions", + { + description: "Perform ticket actions", + }, + (group) => { + group.subcommand("close", { + description: "Close the ticket", + async run(interaction) { + if (!interaction.guild) + return errorMessage(interaction, "This command can only be used in a server."); + + const modal = await actions.triggerCloseTicket( + interaction.user, + interaction.guild, + interaction.channelId, + ); + + await interaction.showModal(modal); + }, + }); + }, +); + +ticketCommand.group( "manage", { description: "Manage ticket settings",
M apps/bot/src/components/buttons/index.tsapps/bot/src/components/buttons/index.ts

@@ -1,1 +1,4 @@

export { TicketButtons, TicketButtonPresets } from "./tickets"; +import { TicketButtons } from "./tickets"; + +export const AllButtons = [TicketButtons];
M apps/bot/src/components/buttons/tickets/index.tsapps/bot/src/components/buttons/tickets/index.ts

@@ -10,7 +10,18 @@ export const closeTicketButton = kitten.button("close-ticket", {

options: { ticketId: option.string({ required: true }), }, - async run() {}, + async run(interaction) { + if (!interaction.guild) + return errorMessage(interaction, "You must run this command in a guild"); + + const modal = await actions.triggerCloseTicket( + interaction.user, + interaction.guild, + interaction.channelId, + ); + + await interaction.showModal(modal); + }, }); export const deleteTicketButton = kitten.button("delete-ticket", {
M apps/bot/src/components/index.tsapps/bot/src/components/index.ts

@@ -1,2 +1,7 @@

export { configTextModal } from "./modals/configModal"; export { newTicketButton } from "./buttons/newTicket"; + +import { AllModals } from "./modals"; +import { AllButtons } from "./buttons"; + +export const AllComponents = [...AllModals, ...AllButtons];
A apps/bot/src/components/modals/index.ts

@@ -0,0 +1,5 @@

+import { configTextModal } from "./configModal"; +import { TicketModals } from "./tickets"; +export { TicketModalPresets, TicketModals } from "./tickets"; + +export const AllModals = [...TicketModals, configTextModal];
A apps/bot/src/components/modals/tickets/index.ts

@@ -0,0 +1,79 @@

+import { TextInputBuilder, LabelBuilder, ModalBuilder } from "discord.js"; +import { TextInputStyle } from "discord-api-types/v9"; +import { option } from "@purrkit/router"; + +import { editTicket, getTicket } from "@stealth-developers/db"; +import { kitten } from "@/client"; +import { errorMessage, upsertUser } from "@/lib"; + +export const closeTicketModal = kitten.modal("close-ticket", { + options: { + ticketId: option.string({ required: true }), + }, + run: async (interaction, { ticketId }) => { + await interaction.deferReply({ flags: ["Ephemeral"] }); + if (!interaction.guild) + return errorMessage(interaction, "You must run this command in a guild"); + + const ticket = await getTicket(ticketId); + if (!ticket) return errorMessage(interaction, "Ticket not found"); + + const user = await upsertUser(interaction.user, interaction.guild); + if (!user) return errorMessage(interaction, "Failed to upsert user"); + + const reasons = { + public: interaction.fields.getTextInputValue("reason:public"), + private: user.moderator ? interaction.fields.getTextInputValue("reason:private") : undefined, + }; + + const result = await editTicket(ticketId, { + closedAt: new Date(), + closeReason: reasons.public, + privateReason: reasons.private, + closedBy: user.id, + status: "closed", + }); + + if (!result) return errorMessage(interaction, "Failed to close ticket"); + return interaction.followUp({ content: "Ticket closed successfully" }); + }, +}); + +export const TicketModals = [closeTicketModal]; + +export const TicketModalPresets = { + close: (id: string, isModerator: boolean) => { + const description = isModerator + ? "The reason for closing the ticket; this will be shared with the user" + : "The reason for closing the ticket"; + + const reasonLabel = new LabelBuilder() + .setLabel("Reason") + .setDescription(description) + .setTextInputComponent( + new TextInputBuilder() + .setStyle(TextInputStyle.Paragraph) + .setCustomId("reason:public") + .setRequired(true), + ); + + const privateReasonLabel = new LabelBuilder() + .setLabel("Private Reason") + .setDescription("The reason for closing the ticket.") + .setTextInputComponent( + new TextInputBuilder() + .setStyle(TextInputStyle.Paragraph) + .setCustomId("reason:private") + .setRequired(isModerator), + ); + + const labels = isModerator ? [reasonLabel, privateReasonLabel] : [reasonLabel]; + + const modal = new ModalBuilder() + .setCustomId(closeTicketModal.id({ ticketId: id })) + .setTitle("Close ticket") + .addLabelComponents(...labels); + + return modal; + }, +};
M apps/bot/src/index.tsapps/bot/src/index.ts

@@ -2,9 +2,8 @@ import config from "@stealth-developers/config";

import { logger } from "$/logger"; import { client, kitten } from "./client"; -import { configTextModal } from "./components"; +import { AllComponents } from "./components"; import commands from "./commands"; -import { TicketButtons } from "./components/buttons"; const main = async () => { try {

@@ -19,7 +18,7 @@

client.once("clientReady", async (client) => { kitten.register({ commands: commands, - components: [configTextModal, ...TicketButtons], + components: AllComponents, }); await kitten.sync();
M apps/bot/src/middleware/index.tsapps/bot/src/middleware/index.ts

@@ -1,15 +1,14 @@

import { kitten } from "@/client"; import { upsertUser } from "@/lib"; import { HaltExecution } from "@purrkit/router"; -import { getActorByDiscordIdAndGuildDiscordId, upsertActor } from "@stealth-developers/db"; const base = kitten.builder(); export const getDbActor = base.use(async (interaction) => { - if (!interaction.guild) throw new HaltExecution("This command can only be run in a guild."); + if (!interaction.guild) throw new HaltExecution("This command can only be run in a guild."); - const actor = await upsertUser(interaction.user, interaction.guild) - if (!actor) throw new HaltExecution("Failed to add you to the database. Please try again."); + const actor = await upsertUser(interaction.user, interaction.guild); + if (!actor) throw new HaltExecution("Failed to add you to the database. Please try again."); - return actor; -} + return actor; +});