import { z } from 'zod'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, EmbedBuilder, ModalBuilder, PermissionFlagsBits, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, UserSelectMenuBuilder, type APIMessageComponentEmoji, type Guild, type GuildBasedChannel, type GuildMember, type GuildTextBasedChannel, type Message, type Role, type SendableChannels, type TextChannel, type ThreadChannel } from 'discord.js'; import { buildTicketTranscriptHtml, TicketModeSchema, TicketPrioritySchema, t, tf } from '@nexumi/shared'; import type { Ticket, TicketCategory, TicketConfig } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; 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'; export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority'; /** 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(), label: z.string(), style: z.enum(['SHORT', 'PARAGRAPH']).default('SHORT'), required: z.boolean().default(true), placeholder: z.string().optional() }); export type TicketFormField = z.infer; export class TicketError extends Error { constructor(public readonly code: string) { super(code); } } export class TicketPanelError extends Error { constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') { super(code); this.name = 'TicketPanelError'; } } function isUnknownChannelError(error: unknown): boolean { return Boolean( error && typeof error === 'object' && 'code' in error && (error as { code: unknown }).code === 10003 ); } function botCanPostPanel( channel: GuildBasedChannel, meId: string ): channel is GuildTextBasedChannel { if (!channel.isTextBased() || channel.isDMBased()) { return false; } const permissions = channel.permissionsFor(meId); if (!permissions) { return false; } return ( permissions.has(PermissionFlagsBits.ViewChannel) && permissions.has(PermissionFlagsBits.SendMessages) && permissions.has(PermissionFlagsBits.EmbedLinks) ); } function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | undefined { if (!emoji) { return undefined; } const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/); if (customMatch) { return { id: customMatch[3]!, name: customMatch[2], animated: Boolean(customMatch[1]) }; } if (/^\d+$/.test(emoji)) { return { id: 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 { 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 || customId === TICKET_CTRL_PRIORITY ); } export function buildTicketControlPanel(locale: 'de' | 'en'): { embeds: EmbedBuilder[]; components: ActionRowBuilder< ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder >[]; } { 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 priorityRow = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId(TICKET_CTRL_PRIORITY) .setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder')) .addOptions( { label: t(locale, 'ticket.priority.LOW'), value: 'LOW', description: t(locale, 'ticket.control.priorityLowHint') }, { label: t(locale, 'ticket.priority.NORMAL'), value: 'NORMAL', description: t(locale, 'ticket.control.priorityNormalHint') }, { label: t(locale, 'ticket.priority.HIGH'), value: 'HIGH', description: t(locale, 'ticket.control.priorityHighHint') }, { label: t(locale, 'ticket.priority.URGENT'), value: 'URGENT', description: t(locale, 'ticket.control.priorityUrgentHint') } ) ); 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, priorityRow, addRow, removeRow] }; } async function fetchSendableChannel( context: BotContext, channelId: string ): Promise { const channel = await context.client.channels.fetch(channelId).catch(() => null); if (!channel?.isTextBased() || channel.isDMBased()) { return null; } return channel as SendableChannels; } export async function getTicketConfig( prisma: BotContext['prisma'], guildId: string ): Promise { await ensureGuild(prisma, guildId); return prisma.ticketConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); } export function parseFormFields(raw: unknown): TicketFormField[] { if (!Array.isArray(raw)) { return []; } return raw .map((item) => TicketFormFieldSchema.safeParse(item)) .filter((result) => result.success) .map((result) => result.data!) .slice(0, 5); } export function openButtonId(categoryId: string): string { return `${TICKET_OPEN_PREFIX}${categoryId}`; } export function modalId(categoryId: string): string { return `${TICKET_MODAL_PREFIX}${categoryId}`; } export function rateButtonId(ticketId: string, rating: number): string { return `${TICKET_RATE_PREFIX}${ticketId}:${rating}`; } export function parseRateButton(customId: string): { ticketId: string; rating: number } | null { if (!customId.startsWith(TICKET_RATE_PREFIX)) { return null; } const rest = customId.slice(TICKET_RATE_PREFIX.length); const colon = rest.lastIndexOf(':'); if (colon === -1) { return null; } const ticketId = rest.slice(0, colon); const rating = Number.parseInt(rest.slice(colon + 1), 10); if (!ticketId || Number.isNaN(rating) || rating < 1 || rating > 5) { return null; } return { ticketId, rating }; } export async function findTicketByChannel( prisma: BotContext['prisma'], channelId: string ): Promise<(Ticket & { category: TicketCategory | null }) | null> { return prisma.ticket.findFirst({ where: { OR: [{ channelId }, { threadId: channelId }], status: { not: 'CLOSED' } }, include: { category: true } }); } export async function findOpenTicketForUser( prisma: BotContext['prisma'], guildId: string, userId: string ): Promise { return prisma.ticket.findFirst({ where: { guildId, openerId: userId, status: { in: ['OPEN', 'CLAIMED'] } } }); } function isTicketStaff(member: GuildMember, category: TicketCategory | null): boolean { if (member.permissions.has(PermissionFlagsBits.ManageChannels)) { return true; } if (!category) { return false; } return category.supportRoleIds.some((roleId) => member.roles.cache.has(roleId)); } export async function assertTicketStaff( member: GuildMember, ticket: Ticket & { category: TicketCategory | null } ): Promise { if (!isTicketStaff(member, ticket.category)) { throw new TicketError('not_staff'); } } export function buildPanelEmbed( locale: 'de' | 'en', title: string, description?: string | null ): EmbedBuilder { return new EmbedBuilder() .setTitle(title) .setDescription(description ?? t(locale, 'ticket.panel.defaultDescription')) .setColor(0x6366f1); } export function buildPanelComponents(categories: TicketCategory[]): ActionRowBuilder< ButtonBuilder | StringSelectMenuBuilder >[] { if (categories.length === 0) { return []; } if (categories.length <= 5) { const row = new ActionRowBuilder(); for (const category of categories) { const button = new ButtonBuilder() .setCustomId(openButtonId(category.id)) .setLabel(category.name.slice(0, 80)) .setStyle(ButtonStyle.Primary); const emoji = parseButtonEmoji(category.emoji); if (emoji) { button.setEmoji(emoji); } row.addComponents(button); } return [row]; } const select = new StringSelectMenuBuilder() .setCustomId(TICKET_SELECT_ID) .setPlaceholder('Select a category') .addOptions( categories.slice(0, 25).map((category) => { const emoji = parseButtonEmoji(category.emoji); return { label: category.name.slice(0, 100), value: category.id, description: category.description?.slice(0, 100) ?? undefined, emoji }; }) ); return [new ActionRowBuilder().addComponents(select)]; } /** * Posts or updates the ticket panel. Used by `/ticket panel_create` and by the * dashboard via BullMQ (`ticketPanelSync`). */ export async function syncTicketPanel( context: BotContext, guildId: string, options: { channelId?: string | null; title?: string; description?: string | null; } = {} ): Promise<{ panelChannelId: string; panelMessageId: string }> { const locale = await getGuildLocale(context.prisma, guildId); const config = await getTicketConfig(context.prisma, guildId); const categories = await getGuildCategories(context, guildId); if (categories.length === 0) { throw new TicketPanelError('no_categories'); } const targetChannelId = options.channelId || config.panelChannelId; if (!targetChannelId) { throw new TicketPanelError('not_configured'); } const guild = await context.client.guilds.fetch(guildId); const me = guild.members.me; if (!me) { throw new TicketPanelError('missing_permission'); } const channel = await guild.channels.fetch(targetChannelId).catch(() => null); if (!channel || !botCanPostPanel(channel, me.id)) { throw new TicketPanelError('missing_permission'); } const title = options.title ?? t(locale, 'ticket.panel.defaultTitle'); const description = options.description !== undefined ? options.description : t(locale, 'ticket.panel.defaultDescription'); const payload = { embeds: [buildPanelEmbed(locale, title, description)], components: buildPanelComponents(categories) }; const sameChannel = Boolean(config.panelMessageId) && config.panelChannelId === channel.id; if (sameChannel && config.panelMessageId) { try { const existing = await channel.messages.fetch(config.panelMessageId); await existing.edit(payload); if (!config.panelChannelId) { await context.prisma.ticketConfig.update({ where: { guildId }, data: { panelChannelId: channel.id } }); } return { panelChannelId: channel.id, panelMessageId: existing.id }; } catch (error) { logger.warn( { error, guildId, messageId: config.panelMessageId }, 'Failed to edit ticket panel; recreating' ); } } if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) { try { const oldChannel = await guild.channels.fetch(config.panelChannelId); if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) { const oldMessage = await oldChannel.messages.fetch(config.panelMessageId); await oldMessage.delete(); } } catch { // Previous panel may already be gone } } const message = await channel.send(payload); await context.prisma.ticketConfig.update({ where: { guildId }, data: { panelChannelId: channel.id, panelMessageId: message.id } }); return { panelChannelId: channel.id, panelMessageId: message.id }; } export async function runTicketPanelSyncJob( context: BotContext, data: { guildId: string } ): Promise<{ panelChannelId: string; panelMessageId: string }> { return syncTicketPanel(context, data.guildId); } /** * Dashboard ticket actions (close / claim / priority). Staff checks are done by * the WebUI (`requireGuildAccess`); the bot applies Discord side effects. */ export async function runTicketDashboardActionJob( context: BotContext, data: { ticketId: string; guildId: string; actorUserId: string; action: 'close' | 'claim' | 'priority'; reason?: string; priority?: string; } ): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> { const ticket = await context.prisma.ticket.findFirst({ where: { id: data.ticketId, guildId: data.guildId }, include: { category: true } }); if (!ticket) { throw new TicketError('not_found'); } if (ticket.status === 'CLOSED') { throw new TicketError('closed'); } const locale = await getGuildLocale(context.prisma, data.guildId); if (data.action === 'close') { await closeTicket(context, ticket, data.actorUserId, locale, data.reason); const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } }); return { id: closed.id, status: closed.status, priority: closed.priority, claimedById: closed.claimedById }; } if (data.action === 'claim') { if (ticket.claimedById === data.actorUserId) { throw new TicketError('already_claimed'); } const updated = await context.prisma.ticket.update({ where: { id: ticket.id }, data: { claimedById: data.actorUserId, status: 'CLAIMED', lastActivityAt: new Date() } }); const channelId = ticket.channelId ?? ticket.threadId; if (channelId) { const channel = await fetchSendableChannel(context, channelId); if (channel) { await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` })); } } return { id: updated.id, status: updated.status, priority: updated.priority, claimedById: updated.claimedById }; } const parsed = TicketPrioritySchema.parse(data.priority); const updated = await context.prisma.ticket.update({ where: { id: ticket.id }, data: { priority: parsed, lastActivityAt: new Date() } }); const channelId = ticket.channelId ?? ticket.threadId; if (channelId) { const channel = await fetchSendableChannel(context, channelId); if (channel) { await channel.send( tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) }) ); } } return { id: updated.id, status: updated.status, priority: updated.priority, claimedById: updated.claimedById }; } export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder { const fields = parseFormFields(category.formFields); const modal = new ModalBuilder() .setCustomId(modalId(category.id)) .setTitle(category.name.slice(0, 45)); for (const field of fields) { const input = new TextInputBuilder() .setCustomId(field.id) .setLabel(field.label.slice(0, 45)) .setStyle(field.style === 'PARAGRAPH' ? TextInputStyle.Paragraph : TextInputStyle.Short) .setRequired(field.required); if (field.placeholder) { input.setPlaceholder(field.placeholder.slice(0, 100)); } modal.addComponents(new ActionRowBuilder().addComponents(input)); } if (fields.length === 0) { modal.addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId('subject') .setLabel(t(locale, 'ticket.modal.subject')) .setStyle(TextInputStyle.Short) .setRequired(true) .setMaxLength(100) ) ); } return modal; } async function permissionOverwrites( guild: Guild, openerId: string, supportRoleIds: string[] ) { const me = await guild.members.fetchMe(); const overwrites = [ { id: guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel] }, { id: openerId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.EmbedLinks ] }, { id: me.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageThreads, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.EmbedLinks ] } ]; for (const roleId of supportRoleIds) { overwrites.push({ id: roleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.EmbedLinks, PermissionFlagsBits.ManageChannels ] }); } return overwrites; } async function fetchTicketMessages( context: BotContext, ticket: Ticket ): Promise> { const channelId = ticket.channelId ?? ticket.threadId; if (!channelId) { return []; } try { const channel = await context.client.channels.fetch(channelId); if (!channel?.isTextBased()) { return []; } const collected: Message[] = []; let lastId: string | undefined; while (collected.length < 500) { const batch = await channel.messages.fetch({ limit: 100, before: lastId }); if (batch.size === 0) { break; } collected.push(...batch.values()); lastId = batch.last()?.id; } return collected .reverse() .map((message) => ({ author: message.author.tag, content: message.content || `[${message.attachments.size} attachment(s)]`, timestamp: message.createdAt.toISOString() })); } catch (error) { if (isUnknownChannelError(error)) { logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages'); } else { logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages'); } return []; } } function buildRatingComponents(ticketId: string): ActionRowBuilder { const row = new ActionRowBuilder(); for (let rating = 1; rating <= 5; rating += 1) { row.addComponents( new ButtonBuilder() .setCustomId(rateButtonId(ticketId, rating)) .setLabel(String(rating)) .setStyle(ButtonStyle.Secondary) ); } return row; } export async function createTicketChannel( context: BotContext, params: { guild: Guild; opener: GuildMember; category: TicketCategory; config: TicketConfig; parentChannelId?: string; subject?: string; formAnswers?: Record; }, locale: 'de' | 'en' ): Promise { const existing = await findOpenTicketForUser( context.prisma, params.guild.id, params.opener.id ); if (existing) { throw new TicketError('already_open'); } const mode = TicketModeSchema.parse(params.config.mode); const supportRoleIds = params.category.supportRoleIds; const channelName = `ticket-${params.opener.user.username}` .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .slice(0, 90); let channelId: string | null = null; let threadId: string | null = null; if (mode === 'THREAD') { const parentId = params.parentChannelId ?? params.config.categoryId; if (!parentId) { throw new TicketError('no_parent_channel'); } const parent = await params.guild.channels.fetch(parentId); if (!parent?.isTextBased() || parent.isDMBased()) { throw new TicketError('invalid_parent_channel'); } const thread = await (parent as TextChannel).threads.create({ name: channelName, type: ChannelType.PrivateThread, invitable: false, reason: `Ticket opened by ${params.opener.user.tag}` }); await thread.members.add(params.opener.id); 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 { const overwrites = await permissionOverwrites( params.guild, params.opener.id, supportRoleIds ); const channel = await params.guild.channels.create({ name: channelName, type: ChannelType.GuildText, parent: params.config.categoryId ?? undefined, permissionOverwrites: overwrites, reason: `Ticket opened by ${params.opener.user.tag}` }); channelId = channel.id; } const ticket = await context.prisma.ticket.create({ data: { guildId: params.guild.id, categoryId: params.category.id, openerId: params.opener.id, channelId, threadId, subject: params.subject ?? params.category.name, formAnswers: params.formAnswers ?? undefined, status: 'OPEN', lastActivityAt: new Date() } }); 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) as string[]; if (params.formAnswers && Object.keys(params.formAnswers).length > 0) { const answerLines = Object.entries(params.formAnswers).map( ([key, value]) => `**${key}:** ${value}` ); introLines.push(answerLines.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; } export async function claimTicket( context: BotContext, ticket: Ticket & { category: TicketCategory | null }, member: GuildMember, locale: 'de' | 'en' ): Promise { await assertTicketStaff(member, ticket); if (ticket.status === 'CLOSED') { throw new TicketError('closed'); } if (ticket.claimedById === member.id) { throw new TicketError('already_claimed'); } const updated = await context.prisma.ticket.update({ where: { id: ticket.id }, data: { claimedById: member.id, status: 'CLAIMED', lastActivityAt: new Date() } }); const channelId = ticket.channelId ?? ticket.threadId; if (channelId) { const channel = await fetchSendableChannel(context, channelId); if (channel) { await channel.send(tf(locale, 'ticket.claimed', { user: `<@${member.id}>` })); } } return updated; } export async function addUserToTicket( context: BotContext, ticket: Ticket & { category: TicketCategory | null }, member: GuildMember, target: GuildMember, locale: 'de' | 'en' ): Promise { await assertTicketStaff(member, ticket); const channelId = ticket.channelId ?? ticket.threadId; if (!channelId) { throw new TicketError('no_channel'); } const channel = await context.client.channels.fetch(channelId); if (channel?.isThread()) { await channel.members.add(target.id); } else if (channel?.type === ChannelType.GuildText) { await channel.permissionOverwrites.edit(target.id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, AttachFiles: true }); } await context.prisma.ticket.update({ where: { id: ticket.id }, data: { lastActivityAt: new Date() } }); if (channel?.isTextBased()) { const sendable = channel as SendableChannels; await sendable.send(tf(locale, 'ticket.userAdded', { user: `<@${target.id}>` })); } } export async function removeUserFromTicket( context: BotContext, ticket: Ticket & { category: TicketCategory | null }, member: GuildMember, target: GuildMember, locale: 'de' | 'en' ): Promise { await assertTicketStaff(member, ticket); if (target.id === ticket.openerId) { throw new TicketError('cannot_remove_opener'); } const channelId = ticket.channelId ?? ticket.threadId; if (!channelId) { throw new TicketError('no_channel'); } const channel = await context.client.channels.fetch(channelId); if (channel?.isThread()) { await channel.members.remove(target.id); } else if (channel?.type === ChannelType.GuildText) { await channel.permissionOverwrites.delete(target.id); } if (channel?.isTextBased()) { await (channel as SendableChannels).send( tf(locale, 'ticket.userRemoved', { user: `<@${target.id}>` }) ); } } export async function renameTicket( context: BotContext, ticket: Ticket & { category: TicketCategory | null }, member: GuildMember, name: string, locale: 'de' | 'en' ): Promise { await assertTicketStaff(member, ticket); const sanitized = name .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .slice(0, 90); if (!sanitized) { throw new TicketError('invalid_name'); } const channelId = ticket.channelId ?? ticket.threadId; if (!channelId) { throw new TicketError('no_channel'); } const channel = await context.client.channels.fetch(channelId); if (channel && 'setName' in channel) { await channel.setName(sanitized); } await context.prisma.ticket.update({ where: { id: ticket.id }, data: { subject: name, lastActivityAt: new Date() } }); if (channel?.isTextBased()) { await (channel as SendableChannels).send(tf(locale, 'ticket.renamed', { name })); } } export async function setTicketPriority( context: BotContext, ticket: Ticket & { category: TicketCategory | null }, member: GuildMember, priority: string, locale: 'de' | 'en' ): Promise { await assertTicketStaff(member, ticket); const parsed = TicketPrioritySchema.parse(priority); const updated = await context.prisma.ticket.update({ where: { id: ticket.id }, data: { priority: parsed, lastActivityAt: new Date() } }); const channelId = ticket.channelId ?? ticket.threadId; if (channelId) { const channel = await fetchSendableChannel(context, channelId); if (channel) { await channel.send( tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) }) ); } } return updated; } export async function closeTicket( context: BotContext, ticket: Ticket & { category: TicketCategory | null }, closedById: string, locale: 'de' | 'en', reason?: string ): Promise { if (ticket.status === 'CLOSED') { throw new TicketError('closed'); } const config = await getTicketConfig(context.prisma, ticket.guildId); const guild = await context.client.guilds.fetch(ticket.guildId); const opener = await context.client.users.fetch(ticket.openerId).catch(() => null); const messages = await fetchTicketMessages(context, ticket); const transcriptHtml = buildTicketTranscriptHtml({ guildName: guild.name, ticketId: ticket.id, openerTag: opener?.tag ?? ticket.openerId, messages }); await context.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'CLOSED', closedAt: new Date(), transcriptHtml, lastActivityAt: new Date() } }); const channelId = ticket.channelId ?? ticket.threadId; if (channelId) { const channel = await fetchSendableChannel(context, channelId); if (channel) { const closeMessage = reason ? tf(locale, 'ticket.closedWithReason', { user: `<@${closedById}>`, reason }) : tf(locale, 'ticket.closed', { user: `<@${closedById}>` }); await channel.send(closeMessage); } } if (config.logChannelId) { try { const logChannel = (await context.client.channels.fetch( config.logChannelId )) as TextChannel; await logChannel.send({ embeds: [ new EmbedBuilder() .setTitle(t(locale, 'ticket.log.closed')) .setColor(0x6366f1) .addFields( { name: 'ID', value: ticket.id, inline: true }, { name: t(locale, 'ticket.log.opener'), value: `<@${ticket.openerId}>`, inline: true }, { name: t(locale, 'ticket.log.closedBy'), value: `<@${closedById}>`, inline: true } ) ] }); } catch (error) { logger.warn({ error, ticketId: ticket.id }, 'Failed to post ticket close log'); } } if (config.transcriptDm && opener) { try { await opener.send({ content: t(locale, 'ticket.transcriptDm'), files: [{ attachment: Buffer.from(transcriptHtml, 'utf8'), name: `ticket-${ticket.id}.html` }] }); } catch (error) { logger.warn({ error, ticketId: ticket.id }, 'Failed to DM ticket transcript'); } } if (config.ratingEnabled && opener) { try { await opener.send({ content: t(locale, 'ticket.ratePrompt'), components: [buildRatingComponents(ticket.id)] }); } catch { // User may have DMs disabled } } if (channelId) { try { const channel = await context.client.channels.fetch(channelId); if (channel?.isThread()) { await channel.setArchived(true); } else if (channel && 'delete' in channel) { await channel.delete(`Ticket ${ticket.id} closed`); } } catch (error) { if (isUnknownChannelError(error)) { logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting'); } else { logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel'); } } } } export async function rateTicket( context: BotContext, ticketId: string, userId: string, rating: number ): Promise { const ticket = await context.prisma.ticket.findUnique({ where: { id: ticketId } }); if (!ticket || ticket.openerId !== userId) { throw new TicketError('not_found'); } if (ticket.rating !== null) { throw new TicketError('already_rated'); } await context.prisma.ticket.update({ where: { id: ticketId }, data: { rating } }); } export async function touchTicketActivity( context: BotContext, channelId: string ): Promise { const ticket = await context.prisma.ticket.findFirst({ where: { OR: [{ channelId }, { threadId: channelId }], status: { in: ['OPEN', 'CLAIMED'] } } }); if (!ticket) { return; } await context.prisma.ticket.update({ where: { id: ticket.id }, data: { lastActivityAt: new Date() } }); } export async function closeInactiveTickets(context: BotContext): Promise { const configs = await context.prisma.ticketConfig.findMany({ where: { enabled: true } }); let closed = 0; for (const config of configs) { const cutoff = new Date(Date.now() - config.inactivityHours * 3_600_000); const stale = await context.prisma.ticket.findMany({ where: { guildId: config.guildId, status: { in: ['OPEN', 'CLAIMED'] }, lastActivityAt: { lt: cutoff } }, include: { category: true } }); const locale = await getGuildLocale(context.prisma, config.guildId); for (const ticket of stale) { try { await closeTicket(context, ticket, context.client.user!.id, locale, t(locale, 'ticket.autoCloseReason')); closed += 1; } catch (error) { logger.warn({ error, ticketId: ticket.id }, 'Auto-close ticket failed'); } } } return closed; } export const checkInactiveTickets = closeInactiveTickets; export async function upsertCategoryByName( context: BotContext, guildId: string, name: string, description?: string | null ): Promise { await ensureGuild(context.prisma, guildId); return context.prisma.ticketCategory.upsert({ where: { guildId_name: { guildId, name } }, update: { description: description ?? undefined }, create: { guildId, name, description: description ?? null } }); } export async function createCategoryWithRole( context: BotContext, guildId: string, name: string, supportRoleId: string ): Promise { await ensureGuild(context.prisma, guildId); const existing = await context.prisma.ticketCategory.findUnique({ where: { guildId_name: { guildId, name } } }); if (existing) { const roleIds = existing.supportRoleIds.includes(supportRoleId) ? existing.supportRoleIds : [...existing.supportRoleIds, supportRoleId]; return context.prisma.ticketCategory.update({ where: { id: existing.id }, data: { supportRoleIds: roleIds } }); } return context.prisma.ticketCategory.create({ data: { guildId, name, supportRoleIds: [supportRoleId] } }); } export async function getGuildCategories( context: BotContext, guildId: string ): Promise { return context.prisma.ticketCategory.findMany({ where: { guildId }, orderBy: { name: 'asc' } }); } export async function openTicketForCategory( context: BotContext, guild: Guild, member: GuildMember, categoryId: string, parentChannelId?: string, formAnswers?: Record, subject?: string ): Promise { const locale = await getGuildLocale(context.prisma, guild.id); const config = await getTicketConfig(context.prisma, guild.id); if (!config.enabled) { throw new TicketError('disabled'); } const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } }); if (!category || category.guildId !== guild.id) { throw new TicketError('category_not_found'); } return createTicketChannel( context, { guild, opener: member, category, config, parentChannelId, subject, formAnswers }, locale ); }