bot: permissions for ticket channels aaaand update channels in the background
vi did:web:vt3e.cat
Sat, 27 Jun 2026 20:21:26 +0100
4 files changed,
324 insertions(+),
150 deletions(-)
M
apps/bot/src/events/index.ts
→
apps/bot/src/events/index.ts
@@ -1,9 +1,10 @@
+import { upsertGuildActor } from "f/guild-actor"; + import { client } from "@/client"; -import { getGuildActor } from "f/guild-actor"; client.on("clientReady", async () => { const guilds = client.guilds.fetch(); if (!guilds) return; - for (const guild of (await guilds).values()) await getGuildActor(guild); + for (const guild of (await guilds).values()) await upsertGuildActor(guild); });
M
apps/bot/src/feats/guild-actor.ts
→
apps/bot/src/feats/guild-actor.ts
@@ -1,9 +1,13 @@
import type { Guild, OAuth2Guild } from "discord.js"; -import { upsertActor, upsertGuild } from "@stealth-developers/db"; + +import { upsertActor, upsertGuild, type Actor } from "@stealth-developers/db"; + import { client } from "@/client"; import { logger } from "@/lib"; -export async function getGuildActor(guild: OAuth2Guild | Guild) { +const GUILD_ACTORS = new Map<string, Actor>(); + +export async function upsertGuildActor(guild: OAuth2Guild | Guild) { const member = await client.guilds.cache.get(guild.id)?.members.fetchMe(); if (!member) return;@@ -29,6 +33,12 @@ avatar: member.user.avatarURL(),
}, }); + if (actor) GUILD_ACTORS.set(guild.id, actor); + logger.info({ actor }, `upserted guild actor for ${guild.id} (${guild.name})`); return actor; } + +export function getGuildActor(guildId: string) { + return GUILD_ACTORS.get(guildId); +}
M
apps/bot/src/feats/tickets/README.md
→
apps/bot/src/feats/tickets/README.md
@@ -28,7 +28,7 @@ - [x] greeting
- [x] send-prompt - [ ] other ticket stuff - - [ ] permissions + - [x] permissions - [ ] add participants - [ ] log messages - [ ] private thread too
M
apps/bot/src/feats/tickets/actions.ts
→
apps/bot/src/feats/tickets/actions.ts
@@ -1,39 +1,44 @@
-import { randomUUID } from "node:crypto"; -import { - TextChannel, - ChannelType, - type Guild, - type Channel, - ContainerBuilder, - TextDisplayBuilder, - ActionRowBuilder, - DiscordAPIError, - Message, -} from "discord.js"; import { Result } from "@sapphire/framework"; -import { ButtonBuilder } from "discord.js"; - import db, { + type Actor, + type TicketWithRelations, addTicketComment, + addTicketParticipant, desc, editTicket, eq, getGuildByDiscordId, + getModerators, getTicketByChannelId, tickets, - type Actor, - type TicketWithRelations, } from "@stealth-developers/db"; +import { + ActionRowBuilder, + ButtonBuilder, + type Channel, + ChannelType, + ContainerBuilder, + DiscordAPIError, + type Guild, + Message, + OverwriteType, + PermissionFlagsBits, + TextChannel, + TextDisplayBuilder, +} from "discord.js"; +import { randomUUID } from "node:crypto"; -import { upsertUser, getCategory, PublicError, logger } from "@/lib"; +import type { AnyUser, MessageSendPayload } from "@/types"; + +import { client } from "@/client"; +import { PublicError, getCategory, logger, upsertUser } from "@/lib"; import { hasAccessToTicket } from "@/lib/tickets"; -import { client } from "@/client"; -import type { AnyUser, MessageSendPayload } from "@/types"; +import type { TicketContext } from "./types"; + +import { getGuildActor, upsertGuildActor } from "../guild-actor"; import { TicketButtonPresets } from "./buttons"; import { TicketModalPresets } from "./modals"; -import { getGuildActor } from "../guild-actor"; -import type { TicketContext } from "./types"; type CreateTicketArgs = | undefined@@ -44,6 +49,32 @@ };
const text = (content: string) => new TextDisplayBuilder().setContent(content); +export async function addParticipant( + guildId: string, + data: { + actor: Actor; + ticketId: string; + role: "participant" | "moderator" | "subject"; + reason?: string; + }, +) { + const botActor = getGuildActor(guildId); + if (!botActor) throw new PublicError("Failed to close ticket: Bot actor not found."); + + await addTicketParticipant({ + actorId: botActor.id, + ticketId: data.ticketId, + role: data.role, + reason: data.reason, + }); + + await addTicketComment({ + actorId: botActor.id, + ticketId: data.ticketId, + content: `Added participant: ${data.actor.displayName || data.actor.username!} - ${data.reason ?? "no reason provided"}`, + }); +} + export async function createTicket( opener: AnyUser, guild: Guild,@@ -54,6 +85,9 @@ const publicId = randomUUID();
const dbGuild = await getGuildByDiscordId(guild.id); if (!dbGuild) throw new PublicError("Failed to create ticket: Guild not found in database"); + + const moderators = await getModerators(dbGuild.id); + if (!moderators) throw new PublicError("Failed to create ticket: No moderators found"); const creator = await upsertUser(opener, guild); if (!creator) throw new PublicError("Failed to create ticket: Creator user not found");@@ -74,6 +108,8 @@ let createdChannel: TextChannel | undefined = undefined;
const canDm = await canDmUser(subject); + let ticketId: string | undefined = undefined; + const txResult = await Result.fromAsync(async () => { return await db.transaction(async (tx) => { const [maxTicket] = await tx@@ -99,94 +135,151 @@ })
.returning(); if (!ticket) throw new PublicError("Failed to add ticket to database, please try again."); + return { ticket, localId }; + }); + }); - const channel = await guild.channels.create({ - name: channelName(localId, ticket.status), - type: ChannelType.GuildText, - parent: ticketCategory, - }); + if (txResult.isErr()) { + const error = txResult.unwrapErr(); + const message = + error instanceof PublicError + ? `Failed to create ticket: ${error.message}` + : "There was an error creating the ticket."; - if (!channel) throw new PublicError("Failed to create Discord channel."); - createdChannel = channel; + if (error instanceof Error) logger.error(error.stack, error.message); + throw new PublicError(message); + } + + const { ticket, localId } = txResult.unwrap(); + ticketId = ticket.id; - const greeting = dbGuild.ticketGreeting; - const components = []; + const setupResult = await Result.fromAsync(async () => { + const channel = await guild.channels.create({ + name: channelName(localId, "provisioning"), + type: ChannelType.GuildText, + parent: ticketCategory, + permissionOverwrites: [ + { + id: guild.id, + type: OverwriteType.Role, + deny: [PermissionFlagsBits.ViewChannel], + }, + ...moderators.map((moderator) => ({ + id: moderator.discordId!, + type: OverwriteType.Member, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, + ], + })), + { + id: client.user!.id, + type: OverwriteType.Member, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels], + }, + { + id: subjectUser.discordId!, + type: OverwriteType.Member, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, + ], + }, + ], + }); - if (greeting) - components.push( - new ContainerBuilder() - .setAccentColor(0x3a9027) - .addTextDisplayComponents( - new TextDisplayBuilder({ - content: greeting.replaceAll("{user}", `<@${subjectUser.discordId}>`), - }), - ) - .addActionRowComponents( - new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.claimTicketButton(publicId), - TicketButtonPresets.closeTicketButton(publicId), - ), - ), - ); + if (!channel) throw new PublicError("Failed to create Discord channel."); + createdChannel = channel; - if (!canDm) - components.push( - new ContainerBuilder() - .setAccentColor(0xdf8e1d) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent("## I can't DM you!"), - new TextDisplayBuilder().setContent( - [ - "You won't receive a notification once your ticket has been handled.", - `To check the outcome, you can use the \`/ticket view\` command with the ID \`${ticket.localId}\``, - "or, see all of your tickets with `/ticket manage list`.", - ].join(" "), - ), - ), - ); + const greeting = dbGuild.ticketGreeting; + const components = []; + if (greeting) { components.push( new ContainerBuilder() .setAccentColor(0x3a9027) .addTextDisplayComponents( new TextDisplayBuilder({ - content: [ - "# Uploading Videos\n", - "You can either upload your video directly to this channel, or", - "you can use the button below to upload it directly to us, which", - "allows you to upload videos up to 2GB in size.", - ].join(" "), + content: greeting.replaceAll("{user}", `<@${subjectUser.discordId}>`), }), ) .addActionRowComponents( new ActionRowBuilder<ButtonBuilder>().addComponents( - TicketButtonPresets.uploadLink(publicId), + TicketButtonPresets.claimTicketButton(publicId), + TicketButtonPresets.closeTicketButton(publicId), ), ), ); + } - await channel.send({ - components, - flags: ["IsComponentsV2"], - }); + if (!canDm) { + components.push( + new ContainerBuilder() + .setAccentColor(0xdf8e1d) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("## I can't DM you!"), + new TextDisplayBuilder().setContent( + [ + "You won't receive a notification once your ticket has been handled.", + `To check the outcome, you can use the \`/ticket view\` command with the ID \`${ticket.localId}\``, + "or, see all of your tickets with `/ticket manage list`.", + ].join(" "), + ), + ), + ); + } + + components.push( + new ContainerBuilder() + .setAccentColor(0x3a9027) + .addTextDisplayComponents( + new TextDisplayBuilder({ + content: [ + "# Uploading Videos\n", + "You can either upload your video directly to this channel, or", + "you can use the button below to upload it directly to us, which", + "allows you to upload videos up to 2GB in size.", + ].join(" "), + }), + ) + .addActionRowComponents( + new ActionRowBuilder<ButtonBuilder>().addComponents( + TicketButtonPresets.uploadLink(publicId), + ), + ), + ); + + await channel.send({ + components, + flags: ["IsComponentsV2"], + }); - await tx - .update(tickets) - .set({ channelId: channel.id, status: "open" }) - .where(eq(tickets.id, ticket.id)); + await db + .update(tickets) + .set({ channelId: channel.id, status: "open" }) + .where(eq(tickets.id, ticket.id)); - await channel.edit({ + channel + .edit({ name: channelName(localId, "open"), + }) + .catch((err) => { + logger.error(err, `failed to update channel name to open for ticket ${ticket.id}`); }); - return channel; - }); + return channel; }); - if (txResult.isErr()) { - if (createdChannel) await Result.fromAsync(() => createdChannel?.delete()); + if (setupResult.isErr()) { + if (createdChannel) { + await Result.fromAsync(() => (createdChannel as TextChannel).delete()); + } + + await Result.fromAsync(() => db.delete(tickets).where(eq(tickets.id, ticketId!))); - const error = txResult.unwrapErr(); + const error = setupResult.unwrapErr(); const message = error instanceof PublicError ? `Failed to create ticket: ${error.message}`@@ -197,7 +290,14 @@
throw new PublicError(message); } - const channel = txResult.unwrap(); + await addParticipant(guild.id, { + actor: subjectUser, + ticketId: ticketId!, + role: "subject", + reason: "subject of ticket", + }); + + const channel = setupResult.unwrap(); return { channelId: channel.id, publicId }; }@@ -332,12 +432,10 @@ if (!channel || !channel.isTextBased())
throw new PublicError("Failed to close ticket: Channel not found in database"); if (cleanup) { - await channel.delete(); + channel + .delete() + .catch((err) => logger.error(err, `Failed to delete channel ${channel.id} during cleanup`)); } else { - channel.edit({ - name: channelName(result.localId, result.status), - }); - const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( TicketButtonPresets.reopenTicketButton(ticketId), TicketButtonPresets.cleanTicketButton(ticketId),@@ -347,58 +445,82 @@ messageChannel(channel, {
content: `This ticket has been closed by <@${user.discordId}>.`, components: [actionRow], flags: ["SuppressNotifications"], - }); + }).catch((err) => logger.error(err, `Failed to message closed channel ${channel.id}`)); + + channel + .edit({ + name: channelName(result.localId, result.status), + permissionOverwrites: [ + { + id: dbSubject.discordId!, + deny: [PermissionFlagsBits.ViewChannel], + allow: [], + type: OverwriteType.Member, + }, + ], + }) + .catch((err) => + logger.error(err, `Failed to update name/permissions for closed channel ${channel.id}`), + ); } - 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); + (async () => { + try { + const botActor = await upsertGuildActor(guild); + if (!botActor) { + logger.warn("Failed to complete closeTicket notifications: Bot actor not found."); + return; + } - const container = getTicketContainer(result, true); + const subject = await client.users.fetch(dbSubject.discordId!); + const container = getTicketContainer(result, true); - const messageRes = await Result.fromAsync( - messageUser(subject, { - components: [container], - flags: ["IsComponentsV2"], - }), - ); + 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.`, - }); - } + 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 = getTicketContainer(result, false); - const commentContainer = getCommentContainer(result.localId, result.comments); + 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 containerLong = getTicketContainer(result, false); + const commentContainer = getCommentContainer(result.localId, result.comments); - const message = await Result.fromAsync( - messageChannel(logChannel, { - components: [container, commentContainer], - flags: ["SuppressNotifications", "IsComponentsV2"], - allowedMentions: { parse: [] }, - }), - ); + const message = await Result.fromAsync( + messageChannel(logChannel, { + components: [containerLong, commentContainer], + flags: ["SuppressNotifications", "IsComponentsV2"], + allowedMentions: { parse: [] }, + }), + ); - if (message.isErr()) { - await addTicketComment({ - actorId: botActor.id, - ticketId: result.id, - content: `Failed to send log message: ${message.unwrapErr()}`, - }); + if (message.isErr()) { + await addTicketComment({ + actorId: botActor.id, + ticketId: result.id, + content: `Failed to send log message: ${message.unwrapErr()}`, + }); + } + } + } catch (err) { + logger.error(err, `Error performing background notification tasks for ticket ${result.id}`); } - } + })(); return result; }@@ -430,12 +552,34 @@ messageChannel(ticketChannel, {
content: `<@${actor.discordId}> reopened this ticket.`, flags: ["SuppressNotifications"], components: [actionRow], - }); + }).catch((err) => + logger.error(err, `Failed to send reopen message to channel ${ticketChannel.id}`), + ); if (ticketChannel.type === ChannelType.GuildText) { - ticketChannel.edit({ - name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), - }); + ticketChannel + .edit({ + name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), + permissionOverwrites: ticket.subjectActor + ? [ + { + id: ticket.subjectActor.discordId!, + type: OverwriteType.Member, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, + ], + }, + ] + : undefined, + }) + .catch((err) => + logger.error( + err, + `Failed to update name/permissions on reopen for channel ${ticketChannel.id}`, + ), + ); } }@@ -476,12 +620,18 @@ messageChannel(ticketChannel, {
content: `<@${actor.discordId}> has claimed this ticket, they will be with you shortly.`, flags: ["SuppressNotifications"], components: [actionRow], - }); + }).catch((err) => + logger.error(err, `failed to send claim message to channel ${ticketChannel.id}`), + ); if (ticketChannel.type === ChannelType.GuildText) { - ticketChannel.edit({ - name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), - }); + ticketChannel + .edit({ + name: channelName(ticket.localId, "claimed", actor.displayName || actor.username!), + }) + .catch((err) => + logger.error(err, `failed to update channel name on claim for ${ticketChannel.id}`), + ); } }@@ -524,16 +674,23 @@ if (ticketChannel) {
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( TicketButtonPresets.claimTicketButton(ticket.id), ); + messageChannel(ticketChannel, { content: `<@${actor.discordId}> unclaimed this ticket, someone else will be with you shortly.`, flags: ["SuppressNotifications"], components: [actionRow], - }); + }).catch((err) => + logger.error(err, `Failed to send unclaim message to channel ${ticketChannel.id}`), + ); if (ticketChannel.type === ChannelType.GuildText) { - ticketChannel.edit({ - name: channelName(ticket.localId, "open"), - }); + ticketChannel + .edit({ + name: channelName(ticket.localId, "open"), + }) + .catch((err) => + logger.error(err, `Failed to update channel name on unclaim for ${ticketChannel.id}`), + ); } }@@ -549,11 +706,17 @@ const canAccess = hasAccessToTicket(actor, guild, ticket, { requireModerator: true });
if (!canAccess) throw new PublicError("You don't have permission to clean up this ticket."); if (!ticketChannel) throw new PublicError("Ticket channel not found."); - await editTicket(ticket.id, { + const result = await editTicket(ticket.id, { channelId: null, }); - return await ticketChannel.delete("Ticket channel cleaned up"); + ticketChannel + .delete("Ticket channel cleaned up") + .catch((err) => + logger.error(err, `failed to delete channel ${ticketChannel.id} during clean up`), + ); + + return result; } export async function canDmUser(user: AnyUser): Promise<boolean> {