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.
This commit is contained in:
TheOnlyMace
2026-07-25 16:16:53 +02:00
parent dbee58cd81
commit ac5ecc9a1c
4 changed files with 303 additions and 23 deletions

View File

@@ -1,17 +1,30 @@
import type { import type {
ButtonInteraction, ButtonInteraction,
ModalSubmitInteraction, ModalSubmitInteraction,
StringSelectMenuInteraction StringSelectMenuInteraction,
UserSelectMenuInteraction
} from 'discord.js'; } from 'discord.js';
import { t, tf } from '@nexumi/shared'; import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import { ephemeral } from '../../interaction-reply.js';
import { import {
addUserToTicket,
assertTicketStaff,
buildOpenModal, buildOpenModal,
claimTicket,
closeTicket,
findTicketByChannel,
isTicketControlInteraction,
openTicketForCategory, openTicketForCategory,
parseFormFields, parseFormFields,
parseRateButton, parseRateButton,
rateTicket, rateTicket,
removeUserFromTicket,
TICKET_CTRL_ADD,
TICKET_CTRL_CLAIM,
TICKET_CTRL_CLOSE,
TICKET_CTRL_REMOVE,
TICKET_MODAL_PREFIX, TICKET_MODAL_PREFIX,
TICKET_OPEN_PREFIX, TICKET_OPEN_PREFIX,
TICKET_RATE_PREFIX, TICKET_RATE_PREFIX,
@@ -24,37 +37,45 @@ export function isTicketInteraction(customId: string): boolean {
customId.startsWith(TICKET_OPEN_PREFIX) || customId.startsWith(TICKET_OPEN_PREFIX) ||
customId === TICKET_SELECT_ID || customId === TICKET_SELECT_ID ||
customId.startsWith(TICKET_MODAL_PREFIX) || customId.startsWith(TICKET_MODAL_PREFIX) ||
customId.startsWith(TICKET_RATE_PREFIX) customId.startsWith(TICKET_RATE_PREFIX) ||
isTicketControlInteraction(customId)
); );
} }
async function replyTicketError( async function replyTicketError(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, interaction:
| ButtonInteraction
| StringSelectMenuInteraction
| ModalSubmitInteraction
| UserSelectMenuInteraction,
locale: 'de' | 'en', locale: 'de' | 'en',
code: string code: string
): Promise<void> { ): Promise<void> {
const content = t(locale, `ticket.error.${code}`); const content = t(locale, `ticket.error.${code}`);
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true }); await interaction.followUp({ content, ...ephemeral() });
} else { } else {
await interaction.reply({ content, ephemeral: true }); await interaction.reply({ content, ...ephemeral() });
} }
} }
export async function handleOpenTicketRequest( async function handleOpenTicketRequest(
interaction: ButtonInteraction | StringSelectMenuInteraction, interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext, context: BotContext,
categoryId: string categoryId: string
): Promise<void> { ): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) { 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; return;
} }
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } }); const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category || category.guildId !== interaction.guildId) { 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; return;
} }
@@ -76,7 +97,7 @@ export async function handleOpenTicketRequest(
const channelRef = ticket.channelId ?? ticket.threadId; const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true ...ephemeral()
}); });
} catch (error) { } catch (error) {
if (error instanceof TicketError) { if (error instanceof TicketError) {
@@ -87,8 +108,94 @@ export async function handleOpenTicketRequest(
} }
} }
async function handleTicketControl(
interaction: ButtonInteraction | UserSelectMenuInteraction,
context: BotContext
): Promise<void> {
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( export async function handleTicketInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, interaction:
| ButtonInteraction
| StringSelectMenuInteraction
| ModalSubmitInteraction
| UserSelectMenuInteraction,
context: BotContext context: BotContext
): Promise<void> { ): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
@@ -96,13 +203,23 @@ export async function handleTicketInteraction(
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) { if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
const categoryId = interaction.values[0]; const categoryId = interaction.values[0];
if (!categoryId) { 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; return;
} }
await handleOpenTicketRequest(interaction, context, categoryId); await handleOpenTicketRequest(interaction, context, categoryId);
return; return;
} }
if (interaction.isButton() || interaction.isUserSelectMenu()) {
if (isTicketControlInteraction(interaction.customId)) {
await handleTicketControl(interaction, context);
return;
}
}
if (interaction.isButton()) { if (interaction.isButton()) {
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) { if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length); const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
@@ -122,7 +239,7 @@ export async function handleTicketInteraction(
if (error instanceof TicketError) { if (error instanceof TicketError) {
await interaction.reply({ await interaction.reply({
content: t(locale, `ticket.error.${error.code}`), content: t(locale, `ticket.error.${error.code}`),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
@@ -134,14 +251,17 @@ export async function handleTicketInteraction(
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) { if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
if (!interaction.inGuild() || !interaction.guild) { 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; return;
} }
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length); const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } }); const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category) { 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; return;
} }
@@ -169,7 +289,7 @@ export async function handleTicketInteraction(
const channelRef = ticket.channelId ?? ticket.threadId; const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }), content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true ...ephemeral()
}); });
} catch (error) { } catch (error) {
if (error instanceof TicketError) { if (error instanceof TicketError) {

View File

@@ -10,12 +10,14 @@ import {
StringSelectMenuBuilder, StringSelectMenuBuilder,
TextInputBuilder, TextInputBuilder,
TextInputStyle, TextInputStyle,
UserSelectMenuBuilder,
type APIMessageComponentEmoji, type APIMessageComponentEmoji,
type Guild, type Guild,
type GuildBasedChannel, type GuildBasedChannel,
type GuildMember, type GuildMember,
type GuildTextBasedChannel, type GuildTextBasedChannel,
type Message, type Message,
type Role,
type SendableChannels, type SendableChannels,
type TextChannel, type TextChannel,
type ThreadChannel type ThreadChannel
@@ -37,6 +39,13 @@ export const TICKET_OPEN_PREFIX = 'ticket:open:';
export const TICKET_SELECT_ID = 'ticket:select'; export const TICKET_SELECT_ID = 'ticket:select';
export const TICKET_MODAL_PREFIX = 'ticket:modal:'; export const TICKET_MODAL_PREFIX = 'ticket:modal:';
export const TICKET_RATE_PREFIX = 'ticket:rate:'; 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({ export const TicketFormFieldSchema = z.object({
id: z.string(), id: z.string(),
@@ -100,6 +109,103 @@ function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | und
return { name: emoji }; 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<GuildMember[]> {
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<string>();
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<ButtonBuilder | UserSelectMenuBuilder>[];
} {
const embed = new EmbedBuilder()
.setTitle(t(locale, 'ticket.control.title'))
.setDescription(t(locale, 'ticket.control.description'))
.setColor(0x6366f1);
const buttons = new ActionRowBuilder<ButtonBuilder>().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<UserSelectMenuBuilder>().addComponents(
new UserSelectMenuBuilder()
.setCustomId(TICKET_CTRL_ADD)
.setPlaceholder(t(locale, 'ticket.control.addPlaceholder'))
.setMinValues(1)
.setMaxValues(1)
);
const removeRow = new ActionRowBuilder<UserSelectMenuBuilder>().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( async function fetchSendableChannel(
context: BotContext, context: BotContext,
channelId: string channelId: string
@@ -548,13 +654,24 @@ export async function createTicketChannel(
reason: `Ticket opened by ${params.opener.user.tag}` reason: `Ticket opened by ${params.opener.user.tag}`
}); });
await thread.members.add(params.opener.id); await thread.members.add(params.opener.id);
for (const roleId of supportRoleIds) {
const role = params.guild.roles.cache.get(roleId); const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds);
if (role) { for (const member of supportMembers) {
for (const [, member] of role.members) { if (member.id === params.opener.id) {
await thread.members.add(member.id).catch(() => undefined); 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; threadId = thread.id;
} else { } else {
@@ -589,10 +706,13 @@ export async function createTicketChannel(
const targetId = channelId ?? threadId!; const targetId = channelId ?? threadId!;
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel; const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' ');
const introLines = [ const introLines = [
roleMentions || null,
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }), tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null 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) { if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
const answerLines = Object.entries(params.formAnswers).map( const answerLines = Object.entries(params.formAnswers).map(
@@ -601,7 +721,17 @@ export async function createTicketChannel(
introLines.push(answerLines.join('\n')); 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; return ticket;
} }

View File

@@ -500,6 +500,22 @@
- [ ] Kategorie ändern/löschen → Panel-Buttons aktualisieren sich - [ ] Kategorie ändern/löschen → Panel-Buttons aktualisieren sich
- [ ] Panel-Kanal wechseln → altes Panel weg, neues im Zielkanal - [ ] 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) ## Post-Phase Ban/Kick-Reason Logging + Verification Fail Event (Status: implementiert)
### Abgeschlossen (Code) ### Abgeschlossen (Code)

View File

@@ -425,6 +425,13 @@ const de: Dictionary = {
'ticket.opened': '{user} hat ein Ticket geöffnet.', 'ticket.opened': '{user} hat ein Ticket geöffnet.',
'ticket.subjectLine': 'Betreff: {subject}', 'ticket.subjectLine': 'Betreff: {subject}',
'ticket.claimed': '{user} hat dieses Ticket übernommen.', '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.userAdded': '{user} wurde zum Ticket hinzugefügt.',
'ticket.userRemoved': '{user} wurde aus dem Ticket entfernt.', 'ticket.userRemoved': '{user} wurde aus dem Ticket entfernt.',
'ticket.renamed': 'Ticket umbenannt in **{name}**.', 'ticket.renamed': 'Ticket umbenannt in **{name}**.',
@@ -1025,6 +1032,13 @@ const en: Dictionary = {
'ticket.opened': '{user} opened a ticket.', 'ticket.opened': '{user} opened a ticket.',
'ticket.subjectLine': 'Subject: {subject}', 'ticket.subjectLine': 'Subject: {subject}',
'ticket.claimed': '{user} claimed this ticket.', '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.userAdded': '{user} was added to the ticket.',
'ticket.userRemoved': '{user} was removed from the ticket.', 'ticket.userRemoved': '{user} was removed from the ticket.',
'ticket.renamed': 'Ticket renamed to **{name}**.', 'ticket.renamed': 'Ticket renamed to **{name}**.',