all repos — stealth-developers @ 529dba23bb15864af72a10bbf4162a6406dd9294

bot: ticket closing donee
vi did:web:vt3e.cat
Fri, 26 Jun 2026 09:06:35 +0100
commit

529dba23bb15864af72a10bbf4162a6406dd9294

parent

75b3455c0ab83f42ca6511de32864991d10876f8

M apps/bot/src/events/index.tsapps/bot/src/events/index.ts

@@ -1,7 +1,5 @@

-import type { OAuth2Guild } from "discord.js"; -import { upsertActor, upsertGuild } from "@stealth-developers/db"; import { client } from "@/client"; -import { logger } from "@/lib"; +import { getGuildActor } from "f/guild-actor"; client.on("clientReady", async () => { const guilds = client.guilds.fetch();

@@ -9,33 +7,3 @@ if (!guilds) return;

for (const guild of (await guilds).values()) await getGuildActor(guild); }); - -async function getGuildActor(guild: OAuth2Guild) { - const member = await client.guilds.cache.get(guild.id)?.members.fetchMe(); - if (!member) return; - - const dbGuild = await upsertGuild({ - discordId: guild.id, - icon: guild.iconURL(), - name: guild.name, - }); - if (!dbGuild) return; - - const actor = await upsertActor({ - moderator: true, - guild: { - discordId: guild.id, - icon: guild.iconURL(), - name: guild.name, - }, - user: { - discordId: member.user.id, - username: member.user.username, - displayName: member.displayName, - avatar: member.user.avatarURL(), - }, - }); - - logger.info({ actor }, `upserted guild actor for ${guild.id} (${guild.name})`); - return actor; -}
A apps/bot/src/feats/guild-actor.ts

@@ -0,0 +1,34 @@

+import type { Guild, OAuth2Guild } from "discord.js"; +import { upsertActor, upsertGuild } from "@stealth-developers/db"; +import { client } from "@/client"; +import { logger } from "@/lib"; + +export async function getGuildActor(guild: OAuth2Guild | Guild) { + const member = await client.guilds.cache.get(guild.id)?.members.fetchMe(); + if (!member) return; + + const dbGuild = await upsertGuild({ + discordId: guild.id, + icon: guild.iconURL(), + name: guild.name, + }); + if (!dbGuild) return; + + const actor = await upsertActor({ + moderator: true, + guild: { + discordId: guild.id, + icon: guild.iconURL(), + name: guild.name, + }, + user: { + discordId: member.user.id, + username: member.user.username, + displayName: member.displayName, + avatar: member.user.avatarURL(), + }, + }); + + logger.info({ actor }, `upserted guild actor for ${guild.id} (${guild.name})`); + return actor; +}
M apps/bot/src/feats/tickets/README.mdapps/bot/src/feats/tickets/README.md

@@ -3,7 +3,7 @@ - [x] new

- [x] for - [ ] view [ticketId] - [ ] actions - - [ ] _close_ + - [x] close - [ ] claim - [ ] unclaim - [ ] reopen
M apps/bot/src/feats/tickets/actions.tsapps/bot/src/feats/tickets/actions.ts

@@ -7,11 +7,13 @@ ContainerBuilder,

TextDisplayBuilder, ActionRowBuilder, DiscordAPIError, + Message, } from "discord.js"; import { Result } from "@sapphire/framework"; import { ButtonBuilder } from "discord.js"; import db, { + addTicketComment, desc, editTicket, eq,

@@ -19,12 +21,16 @@ getGuildByDiscordId,

getTicketByChannelId, tickets, type Actor, + type TicketWithRelations, } from "@stealth-developers/db"; import { upsertUser, getCategory, PublicError, logger } from "@/lib"; import type { AnyUser, MessageSendPayload } from "@/types"; import { TicketButtonPresets } from "./buttons"; import { TicketModalPresets } from "./modals"; +import { client } from "@/client"; +import { getGuildActor } from "../guild-actor"; +import type { Channel } from "discord.js"; type CreateTicketArgs = | undefined

@@ -203,10 +209,60 @@

return TicketModalPresets.close(ticket.id, Boolean(dbUser.moderator)); } +const text = (content: string) => new TextDisplayBuilder().setContent(content); + +/** + * @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) { + const container = new ContainerBuilder(); + container.addTextDisplayComponents(text(`# Ticket #${ticket.localId} - ${ticket.status}`)); + + let closedBySubject = true; + if (ticket.closedByActor && ticket.subjectActor) { + closedBySubject = ticket.subjectActor.id === ticket.closedByActor.id; + + container.addTextDisplayComponents( + text(`Closed by ${closedBySubject ? "the subject" : `<@${ticket.closedByActor.discordId}>`}`), + ); + + container.addTextDisplayComponents(text(["**Reason**", `> ${ticket.closeReason}`].join("\n"))); + + if (!limitedDetails && ticket.privateReason) + container.addTextDisplayComponents( + text(["**Private Reason**", `> ${ticket.privateReason}`].join("\n")), + ); + } + + const hasAttachments = ticket.attachments.length > 0; + if (hasAttachments) + container.addTextDisplayComponents(text(`**Attachments:** ${ticket.attachments.length}`)); + else container.addTextDisplayComponents(text("**Attachments:** no attachments")); + + container.addTextDisplayComponents(text(`-# ${ticket.id}`)); + + const actionRow = new ActionRowBuilder<ButtonBuilder>(); + if (closedBySubject) actionRow.addComponents(TicketButtonPresets.thank(ticket.id)); + actionRow.addComponents(TicketButtonPresets.transcript(ticket.id)); + + container.addActionRowComponents(actionRow); + return container; +} + +/** + * + * @param user + * @param ticketId + * @param reasons + * @param cleanup whether to delete the ticket channel immediately after closing + * @returns + */ export async function closeTicket( user: Actor, ticketId: string, reasons: { public: string; private?: string | null }, + cleanup: boolean = false, ) { const result = await editTicket(ticketId, { closedAt: new Date(),

@@ -217,6 +273,82 @@ status: "closed",

}); if (!result) throw new PublicError("Failed to close ticket: Ticket not found in database"); + + const dbGuild = result.guild; + if (!dbGuild) throw new PublicError("Failed to close ticket: Guild not found in database"); + + const guild = await client.guilds.fetch(dbGuild.discordId); + if (!guild) throw new PublicError("Failed to close ticket: Guild not found in Discord"); + + if (!result.channelId) + throw new PublicError("Failed to close ticket: Channel not found in database"); + + const dbSubject = result.subjectActor; + if (!dbSubject || !dbSubject.discordId) + throw new PublicError("Failed to close ticket: Subject not found in database"); + + const channel = await guild.channels.fetch(result.channelId); + if (!channel || !channel.isTextBased()) + throw new PublicError("Failed to close ticket: Channel not found in database"); + + if (cleanup) { + await channel.delete(); + } else { + channel.edit({ + name: channelName(result.localId, result.status), + }); + messageChannel(channel, { + content: `This ticket has been closed by <@${user.discordId}>.`, + }); + } + + const botActor = await getGuildActor(guild); + if (!botActor) throw new PublicError("Failed to close ticket: Bot actor not found in database"); + + const subject = await client.users.fetch(dbSubject.discordId); + + const container = await getTicketContainer(result, true); + + const messageRes = await Result.fromAsync( + messageUser(subject, { + components: [container], + flags: ["IsComponentsV2"], + }), + ); + + if (messageRes.isErr()) { + await addTicketComment({ + actorId: botActor.id, + ticketId: result.id, + content: `User has DMs closed, a notification was not sent.`, + }); + } + + const logChannel = dbGuild.ticketLogChannel; + if (!logChannel) { + await addTicketComment({ + actorId: botActor.id, + ticketId: result.id, + content: `No log channel was found, a notification was not sent.`, + }); + } else { + const container = await getTicketContainer(result, false); + const message = await Result.fromAsync( + messageChannel(logChannel, { + components: [container], + flags: ["IsComponentsV2"], + }), + ); + + if (message.isErr()) { + await addTicketComment({ + actorId: botActor.id, + ticketId: result.id, + content: `Failed to send log message: ${message.unwrapErr()}`, + }); + } + } + return result; }

@@ -232,6 +364,21 @@ .then(() => true)

.catch(() => false); } +export function channelName(localId: number, status: string, claimedBy?: string) { + const initNumber = localId.toString().padStart(4, "0"); + + // discord errors if we try to create channels with certain phrases in them + const bad = ["1488", "1919"]; + if (bad.includes(initNumber)) return `${status}-filtered`; + + if (claimedBy) { + const firstLetters = claimedBy.slice(0, 3); + return `${firstLetters}-${initNumber}`; + } + + return `${status}-ticket-${localId}`; +} + export async function messageUser(user: AnyUser, message: MessageSendPayload) { if (!("send" in user)) throw new PublicError("Failed to message user: User is not a Discord user");

@@ -248,12 +395,24 @@

return result.unwrap(); } -export function channelName(localId: number, status: string) { - const initNumber = localId.toString().padStart(4, "0"); +export async function messageChannel(channelIn: string | Channel, message: MessageSendPayload) { + const channel = + typeof channelIn === "string" ? await client.channels.fetch(channelIn) : channelIn; + if (!channel) throw new PublicError("Failed to message channel: Channel not found"); + + if (!("send" in channel)) + throw new PublicError("Failed to message channel: Channel is not a text channel"); + + const result = await Result.fromAsync<Message<boolean>>(channel.send(message)); + if (result.isErr()) { + const error = result.unwrapErr(); + logger.warn({ error }, "Failed to message channel"); - // discord errors if we try to create channels with certain phrases in them - const bad = ["1488", "1919"]; - if (bad.includes(initNumber)) return `${status}-filtered`; + if (error instanceof DiscordAPIError && [50007, 50278].includes(error.code as number)) + throw new PublicError("Failed to message channel: Channel is not a text channel"); - return `${status}-ticket-${localId}`; + throw error; + } + + return result.unwrap(); }
M apps/bot/src/feats/tickets/buttons.tsapps/bot/src/feats/tickets/buttons.ts

@@ -45,6 +45,20 @@ },

async run() {}, }); +export const thankButton = kitten.button("thank", { + options: { + ticketId: option.string({ required: true }), + }, + async run() {}, +}); + +export const commentsButton = kitten.button("comments", { + options: { + ticketId: option.string({ required: true }), + }, + async run() {}, +}); + export const newTicketButton = kitten.button("open-ticket", { run: async (interaction) => { if (!interaction.guild)

@@ -77,6 +91,8 @@ newTicketButton,

closeTicketButton, deleteTicketButton, claimTicketButton, + thankButton, + commentsButton, ]; export const TicketButtonPresets = {

@@ -95,6 +111,22 @@ new ButtonBuilder()

.setCustomId(claimTicketButton.id({ ticketId: id })) .setLabel("Claim Ticket") .setStyle(ButtonStyle.Primary), + thank: (id: string) => + new ButtonBuilder() + .setCustomId(thankButton.id({ ticketId: id })) + .setLabel("Thank your moderator") + .setEmoji("💖") + .setStyle(ButtonStyle.Primary), + comments: (id: string) => + new ButtonBuilder() + .setCustomId(commentsButton.id({ ticketId: id })) + .setLabel("View comments") + .setStyle(ButtonStyle.Primary), + transcript: (id: string) => + new ButtonBuilder() + .setLabel("View Transcript") + .setURL(`https://tickets.vt3e.cat/tickets/${id}`) + .setStyle(ButtonStyle.Link), create: () => new ButtonBuilder() .setLabel("Open a Ticket")