all repos — stealth-developers @ a54c0eba350a2ce6546407258ac49fe380aa851e

feat(tickets): allow adding other members to a ticket
vi v@vt3e.cat
Sat, 11 Apr 2026 18:51:06 +0100
commit

a54c0eba350a2ce6546407258ac49fe380aa851e

parent

32d9a5c29c7da351a4df5844ec1505c16b34d756

M src/database/schema/tickets.tssrc/database/schema/tickets.ts

@@ -13,6 +13,11 @@

authorId: text("author_id").notNull(), anonymousId: text("anonymous_id").notNull(), + addedUsers: text("added_users", { mode: "json" }) + .$type<string[]>() + .default([]) + .notNull(), + type: text("type").notNull(), status: text("status", { enum: ["open", "closed", "archived"] }) .default("open")
M src/events/messageCreate.tssrc/events/messageCreate.ts

@@ -205,9 +205,20 @@ message?.member as GuildMember,

); const authorType = isSystem ? "system" : isModerator ? "staff" : "user"; - const anonymisedContent = message.content.replace( + let anonymisedContent = message.content.replace( new RegExp(ticket.authorId, "g"), - "-".repeat(ticket.authorId.length), + "Reporter", + ); + + for (const [index, userId] of (ticket.addedUsers || []).entries()) { + anonymisedContent = anonymisedContent.replace( + new RegExp(userId, "g"), + `Guest_${index + 1}`, + ); + } + + logger.debug( + `Anonymised content for message ${message.id}: ${anonymisedContent}`, ); const savedMessage = db
M src/events/messageUpdate.tssrc/events/messageUpdate.ts

@@ -25,10 +25,17 @@

if (!ticket) return; try { - const anonymisedContent = newMessage.content.replace( + let anonymisedContent = newMessage.content.replace( new RegExp(ticket.authorId, "g"), - "-".repeat(ticket.authorId.length), + "Reporter", ); + + for (const [index, userId] of (ticket.addedUsers || []).entries()) { + anonymisedContent = anonymisedContent.replace( + new RegExp(userId, "g"), + `Guest_${index + 1}`, + ); + } logger.debug( `Anonymised content for message ${newMessage.id}: ${anonymisedContent}`,
M src/interactions/commands/tickets/actions.tssrc/interactions/commands/tickets/actions.ts

@@ -215,9 +215,57 @@ content: `:white_check_mark: Ticket created ${channel.toString()}`,

}); } -async function sendMessage(interaction: Interaction, content: string) { - if (!interaction.channel?.isSendable()) return; - await interaction.channel.send(content); +export async function handleAddUser( + client: Client, + interaction: ChatInputCommandInteraction, + ticket: Ticket, +) { + const targetUser = interaction.options.getUser("user", true); + + const isStaff = await hasManagerPermissions( + interaction.member as GuildMember, + ); + const isAuthor = interaction.user.id === ticket.authorId; + + if (!isStaff && !isAuthor) { + await interaction.editReply( + "❌ You don't have permission to add users to this ticket.", + ); + return; + } + + const channel = interaction.channel; + if (!channel || !("permissionOverwrites" in channel)) return; + + try { + await channel.permissionOverwrites.edit(targetUser.id, { + ViewChannel: true, + SendMessages: true, + AttachFiles: true, + }); + + const updatedUsers = [ + ...new Set([...(ticket.addedUsers || []), targetUser.id]), + ]; + await db + .update(tickets) + .set({ addedUsers: updatedUsers }) + .where(eq(tickets.id, ticket.id)); + + await channel.send({ + content: `✅ <@${targetUser.id}> has been added to the ticket by <@${interaction.user.id}>.`, + }); + + await interaction.editReply("✅ User added successfully."); + } catch (error) { + logger.error( + error, + `Failed to add user ${targetUser.id} to ticket ${ticket.id}`, + ); + await interaction.editReply( + "❌ Failed to add the user to the channel permissions.", + ); + } } export async function handleClose(

@@ -287,6 +335,14 @@ if (!isAdmin && !isOwner && !isManager) {

await channel.permissionOverwrites.edit(ticket.authorId, { ViewChannel: false, }); + + for (const addedUserId of ticket.addedUsers || []) { + await channel.permissionOverwrites + .edit(addedUserId, { + ViewChannel: false, + }) + .catch(() => null); + } } } catch (error) { logger.error(error, `could not remove permissions for ${ticket.authorId}`);

@@ -515,6 +571,14 @@ ViewChannel: true,

SendMessages: true, AttachFiles: true, }); + + for (const addedUserId of ticket.addedUsers || []) { + await channel.permissionOverwrites.edit(addedUserId, { + ViewChannel: true, + SendMessages: true, + AttachFiles: true, + }); + } } catch (error) { logger.error(error, `could not restore permissions for ${ticket.authorId}`); }
M src/interactions/commands/tickets/gdpr.tssrc/interactions/commands/tickets/gdpr.ts

@@ -15,7 +15,7 @@ type GuildMember,

SeparatorBuilder, TextDisplayBuilder, } from "discord.js"; -import { eq } from "drizzle-orm"; +import { eq, like, or } from "drizzle-orm"; import { PAGINATION_SIZE, getTicketContainer } from "./_shared"; const logger = loggers.interactions.child({ name: "ticket/gdpr" });

@@ -24,7 +24,9 @@ async function buildListContainerForUser(userId: string, page = 1) {

const ticketsRes = await db .select() .from(tickets) - .where(eq(tickets.authorId, userId)); + .where( + or(eq(tickets.authorId, userId), like(tickets.addedUsers, `%${userId}%`)), + ); if (!ticketsRes || ticketsRes.length === 0) { const empty = new ContainerBuilder()

@@ -192,7 +194,9 @@

const isStaff = await hasManagerPermissions( interaction.member as GuildMember, ); - const isAuthor = ticket.authorId === interaction.user.id; + const isAuthor = + ticket.authorId === interaction.user.id || + ticket.addedUsers?.includes(interaction.user.id); if (!isStaff && !isAuthor) { await interaction.editReply({

@@ -221,7 +225,12 @@ ) {

const _tickets = await db .select() .from(tickets) - .where(eq(tickets.authorId, interaction.user.id)); + .where( + or( + eq(tickets.authorId, interaction.user.id), + like(tickets.addedUsers, `%${interaction.user.id}%`), + ), + ); const transcripts = await Promise.all( _tickets.map(async (ticket) => {
M src/interactions/commands/tickets/index.tssrc/interactions/commands/tickets/index.ts

@@ -13,6 +13,7 @@ import { getGuild, getTicket } from "@/database/queries";

import { parseComponentId } from "@/utils/discord/components"; import { getTicketReasonModal } from "./_shared"; import { + handleAddUser, handleClose, handleCreate, handleDelete,

@@ -31,11 +32,13 @@

const commandData = new SlashCommandBuilder() .setName("ticket") .setDescription("Manage and create new tickets") + // create .addSubcommand( new SlashCommandSubcommandBuilder() .setName("create") .setDescription("Create a new ticket"), ) + // create-for .addSubcommand( new SlashCommandSubcommandBuilder() .setName("create-for")

@@ -53,6 +56,19 @@ .setDescription("Reason for creating the ticket, shown to the user")

.setRequired(false), ), ) + // add + .addSubcommand( + new SlashCommandSubcommandBuilder() + .setName("add") + .setDescription("Add another user to the ticket") + .addUserOption((option) => + option + .setName("user") + .setDescription("The user to add") + .setRequired(true), + ), + ) + // close .addSubcommand( new SlashCommandSubcommandBuilder() .setName("close")

@@ -72,16 +88,19 @@ )

.setRequired(false), ), ) + // reopen .addSubcommand( new SlashCommandSubcommandBuilder() .setName("reopen") .setDescription("Reopen the ticket in the current channel"), ) + // delete .addSubcommand( new SlashCommandSubcommandBuilder() .setName("delete") .setDescription("Delete the ticket in the current channel"), ) + // transcript .addSubcommand( new SlashCommandSubcommandBuilder() .setName("transcript")

@@ -89,11 +108,13 @@ .setDescription(

"Get the transcript of the ticket in the current channel", ), ) + // list .addSubcommand( new SlashCommandSubcommandBuilder() .setName("list") .setDescription("Lists all of your current and past tickets"), ) + // view .addSubcommand( new SlashCommandSubcommandBuilder() .setName("view")

@@ -105,6 +126,7 @@ .setDescription("The ID of the ticket you want to view")

.setRequired(true), ), ) + // export .addSubcommand( new SlashCommandSubcommandBuilder() .setName("export")

@@ -207,6 +229,7 @@ if (action === "reopen") await handleReopen(client, interaction, ticket);

else if (action === "delete") await handleDelete(client, interaction, ticket); else if (action === "transcript") await handleTranscript(client, interaction, ticket); + else if (action === "add") await handleAddUser(client, interaction, ticket); else await interaction.editReply("❌ Invalid or unhandled action."); }
M src/utils/tickets/transcripts.tssrc/utils/tickets/transcripts.ts

@@ -38,9 +38,14 @@ const lines = messages.map((msg) => {

const msgAttachments = attachmentMap.get(msg.id) ?? []; const time = new Date(msg.createdAt).toISOString(); - let author = "reporter"; - if (msg.authorType === "system") author = "system"; - if (msg.authorType === "staff") author = `<@${msg.authorId}>`; + let author = "Reporter"; + if (msg.authorType === "system") { + author = "System"; + } else if (msg.authorType === "staff") { + author = `<@${msg.authorId}>`; + } else if (ticket.addedUsers?.includes(msg.authorId)) { + author = `Guest ${ticket.addedUsers.indexOf(msg.authorId) + 1}`; + } let text = `[${time}] ${author}: ${msg.content}`;