diff --git a/apps/bot/prisma/migrations/20260722160000_phase4_modules/migration.sql b/apps/bot/prisma/migrations/20260722160000_phase4_modules/migration.sql new file mode 100644 index 0000000..aa9bdfc --- /dev/null +++ b/apps/bot/prisma/migrations/20260722160000_phase4_modules/migration.sql @@ -0,0 +1,246 @@ +CREATE TABLE "Giveaway" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "messageId" TEXT, + "prize" TEXT NOT NULL, + "winnerCount" INTEGER NOT NULL DEFAULT 1, + "endsAt" TIMESTAMP(3) NOT NULL, + "requiredRoleId" TEXT, + "requiredLevel" INTEGER, + "requiredMemberDays" INTEGER, + "paused" BOOLEAN NOT NULL DEFAULT false, + "endedAt" TIMESTAMP(3), + "winners" TEXT[] DEFAULT ARRAY[]::TEXT[], + "entrants" TEXT[] DEFAULT ARRAY[]::TEXT[], + "hostId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Giveaway_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "TicketConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "mode" TEXT NOT NULL DEFAULT 'CHANNEL', + "categoryId" TEXT, + "logChannelId" TEXT, + "transcriptDm" BOOLEAN NOT NULL DEFAULT false, + "inactivityHours" INTEGER NOT NULL DEFAULT 72, + "ratingEnabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "TicketConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "TicketCategory" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "emoji" TEXT, + "supportRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "formFields" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "TicketCategory_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Ticket" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "categoryId" TEXT, + "openerId" TEXT NOT NULL, + "claimedById" TEXT, + "channelId" TEXT, + "threadId" TEXT, + "status" TEXT NOT NULL DEFAULT 'OPEN', + "priority" TEXT NOT NULL DEFAULT 'NORMAL', + "subject" TEXT, + "formAnswers" JSONB, + "transcriptHtml" TEXT, + "rating" INTEGER, + "closedAt" TIMESTAMP(3), + "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "SelfRolePanel" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "messageId" TEXT, + "title" TEXT NOT NULL, + "description" TEXT, + "mode" TEXT NOT NULL DEFAULT 'BUTTONS', + "behavior" TEXT NOT NULL DEFAULT 'NORMAL', + "roles" JSONB NOT NULL, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SelfRolePanel_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Tag" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "content" TEXT, + "embed" JSONB, + "responseType" TEXT NOT NULL DEFAULT 'TEXT', + "triggerWord" TEXT, + "allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdById" TEXT NOT NULL, + "useCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "StarboardConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "channelId" TEXT, + "emoji" TEXT NOT NULL DEFAULT E'⭐', + "threshold" INTEGER NOT NULL DEFAULT 3, + "allowSelfStar" BOOLEAN NOT NULL DEFAULT false, + "ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "StarboardConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "StarboardEntry" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "sourceChannelId" TEXT NOT NULL, + "sourceMessageId" TEXT NOT NULL, + "starboardMessageId" TEXT NOT NULL, + "starCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "StarboardEntry_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "SuggestionConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "openChannelId" TEXT, + "approvedChannelId" TEXT, + "deniedChannelId" TEXT, + "createThread" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SuggestionConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Suggestion" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'OPEN', + "messageId" TEXT, + "channelId" TEXT, + "threadId" TEXT, + "upvotes" INTEGER NOT NULL DEFAULT 0, + "downvotes" INTEGER NOT NULL DEFAULT 0, + "voterUpIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "voterDownIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "staffReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "BirthdayConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "channelId" TEXT, + "roleId" TEXT, + "message" TEXT, + "timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "BirthdayConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Birthday" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "month" INTEGER NOT NULL, + "day" INTEGER NOT NULL, + "year" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Birthday_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "TempVoiceConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "hubChannelId" TEXT, + "categoryId" TEXT, + "defaultName" TEXT NOT NULL DEFAULT '{user}''s Channel', + "defaultLimit" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "TempVoiceConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "TempVoiceChannel" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "controlMessageId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "TempVoiceChannel_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "Giveaway_guildId_endsAt_idx" ON "Giveaway"("guildId", "endsAt"); +CREATE UNIQUE INDEX "TicketConfig_guildId_key" ON "TicketConfig"("guildId"); +CREATE UNIQUE INDEX "TicketCategory_guildId_name_key" ON "TicketCategory"("guildId", "name"); +CREATE INDEX "TicketCategory_guildId_idx" ON "TicketCategory"("guildId"); +CREATE INDEX "Ticket_guildId_status_idx" ON "Ticket"("guildId", "status"); +CREATE INDEX "Ticket_channelId_idx" ON "Ticket"("channelId"); +CREATE INDEX "SelfRolePanel_guildId_idx" ON "SelfRolePanel"("guildId"); +CREATE UNIQUE INDEX "Tag_guildId_name_key" ON "Tag"("guildId", "name"); +CREATE INDEX "Tag_guildId_idx" ON "Tag"("guildId"); +CREATE UNIQUE INDEX "StarboardConfig_guildId_key" ON "StarboardConfig"("guildId"); +CREATE UNIQUE INDEX "StarboardEntry_guildId_sourceMessageId_key" ON "StarboardEntry"("guildId", "sourceMessageId"); +CREATE INDEX "StarboardEntry_guildId_idx" ON "StarboardEntry"("guildId"); +CREATE UNIQUE INDEX "SuggestionConfig_guildId_key" ON "SuggestionConfig"("guildId"); +CREATE INDEX "Suggestion_guildId_status_idx" ON "Suggestion"("guildId", "status"); +CREATE UNIQUE INDEX "BirthdayConfig_guildId_key" ON "BirthdayConfig"("guildId"); +CREATE UNIQUE INDEX "Birthday_guildId_userId_key" ON "Birthday"("guildId", "userId"); +CREATE INDEX "Birthday_guildId_month_day_idx" ON "Birthday"("guildId", "month", "day"); +CREATE UNIQUE INDEX "TempVoiceConfig_guildId_key" ON "TempVoiceConfig"("guildId"); +CREATE UNIQUE INDEX "TempVoiceChannel_channelId_key" ON "TempVoiceChannel"("channelId"); +CREATE INDEX "TempVoiceChannel_guildId_ownerId_idx" ON "TempVoiceChannel"("guildId", "ownerId"); + +ALTER TABLE "Giveaway" ADD CONSTRAINT "Giveaway_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TicketConfig" ADD CONSTRAINT "TicketConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TicketCategory" ADD CONSTRAINT "TicketCategory_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TicketCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SelfRolePanel" ADD CONSTRAINT "SelfRolePanel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Tag" ADD CONSTRAINT "Tag_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "StarboardConfig" ADD CONSTRAINT "StarboardConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "StarboardEntry" ADD CONSTRAINT "StarboardEntry_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SuggestionConfig" ADD CONSTRAINT "SuggestionConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "BirthdayConfig" ADD CONSTRAINT "BirthdayConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Birthday" ADD CONSTRAINT "Birthday_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TempVoiceConfig" ADD CONSTRAINT "TempVoiceConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TempVoiceChannel" ADD CONSTRAINT "TempVoiceChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index d32da85..6e0d193 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -30,6 +30,20 @@ model Guild { polls Poll[] reminders Reminder[] afkStatuses AfkStatus[] + giveaways Giveaway[] + ticketConfig TicketConfig? + ticketCategories TicketCategory[] + tickets Ticket[] + selfRolePanels SelfRolePanel[] + tags Tag[] + starboardConfig StarboardConfig? + starboardEntries StarboardEntry[] + suggestionConfig SuggestionConfig? + suggestions Suggestion[] + birthdays Birthday[] + birthdayConfig BirthdayConfig? + tempVoiceConfig TempVoiceConfig? + tempVoiceChannels TempVoiceChannel[] } model GuildSettings { @@ -389,3 +403,239 @@ model AfkStatus { @@unique([guildId, userId]) } + +model Giveaway { + id String @id @default(cuid()) + guildId String + channelId String + messageId String? + prize String + winnerCount Int @default(1) + endsAt DateTime + requiredRoleId String? + requiredLevel Int? + requiredMemberDays Int? + paused Boolean @default(false) + endedAt DateTime? + winners String[] @default([]) + entrants String[] @default([]) + hostId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId, endsAt]) +} + +model TicketConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(true) + mode String @default("CHANNEL") + categoryId String? + logChannelId String? + transcriptDm Boolean @default(false) + inactivityHours Int @default(72) + ratingEnabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model TicketCategory { + id String @id @default(cuid()) + guildId String + name String + description String? + emoji String? + supportRoleIds String[] @default([]) + formFields Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + tickets Ticket[] + + @@unique([guildId, name]) + @@index([guildId]) +} + +model Ticket { + id String @id @default(cuid()) + guildId String + categoryId String? + openerId String + claimedById String? + channelId String? + threadId String? + status String @default("OPEN") + priority String @default("NORMAL") + subject String? + formAnswers Json? + transcriptHtml String? + rating Int? + closedAt DateTime? + lastActivityAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + category TicketCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + + @@index([guildId, status]) + @@index([channelId]) +} + +model SelfRolePanel { + id String @id @default(cuid()) + guildId String + channelId String + messageId String? + title String + description String? + mode String @default("BUTTONS") + behavior String @default("NORMAL") + roles Json + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId]) +} + +model Tag { + id String @id @default(cuid()) + guildId String + name String + content String? + embed Json? + responseType String @default("TEXT") + triggerWord String? + allowedRoleIds String[] @default([]) + allowedChannelIds String[] @default([]) + createdById String + useCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, name]) + @@index([guildId]) +} + +model StarboardConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(false) + channelId String? + emoji String @default("⭐") + threshold Int @default(3) + allowSelfStar Boolean @default(false) + ignoredChannelIds String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model StarboardEntry { + id String @id @default(cuid()) + guildId String + sourceChannelId String + sourceMessageId String + starboardMessageId String + starCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, sourceMessageId]) + @@index([guildId]) +} + +model SuggestionConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(true) + openChannelId String? + approvedChannelId String? + deniedChannelId String? + createThread Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model Suggestion { + id String @id @default(cuid()) + guildId String + authorId String + content String + status String @default("OPEN") + messageId String? + channelId String? + threadId String? + upvotes Int @default(0) + downvotes Int @default(0) + voterUpIds String[] @default([]) + voterDownIds String[] @default([]) + staffReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId, status]) +} + +model BirthdayConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(true) + channelId String? + roleId String? + message String? + timezone String @default("Europe/Berlin") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model Birthday { + id String @id @default(cuid()) + guildId String + userId String + month Int + day Int + year Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, userId]) + @@index([guildId, month, day]) +} + +model TempVoiceConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(false) + hubChannelId String? + categoryId String? + defaultName String @default("{user}'s Channel") + defaultLimit Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model TempVoiceChannel { + id String @id @default(cuid()) + guildId String + channelId String @unique + ownerId String + controlMessageId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId, ownerId]) +} + diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 5e3d14f..21edc2d 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -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( 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( + 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 { diff --git a/apps/bot/src/modules/birthdays/command-definitions.ts b/apps/bot/src/modules/birthdays/command-definitions.ts new file mode 100644 index 0000000..4b2de18 --- /dev/null +++ b/apps/bot/src/modules/birthdays/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/birthdays/commands.ts b/apps/bot/src/modules/birthdays/commands.ts new file mode 100644 index 0000000..0a71cd4 --- /dev/null +++ b/apps/bot/src/modules/birthdays/commands.ts @@ -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]; diff --git a/apps/bot/src/modules/birthdays/index.ts b/apps/bot/src/modules/birthdays/index.ts new file mode 100644 index 0000000..c4b5eb6 --- /dev/null +++ b/apps/bot/src/modules/birthdays/index.ts @@ -0,0 +1,2 @@ +export { birthdayCommands } from './commands.js'; +export { runBirthdayCheck, expireBirthdayRole } from './service.js'; diff --git a/apps/bot/src/modules/birthdays/service.ts b/apps/bot/src/modules/birthdays/service.ts new file mode 100644 index 0000000..b82bfce --- /dev/null +++ b/apps/bot/src/modules/birthdays/service.ts @@ -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; + +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 { + 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 = { + 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>, + birthday: { userId: string; month: number; day: number; year: number | null }, + dateKey: string +): Promise { + 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 { + 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 { + 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'); + } + } +} diff --git a/apps/bot/src/modules/giveaways/buttons.ts b/apps/bot/src/modules/giveaways/buttons.ts new file mode 100644 index 0000000..43b517a --- /dev/null +++ b/apps/bot/src/modules/giveaways/buttons.ts @@ -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 { + 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; + } +} diff --git a/apps/bot/src/modules/giveaways/command-definitions.ts b/apps/bot/src/modules/giveaways/command-definitions.ts new file mode 100644 index 0000000..5989d34 --- /dev/null +++ b/apps/bot/src/modules/giveaways/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/giveaways/commands.ts b/apps/bot/src/modules/giveaways/commands.ts new file mode 100644 index 0000000..bd28e59 --- /dev/null +++ b/apps/bot/src/modules/giveaways/commands.ts @@ -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[0], + locale: 'de' | 'en' +): Promise { + 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')}, )` + ); + 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]; diff --git a/apps/bot/src/modules/giveaways/index.ts b/apps/bot/src/modules/giveaways/index.ts new file mode 100644 index 0000000..b92a05e --- /dev/null +++ b/apps/bot/src/modules/giveaways/index.ts @@ -0,0 +1,3 @@ +export { giveawayCommands } from './commands.js'; +export { isGiveawayButton, handleGiveawayButton } from './buttons.js'; +export { endGiveaway, scheduleGiveawayEnd } from './service.js'; diff --git a/apps/bot/src/modules/giveaways/service.ts b/apps/bot/src/modules/giveaways/service.ts new file mode 100644 index 0000000..012dc3e --- /dev/null +++ b/apps/bot/src/modules/giveaways/service.ts @@ -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 { + 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 { + 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: `` + }) + }); + } else { + embed.setDescription(t(locale, 'giveaway.embed.active')); + embed.setFooter({ + text: tf(locale, 'giveaway.embed.endsAt', { + time: `` + }) + }); + } + + 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[] { + if (giveaway.endedAt) { + return []; + } + return [ + new ActionRowBuilder().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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return context.prisma.giveaway.findMany({ + where: { guildId, endedAt: null }, + orderBy: { endsAt: 'asc' } + }); +} diff --git a/apps/bot/src/modules/selfroles/command-definitions.ts b/apps/bot/src/modules/selfroles/command-definitions.ts new file mode 100644 index 0000000..983d7b4 --- /dev/null +++ b/apps/bot/src/modules/selfroles/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/selfroles/commands.ts b/apps/bot/src/modules/selfroles/commands.ts new file mode 100644 index 0000000..c8681b0 --- /dev/null +++ b/apps/bot/src/modules/selfroles/commands.ts @@ -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[0], + locale: 'de' | 'en' +): Promise { + 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[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]; diff --git a/apps/bot/src/modules/selfroles/index.ts b/apps/bot/src/modules/selfroles/index.ts new file mode 100644 index 0000000..e40783c --- /dev/null +++ b/apps/bot/src/modules/selfroles/index.ts @@ -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'; diff --git a/apps/bot/src/modules/selfroles/interactions.ts b/apps/bot/src/modules/selfroles/interactions.ts new file mode 100644 index 0000000..a8d2c55 --- /dev/null +++ b/apps/bot/src/modules/selfroles/interactions.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/apps/bot/src/modules/selfroles/reactions.ts b/apps/bot/src/modules/selfroles/reactions.ts new file mode 100644 index 0000000..3244366 --- /dev/null +++ b/apps/bot/src/modules/selfroles/reactions.ts @@ -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 { + 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'); + } + }); +} diff --git a/apps/bot/src/modules/selfroles/service.ts b/apps/bot/src/modules/selfroles/service.ts new file mode 100644 index 0000000..a2621fc --- /dev/null +++ b/apps/bot/src/modules/selfroles/service.ts @@ -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().addComponents(select)]; + } + + const rows: ActionRowBuilder[] = []; + let currentRow = new ActionRowBuilder(); + let buttonsInRow = 0; + + for (const entry of entries) { + if (buttonsInRow >= 5) { + rows.push(currentRow); + currentRow = new ActionRowBuilder(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/bot/src/modules/starboard/command-definitions.ts b/apps/bot/src/modules/starboard/command-definitions.ts new file mode 100644 index 0000000..65a3fa1 --- /dev/null +++ b/apps/bot/src/modules/starboard/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/starboard/commands.ts b/apps/bot/src/modules/starboard/commands.ts new file mode 100644 index 0000000..5818c74 --- /dev/null +++ b/apps/bot/src/modules/starboard/commands.ts @@ -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]; diff --git a/apps/bot/src/modules/starboard/events.ts b/apps/bot/src/modules/starboard/events.ts new file mode 100644 index 0000000..e3ad7e1 --- /dev/null +++ b/apps/bot/src/modules/starboard/events.ts @@ -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[1] + ): Promise => { + 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); + }); +} diff --git a/apps/bot/src/modules/starboard/index.ts b/apps/bot/src/modules/starboard/index.ts new file mode 100644 index 0000000..5faf7f5 --- /dev/null +++ b/apps/bot/src/modules/starboard/index.ts @@ -0,0 +1,2 @@ +export { starboardCommands } from './commands.js'; +export { registerStarboardEvents } from './events.js'; diff --git a/apps/bot/src/modules/starboard/service.ts b/apps/bot/src/modules/starboard/service.ts new file mode 100644 index 0000000..051f9f3 --- /dev/null +++ b/apps/bot/src/modules/starboard/service.ts @@ -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 === `` + ); + } + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/apps/bot/src/modules/suggestions/buttons.ts b/apps/bot/src/modules/suggestions/buttons.ts new file mode 100644 index 0000000..79fab5e --- /dev/null +++ b/apps/bot/src/modules/suggestions/buttons.ts @@ -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 { + 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; + } +} diff --git a/apps/bot/src/modules/suggestions/command-definitions.ts b/apps/bot/src/modules/suggestions/command-definitions.ts new file mode 100644 index 0000000..b073ca4 --- /dev/null +++ b/apps/bot/src/modules/suggestions/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/suggestions/commands.ts b/apps/bot/src/modules/suggestions/commands.ts new file mode 100644 index 0000000..c60bd99 --- /dev/null +++ b/apps/bot/src/modules/suggestions/commands.ts @@ -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]; diff --git a/apps/bot/src/modules/suggestions/index.ts b/apps/bot/src/modules/suggestions/index.ts new file mode 100644 index 0000000..05f4582 --- /dev/null +++ b/apps/bot/src/modules/suggestions/index.ts @@ -0,0 +1,2 @@ +export { suggestionsCommands } from './commands.js'; +export { isSuggestionButton, handleSuggestionButton } from './buttons.js'; diff --git a/apps/bot/src/modules/suggestions/service.ts b/apps/bot/src/modules/suggestions/service.ts new file mode 100644 index 0000000..3163807 --- /dev/null +++ b/apps/bot/src/modules/suggestions/service.ts @@ -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 = { + 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 { + return new ActionRowBuilder().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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/bot/src/modules/tags/command-definitions.ts b/apps/bot/src/modules/tags/command-definitions.ts new file mode 100644 index 0000000..468d208 --- /dev/null +++ b/apps/bot/src/modules/tags/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/tags/commands.ts b/apps/bot/src/modules/tags/commands.ts new file mode 100644 index 0000000..4587a8f --- /dev/null +++ b/apps/bot/src/modules/tags/commands.ts @@ -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[0], + locale: 'de' | 'en' +): Promise { + 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[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]; diff --git a/apps/bot/src/modules/tags/events.ts b/apps/bot/src/modules/tags/events.ts new file mode 100644 index 0000000..2a9ed4d --- /dev/null +++ b/apps/bot/src/modules/tags/events.ts @@ -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'); + } + }); +} diff --git a/apps/bot/src/modules/tags/index.ts b/apps/bot/src/modules/tags/index.ts new file mode 100644 index 0000000..6d9fce5 --- /dev/null +++ b/apps/bot/src/modules/tags/index.ts @@ -0,0 +1,2 @@ +export { tagsCommands } from './commands.js'; +export { registerTagEvents } from './events.js'; diff --git a/apps/bot/src/modules/tags/service.ts b/apps/bot/src/modules/tags/service.ts new file mode 100644 index 0000000..4fc17e2 --- /dev/null +++ b/apps/bot/src/modules/tags/service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/bot/src/modules/tempvoice/command-definitions.ts b/apps/bot/src/modules/tempvoice/command-definitions.ts new file mode 100644 index 0000000..d8fac50 --- /dev/null +++ b/apps/bot/src/modules/tempvoice/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/tempvoice/commands.ts b/apps/bot/src/modules/tempvoice/commands.ts new file mode 100644 index 0000000..ff4f386 --- /dev/null +++ b/apps/bot/src/modules/tempvoice/commands.ts @@ -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[0], + context: Parameters[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]; diff --git a/apps/bot/src/modules/tempvoice/events.ts b/apps/bot/src/modules/tempvoice/events.ts new file mode 100644 index 0000000..1a329b8 --- /dev/null +++ b/apps/bot/src/modules/tempvoice/events.ts @@ -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'); + } + }); +} diff --git a/apps/bot/src/modules/tempvoice/index.ts b/apps/bot/src/modules/tempvoice/index.ts new file mode 100644 index 0000000..50abbf4 --- /dev/null +++ b/apps/bot/src/modules/tempvoice/index.ts @@ -0,0 +1,3 @@ +export { tempVoiceCommands } from './commands.js'; +export { registerTempVoiceEvents } from './events.js'; +export { isTempVoiceInteraction, isTempVoiceModal, handleTempVoiceInteraction } from './interactions.js'; diff --git a/apps/bot/src/modules/tempvoice/interactions.ts b/apps/bot/src/modules/tempvoice/interactions.ts new file mode 100644 index 0000000..364729c --- /dev/null +++ b/apps/bot/src/modules/tempvoice/interactions.ts @@ -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 { + const modal = new ModalBuilder() + .setCustomId(tempVoiceModalId('rename', channelId)) + .setTitle('Rename channel') + .addComponents( + new ActionRowBuilder().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 { + const modal = new ModalBuilder() + .setCustomId(tempVoiceModalId('limit', channelId)) + .setTitle('Set user limit') + .addComponents( + new ActionRowBuilder().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 { + const modal = new ModalBuilder() + .setCustomId(tempVoiceModalId('kick', channelId)) + .setTitle('Kick user') + .addComponents( + new ActionRowBuilder().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 { + const modal = new ModalBuilder() + .setCustomId(tempVoiceModalId('transfer', channelId)) + .setTitle('Transfer ownership') + .addComponents( + new ActionRowBuilder().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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/bot/src/modules/tempvoice/service.ts b/apps/bot/src/modules/tempvoice/service.ts new file mode 100644 index 0000000..1641425 --- /dev/null +++ b/apps/bot/src/modules/tempvoice/service.ts @@ -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>>; 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[] { + 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().addComponents( + mk('rename', 'tempvoice.button.rename'), + mk('limit', 'tempvoice.button.limit'), + mk('lock', 'tempvoice.button.lock'), + mk('hide', 'tempvoice.button.hide') + ), + new ActionRowBuilder().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 { + 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> +): Promise { + 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 { + 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>>; 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 { + await channel.setName(name.slice(0, 100)); +} + +export async function setTempChannelLimit(channel: VoiceChannel, limit: number): Promise { + await channel.setUserLimit(limit); +} + +export async function lockTempChannel(channel: VoiceChannel): Promise { + await channel.permissionOverwrites.edit(channel.guild.roles.everyone, { + Connect: false + }); + return true; +} + +export async function hideTempChannel(channel: VoiceChannel): Promise { + await channel.permissionOverwrites.edit(channel.guild.roles.everyone, { + ViewChannel: false + }); + return true; +} + +export async function kickFromTempChannel(channel: VoiceChannel, targetId: string): Promise { + 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 { + 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 { + 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 { + 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 { + const channel = await guild.channels.fetch(categoryId).catch(() => null); + if (!channel || channel.type !== ChannelType.GuildCategory) { + return null; + } + return channel; +} diff --git a/apps/bot/src/modules/tickets/command-definitions.ts b/apps/bot/src/modules/tickets/command-definitions.ts new file mode 100644 index 0000000..f2ff815 --- /dev/null +++ b/apps/bot/src/modules/tickets/command-definitions.ts @@ -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' +); diff --git a/apps/bot/src/modules/tickets/commands.ts b/apps/bot/src/modules/tickets/commands.ts new file mode 100644 index 0000000..164f4da --- /dev/null +++ b/apps/bot/src/modules/tickets/commands.ts @@ -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[0], + locale: 'de' | 'en' +): Promise { + 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]; diff --git a/apps/bot/src/modules/tickets/events.ts b/apps/bot/src/modules/tickets/events.ts new file mode 100644 index 0000000..03503bb --- /dev/null +++ b/apps/bot/src/modules/tickets/events.ts @@ -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); + }); +} diff --git a/apps/bot/src/modules/tickets/index.ts b/apps/bot/src/modules/tickets/index.ts new file mode 100644 index 0000000..3464c30 --- /dev/null +++ b/apps/bot/src/modules/tickets/index.ts @@ -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'; diff --git a/apps/bot/src/modules/tickets/interactions.ts b/apps/bot/src/modules/tickets/interactions.ts new file mode 100644 index 0000000..0ced144 --- /dev/null +++ b/apps/bot/src/modules/tickets/interactions.ts @@ -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 { + 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 { + 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 { + 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 = {}; + 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; + } + } +} diff --git a/apps/bot/src/modules/tickets/service.ts b/apps/bot/src/modules/tickets/service.ts new file mode 100644 index 0000000..d88a18f --- /dev/null +++ b/apps/bot/src/modules/tickets/service.ts @@ -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; + +export class TicketError extends Error { + constructor(public readonly code: string) { + super(code); + } +} + +async function fetchSendableChannel( + context: BotContext, + channelId: string +): Promise { + 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 { + 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 { + 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 { + 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(); + 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().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().addComponents(input)); + } + + if (fields.length === 0) { + modal.addComponents( + new ActionRowBuilder().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> { + 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 { + const row = new ActionRowBuilder(); + 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; + }, + locale: 'de' | 'en' +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + subject?: string +): Promise { + 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 + ); +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index 6242129..1eefdd8 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -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 }); diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 2796135..3813a62 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -183,5 +183,38 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede ## Nächster geplanter Schritt -- Manuelle Phase-3-Tests, dann nach Freigabe Phase 4 (Giveaways, Tickets, Reaction Roles, Custom Commands, Starboard, Suggestions, Geburtstage, Temp-Voice). +- Manuelle Phase-3-Tests, dann nach Freigabe Phase 4 (Giveaways, Tickets, Reaction Roles, Custom Commands, Geburtstage, Temp-Voice). + +### Starboard (`apps/bot/src/modules/starboard/`) + +- `/starboard setup` (Kanal, Schwellenwert, Emoji, Self-Star) +- Reaktions-Events: Nachrichten mit genügend ⭐ (konfigurierbar) → Embed im Starboard-Kanal +- NSFW-/Bot-/Ignore-Kanal-Ausschluss, Self-Star-Regel, Eintrag in `StarboardEntry` + +### Suggestions (`apps/bot/src/modules/suggestions/`) + +- `/suggest`, `/suggestion setup|approve|deny|consider|implement` +- Upvote/Downvote-Buttons (`sug:vote:{id}:up|down`), optional Thread +- Status-Update mit Kanal-Verschiebung (approved/denied) und DM an Autor + +### Manuelle Discord-Tests Starboard/Suggestions (offen) + +- `/starboard setup`, Reaktionen bis/unter Schwellenwert, Self-Star aus/an +- `/suggestion setup`, `/suggest`, Voting, Staff-Status + DM + +### Giveaways (`apps/bot/src/modules/giveaways/`) + +- `/giveaway start|end|reroll|list|delete|pause` +- Button `gw:join:{giveawayId}`, BullMQ-Job `giveawayEnd` (Export: `endGiveaway`) +- Anforderungen: Rolle, Level, Mitgliedschaftsdauer; DM an Gewinner + +### Tickets (`apps/bot/src/modules/tickets/`) + +- `/ticket panel create`, `/ticket category create`, `/ticket close|claim|add|remove|rename|priority` +- Panel-Buttons/Select, Modal, Kanal-/Thread-Modus, HTML-Transkript, Bewertung, Auto-Close-Export + +### Manuelle Discord-Tests Giveaways/Tickets (offen) + +- Giveaway: Start, Join, Ende/Reroll/Pause; Ticket: Panel, Öffnen, Close, Transkript, Rating +- Wiring in `commands.ts`, `index.ts`, `jobs.ts` (Parent-Agent) diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index 985c990..baf289f 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -682,6 +682,518 @@ export const commandLocales = { 'fun.common.options.opponent': { de: 'Gegner', en: 'Opponent' + }, + 'starboard.description': { + de: 'Starboard konfigurieren', + en: 'Configure starboard' + }, + 'starboard.setup.description': { + de: 'Starboard einrichten', + en: 'Set up starboard' + }, + 'starboard.setup.options.channel': { + de: 'Starboard-Kanal', + en: 'Starboard channel' + }, + 'starboard.setup.options.threshold': { + de: 'Mindestanzahl Reaktionen', + en: 'Minimum reaction count' + }, + 'starboard.setup.options.emoji': { + de: 'Emoji (Standard: ⭐)', + en: 'Emoji (default: ⭐)' + }, + 'starboard.setup.options.allow_self_star': { + de: 'Self-Star erlauben', + en: 'Allow self-star' + }, + 'suggest.description': { + de: 'Einen Vorschlag einreichen', + en: 'Submit a suggestion' + }, + 'suggest.options.content': { + de: 'Vorschlagstext', + en: 'Suggestion content' + }, + 'suggestion.description': { + de: 'Vorschläge verwalten', + en: 'Manage suggestions' + }, + 'suggestion.setup.description': { + de: 'Vorschlagssystem einrichten', + en: 'Set up suggestions' + }, + 'suggestion.setup.options.open_channel': { + de: 'Kanal für offene Vorschläge', + en: 'Open suggestions channel' + }, + 'suggestion.setup.options.approved_channel': { + de: 'Kanal für angenommene Vorschläge', + en: 'Approved suggestions channel' + }, + 'suggestion.setup.options.denied_channel': { + de: 'Kanal für abgelehnte Vorschläge', + en: 'Denied suggestions channel' + }, + 'suggestion.setup.options.create_thread': { + de: 'Diskussions-Thread erstellen', + en: 'Create discussion thread' + }, + 'suggestion.approve.description': { + de: 'Vorschlag annehmen', + en: 'Approve suggestion' + }, + 'suggestion.deny.description': { + de: 'Vorschlag ablehnen', + en: 'Deny suggestion' + }, + 'suggestion.consider.description': { + de: 'Vorschlag in Prüfung markieren', + en: 'Mark suggestion as under consideration' + }, + 'suggestion.implement.description': { + de: 'Vorschlag als umgesetzt markieren', + en: 'Mark suggestion as implemented' + }, + 'suggestion.options.suggestion_id': { + de: 'Vorschlags-ID', + en: 'Suggestion ID' + }, + 'suggestion.options.reason': { + de: 'Begründung', + en: 'Reason' + }, + 'giveaway.description': { + de: 'Giveaways verwalten', + en: 'Manage giveaways' + }, + 'giveaway.start.description': { + de: 'Giveaway starten', + en: 'Start a giveaway' + }, + 'giveaway.start.options.duration': { + de: 'Dauer (z. B. 1h, 2d)', + en: 'Duration (e.g. 1h, 2d)' + }, + 'giveaway.start.options.winners': { + de: 'Anzahl Gewinner', + en: 'Number of winners' + }, + 'giveaway.start.options.prize': { + de: 'Preis', + en: 'Prize' + }, + 'giveaway.start.options.role': { + de: 'Erforderliche Rolle', + en: 'Required role' + }, + 'giveaway.start.options.level': { + de: 'Mindest-Level', + en: 'Minimum level' + }, + 'giveaway.start.options.member_days': { + de: 'Mindest-Mitgliedschaft in Tagen', + en: 'Minimum membership in days' + }, + 'giveaway.end.description': { + de: 'Giveaway vorzeitig beenden', + en: 'End a giveaway early' + }, + 'giveaway.reroll.description': { + de: 'Gewinner neu ziehen', + en: 'Reroll winners' + }, + 'giveaway.list.description': { + de: 'Aktive Giveaways anzeigen', + en: 'List active giveaways' + }, + 'giveaway.delete.description': { + de: 'Giveaway löschen', + en: 'Delete a giveaway' + }, + 'giveaway.pause.description': { + de: 'Giveaway pausieren oder fortsetzen', + en: 'Pause or resume a giveaway' + }, + 'giveaway.common.options.id': { + de: 'Giveaway-ID', + en: 'Giveaway ID' + }, + 'ticket.description': { + de: 'Ticket-System verwalten', + en: 'Manage the ticket system' + }, + 'ticket.panel.description': { + de: 'Ticket-Panels', + en: 'Ticket panels' + }, + 'ticket.panel.create.description': { + de: 'Ticket-Panel erstellen', + en: 'Create a ticket panel' + }, + 'ticket.panel.create.options.name': { + de: 'Kategoriename', + en: 'Category name' + }, + 'ticket.panel.create.options.description': { + de: 'Panel-Beschreibung', + en: 'Panel description' + }, + 'ticket.category.description': { + de: 'Ticket-Kategorien', + en: 'Ticket categories' + }, + 'ticket.category.create.description': { + de: 'Ticket-Kategorie mit Support-Rolle erstellen', + en: 'Create a ticket category with support role' + }, + 'ticket.category.create.options.name': { + de: 'Kategoriename', + en: 'Category name' + }, + 'ticket.category.create.options.support_role': { + de: 'Support-Rolle', + en: 'Support role' + }, + 'ticket.close.description': { + de: 'Ticket schließen', + en: 'Close the ticket' + }, + 'ticket.close.options.reason': { + de: 'Optionaler Schließgrund', + en: 'Optional close reason' + }, + 'ticket.claim.description': { + de: 'Ticket übernehmen', + en: 'Claim the ticket' + }, + 'ticket.add.description': { + de: 'Nutzer zum Ticket hinzufügen', + en: 'Add a user to the ticket' + }, + 'ticket.remove.description': { + de: 'Nutzer aus dem Ticket entfernen', + en: 'Remove a user from the ticket' + }, + 'ticket.rename.description': { + de: 'Ticket umbenennen', + en: 'Rename the ticket' + }, + 'ticket.rename.options.name': { + de: 'Neuer Name', + en: 'New name' + }, + 'ticket.priority.description': { + de: 'Ticket-Priorität setzen', + en: 'Set ticket priority' + }, + 'ticket.priority.options.level': { + de: 'Priorität', + en: 'Priority' + }, + 'ticket.priority.choice.low': { + de: 'Niedrig', + en: 'Low' + }, + 'ticket.priority.choice.normal': { + de: 'Normal', + en: 'Normal' + }, + 'ticket.priority.choice.high': { + de: 'Hoch', + en: 'High' + }, + 'ticket.priority.choice.urgent': { + de: 'Dringend', + en: 'Urgent' + }, + 'ticket.common.options.user': { + de: 'Nutzer', + en: 'User' + }, + 'birthdays.description': { + de: 'Geburtstage verwalten und anzeigen', + en: 'Manage and view birthdays' + }, + 'birthdays.set.description': { + de: 'Eigenen Geburtstag eintragen', + en: 'Set your birthday' + }, + 'birthdays.set.options.month': { + de: 'Monat (1–12)', + en: 'Month (1–12)' + }, + 'birthdays.set.options.day': { + de: 'Tag (1–31)', + en: 'Day (1–31)' + }, + 'birthdays.set.options.year': { + de: 'Optionales Geburtsjahr', + en: 'Optional birth year' + }, + 'birthdays.remove.description': { + de: 'Eigenen Geburtstag entfernen', + en: 'Remove your birthday' + }, + 'birthdays.next.description': { + de: 'Nächste Geburtstage anzeigen', + en: 'Show upcoming birthdays' + }, + 'birthdays.list.description': { + de: 'Alle Geburtstage auflisten', + en: 'List all birthdays' + }, + 'birthdays.setup.description': { + de: 'Geburtstags-Modul einrichten', + en: 'Configure the birthday module' + }, + 'birthdays.setup.options.channel': { + de: 'Kanal für Glückwünsche', + en: 'Channel for congratulations' + }, + 'birthdays.setup.options.role': { + de: 'Optionale 24h-Geburtstagsrolle', + en: 'Optional 24h birthday role' + }, + 'birthdays.setup.options.timezone': { + de: 'Zeitzone (z. B. Europe/Berlin)', + en: 'Timezone (e.g. Europe/Berlin)' + }, + 'birthdays.setup.options.message': { + de: 'Nachrichtenvorlage ({user}, {age})', + en: 'Message template ({user}, {age})' + }, + 'tempvoice.description': { + de: 'Temp-Voice (Join to Create) steuern', + en: 'Control temp voice (join to create)' + }, + 'tempvoice.setup.description': { + de: 'Temp-Voice einrichten', + en: 'Configure temp voice' + }, + 'tempvoice.setup.options.hub_channel': { + de: 'Hub-Sprachkanal (Join to Create)', + en: 'Hub voice channel (join to create)' + }, + 'tempvoice.setup.options.category': { + de: 'Kategorie für neue Kanäle', + en: 'Category for new channels' + }, + 'tempvoice.setup.options.default_name': { + de: 'Standardname ({user})', + en: 'Default name ({user})' + }, + 'tempvoice.setup.options.limit': { + de: 'Standard-Nutzerlimit (0 = unbegrenzt)', + en: 'Default user limit (0 = unlimited)' + }, + 'tempvoice.rename.description': { + de: 'Eigenen Temp-Kanal umbenennen', + en: 'Rename your temp channel' + }, + 'tempvoice.rename.options.name': { + de: 'Neuer Kanalname', + en: 'New channel name' + }, + 'tempvoice.limit.description': { + de: 'Nutzerlimit des Temp-Kanals setzen', + en: 'Set temp channel user limit' + }, + 'tempvoice.limit.options.limit': { + de: 'Limit (0 = unbegrenzt)', + en: 'Limit (0 = unlimited)' + }, + 'tempvoice.lock.description': { + de: 'Temp-Kanal sperren', + en: 'Lock temp channel' + }, + 'tempvoice.hide.description': { + de: 'Temp-Kanal verstecken', + en: 'Hide temp channel' + }, + 'tempvoice.kick.description': { + de: 'Nutzer aus Temp-Kanal entfernen', + en: 'Kick user from temp channel' + }, + 'tempvoice.kick.options.user': { + de: 'Zielnutzer', + en: 'Target user' + }, + 'tempvoice.transfer.description': { + de: 'Besitz an anderen Nutzer übertragen', + en: 'Transfer ownership to another user' + }, + 'tempvoice.transfer.options.user': { + de: 'Neuer Besitzer', + en: 'New owner' + }, + 'tempvoice.claim.description': { + de: 'Temp-Kanal übernehmen wenn Besitzer weg ist', + en: 'Claim temp channel when owner left' + }, + 'tempvoice.panel.description': { + de: 'Control-Panel mit Buttons anzeigen', + en: 'Show control panel with buttons' + }, + 'selfroles.description': { + de: 'Self-Roles (Reaction Roles) verwalten', + en: 'Manage self roles (reaction roles)' + }, + 'selfroles.panel.description': { + de: 'Self-Role-Panels', + en: 'Self-role panels' + }, + 'selfroles.panel.create.description': { + de: 'Self-Role-Panel erstellen', + en: 'Create a self-role panel' + }, + 'selfroles.panel.edit.description': { + de: 'Self-Role-Panel bearbeiten', + en: 'Edit a self-role panel' + }, + 'selfroles.panel.delete.description': { + de: 'Self-Role-Panel löschen', + en: 'Delete a self-role panel' + }, + 'selfroles.panel.create.options.title': { + de: 'Panel-Titel', + en: 'Panel title' + }, + 'selfroles.panel.create.options.description': { + de: 'Panel-Beschreibung', + en: 'Panel description' + }, + 'selfroles.panel.create.options.mode': { + de: 'Darstellungsmodus', + en: 'Display mode' + }, + 'selfroles.panel.create.options.behavior': { + de: 'Rollen-Verhalten', + en: 'Role behavior' + }, + 'selfroles.panel.create.options.roles': { + de: 'Rollen (roleId:label:emoji, …)', + en: 'Roles (roleId:label:emoji, …)' + }, + 'selfroles.panel.create.options.channel': { + de: 'Zielkanal', + en: 'Target channel' + }, + 'selfroles.panel.create.options.duration': { + de: 'Temporäre Rollendauer (z. B. 24h)', + en: 'Temporary role duration (e.g. 24h)' + }, + 'selfroles.panel.common.options.panel_id': { + de: 'Panel-ID', + en: 'Panel ID' + }, + 'selfroles.choice.mode.buttons': { + de: 'Buttons', + en: 'Buttons' + }, + 'selfroles.choice.mode.dropdown': { + de: 'Dropdown', + en: 'Dropdown' + }, + 'selfroles.choice.mode.reactions': { + de: 'Reaktionen', + en: 'Reactions' + }, + 'selfroles.choice.behavior.normal': { + de: 'Normal (Toggle)', + en: 'Normal (toggle)' + }, + 'selfroles.choice.behavior.unique': { + de: 'Nur eine Rolle', + en: 'Unique (one role)' + }, + 'selfroles.choice.behavior.verify': { + de: 'Nur hinzufügen', + en: 'Verify (add only)' + }, + 'selfroles.choice.behavior.temporary': { + de: 'Temporär', + en: 'Temporary' + }, + 'tag.description': { + de: 'Custom Commands (Tags) verwalten', + en: 'Manage custom commands (tags)' + }, + 'tag.create.description': { + de: 'Tag erstellen', + en: 'Create a tag' + }, + 'tag.edit.description': { + de: 'Tag bearbeiten', + en: 'Edit a tag' + }, + 'tag.delete.description': { + de: 'Tag löschen', + en: 'Delete a tag' + }, + 'tag.list.description': { + de: 'Tags auflisten', + en: 'List tags' + }, + 'tag.info.description': { + de: 'Tag-Details anzeigen', + en: 'Show tag details' + }, + 'tag.run.description': { + de: 'Tag ausführen', + en: 'Run a tag' + }, + 'tag.common.options.name': { + de: 'Tag-Name', + en: 'Tag name' + }, + 'tag.edit.options.new_name': { + de: 'Neuer Name', + en: 'New name' + }, + 'tag.create.options.response_type': { + de: 'Antworttyp', + en: 'Response type' + }, + 'tag.create.options.content': { + de: 'Textinhalt', + en: 'Text content' + }, + 'tag.create.options.embed_title': { + de: 'Embed-Titel', + en: 'Embed title' + }, + 'tag.create.options.embed_description': { + de: 'Embed-Beschreibung', + en: 'Embed description' + }, + 'tag.create.options.embed_color': { + de: 'Embed-Farbe (Dezimal)', + en: 'Embed color (decimal)' + }, + 'tag.create.options.trigger_word': { + de: 'Trigger-Wort (Auto-Responder)', + en: 'Trigger word (auto-responder)' + }, + 'tag.create.options.allowed_roles': { + de: 'Erlaubte Rollen-IDs (kommagetrennt)', + en: 'Allowed role IDs (comma-separated)' + }, + 'tag.create.options.allowed_channels': { + de: 'Erlaubte Kanal-IDs (kommagetrennt)', + en: 'Allowed channel IDs (comma-separated)' + }, + 'tag.run.options.args': { + de: 'Argumente für Platzhalter {args}', + en: 'Arguments for {args} placeholder' + }, + 'tag.choice.response_type.text': { + de: 'Text', + en: 'Text' + }, + 'tag.choice.response_type.embed': { + de: 'Embed', + en: 'Embed' } } as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c564baf..cc0ee87 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export * from './command-locales.js'; export * from './discord-localization.js'; export * from './phase2.js'; export * from './phase3.js'; +export * from './phase4.js'; export const LocaleSchema = z.enum(['de', 'en']); export type Locale = z.infer; @@ -238,7 +239,204 @@ const de: Dictionary = { 'fun.media.disabled': 'Medien-Commands sind auf diesem Server deaktiviert.', 'fun.media.fetchFailed': 'Bild konnte nicht geladen werden. Versuche es später erneut.', 'fun.cat.title': 'Random Cat', - 'fun.dog.title': 'Random Dog' + 'fun.dog.title': 'Random Dog', + 'starboard.notConfigured': 'Starboard ist noch nicht konfiguriert. Nutze `/starboard setup`.', + 'starboard.invalidChannel': 'Der Starboard-Kanal ist ungültig.', + 'starboard.setupDone': 'Starboard konfiguriert.', + 'starboard.noContent': '*Kein Textinhalt*', + 'starboard.field.stars': 'Sterne', + 'starboard.field.source': 'Quelle', + 'starboard.jumpLink': 'Zur Nachricht', + 'suggestion.created': 'Vorschlag eingereicht (ID: `{id}`).', + 'suggestion.setupDone': 'Vorschlagssystem konfiguriert.', + 'suggestion.staffUpdated': 'Vorschlag aktualisiert ({status}).', + 'suggestion.votedUp': 'Upvote gezählt.', + 'suggestion.votedDown': 'Downvote gezählt.', + 'suggestion.threadName': 'Diskussion #{id}', + 'suggestion.error.not_configured': 'Vorschläge sind nicht konfiguriert. Nutze `/suggestion setup`.', + 'suggestion.error.invalid_channel': 'Der konfigurierte Kanal ist ungültig.', + 'suggestion.error.not_found': 'Vorschlag nicht gefunden.', + 'suggestion.error.not_open': 'Dieser Vorschlag ist nicht mehr offen.', + 'suggestion.error.already_voted': 'Du hast bereits so abgestimmt.', + 'suggestion.embed.title': 'Vorschlag #{id}', + 'suggestion.embed.status': 'Status', + 'suggestion.embed.votes': 'Stimmen', + 'suggestion.embed.author': 'Von {author}', + 'suggestion.embed.staffReason': 'Team-Begründung', + 'suggestion.status.open': 'Offen', + 'suggestion.status.approved': 'Angenommen', + 'suggestion.status.denied': 'Abgelehnt', + 'suggestion.status.considered': 'In Prüfung', + 'suggestion.status.implemented': 'Umgesetzt', + 'suggestion.dm.statusUpdate': 'Dein Vorschlag wurde aktualisiert: **{status}**\nBegründung: {reason}', + 'giveaway.embed.title': 'Giveaway', + 'giveaway.embed.prize': 'Preis', + 'giveaway.embed.winners': 'Gewinner', + 'giveaway.embed.entrants': 'Teilnehmer', + 'giveaway.embed.host': 'Host', + 'giveaway.embed.requirements': 'Anforderungen', + 'giveaway.embed.active': 'Klicke auf **Join**, um teilzunehmen!', + 'giveaway.embed.paused': '⏸️ Dieses Giveaway ist pausiert.', + 'giveaway.embed.ended': 'Beendet', + 'giveaway.embed.endedWinners': 'Gewinner: {winners}', + 'giveaway.embed.endedNoWinners': 'Keine gültigen Teilnehmer.', + 'giveaway.embed.endsAt': 'Endet {time}', + 'giveaway.button.join': 'Teilnehmen', + 'giveaway.requirement.role': 'Rolle: {role}', + 'giveaway.requirement.level': 'Mindest-Level: {level}', + 'giveaway.requirement.memberDays': 'Server-Mitglied seit mindestens {days} Tagen', + 'giveaway.started': 'Giveaway gestartet (ID: `{id}`).', + 'giveaway.ended': 'Giveaway beendet.', + 'giveaway.rerolled': 'Neue Gewinner: {winners}', + 'giveaway.deleted': 'Giveaway gelöscht.', + 'giveaway.paused': 'Giveaway pausiert.', + 'giveaway.resumed': 'Giveaway fortgesetzt.', + 'giveaway.joined': 'Du nimmst am Giveaway teil!', + 'giveaway.list.header': '**Aktive Giveaways**', + 'giveaway.list.empty': 'Keine aktiven Giveaways.', + 'giveaway.list.entrants': 'Teilnehmer', + 'giveaway.dm.won': 'Glückwunsch! Du hast **{prize}** auf **{guild}** gewonnen!', + 'giveaway.error.not_found': 'Giveaway nicht gefunden oder bereits beendet.', + 'giveaway.error.not_ended': 'Giveaway ist noch nicht beendet.', + 'giveaway.error.paused': 'Dieses Giveaway ist pausiert.', + 'giveaway.error.already_joined': 'Du nimmst bereits teil.', + 'giveaway.error.member_not_found': 'Mitglied nicht gefunden.', + 'giveaway.error.missing_role': 'Dir fehlt die erforderliche Rolle.', + 'giveaway.error.insufficient_level': 'Dein Level ist zu niedrig.', + 'giveaway.error.insufficient_member_days': 'Du bist noch nicht lange genug auf dem Server.', + 'giveaway.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.', + 'giveaway.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.', + 'giveaway.error.invalid_channel': 'Ungültiger Kanal.', + 'ticket.panel.defaultDescription': 'Wähle eine Kategorie, um ein Ticket zu öffnen.', + 'ticket.panel.created': 'Ticket-Panel erstellt.', + 'ticket.category.created': 'Kategorie **{name}** erstellt.', + 'ticket.created': 'Dein Ticket wurde erstellt: {channel}', + 'ticket.opened': '{user} hat ein Ticket geöffnet.', + 'ticket.subjectLine': 'Betreff: {subject}', + 'ticket.claimed': '{user} hat dieses Ticket übernommen.', + 'ticket.userAdded': '{user} wurde zum Ticket hinzugefügt.', + 'ticket.userRemoved': '{user} wurde aus dem Ticket entfernt.', + 'ticket.renamed': 'Ticket umbenannt in **{name}**.', + 'ticket.prioritySet': 'Priorität gesetzt: **{priority}**', + 'ticket.closed': 'Ticket geschlossen von {user}.', + 'ticket.closedWithReason': 'Ticket geschlossen von {user}.\nGrund: {reason}', + 'ticket.close.success': 'Ticket geschlossen.', + 'ticket.claim.success': 'Ticket übernommen.', + 'ticket.add.success': '{user} hinzugefügt.', + 'ticket.remove.success': '{user} entfernt.', + 'ticket.rename.success': 'Ticket umbenannt in **{name}**.', + 'ticket.priority.success': 'Priorität: **{priority}**', + 'ticket.transcriptDm': 'Hier ist dein Ticket-Transkript.', + 'ticket.ratePrompt': 'Wie zufrieden warst du mit dem Support? (1–5)', + 'ticket.rated': 'Danke für deine Bewertung: {rating}/5', + 'ticket.autoCloseReason': 'Automatisch wegen Inaktivität geschlossen', + 'ticket.modal.subject': 'Betreff', + 'ticket.log.closed': 'Ticket geschlossen', + 'ticket.log.opener': 'Ersteller', + 'ticket.log.closedBy': 'Geschlossen von', + 'ticket.priority.LOW': 'Niedrig', + 'ticket.priority.NORMAL': 'Normal', + 'ticket.priority.HIGH': 'Hoch', + 'ticket.priority.URGENT': 'Dringend', + 'ticket.error.disabled': 'Das Ticket-System ist deaktiviert.', + 'ticket.error.category_not_found': 'Kategorie nicht gefunden.', + 'ticket.error.no_categories': 'Keine Ticket-Kategorien vorhanden.', + 'ticket.error.not_in_ticket': 'Dieser Befehl kann nur in einem Ticket-Kanal genutzt werden.', + 'ticket.error.not_staff': 'Du hast keine Berechtigung für diese Ticket-Aktion.', + 'ticket.error.already_open': 'Du hast bereits ein offenes Ticket.', + 'ticket.error.closed': 'Dieses Ticket ist bereits geschlossen.', + 'ticket.error.already_claimed': 'Du hast dieses Ticket bereits übernommen.', + 'ticket.error.cannot_remove_opener': 'Der Ticket-Ersteller kann nicht entfernt werden.', + 'ticket.error.no_channel': 'Ticket-Kanal nicht gefunden.', + 'ticket.error.invalid_name': 'Ungültiger Name.', + 'ticket.error.invalid_channel': 'Ungültiger Kanal.', + 'ticket.error.no_parent_channel': 'Kein Elternkanal für Thread-Tickets konfiguriert.', + 'ticket.error.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.', + 'ticket.error.not_found': 'Ticket nicht gefunden.', + 'ticket.error.already_rated': 'Du hast dieses Ticket bereits bewertet.', + 'birthdays.error.invalidDate': 'Ungültiges Datum. Prüfe Tag, Monat und optionales Jahr.', + 'birthdays.error.invalidTimezone': 'Ungültige Zeitzone. Nutze z. B. Europe/Berlin.', + 'birthdays.setup.done': 'Geburtstags-Modul konfiguriert.', + 'birthdays.set.success': 'Geburtstag gespeichert: {day}.{month}.{year}', + 'birthdays.remove.none': 'Du hast keinen Geburtstag eingetragen.', + 'birthdays.remove.success': 'Dein Geburtstag wurde entfernt.', + 'birthdays.list.empty': 'Noch keine Geburtstage eingetragen.', + 'birthdays.list.header': '**Geburtstage (sortiert nach nächstem Datum):**', + 'birthdays.next.header': '**Nächste Geburtstage:**', + 'birthdays.list.line': '{rank}. {user} — {date} (in {days} T.)', + 'tempvoice.error.disabled': 'Temp-Voice ist auf diesem Server deaktiviert.', + 'tempvoice.error.invalidCategory': 'Ungültige Kategorie.', + 'tempvoice.error.notOwner': 'Du bist nicht der Besitzer dieses Temp-Voice-Kanals.', + 'tempvoice.error.notInChannel': 'Du musst in einem Temp-Voice-Kanal sein.', + 'tempvoice.error.notFound': 'Temp-Voice-Kanal nicht gefunden.', + 'tempvoice.error.invalidName': 'Ungültiger Kanalname.', + 'tempvoice.error.invalidLimit': 'Limit muss zwischen 0 und 99 liegen.', + 'tempvoice.setup.done': 'Temp-Voice konfiguriert.', + 'tempvoice.rename.success': 'Kanal umbenannt in **{name}**.', + 'tempvoice.limit.success': 'Nutzerlimit auf **{limit}** gesetzt (0 = unbegrenzt).', + 'tempvoice.lock.success': 'Kanal gesperrt (Connect für @everyone entfernt).', + 'tempvoice.hide.success': 'Kanal versteckt (ViewChannel für @everyone entfernt).', + 'tempvoice.kick.self': 'Du kannst dich nicht selbst kicken.', + 'tempvoice.kick.notInChannel': 'Dieser Nutzer ist nicht in deinem Kanal.', + 'tempvoice.kick.success': '{user} wurde aus dem Kanal entfernt.', + 'tempvoice.transfer.self': 'Du kannst den Kanal nicht an dich selbst übertragen.', + 'tempvoice.transfer.notInChannel': 'Der neue Besitzer muss in deinem Kanal sein.', + 'tempvoice.transfer.success': 'Kanal an {user} übertragen.', + 'tempvoice.claim.ownerPresent': 'Der Besitzer ist noch im Kanal.', + 'tempvoice.claim.success': 'Du bist jetzt Besitzer dieses Kanals.', + 'tempvoice.panel.title': 'Temp-Voice Steuerung', + 'tempvoice.panel.description': 'Verwalte **{channel}** mit den Buttons oder `/voice`-Commands.', + 'tempvoice.button.rename': 'Umbenennen', + 'tempvoice.button.limit': 'Limit', + 'tempvoice.button.lock': 'Sperren', + 'tempvoice.button.hide': 'Verstecken', + 'tempvoice.button.kick': 'Kicken', + 'tempvoice.button.transfer': 'Übertragen', + 'tempvoice.button.claim': 'Übernehmen', + 'selfroles.panel.defaultDescription': 'Wähle unten deine Rollen aus.', + 'selfroles.panel.rolesField': 'Rollen', + 'selfroles.panel.temporaryFooter': 'Rollen sind temporär und laufen automatisch ab.', + 'selfroles.panel.created': 'Self-Role-Panel erstellt (ID: `{id}`).', + 'selfroles.panel.updated': 'Self-Role-Panel aktualisiert (ID: `{id}`).', + 'selfroles.panel.deleted': 'Self-Role-Panel gelöscht.', + 'selfroles.role.added': 'Rolle hinzugefügt.', + 'selfroles.role.removed': 'Rolle entfernt.', + 'selfroles.role.verified': 'Rolle vergeben.', + 'selfroles.role.unchanged': 'Du hast diese Rolle bereits.', + 'selfroles.error.not_found': 'Self-Role-Panel nicht gefunden.', + 'selfroles.error.invalid_roles': 'Ungültiges Rollen-Format. Nutze roleId:label:emoji.', + 'selfroles.error.too_many_roles': 'Maximal 25 Rollen pro Panel.', + 'selfroles.error.invalid_channel': 'Ungültiger Kanal.', + 'selfroles.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.', + 'selfroles.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.', + 'selfroles.error.duration_required': 'Für temporäre Panels ist eine Dauer erforderlich.', + 'selfroles.error.reaction_emoji_required': 'Reaktions-Modus erfordert Emojis für alle Rollen.', + 'selfroles.error.member_not_found': 'Mitglied nicht gefunden.', + 'selfroles.error.role_not_in_panel': 'Diese Rolle gehört nicht zu diesem Panel.', + 'selfroles.error.role_not_found': 'Rolle nicht gefunden.', + 'selfroles.error.role_hierarchy': 'Die Rolle liegt über der höchsten Bot-Rolle.', + 'selfroles.error.role_managed': 'Integrierte Rollen können nicht vergeben werden.', + 'selfroles.error.bot_missing_permissions': 'Dem Bot fehlt die Berechtigung Rollen zu verwalten.', + 'tag.created': 'Tag `{name}` erstellt.', + 'tag.updated': 'Tag `{name}` aktualisiert.', + 'tag.deleted': 'Tag gelöscht.', + 'tag.list.header': '**Tags**', + 'tag.list.empty': 'Keine Tags vorhanden.', + 'tag.list.uses': 'Aufrufe', + 'tag.info.name': 'Name: `{name}`', + 'tag.info.type': 'Typ: {type}', + 'tag.info.uses': 'Aufrufe: {count}', + 'tag.info.trigger': 'Trigger: `{word}`', + 'tag.info.noTrigger': 'Kein Auto-Responder.', + 'tag.info.roles': 'Rollen: {roles}', + 'tag.info.allRoles': 'Rollen: alle', + 'tag.info.channels': 'Kanäle: {channels}', + 'tag.info.allChannels': 'Kanäle: alle', + 'tag.error.not_found': 'Tag nicht gefunden.', + 'tag.error.not_allowed': 'Du darfst diesen Tag hier nicht nutzen.', + 'tag.error.invalid_name': 'Ungültiger Tag-Name (1–32 Zeichen, a-z, 0-9, _, -).', + 'tag.error.content_required': 'Text-Tags benötigen Inhalt.', + 'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.' }; const en: Dictionary = { 'generic.noPermission': 'You do not have permission for this action.', @@ -439,7 +637,204 @@ const en: Dictionary = { 'fun.media.disabled': 'Media commands are disabled on this server.', 'fun.media.fetchFailed': 'Could not load image. Try again later.', 'fun.cat.title': 'Random Cat', - 'fun.dog.title': 'Random Dog' + 'fun.dog.title': 'Random Dog', + 'starboard.notConfigured': 'Starboard is not configured yet. Use `/starboard setup`.', + 'starboard.invalidChannel': 'The starboard channel is invalid.', + 'starboard.setupDone': 'Starboard configured.', + 'starboard.noContent': '*No text content*', + 'starboard.field.stars': 'Stars', + 'starboard.field.source': 'Source', + 'starboard.jumpLink': 'Jump to message', + 'suggestion.created': 'Suggestion submitted (ID: `{id}`).', + 'suggestion.setupDone': 'Suggestion system configured.', + 'suggestion.staffUpdated': 'Suggestion updated ({status}).', + 'suggestion.votedUp': 'Upvote recorded.', + 'suggestion.votedDown': 'Downvote recorded.', + 'suggestion.threadName': 'Discussion #{id}', + 'suggestion.error.not_configured': 'Suggestions are not configured. Use `/suggestion setup`.', + 'suggestion.error.invalid_channel': 'The configured channel is invalid.', + 'suggestion.error.not_found': 'Suggestion not found.', + 'suggestion.error.not_open': 'This suggestion is no longer open.', + 'suggestion.error.already_voted': 'You already voted this way.', + 'suggestion.embed.title': 'Suggestion #{id}', + 'suggestion.embed.status': 'Status', + 'suggestion.embed.votes': 'Votes', + 'suggestion.embed.author': 'By {author}', + 'suggestion.embed.staffReason': 'Staff reason', + 'suggestion.status.open': 'Open', + 'suggestion.status.approved': 'Approved', + 'suggestion.status.denied': 'Denied', + 'suggestion.status.considered': 'Under consideration', + 'suggestion.status.implemented': 'Implemented', + 'suggestion.dm.statusUpdate': 'Your suggestion was updated: **{status}**\nReason: {reason}', + 'giveaway.embed.title': 'Giveaway', + 'giveaway.embed.prize': 'Prize', + 'giveaway.embed.winners': 'Winners', + 'giveaway.embed.entrants': 'Entrants', + 'giveaway.embed.host': 'Host', + 'giveaway.embed.requirements': 'Requirements', + 'giveaway.embed.active': 'Click **Join** to enter!', + 'giveaway.embed.paused': '⏸️ This giveaway is paused.', + 'giveaway.embed.ended': 'Ended', + 'giveaway.embed.endedWinners': 'Winners: {winners}', + 'giveaway.embed.endedNoWinners': 'No valid entrants.', + 'giveaway.embed.endsAt': 'Ends {time}', + 'giveaway.button.join': 'Join', + 'giveaway.requirement.role': 'Role: {role}', + 'giveaway.requirement.level': 'Minimum level: {level}', + 'giveaway.requirement.memberDays': 'Member for at least {days} days', + 'giveaway.started': 'Giveaway started (ID: `{id}`).', + 'giveaway.ended': 'Giveaway ended.', + 'giveaway.rerolled': 'New winners: {winners}', + 'giveaway.deleted': 'Giveaway deleted.', + 'giveaway.paused': 'Giveaway paused.', + 'giveaway.resumed': 'Giveaway resumed.', + 'giveaway.joined': 'You entered the giveaway!', + 'giveaway.list.header': '**Active giveaways**', + 'giveaway.list.empty': 'No active giveaways.', + 'giveaway.list.entrants': 'entrants', + 'giveaway.dm.won': 'Congratulations! You won **{prize}** on **{guild}**!', + 'giveaway.error.not_found': 'Giveaway not found or already ended.', + 'giveaway.error.not_ended': 'Giveaway has not ended yet.', + 'giveaway.error.paused': 'This giveaway is paused.', + 'giveaway.error.already_joined': 'You already entered.', + 'giveaway.error.member_not_found': 'Member not found.', + 'giveaway.error.missing_role': 'You are missing the required role.', + 'giveaway.error.insufficient_level': 'Your level is too low.', + 'giveaway.error.insufficient_member_days': 'You have not been on the server long enough.', + 'giveaway.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.', + 'giveaway.error.duration_too_short': 'Duration must be at least 1 minute.', + 'giveaway.error.invalid_channel': 'Invalid channel.', + 'ticket.panel.defaultDescription': 'Select a category to open a ticket.', + 'ticket.panel.created': 'Ticket panel created.', + 'ticket.category.created': 'Category **{name}** created.', + 'ticket.created': 'Your ticket was created: {channel}', + 'ticket.opened': '{user} opened a ticket.', + 'ticket.subjectLine': 'Subject: {subject}', + 'ticket.claimed': '{user} claimed this ticket.', + 'ticket.userAdded': '{user} was added to the ticket.', + 'ticket.userRemoved': '{user} was removed from the ticket.', + 'ticket.renamed': 'Ticket renamed to **{name}**.', + 'ticket.prioritySet': 'Priority set to **{priority}**', + 'ticket.closed': 'Ticket closed by {user}.', + 'ticket.closedWithReason': 'Ticket closed by {user}.\nReason: {reason}', + 'ticket.close.success': 'Ticket closed.', + 'ticket.claim.success': 'Ticket claimed.', + 'ticket.add.success': 'Added {user}.', + 'ticket.remove.success': 'Removed {user}.', + 'ticket.rename.success': 'Ticket renamed to **{name}**.', + 'ticket.priority.success': 'Priority: **{priority}**', + 'ticket.transcriptDm': 'Here is your ticket transcript.', + 'ticket.ratePrompt': 'How satisfied were you with support? (1–5)', + 'ticket.rated': 'Thanks for your rating: {rating}/5', + 'ticket.autoCloseReason': 'Auto-closed due to inactivity', + 'ticket.modal.subject': 'Subject', + 'ticket.log.closed': 'Ticket closed', + 'ticket.log.opener': 'Opener', + 'ticket.log.closedBy': 'Closed by', + 'ticket.priority.LOW': 'Low', + 'ticket.priority.NORMAL': 'Normal', + 'ticket.priority.HIGH': 'High', + 'ticket.priority.URGENT': 'Urgent', + 'ticket.error.disabled': 'The ticket system is disabled.', + 'ticket.error.category_not_found': 'Category not found.', + 'ticket.error.no_categories': 'No ticket categories configured.', + 'ticket.error.not_in_ticket': 'This command can only be used inside a ticket channel.', + 'ticket.error.not_staff': 'You do not have permission for this ticket action.', + 'ticket.error.already_open': 'You already have an open ticket.', + 'ticket.error.closed': 'This ticket is already closed.', + 'ticket.error.already_claimed': 'You already claimed this ticket.', + 'ticket.error.cannot_remove_opener': 'The ticket opener cannot be removed.', + 'ticket.error.no_channel': 'Ticket channel not found.', + 'ticket.error.invalid_name': 'Invalid name.', + 'ticket.error.invalid_channel': 'Invalid channel.', + 'ticket.error.no_parent_channel': 'No parent channel configured for thread tickets.', + 'ticket.error.invalid_parent_channel': 'The configured parent channel is invalid.', + 'ticket.error.not_found': 'Ticket not found.', + 'ticket.error.already_rated': 'You already rated this ticket.', + 'birthdays.error.invalidDate': 'Invalid date. Check day, month, and optional year.', + 'birthdays.error.invalidTimezone': 'Invalid timezone. Use e.g. Europe/Berlin.', + 'birthdays.setup.done': 'Birthday module configured.', + 'birthdays.set.success': 'Birthday saved: {day}/{month}/{year}', + 'birthdays.remove.none': 'You have not set a birthday.', + 'birthdays.remove.success': 'Your birthday was removed.', + 'birthdays.list.empty': 'No birthdays registered yet.', + 'birthdays.list.header': '**Birthdays (sorted by next date):**', + 'birthdays.next.header': '**Upcoming birthdays:**', + 'birthdays.list.line': '{rank}. {user} — {date} (in {days} d)', + 'tempvoice.error.disabled': 'Temp voice is disabled on this server.', + 'tempvoice.error.invalidCategory': 'Invalid category.', + 'tempvoice.error.notOwner': 'You are not the owner of this temp voice channel.', + 'tempvoice.error.notInChannel': 'You must be in a temp voice channel.', + 'tempvoice.error.notFound': 'Temp voice channel not found.', + 'tempvoice.error.invalidName': 'Invalid channel name.', + 'tempvoice.error.invalidLimit': 'Limit must be between 0 and 99.', + 'tempvoice.setup.done': 'Temp voice configured.', + 'tempvoice.rename.success': 'Channel renamed to **{name}**.', + 'tempvoice.limit.success': 'User limit set to **{limit}** (0 = unlimited).', + 'tempvoice.lock.success': 'Channel locked (Connect denied for @everyone).', + 'tempvoice.hide.success': 'Channel hidden (ViewChannel denied for @everyone).', + 'tempvoice.kick.self': 'You cannot kick yourself.', + 'tempvoice.kick.notInChannel': 'That user is not in your channel.', + 'tempvoice.kick.success': 'Removed {user} from the channel.', + 'tempvoice.transfer.self': 'You cannot transfer the channel to yourself.', + 'tempvoice.transfer.notInChannel': 'The new owner must be in your channel.', + 'tempvoice.transfer.success': 'Channel transferred to {user}.', + 'tempvoice.claim.ownerPresent': 'The owner is still in the channel.', + 'tempvoice.claim.success': 'You are now the owner of this channel.', + 'tempvoice.panel.title': 'Temp Voice Control', + 'tempvoice.panel.description': 'Manage **{channel}** with the buttons below or `/voice` commands.', + 'tempvoice.button.rename': 'Rename', + 'tempvoice.button.limit': 'Limit', + 'tempvoice.button.lock': 'Lock', + 'tempvoice.button.hide': 'Hide', + 'tempvoice.button.kick': 'Kick', + 'tempvoice.button.transfer': 'Transfer', + 'tempvoice.button.claim': 'Claim', + 'selfroles.panel.defaultDescription': 'Choose your roles below.', + 'selfroles.panel.rolesField': 'Roles', + 'selfroles.panel.temporaryFooter': 'Roles are temporary and expire automatically.', + 'selfroles.panel.created': 'Self-role panel created (ID: `{id}`).', + 'selfroles.panel.updated': 'Self-role panel updated (ID: `{id}`).', + 'selfroles.panel.deleted': 'Self-role panel deleted.', + 'selfroles.role.added': 'Role added.', + 'selfroles.role.removed': 'Role removed.', + 'selfroles.role.verified': 'Role granted.', + 'selfroles.role.unchanged': 'You already have this role.', + 'selfroles.error.not_found': 'Self-role panel not found.', + 'selfroles.error.invalid_roles': 'Invalid roles format. Use roleId:label:emoji.', + 'selfroles.error.too_many_roles': 'Maximum 25 roles per panel.', + 'selfroles.error.invalid_channel': 'Invalid channel.', + 'selfroles.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.', + 'selfroles.error.duration_too_short': 'Duration must be at least 1 minute.', + 'selfroles.error.duration_required': 'Temporary panels require a duration.', + 'selfroles.error.reaction_emoji_required': 'Reaction mode requires emojis for all roles.', + 'selfroles.error.member_not_found': 'Member not found.', + 'selfroles.error.role_not_in_panel': 'This role is not part of this panel.', + 'selfroles.error.role_not_found': 'Role not found.', + 'selfroles.error.role_hierarchy': 'The role is above the bot\'s highest role.', + 'selfroles.error.role_managed': 'Managed roles cannot be assigned.', + 'selfroles.error.bot_missing_permissions': 'The bot lacks Manage Roles permission.', + 'tag.created': 'Tag `{name}` created.', + 'tag.updated': 'Tag `{name}` updated.', + 'tag.deleted': 'Tag deleted.', + 'tag.list.header': '**Tags**', + 'tag.list.empty': 'No tags configured.', + 'tag.list.uses': 'uses', + 'tag.info.name': 'Name: `{name}`', + 'tag.info.type': 'Type: {type}', + 'tag.info.uses': 'Uses: {count}', + 'tag.info.trigger': 'Trigger: `{word}`', + 'tag.info.noTrigger': 'No auto-responder.', + 'tag.info.roles': 'Roles: {roles}', + 'tag.info.allRoles': 'Roles: all', + 'tag.info.channels': 'Channels: {channels}', + 'tag.info.allChannels': 'Channels: all', + 'tag.error.not_found': 'Tag not found.', + 'tag.error.not_allowed': 'You are not allowed to use this tag here.', + 'tag.error.invalid_name': 'Invalid tag name (1–32 chars, a-z, 0-9, _, -).', + 'tag.error.content_required': 'Text tags require content.', + 'tag.error.embed_required': 'Embed tags require a title or description.' }; const locales: Record = { de, en }; diff --git a/packages/shared/src/phase4.ts b/packages/shared/src/phase4.ts new file mode 100644 index 0000000..0b8b9b3 --- /dev/null +++ b/packages/shared/src/phase4.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +export const GiveawayStatusSchema = z.enum(['ACTIVE', 'PAUSED', 'ENDED']); +export type GiveawayStatus = z.infer; + +export const TicketModeSchema = z.enum(['CHANNEL', 'THREAD']); +export type TicketMode = z.infer; + +export const TicketStatusSchema = z.enum(['OPEN', 'CLAIMED', 'CLOSED']); +export type TicketStatus = z.infer; + +export const TicketPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']); +export type TicketPriority = z.infer; + +export const SelfRoleModeSchema = z.enum(['BUTTONS', 'DROPDOWN', 'REACTIONS']); +export type SelfRoleMode = z.infer; + +export const SelfRoleBehaviorSchema = z.enum(['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY']); +export type SelfRoleBehavior = z.infer; + +export const SelfRoleEntrySchema = z.object({ + roleId: z.string(), + label: z.string(), + emoji: z.string().optional(), + description: z.string().optional() +}); +export type SelfRoleEntry = z.infer; + +export const TagResponseTypeSchema = z.enum(['TEXT', 'EMBED']); +export type TagResponseType = z.infer; + +export const SuggestionStatusSchema = z.enum([ + 'OPEN', + 'APPROVED', + 'DENIED', + 'CONSIDERED', + 'IMPLEMENTED' +]); +export type SuggestionStatus = z.infer; + +export function applyTagPlaceholders( + template: string, + vars: Record +): string { + return Object.entries(vars).reduce( + (acc, [key, value]) => acc.replaceAll(`{${key}}`, value), + template + ); +} + +export function pickRandomWinners(items: T[], count: number): T[] { + const pool = [...items]; + const winners: T[] = []; + while (winners.length < count && pool.length > 0) { + const index = Math.floor(Math.random() * pool.length); + winners.push(pool.splice(index, 1)[0]!); + } + return winners; +} + +export function escapeHtml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export function buildTicketTranscriptHtml(input: { + guildName: string; + ticketId: string; + openerTag: string; + messages: Array<{ author: string; content: string; timestamp: string }>; +}): string { + const rows = input.messages + .map( + (m) => + `
${escapeHtml(m.timestamp)} — ${escapeHtml(m.author)}
${escapeHtml(m.content)}
` + ) + .join('\n'); + return `Ticket ${escapeHtml(input.ticketId)} + +

Ticket ${escapeHtml(input.ticketId)}

${escapeHtml(input.guildName)} — ${escapeHtml(input.openerTag)}

${rows}`; +}