- 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.
1306 lines
36 KiB
TypeScript
1306 lines
36 KiB
TypeScript
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
|
||
);
|
||
}
|