- Introduced new models in the Prisma schema for giveaways, tickets, self-role panels, starboard configurations, and suggestions, enhancing the bot's functionality. - Implemented job handling for self-role expiration and ticket management, improving user experience. - Updated command localization to support new features in both German and English. - Enhanced the job processing system to include dedicated workers for managing tickets and self-role expirations. - Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
887 lines
24 KiB
TypeScript
887 lines
24 KiB
TypeScript
import { z } from 'zod';
|
|
import {
|
|
ActionRowBuilder,
|
|
ButtonBuilder,
|
|
ButtonStyle,
|
|
ChannelType,
|
|
EmbedBuilder,
|
|
ModalBuilder,
|
|
PermissionFlagsBits,
|
|
StringSelectMenuBuilder,
|
|
TextInputBuilder,
|
|
TextInputStyle,
|
|
type Guild,
|
|
type GuildMember,
|
|
type Message,
|
|
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 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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
if (category.emoji) {
|
|
button.setEmoji(category.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) => ({
|
|
label: category.name.slice(0, 100),
|
|
value: category.id,
|
|
description: category.description?.slice(0, 100) ?? undefined,
|
|
emoji: category.emoji ? { name: category.emoji } : undefined
|
|
}))
|
|
);
|
|
|
|
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
|
}
|
|
|
|
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) {
|
|
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function buildRatingComponents(ticketId: string, locale: 'de' | 'en'): 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);
|
|
for (const roleId of supportRoleIds) {
|
|
const role = params.guild.roles.cache.get(roleId);
|
|
if (role) {
|
|
for (const [, member] of role.members) {
|
|
await thread.members.add(member.id).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
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 introLines = [
|
|
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
|
|
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
|
|
].filter(Boolean);
|
|
|
|
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') });
|
|
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, locale)]
|
|
});
|
|
} 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) {
|
|
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function rateTicket(
|
|
context: BotContext,
|
|
ticketId: string,
|
|
userId: string,
|
|
rating: number,
|
|
locale: 'de' | 'en'
|
|
): 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
|
|
);
|
|
}
|