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:
smueller
2026-07-22 13:08:37 +02:00
parent f2f856628d
commit a583db7a15
50 changed files with 7820 additions and 4 deletions

View 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'
);

View 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];

View 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);
});
}

View 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';

View 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;
}
}
}

View 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
);
}