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

@@ -11,6 +11,7 @@ import { refreshPhishingDomains } from './modules/automod/filters.js';
import { completeVerification } from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js';
import { expireSelfRole } from './modules/selfroles/service.js';
import {
automodQueue,
automodQueueName,
@@ -18,6 +19,7 @@ import {
backupQueueName,
moderationQueueName,
reminderQueueName,
ticketQueueName,
verificationQueueName
} from './queues.js';
@@ -49,6 +51,12 @@ type PollCloseJob = {
pollId: string;
};
type SelfRoleExpireJob = {
guildId: string;
userId: string;
roleId: string;
};
export function startWorkers(context: BotContext): Worker[] {
const moderationWorker = new Worker<TempBanJob>(
moderationQueueName,
@@ -135,7 +143,22 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
});
return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker];
const ticketWorker = new Worker<SelfRoleExpireJob>(
ticketQueueName,
async (job) => {
if (job.name !== 'selfRoleExpire') {
return;
}
await expireSelfRole(context, job.data.guildId, job.data.userId, job.data.roleId);
},
{ connection: redis }
);
ticketWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
});
return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker, ticketWorker];
}
export async function ensureRecurringJobs(): Promise<void> {

View File

@@ -0,0 +1,45 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const birthdayCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('birthday')
.addSubcommand((s) =>
applyCommandDescription(s.setName('set'), 'birthdays.set.description')
.addIntegerOption((o) =>
applyOptionDescription(o.setName('month').setRequired(true).setMinValue(1).setMaxValue(12), 'birthdays.set.options.month')
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('day').setRequired(true).setMinValue(1).setMaxValue(31), 'birthdays.set.options.day')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('year').setMinValue(1900).setMaxValue(new Date().getFullYear()),
'birthdays.set.options.year'
)
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('remove'), 'birthdays.remove.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('next'), 'birthdays.next.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'birthdays.list.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'birthdays.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'birthdays.setup.options.channel'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'birthdays.setup.options.role')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('timezone'), 'birthdays.setup.options.timezone')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('message'), 'birthdays.setup.options.message')
)
),
'birthdays.description'
);

View File

@@ -0,0 +1,126 @@
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 { birthdayCommandData } from './command-definitions.js';
import {
BirthdaySetSchema,
daysUntilBirthday,
getBirthdayConfig,
isValidTimezone,
listBirthdays,
removeBirthday,
setBirthday,
updateBirthdayConfig
} from './service.js';
const birthdayCommand: SlashCommand = {
data: birthdayCommandData,
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 guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const channel = interaction.options.getChannel('channel', true);
const role = interaction.options.getRole('role');
const timezone = interaction.options.getString('timezone') ?? 'Europe/Berlin';
const message = interaction.options.getString('message');
if (!isValidTimezone(timezone)) {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidTimezone'), ephemeral: true });
return;
}
await updateBirthdayConfig(context.prisma, guildId, {
enabled: true,
channelId: channel.id,
roleId: role?.id ?? null,
message: message ?? null,
timezone
});
await interaction.reply({ content: t(locale, 'birthdays.setup.done'), ephemeral: true });
return;
}
const config = await getBirthdayConfig(context.prisma, guildId);
if (sub === 'set') {
const month = interaction.options.getInteger('month', true);
const day = interaction.options.getInteger('day', true);
const year = interaction.options.getInteger('year') ?? undefined;
const parsed = BirthdaySetSchema.safeParse({ month, day, year });
if (!parsed.success) {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
return;
}
try {
await setBirthday(context.prisma, guildId, interaction.user.id, parsed.data);
} catch (error) {
if (error instanceof Error && error.message === 'invalid_date') {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
return;
}
throw error;
}
await interaction.reply({
content: tf(locale, 'birthdays.set.success', { month, day, year: year ?? '—' }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const removed = await removeBirthday(context.prisma, guildId, interaction.user.id);
if (!removed) {
await interaction.reply({ content: t(locale, 'birthdays.remove.none'), ephemeral: true });
return;
}
await interaction.reply({ content: t(locale, 'birthdays.remove.success'), ephemeral: true });
return;
}
if (sub === 'list' || sub === 'next') {
const entries = await listBirthdays(context.prisma, guildId, config.timezone);
if (entries.length === 0) {
await interaction.reply({ content: t(locale, 'birthdays.list.empty'), ephemeral: true });
return;
}
const slice = sub === 'next' ? entries.slice(0, 10) : entries.slice(0, 25);
const lines = slice.map((entry, index) => {
const days = daysUntilBirthday(entry.month, entry.day, config.timezone);
const dateLabel = `${String(entry.day).padStart(2, '0')}.${String(entry.month).padStart(2, '0')}`;
const yearLabel = entry.year ? ` (${entry.year})` : '';
return tf(locale, 'birthdays.list.line', {
rank: index + 1,
user: `<@${entry.userId}>`,
date: `${dateLabel}${yearLabel}`,
days
});
});
const header = t(locale, sub === 'next' ? 'birthdays.next.header' : 'birthdays.list.header');
await interaction.reply({
content: `${header}\n${lines.join('\n')}`,
ephemeral: true
});
}
}
};
export const birthdayCommands: SlashCommand[] = [birthdayCommand];

View File

@@ -0,0 +1,2 @@
export { birthdayCommands } from './commands.js';
export { runBirthdayCheck, expireBirthdayRole } from './service.js';

View File

@@ -0,0 +1,304 @@
import { PermissionFlagsBits, type Guild, type GuildMember, type TextChannel } from 'discord.js';
import { z } from 'zod';
import { applyTagPlaceholders, tf } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { birthdayQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export const BirthdaySetSchema = z.object({
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(31),
year: z.number().int().min(1900).max(new Date().getFullYear()).optional()
});
export type BirthdaySetInput = z.infer<typeof BirthdaySetSchema>;
const DEFAULT_MESSAGE = 'Happy birthday {user}! 🎂';
const ROLE_EXPIRE_MS = 24 * 60 * 60 * 1000;
function congratsRedisKey(guildId: string, userId: string, dateKey: string): string {
return `birthday:sent:${guildId}:${userId}:${dateKey}`;
}
export function isValidTimezone(timezone: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
export function getDatePartsInTimezone(
timezone: string,
date = new Date()
): { month: number; day: number; year: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: 'numeric',
day: 'numeric'
}).formatToParts(date);
return {
year: Number(parts.find((p) => p.type === 'year')!.value),
month: Number(parts.find((p) => p.type === 'month')!.value),
day: Number(parts.find((p) => p.type === 'day')!.value)
};
}
export function isValidCalendarDate(month: number, day: number, year?: number): boolean {
const probeYear = year ?? 2000;
const date = new Date(probeYear, month - 1, day);
return date.getFullYear() === probeYear && date.getMonth() === month - 1 && date.getDate() === day;
}
export function computeAge(year: number, month: number, day: number, timezone: string): number {
const today = getDatePartsInTimezone(timezone);
let age = today.year - year;
if (today.month < month || (today.month === month && today.day < day)) {
age -= 1;
}
return age;
}
export function daysUntilBirthday(month: number, day: number, timezone: string): number {
const today = getDatePartsInTimezone(timezone);
let year = today.year;
let candidate = new Date(Date.UTC(year, month - 1, day));
const todayUtc = new Date(Date.UTC(today.year, today.month - 1, today.day));
if (candidate.getTime() < todayUtc.getTime()) {
year += 1;
candidate = new Date(Date.UTC(year, month - 1, day));
}
return Math.round((candidate.getTime() - todayUtc.getTime()) / (24 * 60 * 60 * 1000));
}
export async function getBirthdayConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.birthdayConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.birthdayConfig.create({ data: { guildId } });
}
return config;
}
export async function updateBirthdayConfig(
prisma: BotContext['prisma'],
guildId: string,
data: {
enabled?: boolean;
channelId?: string | null;
roleId?: string | null;
message?: string | null;
timezone?: string;
}
) {
await ensureGuild(prisma, guildId);
return prisma.birthdayConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export async function setBirthday(
prisma: BotContext['prisma'],
guildId: string,
userId: string,
input: BirthdaySetInput
) {
if (!isValidCalendarDate(input.month, input.day, input.year)) {
throw new Error('invalid_date');
}
return prisma.birthday.upsert({
where: { guildId_userId: { guildId, userId } },
update: {
month: input.month,
day: input.day,
year: input.year ?? null
},
create: {
guildId,
userId,
month: input.month,
day: input.day,
year: input.year ?? null
}
});
}
export async function removeBirthday(prisma: BotContext['prisma'], guildId: string, userId: string) {
const existing = await prisma.birthday.findUnique({
where: { guildId_userId: { guildId, userId } }
});
if (!existing) {
return null;
}
await prisma.birthday.delete({ where: { guildId_userId: { guildId, userId } } });
return existing;
}
export async function listBirthdays(prisma: BotContext['prisma'], guildId: string, timezone: string) {
const birthdays = await prisma.birthday.findMany({ where: { guildId } });
return birthdays.sort((a, b) => {
const diff = daysUntilBirthday(a.month, a.day, timezone) - daysUntilBirthday(b.month, b.day, timezone);
if (diff !== 0) {
return diff;
}
return a.userId.localeCompare(b.userId);
});
}
export async function scheduleBirthdayRoleExpire(
guildId: string,
userId: string,
roleId: string
): Promise<void> {
await birthdayQueue.add(
'birthdayRoleExpire',
{ guildId, userId, roleId },
{
delay: ROLE_EXPIRE_MS,
jobId: `birthday-role-${guildId}-${userId}-${Date.now()}`,
removeOnComplete: 1000,
removeOnFail: 500
}
);
}
function renderBirthdayMessage(
template: string | null | undefined,
member: GuildMember,
age?: number
): string {
const vars: Record<string, string> = {
user: `<@${member.id}>`,
'user.name': member.displayName,
'user.tag': member.user.tag
};
if (age !== undefined) {
vars.age = String(age);
}
return applyTagPlaceholders(template?.trim() || DEFAULT_MESSAGE, vars);
}
async function sendBirthdayCongrats(
context: BotContext,
guild: Guild,
config: Awaited<ReturnType<typeof getBirthdayConfig>>,
birthday: { userId: string; month: number; day: number; year: number | null },
dateKey: string
): Promise<void> {
if (!config.channelId) {
return;
}
const dedupeKey = congratsRedisKey(guild.id, birthday.userId, dateKey);
const alreadySent = await context.redis.get(dedupeKey);
if (alreadySent) {
return;
}
const channel = await guild.channels.fetch(config.channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
logger.warn({ guildId: guild.id, channelId: config.channelId }, 'Birthday channel invalid');
return;
}
const member = await guild.members.fetch(birthday.userId).catch(() => null);
if (!member) {
return;
}
const age =
birthday.year !== null
? computeAge(birthday.year, birthday.month, birthday.day, config.timezone)
: undefined;
const content = renderBirthdayMessage(config.message, member, age);
await (channel as TextChannel).send({ content });
if (config.roleId) {
const me = guild.members.me;
const role = guild.roles.cache.get(config.roleId);
if (
me?.permissions.has(PermissionFlagsBits.ManageRoles) &&
role &&
role.position < me.roles.highest.position &&
!member.roles.cache.has(role.id)
) {
await member.roles.add(role).catch((error) => {
logger.warn({ error, guildId: guild.id, userId: birthday.userId }, 'Failed to assign birthday role');
});
await scheduleBirthdayRoleExpire(guild.id, birthday.userId, role.id);
}
}
await context.redis.set(dedupeKey, '1', 'EX', 60 * 60 * 26);
logger.info({ guildId: guild.id, userId: birthday.userId }, 'Birthday congratulations sent');
}
export async function expireBirthdayRole(
context: BotContext,
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
return;
}
const me = guild.members.me;
const role = guild.roles.cache.get(roleId);
if (
!me?.permissions.has(PermissionFlagsBits.ManageRoles) ||
!role ||
role.position >= me.roles.highest.position
) {
return;
}
if (member.roles.cache.has(roleId)) {
await member.roles.remove(roleId).catch((error) => {
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove birthday role');
});
}
}
export async function runBirthdayCheck(context: BotContext): Promise<void> {
const configs = await context.prisma.birthdayConfig.findMany({
where: { enabled: true, channelId: { not: null } }
});
for (const config of configs) {
try {
const guild = await context.client.guilds.fetch(config.guildId).catch(() => null);
if (!guild) {
continue;
}
const { month, day, year } = getDatePartsInTimezone(config.timezone);
const dateKey = `${year}-${month}-${day}`;
const birthdays = await context.prisma.birthday.findMany({
where: { guildId: config.guildId, month, day }
});
await Promise.all(
birthdays.map((birthday) => sendBirthdayCongrats(context, guild, config, birthday, dateKey))
);
} catch (error) {
logger.error({ error, guildId: config.guildId }, 'Birthday check failed for guild');
}
}
}

View File

@@ -0,0 +1,42 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { GiveawayError, joinGiveaway } from './service.js';
export const GIVEAWAY_JOIN_PREFIX = 'gw:join:';
export function joinButtonId(giveawayId: string): string {
return `${GIVEAWAY_JOIN_PREFIX}${giveawayId}`;
}
export function isGiveawayButton(customId: string): boolean {
return customId.startsWith(GIVEAWAY_JOIN_PREFIX);
}
export async function handleGiveawayButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const giveawayId = interaction.customId.slice(GIVEAWAY_JOIN_PREFIX.length);
if (!giveawayId || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'giveaway.error.not_found'), ephemeral: true });
return;
}
try {
await joinGiveaway(context, giveawayId, interaction.user.id, locale);
await interaction.reply({ content: t(locale, 'giveaway.joined'), ephemeral: true });
} catch (error) {
if (error instanceof GiveawayError) {
await interaction.reply({
content: t(locale, `giveaway.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}

View File

@@ -0,0 +1,61 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const giveawayCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('giveaway')
.addSubcommand((s) =>
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('duration').setRequired(true), 'giveaway.start.options.duration')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('winners').setRequired(true).setMinValue(1).setMaxValue(25),
'giveaway.start.options.winners'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('prize').setRequired(true), 'giveaway.start.options.prize')
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'giveaway.start.options.role')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('level').setMinValue(0).setMaxValue(500),
'giveaway.start.options.level'
)
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('member_days').setMinValue(0).setMaxValue(3650),
'giveaway.start.options.member_days'
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('end'), 'giveaway.end.description').addStringOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('reroll'), 'giveaway.reroll.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('list'), 'giveaway.list.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('delete'), 'giveaway.delete.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('pause'), 'giveaway.pause.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
),
'giveaway.description'
);

View File

@@ -0,0 +1,166 @@
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 { parseDuration } from '../moderation/duration.js';
import { giveawayCommandData } from './command-definitions.js';
import {
deleteGiveaway,
endGiveaway,
GiveawayError,
listActiveGiveaways,
pauseGiveaway,
rerollGiveaway,
startGiveaway
} 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 giveawayCommand: SlashCommand = {
data: giveawayCommandData,
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 sub = interaction.options.getSubcommand();
if (sub === 'start') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const durationInput = interaction.options.getString('duration', true);
let durationMs: number;
try {
durationMs = parseDuration(durationInput);
} catch {
await interaction.reply({
content: t(locale, 'giveaway.error.invalid_duration'),
ephemeral: true
});
return;
}
if (durationMs < 60_000) {
await interaction.reply({
content: t(locale, 'giveaway.error.duration_too_short'),
ephemeral: true
});
return;
}
const channel = interaction.channel;
if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'giveaway.error.invalid_channel'),
ephemeral: true
});
return;
}
const giveaway = await startGiveaway(
context,
{
guildId: interaction.guildId!,
channelId: channel.id,
hostId: interaction.user.id,
prize: interaction.options.getString('prize', true),
winnerCount: interaction.options.getInteger('winners', true),
durationMs,
requiredRoleId: interaction.options.getRole('role')?.id,
requiredLevel: interaction.options.getInteger('level'),
requiredMemberDays: interaction.options.getInteger('member_days')
},
locale
);
await interaction.reply({
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
ephemeral: true
});
return;
}
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
try {
if (sub === 'end') {
const id = interaction.options.getString('id', true);
await endGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
return;
}
if (sub === 'reroll') {
const id = interaction.options.getString('id', true);
const updated = await rerollGiveaway(context, id, locale);
await interaction.reply({
content: tf(locale, 'giveaway.rerolled', {
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
}),
ephemeral: true
});
return;
}
if (sub === 'list') {
const active = await listActiveGiveaways(context, interaction.guildId!);
if (active.length === 0) {
await interaction.reply({ content: t(locale, 'giveaway.list.empty'), ephemeral: true });
return;
}
const lines = active.map(
(g) =>
`\`${g.id}\` — **${g.prize}** (${g.entrants.length} ${t(locale, 'giveaway.list.entrants')}, <t:${Math.floor(g.endsAt.getTime() / 1000)}:R>)`
);
await interaction.reply({
content: `${t(locale, 'giveaway.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'delete') {
const id = interaction.options.getString('id', true);
await deleteGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
return;
}
if (sub === 'pause') {
const id = interaction.options.getString('id', true);
const updated = await pauseGiveaway(context, id, locale);
await interaction.reply({
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof GiveawayError) {
await interaction.reply({
content: t(locale, `giveaway.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const giveawayCommands = [giveawayCommand];

View File

@@ -0,0 +1,3 @@
export { giveawayCommands } from './commands.js';
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
export { endGiveaway, scheduleGiveawayEnd } from './service.js';

View File

@@ -0,0 +1,419 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
type Guild,
type GuildMember,
type TextChannel
} from 'discord.js';
import { pickRandomWinners, t, tf } from '@nexumi/shared';
import type { Giveaway } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { giveawayQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { joinButtonId } from './buttons.js';
export class GiveawayError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
const delay = Math.max(0, endsAt.getTime() - Date.now());
await giveawayQueue.add(
'giveawayEnd',
{ giveawayId },
{
delay,
jobId: `giveaway-end-${giveawayId}`,
removeOnComplete: true,
removeOnFail: 50
}
);
}
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
await job?.remove();
}
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
const lines: string[] = [];
if (giveaway.requiredRoleId) {
lines.push(tf(locale, 'giveaway.requirement.role', { role: `<@&${giveaway.requiredRoleId}>` }));
}
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
lines.push(tf(locale, 'giveaway.requirement.level', { level: giveaway.requiredLevel }));
}
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
lines.push(
tf(locale, 'giveaway.requirement.memberDays', { days: giveaway.requiredMemberDays })
);
}
return lines;
}
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
const ended = giveaway.endedAt !== null;
const embed = new EmbedBuilder()
.setTitle(t(locale, 'giveaway.embed.title'))
.setColor(ended ? 0x6b7280 : 0x6366f1)
.addFields(
{ name: t(locale, 'giveaway.embed.prize'), value: giveaway.prize, inline: true },
{
name: t(locale, 'giveaway.embed.winners'),
value: String(giveaway.winnerCount),
inline: true
},
{
name: t(locale, 'giveaway.embed.entrants'),
value: String(giveaway.entrants.length),
inline: true
}
);
if (ended) {
embed.setDescription(
giveaway.winners.length > 0
? tf(locale, 'giveaway.embed.endedWinners', {
winners: giveaway.winners.map((id) => `<@${id}>`).join(', ')
})
: t(locale, 'giveaway.embed.endedNoWinners')
);
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
} else if (giveaway.paused) {
embed.setDescription(t(locale, 'giveaway.embed.paused'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
} else {
embed.setDescription(t(locale, 'giveaway.embed.active'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
}
const requirements = requirementLines(locale, giveaway);
if (requirements.length > 0) {
embed.addFields({
name: t(locale, 'giveaway.embed.requirements'),
value: requirements.join('\n')
});
}
embed.addFields({
name: t(locale, 'giveaway.embed.host'),
value: `<@${giveaway.hostId}>`,
inline: true
});
return embed;
}
export function buildGiveawayComponents(
locale: 'de' | 'en',
giveaway: Giveaway
): ActionRowBuilder<ButtonBuilder>[] {
if (giveaway.endedAt) {
return [];
}
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(joinButtonId(giveaway.id))
.setLabel(t(locale, 'giveaway.button.join'))
.setStyle(ButtonStyle.Primary)
.setDisabled(giveaway.paused)
)
];
}
async function updateGiveawayMessage(
context: BotContext,
giveaway: Giveaway,
locale: 'de' | 'en'
): Promise<void> {
if (!giveaway.messageId) {
return;
}
try {
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
const message = await channel.messages.fetch(giveaway.messageId);
await message.edit({
embeds: [buildGiveawayEmbed(locale, giveaway)],
components: buildGiveawayComponents(locale, giveaway)
});
} catch (error) {
logger.warn({ error, giveawayId: giveaway.id }, 'Failed to update giveaway message');
}
}
export async function checkGiveawayRequirements(
context: BotContext,
guild: Guild,
member: GuildMember,
giveaway: Giveaway
): Promise<string | null> {
if (giveaway.requiredRoleId && !member.roles.cache.has(giveaway.requiredRoleId)) {
return 'missing_role';
}
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
const levelRow = await context.prisma.memberLevel.findUnique({
where: { guildId_userId: { guildId: guild.id, userId: member.id } }
});
const level = levelRow?.level ?? 0;
if (level < giveaway.requiredLevel) {
return 'insufficient_level';
}
}
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
const joinedAt = member.joinedAt;
const days = joinedAt
? Math.floor((Date.now() - joinedAt.getTime()) / 86_400_000)
: 0;
if (days < giveaway.requiredMemberDays) {
return 'insufficient_member_days';
}
}
return null;
}
export async function startGiveaway(
context: BotContext,
params: {
guildId: string;
channelId: string;
hostId: string;
prize: string;
winnerCount: number;
durationMs: number;
requiredRoleId?: string | null;
requiredLevel?: number | null;
requiredMemberDays?: number | null;
},
locale: 'de' | 'en'
): Promise<Giveaway> {
await ensureGuild(context.prisma, params.guildId);
const endsAt = new Date(Date.now() + params.durationMs);
const giveaway = await context.prisma.giveaway.create({
data: {
guildId: params.guildId,
channelId: params.channelId,
prize: params.prize,
winnerCount: params.winnerCount,
endsAt,
requiredRoleId: params.requiredRoleId ?? null,
requiredLevel: params.requiredLevel ?? null,
requiredMemberDays: params.requiredMemberDays ?? null,
hostId: params.hostId
}
});
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({
embeds: [buildGiveawayEmbed(locale, giveaway)],
components: buildGiveawayComponents(locale, giveaway)
});
const updated = await context.prisma.giveaway.update({
where: { id: giveaway.id },
data: { messageId: message.id }
});
await scheduleGiveawayEnd(updated.id, updated.endsAt);
return updated;
}
export async function joinGiveaway(
context: BotContext,
giveawayId: string,
userId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
throw new GiveawayError('not_found');
}
if (giveaway.paused) {
throw new GiveawayError('paused');
}
if (giveaway.entrants.includes(userId)) {
throw new GiveawayError('already_joined');
}
const guild = await context.client.guilds.fetch(giveaway.guildId);
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
throw new GiveawayError('member_not_found');
}
const requirementError = await checkGiveawayRequirements(context, guild, member, giveaway);
if (requirementError) {
throw new GiveawayError(requirementError);
}
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { entrants: { push: userId } }
});
await updateGiveawayMessage(context, updated, locale);
return updated;
}
async function filterEligibleEntrants(
context: BotContext,
guild: Guild,
giveaway: Giveaway
): Promise<string[]> {
const eligible: string[] = [];
for (const userId of giveaway.entrants) {
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
continue;
}
const error = await checkGiveawayRequirements(context, guild, member, giveaway);
if (!error) {
eligible.push(userId);
}
}
return eligible;
}
async function dmWinners(
context: BotContext,
giveaway: Giveaway,
winnerIds: string[],
locale: 'de' | 'en'
): Promise<void> {
for (const userId of winnerIds) {
try {
const user = await context.client.users.fetch(userId);
await user.send(
tf(locale, 'giveaway.dm.won', {
prize: giveaway.prize,
guild: (await context.client.guilds.fetch(giveaway.guildId)).name
})
);
} catch (error) {
logger.warn({ error, userId, giveawayId: giveaway.id }, 'Failed to DM giveaway winner');
}
}
}
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
return;
}
await cancelGiveawayJob(giveawayId);
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = await filterEligibleEntrants(context, guild, giveaway);
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: {
endedAt: new Date(),
winners,
paused: false
}
});
await updateGiveawayMessage(context, updated, locale);
if (winners.length > 0) {
await dmWinners(context, updated, winners, locale);
}
}
export async function rerollGiveaway(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || !giveaway.endedAt) {
throw new GiveawayError('not_ended');
}
const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = (await filterEligibleEntrants(context, guild, giveaway)).filter(
(id) => !giveaway.winners.includes(id)
);
const newWinners = pickRandomWinners(eligible, giveaway.winnerCount);
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { winners: newWinners }
});
await updateGiveawayMessage(context, updated, locale);
if (newWinners.length > 0) {
await dmWinners(context, updated, newWinners, locale);
}
return updated;
}
export async function pauseGiveaway(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
throw new GiveawayError('not_found');
}
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { paused: !giveaway.paused }
});
await updateGiveawayMessage(context, updated, locale);
return updated;
}
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) {
throw new GiveawayError('not_found');
}
await cancelGiveawayJob(giveawayId);
if (giveaway.messageId) {
try {
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
const message = await channel.messages.fetch(giveaway.messageId);
await message.delete();
} catch {
// Message may already be gone
}
}
await context.prisma.giveaway.delete({ where: { id: giveawayId } });
}
export async function listActiveGiveaways(
context: BotContext,
guildId: string
): Promise<Giveaway[]> {
return context.prisma.giveaway.findMany({
where: { guildId, endedAt: null },
orderBy: { endsAt: 'asc' }
});
}

View File

@@ -0,0 +1,95 @@
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { ChannelType, SlashCommandBuilder } from 'discord.js';
export const selfrolesCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('selfroles')
.addSubcommandGroup((group) =>
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'selfroles.panel.create.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('title').setRequired(true), 'selfroles.panel.create.options.title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('mode').setRequired(true), 'selfroles.panel.create.options.mode')
.addChoices(
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('behavior').setRequired(true),
'selfroles.panel.create.options.behavior'
)
.addChoices(
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('roles').setRequired(true), 'selfroles.panel.create.options.roles')
)
.addChannelOption((o) =>
applyOptionDescription(o.setName('channel').setRequired(true), 'selfroles.panel.create.options.channel')
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('edit'), 'selfroles.panel.edit.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('title'), 'selfroles.panel.create.options.title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('mode'), 'selfroles.panel.create.options.mode')
.addChoices(
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('behavior'), 'selfroles.panel.create.options.behavior')
.addChoices(
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('roles'), 'selfroles.panel.create.options.roles')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'selfroles.panel.delete.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
)
)
),
'selfroles.description'
);

View File

@@ -0,0 +1,203 @@
import { PermissionFlagsBits } from 'discord.js';
import {
SelfRoleBehaviorSchema,
SelfRoleModeSchema,
t,
tf
} from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { parseDuration } from '../moderation/duration.js';
import { selfrolesCommandData } from './command-definitions.js';
import {
createSelfRolePanel,
deleteSelfRolePanel,
parseRolesInput,
SelfRoleError,
updateSelfRolePanel
} 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);
}
function parseOptionalDuration(
input: string | null,
locale: 'de' | 'en',
interaction: Parameters<SlashCommand['execute']>[0]
): number | undefined | null {
if (!input) {
return undefined;
}
try {
return parseDuration(input);
} catch {
interaction.reply({
content: t(locale, 'selfroles.error.invalid_duration'),
ephemeral: true
});
return null;
}
}
const selfrolesCommand: SlashCommand = {
data: selfrolesCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const group = interaction.options.getSubcommandGroup(true);
const sub = interaction.options.getSubcommand(true);
if (group !== 'panel') {
return;
}
try {
if (sub === 'create') {
const behavior = SelfRoleBehaviorSchema.parse(
interaction.options.getString('behavior', true)
);
const mode = SelfRoleModeSchema.parse(interaction.options.getString('mode', true));
const channel = interaction.options.getChannel('channel', true);
if (!channel.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'selfroles.error.invalid_channel'),
ephemeral: true
});
return;
}
let temporaryDurationMs: number | undefined;
if (behavior === 'TEMPORARY') {
const durationInput = interaction.options.getString('duration', true);
const parsed = parseOptionalDuration(durationInput, locale, interaction);
if (parsed === null) {
return;
}
if (!parsed || parsed < 60_000) {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_too_short'),
ephemeral: true
});
return;
}
temporaryDurationMs = parsed;
}
const roles = parseRolesInput(interaction.options.getString('roles', true));
const panel = await createSelfRolePanel(
context,
{
guildId: interaction.guildId!,
channelId: channel.id,
title: interaction.options.getString('title', true),
description: interaction.options.getString('description'),
mode,
behavior,
roles,
temporaryDurationMs
},
locale
);
await interaction.reply({
content: tf(locale, 'selfroles.panel.created', { id: panel.id }),
ephemeral: true
});
return;
}
if (sub === 'edit') {
const panelId = interaction.options.getString('panel_id', true);
const behaviorRaw = interaction.options.getString('behavior');
const behavior = behaviorRaw ? SelfRoleBehaviorSchema.parse(behaviorRaw) : undefined;
const modeRaw = interaction.options.getString('mode');
const mode = modeRaw ? SelfRoleModeSchema.parse(modeRaw) : undefined;
let roles;
const rolesInput = interaction.options.getString('roles');
if (rolesInput) {
roles = parseRolesInput(rolesInput);
}
let temporaryDurationMs: number | undefined;
const durationInput = interaction.options.getString('duration');
if (durationInput) {
const parsed = parseOptionalDuration(durationInput, locale, interaction);
if (parsed === null) {
return;
}
if (!parsed || parsed < 60_000) {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_too_short'),
ephemeral: true
});
return;
}
temporaryDurationMs = parsed;
} else if (behavior === 'TEMPORARY') {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_required'),
ephemeral: true
});
return;
}
const descriptionValue = interaction.options.getString('description');
const panel = await updateSelfRolePanel(
context,
panelId,
interaction.guildId!,
{
title: interaction.options.getString('title') ?? undefined,
description: descriptionValue !== null ? descriptionValue : undefined,
mode,
behavior,
roles,
temporaryDurationMs
},
locale
);
await interaction.reply({
content: tf(locale, 'selfroles.panel.updated', { id: panel.id }),
ephemeral: true
});
return;
}
if (sub === 'delete') {
const panelId = interaction.options.getString('panel_id', true);
await deleteSelfRolePanel(context, panelId, interaction.guildId!);
await interaction.reply({
content: t(locale, 'selfroles.panel.deleted'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof SelfRoleError) {
await interaction.reply({
content: t(locale, `selfroles.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const selfrolesCommands = [selfrolesCommand];

View File

@@ -0,0 +1,8 @@
export { selfrolesCommands } from './commands.js';
export { isSelfRoleInteraction, handleSelfRoleInteraction } from './interactions.js';
export { registerSelfRoleEvents } from './reactions.js';
export {
expireSelfRole,
scheduleSelfRoleExpiry,
cancelSelfRoleExpiry
} from './service.js';

View File

@@ -0,0 +1,132 @@
import type { ButtonInteraction, StringSelectMenuInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
SELF_ROLE_BTN_PREFIX,
SELF_ROLE_SEL_PREFIX,
SelfRoleError,
toggleSelfRole
} from './service.js';
export function isSelfRoleInteraction(customId: string): boolean {
return customId.startsWith(SELF_ROLE_BTN_PREFIX) || customId.startsWith(SELF_ROLE_SEL_PREFIX);
}
function parseButtonCustomId(customId: string): { panelId: string; roleId: string } | null {
if (!customId.startsWith(SELF_ROLE_BTN_PREFIX)) {
return null;
}
const rest = customId.slice(SELF_ROLE_BTN_PREFIX.length);
const colon = rest.indexOf(':');
if (colon === -1) {
return null;
}
const panelId = rest.slice(0, colon);
const roleId = rest.slice(colon + 1);
if (!panelId || !roleId) {
return null;
}
return { panelId, roleId };
}
function parseSelectCustomId(customId: string): string | null {
if (!customId.startsWith(SELF_ROLE_SEL_PREFIX)) {
return null;
}
return customId.slice(SELF_ROLE_SEL_PREFIX.length) || null;
}
async function replySelfRoleResult(
interaction: ButtonInteraction | StringSelectMenuInteraction,
locale: 'de' | 'en',
result: 'added' | 'removed' | 'unchanged',
behavior: string
): Promise<void> {
let key: string;
if (result === 'added') {
key = behavior === 'VERIFY' ? 'selfroles.role.verified' : 'selfroles.role.added';
} else if (result === 'removed') {
key = 'selfroles.role.removed';
} else {
key = 'selfroles.role.unchanged';
}
const content = t(locale, key);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true });
} else {
await interaction.reply({ content, ephemeral: true });
}
}
async function handleRoleToggle(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext,
panelId: string,
roleId: 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 panel = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId: interaction.guild.id }
});
if (!panel) {
await interaction.reply({ content: t(locale, 'selfroles.error.not_found'), ephemeral: true });
return;
}
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({ content: t(locale, 'selfroles.error.member_not_found'), ephemeral: true });
return;
}
try {
const result = await toggleSelfRole(context, panel, member, roleId);
await replySelfRoleResult(interaction, locale, result, panel.behavior);
} catch (error) {
if (error instanceof SelfRoleError) {
await interaction.reply({
content: t(locale, `selfroles.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
export async function handleSelfRoleInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext
): Promise<void> {
if (interaction.isButton()) {
const parsed = parseButtonCustomId(interaction.customId);
if (!parsed) {
return;
}
await handleRoleToggle(interaction, context, parsed.panelId, parsed.roleId);
return;
}
const panelId = parseSelectCustomId(interaction.customId);
if (!panelId) {
return;
}
const roleId = interaction.values[0];
if (!roleId) {
return;
}
await handleRoleToggle(interaction, context, panelId, roleId);
}

View File

@@ -0,0 +1,132 @@
import { Events, type Client, type PartialUser, type User } from 'discord.js';
import { SelfRoleModeSchema, t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
import {
findPanelByMessageId,
matchReactionEntry,
parseRolesJson,
SelfRoleError,
toggleSelfRole
} from './service.js';
async function handleReactionToggle(
context: BotContext,
messageId: string,
userId: string,
emojiId: string | null,
emojiName: string,
add: boolean
): Promise<void> {
const panel = await findPanelByMessageId(context, messageId);
if (!panel || SelfRoleModeSchema.parse(panel.mode) !== 'REACTIONS') {
return;
}
const { entries } = parseRolesJson(panel.roles);
const entry = matchReactionEntry(entries, emojiId, emojiName);
if (!entry) {
return;
}
const guild = await context.client.guilds.fetch(panel.guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member || member.user.bot) {
return;
}
const behavior = panel.behavior;
const hasRole = member.roles.cache.has(entry.roleId);
if (add) {
if (behavior === 'VERIFY' || behavior === 'TEMPORARY') {
if (hasRole) {
return;
}
} else if (behavior === 'NORMAL' && hasRole) {
return;
}
} else {
if (behavior === 'VERIFY') {
return;
}
if (!hasRole) {
return;
}
}
try {
await toggleSelfRole(context, panel, member, entry.roleId);
} catch (error) {
if (error instanceof SelfRoleError) {
const locale = await getGuildLocale(context.prisma, panel.guildId);
try {
const user = await context.client.users.fetch(userId);
await user.send(t(locale, `selfroles.error.${error.code}`));
} catch {
// User may have DMs disabled
}
return;
}
logger.error({ error, panelId: panel.id, userId }, 'Self-role reaction handler failed');
}
}
export function registerSelfRoleEvents(client: Client, context: BotContext): void {
client.on(Events.MessageReactionAdd, async (reaction, user: User | PartialUser) => {
try {
if (user.bot || !reaction.message.guildId) {
return;
}
const message = reaction.message.partial
? await reaction.message.fetch().catch(() => null)
: reaction.message;
if (!message) {
return;
}
await handleReactionToggle(
context,
message.id,
user.id,
reaction.emoji.id,
reaction.emoji.name ?? '',
true
);
} catch (error) {
logger.error({ error }, 'Self-role reaction add failed');
}
});
client.on(Events.MessageReactionRemove, async (reaction: PartialMessageReaction, user: PartialUser) => {
try {
if (user.bot || !reaction.message.guildId) {
return;
}
const message = reaction.message.partial
? await reaction.message.fetch().catch(() => null)
: reaction.message;
if (!message) {
return;
}
await handleReactionToggle(
context,
message.id,
user.id,
reaction.emoji.id,
reaction.emoji.name ?? '',
false
);
} catch (error) {
logger.error({ error }, 'Self-role reaction remove failed');
}
});
}

View File

@@ -0,0 +1,602 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
PermissionFlagsBits,
StringSelectMenuBuilder,
type APIMessageComponentEmoji,
type GuildMember,
type Message,
type TextChannel
} from 'discord.js';
import {
SelfRoleBehaviorSchema,
SelfRoleEntrySchema,
SelfRoleModeSchema,
type SelfRoleBehavior,
type SelfRoleEntry,
type SelfRoleMode,
t
} from '@nexumi/shared';
import type { SelfRolePanel } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { ticketQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export class SelfRoleError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export const SELF_ROLE_BTN_PREFIX = 'sr:btn:';
export const SELF_ROLE_SEL_PREFIX = 'sr:sel:';
type RolesStorage = {
entries: SelfRoleEntry[];
temporaryDurationMs?: number;
};
export function parseRolesInput(input: string): SelfRoleEntry[] {
const parts = input
.split(',')
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
throw new SelfRoleError('invalid_roles');
}
const entries: SelfRoleEntry[] = [];
for (const part of parts) {
const segments = part.split(':');
if (segments.length < 2) {
throw new SelfRoleError('invalid_roles');
}
const [roleId, label, emoji] = segments;
if (!roleId || !label) {
throw new SelfRoleError('invalid_roles');
}
entries.push(
SelfRoleEntrySchema.parse({
roleId: roleId.trim(),
label: label.trim(),
emoji: emoji?.trim() || undefined
})
);
}
if (entries.length > 25) {
throw new SelfRoleError('too_many_roles');
}
return entries;
}
export function parseRolesJson(raw: unknown): RolesStorage {
if (Array.isArray(raw)) {
return {
entries: raw
.map((item) => SelfRoleEntrySchema.safeParse(item))
.filter((result) => result.success)
.map((result) => result.data!)
};
}
if (raw && typeof raw === 'object') {
const obj = raw as { entries?: unknown; temporaryDurationMs?: unknown };
const entries = Array.isArray(obj.entries)
? obj.entries
.map((item) => SelfRoleEntrySchema.safeParse(item))
.filter((result) => result.success)
.map((result) => result.data!)
: [];
const temporaryDurationMs =
typeof obj.temporaryDurationMs === 'number' && obj.temporaryDurationMs > 0
? obj.temporaryDurationMs
: undefined;
return { entries, temporaryDurationMs };
}
return { entries: [] };
}
export function serializeRolesJson(
entries: SelfRoleEntry[],
temporaryDurationMs?: number
): RolesStorage {
if (temporaryDurationMs !== undefined) {
return { entries, temporaryDurationMs };
}
return { entries };
}
function parseButtonEmoji(
emoji?: string
): APIMessageComponentEmoji | undefined {
if (!emoji) {
return undefined;
}
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
return {
id: customMatch[3]!,
name: customMatch[2],
animated: Boolean(customMatch[1])
};
}
if (/^\d+$/.test(emoji)) {
return { id: emoji };
}
return { name: emoji };
}
function parseReactionEmoji(emoji?: string): string {
if (!emoji) {
throw new SelfRoleError('reaction_emoji_required');
}
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
return customMatch[3]!;
}
if (/^\d+$/.test(emoji)) {
return emoji;
}
return emoji;
}
export function buildPanelEmbed(panel: SelfRolePanel, locale: 'de' | 'en'): EmbedBuilder {
const { entries } = parseRolesJson(panel.roles);
const roleLines = entries.map((entry) => {
const prefix = entry.emoji ? `${entry.emoji} ` : '';
return `${prefix}**${entry.label}** — <@&${entry.roleId}>`;
});
const embed = new EmbedBuilder()
.setTitle(panel.title)
.setColor(0x6366f1)
.setDescription(panel.description ?? t(locale, 'selfroles.panel.defaultDescription'));
if (roleLines.length > 0) {
embed.addFields({
name: t(locale, 'selfroles.panel.rolesField'),
value: roleLines.join('\n')
});
}
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
if (behavior === 'TEMPORARY') {
const { temporaryDurationMs } = parseRolesJson(panel.roles);
if (temporaryDurationMs) {
embed.setFooter({
text: t(locale, 'selfroles.panel.temporaryFooter')
});
}
}
return embed;
}
export function buildPanelComponents(panel: SelfRolePanel): ActionRowBuilder<
ButtonBuilder | StringSelectMenuBuilder
>[] {
const mode = SelfRoleModeSchema.parse(panel.mode);
const { entries } = parseRolesJson(panel.roles);
if (mode === 'REACTIONS' || entries.length === 0) {
return [];
}
if (mode === 'DROPDOWN') {
const select = new StringSelectMenuBuilder()
.setCustomId(`${SELF_ROLE_SEL_PREFIX}${panel.id}`)
.setPlaceholder('Select a role')
.setMinValues(1)
.setMaxValues(1)
.addOptions(
entries.map((entry) => ({
label: entry.label.slice(0, 100),
value: entry.roleId,
description: entry.description?.slice(0, 100),
emoji: parseButtonEmoji(entry.emoji)
}))
);
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
}
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
let currentRow = new ActionRowBuilder<ButtonBuilder>();
let buttonsInRow = 0;
for (const entry of entries) {
if (buttonsInRow >= 5) {
rows.push(currentRow);
currentRow = new ActionRowBuilder<ButtonBuilder>();
buttonsInRow = 0;
}
const button = new ButtonBuilder()
.setCustomId(`${SELF_ROLE_BTN_PREFIX}${panel.id}:${entry.roleId}`)
.setLabel(entry.label.slice(0, 80))
.setStyle(ButtonStyle.Secondary);
const emoji = parseButtonEmoji(entry.emoji);
if (emoji) {
button.setEmoji(emoji);
}
currentRow.addComponents(button);
buttonsInRow += 1;
if (rows.length >= 5) {
break;
}
}
if (buttonsInRow > 0 && rows.length < 5) {
rows.push(currentRow);
}
return rows;
}
async function applyReactionEmojis(message: Message, entries: SelfRoleEntry[]): Promise<void> {
for (const entry of entries) {
try {
const emoji = parseReactionEmoji(entry.emoji);
await message.react(emoji);
} catch (error) {
logger.warn({ error, panelId: message.id, roleId: entry.roleId }, 'Failed to add self-role reaction');
}
}
}
export async function scheduleSelfRoleExpiry(
guildId: string,
userId: string,
roleId: string,
expiresAt: Date
): Promise<void> {
const delay = Math.max(0, expiresAt.getTime() - Date.now());
await ticketQueue.add(
'selfRoleExpire',
{ guildId, userId, roleId },
{
delay,
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
removeOnComplete: true,
removeOnFail: 50
}
);
}
export async function cancelSelfRoleExpiry(
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
await job?.remove();
}
export async function expireSelfRole(
context: BotContext,
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member?.roles.cache.has(roleId)) {
return;
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
logger.warn({ guildId, roleId }, 'Missing ManageRoles for self-role expiry');
return;
}
const role = guild.roles.cache.get(roleId);
if (!role || role.position >= me.roles.highest.position) {
logger.warn({ guildId, roleId }, 'Cannot remove self-role above bot hierarchy');
return;
}
await member.roles.remove(roleId, 'Temporary self-role expired').catch((error) => {
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove expired self-role');
});
}
async function assertAssignableRole(member: GuildMember, roleId: string): Promise<void> {
const guild = member.guild;
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
throw new SelfRoleError('bot_missing_permissions');
}
const role = guild.roles.cache.get(roleId);
if (!role) {
throw new SelfRoleError('role_not_found');
}
if (role.position >= me.roles.highest.position) {
throw new SelfRoleError('role_hierarchy');
}
if (role.managed) {
throw new SelfRoleError('role_managed');
}
}
export async function toggleSelfRole(
context: BotContext,
panel: SelfRolePanel,
member: GuildMember,
roleId: string
): Promise<'added' | 'removed' | 'unchanged'> {
const { entries, temporaryDurationMs } = parseRolesJson(panel.roles);
const entry = entries.find((item) => item.roleId === roleId);
if (!entry) {
throw new SelfRoleError('role_not_in_panel');
}
await assertAssignableRole(member, roleId);
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
const panelRoleIds = entries.map((item) => item.roleId);
const hasRole = member.roles.cache.has(roleId);
if (behavior === 'VERIFY') {
if (hasRole) {
return 'unchanged';
}
await member.roles.add(roleId);
return 'added';
}
if (behavior === 'UNIQUE') {
if (hasRole) {
await member.roles.remove(roleId);
return 'removed';
}
const toRemove = panelRoleIds.filter((id) => id !== roleId && member.roles.cache.has(id));
if (toRemove.length > 0) {
await member.roles.remove(toRemove);
}
await member.roles.add(roleId);
return 'added';
}
if (behavior === 'TEMPORARY') {
if (hasRole) {
await member.roles.remove(roleId);
await cancelSelfRoleExpiry(member.guild.id, member.id, roleId);
return 'removed';
}
await member.roles.add(roleId);
if (temporaryDurationMs) {
const expiresAt = new Date(Date.now() + temporaryDurationMs);
await scheduleSelfRoleExpiry(member.guild.id, member.id, roleId, expiresAt);
}
return 'added';
}
if (hasRole) {
await member.roles.remove(roleId);
return 'removed';
}
await member.roles.add(roleId);
return 'added';
}
export async function findPanelByMessageId(
context: BotContext,
messageId: string
): Promise<SelfRolePanel | null> {
return context.prisma.selfRolePanel.findFirst({
where: { messageId }
});
}
export async function createSelfRolePanel(
context: BotContext,
params: {
guildId: string;
channelId: string;
title: string;
description?: string | null;
mode: SelfRoleMode;
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
await ensureGuild(context.prisma, params.guildId);
if (params.behavior === 'TEMPORARY' && !params.temporaryDurationMs) {
throw new SelfRoleError('duration_required');
}
if (params.mode === 'REACTIONS') {
const missingEmoji = params.roles.some((entry) => !entry.emoji);
if (missingEmoji) {
throw new SelfRoleError('reaction_emoji_required');
}
}
const panel = await context.prisma.selfRolePanel.create({
data: {
guildId: params.guildId,
channelId: params.channelId,
title: params.title,
description: params.description ?? null,
mode: params.mode,
behavior: params.behavior,
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
}
});
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (params.mode === 'REACTIONS') {
await applyReactionEmojis(message, params.roles);
}
return context.prisma.selfRolePanel.update({
where: { id: panel.id },
data: { messageId: message.id }
});
}
export async function updateSelfRolePanel(
context: BotContext,
panelId: string,
guildId: string,
updates: {
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
const existing = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId }
});
if (!existing) {
throw new SelfRoleError('not_found');
}
const mode = updates.mode ?? SelfRoleModeSchema.parse(existing.mode);
const behavior = updates.behavior ?? SelfRoleBehaviorSchema.parse(existing.behavior);
const roles = updates.roles ?? parseRolesJson(existing.roles).entries;
const { temporaryDurationMs: existingDuration } = parseRolesJson(existing.roles);
const temporaryDurationMs = updates.temporaryDurationMs ?? existingDuration;
if (behavior === 'TEMPORARY' && !temporaryDurationMs) {
throw new SelfRoleError('duration_required');
}
if (mode === 'REACTIONS' && roles.some((entry) => !entry.emoji)) {
throw new SelfRoleError('reaction_emoji_required');
}
const panel = await context.prisma.selfRolePanel.update({
where: { id: panelId },
data: {
title: updates.title ?? existing.title,
description: updates.description !== undefined ? updates.description : existing.description,
mode,
behavior,
roles: serializeRolesJson(roles, temporaryDurationMs)
}
});
if (panel.messageId) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.edit({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') {
const existingReactions = message.reactions.cache;
for (const reaction of existingReactions.values()) {
await reaction.remove().catch(() => undefined);
}
await applyReactionEmojis(message, roles);
}
} catch (error) {
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
}
}
return panel;
}
export async function deleteSelfRolePanel(
context: BotContext,
panelId: string,
guildId: string
): Promise<void> {
const panel = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId }
});
if (!panel) {
throw new SelfRoleError('not_found');
}
if (panel.messageId) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.delete();
} catch {
// Message may already be gone
}
}
await context.prisma.selfRolePanel.delete({ where: { id: panelId } });
}
export function matchReactionEntry(
entries: SelfRoleEntry[],
emojiId: string | null,
emojiName: string
): SelfRoleEntry | null {
for (const entry of entries) {
if (!entry.emoji) {
continue;
}
const customMatch = entry.emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
if (emojiId === customMatch[3]) {
return entry;
}
continue;
}
if (/^\d+$/.test(entry.emoji)) {
if (emojiId === entry.emoji) {
return entry;
}
continue;
}
if (!emojiId && entry.emoji === emojiName) {
return entry;
}
}
return null;
}

View File

@@ -0,0 +1,33 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const starboardCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('starboard')
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'starboard.setup.options.channel'
)
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('threshold'), 'starboard.setup.options.threshold')
.setMinValue(1)
.setMaxValue(100)
.setRequired(true)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('emoji'), 'starboard.setup.options.emoji')
)
.addBooleanOption((o) =>
applyOptionDescription(
o.setName('allow_self_star'),
'starboard.setup.options.allow_self_star'
)
)
),
'starboard.description'
);

View File

@@ -0,0 +1,49 @@
import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { starboardCommandData } from './command-definitions.js';
import { getStarboardConfig, updateStarboardConfig } from './service.js';
const starboardCommand: SlashCommand = {
data: starboardCommandData,
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;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
const channel = interaction.options.getChannel('channel', true);
const threshold = interaction.options.getInteger('threshold', true);
const emoji = interaction.options.getString('emoji') ?? '⭐';
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
await updateStarboardConfig(context.prisma, guildId, {
enabled: true,
channelId: channel.id,
emoji,
threshold,
allowSelfStar
});
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true });
return;
}
const config = await getStarboardConfig(context.prisma, guildId);
if (!config.enabled || !config.channelId) {
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true });
}
}
};
export const starboardCommands: SlashCommand[] = [starboardCommand];

View File

@@ -0,0 +1,29 @@
import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js';
import { processStarboardReaction } from './service.js';
export function registerStarboardEvents(context: BotContext): void {
const handleReaction = async (
reaction: Parameters<typeof processStarboardReaction>[1]
): Promise<void> => {
try {
await processStarboardReaction(context, reaction);
} catch (error) {
logger.warn({ error }, 'Starboard reaction handler failed');
}
};
context.client.on('messageReactionAdd', async (_reaction, user) => {
if (user.bot) {
return;
}
await handleReaction(_reaction);
});
context.client.on('messageReactionRemove', async (_reaction, user) => {
if (user.bot) {
return;
}
await handleReaction(_reaction);
});
}

View File

@@ -0,0 +1,2 @@
export { starboardCommands } from './commands.js';
export { registerStarboardEvents } from './events.js';

View File

@@ -0,0 +1,251 @@
import {
EmbedBuilder,
type Emoji,
type Message,
type PartialMessageReaction,
type MessageReaction,
type TextChannel
} from 'discord.js';
import type { PrismaClient, StarboardConfig } from '@prisma/client';
import { t } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
export async function getStarboardConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.starboardConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.starboardConfig.create({ data: { guildId } });
}
return config;
}
export async function updateStarboardConfig(
prisma: PrismaClient,
guildId: string,
data: {
enabled: boolean;
channelId: string;
emoji: string;
threshold: number;
allowSelfStar: boolean;
}
) {
await ensureGuild(prisma, guildId);
return prisma.starboardConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export function emojiMatches(configEmoji: string, reactionEmoji: Emoji): boolean {
if (reactionEmoji.id) {
return (
configEmoji === reactionEmoji.identifier ||
configEmoji === reactionEmoji.toString() ||
configEmoji === `<:${reactionEmoji.name}:${reactionEmoji.id}>` ||
configEmoji === `<a:${reactionEmoji.name}:${reactionEmoji.id}>`
);
}
return configEmoji === reactionEmoji.name || configEmoji === reactionEmoji.toString();
}
export function shouldIgnoreMessage(message: Message, config: StarboardConfig): boolean {
if (message.author.bot) {
return true;
}
if (!message.guild || !message.channel.isTextBased() || message.channel.isDMBased()) {
return true;
}
if ('nsfw' in message.channel && message.channel.nsfw) {
return true;
}
if (config.ignoredChannelIds.includes(message.channel.id)) {
return true;
}
if (config.channelId && message.channel.id === config.channelId) {
return true;
}
return false;
}
export async function countMatchingStars(
message: Message,
config: StarboardConfig
): Promise<number> {
const reaction = message.reactions.cache.find((r) => emojiMatches(config.emoji, r.emoji));
if (!reaction || reaction.count === 0) {
return 0;
}
const fetchedReaction = reaction.partial ? await reaction.fetch() : reaction;
const users = await fetchedReaction.users.fetch();
let count = 0;
for (const [, user] of users) {
if (user.bot) {
continue;
}
if (!config.allowSelfStar && user.id === message.author.id) {
continue;
}
count++;
}
return count;
}
function resolveImageUrl(message: Message): string | undefined {
const attachment = message.attachments.find((a) => {
const type = a.contentType ?? '';
return type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/i.test(a.name);
});
if (attachment) {
return attachment.url;
}
const embedImage = message.embeds.find((e) => e.image?.url)?.image?.url;
return embedImage ?? undefined;
}
export function buildStarboardEmbed(
message: Message,
starCount: number,
config: StarboardConfig,
locale: Locale
): EmbedBuilder {
const content = message.content?.trim() || t(locale, 'starboard.noContent');
const embed = new EmbedBuilder()
.setColor(0x6366f1)
.setAuthor({
name: message.author.tag,
iconURL: message.author.displayAvatarURL()
})
.setDescription(content.slice(0, 4096))
.addFields({
name: t(locale, 'starboard.field.stars'),
value: `${config.emoji} **${starCount}**`,
inline: true
})
.addFields({
name: t(locale, 'starboard.field.source'),
value: `[${t(locale, 'starboard.jumpLink')}](${message.url})`,
inline: true
})
.setTimestamp(message.createdAt);
const imageUrl = resolveImageUrl(message);
if (imageUrl) {
embed.setImage(imageUrl);
}
return embed;
}
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
if (!entry) {
return;
}
try {
const config = await getStarboardConfig(context.prisma, entry.guildId);
if (config.channelId) {
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null);
await starboardMessage?.delete().catch(() => undefined);
}
} catch (error) {
logger.warn({ error, entryId }, 'Failed to delete starboard message');
}
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
}
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
if (!message.guild) {
return;
}
const config = await getStarboardConfig(context.prisma, message.guild.id);
if (!config.enabled || !config.channelId) {
return;
}
if (shouldIgnoreMessage(message, config)) {
return;
}
const starCount = await countMatchingStars(message, config);
const locale = await getGuildLocale(context.prisma, message.guild.id);
const existing = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: {
guildId: message.guild.id,
sourceMessageId: message.id
}
}
});
if (starCount < config.threshold) {
if (existing) {
await removeStarboardEntry(context, existing.id);
}
return;
}
const embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
if (existing) {
try {
const starboardMessage = await starboardChannel.messages.fetch(existing.starboardMessageId);
await starboardMessage.edit({ embeds: [embed] });
await context.prisma.starboardEntry.update({
where: { id: existing.id },
data: { starCount }
});
} catch (error) {
logger.warn({ error, messageId: message.id }, 'Failed to update starboard message');
}
return;
}
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
await context.prisma.starboardEntry.create({
data: {
guildId: message.guild.id,
sourceChannelId: message.channel.id,
sourceMessageId: message.id,
starboardMessageId: starboardMessage.id,
starCount
}
});
}
export async function processStarboardReaction(
context: BotContext,
reaction: MessageReaction | PartialMessageReaction
): Promise<void> {
if (reaction.partial) {
await reaction.fetch();
}
const message = reaction.message;
if (message.partial) {
await message.fetch();
}
if (!message.guild || !message.author) {
return;
}
const config = await getStarboardConfig(context.prisma, message.guild.id);
if (!config.enabled || !config.channelId) {
return;
}
if (!emojiMatches(config.emoji, reaction.emoji)) {
return;
}
await processStarboardMessage(context, message as Message);
}

View File

@@ -0,0 +1,60 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
applySuggestionVote,
SUGGESTION_VOTE_PREFIX,
SuggestionError
} from './service.js';
export { isSuggestionVoteButton as isSuggestionButton } from './service.js';
function parseSuggestionVoteButton(
customId: string
): { suggestionId: string; direction: 'up' | 'down' } | null {
if (!customId.startsWith(SUGGESTION_VOTE_PREFIX)) {
return null;
}
const rest = customId.slice(SUGGESTION_VOTE_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) {
return null;
}
const suggestionId = rest.slice(0, lastColon);
const direction = rest.slice(lastColon + 1);
if (!suggestionId || (direction !== 'up' && direction !== 'down')) {
return null;
}
return { suggestionId, direction };
}
export async function handleSuggestionButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const parsed = parseSuggestionVoteButton(interaction.customId);
if (!parsed || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'suggestion.error.not_found'), ephemeral: true });
return;
}
try {
await applySuggestionVote(context, parsed.suggestionId, interaction.user.id, parsed.direction, locale);
await interaction.reply({
content: t(locale, parsed.direction === 'up' ? 'suggestion.votedUp' : 'suggestion.votedDown'),
ephemeral: true
});
} catch (error) {
if (error instanceof SuggestionError) {
await interaction.reply({
content: t(locale, `suggestion.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}

View File

@@ -0,0 +1,90 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const suggestCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('suggest')
.addStringOption((o) =>
applyOptionDescription(o.setName('content').setRequired(true), 'suggest.options.content')
),
'suggest.description'
);
export const suggestionCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('suggestion')
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'suggestion.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('open_channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'suggestion.setup.options.open_channel'
)
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('approved_channel').addChannelTypes(ChannelType.GuildText),
'suggestion.setup.options.approved_channel'
)
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('denied_channel').addChannelTypes(ChannelType.GuildText),
'suggestion.setup.options.denied_channel'
)
)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('create_thread'), 'suggestion.setup.options.create_thread')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('approve'), 'suggestion.approve.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('deny'), 'suggestion.deny.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('consider'), 'suggestion.consider.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('implement'), 'suggestion.implement.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
),
'suggestion.description'
);

View File

@@ -0,0 +1,121 @@
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 { suggestCommandData, suggestionCommandData } from './command-definitions.js';
import {
createSuggestion,
SuggestionError,
updateSuggestionConfig,
updateSuggestionStatus
} from './service.js';
const suggestCommand: SlashCommand = {
data: suggestCommandData,
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 content = interaction.options.getString('content', true);
await interaction.deferReply({ ephemeral: true });
try {
const suggestion = await createSuggestion(
context,
{
guildId: interaction.guildId!,
authorId: interaction.user.id,
content
},
locale
);
await interaction.editReply({
content: tf(locale, 'suggestion.created', { id: suggestion.id })
});
} catch (error) {
if (error instanceof SuggestionError) {
await interaction.editReply({
content: t(locale, `suggestion.error.${error.code}`)
});
return;
}
throw error;
}
}
};
const suggestionCommand: SlashCommand = {
data: suggestionCommandData,
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;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
const openChannel = interaction.options.getChannel('open_channel', true);
const approvedChannel = interaction.options.getChannel('approved_channel');
const deniedChannel = interaction.options.getChannel('denied_channel');
const createThread = interaction.options.getBoolean('create_thread') ?? true;
await updateSuggestionConfig(context.prisma, guildId, {
enabled: true,
openChannelId: openChannel.id,
approvedChannelId: approvedChannel?.id ?? null,
deniedChannelId: deniedChannel?.id ?? null,
createThread
});
await interaction.reply({ content: t(locale, 'suggestion.setupDone'), ephemeral: true });
return;
}
const suggestionId = interaction.options.getString('suggestion_id', true);
const reason = interaction.options.getString('reason', true);
const statusMap = {
approve: 'APPROVED',
deny: 'DENIED',
consider: 'CONSIDERED',
implement: 'IMPLEMENTED'
} as const;
try {
await updateSuggestionStatus(
context,
{
suggestionId,
guildId,
status: statusMap[sub as keyof typeof statusMap],
reason
},
locale
);
await interaction.reply({
content: tf(locale, 'suggestion.staffUpdated', { status: sub }),
ephemeral: true
});
} catch (error) {
if (error instanceof SuggestionError) {
await interaction.reply({
content: t(locale, `suggestion.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const suggestionsCommands: SlashCommand[] = [suggestCommand, suggestionCommand];

View File

@@ -0,0 +1,2 @@
export { suggestionsCommands } from './commands.js';
export { isSuggestionButton, handleSuggestionButton } from './buttons.js';

View File

@@ -0,0 +1,341 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
type TextChannel
} from 'discord.js';
import type { PrismaClient, Suggestion } from '@prisma/client';
import { SuggestionStatusSchema, t, tf, type SuggestionStatus } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
export const SUGGESTION_VOTE_PREFIX = 'sug:vote:';
export function upvoteButtonId(suggestionId: string): string {
return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:up`;
}
export function downvoteButtonId(suggestionId: string): string {
return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:down`;
}
export function isSuggestionVoteButton(customId: string): boolean {
return customId.startsWith(SUGGESTION_VOTE_PREFIX);
}
export class SuggestionError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
const STATUS_COLORS: Record<SuggestionStatus, number> = {
OPEN: 0x6366f1,
APPROVED: 0x22c55e,
DENIED: 0xef4444,
CONSIDERED: 0xeab308,
IMPLEMENTED: 0x3b82f6
};
export async function getSuggestionConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.suggestionConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.suggestionConfig.create({ data: { guildId } });
}
return config;
}
export async function updateSuggestionConfig(
prisma: PrismaClient,
guildId: string,
data: {
enabled: boolean;
openChannelId: string;
approvedChannelId?: string | null;
deniedChannelId?: string | null;
createThread: boolean;
}
) {
await ensureGuild(prisma, guildId);
return prisma.suggestionConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
function statusLabel(locale: Locale, status: SuggestionStatus): string {
return t(locale, `suggestion.status.${status.toLowerCase()}`);
}
export function buildSuggestionEmbed(
locale: Locale,
suggestion: Suggestion,
authorTag?: string
): EmbedBuilder {
const status = SuggestionStatusSchema.parse(suggestion.status);
const embed = new EmbedBuilder()
.setColor(STATUS_COLORS[status])
.setTitle(tf(locale, 'suggestion.embed.title', { id: suggestion.id.slice(-8) }))
.setDescription(suggestion.content.slice(0, 4096))
.addFields(
{
name: t(locale, 'suggestion.embed.status'),
value: statusLabel(locale, status),
inline: true
},
{
name: t(locale, 'suggestion.embed.votes'),
value: `👍 ${suggestion.upvotes} · 👎 ${suggestion.downvotes}`,
inline: true
}
)
.setFooter({
text: tf(locale, 'suggestion.embed.author', {
author: authorTag ?? `<@${suggestion.authorId}>`
})
})
.setTimestamp(suggestion.createdAt);
if (suggestion.staffReason) {
embed.addFields({
name: t(locale, 'suggestion.embed.staffReason'),
value: suggestion.staffReason.slice(0, 1024),
inline: false
});
}
return embed;
}
export function buildVoteButtons(suggestion: Suggestion, disabled = false): ActionRowBuilder<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(upvoteButtonId(suggestion.id))
.setLabel(`👍 ${suggestion.upvotes}`)
.setStyle(ButtonStyle.Success)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId(downvoteButtonId(suggestion.id))
.setLabel(`👎 ${suggestion.downvotes}`)
.setStyle(ButtonStyle.Danger)
.setDisabled(disabled)
);
}
export async function createSuggestion(
context: BotContext,
params: {
guildId: string;
authorId: string;
content: string;
},
locale: Locale
): Promise<Suggestion> {
const config = await getSuggestionConfig(context.prisma, params.guildId);
if (!config.enabled || !config.openChannelId) {
throw new SuggestionError('not_configured');
}
const channel = (await context.client.channels.fetch(config.openChannelId)) as TextChannel;
if (!channel?.isTextBased() || channel.isDMBased()) {
throw new SuggestionError('invalid_channel');
}
const suggestion = await context.prisma.suggestion.create({
data: {
guildId: params.guildId,
authorId: params.authorId,
content: params.content,
status: 'OPEN',
channelId: config.openChannelId
}
});
const author = await context.client.users.fetch(params.authorId).catch(() => null);
const embed = buildSuggestionEmbed(locale, suggestion, author?.tag);
const components = [buildVoteButtons(suggestion)];
const message = await channel.send({ embeds: [embed], components });
let threadId: string | null = null;
if (config.createThread) {
const thread = await message
.startThread({
name: tf(locale, 'suggestion.threadName', { id: suggestion.id.slice(-8) }),
autoArchiveDuration: 1440
})
.catch((error) => {
logger.warn({ error, suggestionId: suggestion.id }, 'Failed to create suggestion thread');
return null;
});
threadId = thread?.id ?? null;
}
return context.prisma.suggestion.update({
where: { id: suggestion.id },
data: {
messageId: message.id,
threadId
}
});
}
export async function applySuggestionVote(
context: BotContext,
suggestionId: string,
userId: string,
direction: 'up' | 'down',
locale: Locale
): Promise<Suggestion> {
const suggestion = await context.prisma.suggestion.findUnique({ where: { id: suggestionId } });
if (!suggestion || suggestion.status !== 'OPEN') {
throw new SuggestionError('not_open');
}
if (!suggestion.messageId || !suggestion.channelId) {
throw new SuggestionError('not_found');
}
const upIds = suggestion.voterUpIds.filter((id) => id !== userId);
const downIds = suggestion.voterDownIds.filter((id) => id !== userId);
if (direction === 'up') {
if (suggestion.voterUpIds.includes(userId)) {
throw new SuggestionError('already_voted');
}
upIds.push(userId);
} else {
if (suggestion.voterDownIds.includes(userId)) {
throw new SuggestionError('already_voted');
}
downIds.push(userId);
}
const upvotes = upIds.length;
const downvotes = downIds.length;
const updated = await context.prisma.suggestion.update({
where: { id: suggestionId },
data: {
voterUpIds: upIds,
voterDownIds: downIds,
upvotes,
downvotes
}
});
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const message = await channel.messages.fetch(suggestion.messageId).catch(() => null);
if (message) {
const author = await context.client.users.fetch(updated.authorId).catch(() => null);
await message.edit({
embeds: [buildSuggestionEmbed(locale, updated, author?.tag)],
components: [buildVoteButtons(updated)]
});
}
return updated;
}
async function notifyAuthor(
context: BotContext,
suggestion: Suggestion,
locale: Locale,
status: SuggestionStatus,
reason: string
): Promise<void> {
const user = await context.client.users.fetch(suggestion.authorId).catch(() => null);
if (!user) {
return;
}
await user
.send({
content: tf(locale, 'suggestion.dm.statusUpdate', {
status: statusLabel(locale, status),
reason
})
})
.catch(() => undefined);
}
export async function updateSuggestionStatus(
context: BotContext,
params: {
suggestionId: string;
guildId: string;
status: SuggestionStatus;
reason: string;
},
locale: Locale
): Promise<Suggestion> {
const suggestion = await context.prisma.suggestion.findFirst({
where: { id: params.suggestionId, guildId: params.guildId }
});
if (!suggestion) {
throw new SuggestionError('not_found');
}
const config = await getSuggestionConfig(context.prisma, params.guildId);
const author = await context.client.users.fetch(suggestion.authorId).catch(() => null);
const updatedData = {
status: params.status,
staffReason: params.reason
};
let targetChannelId = suggestion.channelId;
if (params.status === 'APPROVED' || params.status === 'IMPLEMENTED') {
targetChannelId = config.approvedChannelId ?? suggestion.channelId;
} else if (params.status === 'DENIED') {
targetChannelId = config.deniedChannelId ?? suggestion.channelId;
}
const embed = buildSuggestionEmbed(
locale,
{ ...suggestion, ...updatedData },
author?.tag
);
const components = [buildVoteButtons({ ...suggestion, ...updatedData }, true)];
let messageId = suggestion.messageId;
let channelId = suggestion.channelId;
if (targetChannelId && targetChannelId !== suggestion.channelId) {
const targetChannel = (await context.client.channels.fetch(targetChannelId)) as TextChannel;
if (targetChannel?.isTextBased() && !targetChannel.isDMBased()) {
const newMessage = await targetChannel.send({ embeds: [embed], components });
messageId = newMessage.id;
channelId = targetChannelId;
if (suggestion.messageId && suggestion.channelId) {
const oldChannel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const oldMessage = await oldChannel?.messages.fetch(suggestion.messageId).catch(() => null);
await oldMessage?.delete().catch(() => undefined);
}
} else if (suggestion.messageId && suggestion.channelId) {
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null);
await message?.edit({ embeds: [embed], components });
}
} else if (suggestion.messageId && suggestion.channelId) {
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null);
await message?.edit({ embeds: [embed], components });
}
const updated = await context.prisma.suggestion.update({
where: { id: suggestion.id },
data: {
...updatedData,
messageId,
channelId
}
});
await notifyAuthor(context, updated, locale, params.status, params.reason);
return updated;
}

View File

@@ -0,0 +1,107 @@
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const tagCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('tag')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'tag.create.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('response_type').setRequired(true), 'tag.create.options.response_type')
.addChoices(
localizedChoice('tag.choice.response_type.text', 'TEXT'),
localizedChoice('tag.choice.response_type.embed', 'EMBED')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('content'), 'tag.create.options.content')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_title'), 'tag.create.options.embed_title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_description'), 'tag.create.options.embed_description')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
'tag.create.options.embed_color'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('trigger_word'), 'tag.create.options.trigger_word')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_roles'), 'tag.create.options.allowed_roles')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_channels'), 'tag.create.options.allowed_channels')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('edit'), 'tag.edit.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('new_name'), 'tag.edit.options.new_name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('response_type'), 'tag.create.options.response_type')
.addChoices(
localizedChoice('tag.choice.response_type.text', 'TEXT'),
localizedChoice('tag.choice.response_type.embed', 'EMBED')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('content'), 'tag.create.options.content')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_title'), 'tag.create.options.embed_title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_description'), 'tag.create.options.embed_description')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
'tag.create.options.embed_color'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('trigger_word'), 'tag.create.options.trigger_word')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_roles'), 'tag.create.options.allowed_roles')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_channels'), 'tag.create.options.allowed_channels')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'tag.delete.description').addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('list'), 'tag.list.description')
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('info'), 'tag.info.description').addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('run'), 'tag.run.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('args'), 'tag.run.options.args')
)
),
'tag.description'
);

View File

@@ -0,0 +1,199 @@
import { PermissionFlagsBits } from 'discord.js';
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { tagCommandData } from './command-definitions.js';
import {
createTag,
deleteTag,
executeTag,
getTagByName,
listTags,
parseIdList,
TagError,
updateTag
} 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);
}
function buildEmbedPayload(interaction: Parameters<SlashCommand['execute']>[0]) {
const title = interaction.options.getString('embed_title');
const description = interaction.options.getString('embed_description');
const color = interaction.options.getInteger('embed_color');
if (!title && !description && color === null) {
return undefined;
}
return {
title: title ?? undefined,
description: description ?? undefined,
color: color ?? undefined
};
}
const tagCommand: SlashCommand = {
data: tagCommandData,
async execute(interaction, context) {
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 sub = interaction.options.getSubcommand(true);
try {
if (sub === 'run') {
const name = interaction.options.getString('name', true);
const args = interaction.options.getString('args') ?? '';
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
const payload = await executeTag(
context,
interaction.guildId!,
name,
member,
interaction.guild,
interaction.channelId!,
args
);
await interaction.reply(payload);
return;
}
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
if (sub === 'create') {
const responseType = TagResponseTypeSchema.parse(
interaction.options.getString('response_type', true)
);
const tag = await createTag(context, {
guildId: interaction.guildId!,
name: interaction.options.getString('name', true),
responseType,
content: interaction.options.getString('content'),
embed: buildEmbedPayload(interaction) ?? null,
triggerWord: interaction.options.getString('trigger_word'),
allowedRoleIds: parseIdList(interaction.options.getString('allowed_roles')),
allowedChannelIds: parseIdList(interaction.options.getString('allowed_channels')),
createdById: interaction.user.id
});
await interaction.reply({
content: tf(locale, 'tag.created', { name: tag.name }),
ephemeral: true
});
return;
}
if (sub === 'edit') {
const tag = await updateTag(context, interaction.guildId!, interaction.options.getString('name', true), {
newName: interaction.options.getString('new_name') ?? undefined,
responseType: interaction.options.getString('response_type')
? TagResponseTypeSchema.parse(interaction.options.getString('response_type'))
: undefined,
content: interaction.options.has('content')
? interaction.options.getString('content')
: undefined,
embed: interaction.options.has('embed_title') ||
interaction.options.has('embed_description') ||
interaction.options.has('embed_color')
? buildEmbedPayload(interaction) ?? null
: undefined,
triggerWord: interaction.options.has('trigger_word')
? interaction.options.getString('trigger_word')
: undefined,
allowedRoleIds: interaction.options.has('allowed_roles')
? parseIdList(interaction.options.getString('allowed_roles'))
: undefined,
allowedChannelIds: interaction.options.has('allowed_channels')
? parseIdList(interaction.options.getString('allowed_channels'))
: undefined
});
await interaction.reply({
content: tf(locale, 'tag.updated', { name: tag.name }),
ephemeral: true
});
return;
}
if (sub === 'delete') {
await deleteTag(context, interaction.guildId!, interaction.options.getString('name', true));
await interaction.reply({ content: t(locale, 'tag.deleted'), ephemeral: true });
return;
}
if (sub === 'list') {
const tags = await listTags(context, interaction.guildId!);
if (tags.length === 0) {
await interaction.reply({ content: t(locale, 'tag.list.empty'), ephemeral: true });
return;
}
const lines = tags.map(
(tag) =>
`\`${tag.name}\`${TagResponseTypeSchema.parse(tag.responseType)} (${tag.useCount} ${t(locale, 'tag.list.uses')})`
);
await interaction.reply({
content: `${t(locale, 'tag.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'info') {
const tag = await getTagByName(context, interaction.guildId!, interaction.options.getString('name', true));
if (!tag) {
await interaction.reply({ content: t(locale, 'tag.error.not_found'), ephemeral: true });
return;
}
const lines = [
tf(locale, 'tag.info.name', { name: tag.name }),
tf(locale, 'tag.info.type', { type: tag.responseType }),
tf(locale, 'tag.info.uses', { count: tag.useCount }),
tag.triggerWord
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
: t(locale, 'tag.info.noTrigger'),
tag.allowedRoleIds.length > 0
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
: t(locale, 'tag.info.allRoles'),
tag.allowedChannelIds.length > 0
? tf(locale, 'tag.info.channels', {
channels: tag.allowedChannelIds.map((id) => `<#${id}>`).join(', ')
})
: t(locale, 'tag.info.allChannels')
];
await interaction.reply({
content: lines.join('\n'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof TagError) {
await interaction.reply({
content: t(locale, `tag.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const tagsCommands = [tagCommand];

View File

@@ -0,0 +1,50 @@
import { Events, type Client } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
import {
buildTagReply,
findTriggeredTag,
memberCanUseTag,
TagError
} from './service.js';
export function registerTagEvents(client: Client, context: BotContext): void {
client.on(Events.MessageCreate, async (message) => {
try {
if (!message.guild || message.author.bot || !message.content.trim()) {
return;
}
const tag = await findTriggeredTag(context, message.guild.id, message.content);
if (!tag) {
return;
}
const member = await message.guild.members.fetch(message.author.id).catch(() => null);
if (!member) {
return;
}
if (!memberCanUseTag(tag, member, message.channel.id)) {
return;
}
await context.prisma.tag.update({
where: { id: tag.id },
data: { useCount: { increment: 1 } }
});
const payload = buildTagReply(tag, member, message.guild, '');
await message.reply(payload);
} catch (error) {
if (error instanceof TagError) {
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);
logger.debug({ code: error.code, tag: error.message }, t(locale, `tag.error.${error.code}`));
return;
}
logger.error({ error, messageId: message.id }, 'Tag auto-responder failed');
}
});
}

View File

@@ -0,0 +1,2 @@
export { tagsCommands } from './commands.js';
export { registerTagEvents } from './events.js';

View File

@@ -0,0 +1,286 @@
import { EmbedBuilder, type Guild, type GuildMember, type MessageReplyOptions } from 'discord.js';
import {
applyTagPlaceholders,
TagResponseTypeSchema,
WelcomeEmbedSchema,
type TagResponseType
} from '@nexumi/shared';
import type { Tag } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import type { BotContext } from '../../types.js';
export class TagError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export function normalizeTagName(name: string): string {
return name.trim().toLowerCase();
}
export function parseIdList(input: string | null | undefined): string[] {
if (!input?.trim()) {
return [];
}
return input
.split(',')
.map((part) => part.trim())
.filter(Boolean);
}
function applyRandomPlaceholders(template: string): string {
return template.replace(/\{random:([^}]+)\}/g, (_match, group: string) => {
const options = group.split('|').map((part) => part.trim()).filter(Boolean);
if (options.length === 0) {
return '';
}
return options[Math.floor(Math.random() * options.length)]!;
});
}
export function buildTagPlaceholderVars(
member: GuildMember | null,
guild: Guild,
args: string
): Record<string, string> {
return {
user: member ? `<@${member.id}>` : '',
'user.name': member?.displayName ?? '',
'user.tag': member?.user.tag ?? '',
'user.id': member?.id ?? '',
server: guild.name,
args
};
}
export function renderTagContent(template: string, vars: Record<string, string>): string {
const withRandom = applyRandomPlaceholders(template);
return applyTagPlaceholders(withRandom, vars);
}
export function parseTagEmbed(raw: unknown) {
const parsed = WelcomeEmbedSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
export function buildTagReply(
tag: Tag,
member: GuildMember | null,
guild: Guild,
args: string
): MessageReplyOptions {
const vars = buildTagPlaceholderVars(member, guild, args);
const responseType = TagResponseTypeSchema.parse(tag.responseType);
if (responseType === 'EMBED') {
const embedData = parseTagEmbed(tag.embed);
const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1);
if (embedData?.title) {
embed.setTitle(renderTagContent(embedData.title, vars));
}
if (embedData?.description) {
embed.setDescription(renderTagContent(embedData.description, vars));
}
return { embeds: [embed] };
}
const content = renderTagContent(tag.content ?? '', vars);
return { content: content || '—' };
}
export function memberCanUseTag(tag: Tag, member: GuildMember, channelId: string): boolean {
if (tag.allowedRoleIds.length > 0) {
const hasRole = tag.allowedRoleIds.some((roleId) => member.roles.cache.has(roleId));
if (!hasRole) {
return false;
}
}
if (tag.allowedChannelIds.length > 0 && !tag.allowedChannelIds.includes(channelId)) {
return false;
}
return true;
}
export async function getTagByName(
context: BotContext,
guildId: string,
name: string
): Promise<Tag | null> {
return context.prisma.tag.findUnique({
where: {
guildId_name: {
guildId,
name: normalizeTagName(name)
}
}
});
}
export async function createTag(
context: BotContext,
params: {
guildId: string;
name: string;
responseType: TagResponseType;
content?: string | null;
embed?: { title?: string; description?: string; color?: number } | null;
triggerWord?: string | null;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
createdById: string;
}
): Promise<Tag> {
await ensureGuild(context.prisma, params.guildId);
const name = normalizeTagName(params.name);
if (!name || !/^[a-z0-9_-]{1,32}$/.test(name)) {
throw new TagError('invalid_name');
}
if (params.responseType === 'TEXT' && !params.content?.trim()) {
throw new TagError('content_required');
}
if (params.responseType === 'EMBED' && !params.embed?.title && !params.embed?.description) {
throw new TagError('embed_required');
}
return context.prisma.tag.create({
data: {
guildId: params.guildId,
name,
responseType: params.responseType,
content: params.content ?? null,
embed: params.embed ?? null,
triggerWord: params.triggerWord?.trim() || null,
allowedRoleIds: params.allowedRoleIds ?? [],
allowedChannelIds: params.allowedChannelIds ?? [],
createdById: params.createdById
}
});
}
export async function updateTag(
context: BotContext,
guildId: string,
name: string,
updates: {
newName?: string;
responseType?: TagResponseType;
content?: string | null;
embed?: { title?: string; description?: string; color?: number } | null;
triggerWord?: string | null;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
}
): Promise<Tag> {
const existing = await getTagByName(context, guildId, name);
if (!existing) {
throw new TagError('not_found');
}
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
const content = updates.content !== undefined ? updates.content : existing.content;
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
if (responseType === 'TEXT' && !content?.trim()) {
throw new TagError('content_required');
}
if (responseType === 'EMBED' && !embed?.title && !embed?.description) {
throw new TagError('embed_required');
}
let newName: string | undefined;
if (updates.newName) {
newName = normalizeTagName(updates.newName);
if (!newName || !/^[a-z0-9_-]{1,32}$/.test(newName)) {
throw new TagError('invalid_name');
}
}
return context.prisma.tag.update({
where: { id: existing.id },
data: {
name: newName ?? existing.name,
responseType,
content,
embed: embed ?? null,
triggerWord:
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
}
});
}
export async function deleteTag(context: BotContext, guildId: string, name: string): Promise<void> {
const existing = await getTagByName(context, guildId, name);
if (!existing) {
throw new TagError('not_found');
}
await context.prisma.tag.delete({ where: { id: existing.id } });
}
export async function listTags(context: BotContext, guildId: string): Promise<Tag[]> {
return context.prisma.tag.findMany({
where: { guildId },
orderBy: { name: 'asc' }
});
}
export async function executeTag(
context: BotContext,
guildId: string,
name: string,
member: GuildMember | null,
guild: Guild,
channelId: string,
args: string
): Promise<MessageReplyOptions> {
const tag = await getTagByName(context, guildId, name);
if (!tag) {
throw new TagError('not_found');
}
if (member && !memberCanUseTag(tag, member, channelId)) {
throw new TagError('not_allowed');
}
await context.prisma.tag.update({
where: { id: tag.id },
data: { useCount: { increment: 1 } }
});
return buildTagReply(tag, member, guild, args);
}
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
return pattern.test(content);
}
export async function findTriggeredTag(
context: BotContext,
guildId: string,
content: string
): Promise<Tag | null> {
const tags = await context.prisma.tag.findMany({
where: {
guildId,
triggerWord: { not: null }
}
});
for (const tag of tags) {
if (tag.triggerWord && matchesTriggerWord(content, tag.triggerWord)) {
return tag;
}
}
return null;
}

View File

@@ -0,0 +1,64 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const voiceCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('voice')
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'tempvoice.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('hub_channel').addChannelTypes(ChannelType.GuildVoice).setRequired(true),
'tempvoice.setup.options.hub_channel'
)
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('category').addChannelTypes(ChannelType.GuildCategory).setRequired(true),
'tempvoice.setup.options.category'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('default_name'), 'tempvoice.setup.options.default_name')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('limit').setMinValue(0).setMaxValue(99),
'tempvoice.setup.options.limit'
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('rename'), 'tempvoice.rename.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tempvoice.rename.options.name')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('limit'), 'tempvoice.limit.description')
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('limit').setRequired(true).setMinValue(0).setMaxValue(99),
'tempvoice.limit.options.limit'
)
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('lock'), 'tempvoice.lock.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('hide'), 'tempvoice.hide.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('kick'), 'tempvoice.kick.description')
.addUserOption((o) =>
applyOptionDescription(o.setName('user').setRequired(true), 'tempvoice.kick.options.user')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('transfer'), 'tempvoice.transfer.description')
.addUserOption((o) =>
applyOptionDescription(o.setName('user').setRequired(true), 'tempvoice.transfer.options.user')
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('claim'), 'tempvoice.claim.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('panel'), 'tempvoice.panel.description')),
'tempvoice.description'
);

View File

@@ -0,0 +1,201 @@
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 { voiceCommandData } from './command-definitions.js';
import {
buildControlPanelEmbed,
buildControlPanelRows,
claimTempChannel,
fetchCategory,
getTempVoiceChannelForMember,
getTempVoiceConfig,
hideTempChannel,
kickFromTempChannel,
lockTempChannel,
renameTempChannel,
setTempChannelLimit,
transferTempChannelOwnership,
updateTempVoiceConfig
} from './service.js';
async function requireOwnedTempChannel(
interaction: Parameters<SlashCommand['execute']>[0],
context: Parameters<SlashCommand['execute']>[1],
locale: 'de' | 'en'
) {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return null;
}
const guildMember =
interaction.guild!.members.cache.get(interaction.user.id) ??
(await interaction.guild!.members.fetch(interaction.user.id));
const owned = await getTempVoiceChannelForMember(context.prisma, interaction.guildId!, guildMember);
if (!owned || owned.record.ownerId !== interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
return null;
}
return owned;
}
const voiceCommand: SlashCommand = {
data: voiceCommandData,
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 guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const hub = interaction.options.getChannel('hub_channel', true);
const category = interaction.options.getChannel('category', true);
const defaultName = interaction.options.getString('default_name');
const limit = interaction.options.getInteger('limit');
const categoryChannel = await fetchCategory(interaction.guild!, category.id);
if (!categoryChannel) {
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidCategory'), ephemeral: true });
return;
}
await updateTempVoiceConfig(context.prisma, guildId, {
enabled: true,
hubChannelId: hub.id,
categoryId: category.id,
defaultName: defaultName ?? undefined,
defaultLimit: limit ?? undefined
});
await interaction.reply({ content: t(locale, 'tempvoice.setup.done'), ephemeral: true });
return;
}
const config = await getTempVoiceConfig(context.prisma, guildId);
if (!config.enabled) {
await interaction.reply({ content: t(locale, 'tempvoice.error.disabled'), ephemeral: true });
return;
}
if (sub === 'panel') {
const owned = await requireOwnedTempChannel(interaction, context, locale);
if (!owned) {
return;
}
await interaction.reply({
embeds: [buildControlPanelEmbed(owned.channel.name, locale)],
components: buildControlPanelRows(owned.channel.id, locale),
ephemeral: true
});
return;
}
if (sub === 'claim') {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const inChannel = await getTempVoiceChannelForMember(context.prisma, guildId, member);
if (!inChannel) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
return;
}
const result = await claimTempChannel(context, inChannel.record, inChannel.channel, member, locale);
if (result === 'owner_present') {
await interaction.reply({ content: t(locale, 'tempvoice.claim.ownerPresent'), ephemeral: true });
return;
}
if (result === 'not_in_channel') {
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({ content: t(locale, 'tempvoice.claim.success'), ephemeral: true });
return;
}
const owned = await requireOwnedTempChannel(interaction, context, locale);
if (!owned) {
return;
}
if (sub === 'rename') {
const name = interaction.options.getString('name', true);
await renameTempChannel(owned.channel, name);
await interaction.reply({
content: tf(locale, 'tempvoice.rename.success', { name: name.slice(0, 100) }),
ephemeral: true
});
return;
}
if (sub === 'limit') {
const limit = interaction.options.getInteger('limit', true);
await setTempChannelLimit(owned.channel, limit);
await interaction.reply({
content: tf(locale, 'tempvoice.limit.success', { limit }),
ephemeral: true
});
return;
}
if (sub === 'lock') {
await lockTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.lock.success'), ephemeral: true });
return;
}
if (sub === 'hide') {
await hideTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.hide.success'), ephemeral: true });
return;
}
if (sub === 'kick') {
const user = interaction.options.getUser('user', true);
if (user.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.self'), ephemeral: true });
return;
}
const ok = await kickFromTempChannel(owned.channel, user.id);
if (!ok) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'tempvoice.kick.success', { user: `<@${user.id}>` }),
ephemeral: true
});
return;
}
if (sub === 'transfer') {
const user = interaction.options.getUser('user', true);
if (user.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.self'), ephemeral: true });
return;
}
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target || !owned.channel.members.has(target.id)) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.notInChannel'), ephemeral: true });
return;
}
await transferTempChannelOwnership(context, owned.record, owned.channel, target, locale);
await interaction.reply({
content: tf(locale, 'tempvoice.transfer.success', { user: `<@${user.id}>` }),
ephemeral: true
});
}
}
};
export const tempVoiceCommands: SlashCommand[] = [voiceCommand];

View File

@@ -0,0 +1,28 @@
import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js';
import { resolveHubJoin, resolveVoiceLeave } from './service.js';
export function registerTempVoiceEvents(context: BotContext): void {
context.client.on('voiceStateUpdate', async (oldState, newState) => {
try {
const guild = newState.guild;
const member = newState.member;
if (!member || member.user.bot) {
return;
}
const oldChannelId = oldState.channelId;
const newChannelId = newState.channelId;
if (newChannelId && newChannelId !== oldChannelId) {
await resolveHubJoin(context, member, newChannelId);
}
if (oldChannelId && oldChannelId !== newChannelId) {
await resolveVoiceLeave(context, guild, oldChannelId);
}
} catch (error) {
logger.error({ error, userId: newState.id }, 'Temp voice state handler failed');
}
});
}

View File

@@ -0,0 +1,3 @@
export { tempVoiceCommands } from './commands.js';
export { registerTempVoiceEvents } from './events.js';
export { isTempVoiceInteraction, isTempVoiceModal, handleTempVoiceInteraction } from './interactions.js';

View File

@@ -0,0 +1,268 @@
import {
ActionRowBuilder,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
type ButtonInteraction,
type ModalSubmitInteraction,
type VoiceChannel
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
assertTempVoiceOwner,
claimTempChannel,
hideTempChannel,
kickFromTempChannel,
lockTempChannel,
parseTempVoiceButton,
parseTempVoiceModal,
tempVoiceModalId,
TV_BUTTON_PREFIX,
TV_MODAL_PREFIX,
transferTempChannelOwnership
} from './service.js';
export function isTempVoiceInteraction(customId: string): boolean {
return customId.startsWith(TV_BUTTON_PREFIX);
}
export function isTempVoiceModal(customId: string): boolean {
return customId.startsWith(TV_MODAL_PREFIX);
}
async function showRenameModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('rename', channelId))
.setTitle('Rename channel')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('name')
.setLabel('New name')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(100)
)
);
await interaction.showModal(modal);
}
async function showLimitModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('limit', channelId))
.setTitle('Set user limit')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('limit')
.setLabel('Limit (0 = unlimited)')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(2)
)
);
await interaction.showModal(modal);
}
async function showKickModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('kick', channelId))
.setTitle('Kick user')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('user_id')
.setLabel('User ID')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(20)
)
);
await interaction.showModal(modal);
}
async function showTransferModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('transfer', channelId))
.setTitle('Transfer ownership')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('user_id')
.setLabel('New owner user ID')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(20)
)
);
await interaction.showModal(modal);
}
async function handleTempVoiceButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTempVoiceButton(interaction.customId);
if (!parsed || !interaction.inGuild()) {
return;
}
const member = await interaction.guild!.members.fetch(interaction.user.id);
const owned = await assertTempVoiceOwner(context, member, parsed.channelId);
if (parsed.action === 'claim') {
const channel = interaction.guild!.channels.cache.get(parsed.channelId);
if (!channel?.isVoiceBased() || channel.isDMBased()) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notFound'), ephemeral: true });
return;
}
const record = await context.prisma.tempVoiceChannel.findUnique({ where: { channelId: parsed.channelId } });
if (!record) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notFound'), ephemeral: true });
return;
}
const result = await claimTempChannel(context, record, channel as VoiceChannel, member, locale);
if (result === 'owner_present') {
await interaction.reply({ content: t(locale, 'tempvoice.claim.ownerPresent'), ephemeral: true });
return;
}
if (result === 'not_in_channel') {
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({ content: t(locale, 'tempvoice.claim.success'), ephemeral: true });
return;
}
if (!owned) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
return;
}
switch (parsed.action) {
case 'rename':
await showRenameModal(interaction, parsed.channelId);
return;
case 'limit':
await showLimitModal(interaction, parsed.channelId);
return;
case 'kick':
await showKickModal(interaction, parsed.channelId);
return;
case 'transfer':
await showTransferModal(interaction, parsed.channelId);
return;
case 'lock':
await lockTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.lock.success'), ephemeral: true });
return;
case 'hide':
await hideTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.hide.success'), ephemeral: true });
return;
default:
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
}
}
async function handleTempVoiceModal(
interaction: ModalSubmitInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTempVoiceModal(interaction.customId);
if (!parsed || !interaction.inGuild()) {
return;
}
const member = await interaction.guild!.members.fetch(interaction.user.id);
const owned = await assertTempVoiceOwner(context, member, parsed.channelId);
if (!owned) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
return;
}
if (parsed.action === 'rename') {
const name = interaction.fields.getTextInputValue('name').trim();
if (!name) {
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidName'), ephemeral: true });
return;
}
await owned.channel.setName(name.slice(0, 100));
await interaction.reply({
content: tf(locale, 'tempvoice.rename.success', { name: name.slice(0, 100) }),
ephemeral: true
});
return;
}
if (parsed.action === 'limit') {
const raw = interaction.fields.getTextInputValue('limit').trim();
const limit = Number.parseInt(raw, 10);
if (Number.isNaN(limit) || limit < 0 || limit > 99) {
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidLimit'), ephemeral: true });
return;
}
await owned.channel.setUserLimit(limit);
await interaction.reply({
content: tf(locale, 'tempvoice.limit.success', { limit }),
ephemeral: true
});
return;
}
if (parsed.action === 'kick') {
const userId = interaction.fields.getTextInputValue('user_id').trim();
if (userId === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.self'), ephemeral: true });
return;
}
const ok = await kickFromTempChannel(owned.channel, userId);
if (!ok) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'tempvoice.kick.success', { user: `<@${userId}>` }),
ephemeral: true
});
return;
}
if (parsed.action === 'transfer') {
const userId = interaction.fields.getTextInputValue('user_id').trim();
if (userId === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.self'), ephemeral: true });
return;
}
const target = await interaction.guild!.members.fetch(userId).catch(() => null);
if (!target || !owned.channel.members.has(target.id)) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.notInChannel'), ephemeral: true });
return;
}
await transferTempChannelOwnership(context, owned.record, owned.channel, target, locale);
await interaction.reply({
content: tf(locale, 'tempvoice.transfer.success', { user: `<@${userId}>` }),
ephemeral: true
});
}
}
export async function handleTempVoiceInteraction(
interaction: ButtonInteraction | ModalSubmitInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (interaction.isButton()) {
await handleTempVoiceButton(interaction, context, locale);
return;
}
if (interaction.isModalSubmit() && interaction.customId.startsWith(TV_MODAL_PREFIX)) {
await handleTempVoiceModal(interaction, context, locale);
}
}

View File

@@ -0,0 +1,389 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
EmbedBuilder,
OverwriteType,
PermissionFlagsBits,
type CategoryChannel,
type Guild,
type GuildMember,
type VoiceChannel
} from 'discord.js';
import { applyTagPlaceholders, t, tf, type Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
export const TV_BUTTON_PREFIX = 'tv:';
export const TV_MODAL_PREFIX = 'tv:modal:';
export async function getTempVoiceConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.tempVoiceConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.tempVoiceConfig.create({ data: { guildId } });
}
return config;
}
export async function updateTempVoiceConfig(
prisma: BotContext['prisma'],
guildId: string,
data: {
enabled?: boolean;
hubChannelId?: string | null;
categoryId?: string | null;
defaultName?: string;
defaultLimit?: number;
}
) {
await ensureGuild(prisma, guildId);
return prisma.tempVoiceConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export async function getTempVoiceChannel(prisma: BotContext['prisma'], channelId: string) {
return prisma.tempVoiceChannel.findUnique({ where: { channelId } });
}
export async function getTempVoiceChannelForMember(
prisma: BotContext['prisma'],
guildId: string,
member: GuildMember
): Promise<{ record: NonNullable<Awaited<ReturnType<typeof getTempVoiceChannel>>>; channel: VoiceChannel } | null> {
const voiceChannelId = member.voice.channelId;
if (!voiceChannelId) {
return null;
}
const record = await getTempVoiceChannel(prisma, voiceChannelId);
if (!record || record.guildId !== guildId) {
return null;
}
const channel = member.guild.channels.cache.get(voiceChannelId);
if (!channel?.isVoiceBased() || channel.isDMBased()) {
return null;
}
return { record, channel: channel as VoiceChannel };
}
export function renderDefaultChannelName(template: string, member: GuildMember): string {
const name = applyTagPlaceholders(template, {
user: member.displayName,
'user.name': member.displayName,
'user.tag': member.user.username
});
return name.slice(0, 100);
}
export function buildControlPanelRows(channelId: string, locale: Locale): ActionRowBuilder<ButtonBuilder>[] {
const mk = (action: string, labelKey: string, style: ButtonStyle = ButtonStyle.Secondary) =>
new ButtonBuilder()
.setCustomId(`${TV_BUTTON_PREFIX}${action}:${channelId}`)
.setLabel(t(locale, labelKey))
.setStyle(style);
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
mk('rename', 'tempvoice.button.rename'),
mk('limit', 'tempvoice.button.limit'),
mk('lock', 'tempvoice.button.lock'),
mk('hide', 'tempvoice.button.hide')
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
mk('kick', 'tempvoice.button.kick', ButtonStyle.Danger),
mk('transfer', 'tempvoice.button.transfer'),
mk('claim', 'tempvoice.button.claim', ButtonStyle.Success)
)
];
}
export function buildControlPanelEmbed(channelName: string, locale: Locale): EmbedBuilder {
return new EmbedBuilder()
.setTitle(t(locale, 'tempvoice.panel.title'))
.setDescription(tf(locale, 'tempvoice.panel.description', { channel: channelName }))
.setColor(0x6366f1);
}
export async function sendOwnerControlPanelDm(
member: GuildMember,
channelId: string,
channelName: string,
locale: Locale
): Promise<void> {
const embed = buildControlPanelEmbed(channelName, locale);
const rows = buildControlPanelRows(channelId, locale);
await member.send({ embeds: [embed], components: rows }).catch((error) => {
logger.debug({ error, userId: member.id }, 'Could not DM temp voice control panel');
});
}
export async function createTempVoiceChannel(
context: BotContext,
member: GuildMember,
config: Awaited<ReturnType<typeof getTempVoiceConfig>>
): Promise<VoiceChannel | null> {
if (!config.categoryId) {
return null;
}
const category = await member.guild.channels.fetch(config.categoryId).catch(() => null);
if (!category || category.type !== ChannelType.GuildCategory) {
return null;
}
const channelName = renderDefaultChannelName(config.defaultName, member);
const limit = config.defaultLimit > 0 ? config.defaultLimit : undefined;
const channel = await member.guild.channels.create({
name: channelName,
type: ChannelType.GuildVoice,
parent: category.id,
userLimit: limit,
permissionOverwrites: [
{
id: member.guild.roles.everyone.id,
type: OverwriteType.Role,
allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.ViewChannel]
},
{
id: member.id,
type: OverwriteType.Member,
allow: [
PermissionFlagsBits.Connect,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.MoveMembers
]
},
{
id: member.guild.members.me!.id,
type: OverwriteType.Member,
allow: [
PermissionFlagsBits.Connect,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.MoveMembers
]
}
],
reason: 'Temp voice join-to-create'
});
await context.prisma.tempVoiceChannel.create({
data: {
guildId: member.guild.id,
channelId: channel.id,
ownerId: member.id
}
});
await member.voice.setChannel(channel).catch(async (error) => {
logger.warn({ error, channelId: channel.id }, 'Failed to move member into temp voice channel');
await channel.delete('Temp voice move failed').catch(() => undefined);
await context.prisma.tempVoiceChannel.delete({ where: { channelId: channel.id } }).catch(() => undefined);
});
if (!member.voice.channelId || member.voice.channelId !== channel.id) {
return null;
}
const { getGuildLocale } = await import('../../i18n.js');
const locale = await getGuildLocale(context.prisma, member.guild.id);
await sendOwnerControlPanelDm(member, channel.id, channel.name, locale);
return channel;
}
export async function deleteTempVoiceChannel(context: BotContext, channelId: string): Promise<void> {
await context.prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => undefined);
const channel = await context.client.channels.fetch(channelId).catch(() => null);
if (channel?.isVoiceBased()) {
await channel.delete('Temp voice channel empty').catch((error) => {
logger.warn({ error, channelId }, 'Failed to delete temp voice channel');
});
}
}
export async function assertTempVoiceOwner(
context: BotContext,
member: GuildMember,
channelId: string
): Promise<{ record: NonNullable<Awaited<ReturnType<typeof getTempVoiceChannel>>>; channel: VoiceChannel } | null> {
const record = await getTempVoiceChannel(context.prisma, channelId);
if (!record || record.guildId !== member.guild.id) {
return null;
}
const channel = member.guild.channels.cache.get(channelId);
if (!channel?.isVoiceBased() || channel.isDMBased()) {
return null;
}
if (record.ownerId !== member.id) {
return null;
}
return { record, channel: channel as VoiceChannel };
}
export async function renameTempChannel(channel: VoiceChannel, name: string): Promise<void> {
await channel.setName(name.slice(0, 100));
}
export async function setTempChannelLimit(channel: VoiceChannel, limit: number): Promise<void> {
await channel.setUserLimit(limit);
}
export async function lockTempChannel(channel: VoiceChannel): Promise<boolean> {
await channel.permissionOverwrites.edit(channel.guild.roles.everyone, {
Connect: false
});
return true;
}
export async function hideTempChannel(channel: VoiceChannel): Promise<boolean> {
await channel.permissionOverwrites.edit(channel.guild.roles.everyone, {
ViewChannel: false
});
return true;
}
export async function kickFromTempChannel(channel: VoiceChannel, targetId: string): Promise<boolean> {
const target = channel.members.get(targetId);
if (!target) {
return false;
}
await target.voice.disconnect('Temp voice kick');
return true;
}
export async function transferTempChannelOwnership(
context: BotContext,
record: { channelId: string; ownerId: string },
channel: VoiceChannel,
newOwner: GuildMember,
locale: Locale
): Promise<void> {
await context.prisma.tempVoiceChannel.update({
where: { channelId: record.channelId },
data: { ownerId: newOwner.id }
});
await channel.permissionOverwrites.edit(record.ownerId, {
ManageChannels: null,
MoveMembers: null
});
await channel.permissionOverwrites.edit(newOwner.id, {
Connect: true,
ViewChannel: true,
ManageChannels: true,
MoveMembers: true
});
await sendOwnerControlPanelDm(newOwner, channel.id, channel.name, locale);
}
export async function claimTempChannel(
context: BotContext,
record: { channelId: string; ownerId: string },
channel: VoiceChannel,
claimer: GuildMember,
locale: Locale
): Promise<'owner_present' | 'claimed' | 'not_in_channel'> {
if (channel.members.has(record.ownerId)) {
return 'owner_present';
}
if (!channel.members.has(claimer.id)) {
return 'not_in_channel';
}
await transferTempChannelOwnership(context, record, channel, claimer, locale);
return 'claimed';
}
export async function resolveHubJoin(
context: BotContext,
member: GuildMember,
hubChannelId: string
): Promise<void> {
const config = await getTempVoiceConfig(context.prisma, member.guild.id);
if (!config.enabled || config.hubChannelId !== hubChannelId || !config.categoryId) {
return;
}
await createTempVoiceChannel(context, member, config);
}
export async function resolveVoiceLeave(
context: BotContext,
guild: Guild,
channelId: string
): Promise<void> {
const record = await getTempVoiceChannel(context.prisma, channelId);
if (!record) {
return;
}
const channel = guild.channels.cache.get(channelId);
if (!channel?.isVoiceBased()) {
await context.prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => undefined);
return;
}
if (channel.members.size === 0) {
await deleteTempVoiceChannel(context, channelId);
}
}
export function parseTempVoiceButton(customId: string): { action: string; channelId: string } | null {
if (!customId.startsWith(TV_BUTTON_PREFIX) || customId.startsWith(TV_MODAL_PREFIX)) {
return null;
}
const body = customId.slice(TV_BUTTON_PREFIX.length);
const sep = body.indexOf(':');
if (sep <= 0) {
return null;
}
return {
action: body.slice(0, sep),
channelId: body.slice(sep + 1)
};
}
export function parseTempVoiceModal(customId: string): { action: string; channelId: string } | null {
if (!customId.startsWith(TV_MODAL_PREFIX)) {
return null;
}
const body = customId.slice(TV_MODAL_PREFIX.length);
const sep = body.indexOf(':');
if (sep <= 0) {
return null;
}
return {
action: body.slice(0, sep),
channelId: body.slice(sep + 1)
};
}
export function tempVoiceModalId(action: string, channelId: string): string {
return `${TV_MODAL_PREFIX}${action}:${channelId}`;
}
export function tempVoiceButtonId(action: string, channelId: string): string {
return `${TV_BUTTON_PREFIX}${action}:${channelId}`;
}
export async function fetchCategory(guild: Guild, categoryId: string): Promise<CategoryChannel | null> {
const channel = await guild.channels.fetch(categoryId).catch(() => null);
if (!channel || channel.type !== ChannelType.GuildCategory) {
return null;
}
return channel;
}

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

View File

@@ -11,3 +11,9 @@ export const verificationQueueName = 'verification';
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });
export const reminderQueueName = 'reminders';
export const reminderQueue = new Queue(reminderQueueName, { connection: redis });
export const giveawayQueueName = 'giveaways';
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
export const ticketQueueName = 'tickets';
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
export const birthdayQueueName = 'birthdays';
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });