Add giveaway, ticket, self-role, starboard, and suggestion features to the bot
- 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.
This commit is contained in:
886
apps/bot/src/modules/tickets/service.ts
Normal file
886
apps/bot/src/modules/tickets/service.ts
Normal file
@@ -0,0 +1,886 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user