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 {
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<void> {
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<void> {
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<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(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
interaction:
| ButtonInteraction
| StringSelectMenuInteraction
| ModalSubmitInteraction
| UserSelectMenuInteraction,
context: BotContext
): Promise<void> {
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) {

View File

@@ -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<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(
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;
}

View File

@@ -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)

View File

@@ -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}**.',