bot: ticket closing donee
vi did:web:vt3e.cat
Fri, 26 Jun 2026 09:06:35 +0100
5 files changed,
233 insertions(+),
40 deletions(-)
M
apps/bot/src/events/index.ts
→
apps/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.md
→
apps/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.ts
→
apps/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(); }