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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user