all repos — stealth-developers @ 212c942ae395a7b4812d0ad897c3d128b2260413

bot: ticket view command
vi did:web:vt3e.cat
Fri, 26 Jun 2026 10:56:28 +0100
commit

212c942ae395a7b4812d0ad897c3d128b2260413

parent

529dba23bb15864af72a10bbf4162a6406dd9294

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

@@ -215,12 +215,12 @@ /**

* @param ticket the ticket to get the container for * @param limitedDetails whether to exclude comments & other internal details */ -export async function getTicketContainer(ticket: TicketWithRelations, limitedDetails = false) { +export function getTicketContainer(ticket: TicketWithRelations, limitedDetails = false) { const container = new ContainerBuilder(); container.addTextDisplayComponents(text(`# Ticket #${ticket.localId} - ${ticket.status}`)); let closedBySubject = true; - if (ticket.closedByActor && ticket.subjectActor) { + if (ticket.status !== "open" && ticket.closedByActor && ticket.subjectActor) { closedBySubject = ticket.subjectActor.id === ticket.closedByActor.id; container.addTextDisplayComponents(

@@ -243,7 +243,9 @@

container.addTextDisplayComponents(text(`-# ${ticket.id}`)); const actionRow = new ActionRowBuilder<ButtonBuilder>(); - if (closedBySubject) actionRow.addComponents(TicketButtonPresets.thank(ticket.id)); + if (limitedDetails) actionRow.addComponents(TicketButtonPresets.thank(ticket.id)); + if (!limitedDetails) + actionRow.addComponents(TicketButtonPresets.comments(ticket.id, ticket.comments.length)); actionRow.addComponents(TicketButtonPresets.transcript(ticket.id)); container.addActionRowComponents(actionRow);
M apps/bot/src/feats/tickets/buttons.tsapps/bot/src/feats/tickets/buttons.ts

@@ -116,15 +116,15 @@ new ButtonBuilder()

.setCustomId(thankButton.id({ ticketId: id })) .setLabel("Thank your moderator") .setEmoji("💖") - .setStyle(ButtonStyle.Primary), - comments: (id: string) => + .setStyle(ButtonStyle.Success), + comments: (id: string, quantity: number) => new ButtonBuilder() .setCustomId(commentsButton.id({ ticketId: id })) - .setLabel("View comments") - .setStyle(ButtonStyle.Primary), + .setLabel(`View Comments (≥${quantity})`) + .setStyle(ButtonStyle.Secondary), transcript: (id: string) => new ButtonBuilder() - .setLabel("View Transcript") + .setLabel("View on Web") .setURL(`https://tickets.vt3e.cat/tickets/${id}`) .setStyle(ButtonStyle.Link), create: () =>
M apps/bot/src/feats/tickets/commands.tsapps/bot/src/feats/tickets/commands.ts

@@ -1,15 +1,16 @@

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 { getTicketByGuild, getTicketByUser } from "@stealth-developers/db"; -import { kitten } from "@/client"; import { errorMessage, getTextChannel, logger, PublicError } from "@/lib"; import { TicketButtonPresets } from "./buttons"; import * as actions from "./actions"; import { manageConfig } from "./config"; +import { getBasicData } from "@/middleware"; +import { hasAccessToTicket } from "@/lib/tickets"; -const ticketCommand = kitten.command("ticket", { +const ticketCommand = getBasicData.command("ticket", { description: "Ticket-related commands", });

@@ -76,6 +77,36 @@ });

}, }); +ticketCommand.subcommand("view", { + description: "View a ticket", + options: { + id: option.string("The ticket's ID", { required: true }), + }, + async run(interaction, args, { guild, actor, user }) { + if (guild && actor) { + const ticket = await getTicketByGuild(guild.id, Number(args.id)); + if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.id} not found.`); + + if (!hasAccessToTicket(actor, guild, ticket)) return; + + const container = actions.getTicketContainer(ticket, !actor.moderator); + await interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"] }); + return; + } + + if (user) { + const ticket = await getTicketByUser(user.id, Number(args.id)); + if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.id} not found.`); + + const container = actions.getTicketContainer(ticket, false); + await interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"] }); + return; + } + + await errorMessage(interaction, "We couldn't find a ticket with that ID."); + }, +}); + ticketCommand.group( "actions", {

@@ -112,7 +143,9 @@ description: "Set the channel where transcripts will be sent",

options: { channel: option.channel("The channel to send transcripts to", { required: true }), }, - async run(interaction) { + async run(interaction, _, { actor }) { + if (!actor?.moderator) + return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, });

@@ -122,7 +155,9 @@ description: "Set the channel where the ticket prompt will be sent",

options: { channel: option.channel("The channel to send the ticket prompt to", { required: true }), }, - async run(interaction) { + async run(interaction, _, { actor }) { + if (!actor?.moderator) + return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, });

@@ -132,7 +167,9 @@ description: "Sets the category for new tickets",

options: { category: option.channel("The category to create new tickets in", { required: true }), }, - async run(interaction) { + async run(interaction, _, { actor }) { + if (!actor?.moderator) + return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, });

@@ -140,14 +177,18 @@

// text group.subcommand("prompt", { description: "Set the message sent in the entry point channel", - async run(interaction) { + async run(interaction, _, { actor }) { + if (!actor?.moderator) + return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, }); group.subcommand("greeting", { description: "Set the message sent in new tickets", - async run(interaction) { + async run(interaction, _, { actor }) { + if (!actor?.moderator) + return errorMessage(interaction, "You must be a moderator to run this command"); await manageConfig(interaction); }, });

@@ -155,12 +196,12 @@

// actions group.subcommand("send-prompt", { description: "Send the prompt to the entry point channel", - async run(interaction) { - if (!interaction.guild) + async run(interaction, _, { guild, actor }) { + if (!interaction.guild || !guild) return errorMessage(interaction, "You must run this command in a guild"); - const guild = await getGuildByDiscordId(interaction.guild.id); - if (!guild) return errorMessage(interaction, "Failed to send prompt: Guild not found."); + if (!actor?.moderator) + return errorMessage(interaction, "You must be a moderator to run this command"); const commandString = (sub: string) => `</ticket manage ${sub}:${interaction.commandId}>`; const channelId = guild.ticketPromptChannel;
M apps/bot/src/lib/tickets.tsapps/bot/src/lib/tickets.ts

@@ -1,22 +1,16 @@

-import db, { tickets, eq, getActor, getActorByDiscordId } from "@stealth-developers/db"; - -export async function hasAccessToTicket(userId: string, ticketId: string) { - const [ticket] = await db.select().from(tickets).where(eq(tickets.id, ticketId)).limit(1); - if (!ticket) return false; - - const userPromise = getActorByDiscordId(userId, ticket.guildId); - const subjectPromise = getActor(ticket.subject); - const openedByPromise = ticket.subject === ticket.openedBy ? null : getActor(ticket.openedBy); - - const [user, subject, openedByResult] = await Promise.all([ - userPromise, - subjectPromise, - openedByPromise, - ]); - const openedBy = ticket.subject === ticket.openedBy ? subject : openedByResult; - if (!user) return false; +import { + type User, + type Actor, + type TicketWithRelations, + type Guild, +} from "@stealth-developers/db"; - if (user.moderator) return true; - if (!subject || !openedBy) return false; - if (user.id === subject.id || user.id === openedBy.id) return true; +export async function hasAccessToTicket( + user: Actor | User, + guild: Guild, + ticket: TicketWithRelations, +) { + if (user.discordId === ticket.subjectActor?.discordId) return true; + if ("moderator" in user) if (user.guildId === guild.id && user.moderator) return true; + return false; }
M apps/bot/src/middleware/index.tsapps/bot/src/middleware/index.ts

@@ -1,14 +1,40 @@

+import { HaltExecution } from "@purrkit/router"; import { kitten } from "@/client"; +import { upsertGuild } from "@stealth-developers/db"; import { upsertUser } from "@/lib"; -import { HaltExecution } from "@purrkit/router"; 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."); +export const getUser = base + .use(async (interaction) => { + if (!interaction.guild) return {}; - const actor = await upsertUser(interaction.user, interaction.guild); - if (!actor) throw new HaltExecution("Failed to add you to the database. Please try again."); + const user = await upsertUser(interaction.user, interaction.guild); + if (!user) throw new HaltExecution("Failed to add you to the database. Please try again."); - return actor; -}); + return { user }; + }) + .use(async (interaction) => { + if (!interaction.guild) return {}; + + 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 }; + }) + .use(async (interaction) => { + if (!interaction.guild) return {}; + + const guild = await upsertGuild({ + discordId: interaction.guild.id, + icon: interaction.guild.iconURL(), + name: interaction.guild.name, + }); + + if (!guild) + throw new HaltExecution("Failed to find your guild in the database. Please try again."); + + return { guild }; + }); + +export const getBasicData = getUser;