From ac5ecc9a1c5b2e68efb5cff5913320f7c7728b50 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Sat, 25 Jul 2026 16:16:53 +0200 Subject: [PATCH] Enhance ticket management with control panel and support role integration - Introduced a control panel for ticket interactions, allowing users to claim, close, and manage ticket participants via buttons and user select menus. - Implemented support role member caching to efficiently add staff to private ticket threads, adhering to Discord's rate limits. - Updated ticket opening messages to mention support roles, ensuring staff are notified when a ticket is created. - Enhanced localization for new ticket control features in both English and German. - Documented changes in phase tracking to reflect the new ticket support functionalities. --- apps/bot/src/modules/tickets/interactions.ts | 150 +++++++++++++++++-- apps/bot/src/modules/tickets/service.ts | 146 +++++++++++++++++- docs/PHASE-TRACKING.md | 16 ++ packages/shared/src/index.ts | 14 ++ 4 files changed, 303 insertions(+), 23 deletions(-) diff --git a/apps/bot/src/modules/tickets/interactions.ts b/apps/bot/src/modules/tickets/interactions.ts index e8c8fea..effe52f 100644 --- a/apps/bot/src/modules/tickets/interactions.ts +++ b/apps/bot/src/modules/tickets/interactions.ts @@ -1,17 +1,30 @@ import type { ButtonInteraction, ModalSubmitInteraction, - StringSelectMenuInteraction + StringSelectMenuInteraction, + UserSelectMenuInteraction } from 'discord.js'; import { t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; +import { ephemeral } from '../../interaction-reply.js'; import { + addUserToTicket, + assertTicketStaff, buildOpenModal, + claimTicket, + closeTicket, + findTicketByChannel, + isTicketControlInteraction, openTicketForCategory, parseFormFields, parseRateButton, rateTicket, + removeUserFromTicket, + TICKET_CTRL_ADD, + TICKET_CTRL_CLAIM, + TICKET_CTRL_CLOSE, + TICKET_CTRL_REMOVE, TICKET_MODAL_PREFIX, TICKET_OPEN_PREFIX, TICKET_RATE_PREFIX, @@ -24,37 +37,45 @@ export function isTicketInteraction(customId: string): boolean { customId.startsWith(TICKET_OPEN_PREFIX) || customId === TICKET_SELECT_ID || customId.startsWith(TICKET_MODAL_PREFIX) || - customId.startsWith(TICKET_RATE_PREFIX) + customId.startsWith(TICKET_RATE_PREFIX) || + isTicketControlInteraction(customId) ); } async function replyTicketError( - interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, + interaction: + | ButtonInteraction + | StringSelectMenuInteraction + | ModalSubmitInteraction + | UserSelectMenuInteraction, locale: 'de' | 'en', code: string ): Promise { const content = t(locale, `ticket.error.${code}`); if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content, ephemeral: true }); + await interaction.followUp({ content, ...ephemeral() }); } else { - await interaction.reply({ content, ephemeral: true }); + await interaction.reply({ content, ...ephemeral() }); } } -export async function handleOpenTicketRequest( +async function handleOpenTicketRequest( interaction: ButtonInteraction | StringSelectMenuInteraction, context: BotContext, categoryId: string ): Promise { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return; } const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } }); if (!category || category.guildId !== interaction.guildId) { - await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'ticket.error.category_not_found'), + ...ephemeral() + }); return; } @@ -76,7 +97,7 @@ export async function handleOpenTicketRequest( const channelRef = ticket.channelId ?? ticket.threadId; await interaction.reply({ content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), - ephemeral: true + ...ephemeral() }); } catch (error) { if (error instanceof TicketError) { @@ -87,8 +108,94 @@ export async function handleOpenTicketRequest( } } +async function handleTicketControl( + interaction: ButtonInteraction | UserSelectMenuInteraction, + context: BotContext +): Promise { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); + return; + } + + const ticket = await findTicketByChannel(context.prisma, interaction.channelId); + if (!ticket) { + await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ...ephemeral() }); + return; + } + + const member = await interaction.guild.members.fetch(interaction.user.id); + + try { + if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) { + const isOpener = ticket.openerId === interaction.user.id; + let isStaff = false; + try { + await assertTicketStaff(member, ticket); + isStaff = true; + } catch { + isStaff = false; + } + if (!isStaff && !isOpener) { + await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ...ephemeral() }); + return; + } + + await interaction.deferReply(ephemeral()); + await closeTicket(context, ticket, interaction.user.id, locale); + await interaction.editReply({ content: t(locale, 'ticket.close.success') }); + return; + } + + if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLAIM) { + await claimTicket(context, ticket, member, locale); + await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() }); + return; + } + + if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) { + const userId = interaction.values[0]; + if (!userId) { + await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); + return; + } + const target = await interaction.guild.members.fetch(userId); + await addUserToTicket(context, ticket, member, target, locale); + await interaction.reply({ + content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }), + ...ephemeral() + }); + return; + } + + if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_REMOVE) { + const userId = interaction.values[0]; + if (!userId) { + await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); + return; + } + const target = await interaction.guild.members.fetch(userId); + await removeUserFromTicket(context, ticket, member, target, locale); + await interaction.reply({ + content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }), + ...ephemeral() + }); + } + } catch (error) { + if (error instanceof TicketError) { + await replyTicketError(interaction, locale, error.code); + return; + } + throw error; + } +} + export async function handleTicketInteraction( - interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, + interaction: + | ButtonInteraction + | StringSelectMenuInteraction + | ModalSubmitInteraction + | UserSelectMenuInteraction, context: BotContext ): Promise { const locale = await getGuildLocale(context.prisma, interaction.guildId); @@ -96,13 +203,23 @@ export async function handleTicketInteraction( if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) { const categoryId = interaction.values[0]; if (!categoryId) { - await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'ticket.error.category_not_found'), + ...ephemeral() + }); return; } await handleOpenTicketRequest(interaction, context, categoryId); return; } + if (interaction.isButton() || interaction.isUserSelectMenu()) { + if (isTicketControlInteraction(interaction.customId)) { + await handleTicketControl(interaction, context); + return; + } + } + if (interaction.isButton()) { if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) { const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length); @@ -122,7 +239,7 @@ export async function handleTicketInteraction( if (error instanceof TicketError) { await interaction.reply({ content: t(locale, `ticket.error.${error.code}`), - ephemeral: true + ...ephemeral() }); return; } @@ -134,14 +251,17 @@ export async function handleTicketInteraction( if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) { if (!interaction.inGuild() || !interaction.guild) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return; } const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length); const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } }); if (!category) { - await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'ticket.error.category_not_found'), + ...ephemeral() + }); return; } @@ -169,7 +289,7 @@ export async function handleTicketInteraction( const channelRef = ticket.channelId ?? ticket.threadId; await interaction.reply({ content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), - ephemeral: true + ...ephemeral() }); } catch (error) { if (error instanceof TicketError) { diff --git a/apps/bot/src/modules/tickets/service.ts b/apps/bot/src/modules/tickets/service.ts index 4d5b695..ef23e70 100644 --- a/apps/bot/src/modules/tickets/service.ts +++ b/apps/bot/src/modules/tickets/service.ts @@ -10,12 +10,14 @@ import { StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, + UserSelectMenuBuilder, type APIMessageComponentEmoji, type Guild, type GuildBasedChannel, type GuildMember, type GuildTextBasedChannel, type Message, + type Role, type SendableChannels, type TextChannel, type ThreadChannel @@ -37,6 +39,13 @@ export const TICKET_OPEN_PREFIX = 'ticket:open:'; export const TICKET_SELECT_ID = 'ticket:select'; export const TICKET_MODAL_PREFIX = 'ticket:modal:'; export const TICKET_RATE_PREFIX = 'ticket:rate:'; +export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim'; +export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close'; +export const TICKET_CTRL_ADD = 'ticket:ctrl:add'; +export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove'; + +/** Cap private-thread staff adds to stay within Discord rate limits. */ +const MAX_THREAD_SUPPORT_MEMBERS = 50; export const TicketFormFieldSchema = z.object({ id: z.string(), @@ -100,6 +109,103 @@ function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | und return { name: emoji }; } +/** + * `Role#members` only includes cached guild members. Fetch the member list when + * the cache looks empty so support staff can be added to private threads. + */ +async function resolveSupportMembers(guild: Guild, roleIds: string[]): Promise { + if (roleIds.length === 0) { + return []; + } + + const roles: Role[] = []; + for (const roleId of roleIds) { + const role = + guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null)); + if (role) { + roles.push(role); + } + } + + if (roles.length === 0) { + return []; + } + + const cachedCount = roles.reduce((sum, role) => sum + role.members.size, 0); + if (cachedCount === 0) { + await guild.members.fetch().catch((error) => { + logger.warn({ error, guildId: guild.id }, 'Failed to fetch members for ticket support roles'); + }); + } + + const seen = new Set(); + const members: GuildMember[] = []; + for (const role of roles) { + for (const member of role.members.values()) { + if (member.user.bot || seen.has(member.id)) { + continue; + } + seen.add(member.id); + members.push(member); + if (members.length >= MAX_THREAD_SUPPORT_MEMBERS) { + return members; + } + } + } + return members; +} + +export function isTicketControlInteraction(customId: string): boolean { + return ( + customId === TICKET_CTRL_CLAIM || + customId === TICKET_CTRL_CLOSE || + customId === TICKET_CTRL_ADD || + customId === TICKET_CTRL_REMOVE + ); +} + +export function buildTicketControlPanel(locale: 'de' | 'en'): { + embeds: EmbedBuilder[]; + components: ActionRowBuilder[]; +} { + const embed = new EmbedBuilder() + .setTitle(t(locale, 'ticket.control.title')) + .setDescription(t(locale, 'ticket.control.description')) + .setColor(0x6366f1); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(TICKET_CTRL_CLAIM) + .setLabel(t(locale, 'ticket.control.claim')) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(TICKET_CTRL_CLOSE) + .setLabel(t(locale, 'ticket.control.close')) + .setStyle(ButtonStyle.Danger) + ); + + const addRow = new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder() + .setCustomId(TICKET_CTRL_ADD) + .setPlaceholder(t(locale, 'ticket.control.addPlaceholder')) + .setMinValues(1) + .setMaxValues(1) + ); + + const removeRow = new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder() + .setCustomId(TICKET_CTRL_REMOVE) + .setPlaceholder(t(locale, 'ticket.control.removePlaceholder')) + .setMinValues(1) + .setMaxValues(1) + ); + + return { + embeds: [embed], + components: [buttons, addRow, removeRow] + }; +} + async function fetchSendableChannel( context: BotContext, channelId: string @@ -548,13 +654,24 @@ export async function createTicketChannel( reason: `Ticket opened by ${params.opener.user.tag}` }); await thread.members.add(params.opener.id); - for (const roleId of supportRoleIds) { - const role = params.guild.roles.cache.get(roleId); - if (role) { - for (const [, member] of role.members) { - await thread.members.add(member.id).catch(() => undefined); - } + + const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds); + for (const member of supportMembers) { + if (member.id === params.opener.id) { + continue; } + await thread.members.add(member.id).catch((error) => { + logger.warn( + { error, threadId: thread.id, userId: member.id }, + 'Failed to add support member to ticket thread' + ); + }); + } + if (supportRoleIds.length > 0 && supportMembers.length === 0) { + logger.warn( + { guildId: params.guild.id, supportRoleIds }, + 'No cached members found for ticket support roles – staff may need Server Members intent coverage' + ); } threadId = thread.id; } else { @@ -589,10 +706,13 @@ export async function createTicketChannel( const targetId = channelId ?? threadId!; const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel; + + const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' '); const introLines = [ + roleMentions || null, tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }), params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null - ].filter(Boolean); + ].filter(Boolean) as string[]; if (params.formAnswers && Object.keys(params.formAnswers).length > 0) { const answerLines = Object.entries(params.formAnswers).map( @@ -601,7 +721,17 @@ export async function createTicketChannel( introLines.push(answerLines.join('\n')); } - await target.send({ content: introLines.join('\n') }); + await target.send({ + content: introLines.join('\n'), + allowedMentions: { + roles: supportRoleIds, + users: [params.opener.id] + } + }); + + const control = buildTicketControlPanel(locale); + await target.send(control); + return ticket; } diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 34e088f..be76ab6 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -500,6 +500,22 @@ - [ ] Kategorie ändern/löschen → Panel-Buttons aktualisieren sich - [ ] Panel-Kanal wechseln → altes Panel weg, neues im Zielkanal +## Post-Phase – Ticket Support-Ping + Control-Panel (Status: implementiert) + +### Abgeschlossen (Code) + +- Support-Rollen: Member-Cache nachladen (`guild.members.fetch`), bis 50 Staff in Private Threads adden +- Ticket-Eröffnung pingt Support-Rollen (`allowedMentions.roles`) +- Control-Panel im Ticket: Claim, Close, User-Select Add/Remove (wie TempVoice-Pattern) +- Opener darf schließen; Claim/Add/Remove nur Support-Staff + +### Manuell testen + +- [ ] Ticket öffnen (Thread) → Support-Rolle wird gepingt, Staff erscheinen im Thread +- [ ] Ticket öffnen (Channel) → Support-Rolle gepingt, Rolle hat Kanal-Zugriff +- [ ] Control-Panel: Claim / Close / User hinzufügen / entfernen +- [ ] Opener kann schließen; Nicht-Staff kann nicht claimen + ## Post-Phase – Ban/Kick-Reason Logging + Verification Fail Event (Status: implementiert) ### Abgeschlossen (Code) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b252a2..2c4490b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -425,6 +425,13 @@ const de: Dictionary = { 'ticket.opened': '{user} hat ein Ticket geöffnet.', 'ticket.subjectLine': 'Betreff: {subject}', 'ticket.claimed': '{user} hat dieses Ticket übernommen.', + 'ticket.control.title': 'Ticket-Steuerung', + 'ticket.control.description': + 'Übernehmen oder schließen, bzw. weitere Nutzer über die Auswahlmenüs hinzufügen oder entfernen. Slash-Commands: `/ticket rename`, `/ticket priority`.', + 'ticket.control.claim': 'Übernehmen', + 'ticket.control.close': 'Schließen', + 'ticket.control.addPlaceholder': 'Nutzer zum Ticket hinzufügen…', + 'ticket.control.removePlaceholder': 'Nutzer aus dem Ticket entfernen…', 'ticket.userAdded': '{user} wurde zum Ticket hinzugefügt.', 'ticket.userRemoved': '{user} wurde aus dem Ticket entfernt.', 'ticket.renamed': 'Ticket umbenannt in **{name}**.', @@ -1025,6 +1032,13 @@ const en: Dictionary = { 'ticket.opened': '{user} opened a ticket.', 'ticket.subjectLine': 'Subject: {subject}', 'ticket.claimed': '{user} claimed this ticket.', + 'ticket.control.title': 'Ticket controls', + 'ticket.control.description': + 'Claim or close this ticket, or add/remove users with the menus below. Slash commands: `/ticket rename`, `/ticket priority`.', + 'ticket.control.claim': 'Claim', + 'ticket.control.close': 'Close', + 'ticket.control.addPlaceholder': 'Add a user to this ticket…', + 'ticket.control.removePlaceholder': 'Remove a user from this ticket…', 'ticket.userAdded': '{user} was added to the ticket.', 'ticket.userRemoved': '{user} was removed from the ticket.', 'ticket.renamed': 'Ticket renamed to **{name}**.',