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:
78
apps/bot/src/modules/tickets/command-definitions.ts
Normal file
78
apps/bot/src/modules/tickets/command-definitions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
applyCommandDescription,
|
||||
applyOptionDescription,
|
||||
localizedChoice
|
||||
} from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const ticketCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('ticket')
|
||||
.addSubcommandGroup((group) =>
|
||||
applyCommandDescription(group.setName('panel'), 'ticket.panel.description').addSubcommand(
|
||||
(s) =>
|
||||
applyCommandDescription(s.setName('create'), 'ticket.panel.create.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'ticket.panel.create.options.name')
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('description'),
|
||||
'ticket.panel.create.options.description'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommandGroup((group) =>
|
||||
applyCommandDescription(group.setName('category'), 'ticket.category.description').addSubcommand(
|
||||
(s) =>
|
||||
applyCommandDescription(s.setName('create'), 'ticket.category.create.description')
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('name').setRequired(true),
|
||||
'ticket.category.create.options.name'
|
||||
)
|
||||
)
|
||||
.addRoleOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('support_role').setRequired(true),
|
||||
'ticket.category.create.options.support_role'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('close'), 'ticket.close.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('reason'), 'ticket.close.options.reason')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) => applyCommandDescription(s.setName('claim'), 'ticket.claim.description'))
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('add'), 'ticket.add.description').addUserOption((o) =>
|
||||
applyOptionDescription(o.setName('user').setRequired(true), 'ticket.common.options.user')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('remove'), 'ticket.remove.description').addUserOption((o) =>
|
||||
applyOptionDescription(o.setName('user').setRequired(true), 'ticket.common.options.user')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('rename'), 'ticket.rename.description').addStringOption((o) =>
|
||||
applyOptionDescription(o.setName('name').setRequired(true), 'ticket.rename.options.name')
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('priority'), 'ticket.priority.description').addStringOption(
|
||||
(o) =>
|
||||
applyOptionDescription(o.setName('level').setRequired(true), 'ticket.priority.options.level')
|
||||
.addChoices(
|
||||
localizedChoice('ticket.priority.choice.low', 'LOW'),
|
||||
localizedChoice('ticket.priority.choice.normal', 'NORMAL'),
|
||||
localizedChoice('ticket.priority.choice.high', 'HIGH'),
|
||||
localizedChoice('ticket.priority.choice.urgent', 'URGENT')
|
||||
)
|
||||
)
|
||||
),
|
||||
'ticket.description'
|
||||
);
|
||||
190
apps/bot/src/modules/tickets/commands.ts
Normal file
190
apps/bot/src/modules/tickets/commands.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { ticketCommandData } from './command-definitions.js';
|
||||
import {
|
||||
addUserToTicket,
|
||||
assertTicketStaff,
|
||||
buildPanelComponents,
|
||||
buildPanelEmbed,
|
||||
claimTicket,
|
||||
closeTicket,
|
||||
createCategoryWithRole,
|
||||
findTicketByChannel,
|
||||
getGuildCategories,
|
||||
removeUserFromTicket,
|
||||
renameTicket,
|
||||
setTicketPriority,
|
||||
TicketError,
|
||||
upsertCategoryByName
|
||||
} from './service.js';
|
||||
|
||||
async function ensureManageGuild(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
locale: 'de' | 'en'
|
||||
): Promise<boolean> {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
}
|
||||
|
||||
const ticketCommand: SlashCommand = {
|
||||
data: ticketCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const group = interaction.options.getSubcommandGroup(false);
|
||||
const sub = interaction.options.getSubcommand(false);
|
||||
|
||||
if (group === 'panel' && sub === 'create') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = interaction.options.getString('name', true);
|
||||
const description = interaction.options.getString('description');
|
||||
await upsertCategoryByName(context, interaction.guildId!, name, description);
|
||||
|
||||
const categories = await getGuildCategories(context, interaction.guildId!);
|
||||
if (categories.length === 0) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.invalid_channel'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = buildPanelEmbed(locale, name, description);
|
||||
const components = buildPanelComponents(categories);
|
||||
await channel.send({ embeds: [embed], components });
|
||||
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (group === 'category' && sub === 'create') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = interaction.options.getString('name', true);
|
||||
const role = interaction.options.getRole('support_role', true);
|
||||
const category = await createCategoryWithRole(
|
||||
context,
|
||||
interaction.guildId!,
|
||||
name,
|
||||
role.id
|
||||
);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.category.created', { name: category.name }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
|
||||
try {
|
||||
if (sub === 'close') {
|
||||
const isStaff = await (async () => {
|
||||
try {
|
||||
await assertTicketStaff(member, ticket);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const isOpener = ticket.openerId === interaction.user.id;
|
||||
|
||||
if (!isStaff && !isOpener) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = interaction.options.getString('reason') ?? undefined;
|
||||
await closeTicket(context, ticket, interaction.user.id, locale, reason);
|
||||
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'claim') {
|
||||
await claimTicket(context, ticket, member, locale);
|
||||
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const target = await interaction.guild!.members.fetch(user.id);
|
||||
await addUserToTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const target = await interaction.guild!.members.fetch(user.id);
|
||||
await removeUserFromTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'rename') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
await renameTicket(context, ticket, member, name, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.rename.success', { name }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'priority') {
|
||||
const level = interaction.options.getString('level', true);
|
||||
await setTicketPriority(context, ticket, member, level, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.priority.success', {
|
||||
priority: t(locale, `ticket.priority.${level}`)
|
||||
}),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `ticket.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ticketsCommands = [ticketCommand];
|
||||
12
apps/bot/src/modules/tickets/events.ts
Normal file
12
apps/bot/src/modules/tickets/events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Events } from 'discord.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { touchTicketActivity } from './service.js';
|
||||
|
||||
export function registerTicketEvents(context: BotContext): void {
|
||||
context.client.on(Events.MessageCreate, async (message) => {
|
||||
if (message.author.bot || !message.guildId) {
|
||||
return;
|
||||
}
|
||||
await touchTicketActivity(context, message.channelId);
|
||||
});
|
||||
}
|
||||
4
apps/bot/src/modules/tickets/index.ts
Normal file
4
apps/bot/src/modules/tickets/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ticketsCommands } from './commands.js';
|
||||
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
|
||||
export { closeInactiveTickets, checkInactiveTickets } from './service.js';
|
||||
export { registerTicketEvents } from './events.js';
|
||||
182
apps/bot/src/modules/tickets/interactions.ts
Normal file
182
apps/bot/src/modules/tickets/interactions.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
ButtonInteraction,
|
||||
ModalSubmitInteraction,
|
||||
StringSelectMenuInteraction
|
||||
} from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
buildOpenModal,
|
||||
openTicketForCategory,
|
||||
parseFormFields,
|
||||
parseRateButton,
|
||||
rateTicket,
|
||||
TICKET_MODAL_PREFIX,
|
||||
TICKET_OPEN_PREFIX,
|
||||
TICKET_RATE_PREFIX,
|
||||
TICKET_SELECT_ID,
|
||||
TicketError
|
||||
} from './service.js';
|
||||
|
||||
export function isTicketInteraction(customId: string): boolean {
|
||||
return (
|
||||
customId.startsWith(TICKET_OPEN_PREFIX) ||
|
||||
customId === TICKET_SELECT_ID ||
|
||||
customId.startsWith(TICKET_MODAL_PREFIX) ||
|
||||
customId.startsWith(TICKET_RATE_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
async function replyTicketError(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
||||
locale: 'de' | 'en',
|
||||
code: string
|
||||
): Promise<void> {
|
||||
const content = t(locale, `ticket.error.${code}`);
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOpenTicketRequest(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
context: BotContext,
|
||||
categoryId: string
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!category || category.guildId !== interaction.guildId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = parseFormFields(category.formFields);
|
||||
if (fields.length > 0) {
|
||||
await interaction.showModal(buildOpenModal(category, locale));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
const ticket = await openTicketForCategory(
|
||||
context,
|
||||
interaction.guild,
|
||||
member,
|
||||
categoryId,
|
||||
interaction.channelId ?? undefined
|
||||
);
|
||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await replyTicketError(interaction, locale, error.code);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTicketInteraction(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
|
||||
const categoryId = interaction.values[0];
|
||||
if (!categoryId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await handleOpenTicketRequest(interaction, context, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
|
||||
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
|
||||
await handleOpenTicketRequest(interaction, context, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedRate = parseRateButton(interaction.customId);
|
||||
if (parsedRate) {
|
||||
try {
|
||||
await rateTicket(context, parsedRate.ticketId, interaction.user.id, parsedRate.rating, locale);
|
||||
await interaction.update({
|
||||
content: tf(locale, 'ticket.rated', { rating: parsedRate.rating }),
|
||||
components: []
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `ticket.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
|
||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!category) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = parseFormFields(category.formFields);
|
||||
const formAnswers: Record<string, string> = {};
|
||||
if (fields.length > 0) {
|
||||
for (const field of fields) {
|
||||
formAnswers[field.label] = interaction.fields.getTextInputValue(field.id);
|
||||
}
|
||||
} else {
|
||||
formAnswers[t(locale, 'ticket.modal.subject')] = interaction.fields.getTextInputValue('subject');
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
const ticket = await openTicketForCategory(
|
||||
context,
|
||||
interaction.guild,
|
||||
member,
|
||||
categoryId,
|
||||
interaction.channelId ?? undefined,
|
||||
formAnswers,
|
||||
formAnswers[t(locale, 'ticket.modal.subject')] ?? category.name
|
||||
);
|
||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await replyTicketError(interaction, locale, error.code);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
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