Files
Nexumi/apps/bot/src/modules/tickets/service.ts
TheOnlyMace 5f932f5d63 Refactor ephemeral message handling in ticket interactions
- Updated ticket command and interaction responses to utilize the new `ephemeral()` function for consistent ephemeral message handling.
- Enhanced error handling for member fetching in ticket interactions, ensuring proper responses when members are not found.
- Improved localization for new error messages related to member presence in ticket commands.
- Documented changes in phase tracking to reflect updates in ticket interaction responses.
2026-07-25 16:32:30 +02:00

1306 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof TicketFormFieldSchema>;
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<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 ||
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<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 priorityRow = new ActionRowBuilder<StringSelectMenuBuilder>().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<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, priorityRow, addRow, removeRow]
};
}
async function fetchSendableChannel(
context: BotContext,
channelId: string
): Promise<SendableChannels | null> {
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<TicketConfig> {
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<Ticket | null> {
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<void> {
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<ButtonBuilder>();
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<StringSelectMenuBuilder>().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<TextInputBuilder>().addComponents(input));
}
if (fields.length === 0) {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().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<Array<{ author: string; content: string; timestamp: string }>> {
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<ButtonBuilder> {
const row = new ActionRowBuilder<ButtonBuilder>();
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<string, string>;
},
locale: 'de' | 'en'
): Promise<Ticket> {
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<Ticket> {
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<void> {
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<void> {
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<void> {
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<Ticket> {
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<void> {
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<void> {
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<void> {
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<number> {
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<TicketCategory> {
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<TicketCategory> {
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<TicketCategory[]> {
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<string, string>,
subject?: string
): Promise<Ticket> {
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
);
}