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:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}**.',
|
||||||
|
|||||||
Reference in New Issue
Block a user