From fa5910ec43ff31689633d5300f0b77cabe2750eb Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 12:49:17 +0200 Subject: [PATCH] Implement leveling and economy features with associated models and commands - Added new models for leveling and economy functionalities in the Prisma schema, including LevelingConfig, MemberLevel, LevelReward, EconomyConfig, MemberEconomy, ShopItem, and InventoryItem. - Introduced commands for managing XP, economy transactions, and shop interactions, enhancing user engagement. - Updated job handling to include reminders and poll closing functionalities. - Enhanced localization support for new commands and features in both German and English. - Improved the bot's job processing capabilities with a dedicated reminder worker for scheduled tasks. --- .../migration.sql | 180 ++++++ apps/bot/prisma/schema.prisma | 186 ++++++ apps/bot/src/jobs.ts | 33 +- .../src/modules/economy/blackjack-buttons.ts | 50 ++ .../modules/economy/command-definitions.ts | 121 ++++ apps/bot/src/modules/economy/commands.ts | 497 +++++++++++++++ apps/bot/src/modules/economy/games.ts | 420 +++++++++++++ apps/bot/src/modules/economy/index.ts | 2 + apps/bot/src/modules/economy/service.ts | 380 ++++++++++++ apps/bot/src/modules/fun/buttons.ts | 234 +++++++ .../src/modules/fun/command-definitions.ts | 112 ++++ apps/bot/src/modules/fun/commands.ts | 412 +++++++++++++ apps/bot/src/modules/fun/config.ts | 23 + apps/bot/src/modules/fun/games.ts | 574 ++++++++++++++++++ apps/bot/src/modules/fun/index.ts | 2 + apps/bot/src/modules/fun/media.ts | 65 ++ .../modules/leveling/command-definitions.ts | 49 ++ apps/bot/src/modules/leveling/commands.ts | 160 +++++ apps/bot/src/modules/leveling/events.ts | 69 +++ apps/bot/src/modules/leveling/rank-card.ts | 84 +++ apps/bot/src/modules/leveling/service.ts | 415 +++++++++++++ apps/bot/src/modules/utility/afk.ts | 89 +++ apps/bot/src/modules/utility/buttons.ts | 111 ++++ .../modules/utility/command-definitions.ts | 186 ++++++ apps/bot/src/modules/utility/commands.ts | 438 +++++++++++++ apps/bot/src/modules/utility/context-menus.ts | 50 ++ apps/bot/src/modules/utility/events.ts | 8 + apps/bot/src/modules/utility/index.ts | 13 + apps/bot/src/modules/utility/poll.ts | 274 +++++++++ apps/bot/src/modules/utility/reminders.ts | 142 +++++ apps/bot/src/modules/utility/service.ts | 241 ++++++++ apps/bot/src/modules/utility/snipe.ts | 130 ++++ apps/bot/src/queues.ts | 2 + apps/bot/src/types.ts | 14 +- docs/PHASE-TRACKING.md | 322 +++++----- packages/shared/src/command-locales.ts | 408 +++++++++++++ packages/shared/src/index.ts | 295 ++++++++- packages/shared/src/phase3.ts | 112 ++++ 38 files changed, 6759 insertions(+), 144 deletions(-) create mode 100644 apps/bot/prisma/migrations/20260722150000_phase3_modules/migration.sql create mode 100644 apps/bot/src/modules/economy/blackjack-buttons.ts create mode 100644 apps/bot/src/modules/economy/command-definitions.ts create mode 100644 apps/bot/src/modules/economy/commands.ts create mode 100644 apps/bot/src/modules/economy/games.ts create mode 100644 apps/bot/src/modules/economy/index.ts create mode 100644 apps/bot/src/modules/economy/service.ts create mode 100644 apps/bot/src/modules/fun/buttons.ts create mode 100644 apps/bot/src/modules/fun/command-definitions.ts create mode 100644 apps/bot/src/modules/fun/commands.ts create mode 100644 apps/bot/src/modules/fun/config.ts create mode 100644 apps/bot/src/modules/fun/games.ts create mode 100644 apps/bot/src/modules/fun/index.ts create mode 100644 apps/bot/src/modules/fun/media.ts create mode 100644 apps/bot/src/modules/leveling/command-definitions.ts create mode 100644 apps/bot/src/modules/leveling/commands.ts create mode 100644 apps/bot/src/modules/leveling/events.ts create mode 100644 apps/bot/src/modules/leveling/rank-card.ts create mode 100644 apps/bot/src/modules/leveling/service.ts create mode 100644 apps/bot/src/modules/utility/afk.ts create mode 100644 apps/bot/src/modules/utility/buttons.ts create mode 100644 apps/bot/src/modules/utility/command-definitions.ts create mode 100644 apps/bot/src/modules/utility/commands.ts create mode 100644 apps/bot/src/modules/utility/context-menus.ts create mode 100644 apps/bot/src/modules/utility/events.ts create mode 100644 apps/bot/src/modules/utility/index.ts create mode 100644 apps/bot/src/modules/utility/poll.ts create mode 100644 apps/bot/src/modules/utility/reminders.ts create mode 100644 apps/bot/src/modules/utility/service.ts create mode 100644 apps/bot/src/modules/utility/snipe.ts create mode 100644 packages/shared/src/phase3.ts diff --git a/apps/bot/prisma/migrations/20260722150000_phase3_modules/migration.sql b/apps/bot/prisma/migrations/20260722150000_phase3_modules/migration.sql new file mode 100644 index 0000000..b1392e6 --- /dev/null +++ b/apps/bot/prisma/migrations/20260722150000_phase3_modules/migration.sql @@ -0,0 +1,180 @@ +-- CreateTable +CREATE TABLE "LevelingConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "textXpMin" INTEGER NOT NULL DEFAULT 15, + "textXpMax" INTEGER NOT NULL DEFAULT 25, + "textCooldownSeconds" INTEGER NOT NULL DEFAULT 60, + "voiceXpPerMinute" INTEGER NOT NULL DEFAULT 10, + "noXpChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "noXpRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "roleMultipliers" JSONB, + "channelMultipliers" JSONB, + "levelUpMode" TEXT NOT NULL DEFAULT 'CHANNEL', + "levelUpChannelId" TEXT, + "levelUpMessage" TEXT, + "stackRewards" BOOLEAN NOT NULL DEFAULT true, + "cardAccentColor" TEXT NOT NULL DEFAULT '#6366F1', + "cardBackgroundColor" TEXT NOT NULL DEFAULT '#111827', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "LevelingConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "MemberLevel" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "xp" INTEGER NOT NULL DEFAULT 0, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "MemberLevel_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "LevelReward" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "level" INTEGER NOT NULL, + "roleId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "LevelReward_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "EconomyConfig" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "currencyName" TEXT NOT NULL DEFAULT 'Coins', + "currencySymbol" TEXT NOT NULL DEFAULT 'đŸȘ™', + "dailyAmount" INTEGER NOT NULL DEFAULT 200, + "weeklyAmount" INTEGER NOT NULL DEFAULT 1000, + "workMin" INTEGER NOT NULL DEFAULT 50, + "workMax" INTEGER NOT NULL DEFAULT 150, + "workCooldownSeconds" INTEGER NOT NULL DEFAULT 3600, + "startingBalance" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "EconomyConfig_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "MemberEconomy" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "balance" INTEGER NOT NULL DEFAULT 0, + "lastDailyAt" TIMESTAMP(3), + "lastWeeklyAt" TIMESTAMP(3), + "lastWorkAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "MemberEconomy_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ShopItem" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "price" INTEGER NOT NULL, + "roleId" TEXT, + "stock" INTEGER, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ShopItem_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "InventoryItem" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "InventoryItem_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Poll" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "messageId" TEXT, + "creatorId" TEXT NOT NULL, + "question" TEXT NOT NULL, + "options" JSONB NOT NULL, + "multiSelect" BOOLEAN NOT NULL DEFAULT false, + "anonymous" BOOLEAN NOT NULL DEFAULT false, + "endsAt" TIMESTAMP(3), + "closedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Poll_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "PollVote" ( + "id" TEXT NOT NULL, + "pollId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "optionIndex" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PollVote_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Reminder" ( + "id" TEXT NOT NULL, + "guildId" TEXT, + "userId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "remindAt" TIMESTAMP(3) NOT NULL, + "recurringCron" TEXT, + "jobId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "AfkStatus" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "reason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AfkStatus_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "LevelingConfig_guildId_key" ON "LevelingConfig"("guildId"); +CREATE UNIQUE INDEX "MemberLevel_guildId_userId_key" ON "MemberLevel"("guildId", "userId"); +CREATE INDEX "MemberLevel_guildId_xp_idx" ON "MemberLevel"("guildId", "xp"); +CREATE UNIQUE INDEX "LevelReward_guildId_level_key" ON "LevelReward"("guildId", "level"); +CREATE INDEX "LevelReward_guildId_idx" ON "LevelReward"("guildId"); +CREATE UNIQUE INDEX "EconomyConfig_guildId_key" ON "EconomyConfig"("guildId"); +CREATE UNIQUE INDEX "MemberEconomy_guildId_userId_key" ON "MemberEconomy"("guildId", "userId"); +CREATE INDEX "MemberEconomy_guildId_balance_idx" ON "MemberEconomy"("guildId", "balance"); +CREATE INDEX "ShopItem_guildId_enabled_idx" ON "ShopItem"("guildId", "enabled"); +CREATE UNIQUE INDEX "InventoryItem_guildId_userId_itemId_key" ON "InventoryItem"("guildId", "userId", "itemId"); +CREATE INDEX "InventoryItem_guildId_userId_idx" ON "InventoryItem"("guildId", "userId"); +CREATE INDEX "Poll_guildId_idx" ON "Poll"("guildId"); +CREATE UNIQUE INDEX "PollVote_pollId_userId_optionIndex_key" ON "PollVote"("pollId", "userId", "optionIndex"); +CREATE INDEX "PollVote_pollId_idx" ON "PollVote"("pollId"); +CREATE INDEX "Reminder_userId_idx" ON "Reminder"("userId"); +CREATE INDEX "Reminder_remindAt_idx" ON "Reminder"("remindAt"); +CREATE UNIQUE INDEX "AfkStatus_guildId_userId_key" ON "AfkStatus"("guildId", "userId"); + +ALTER TABLE "LevelingConfig" ADD CONSTRAINT "LevelingConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "MemberLevel" ADD CONSTRAINT "MemberLevel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "LevelReward" ADD CONSTRAINT "LevelReward_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "EconomyConfig" ADD CONSTRAINT "EconomyConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "MemberEconomy" ADD CONSTRAINT "MemberEconomy_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ShopItem" ADD CONSTRAINT "ShopItem_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "ShopItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Poll" ADD CONSTRAINT "Poll_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AfkStatus" ADD CONSTRAINT "AfkStatus_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 dfde187..d32da85 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -20,6 +20,16 @@ model Guild { logChannels LogChannel[] welcomeConfig WelcomeConfig? verificationConfig VerificationConfig? + levelingConfig LevelingConfig? + memberLevels MemberLevel[] + levelRewards LevelReward[] + economyConfig EconomyConfig? + memberEconomies MemberEconomy[] + shopItems ShopItem[] + inventoryItems InventoryItem[] + polls Poll[] + reminders Reminder[] + afkStatuses AfkStatus[] } model GuildSettings { @@ -203,3 +213,179 @@ model VerificationConfig { updatedAt DateTime @updatedAt guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) } + +model LevelingConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(true) + textXpMin Int @default(15) + textXpMax Int @default(25) + textCooldownSeconds Int @default(60) + voiceXpPerMinute Int @default(10) + noXpChannelIds String[] @default([]) + noXpRoleIds String[] @default([]) + roleMultipliers Json? + channelMultipliers Json? + levelUpMode String @default("CHANNEL") + levelUpChannelId String? + levelUpMessage String? + stackRewards Boolean @default(true) + cardAccentColor String @default("#6366F1") + cardBackgroundColor String @default("#111827") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model MemberLevel { + id String @id @default(cuid()) + guildId String + userId String + xp Int @default(0) + level Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, userId]) + @@index([guildId, xp]) +} + +model LevelReward { + id String @id @default(cuid()) + guildId String + level Int + roleId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, level]) + @@index([guildId]) +} + +model EconomyConfig { + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(true) + currencyName String @default("Coins") + currencySymbol String @default("đŸȘ™") + dailyAmount Int @default(200) + weeklyAmount Int @default(1000) + workMin Int @default(50) + workMax Int @default(150) + workCooldownSeconds Int @default(3600) + startingBalance Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model MemberEconomy { + id String @id @default(cuid()) + guildId String + userId String + balance Int @default(0) + lastDailyAt DateTime? + lastWeeklyAt DateTime? + lastWorkAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, userId]) + @@index([guildId, balance]) +} + +model ShopItem { + id String @id @default(cuid()) + guildId String + name String + description String? + price Int + roleId String? + stock Int? + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + inventory InventoryItem[] + + @@index([guildId, enabled]) +} + +model InventoryItem { + id String @id @default(cuid()) + guildId String + userId String + itemId String + quantity Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + item ShopItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@unique([guildId, userId, itemId]) + @@index([guildId, userId]) +} + +model Poll { + id String @id @default(cuid()) + guildId String + channelId String + messageId String? + creatorId String + question String + options Json + multiSelect Boolean @default(false) + anonymous Boolean @default(false) + endsAt DateTime? + closedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + votes PollVote[] + + @@index([guildId]) +} + +model PollVote { + id String @id @default(cuid()) + pollId String + userId String + optionIndex Int + createdAt DateTime @default(now()) + poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade) + + @@unique([pollId, userId, optionIndex]) + @@index([pollId]) +} + +model Reminder { + id String @id @default(cuid()) + guildId String? + userId String + channelId String + content String + remindAt DateTime + recurringCron String? + jobId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([remindAt]) +} + +model AfkStatus { + id String @id @default(cuid()) + guildId String + userId String + reason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@unique([guildId, userId]) +} diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index b72a668..ff57d9c 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -9,12 +9,16 @@ import type { BotContext } from './types.js'; import { env } from './env.js'; 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 { automodQueue, automodQueueName, backupQueue, backupQueueName, moderationQueueName, + reminderQueue, + reminderQueueName, verificationQueueName } from './queues.js'; @@ -23,6 +27,7 @@ export { backupQueue, moderationQueue, moderationQueueName, + reminderQueue, verificationQueue } from './queues.js'; @@ -37,6 +42,14 @@ type VerificationCompleteJob = { userId: string; }; +type ReminderSendJob = { + reminderId: string; +}; + +type PollCloseJob = { + pollId: string; +}; + export function startWorkers(context: BotContext): Worker[] { const moderationWorker = new Worker( moderationQueueName, @@ -105,7 +118,25 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Verification job failed'); }); - return [moderationWorker, backupWorker, automodWorker, verificationWorker]; + const reminderWorker = new Worker( + reminderQueueName, + async (job) => { + if (job.name === 'reminderSend') { + await sendReminder(context, (job.data as ReminderSendJob).reminderId); + return; + } + if (job.name === 'pollClose') { + await closePoll(context, (job.data as PollCloseJob).pollId); + } + }, + { connection: redis } + ); + + reminderWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Reminder job failed'); + }); + + return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker]; } export async function ensureRecurringJobs(): Promise { diff --git a/apps/bot/src/modules/economy/blackjack-buttons.ts b/apps/bot/src/modules/economy/blackjack-buttons.ts new file mode 100644 index 0000000..0e4897f --- /dev/null +++ b/apps/bot/src/modules/economy/blackjack-buttons.ts @@ -0,0 +1,50 @@ +import type { ButtonInteraction } from 'discord.js'; +import { t } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { + BLACKJACK_BUTTON_PREFIX, + hitBlackjack, + parseBlackjackButtonCustomId, + standBlackjack +} from './games.js'; +import { EconomyError } from './service.js'; + +export function isBlackjackButton(customId: string): boolean { + return customId.startsWith(BLACKJACK_BUTTON_PREFIX); +} + +export async function handleBlackjackButton( + interaction: ButtonInteraction, + context: BotContext +): Promise { + const parsed = parseBlackjackButtonCustomId(interaction.customId); + const locale = await getGuildLocale(context.prisma, interaction.guildId); + + if (!parsed || !interaction.guildId) { + await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true }); + return; + } + + if (interaction.user.id !== parsed.userId) { + await interaction.reply({ content: t(locale, 'economy.blackjack.notOwner'), ephemeral: true }); + return; + } + + try { + if (parsed.action === 'hit') { + const result = await hitBlackjack(context, parsed.guildId, parsed.userId, locale); + await interaction.update({ embeds: [result.embed], components: result.components }); + return; + } + + const result = await standBlackjack(context, parsed.guildId, parsed.userId, locale); + await interaction.update({ embeds: [result.embed], components: [] }); + } catch (error) { + if (error instanceof EconomyError && error.code === 'not_found') { + await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true }); + return; + } + throw error; + } +} diff --git a/apps/bot/src/modules/economy/command-definitions.ts b/apps/bot/src/modules/economy/command-definitions.ts new file mode 100644 index 0000000..b94ce12 --- /dev/null +++ b/apps/bot/src/modules/economy/command-definitions.ts @@ -0,0 +1,121 @@ +import { + SlashCommandBuilder, + type SlashCommandIntegerOption, + type SlashCommandStringOption, + type SlashCommandUserOption +} from 'discord.js'; +import { + applyCommandDescription, + applyOptionDescription, + localizedChoice, + type CommandLocaleKey +} from '@nexumi/shared'; + +function amountOpt(o: SlashCommandIntegerOption, key: CommandLocaleKey = 'economy.common.options.amount') { + return applyOptionDescription(o.setName('amount').setRequired(true).setMinValue(1), key); +} + +function userOpt(o: SlashCommandUserOption, key: CommandLocaleKey = 'economy.common.options.user') { + return applyOptionDescription(o.setName('user').setRequired(true), key); +} + +function optionalUserOpt(o: SlashCommandUserOption) { + return applyOptionDescription(o.setName('user'), 'economy.common.options.userOptional'); +} + +function choiceOpt(o: SlashCommandStringOption) { + return applyOptionDescription(o.setName('choice').setRequired(true), 'economy.coinflip.options.choice').addChoices( + localizedChoice('economy.choice.heads', 'heads'), + localizedChoice('economy.choice.tails', 'tails') + ); +} + +export const balanceCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('balance'), + 'economy.balance.description' +).addUserOption((o) => optionalUserOpt(o)); + +export const dailyCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('daily'), + 'economy.daily.description' +); + +export const weeklyCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('weekly'), + 'economy.weekly.description' +); + +export const workCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('work'), + 'economy.work.description' +); + +export const payCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('pay'), + 'economy.pay.description' +) + .addUserOption((o) => userOpt(o)) + .addIntegerOption((o) => amountOpt(o)); + +export const gambleCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('gamble'), + 'economy.gamble.description' +).addIntegerOption((o) => amountOpt(o)); + +export const slotsCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('slots'), + 'economy.slots.description' +).addIntegerOption((o) => amountOpt(o)); + +export const blackjackCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('blackjack'), + 'economy.blackjack.description' +).addIntegerOption((o) => amountOpt(o)); + +export const coinflipCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('coinflip'), + 'economy.coinflip.description' +) + .addIntegerOption((o) => amountOpt(o)) + .addStringOption((o) => choiceOpt(o)); + +export const shopCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('shop'), + 'economy.shop.description' +) + .addSubcommand((s) => + applyCommandDescription(s.setName('view'), 'economy.shop.view.description') + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('buy'), 'economy.shop.buy.description').addStringOption((o) => + applyOptionDescription(o.setName('item_id').setRequired(true), 'economy.shop.options.item_id') + ) + ); + +export const inventoryCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('inventory'), + 'economy.inventory.description' +); + +export const ecoCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('eco'), + 'economy.eco.description' +) + .addSubcommand((s) => + applyCommandDescription(s.setName('leaderboard'), 'economy.eco.leaderboard.description') + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('give'), 'economy.eco.give.description') + .addUserOption((o) => userOpt(o, 'economy.common.options.target')) + .addIntegerOption((o) => amountOpt(o)) + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('remove'), 'economy.eco.remove.description') + .addUserOption((o) => userOpt(o, 'economy.common.options.target')) + .addIntegerOption((o) => amountOpt(o)) + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('reset'), 'economy.eco.reset.description').addUserOption((o) => + userOpt(o, 'economy.common.options.target') + ) + ); diff --git a/apps/bot/src/modules/economy/commands.ts b/apps/bot/src/modules/economy/commands.ts new file mode 100644 index 0000000..377a070 --- /dev/null +++ b/apps/bot/src/modules/economy/commands.ts @@ -0,0 +1,497 @@ +import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; +import { formatCurrency, randomInt, t, tf } from '@nexumi/shared'; +import type { SlashCommand } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { requirePermission } from '../../permissions.js'; +import { + balanceCommandData, + blackjackCommandData, + coinflipCommandData, + dailyCommandData, + ecoCommandData, + gambleCommandData, + inventoryCommandData, + payCommandData, + shopCommandData, + slotsCommandData, + weeklyCommandData, + workCommandData +} from './command-definitions.js'; +import { + adminGiveBalance, + adminRemoveBalance, + adminResetEconomy, + assertEconomyEnabled, + buyShopItem, + claimDaily, + claimWeekly, + claimWork, + EconomyError, + formatCooldownSeconds, + getBalance, + getDailyCooldownRemaining, + getEconomyConfig, + getInventory, + getLeaderboard, + getWeeklyCooldownRemaining, + getWorkCooldownRemaining, + listShopItems, + transferBalance +} from './service.js'; +import { playCoinflipBet, playGamble, playSlots, startBlackjack, type CoinflipChoice } from './games.js'; + +async function ensureGuildCommand( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en' +): Promise { + if (!interaction.inGuild()) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return null; + } + return interaction.guildId!; +} + +async function handleEconomyError( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en', + error: unknown, + extras?: Record +): Promise { + if (!(error instanceof EconomyError)) { + throw error; + } + + const key = `economy.error.${error.code}` as const; + const content = + error.code === 'cooldown' && extras?.seconds !== undefined + ? tf(locale, 'economy.error.cooldown', { seconds: extras.seconds }) + : t(locale, key); + + await interaction.reply({ content, ephemeral: true }); +} + +function formatMoney(locale: 'de' | 'en', amount: number, symbol: string, name: string): string { + return formatCurrency(amount, symbol, name); +} + +const balanceCommand: SlashCommand = { + data: balanceCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + try { + const config = await assertEconomyEnabled(context.prisma, guildId); + const target = interaction.options.getUser('user') ?? interaction.user; + const balance = await getBalance(context.prisma, guildId, target.id); + await interaction.reply({ + content: tf(locale, 'economy.balance.result', { + user: `<@${target.id}>`, + amount: formatMoney(locale, balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const dailyCommand: SlashCommand = { + data: dailyCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + try { + const config = await getEconomyConfig(context.prisma, guildId); + const result = await claimDaily(context.prisma, guildId, interaction.user.id); + await interaction.reply({ + content: tf(locale, 'economy.daily.success', { + amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + if (error instanceof EconomyError && error.code === 'cooldown') { + const seconds = formatCooldownSeconds( + await getDailyCooldownRemaining(context.prisma, guildId, interaction.user.id) + ); + await handleEconomyError(interaction, locale, error, { seconds }); + return; + } + await handleEconomyError(interaction, locale, error); + } + } +}; + +const weeklyCommand: SlashCommand = { + data: weeklyCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + try { + const config = await getEconomyConfig(context.prisma, guildId); + const result = await claimWeekly(context.prisma, guildId, interaction.user.id); + await interaction.reply({ + content: tf(locale, 'economy.weekly.success', { + amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + if (error instanceof EconomyError && error.code === 'cooldown') { + const seconds = formatCooldownSeconds( + await getWeeklyCooldownRemaining(context.prisma, guildId, interaction.user.id) + ); + await handleEconomyError(interaction, locale, error, { seconds }); + return; + } + await handleEconomyError(interaction, locale, error); + } + } +}; + +const workCommand: SlashCommand = { + data: workCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + try { + const config = await getEconomyConfig(context.prisma, guildId); + const earned = randomInt(config.workMin, config.workMax); + const result = await claimWork(context.prisma, guildId, interaction.user.id, earned); + await interaction.reply({ + content: tf(locale, 'economy.work.success', { + amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + if (error instanceof EconomyError && error.code === 'cooldown') { + const seconds = formatCooldownSeconds( + await getWorkCooldownRemaining(context.prisma, guildId, interaction.user.id) + ); + await handleEconomyError(interaction, locale, error, { seconds }); + return; + } + await handleEconomyError(interaction, locale, error); + } + } +}; + +const payCommand: SlashCommand = { + data: payCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + const target = interaction.options.getUser('user', true); + const amount = interaction.options.getInteger('amount', true); + + try { + await assertEconomyEnabled(context.prisma, guildId); + const config = await getEconomyConfig(context.prisma, guildId); + const { sender } = await transferBalance( + context.prisma, + guildId, + interaction.user.id, + target.id, + amount + ); + await interaction.reply({ + content: tf(locale, 'economy.pay.success', { + user: `<@${target.id}>`, + amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, sender.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const gambleCommand: SlashCommand = { + data: gambleCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + const amount = interaction.options.getInteger('amount', true); + try { + const config = await getEconomyConfig(context.prisma, guildId); + const result = await playGamble(context, guildId, interaction.user.id, amount); + await interaction.reply({ + content: tf(locale, result.won ? 'economy.gamble.win' : 'economy.gamble.lose', { + amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const slotsCommand: SlashCommand = { + data: slotsCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + const amount = interaction.options.getInteger('amount', true); + try { + const config = await getEconomyConfig(context.prisma, guildId); + const result = await playSlots(context, guildId, interaction.user.id, amount); + const key = result.payout > 0 ? 'economy.slots.win' : 'economy.slots.lose'; + await interaction.reply({ + content: tf(locale, key, { + reels: result.reels.join(' | '), + payout: formatMoney(locale, result.payout, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const blackjackCommand: SlashCommand = { + data: blackjackCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + const amount = interaction.options.getInteger('amount', true); + try { + const { embed, components } = await startBlackjack( + context, + guildId, + interaction.user.id, + amount, + locale + ); + await interaction.reply({ embeds: [embed], components }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const coinflipCommand: SlashCommand = { + data: coinflipCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + const amount = interaction.options.getInteger('amount', true); + const choice = interaction.options.getString('choice', true) as CoinflipChoice; + + try { + const config = await getEconomyConfig(context.prisma, guildId); + const result = await playCoinflipBet(context, guildId, interaction.user.id, amount, choice); + const choiceLabel = t(locale, `economy.choice.${choice}`); + const resultLabel = t(locale, `economy.choice.${result.result}`); + await interaction.reply({ + content: tf(locale, result.won ? 'economy.coinflip.win' : 'economy.coinflip.lose', { + choice: choiceLabel, + result: resultLabel, + amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const shopCommand: SlashCommand = { + data: shopCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + const sub = interaction.options.getSubcommand(); + try { + const config = await getEconomyConfig(context.prisma, guildId); + + if (sub === 'view') { + const items = await listShopItems(context.prisma, guildId); + const content = + items.length === 0 + ? t(locale, 'economy.shop.empty') + : items + .map((item) => + tf(locale, 'economy.shop.itemLine', { + id: item.id, + name: item.name, + price: formatMoney(locale, item.price, config.currencySymbol, config.currencyName), + stock: item.stock === null ? '∞' : String(item.stock), + description: item.description ?? t(locale, 'economy.shop.noDescription') + }) + ) + .join('\n'); + await interaction.reply({ content, ephemeral: true }); + return; + } + + const itemId = interaction.options.getString('item_id', true); + const purchase = await buyShopItem(context, guildId, interaction.user.id, itemId); + await interaction.reply({ + content: tf(locale, 'economy.shop.purchased', { + name: purchase.item.name, + balance: formatMoney(locale, purchase.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const inventoryCommand: SlashCommand = { + data: inventoryCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + try { + const entries = await getInventory(context.prisma, guildId, interaction.user.id); + const content = + entries.length === 0 + ? t(locale, 'economy.inventory.empty') + : entries + .map((entry) => + tf(locale, 'economy.inventory.itemLine', { + name: entry.item.name, + quantity: entry.quantity + }) + ) + .join('\n'); + await interaction.reply({ content, ephemeral: true }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +const ecoCommand: SlashCommand = { + data: ecoCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuildCommand(interaction, locale); + if (!guildId) return; + + const sub = interaction.options.getSubcommand(); + + if (sub === 'leaderboard') { + try { + const config = await getEconomyConfig(context.prisma, guildId); + const rows = await getLeaderboard(context.prisma, guildId); + const content = + rows.length === 0 + ? t(locale, 'economy.leaderboard.empty') + : rows + .map((row, index) => + tf(locale, 'economy.leaderboard.line', { + rank: index + 1, + user: `<@${row.userId}>`, + amount: formatMoney(locale, row.balance, config.currencySymbol, config.currencyName) + }) + ) + .join('\n'); + await interaction.reply({ content, ephemeral: true }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + return; + } + + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { + return; + } + + const target = interaction.options.getUser('user', true); + + try { + const config = await getEconomyConfig(context.prisma, guildId); + + if (sub === 'give') { + const amount = interaction.options.getInteger('amount', true); + const updated = await adminGiveBalance(context.prisma, guildId, target.id, amount); + await interaction.reply({ + content: tf(locale, 'economy.admin.give', { + user: `<@${target.id}>`, + amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + return; + } + + if (sub === 'remove') { + const amount = interaction.options.getInteger('amount', true); + const updated = await adminRemoveBalance(context.prisma, guildId, target.id, amount); + await interaction.reply({ + content: tf(locale, 'economy.admin.remove', { + user: `<@${target.id}>`, + amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName), + balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName) + }), + ephemeral: true + }); + return; + } + + await adminResetEconomy(context.prisma, guildId, target.id); + await interaction.reply({ + content: tf(locale, 'economy.admin.reset', { + user: `<@${target.id}>` + }), + ephemeral: true + }); + } catch (error) { + await handleEconomyError(interaction, locale, error); + } + } +}; + +export const economyCommands: SlashCommand[] = [ + balanceCommand, + dailyCommand, + weeklyCommand, + workCommand, + payCommand, + gambleCommand, + slotsCommand, + blackjackCommand, + coinflipCommand, + shopCommand, + inventoryCommand, + ecoCommand +]; diff --git a/apps/bot/src/modules/economy/games.ts b/apps/bot/src/modules/economy/games.ts new file mode 100644 index 0000000..1da5008 --- /dev/null +++ b/apps/bot/src/modules/economy/games.ts @@ -0,0 +1,420 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type APIEmbed } from 'discord.js'; +import { formatCurrency, randomInt } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import type { Locale } from '@nexumi/shared'; +import { tf } from '@nexumi/shared'; +import { + addBalance, + assertEconomyEnabled, + deductBalance, + EconomyError, + getEconomyConfig +} from './service.js'; + +const BLACKJACK_TTL_SECONDS = 300; +const BLACKJACK_KEY_PREFIX = 'eco:bj:'; +export const BLACKJACK_BUTTON_PREFIX = 'eco:bj:'; + +const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] as const; +const SUITS = ['H', 'D', 'C', 'S'] as const; + +type Card = `${(typeof RANKS)[number]}${(typeof SUITS)[number]}`; + +export type BlackjackState = { + userId: string; + guildId: string; + bet: number; + deck: Card[]; + playerHand: Card[]; + dealerHand: Card[]; + finished: boolean; +}; + +const SLOT_SYMBOLS = ['🍒', '🍋', '🔔', '⭐', '💎', '7ïžâƒŁ'] as const; + +function buildDeck(): Card[] { + const deck: Card[] = []; + for (const suit of SUITS) { + for (const rank of RANKS) { + deck.push(`${rank}${suit}`); + } + } + for (let i = deck.length - 1; i > 0; i -= 1) { + const j = randomInt(0, i); + [deck[i], deck[j]] = [deck[j]!, deck[i]!]; + } + return deck; +} + +function cardValue(card: Card): number { + const rank = card.slice(0, card.length - 1); + if (rank === 'A') { + return 11; + } + if (rank === 'K' || rank === 'Q' || rank === 'J') { + return 10; + } + return Number(rank); +} + +export function handValue(hand: Card[]): number { + let total = hand.reduce((sum, card) => sum + cardValue(card), 0); + let aces = hand.filter((card) => card.startsWith('A')).length; + while (total > 21 && aces > 0) { + total -= 10; + aces -= 1; + } + return total; +} + +function isBlackjack(hand: Card[]): boolean { + return hand.length === 2 && handValue(hand) === 21; +} + +function formatHand(hand: Card[], hideFirst = false): string { + if (hideFirst && hand.length > 0) { + return `🂠 ${hand.slice(1).join(' ')}`; + } + return hand.join(' '); +} + +function blackjackKey(guildId: string, userId: string): string { + return `${BLACKJACK_KEY_PREFIX}${guildId}:${userId}`; +} + +async function saveBlackjackState(context: BotContext, state: BlackjackState): Promise { + await context.redis.set( + blackjackKey(state.guildId, state.userId), + JSON.stringify(state), + 'EX', + BLACKJACK_TTL_SECONDS + ); +} + +async function loadBlackjackState( + context: BotContext, + guildId: string, + userId: string +): Promise { + const raw = await context.redis.get(blackjackKey(guildId, userId)); + if (!raw) { + return null; + } + return JSON.parse(raw) as BlackjackState; +} + +async function deleteBlackjackState(context: BotContext, guildId: string, userId: string): Promise { + await context.redis.del(blackjackKey(guildId, userId)); +} + +function drawCard(state: BlackjackState): Card { + const card = state.deck.pop(); + if (!card) { + throw new Error('Blackjack deck empty'); + } + return card; +} + +export function buildBlackjackButtons(locale: Locale, guildId: string, userId: string): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${BLACKJACK_BUTTON_PREFIX}hit:${guildId}:${userId}`) + .setLabel(tf(locale, 'economy.blackjack.button.hit', {})) + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`${BLACKJACK_BUTTON_PREFIX}stand:${guildId}:${userId}`) + .setLabel(tf(locale, 'economy.blackjack.button.stand', {})) + .setStyle(ButtonStyle.Secondary) + ); +} + +export function buildBlackjackEmbed( + locale: Locale, + state: BlackjackState, + config: { currencySymbol: string; currencyName: string }, + revealDealer: boolean, + outcome?: 'win' | 'lose' | 'push' | 'blackjack' +): APIEmbed { + const playerTotal = handValue(state.playerHand); + const dealerTotal = revealDealer ? handValue(state.dealerHand) : handValue(state.dealerHand.slice(1)); + + let description = tf(locale, 'economy.blackjack.hand', { + playerHand: formatHand(state.playerHand), + playerTotal: String(playerTotal), + dealerHand: formatHand(state.dealerHand, !revealDealer), + dealerTotal: revealDealer ? String(dealerTotal) : '?', + bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName) + }); + + if (outcome) { + const outcomeKey = `economy.blackjack.outcome.${outcome}` as const; + description += `\n\n${tf(locale, outcomeKey, { + bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName) + })}`; + } + + return { + title: tf(locale, 'economy.blackjack.title', {}), + description, + color: outcome === 'win' || outcome === 'blackjack' ? 0x22c55e : outcome === 'push' ? 0xeab308 : 0x6366f1 + }; +} + +async function finishBlackjack( + context: BotContext, + state: BlackjackState +): Promise<{ state: BlackjackState; outcome: 'win' | 'lose' | 'push' | 'blackjack'; payout: number }> { + const playerTotal = handValue(state.playerHand); + const dealerTotal = handValue(state.dealerHand); + + let outcome: 'win' | 'lose' | 'push' | 'blackjack'; + let payout = 0; + + if (playerTotal > 21) { + outcome = 'lose'; + } else if (isBlackjack(state.playerHand) && !isBlackjack(state.dealerHand)) { + outcome = 'blackjack'; + payout = Math.floor(state.bet * 2.5); + } else if (dealerTotal > 21 || playerTotal > dealerTotal) { + outcome = 'win'; + payout = state.bet * 2; + } else if (playerTotal === dealerTotal) { + outcome = 'push'; + payout = state.bet; + } else { + outcome = 'lose'; + } + + if (payout > 0) { + await addBalance(context.prisma, state.guildId, state.userId, payout); + } + + state.finished = true; + await deleteBlackjackState(context, state.guildId, state.userId); + + return { state, outcome, payout }; +} + +export async function startBlackjack( + context: BotContext, + guildId: string, + userId: string, + bet: number, + locale: Locale +): Promise<{ embed: APIEmbed; components: ActionRowBuilder[] }> { + await assertEconomyEnabled(context.prisma, guildId); + if (!Number.isInteger(bet) || bet <= 0) { + throw new EconomyError('invalid_amount'); + } + + const existing = await loadBlackjackState(context, guildId, userId); + if (existing && !existing.finished) { + throw new EconomyError('game_in_progress'); + } + + await deductBalance(context.prisma, guildId, userId, bet); + + const state: BlackjackState = { + userId, + guildId, + bet, + deck: buildDeck(), + playerHand: [], + dealerHand: [], + finished: false + }; + + state.playerHand.push(drawCard(state), drawCard(state)); + state.dealerHand.push(drawCard(state), drawCard(state)); + + const config = await getEconomyConfig(context.prisma, guildId); + + if (isBlackjack(state.playerHand) || isBlackjack(state.dealerHand)) { + const { state: finished, outcome } = await finishBlackjack(context, state); + return { + embed: buildBlackjackEmbed(locale, finished, config, true, outcome), + components: [] + }; + } + + await saveBlackjackState(context, state); + return { + embed: buildBlackjackEmbed(locale, state, config, false), + components: [buildBlackjackButtons(locale, guildId, userId)] + }; +} + +export async function hitBlackjack( + context: BotContext, + guildId: string, + userId: string, + locale: Locale +): Promise<{ embed: APIEmbed; components: ActionRowBuilder[]; finished: boolean }> { + const state = await loadBlackjackState(context, guildId, userId); + if (!state || state.finished) { + throw new EconomyError('not_found'); + } + if (state.userId !== userId) { + throw new EconomyError('not_found'); + } + + state.playerHand.push(drawCard(state)); + const config = await getEconomyConfig(context.prisma, guildId); + + if (handValue(state.playerHand) > 21) { + const { state: finished, outcome } = await finishBlackjack(context, state); + return { + embed: buildBlackjackEmbed(locale, finished, config, true, outcome), + components: [], + finished: true + }; + } + + await saveBlackjackState(context, state); + return { + embed: buildBlackjackEmbed(locale, state, config, false), + components: [buildBlackjackButtons(locale, guildId, userId)], + finished: false + }; +} + +export async function standBlackjack( + context: BotContext, + guildId: string, + userId: string, + locale: Locale +): Promise<{ embed: APIEmbed; finished: true }> { + const state = await loadBlackjackState(context, guildId, userId); + if (!state || state.finished) { + throw new EconomyError('not_found'); + } + if (state.userId !== userId) { + throw new EconomyError('not_found'); + } + + while (handValue(state.dealerHand) < 17) { + state.dealerHand.push(drawCard(state)); + } + + const config = await getEconomyConfig(context.prisma, guildId); + const { state: finished, outcome } = await finishBlackjack(context, state); + return { + embed: buildBlackjackEmbed(locale, finished, config, true, outcome), + finished: true + }; +} + +export async function playGamble( + context: BotContext, + guildId: string, + userId: string, + amount: number +): Promise<{ won: boolean; balance: number }> { + await assertEconomyEnabled(context.prisma, guildId); + if (!Number.isInteger(amount) || amount <= 0) { + throw new EconomyError('invalid_amount'); + } + + await deductBalance(context.prisma, guildId, userId, amount); + const won = Math.random() < 0.5; + if (won) { + const updated = await addBalance(context.prisma, guildId, userId, amount * 2); + return { won: true, balance: updated.balance }; + } + const member = await context.prisma.memberEconomy.findUniqueOrThrow({ + where: { guildId_userId: { guildId, userId } } + }); + return { won: false, balance: member.balance }; +} + +export async function playSlots( + context: BotContext, + guildId: string, + userId: string, + amount: number +): Promise<{ reels: string[]; multiplier: number; payout: number; balance: number }> { + await assertEconomyEnabled(context.prisma, guildId); + if (!Number.isInteger(amount) || amount <= 0) { + throw new EconomyError('invalid_amount'); + } + + await deductBalance(context.prisma, guildId, userId, amount); + + const reels = [ + SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!, + SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!, + SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]! + ]; + + let multiplier = 0; + if (reels[0] === reels[1] && reels[1] === reels[2]) { + multiplier = reels[0] === '7ïžâƒŁ' ? 10 : reels[0] === '💎' ? 5 : 3; + } else if (reels[0] === reels[1] || reels[1] === reels[2] || reels[0] === reels[2]) { + multiplier = 1.5; + } + + const payout = Math.floor(amount * multiplier); + let balance: number; + if (payout > 0) { + const updated = await addBalance(context.prisma, guildId, userId, payout); + balance = updated.balance; + } else { + const member = await context.prisma.memberEconomy.findUniqueOrThrow({ + where: { guildId_userId: { guildId, userId } } + }); + balance = member.balance; + } + + return { reels, multiplier, payout, balance }; +} + +export type CoinflipChoice = 'heads' | 'tails'; + +export async function playCoinflipBet( + context: BotContext, + guildId: string, + userId: string, + amount: number, + choice: CoinflipChoice +): Promise<{ result: CoinflipChoice; won: boolean; balance: number }> { + await assertEconomyEnabled(context.prisma, guildId); + if (!Number.isInteger(amount) || amount <= 0) { + throw new EconomyError('invalid_amount'); + } + + await deductBalance(context.prisma, guildId, userId, amount); + const result: CoinflipChoice = Math.random() < 0.5 ? 'heads' : 'tails'; + const won = result === choice; + + if (won) { + const updated = await addBalance(context.prisma, guildId, userId, amount * 2); + return { result, won: true, balance: updated.balance }; + } + + const member = await context.prisma.memberEconomy.findUniqueOrThrow({ + where: { guildId_userId: { guildId, userId } } + }); + return { result, won: false, balance: member.balance }; +} + +export function parseBlackjackButtonCustomId(customId: string): { + action: 'hit' | 'stand'; + guildId: string; + userId: string; +} | null { + if (!customId.startsWith(BLACKJACK_BUTTON_PREFIX)) { + return null; + } + const parts = customId.slice(BLACKJACK_BUTTON_PREFIX.length).split(':'); + if (parts.length !== 3) { + return null; + } + const [action, guildId, userId] = parts; + if (action !== 'hit' && action !== 'stand') { + return null; + } + if (!guildId || !userId) { + return null; + } + return { action, guildId, userId }; +} diff --git a/apps/bot/src/modules/economy/index.ts b/apps/bot/src/modules/economy/index.ts new file mode 100644 index 0000000..a0e1f9f --- /dev/null +++ b/apps/bot/src/modules/economy/index.ts @@ -0,0 +1,2 @@ +export { economyCommands } from './commands.js'; +export { isBlackjackButton, handleBlackjackButton } from './blackjack-buttons.js'; diff --git a/apps/bot/src/modules/economy/service.ts b/apps/bot/src/modules/economy/service.ts new file mode 100644 index 0000000..d0c9177 --- /dev/null +++ b/apps/bot/src/modules/economy/service.ts @@ -0,0 +1,380 @@ +import type { EconomyConfig, MemberEconomy, Prisma, PrismaClient, ShopItem } from '@prisma/client'; +import { ensureGuild } from '../../guild.js'; +import type { BotContext } from '../../types.js'; + +type DbClient = PrismaClient | Prisma.TransactionClient; + +export type EconomyErrorCode = + | 'disabled' + | 'insufficient' + | 'cooldown' + | 'invalid_amount' + | 'self_transfer' + | 'not_found' + | 'out_of_stock' + | 'role_assign_failed' + | 'game_in_progress'; + +export class EconomyError extends Error { + constructor(public readonly code: EconomyErrorCode) { + super(code); + this.name = 'EconomyError'; + } +} + +const DAILY_COOLDOWN_MS = 24 * 60 * 60 * 1000; +const WEEKLY_COOLDOWN_MS = 7 * DAILY_COOLDOWN_MS; + +export async function getEconomyConfig( + prisma: DbClient, + guildId: string +): Promise { + await ensureGuild(prisma as PrismaClient, guildId); + return prisma.economyConfig.upsert({ + where: { guildId }, + update: {}, + create: { guildId } + }); +} + +export async function assertEconomyEnabled(prisma: PrismaClient, guildId: string): Promise { + const config = await getEconomyConfig(prisma, guildId); + if (!config.enabled) { + throw new EconomyError('disabled'); + } + return config; +} + +export async function ensureMemberEconomy( + prisma: DbClient, + guildId: string, + userId: string +): Promise { + const config = await getEconomyConfig(prisma, guildId); + return prisma.memberEconomy.upsert({ + where: { guildId_userId: { guildId, userId } }, + update: {}, + create: { + guildId, + userId, + balance: config.startingBalance + } + }); +} + +export async function getBalance( + prisma: PrismaClient, + guildId: string, + userId: string +): Promise { + const member = await ensureMemberEconomy(prisma, guildId, userId); + return member.balance; +} + +function assertPositiveAmount(amount: number): void { + if (!Number.isInteger(amount) || amount <= 0) { + throw new EconomyError('invalid_amount'); + } +} + +export async function addBalance( + prisma: DbClient, + guildId: string, + userId: string, + amount: number +): Promise { + await ensureMemberEconomy(prisma, guildId, userId); + return prisma.memberEconomy.update({ + where: { guildId_userId: { guildId, userId } }, + data: { balance: { increment: amount } } + }); +} + +export async function deductBalance( + prisma: DbClient, + guildId: string, + userId: string, + amount: number +): Promise { + assertPositiveAmount(amount); + const member = await ensureMemberEconomy(prisma, guildId, userId); + if (member.balance < amount) { + throw new EconomyError('insufficient'); + } + return prisma.memberEconomy.update({ + where: { guildId_userId: { guildId, userId } }, + data: { balance: { decrement: amount } } + }); +} + +export async function transferBalance( + prisma: PrismaClient, + guildId: string, + fromUserId: string, + toUserId: string, + amount: number +): Promise<{ sender: MemberEconomy; receiver: MemberEconomy }> { + assertPositiveAmount(amount); + if (fromUserId === toUserId) { + throw new EconomyError('self_transfer'); + } + + return prisma.$transaction(async (tx) => { + const sender = await ensureMemberEconomy(tx, guildId, fromUserId); + if (sender.balance < amount) { + throw new EconomyError('insufficient'); + } + await ensureMemberEconomy(tx, guildId, toUserId); + const updatedSender = await tx.memberEconomy.update({ + where: { guildId_userId: { guildId, userId: fromUserId } }, + data: { balance: { decrement: amount } } + }); + const updatedReceiver = await tx.memberEconomy.update({ + where: { guildId_userId: { guildId, userId: toUserId } }, + data: { balance: { increment: amount } } + }); + return { sender: updatedSender, receiver: updatedReceiver }; + }); +} + +function cooldownRemainingMs(lastAt: Date | null, cooldownMs: number): number { + if (!lastAt) { + return 0; + } + const elapsed = Date.now() - lastAt.getTime(); + return Math.max(0, cooldownMs - elapsed); +} + +export async function claimDaily( + prisma: PrismaClient, + guildId: string, + userId: string +): Promise<{ amount: number; balance: number }> { + const config = await assertEconomyEnabled(prisma, guildId); + const member = await ensureMemberEconomy(prisma, guildId, userId); + const remaining = cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS); + if (remaining > 0) { + throw new EconomyError('cooldown'); + } + + const updated = await prisma.memberEconomy.update({ + where: { guildId_userId: { guildId, userId } }, + data: { + balance: { increment: config.dailyAmount }, + lastDailyAt: new Date() + } + }); + return { amount: config.dailyAmount, balance: updated.balance }; +} + +export async function claimWeekly( + prisma: PrismaClient, + guildId: string, + userId: string +): Promise<{ amount: number; balance: number }> { + const config = await assertEconomyEnabled(prisma, guildId); + const member = await ensureMemberEconomy(prisma, guildId, userId); + const remaining = cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS); + if (remaining > 0) { + throw new EconomyError('cooldown'); + } + + const updated = await prisma.memberEconomy.update({ + where: { guildId_userId: { guildId, userId } }, + data: { + balance: { increment: config.weeklyAmount }, + lastWeeklyAt: new Date() + } + }); + return { amount: config.weeklyAmount, balance: updated.balance }; +} + +export async function claimWork( + prisma: PrismaClient, + guildId: string, + userId: string, + earned: number +): Promise<{ amount: number; balance: number }> { + await assertEconomyEnabled(prisma, guildId); + const config = await getEconomyConfig(prisma, guildId); + const member = await ensureMemberEconomy(prisma, guildId, userId); + const cooldownMs = config.workCooldownSeconds * 1000; + const remaining = cooldownRemainingMs(member.lastWorkAt, cooldownMs); + if (remaining > 0) { + throw new EconomyError('cooldown'); + } + + const updated = await prisma.memberEconomy.update({ + where: { guildId_userId: { guildId, userId } }, + data: { + balance: { increment: earned }, + lastWorkAt: new Date() + } + }); + return { amount: earned, balance: updated.balance }; +} + +export async function getWorkCooldownRemaining( + prisma: PrismaClient, + guildId: string, + userId: string +): Promise { + const config = await getEconomyConfig(prisma, guildId); + const member = await ensureMemberEconomy(prisma, guildId, userId); + return cooldownRemainingMs(member.lastWorkAt, config.workCooldownSeconds * 1000); +} + +export async function getDailyCooldownRemaining( + prisma: PrismaClient, + guildId: string, + userId: string +): Promise { + const member = await ensureMemberEconomy(prisma, guildId, userId); + return cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS); +} + +export async function getWeeklyCooldownRemaining( + prisma: PrismaClient, + guildId: string, + userId: string +): Promise { + const member = await ensureMemberEconomy(prisma, guildId, userId); + return cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS); +} + +export async function listShopItems(prisma: PrismaClient, guildId: string): Promise { + await assertEconomyEnabled(prisma, guildId); + return prisma.shopItem.findMany({ + where: { guildId, enabled: true }, + orderBy: { price: 'asc' } + }); +} + +export async function buyShopItem( + context: BotContext, + guildId: string, + userId: string, + itemId: string +): Promise<{ item: ShopItem; balance: number }> { + await assertEconomyEnabled(context.prisma, guildId); + + return context.prisma.$transaction(async (tx) => { + const item = await tx.shopItem.findFirst({ + where: { id: itemId, guildId, enabled: true } + }); + if (!item) { + throw new EconomyError('not_found'); + } + + if (item.stock !== null && item.stock <= 0) { + throw new EconomyError('out_of_stock'); + } + + const member = await ensureMemberEconomy(tx, guildId, userId); + if (member.balance < item.price) { + throw new EconomyError('insufficient'); + } + + const updatedMember = await tx.memberEconomy.update({ + where: { guildId_userId: { guildId, userId } }, + data: { balance: { decrement: item.price } } + }); + + await tx.inventoryItem.upsert({ + where: { guildId_userId_itemId: { guildId, userId, itemId: item.id } }, + update: { quantity: { increment: 1 } }, + create: { guildId, userId, itemId: item.id, quantity: 1 } + }); + + if (item.stock !== null) { + await tx.shopItem.update({ + where: { id: item.id }, + data: { stock: { decrement: 1 } } + }); + } + + if (item.roleId) { + const guild = context.client.guilds.cache.get(guildId) ?? (await context.client.guilds.fetch(guildId)); + const memberObj = await guild.members.fetch(userId); + try { + await memberObj.roles.add(item.roleId, 'Economy shop purchase'); + } catch { + throw new EconomyError('role_assign_failed'); + } + } + + return { item, balance: updatedMember.balance }; + }); +} + +export async function getInventory( + prisma: PrismaClient, + guildId: string, + userId: string +) { + await assertEconomyEnabled(prisma, guildId); + return prisma.inventoryItem.findMany({ + where: { guildId, userId }, + include: { item: true }, + orderBy: { createdAt: 'desc' } + }); +} + +export async function getLeaderboard(prisma: PrismaClient, guildId: string, limit = 10) { + await assertEconomyEnabled(prisma, guildId); + return prisma.memberEconomy.findMany({ + where: { guildId }, + orderBy: { balance: 'desc' }, + take: limit + }); +} + +export async function adminGiveBalance( + prisma: PrismaClient, + guildId: string, + userId: string, + amount: number +): Promise { + assertPositiveAmount(amount); + await assertEconomyEnabled(prisma, guildId); + return addBalance(prisma, guildId, userId, amount); +} + +export async function adminRemoveBalance( + prisma: PrismaClient, + guildId: string, + userId: string, + amount: number +): Promise { + return deductBalance(prisma, guildId, userId, amount); +} + +export async function adminResetEconomy( + prisma: PrismaClient, + guildId: string, + userId: string +): Promise { + await assertEconomyEnabled(prisma, guildId); + const config = await getEconomyConfig(prisma, guildId); + await prisma.$transaction([ + prisma.memberEconomy.upsert({ + where: { guildId_userId: { guildId, userId } }, + update: { + balance: config.startingBalance, + lastDailyAt: null, + lastWeeklyAt: null, + lastWorkAt: null + }, + create: { + guildId, + userId, + balance: config.startingBalance + } + }), + prisma.inventoryItem.deleteMany({ where: { guildId, userId } }) + ]); +} + +export function formatCooldownSeconds(ms: number): number { + return Math.ceil(ms / 1000); +} diff --git a/apps/bot/src/modules/fun/buttons.ts b/apps/bot/src/modules/fun/buttons.ts new file mode 100644 index 0000000..000e212 --- /dev/null +++ b/apps/bot/src/modules/fun/buttons.ts @@ -0,0 +1,234 @@ +import type { ButtonInteraction } from 'discord.js'; +import { t } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { + applyConnect4Move, + applyHangmanGuess, + applyTicTacToeMove, + buildConnect4Components, + buildConnect4Embed, + buildHangmanComponents, + buildHangmanEmbed, + buildTicTacToeComponents, + buildTicTacToeEmbed, + currentConnect4PlayerId, + currentTicTacToePlayerId, + deleteConnect4State, + deleteHangmanState, + deleteTicTacToeState, + deleteTriviaSession, + FUN_BUTTON_PREFIX, + loadConnect4State, + loadHangmanState, + loadTicTacToeState, + loadTriviaSession, + parseConnect4Button, + parseHangmanButton, + parseTicTacToeButton, + parseTriviaButton, + saveConnect4State, + saveHangmanState, + saveTicTacToeState +} from './games.js'; + +export function isFunButton(customId: string): boolean { + return customId.startsWith(FUN_BUTTON_PREFIX); +} + +async function replyNotParticipant(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise { + await interaction.reply({ content: t(locale, 'fun.common.notParticipant'), ephemeral: true }); +} + +async function replyExpired(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise { + await interaction.reply({ content: t(locale, 'fun.common.expired'), ephemeral: true }); +} + +async function handleTriviaButton( + interaction: ButtonInteraction, + context: BotContext, + locale: 'de' | 'en' +): Promise { + const parsed = parseTriviaButton(interaction.customId); + if (!parsed) { + await replyExpired(interaction, locale); + return; + } + + if (interaction.user.id !== parsed.userId) { + await interaction.reply({ content: t(locale, 'fun.trivia.notOwner'), ephemeral: true }); + return; + } + + const session = await loadTriviaSession(context, parsed.userId); + if (!session) { + await replyExpired(interaction, locale); + return; + } + + const correct = parsed.answerIndex === parsed.correctIndex; + await deleteTriviaSession(context, parsed.userId); + + await interaction.update({ + embeds: interaction.message.embeds, + components: [], + content: correct ? t(locale, 'fun.trivia.correct') : t(locale, 'fun.trivia.incorrect') + }); +} + +async function handleTicTacToeButton( + interaction: ButtonInteraction, + context: BotContext, + locale: 'de' | 'en' +): Promise { + const parsed = parseTicTacToeButton(interaction.customId); + if (!parsed) { + await replyExpired(interaction, locale); + return; + } + + const state = await loadTicTacToeState(context, parsed.gameId); + if (!state || state.status !== 'active') { + await replyExpired(interaction, locale); + return; + } + + if (interaction.user.id !== currentTicTacToePlayerId(state)) { + await replyNotParticipant(interaction, locale); + return; + } + + const next = applyTicTacToeMove(state, parsed.cell); + if (next.board[parsed.cell] === state.board[parsed.cell]) { + await interaction.reply({ content: t(locale, 'fun.tictactoe.invalidMove'), ephemeral: true }); + return; + } + + if (next.status !== 'active') { + await deleteTicTacToeState(context, parsed.gameId); + } else { + await saveTicTacToeState(context, next); + } + + await interaction.update({ + embeds: [buildTicTacToeEmbed(locale, next)], + components: buildTicTacToeComponents(next) + }); +} + +async function handleConnect4Button( + interaction: ButtonInteraction, + context: BotContext, + locale: 'de' | 'en' +): Promise { + const parsed = parseConnect4Button(interaction.customId); + if (!parsed) { + await replyExpired(interaction, locale); + return; + } + + const state = await loadConnect4State(context, parsed.gameId); + if (!state || state.status !== 'active') { + await replyExpired(interaction, locale); + return; + } + + if (interaction.user.id !== currentConnect4PlayerId(state)) { + await replyNotParticipant(interaction, locale); + return; + } + + const next = applyConnect4Move(state, parsed.col); + const changed = next.board.some((row, rowIndex) => + row.some((cell, colIndex) => cell !== state.board[rowIndex]![colIndex]) + ); + if (!changed) { + await interaction.reply({ content: t(locale, 'fun.connect4.invalidMove'), ephemeral: true }); + return; + } + + if (next.status !== 'active') { + await deleteConnect4State(context, parsed.gameId); + } else { + await saveConnect4State(context, next); + } + + await interaction.update({ + embeds: [buildConnect4Embed(locale, next)], + components: buildConnect4Components(next) + }); +} + +async function handleHangmanButton( + interaction: ButtonInteraction, + context: BotContext, + locale: 'de' | 'en' +): Promise { + const parsed = parseHangmanButton(interaction.customId); + if (!parsed) { + await replyExpired(interaction, locale); + return; + } + + const state = await loadHangmanState(context, parsed.gameId); + if (!state) { + await replyExpired(interaction, locale); + return; + } + + if (interaction.user.id !== state.userId) { + await replyNotParticipant(interaction, locale); + return; + } + + if (parsed.page !== undefined) { + const next = { ...state, letterPage: parsed.page }; + await saveHangmanState(context, next); + await interaction.update({ + embeds: [buildHangmanEmbed(locale, next)], + components: buildHangmanComponents(next) + }); + return; + } + + if (!parsed.letter || state.status !== 'active') { + await replyExpired(interaction, locale); + return; + } + + const next = applyHangmanGuess(state, parsed.letter); + if (next.status !== 'active') { + await deleteHangmanState(context, parsed.gameId); + } else { + await saveHangmanState(context, next); + } + + await interaction.update({ + embeds: [buildHangmanEmbed(locale, next)], + components: buildHangmanComponents(next) + }); +} + +export async function handleFunButton(interaction: ButtonInteraction, context: BotContext): Promise { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const parts = interaction.customId.split(':'); + + if (parts[1] === 'trivia') { + await handleTriviaButton(interaction, context, locale); + return; + } + if (parts[1] === 'ttt') { + await handleTicTacToeButton(interaction, context, locale); + return; + } + if (parts[1] === 'c4') { + await handleConnect4Button(interaction, context, locale); + return; + } + if (parts[1] === 'hm') { + await handleHangmanButton(interaction, context, locale); + return; + } + + await replyExpired(interaction, locale); +} diff --git a/apps/bot/src/modules/fun/command-definitions.ts b/apps/bot/src/modules/fun/command-definitions.ts new file mode 100644 index 0000000..84783c6 --- /dev/null +++ b/apps/bot/src/modules/fun/command-definitions.ts @@ -0,0 +1,112 @@ +import { + SlashCommandBuilder, + type SlashCommandIntegerOption, + type SlashCommandStringOption, + type SlashCommandUserOption +} from 'discord.js'; +import { + applyCommandDescription, + applyOptionDescription, + localizedChoice, + type CommandLocaleKey +} from '@nexumi/shared'; + +function questionOpt(o: SlashCommandStringOption) { + return applyOptionDescription(o.setName('question').setRequired(true), 'fun.8ball.options.question'); +} + +function sidesOpt(o: SlashCommandIntegerOption) { + return applyOptionDescription( + o.setName('sides').setMinValue(2).setMaxValue(100), + 'fun.dice.options.sides' + ); +} + +function countOpt(o: SlashCommandIntegerOption) { + return applyOptionDescription( + o.setName('count').setMinValue(1).setMaxValue(10), + 'fun.dice.options.count' + ); +} + +function rpsChoiceOpt(o: SlashCommandStringOption) { + return applyOptionDescription(o.setName('choice').setRequired(true), 'fun.rps.options.choice').addChoices( + localizedChoice('fun.rps.choice.rock', 'rock'), + localizedChoice('fun.rps.choice.paper', 'paper'), + localizedChoice('fun.rps.choice.scissors', 'scissors') + ); +} + +function optionsOpt(o: SlashCommandStringOption) { + return applyOptionDescription(o.setName('options').setRequired(true), 'fun.choose.options.options'); +} + +function opponentOpt(o: SlashCommandUserOption) { + return applyOptionDescription(o.setName('user').setRequired(true), 'fun.common.options.opponent'); +} + +export const eightBallCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('8ball'), + 'fun.8ball.description' +).addStringOption((o) => questionOpt(o)); + +export const diceCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('dice'), + 'fun.dice.description' +) + .addIntegerOption((o) => sidesOpt(o)) + .addIntegerOption((o) => countOpt(o)); + +export const flipCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('flip'), + 'fun.flip.description' +); + +export const rpsCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('rps'), + 'fun.rps.description' +).addStringOption((o) => rpsChoiceOpt(o)); + +export const chooseCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('choose'), + 'fun.choose.description' +).addStringOption((o) => optionsOpt(o)); + +export const triviaCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('trivia'), + 'fun.trivia.description' +); + +export const tictactoeCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('tictactoe'), + 'fun.tictactoe.description' +).addUserOption((o) => opponentOpt(o)); + +export const connect4CommandData = applyCommandDescription( + new SlashCommandBuilder().setName('connect4'), + 'fun.connect4.description' +).addUserOption((o) => opponentOpt(o)); + +export const hangmanCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('hangman'), + 'fun.hangman.description' +); + +export const memeCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('meme'), + 'fun.meme.description' +); + +export const catCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('cat'), + 'fun.cat.description' +); + +export const dogCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('dog'), + 'fun.dog.description' +); + +export type RpsChoice = 'rock' | 'paper' | 'scissors'; + +export type FunCommandLocaleKey = CommandLocaleKey; diff --git a/apps/bot/src/modules/fun/commands.ts b/apps/bot/src/modules/fun/commands.ts new file mode 100644 index 0000000..8f6e062 --- /dev/null +++ b/apps/bot/src/modules/fun/commands.ts @@ -0,0 +1,412 @@ +import { EmbedBuilder, type ChatInputCommandInteraction } from 'discord.js'; +import { EIGHT_BALL_ANSWERS, randomInt, TRIVIA_QUESTIONS, t, tf } from '@nexumi/shared'; +import type { SlashCommand } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import type { RpsChoice } from './command-definitions.js'; +import { + catCommandData, + chooseCommandData, + connect4CommandData, + diceCommandData, + dogCommandData, + eightBallCommandData, + flipCommandData, + hangmanCommandData, + memeCommandData, + rpsCommandData, + tictactoeCommandData, + triviaCommandData +} from './command-definitions.js'; +import { + buildConnect4Components, + buildConnect4Embed, + buildHangmanComponents, + buildHangmanEmbed, + buildTicTacToeComponents, + buildTicTacToeEmbed, + buildTriviaComponents, + buildTriviaEmbed, + createConnect4Board, + createGameId, + pickHangmanWord, + saveConnect4State, + saveHangmanState, + saveTicTacToeState, + saveTriviaSession +} from './games.js'; +import { + assertMediaEnabled, + fetchCatImage, + fetchDogImage, + fetchMeme, + FunMediaError +} from './media.js'; + +const RPS_CHOICES: RpsChoice[] = ['rock', 'paper', 'scissors']; + +function rpsWinner(a: RpsChoice, b: RpsChoice): 'player' | 'bot' | 'draw' { + if (a === b) { + return 'draw'; + } + if ( + (a === 'rock' && b === 'scissors') || + (a === 'paper' && b === 'rock') || + (a === 'scissors' && b === 'paper') + ) { + return 'player'; + } + return 'bot'; +} + +async function ensureGuild( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en' +): Promise { + if (!interaction.inGuild() || !interaction.guildId) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return null; + } + return interaction.guildId; +} + +function parseChooseOptions(raw: string): string[] { + return raw + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +const eightBallCommand: SlashCommand = { + data: eightBallCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + const question = interaction.options.getString('question', true); + const answers = EIGHT_BALL_ANSWERS[locale]; + const answer = answers[randomInt(0, answers.length - 1)]!; + + await interaction.reply({ + content: tf(locale, 'fun.8ball.result', { question, answer }), + ephemeral: false + }); + } +}; + +const diceCommand: SlashCommand = { + data: diceCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + const sides = interaction.options.getInteger('sides') ?? 6; + const count = interaction.options.getInteger('count') ?? 1; + const rolls = Array.from({ length: count }, () => randomInt(1, sides)); + + await interaction.reply({ + content: tf(locale, 'fun.dice.result', { + rolls: rolls.join(', '), + sides: String(sides) + }), + ephemeral: false + }); + } +}; + +const flipCommand: SlashCommand = { + data: flipCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + const result = Math.random() < 0.5 ? 'heads' : 'tails'; + const label = t(locale, `economy.choice.${result}`); + + await interaction.reply({ + content: tf(locale, 'fun.flip.result', { result: label }), + ephemeral: false + }); + } +}; + +const rpsCommand: SlashCommand = { + data: rpsCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + const playerChoice = interaction.options.getString('choice', true) as RpsChoice; + const botChoice = RPS_CHOICES[randomInt(0, RPS_CHOICES.length - 1)]!; + const outcome = rpsWinner(playerChoice, botChoice); + + const key = + outcome === 'draw' + ? 'fun.rps.draw' + : outcome === 'player' + ? 'fun.rps.win' + : 'fun.rps.lose'; + + await interaction.reply({ + content: tf(locale, key, { + player: t(locale, `fun.rps.choice.${playerChoice}`), + bot: t(locale, `fun.rps.choice.${botChoice}`) + }), + ephemeral: false + }); + } +}; + +const chooseCommand: SlashCommand = { + data: chooseCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + const raw = interaction.options.getString('options', true); + const options = parseChooseOptions(raw); + if (options.length < 2) { + await interaction.reply({ content: t(locale, 'fun.choose.tooFew'), ephemeral: true }); + return; + } + + const picked = options[randomInt(0, options.length - 1)]!; + await interaction.reply({ + content: tf(locale, 'fun.choose.result', { choice: picked }), + ephemeral: false + }); + } +}; + +const triviaCommand: SlashCommand = { + data: triviaCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + const questionIndex = randomInt(0, TRIVIA_QUESTIONS.length - 1); + const question = TRIVIA_QUESTIONS[questionIndex]!; + const correctIndex = question.answer; + + await saveTriviaSession(context, { + userId: interaction.user.id, + guildId, + questionIndex, + correctIndex + }); + + await interaction.reply({ + embeds: [buildTriviaEmbed(locale, question, interaction.user.id)], + components: buildTriviaComponents(interaction.user.id, correctIndex, locale, question) + }); + } +}; + +const tictactoeCommand: SlashCommand = { + data: tictactoeCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId || !interaction.channel) return; + + const opponent = interaction.options.getUser('user', true); + if (opponent.id === interaction.user.id) { + await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true }); + return; + } + if (opponent.bot) { + await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true }); + return; + } + + const gameId = createGameId(); + const state = { + gameId, + guildId, + channelId: interaction.channel.id, + playerX: interaction.user.id, + playerO: opponent.id, + board: Array.from({ length: 9 }, () => null) as Array<'X' | 'O' | null>, + current: 'X' as const, + status: 'active' as const + }; + + await saveTicTacToeState(context, state); + + await interaction.reply({ + content: tf(locale, 'fun.tictactoe.challenge', { + challenger: `<@${interaction.user.id}>`, + opponent: `<@${opponent.id}>` + }), + embeds: [buildTicTacToeEmbed(locale, state)], + components: buildTicTacToeComponents(state) + }); + } +}; + +const connect4Command: SlashCommand = { + data: connect4CommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId || !interaction.channel) return; + + const opponent = interaction.options.getUser('user', true); + if (opponent.id === interaction.user.id) { + await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true }); + return; + } + if (opponent.bot) { + await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true }); + return; + } + + const gameId = createGameId(); + const state = { + gameId, + guildId, + channelId: interaction.channel.id, + playerRed: interaction.user.id, + playerYellow: opponent.id, + board: createConnect4Board(), + current: 'R' as const, + status: 'active' as const + }; + + await saveConnect4State(context, state); + + await interaction.reply({ + content: tf(locale, 'fun.connect4.challenge', { + challenger: `<@${interaction.user.id}>`, + opponent: `<@${opponent.id}>` + }), + embeds: [buildConnect4Embed(locale, state)], + components: buildConnect4Components(state) + }); + } +}; + +const hangmanCommand: SlashCommand = { + data: hangmanCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + const gameId = createGameId(); + const state = { + gameId, + guildId, + userId: interaction.user.id, + word: pickHangmanWord(locale), + guessed: [] as string[], + wrongGuesses: 0, + maxWrong: 6, + status: 'active' as const, + letterPage: 0 + }; + + await saveHangmanState(context, state); + + await interaction.reply({ + embeds: [buildHangmanEmbed(locale, state)], + components: buildHangmanComponents(state) + }); + } +}; + +async function handleMediaError( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en', + error: unknown +): Promise { + if (error instanceof FunMediaError) { + const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed'; + await interaction.reply({ content: t(locale, key), ephemeral: true }); + return; + } + throw error; +} + +const memeCommand: SlashCommand = { + data: memeCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + try { + await assertMediaEnabled(context.redis, guildId); + const meme = await fetchMeme(); + const embed = new EmbedBuilder() + .setTitle(meme.title) + .setImage(meme.url) + .setColor(0x6366f1); + await interaction.reply({ embeds: [embed] }); + } catch (error) { + await handleMediaError(interaction, locale, error); + } + } +}; + +const catCommand: SlashCommand = { + data: catCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + try { + await assertMediaEnabled(context.redis, guildId); + const url = await fetchCatImage(); + const embed = new EmbedBuilder() + .setTitle(t(locale, 'fun.cat.title')) + .setImage(url) + .setColor(0x6366f1); + await interaction.reply({ embeds: [embed] }); + } catch (error) { + await handleMediaError(interaction, locale, error); + } + } +}; + +const dogCommand: SlashCommand = { + data: dogCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const guildId = await ensureGuild(interaction, locale); + if (!guildId) return; + + try { + await assertMediaEnabled(context.redis, guildId); + const url = await fetchDogImage(); + const embed = new EmbedBuilder() + .setTitle(t(locale, 'fun.dog.title')) + .setImage(url) + .setColor(0x6366f1); + await interaction.reply({ embeds: [embed] }); + } catch (error) { + await handleMediaError(interaction, locale, error); + } + } +}; + +export const funCommands: SlashCommand[] = [ + eightBallCommand, + diceCommand, + flipCommand, + rpsCommand, + chooseCommand, + triviaCommand, + tictactoeCommand, + connect4Command, + hangmanCommand, + memeCommand, + catCommand, + dogCommand +]; diff --git a/apps/bot/src/modules/fun/config.ts b/apps/bot/src/modules/fun/config.ts new file mode 100644 index 0000000..0b1df6a --- /dev/null +++ b/apps/bot/src/modules/fun/config.ts @@ -0,0 +1,23 @@ +import type { Redis } from 'ioredis'; +import { z } from 'zod'; + +const FUN_CONFIG_PREFIX = 'fun:config:'; + +export const FunConfigSchema = z.object({ + mediaEnabled: z.boolean().default(true) +}); + +export type FunConfig = z.infer; + +export async function getFunConfig(redis: Redis, guildId: string): Promise { + const raw = await redis.get(`${FUN_CONFIG_PREFIX}${guildId}`); + if (!raw) { + return { mediaEnabled: true }; + } + const parsed = FunConfigSchema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : { mediaEnabled: true }; +} + +export async function setFunConfig(redis: Redis, guildId: string, config: FunConfig): Promise { + await redis.set(`${FUN_CONFIG_PREFIX}${guildId}`, JSON.stringify(config)); +} diff --git a/apps/bot/src/modules/fun/games.ts b/apps/bot/src/modules/fun/games.ts new file mode 100644 index 0000000..39bee6d --- /dev/null +++ b/apps/bot/src/modules/fun/games.ts @@ -0,0 +1,574 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type APIEmbed +} from 'discord.js'; +import { HANGMAN_WORDS, randomInt, TRIVIA_QUESTIONS, type Locale, tf } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; + +export const FUN_BUTTON_PREFIX = 'fun:'; +export const TRIVIA_TTL_SECONDS = 120; +export const GAME_TTL_SECONDS = 3600; + +const TRIVIA_KEY_PREFIX = 'fun:trivia:'; +const TTT_KEY_PREFIX = 'fun:ttt:'; +const C4_KEY_PREFIX = 'fun:c4:'; +const HM_KEY_PREFIX = 'fun:hm:'; + +export type TicTacToeMark = 'X' | 'O'; +export type TicTacToeCell = TicTacToeMark | null; + +export type TicTacToeState = { + gameId: string; + guildId: string; + channelId: string; + playerX: string; + playerO: string; + board: TicTacToeCell[]; + current: TicTacToeMark; + status: 'active' | 'draw' | 'won'; + winner?: TicTacToeMark; +}; + +export type Connect4Mark = 'R' | 'Y'; +export type Connect4Cell = Connect4Mark | null; + +export type Connect4State = { + gameId: string; + guildId: string; + channelId: string; + playerRed: string; + playerYellow: string; + board: Connect4Cell[][]; + current: Connect4Mark; + status: 'active' | 'draw' | 'won'; + winner?: Connect4Mark; +}; + +export type HangmanState = { + gameId: string; + guildId: string; + userId: string; + word: string; + guessed: string[]; + wrongGuesses: number; + maxWrong: number; + status: 'active' | 'won' | 'lost'; + letterPage: number; +}; + +export type TriviaSession = { + userId: string; + guildId: string; + questionIndex: number; + correctIndex: number; +}; + +const TTT_LINES = [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6] +] as const; + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + +export function createGameId(): string { + return crypto.randomUUID().replace(/-/g, '').slice(0, 12); +} + +export function checkTicTacToeWinner(board: TicTacToeCell[]): TicTacToeMark | null { + for (const [a, b, c] of TTT_LINES) { + const mark = board[a]; + if (mark && mark === board[b] && mark === board[c]) { + return mark; + } + } + return null; +} + +export function currentTicTacToePlayerId(state: TicTacToeState): string { + return state.current === 'X' ? state.playerX : state.playerO; +} + +export function applyTicTacToeMove(state: TicTacToeState, cell: number): TicTacToeState { + if (state.status !== 'active' || state.board[cell] !== null) { + return state; + } + const board = [...state.board]; + board[cell] = state.current; + const winner = checkTicTacToeWinner(board); + if (winner) { + return { ...state, board, status: 'won', winner }; + } + if (board.every((value) => value !== null)) { + return { ...state, board, status: 'draw' }; + } + return { ...state, board, current: state.current === 'X' ? 'O' : 'X' }; +} + +export function createConnect4Board(): Connect4Cell[][] { + return Array.from({ length: 6 }, () => Array.from({ length: 7 }, () => null)); +} + +function connect4DropRow(board: Connect4Cell[][], col: number): number { + for (let row = board.length - 1; row >= 0; row -= 1) { + if (board[row]![col] === null) { + return row; + } + } + return -1; +} + +function connect4ColumnFull(board: Connect4Cell[][], col: number): boolean { + return board[0]![col] !== null; +} + +export function applyConnect4Move(state: Connect4State, col: number): Connect4State { + if (state.status !== 'active' || col < 0 || col >= 7 || connect4ColumnFull(state.board, col)) { + return state; + } + const row = connect4DropRow(state.board, col); + if (row < 0) { + return state; + } + const board = state.board.map((line) => [...line]); + board[row]![col] = state.current; + const winner = checkConnect4Winner(board); + if (winner) { + return { ...state, board, status: 'won', winner }; + } + if (board.every((line) => line.every((cell) => cell !== null))) { + return { ...state, board, status: 'draw' }; + } + return { ...state, board, current: state.current === 'R' ? 'Y' : 'R' }; +} + +export function checkConnect4Winner(board: Connect4Cell[][]): Connect4Mark | null { + const directions = [ + [0, 1], + [1, 0], + [1, 1], + [1, -1] + ] as const; + + for (let row = 0; row < 6; row += 1) { + for (let col = 0; col < 7; col += 1) { + const start = board[row]![col]; + if (!start) { + continue; + } + for (const [dr, dc] of directions) { + let matches = 1; + for (let step = 1; step < 4; step += 1) { + const next = board[row + dr * step]?.[col + dc * step]; + if (next !== start) { + break; + } + matches += 1; + } + if (matches >= 4) { + return start; + } + } + } + } + return null; +} + +export function currentConnect4PlayerId(state: Connect4State): string { + return state.current === 'R' ? state.playerRed : state.playerYellow; +} + +export function pickHangmanWord(locale: Locale): string { + const words = HANGMAN_WORDS[locale]; + return words[randomInt(0, words.length - 1)]!; +} + +export function renderHangmanWord(word: string, guessed: string[]): string { + return word + .split('') + .map((letter) => (guessed.includes(letter) ? letter : '⬜')) + .join(' '); +} + +export function applyHangmanGuess(state: HangmanState, letter: string): HangmanState { + const normalized = letter.toUpperCase(); + if ( + state.status !== 'active' || + normalized.length !== 1 || + !/^[A-Z]$/.test(normalized) || + state.guessed.includes(normalized) + ) { + return state; + } + + const guessed = [...state.guessed, normalized]; + const inWord = state.word.includes(normalized); + const wrongGuesses = inWord ? state.wrongGuesses : state.wrongGuesses + 1; + const revealed = state.word.split('').every((char) => guessed.includes(char)); + if (revealed) { + return { ...state, guessed, wrongGuesses, status: 'won' }; + } + if (wrongGuesses >= state.maxWrong) { + return { ...state, guessed, wrongGuesses, status: 'lost' }; + } + return { ...state, guessed, wrongGuesses }; +} + +async function saveJson(redis: BotContext['redis'], key: string, value: unknown, ttl: number): Promise { + await redis.set(key, JSON.stringify(value), 'EX', ttl); +} + +async function loadJson(redis: BotContext['redis'], key: string): Promise { + const raw = await redis.get(key); + if (!raw) { + return null; + } + return JSON.parse(raw) as T; +} + +export async function saveTriviaSession(context: BotContext, session: TriviaSession): Promise { + await saveJson(context.redis, `${TRIVIA_KEY_PREFIX}${session.userId}`, session, TRIVIA_TTL_SECONDS); +} + +export async function loadTriviaSession(context: BotContext, userId: string): Promise { + return loadJson(context.redis, `${TRIVIA_KEY_PREFIX}${userId}`); +} + +export async function deleteTriviaSession(context: BotContext, userId: string): Promise { + await context.redis.del(`${TRIVIA_KEY_PREFIX}${userId}`); +} + +export async function saveTicTacToeState(context: BotContext, state: TicTacToeState): Promise { + await saveJson(context.redis, `${TTT_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS); +} + +export async function loadTicTacToeState(context: BotContext, gameId: string): Promise { + return loadJson(context.redis, `${TTT_KEY_PREFIX}${gameId}`); +} + +export async function deleteTicTacToeState(context: BotContext, gameId: string): Promise { + await context.redis.del(`${TTT_KEY_PREFIX}${gameId}`); +} + +export async function saveConnect4State(context: BotContext, state: Connect4State): Promise { + await saveJson(context.redis, `${C4_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS); +} + +export async function loadConnect4State(context: BotContext, gameId: string): Promise { + return loadJson(context.redis, `${C4_KEY_PREFIX}${gameId}`); +} + +export async function deleteConnect4State(context: BotContext, gameId: string): Promise { + await context.redis.del(`${C4_KEY_PREFIX}${gameId}`); +} + +export async function saveHangmanState(context: BotContext, state: HangmanState): Promise { + await saveJson(context.redis, `${HM_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS); +} + +export async function loadHangmanState(context: BotContext, gameId: string): Promise { + return loadJson(context.redis, `${HM_KEY_PREFIX}${gameId}`); +} + +export async function deleteHangmanState(context: BotContext, gameId: string): Promise { + await context.redis.del(`${HM_KEY_PREFIX}${gameId}`); +} + +function tttCellLabel(cell: TicTacToeCell): string { + if (cell === 'X') return '❌'; + if (cell === 'O') return '⭕'; + return '⬜'; +} + +export function buildTicTacToeEmbed(locale: Locale, state: TicTacToeState): APIEmbed { + const boardLines = [0, 1, 2] + .map((row) => state.board.slice(row * 3, row * 3 + 3).map(tttCellLabel).join(' ')) + .join('\n'); + + let description = tf(locale, 'fun.tictactoe.board', { + x: `<@${state.playerX}>`, + o: `<@${state.playerO}>`, + board: boardLines + }); + + if (state.status === 'won' && state.winner) { + const winnerId = state.winner === 'X' ? state.playerX : state.playerO; + description += `\n\n${tf(locale, 'fun.tictactoe.won', { user: `<@${winnerId}>` })}`; + } else if (state.status === 'draw') { + description += `\n\n${tf(locale, 'fun.tictactoe.draw', {})}`; + } else { + description += `\n\n${tf(locale, 'fun.tictactoe.turn', { + user: `<@${currentTicTacToePlayerId(state)}>` + })}`; + } + + return { title: tf(locale, 'fun.tictactoe.title', {}), description, color: 0x6366f1 }; +} + +export function buildTicTacToeComponents(state: TicTacToeState): ActionRowBuilder[] { + if (state.status !== 'active') { + return []; + } + const rows: ActionRowBuilder[] = []; + for (let row = 0; row < 3; row += 1) { + const builder = new ActionRowBuilder(); + for (let col = 0; col < 3; col += 1) { + const cell = row * 3 + col; + const mark = state.board[cell]; + builder.addComponents( + new ButtonBuilder() + .setCustomId(`${FUN_BUTTON_PREFIX}ttt:${state.gameId}:${cell}`) + .setLabel(mark ?? String(cell + 1)) + .setStyle(mark ? ButtonStyle.Secondary : ButtonStyle.Primary) + .setDisabled(mark !== null) + ); + } + rows.push(builder); + } + return rows; +} + +function c4CellLabel(cell: Connect4Cell): string { + if (cell === 'R') return '🔮'; + if (cell === 'Y') return '🟡'; + return 'âšȘ'; +} + +export function buildConnect4Embed(locale: Locale, state: Connect4State): APIEmbed { + const boardText = [...state.board] + .reverse() + .map((row) => row.map(c4CellLabel).join('')) + .join('\n'); + + let description = tf(locale, 'fun.connect4.board', { + red: `<@${state.playerRed}>`, + yellow: `<@${state.playerYellow}>`, + board: boardText + }); + + if (state.status === 'won' && state.winner) { + const winnerId = state.winner === 'R' ? state.playerRed : state.playerYellow; + description += `\n\n${tf(locale, 'fun.connect4.won', { user: `<@${winnerId}>` })}`; + } else if (state.status === 'draw') { + description += `\n\n${tf(locale, 'fun.connect4.draw', {})}`; + } else { + description += `\n\n${tf(locale, 'fun.connect4.turn', { + user: `<@${currentConnect4PlayerId(state)}>` + })}`; + } + + return { title: tf(locale, 'fun.connect4.title', {}), description, color: 0x6366f1 }; +} + +export function buildConnect4Components(state: Connect4State): ActionRowBuilder[] { + if (state.status !== 'active') { + return []; + } + const row = new ActionRowBuilder(); + for (let col = 0; col < 7; col += 1) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`${FUN_BUTTON_PREFIX}c4:${state.gameId}:${col}`) + .setLabel(String(col + 1)) + .setStyle(ButtonStyle.Primary) + .setDisabled(connect4ColumnFull(state.board, col)) + ); + } + return [row]; +} + +export function buildHangmanEmbed(locale: Locale, state: HangmanState): APIEmbed { + let description = tf(locale, 'fun.hangman.progress', { + word: renderHangmanWord(state.word, state.guessed), + wrong: state.wrongGuesses, + max: state.maxWrong, + guessed: state.guessed.length > 0 ? state.guessed.join(', ') : '—' + }); + + if (state.status === 'won') { + description += `\n\n${tf(locale, 'fun.hangman.won', { word: state.word })}`; + } else if (state.status === 'lost') { + description += `\n\n${tf(locale, 'fun.hangman.lost', { word: state.word })}`; + } + + return { title: tf(locale, 'fun.hangman.title', {}), description, color: 0x6366f1 }; +} + +function hangmanLettersForPage(state: HangmanState): { letters: string[]; page: number; totalPages: number } { + const remaining = ALPHABET.filter((letter) => !state.guessed.includes(letter)); + const lettersPerPage = 24; + const totalPages = Math.max(1, Math.ceil(remaining.length / lettersPerPage)); + const page = Math.min(state.letterPage, totalPages - 1); + const start = page * lettersPerPage; + return { + letters: remaining.slice(start, start + lettersPerPage), + page, + totalPages + }; +} + +export function buildHangmanComponents(state: HangmanState): ActionRowBuilder[] { + if (state.status !== 'active') { + return []; + } + + const { letters, page, totalPages } = hangmanLettersForPage(state); + const rows: ActionRowBuilder[] = []; + + for (let i = 0; i < letters.length; i += 5) { + const chunk = letters.slice(i, i + 5); + const row = new ActionRowBuilder(); + for (const letter of chunk) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:${letter}`) + .setLabel(letter) + .setStyle(ButtonStyle.Primary) + ); + } + rows.push(row); + if (rows.length >= 4) { + break; + } + } + + const navRow = new ActionRowBuilder(); + if (totalPages > 1) { + navRow.addComponents( + new ButtonBuilder() + .setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.max(0, page - 1)}`) + .setLabel('◀') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page <= 0), + new ButtonBuilder() + .setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.min(totalPages - 1, page + 1)}`) + .setLabel('▶') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page >= totalPages - 1) + ); + } + if (navRow.components.length > 0) { + rows.push(navRow); + } + + return rows.slice(0, 5); +} + +export function pickTriviaQuestion(): (typeof TRIVIA_QUESTIONS)[number] { + return TRIVIA_QUESTIONS[randomInt(0, TRIVIA_QUESTIONS.length - 1)]!; +} + +export function buildTriviaEmbed( + locale: Locale, + question: (typeof TRIVIA_QUESTIONS)[number], + userId: string +): APIEmbed { + const options = question.options[locale] + .map((option, index) => `${String.fromCharCode(65 + index)}. ${option}`) + .join('\n'); + + return { + title: tf(locale, 'fun.trivia.title', {}), + description: tf(locale, 'fun.trivia.question', { + question: question.q[locale], + options + }), + footer: { text: tf(locale, 'fun.trivia.footer', { user: `<@${userId}>` }) }, + color: 0x6366f1 + }; +} + +export function buildTriviaComponents( + userId: string, + correctIndex: number, + locale: Locale, + question: (typeof TRIVIA_QUESTIONS)[number] +): ActionRowBuilder[] { + const labels = ['A', 'B', 'C', 'D']; + const row = new ActionRowBuilder(); + for (let index = 0; index < question.options[locale].length; index += 1) { + row.addComponents( + new ButtonBuilder() + .setCustomId(`${FUN_BUTTON_PREFIX}trivia:${userId}:${index}:${correctIndex}`) + .setLabel(`${labels[index]!}. ${question.options[locale][index]!.slice(0, 20)}`) + .setStyle(ButtonStyle.Primary) + ); + } + return [row]; +} + +export function parseTriviaButton(customId: string): { + userId: string; + answerIndex: number; + correctIndex: number; +} | null { + const parts = customId.split(':'); + if (parts.length !== 5 || parts[0] !== 'fun' || parts[1] !== 'trivia') { + return null; + } + const userId = parts[2]; + const answerIndex = Number(parts[3]); + const correctIndex = Number(parts[4]); + if (!userId || Number.isNaN(answerIndex) || Number.isNaN(correctIndex)) { + return null; + } + return { userId, answerIndex, correctIndex }; +} + +export function parseTicTacToeButton(customId: string): { gameId: string; cell: number } | null { + const parts = customId.split(':'); + if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'ttt') { + return null; + } + const gameId = parts[2]; + const cell = Number(parts[3]); + if (!gameId || Number.isNaN(cell) || cell < 0 || cell > 8) { + return null; + } + return { gameId, cell }; +} + +export function parseConnect4Button(customId: string): { gameId: string; col: number } | null { + const parts = customId.split(':'); + if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'c4') { + return null; + } + const gameId = parts[2]; + const col = Number(parts[3]); + if (!gameId || Number.isNaN(col) || col < 0 || col > 6) { + return null; + } + return { gameId, col }; +} + +export function parseHangmanButton( + customId: string +): { gameId: string; letter?: string; page?: number } | null { + const parts = customId.split(':'); + if (parts.length < 4 || parts[0] !== 'fun' || parts[1] !== 'hm') { + return null; + } + const gameId = parts[2]; + if (!gameId) { + return null; + } + if (parts[3] === 'page') { + const page = Number(parts[4]); + if (Number.isNaN(page) || page < 0) { + return null; + } + return { gameId, page }; + } + const letter = parts[3]?.toUpperCase(); + if (!letter || !/^[A-Z]$/.test(letter)) { + return null; + } + return { gameId, letter }; +} diff --git a/apps/bot/src/modules/fun/index.ts b/apps/bot/src/modules/fun/index.ts new file mode 100644 index 0000000..3a6ba82 --- /dev/null +++ b/apps/bot/src/modules/fun/index.ts @@ -0,0 +1,2 @@ +export { funCommands } from './commands.js'; +export { isFunButton, handleFunButton } from './buttons.js'; diff --git a/apps/bot/src/modules/fun/media.ts b/apps/bot/src/modules/fun/media.ts new file mode 100644 index 0000000..115ae64 --- /dev/null +++ b/apps/bot/src/modules/fun/media.ts @@ -0,0 +1,65 @@ +import type { Redis } from 'ioredis'; +import { getFunConfig } from './config.js'; + +export class FunMediaError extends Error { + constructor(public readonly code: 'disabled' | 'fetch_failed') { + super(code); + this.name = 'FunMediaError'; + } +} + +type MemeApiResponse = { + url?: string; + title?: string; +}; + +type CatApiResponse = Array<{ url?: string }>; + +type DogApiResponse = { + message?: string; + status?: string; +}; + +export async function assertMediaEnabled(redis: Redis, guildId: string): Promise { + const config = await getFunConfig(redis, guildId); + if (!config.mediaEnabled) { + throw new FunMediaError('disabled'); + } +} + +export async function fetchMeme(): Promise<{ url: string; title: string }> { + const response = await fetch('https://meme-api.com/gimme'); + if (!response.ok) { + throw new FunMediaError('fetch_failed'); + } + const data = (await response.json()) as MemeApiResponse; + if (!data.url) { + throw new FunMediaError('fetch_failed'); + } + return { url: data.url, title: data.title ?? 'Meme' }; +} + +export async function fetchCatImage(): Promise { + const response = await fetch('https://api.thecatapi.com/v1/images/search'); + if (!response.ok) { + throw new FunMediaError('fetch_failed'); + } + const data = (await response.json()) as CatApiResponse; + const url = data[0]?.url; + if (!url) { + throw new FunMediaError('fetch_failed'); + } + return url; +} + +export async function fetchDogImage(): Promise { + const response = await fetch('https://dog.ceo/api/breeds/image/random'); + if (!response.ok) { + throw new FunMediaError('fetch_failed'); + } + const data = (await response.json()) as DogApiResponse; + if (data.status !== 'success' || !data.message) { + throw new FunMediaError('fetch_failed'); + } + return data.message; +} diff --git a/apps/bot/src/modules/leveling/command-definitions.ts b/apps/bot/src/modules/leveling/command-definitions.ts new file mode 100644 index 0000000..60ca860 --- /dev/null +++ b/apps/bot/src/modules/leveling/command-definitions.ts @@ -0,0 +1,49 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; + +export const rankCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('rank'), + 'leveling.rank.description' +).addUserOption((option) => + applyOptionDescription(option.setName('user'), 'leveling.rank.options.user') +); + +export const leaderboardCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('leaderboard'), + 'leveling.leaderboard.description' +); + +export const xpCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('xp'), + 'leveling.xp.description' +) + .addSubcommand((sub) => + applyCommandDescription(sub.setName('give'), 'leveling.xp.give.description') + .addUserOption((option) => + applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user') + ) + .addIntegerOption((option) => + applyOptionDescription( + option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000), + 'leveling.xp.options.amount' + ) + ) + ) + .addSubcommand((sub) => + applyCommandDescription(sub.setName('remove'), 'leveling.xp.remove.description') + .addUserOption((option) => + applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user') + ) + .addIntegerOption((option) => + applyOptionDescription( + option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000), + 'leveling.xp.options.amount' + ) + ) + ) + .addSubcommand((sub) => + applyCommandDescription(sub.setName('reset'), 'leveling.xp.reset.description').addUserOption( + (option) => + applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user') + ) + ); diff --git a/apps/bot/src/modules/leveling/commands.ts b/apps/bot/src/modules/leveling/commands.ts new file mode 100644 index 0000000..51e9017 --- /dev/null +++ b/apps/bot/src/modules/leveling/commands.ts @@ -0,0 +1,160 @@ +import { AttachmentBuilder, EmbedBuilder, 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 { + addXp, + formatLeaderboardLine, + getConfig, + getLeaderboard, + getOrCreateMemberLevel, + getRankPosition, + memberProgress, + removeXp, + resetXp +} from './service.js'; +import { renderRankCard } from './rank-card.js'; +import { + leaderboardCommandData, + rankCommandData, + xpCommandData +} from './command-definitions.js'; + +const rankCommand: SlashCommand = { + data: rankCommandData, + 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 targetUser = interaction.options.getUser('user') ?? interaction.user; + const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + if (!member) { + await interaction.reply({ content: t(locale, 'leveling.user.notFound'), ephemeral: true }); + return; + } + + const config = await getConfig(context.prisma, interaction.guild.id); + const memberLevel = await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id); + const rank = await getRankPosition(context.prisma, interaction.guild.id, member.id); + const progress = memberProgress(memberLevel); + + const buffer = await renderRankCard({ + displayName: member.displayName, + avatarUrl: member.user.displayAvatarURL({ extension: 'png', size: 256 }), + level: progress.level, + rank, + intoLevel: progress.intoLevel, + needed: progress.needed, + totalXp: memberLevel.xp, + accentColor: config.cardAccentColor, + backgroundColor: config.cardBackgroundColor + }); + + const attachment = new AttachmentBuilder(buffer, { name: 'rank.png' }); + await interaction.reply({ files: [attachment] }); + } +}; + +const leaderboardCommand: SlashCommand = { + data: leaderboardCommandData, + 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 entries = await getLeaderboard(context.prisma, interaction.guild.id, 10); + if (entries.length === 0) { + await interaction.reply({ content: t(locale, 'leveling.leaderboard.empty'), ephemeral: true }); + return; + } + + const lines = await Promise.all( + entries.map(async (entry, index) => { + const user = await context.client.users.fetch(entry.userId).catch(() => null); + const label = user ? `<@${entry.userId}>` : entry.userId; + return formatLeaderboardLine(locale, index + 1, label, entry.level, entry.xp); + }) + ); + + const embed = new EmbedBuilder() + .setTitle(t(locale, 'leveling.leaderboard.title')) + .setDescription(lines.join('\n')) + .setFooter({ text: tf(locale, 'leveling.leaderboard.footer', { count: entries.length }) }) + .setColor(0x6366f1); + + await interaction.reply({ embeds: [embed] }); + } +}; + +const xpCommand: SlashCommand = { + data: xpCommandData, + 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; + } + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { + return; + } + + const sub = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user', true); + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (!member) { + await interaction.reply({ content: t(locale, 'leveling.user.notFound'), ephemeral: true }); + return; + } + + const channelId = interaction.channelId ?? undefined; + + if (sub === 'give') { + const amount = interaction.options.getInteger('amount', true); + const result = await addXp(context, interaction.guild, member, amount, { + channelId, + force: true + }); + const level = result?.newLevel ?? (await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id)).level; + await interaction.reply({ + content: tf(locale, 'leveling.xp.give.success', { + amount, + user: `<@${member.id}>`, + level + }), + ephemeral: true + }); + return; + } + + if (sub === 'remove') { + const amount = interaction.options.getInteger('amount', true); + const result = await removeXp(context, interaction.guild, member, amount, channelId); + const level = + result?.newLevel ?? + (await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id)).level; + await interaction.reply({ + content: tf(locale, 'leveling.xp.remove.success', { + amount, + user: `<@${member.id}>`, + level + }), + ephemeral: true + }); + return; + } + + await resetXp(context, interaction.guild, member, channelId); + await interaction.reply({ + content: tf(locale, 'leveling.xp.reset.success', { user: `<@${member.id}>` }), + ephemeral: true + }); + } +}; + +export const levelingCommands: SlashCommand[] = [rankCommand, leaderboardCommand, xpCommand]; diff --git a/apps/bot/src/modules/leveling/events.ts b/apps/bot/src/modules/leveling/events.ts new file mode 100644 index 0000000..7bdee3f --- /dev/null +++ b/apps/bot/src/modules/leveling/events.ts @@ -0,0 +1,69 @@ +import type { BotContext } from '../../types.js'; +import { logger } from '../../logger.js'; +import { + awardVoiceXpForSession, + handleTextXpMessage, + voiceKey +} from './service.js'; + +async function handleVoiceJoin(context: BotContext, guildId: string, userId: string): Promise { + await context.redis.set(voiceKey(guildId, userId), String(Date.now())); +} + +async function handleVoiceLeave( + context: BotContext, + guildId: string, + userId: string, + channelId: string +): Promise { + const key = voiceKey(guildId, userId); + const joinedAt = await context.redis.get(key); + await context.redis.del(key); + if (!joinedAt) { + return; + } + + 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; + } + + await awardVoiceXpForSession(context, guild, member, Number(joinedAt), channelId); +} + +export function registerLevelingEvents(context: BotContext): void { + context.client.on('messageCreate', async (message) => { + try { + if (!message.guild || message.author.bot || !message.member) { + return; + } + await handleTextXpMessage(context, message.member, message.channel.id); + } catch (error) { + logger.error({ error, messageId: message.id }, 'Leveling message handler failed'); + } + }); + + context.client.on('voiceStateUpdate', async (oldState, newState) => { + try { + const guildId = newState.guild.id; + const userId = newState.id; + const oldChannelId = oldState.channelId; + const newChannelId = newState.channelId; + + if (oldChannelId && oldChannelId !== newChannelId) { + await handleVoiceLeave(context, guildId, userId, oldChannelId); + } + + if (newChannelId && newChannelId !== oldChannelId) { + await handleVoiceJoin(context, guildId, userId); + } + } catch (error) { + logger.error({ error, userId: newState.id }, 'Leveling voice handler failed'); + } + }); +} diff --git a/apps/bot/src/modules/leveling/rank-card.ts b/apps/bot/src/modules/leveling/rank-card.ts new file mode 100644 index 0000000..09e08a8 --- /dev/null +++ b/apps/bot/src/modules/leveling/rank-card.ts @@ -0,0 +1,84 @@ +import { createCanvas, loadImage } from '@napi-rs/canvas'; + +export type RankCardInput = { + displayName: string; + avatarUrl: string; + level: number; + rank: number; + intoLevel: number; + needed: number; + totalXp: number; + accentColor: string; + backgroundColor: string; +}; + +function parseHexColor(color: string, fallback: string): string { + return /^#[0-9A-Fa-f]{6}$/.test(color) ? color : fallback; +} + +export async function renderRankCard(input: RankCardInput): Promise { + const width = 934; + const height = 282; + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + const background = parseHexColor(input.backgroundColor, '#111827'); + const accent = parseHexColor(input.accentColor, '#6366F1'); + + ctx.fillStyle = background; + ctx.fillRect(0, 0, width, height); + + ctx.fillStyle = accent; + ctx.fillRect(0, 0, width, 6); + + const avatarSize = 128; + const avatarX = 36; + const avatarY = 77; + const avatar = await loadImage(input.avatarUrl); + + ctx.save(); + ctx.beginPath(); + ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize); + ctx.restore(); + + ctx.strokeStyle = accent; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 + 2, 0, Math.PI * 2); + ctx.stroke(); + + const textX = 200; + ctx.fillStyle = '#F9FAFB'; + ctx.font = 'bold 32px sans-serif'; + const name = input.displayName.slice(0, 28); + ctx.fillText(name, textX, 110); + + ctx.fillStyle = '#D1D5DB'; + ctx.font = '22px sans-serif'; + ctx.fillText(`Rank #${input.rank}`, textX, 145); + ctx.fillText(`Level ${input.level}`, textX, 175); + ctx.fillText(`${input.totalXp.toLocaleString()} XP`, textX, 205); + + const barX = 520; + const barY = 120; + const barWidth = 370; + const barHeight = 28; + const progress = input.needed > 0 ? Math.min(1, input.intoLevel / input.needed) : 0; + + ctx.fillStyle = '#1F2937'; + ctx.fillRect(barX, barY, barWidth, barHeight); + + if (progress > 0) { + ctx.fillStyle = accent; + ctx.fillRect(barX, barY, Math.max(1, barWidth * progress), barHeight); + } + + ctx.fillStyle = '#E5E7EB'; + ctx.font = '18px sans-serif'; + ctx.fillText(`${input.intoLevel.toLocaleString()} / ${input.needed.toLocaleString()} XP`, barX, barY + 52); + + return canvas.toBuffer('image/png'); +} diff --git a/apps/bot/src/modules/leveling/service.ts b/apps/bot/src/modules/leveling/service.ts new file mode 100644 index 0000000..960e52a --- /dev/null +++ b/apps/bot/src/modules/leveling/service.ts @@ -0,0 +1,415 @@ +import type { LevelingConfig, MemberLevel } from '@prisma/client'; +import { + levelFromXp, + parseMultiplierMap, + progressInLevel, + randomInt, + t, + tf, + type LevelUpMode +} from '@nexumi/shared'; +import { + PermissionFlagsBits, + type Guild, + type GuildMember, + type SendableChannels +} from 'discord.js'; +import { ensureGuild } from '../../guild.js'; +import { logger } from '../../logger.js'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; + +const COOLDOWN_PREFIX = 'leveling:cd:'; +const VOICE_PREFIX = 'leveling:voice:'; + +export function cooldownKey(guildId: string, userId: string): string { + return `${COOLDOWN_PREFIX}${guildId}:${userId}`; +} + +export function voiceKey(guildId: string, userId: string): string { + return `${VOICE_PREFIX}${guildId}:${userId}`; +} + +export async function getConfig( + prisma: BotContext['prisma'], + guildId: string +): Promise { + await ensureGuild(prisma, guildId); + return prisma.levelingConfig.upsert({ + where: { guildId }, + update: {}, + create: { guildId } + }); +} + +export async function getOrCreateMemberLevel( + prisma: BotContext['prisma'], + guildId: string, + userId: string +): Promise { + await ensureGuild(prisma, guildId); + return prisma.memberLevel.upsert({ + where: { guildId_userId: { guildId, userId } }, + update: {}, + create: { guildId, userId, xp: 0, level: 0 } + }); +} + +function resolveRoleMultiplier(config: LevelingConfig, member: GuildMember): number { + const multipliers = parseMultiplierMap(config.roleMultipliers); + let highest = 1; + for (const role of member.roles.cache.values()) { + const value = multipliers[role.id]; + if (value !== undefined && value > highest) { + highest = value; + } + } + return highest; +} + +function resolveChannelMultiplier(config: LevelingConfig, channelId: string): number { + const multipliers = parseMultiplierMap(config.channelMultipliers); + return multipliers[channelId] ?? 1; +} + +export function calculateTextXp( + config: LevelingConfig, + member: GuildMember, + channelId: string +): number { + const base = randomInt(config.textXpMin, config.textXpMax); + const roleMultiplier = resolveRoleMultiplier(config, member); + const channelMultiplier = resolveChannelMultiplier(config, channelId); + return Math.max(1, Math.round(base * roleMultiplier * channelMultiplier)); +} + +export function calculateVoiceXp(config: LevelingConfig, minutes: number): number { + if (minutes <= 0) { + return 0; + } + return config.voiceXpPerMinute * minutes; +} + +export function isNoXpMember(config: LevelingConfig, member: GuildMember): boolean { + return config.noXpRoleIds.some((roleId) => member.roles.cache.has(roleId)); +} + +export function isNoXpChannel(config: LevelingConfig, channelId: string): boolean { + return config.noXpChannelIds.includes(channelId); +} + +export type XpChangeResult = { + memberLevel: MemberLevel; + previousLevel: number; + newLevel: number; + xpDelta: number; + leveledUp: boolean; +}; + +async function persistMemberXp( + prisma: BotContext['prisma'], + guildId: string, + userId: string, + xp: number +): Promise { + const level = levelFromXp(xp); + return prisma.memberLevel.upsert({ + where: { guildId_userId: { guildId, userId } }, + update: { xp, level }, + create: { guildId, userId, xp, level } + }); +} + +export async function addXp( + context: BotContext, + guild: Guild, + member: GuildMember, + amount: number, + options?: { channelId?: string; skipNotifications?: boolean; force?: boolean } +): Promise { + if (amount <= 0) { + return null; + } + + const config = await getConfig(context.prisma, guild.id); + if (!config.enabled && !options?.force) { + return null; + } + + const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id); + const previousLevel = existing.level; + const newXp = existing.xp + amount; + const memberLevel = await persistMemberXp(context.prisma, guild.id, member.id, newXp); + const leveledUp = memberLevel.level > previousLevel; + + if (leveledUp && !options?.skipNotifications) { + await applyLevelRewards(context, guild, member, previousLevel, memberLevel.level, config); + await sendLevelUpNotification(context, guild, member, memberLevel.level, config, options?.channelId); + } + + return { + memberLevel, + previousLevel, + newLevel: memberLevel.level, + xpDelta: amount, + leveledUp + }; +} + +export async function setXp( + context: BotContext, + guild: Guild, + member: GuildMember, + xp: number, + channelId?: string +): Promise { + const config = await getConfig(context.prisma, guild.id); + const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id); + const previousLevel = existing.level; + const clampedXp = Math.max(0, xp); + const memberLevel = await persistMemberXp(context.prisma, guild.id, member.id, clampedXp); + const leveledUp = memberLevel.level > previousLevel; + + if (leveledUp) { + await applyLevelRewards(context, guild, member, previousLevel, memberLevel.level, config); + await sendLevelUpNotification(context, guild, member, memberLevel.level, config, channelId); + } + + return { + memberLevel, + previousLevel, + newLevel: memberLevel.level, + xpDelta: clampedXp - existing.xp, + leveledUp + }; +} + +export async function removeXp( + context: BotContext, + guild: Guild, + member: GuildMember, + amount: number, + channelId?: string +): Promise { + if (amount <= 0) { + return null; + } + const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id); + return setXp(context, guild, member, Math.max(0, existing.xp - amount), channelId); +} + +export async function resetXp( + context: BotContext, + guild: Guild, + member: GuildMember, + channelId?: string +): Promise { + return setXp(context, guild, member, 0, channelId); +} + +export async function getLeaderboard( + prisma: BotContext['prisma'], + guildId: string, + limit = 10 +): Promise { + return prisma.memberLevel.findMany({ + where: { guildId }, + orderBy: [{ xp: 'desc' }, { updatedAt: 'asc' }], + take: limit + }); +} + +export async function getRankPosition( + prisma: BotContext['prisma'], + guildId: string, + userId: string +): Promise { + const member = await getOrCreateMemberLevel(prisma, guildId, userId); + const higher = await prisma.memberLevel.count({ + where: { guildId, xp: { gt: member.xp } } + }); + return higher + 1; +} + +export async function applyLevelRewards( + context: BotContext, + guild: Guild, + member: GuildMember, + previousLevel: number, + newLevel: number, + config?: LevelingConfig +): Promise { + if (newLevel <= previousLevel) { + return; + } + + const levelingConfig = config ?? (await getConfig(context.prisma, guild.id)); + const me = guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return; + } + + const rewards = await context.prisma.levelReward.findMany({ + where: { guildId: guild.id }, + orderBy: { level: 'asc' } + }); + if (rewards.length === 0) { + return; + } + + const rewardRoleIds = new Set(rewards.map((reward) => reward.roleId)); + const assignable = (roleId: string) => { + const role = guild.roles.cache.get(roleId); + return role && role.id !== guild.id && role.position < me.roles.highest.position; + }; + + const rolesToAdd: string[] = []; + + if (levelingConfig.stackRewards) { + for (let level = previousLevel + 1; level <= newLevel; level += 1) { + const reward = rewards.find((entry) => entry.level === level); + if (reward && assignable(reward.roleId)) { + rolesToAdd.push(reward.roleId); + } + } + } else { + const reward = rewards.find((entry) => entry.level === newLevel); + const removable = rewards + .map((entry) => entry.roleId) + .filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId)); + + if (removable.length > 0) { + await member.roles.remove(removable).catch((error) => { + logger.warn({ error, memberId: member.id }, 'Failed to remove level reward roles'); + }); + } + + if (reward && assignable(reward.roleId)) { + rolesToAdd.push(reward.roleId); + } + } + + if (rolesToAdd.length > 0) { + await member.roles.add([...new Set(rolesToAdd)]).catch((error) => { + logger.warn({ error, memberId: member.id }, 'Failed to assign level reward roles'); + }); + } +} + +function renderLevelUpMessage( + template: string, + member: GuildMember, + level: number +): string { + return template + .replaceAll('{user}', `<@${member.id}>`) + .replaceAll('{username}', member.displayName) + .replaceAll('{level}', String(level)); +} + +async function sendLevelUpNotification( + context: BotContext, + guild: Guild, + member: GuildMember, + level: number, + config: LevelingConfig, + fallbackChannelId?: string +): Promise { + const mode = config.levelUpMode as LevelUpMode; + if (mode === 'OFF') { + return; + } + + const locale = await getGuildLocale(context.prisma, guild.id); + const template = config.levelUpMessage ?? t(locale, 'leveling.levelUp.default'); + const content = renderLevelUpMessage(template, member, level); + + if (mode === 'DM') { + await member.send({ content }).catch(() => undefined); + return; + } + + const channelId = config.levelUpChannelId ?? fallbackChannelId; + if (!channelId) { + return; + } + + const channel = await guild.channels.fetch(channelId).catch(() => null); + if (!channel?.isTextBased() || channel.isDMBased()) { + return; + } + + await (channel as SendableChannels).send({ content }).catch((error) => { + logger.warn({ error, channelId }, 'Failed to send level-up message'); + }); +} + +export async function awardVoiceXpForSession( + context: BotContext, + guild: Guild, + member: GuildMember, + joinedAtMs: number, + channelId: string +): Promise { + const config = await getConfig(context.prisma, guild.id); + if (!config.enabled || isNoXpMember(config, member) || isNoXpChannel(config, channelId)) { + return; + } + + const minutes = Math.floor((Date.now() - joinedAtMs) / 60_000); + const baseXp = calculateVoiceXp(config, minutes); + if (baseXp <= 0) { + return; + } + + const roleMultiplier = resolveRoleMultiplier(config, member); + const channelMultiplier = resolveChannelMultiplier(config, channelId); + const amount = Math.max(1, Math.round(baseXp * roleMultiplier * channelMultiplier)); + await addXp(context, guild, member, amount, { channelId }); +} + +export async function handleTextXpMessage( + context: BotContext, + member: GuildMember, + channelId: string +): Promise { + if (member.user.bot) { + return; + } + + const config = await getConfig(context.prisma, member.guild.id); + if (!config.enabled || isNoXpMember(config, member) || isNoXpChannel(config, channelId)) { + return; + } + + const key = cooldownKey(member.guild.id, member.id); + const onCooldown = await context.redis.get(key); + if (onCooldown) { + return; + } + + const amount = calculateTextXp(config, member, channelId); + const result = await addXp(context, member.guild, member, amount, { channelId }); + if (result) { + await context.redis.set(key, '1', 'EX', config.textCooldownSeconds); + } +} + +export function memberProgress(member: MemberLevel) { + return progressInLevel(member.xp); +} + +export function formatLeaderboardLine( + locale: 'de' | 'en', + rank: number, + userLabel: string, + level: number, + xp: number +): string { + return tf(locale, 'leveling.leaderboard.entry', { + rank, + user: userLabel, + level, + xp + }); +} diff --git a/apps/bot/src/modules/utility/afk.ts b/apps/bot/src/modules/utility/afk.ts new file mode 100644 index 0000000..87096e6 --- /dev/null +++ b/apps/bot/src/modules/utility/afk.ts @@ -0,0 +1,89 @@ +import { type Message } from 'discord.js'; +import { tf } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { ensureGuild } from '../../guild.js'; +import { getGuildLocale } from '../../i18n.js'; + +export async function setAfk( + context: BotContext, + guildId: string, + userId: string, + reason: string | null +): Promise { + await ensureGuild(context.prisma, guildId); + await context.prisma.afkStatus.upsert({ + where: { guildId_userId: { guildId, userId } }, + update: { reason }, + create: { guildId, userId, reason } + }); +} + +export async function clearAfk( + context: BotContext, + guildId: string, + userId: string +): Promise<{ reason: string | null } | null> { + const status = await context.prisma.afkStatus.findUnique({ + where: { guildId_userId: { guildId, userId } } + }); + if (!status) { + return null; + } + await context.prisma.afkStatus.delete({ + where: { guildId_userId: { guildId, userId } } + }); + return { reason: status.reason }; +} + +export async function getAfkStatus( + context: BotContext, + guildId: string, + userId: string +): Promise<{ reason: string | null } | null> { + const status = await context.prisma.afkStatus.findUnique({ + where: { guildId_userId: { guildId, userId } } + }); + if (!status) { + return null; + } + return { reason: status.reason }; +} + +export function registerAfkEvents(context: BotContext): void { + context.client.on('messageCreate', async (message: Message) => { + if (message.author.bot || !message.guildId) { + return; + } + + try { + const locale = await getGuildLocale(context.prisma, message.guildId); + + const cleared = await clearAfk(context, message.guildId, message.author.id); + if (cleared) { + await message.reply({ + content: tf(locale, 'utility.afk.welcomeBack', { + reason: cleared.reason ?? '—' + }), + allowedMentions: { repliedUser: false } + }).catch(() => undefined); + } + + const mentions = message.mentions.users.filter((u) => !u.bot && u.id !== message.author.id); + for (const [, user] of mentions) { + const afk = await getAfkStatus(context, message.guildId, user.id); + if (!afk) { + continue; + } + await message.reply({ + content: tf(locale, 'utility.afk.mention', { + user: `<@${user.id}>`, + reason: afk.reason ?? '—' + }), + allowedMentions: { users: [user.id] } + }).catch(() => undefined); + } + } catch { + // Non-critical + } + }); +} diff --git a/apps/bot/src/modules/utility/buttons.ts b/apps/bot/src/modules/utility/buttons.ts new file mode 100644 index 0000000..eac494a --- /dev/null +++ b/apps/bot/src/modules/utility/buttons.ts @@ -0,0 +1,111 @@ +import { + ActionRowBuilder, + EmbedBuilder, + ModalBuilder, + PermissionFlagsBits, + TextInputBuilder, + TextInputStyle, + type ButtonInteraction, + type ModalSubmitInteraction, + type TextChannel +} from 'discord.js'; +import { t } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { POLL_BUTTON_PREFIX } from './poll.js'; +import { handlePollVote } from './poll.js'; +import { parseHexColor } from './service.js'; + +export { POLL_BUTTON_PREFIX, pollButtonId } from './poll.js'; + +export function isPollButton(customId: string): boolean { + return customId.startsWith(POLL_BUTTON_PREFIX); +} + +export async function handlePollButton( + interaction: ButtonInteraction, + context: BotContext +): Promise { + await handlePollVote(interaction, context); +} + +export const EMBED_MODAL_ID = 'util:embed:modal'; + +export function isEmbedModal(customId: string): boolean { + return customId === EMBED_MODAL_ID; +} + +export async function showEmbedBuilderModal(interaction: { + showModal: (modal: ModalBuilder) => Promise; +}): Promise { + const modal = new ModalBuilder() + .setCustomId(EMBED_MODAL_ID) + .setTitle('Embed Builder') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('title') + .setLabel('Title') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(256) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('description') + .setLabel('Description') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(4000) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('color') + .setLabel('Color (hex, e.g. 6366F1)') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(7) + ) + ); + + await interaction.showModal(modal); +} + +export async function handleEmbedModal( + interaction: ModalSubmitInteraction, + context: BotContext +): Promise { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + + if (!interaction.inGuild() || !interaction.channelId) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return; + } + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) { + await interaction.reply({ content: t(locale, 'generic.noPermission'), ephemeral: true }); + return; + } + + const title = interaction.fields.getTextInputValue('title') || undefined; + const description = interaction.fields.getTextInputValue('description'); + const colorInput = interaction.fields.getTextInputValue('color'); + const parsedColor = colorInput ? parseHexColor(colorInput) : 0x6366f1; + + if (colorInput && parsedColor === null) { + await interaction.reply({ content: t(locale, 'utility.embed.invalidColor'), ephemeral: true }); + return; + } + + const embed = new EmbedBuilder() + .setDescription(description) + .setColor(parsedColor ?? 0x6366f1); + if (title) { + embed.setTitle(title); + } + + const channel = (await context.client.channels.fetch(interaction.channelId)) as TextChannel; + await channel.send({ embeds: [embed] }); + + await interaction.reply({ content: t(locale, 'utility.embed.sent'), ephemeral: true }); +} diff --git a/apps/bot/src/modules/utility/command-definitions.ts b/apps/bot/src/modules/utility/command-definitions.ts new file mode 100644 index 0000000..97cef45 --- /dev/null +++ b/apps/bot/src/modules/utility/command-definitions.ts @@ -0,0 +1,186 @@ +import { + SlashCommandBuilder, + type SlashCommandAttachmentOption, + type SlashCommandBooleanOption, + type SlashCommandChannelOption, + type SlashCommandRoleOption, + type SlashCommandStringOption, + type SlashCommandUserOption +} from 'discord.js'; +import { + applyCommandDescription, + applyOptionDescription, + type CommandLocaleKey +} from '@nexumi/shared'; + +function userOpt(o: SlashCommandUserOption, key: CommandLocaleKey = 'utility.common.options.userOptional') { + return applyOptionDescription(o.setName('user'), key); +} + +function channelOpt(o: SlashCommandChannelOption) { + return applyOptionDescription(o.setName('channel'), 'utility.common.options.channelOptional'); +} + +function roleOpt(o: SlashCommandRoleOption) { + return applyOptionDescription(o.setName('role').setRequired(true), 'utility.roleinfo.options.role'); +} + +function attachmentOpt(o: SlashCommandAttachmentOption) { + return applyOptionDescription(o.setName('attachment'), 'utility.common.options.attachment'); +} + +function urlOpt(o: SlashCommandStringOption, key: CommandLocaleKey = 'utility.common.options.url') { + return applyOptionDescription(o.setName('url'), key); +} + +function nameOpt(o: SlashCommandStringOption, required = true) { + const option = o.setName('name'); + if (required) { + option.setRequired(true); + } + return applyOptionDescription(option, 'utility.common.options.name'); +} + +export const userinfoCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('userinfo'), + 'utility.userinfo.description' +).addUserOption((o) => userOpt(o)); + +export const serverinfoCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('serverinfo'), + 'utility.serverinfo.description' +); + +export const roleinfoCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('roleinfo'), + 'utility.roleinfo.description' +).addRoleOption((o) => roleOpt(o)); + +export const channelinfoCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('channelinfo'), + 'utility.channelinfo.description' +).addChannelOption((o) => channelOpt(o)); + +export const avatarCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('avatar'), + 'utility.avatar.description' +).addUserOption((o) => userOpt(o)); + +export const bannerCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('banner'), + 'utility.banner.description' +).addUserOption((o) => userOpt(o)); + +export const pollCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('poll'), + 'utility.poll.description' +).addSubcommand((s) => + applyCommandDescription(s.setName('create'), 'utility.poll.create.description') + .addStringOption((o) => + applyOptionDescription(o.setName('question').setRequired(true), 'utility.poll.options.question') + ) + .addStringOption((o) => + applyOptionDescription(o.setName('options').setRequired(true), 'utility.poll.options.options') + ) + .addBooleanOption((o) => + applyOptionDescription(o.setName('multiselect'), 'utility.poll.options.multiselect') + ) + .addBooleanOption((o) => + applyOptionDescription(o.setName('anonymous'), 'utility.poll.options.anonymous') + ) + .addStringOption((o) => + applyOptionDescription(o.setName('duration'), 'utility.poll.options.duration') + ) +); + +export const remindmeCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('remindme'), + 'utility.remindme.description' +) + .addStringOption((o) => + applyOptionDescription(o.setName('when').setRequired(true), 'utility.remindme.options.when') + ) + .addStringOption((o) => + applyOptionDescription(o.setName('content').setRequired(true), 'utility.remindme.options.content') + ) + .addStringOption((o) => + applyOptionDescription(o.setName('recurring_cron'), 'utility.remindme.options.recurring_cron') + ); + +export const remindersCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('reminders'), + 'utility.reminders.description' +) + .addSubcommand((s) => + applyCommandDescription(s.setName('list'), 'utility.reminders.list.description') + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('delete'), 'utility.reminders.delete.description').addStringOption( + (o) => + applyOptionDescription(o.setName('id').setRequired(true), 'utility.reminders.options.id') + ) + ); + +export const afkCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('afk'), + 'utility.afk.description' +).addSubcommand((s) => + applyCommandDescription(s.setName('set'), 'utility.afk.set.description').addStringOption((o) => + applyOptionDescription(o.setName('reason'), 'utility.afk.options.reason') + ) +); + +export const emojiCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('emoji'), + 'utility.emoji.description' +) + .addSubcommand((s) => + applyCommandDescription(s.setName('add'), 'utility.emoji.add.description') + .addStringOption((o) => nameOpt(o)) + .addAttachmentOption((o) => attachmentOpt(o)) + .addStringOption((o) => urlOpt(o, 'utility.emoji.options.url')) + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('remove'), 'utility.emoji.remove.description').addStringOption((o) => + applyOptionDescription(o.setName('emoji').setRequired(true), 'utility.emoji.options.emoji') + ) + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('steal'), 'utility.emoji.steal.description').addStringOption((o) => + applyOptionDescription(o.setName('message_id').setRequired(true), 'utility.emoji.options.message_id') + ) + ); + +export const stickerCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('sticker'), + 'utility.sticker.description' +).addSubcommand((s) => + applyCommandDescription(s.setName('add'), 'utility.sticker.add.description') + .addStringOption((o) => nameOpt(o)) + .addAttachmentOption((o) => attachmentOpt(o)) + .addStringOption((o) => urlOpt(o, 'utility.sticker.options.url')) +); + +export const timestampCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('timestamp'), + 'utility.timestamp.description' +).addStringOption((o) => + applyOptionDescription(o.setName('datetime').setRequired(true), 'utility.timestamp.options.datetime') +); + +export const snipeCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('snipe'), + 'utility.snipe.description' +); + +export const editsnipeCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('editsnipe'), + 'utility.editsnipe.description' +); + +export const embedCommandData = applyCommandDescription( + new SlashCommandBuilder().setName('embed'), + 'utility.embed.description' +).addSubcommand((s) => + applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description') +); diff --git a/apps/bot/src/modules/utility/commands.ts b/apps/bot/src/modules/utility/commands.ts new file mode 100644 index 0000000..c36589b --- /dev/null +++ b/apps/bot/src/modules/utility/commands.ts @@ -0,0 +1,438 @@ +import { + EmbedBuilder, + PermissionFlagsBits, + type ChatInputCommandInteraction, + type GuildBasedChannel +} 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 { showEmbedBuilderModal } from './buttons.js'; +import { + afkCommandData, + avatarCommandData, + bannerCommandData, + channelinfoCommandData, + editsnipeCommandData, + embedCommandData, + emojiCommandData, + pollCommandData, + remindmeCommandData, + remindersCommandData, + roleinfoCommandData, + serverinfoCommandData, + snipeCommandData, + stickerCommandData, + timestampCommandData, + userinfoCommandData +} from './command-definitions.js'; +import { createPoll } from './poll.js'; +import { createReminder, deleteReminder, listReminders } from './reminders.js'; +import { setAfk } from './afk.js'; +import { getDeletedSnipe, getEditedSnipe } from './snipe.js'; +import { + UtilityError, + buildChannelInfoEmbed, + buildRoleInfoEmbed, + buildServerInfoEmbed, + buildUserInfoEmbed, + extractFirstCustomEmoji, + formatDiscordTimestamps, + parseTimestampInput +} from './service.js'; + +async function ensureGuildCommand( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en' +): Promise { + if (!interaction.inGuild()) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return false; + } + return true; +} + +async function handleUtilityError( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en', + error: unknown +): Promise { + if (error instanceof UtilityError) { + await interaction.reply({ + content: t(locale, `utility.error.${error.code}` as 'utility.error.invalid_when'), + ephemeral: true + }); + return; + } + throw error; +} + +const userinfoCommand: SlashCommand = { + data: userinfoCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + + const user = interaction.options.getUser('user') ?? interaction.user; + const member = await interaction.guild!.members.fetch(user.id).catch(() => null); + const embed = buildUserInfoEmbed(user, member, locale); + await interaction.reply({ embeds: [embed] }); + } +}; + +const serverinfoCommand: SlashCommand = { + data: serverinfoCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + const embed = buildServerInfoEmbed(interaction.guild!, locale); + await interaction.reply({ embeds: [embed] }); + } +}; + +const roleinfoCommand: SlashCommand = { + data: roleinfoCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + const role = interaction.options.getRole('role', true); + const guildRole = + interaction.guild!.roles.cache.get(role.id) ?? (await interaction.guild!.roles.fetch(role.id)); + if (!guildRole) { + await interaction.reply({ content: t(locale, 'utility.error.role_not_found'), ephemeral: true }); + return; + } + await interaction.reply({ embeds: [buildRoleInfoEmbed(guildRole, locale)] }); + } +}; + +const channelinfoCommand: SlashCommand = { + data: channelinfoCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + const channel = interaction.options.getChannel('channel') ?? interaction.channel; + if (!channel || !('guild' in channel)) { + await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true }); + return; + } + await interaction.reply({ embeds: [buildChannelInfoEmbed(channel as GuildBasedChannel, locale)] }); + } +}; + +const avatarCommand: SlashCommand = { + data: avatarCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const user = interaction.options.getUser('user') ?? interaction.user; + const embed = new EmbedBuilder() + .setTitle(tf(locale, 'utility.avatar.title', { user: user.tag })) + .setImage(user.displayAvatarURL({ size: 512 })) + .setColor(0x6366f1); + await interaction.reply({ embeds: [embed] }); + } +}; + +const bannerCommand: SlashCommand = { + data: bannerCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const user = interaction.options.getUser('user') ?? interaction.user; + const fetched = await context.client.users.fetch(user.id, { force: true }); + const banner = fetched.bannerURL({ size: 512 }); + if (!banner) { + await interaction.reply({ content: t(locale, 'utility.banner.none'), ephemeral: true }); + return; + } + const embed = new EmbedBuilder() + .setTitle(tf(locale, 'utility.banner.title', { user: fetched.tag })) + .setImage(banner) + .setColor(0x6366f1); + await interaction.reply({ embeds: [embed] }); + } +}; + +const pollCommand: SlashCommand = { + data: pollCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + + const question = interaction.options.getString('question', true); + const optionsInput = interaction.options.getString('options', true); + const multiSelect = interaction.options.getBoolean('multiselect') ?? false; + const anonymous = interaction.options.getBoolean('anonymous') ?? false; + const duration = interaction.options.getString('duration'); + + try { + await createPoll( + context, + { + guildId: interaction.guildId!, + channelId: interaction.channelId!, + creatorId: interaction.user.id, + question, + optionsInput, + multiSelect, + anonymous, + durationInput: duration + }, + locale + ); + await interaction.reply({ content: t(locale, 'utility.poll.created'), ephemeral: true }); + } catch (error) { + await handleUtilityError(interaction, locale, error); + } + } +}; + +const remindmeCommand: SlashCommand = { + data: remindmeCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + + const whenInput = interaction.options.getString('when', true); + const content = interaction.options.getString('content', true); + const recurringCron = interaction.options.getString('recurring_cron'); + + try { + const id = await createReminder(context, { + guildId: interaction.guildId, + userId: interaction.user.id, + channelId: interaction.channelId!, + content, + whenInput, + recurringCron + }); + await interaction.reply({ + content: tf(locale, 'utility.remindme.created', { id: id.slice(0, 8) }), + ephemeral: true + }); + } catch (error) { + await handleUtilityError(interaction, locale, error); + } + } +}; + +const remindersCommand: SlashCommand = { + data: remindersCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const sub = interaction.options.getSubcommand(); + + if (sub === 'list') { + const content = await listReminders(context, interaction.user.id, locale); + await interaction.reply({ content, ephemeral: true }); + return; + } + + const id = interaction.options.getString('id', true); + const deleted = await deleteReminder(context, interaction.user.id, id); + await interaction.reply({ + content: t(locale, deleted ? 'utility.reminders.deleted' : 'utility.reminders.notFound'), + ephemeral: true + }); + } +}; + +const afkCommand: SlashCommand = { + data: afkCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + + const reason = interaction.options.getString('reason'); + await setAfk(context, interaction.guildId!, interaction.user.id, reason); + await interaction.reply({ + content: tf(locale, 'utility.afk.set', { reason: reason ?? '—' }), + ephemeral: true + }); + } +}; + +const emojiCommand: SlashCommand = { + data: emojiCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) { + return; + } + + const sub = interaction.options.getSubcommand(); + + if (sub === 'add') { + const name = interaction.options.getString('name', true); + const attachment = interaction.options.getAttachment('attachment'); + const url = interaction.options.getString('url') ?? attachment?.url; + if (!url) { + await interaction.reply({ content: t(locale, 'utility.emoji.noSource'), ephemeral: true }); + return; + } + const emoji = await interaction.guild!.emojis.create({ attachment: url, name }); + await interaction.reply({ + content: tf(locale, 'utility.emoji.added', { emoji: emoji.toString() }), + ephemeral: true + }); + return; + } + + if (sub === 'remove') { + const input = interaction.options.getString('emoji', true); + const match = input.match(/:(\d+)>$/) ?? input.match(/^(\d+)$/); + const emojiId = match?.[1] ?? input; + const emoji = interaction.guild!.emojis.cache.get(emojiId); + if (!emoji) { + await interaction.reply({ content: t(locale, 'utility.emoji.notFound'), ephemeral: true }); + return; + } + await emoji.delete(); + await interaction.reply({ content: t(locale, 'utility.emoji.removed'), ephemeral: true }); + return; + } + + const messageId = interaction.options.getString('message_id', true); + const channel = interaction.channel; + if (!channel?.isTextBased()) { + await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true }); + return; + } + const message = await channel.messages.fetch(messageId).catch(() => null); + if (!message) { + await interaction.reply({ content: t(locale, 'utility.emoji.messageNotFound'), ephemeral: true }); + return; + } + const stolen = extractFirstCustomEmoji(message.content); + if (!stolen) { + await interaction.reply({ content: t(locale, 'utility.emoji.noEmojiInMessage'), ephemeral: true }); + return; + } + const isAnimated = message.content.includes(``); + const emoji = await interaction.guild!.emojis.create({ + attachment: `https://cdn.discordapp.com/emojis/${stolen.id}.${isAnimated ? 'gif' : 'png'}`, + name: stolen.name + }); + await interaction.reply({ + content: tf(locale, 'utility.emoji.stolen', { emoji: emoji.toString() }), + ephemeral: true + }); + } +}; + +const stickerCommand: SlashCommand = { + data: stickerCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) { + return; + } + + const name = interaction.options.getString('name', true); + const attachment = interaction.options.getAttachment('attachment'); + const url = interaction.options.getString('url') ?? attachment?.url; + if (!url) { + await interaction.reply({ content: t(locale, 'utility.sticker.noSource'), ephemeral: true }); + return; + } + + const sticker = await interaction.guild!.stickers.create({ + file: url, + name, + tags: name + }); + await interaction.reply({ + content: tf(locale, 'utility.sticker.added', { name: sticker.name }), + ephemeral: true + }); + } +}; + +const timestampCommand: SlashCommand = { + data: timestampCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const input = interaction.options.getString('datetime', true); + try { + const ms = parseTimestampInput(input); + const unix = Math.floor(ms / 1000); + await interaction.reply({ + content: formatDiscordTimestamps(unix), + ephemeral: true + }); + } catch (error) { + await handleUtilityError(interaction, locale, error); + } + } +}; + +const snipeCommand: SlashCommand = { + data: snipeCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + + const result = await getDeletedSnipe(context, interaction.channelId!, locale); + if (!result) { + await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true }); + return; + } + await interaction.reply({ + embeds: [result.embed], + content: result.privacyNote, + ephemeral: true + }); + } +}; + +const editsnipeCommand: SlashCommand = { + data: editsnipeCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + + const result = await getEditedSnipe(context, interaction.channelId!, locale); + if (!result) { + await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true }); + return; + } + await interaction.reply({ + embeds: [result.embed], + content: result.privacyNote, + ephemeral: true + }); + } +}; + +const embedCommand: SlashCommand = { + data: embedCommandData, + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!(await ensureGuildCommand(interaction, locale))) return; + if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) { + return; + } + await showEmbedBuilderModal(interaction); + } +}; + +export const utilityCommands: SlashCommand[] = [ + userinfoCommand, + serverinfoCommand, + roleinfoCommand, + channelinfoCommand, + avatarCommand, + bannerCommand, + pollCommand, + remindmeCommand, + remindersCommand, + afkCommand, + emojiCommand, + stickerCommand, + timestampCommand, + snipeCommand, + editsnipeCommand, + embedCommand +]; diff --git a/apps/bot/src/modules/utility/context-menus.ts b/apps/bot/src/modules/utility/context-menus.ts new file mode 100644 index 0000000..a66897d --- /dev/null +++ b/apps/bot/src/modules/utility/context-menus.ts @@ -0,0 +1,50 @@ +import { ContextMenuCommandBuilder, ApplicationCommandType } from 'discord.js'; +import { t } from '@nexumi/shared'; +import type { BotContext, ContextMenuCommand } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; + +async function translateText(text: string, targetLang: string): Promise { + const url = new URL('https://api.mymemory.translated.net/get'); + url.searchParams.set('q', text.slice(0, 500)); + url.searchParams.set('langpair', `auto|${targetLang}`); + + const response = await fetch(url.toString()); + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { + responseData?: { translatedText?: string }; + }; + return data.responseData?.translatedText ?? null; +} + +const translateContextMenu: ContextMenuCommand = { + data: new ContextMenuCommandBuilder() + .setName('Translate') + .setType(ApplicationCommandType.Message), + async execute(interaction, context) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + const content = interaction.targetMessage.content; + + if (!content?.trim()) { + await interaction.reply({ content: t(locale, 'utility.translate.noContent'), ephemeral: true }); + return; + } + + const targetLang = locale === 'de' ? 'de' : 'en'; + const translated = await translateText(content, targetLang); + + if (!translated) { + await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true }); + return; + } + + await interaction.reply({ + content: t(locale, 'utility.translate.result').replace('{text}', translated), + ephemeral: true + }); + } +}; + +export const utilityContextMenus: ContextMenuCommand[] = [translateContextMenu]; diff --git a/apps/bot/src/modules/utility/events.ts b/apps/bot/src/modules/utility/events.ts new file mode 100644 index 0000000..1ef7b49 --- /dev/null +++ b/apps/bot/src/modules/utility/events.ts @@ -0,0 +1,8 @@ +import type { BotContext } from '../../types.js'; +import { registerAfkEvents } from './afk.js'; +import { registerSnipeEvents } from './snipe.js'; + +export function registerUtilityEvents(context: BotContext): void { + registerSnipeEvents(context); + registerAfkEvents(context); +} diff --git a/apps/bot/src/modules/utility/index.ts b/apps/bot/src/modules/utility/index.ts new file mode 100644 index 0000000..80d234b --- /dev/null +++ b/apps/bot/src/modules/utility/index.ts @@ -0,0 +1,13 @@ +export { utilityCommands } from './commands.js'; +export { utilityContextMenus } from './context-menus.js'; +export { registerUtilityEvents } from './events.js'; +export { + isPollButton, + handlePollButton, + isEmbedModal, + handleEmbedModal +} from './buttons.js'; +export { registerAfkEvents } from './afk.js'; +export { registerSnipeEvents } from './snipe.js'; +export { closePoll } from './poll.js'; +export { sendReminder } from './reminders.js'; diff --git a/apps/bot/src/modules/utility/poll.ts b/apps/bot/src/modules/utility/poll.ts new file mode 100644 index 0000000..841e135 --- /dev/null +++ b/apps/bot/src/modules/utility/poll.ts @@ -0,0 +1,274 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type ButtonInteraction, + type TextChannel +} from 'discord.js'; +import { t, tf } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { ensureGuild } from '../../guild.js'; +import { getGuildLocale } from '../../i18n.js'; +import { reminderQueue } from '../../queues.js'; +import { parseDuration } from '../moderation/duration.js'; +import { UtilityError, parsePollOptions } from './service.js'; + +export const POLL_BUTTON_PREFIX = 'util:poll:'; + +export function pollButtonId(pollId: string, optionIndex: number): string { + return `${POLL_BUTTON_PREFIX}${pollId}:${optionIndex}`; +} + +export function parsePollButtonId(customId: string): { pollId: string; optionIndex: number } | null { + if (!customId.startsWith(POLL_BUTTON_PREFIX)) { + return null; + } + const rest = customId.slice(POLL_BUTTON_PREFIX.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon === -1) { + return null; + } + const pollId = rest.slice(0, lastColon); + const optionIndex = Number.parseInt(rest.slice(lastColon + 1), 10); + if (!pollId || Number.isNaN(optionIndex)) { + return null; + } + return { pollId, optionIndex }; +} + +function parseOptionsJson(raw: unknown): string[] { + if (!Array.isArray(raw)) { + return []; + } + return raw.filter((item): item is string => typeof item === 'string'); +} + +export async function buildPollEmbed( + context: BotContext, + pollId: string, + locale: 'de' | 'en' +): Promise { + const poll = await context.prisma.poll.findUnique({ + where: { id: pollId }, + include: { votes: true } + }); + if (!poll) { + throw new UtilityError('poll_not_found'); + } + + const options = parseOptionsJson(poll.options); + const voteCounts = new Map(); + for (const vote of poll.votes) { + voteCounts.set(vote.optionIndex, (voteCounts.get(vote.optionIndex) ?? 0) + 1); + } + + const lines = options.map((option, index) => { + const count = voteCounts.get(index) ?? 0; + return `${index + 1}. **${option}** — ${count} ${t(locale, 'utility.poll.votes')}`; + }); + + const embed = new EmbedBuilder() + .setTitle(poll.question) + .setDescription(lines.join('\n')) + .setColor(0x6366f1); + + if (poll.closedAt) { + embed.setFooter({ text: t(locale, 'utility.poll.closed') }); + } else if (poll.endsAt) { + embed.setFooter({ + text: tf(locale, 'utility.poll.endsAt', { + time: `` + }) + }); + } + + if (poll.multiSelect) { + embed.addFields({ + name: t(locale, 'utility.poll.multiSelect'), + value: t(locale, 'utility.poll.multiSelectHint'), + inline: false + }); + } + + return embed; +} + +export function buildPollButtons(pollId: string, options: string[], disabled = false): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + let currentRow = new ActionRowBuilder(); + + options.forEach((option, index) => { + const button = new ButtonBuilder() + .setCustomId(pollButtonId(pollId, index)) + .setLabel(option.slice(0, 80)) + .setStyle(ButtonStyle.Primary) + .setDisabled(disabled); + + currentRow.addComponents(button); + if ((index + 1) % 5 === 0 || index === options.length - 1) { + rows.push(currentRow); + currentRow = new ActionRowBuilder(); + } + }); + + return rows; +} + +export async function createPoll( + context: BotContext, + params: { + guildId: string; + channelId: string; + creatorId: string; + question: string; + optionsInput: string; + multiSelect: boolean; + anonymous: boolean; + durationInput?: string | null; + }, + locale: 'de' | 'en' +): Promise<{ pollId: string; messageId: string }> { + await ensureGuild(context.prisma, params.guildId); + const options = parsePollOptions(params.optionsInput); + + let endsAt: Date | null = null; + let delayMs: number | null = null; + if (params.durationInput) { + delayMs = parseDuration(params.durationInput); + endsAt = new Date(Date.now() + delayMs); + } + + const poll = await context.prisma.poll.create({ + data: { + guildId: params.guildId, + channelId: params.channelId, + creatorId: params.creatorId, + question: params.question, + options, + multiSelect: params.multiSelect, + anonymous: params.anonymous, + endsAt + } + }); + + const embed = await buildPollEmbed(context, poll.id, locale); + const components = buildPollButtons(poll.id, options); + + const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel; + const message = await channel.send({ embeds: [embed], components }); + + await context.prisma.poll.update({ + where: { id: poll.id }, + data: { messageId: message.id } + }); + + if (delayMs !== null) { + await reminderQueue.add( + 'pollClose', + { pollId: poll.id }, + { + delay: delayMs, + jobId: `poll-close-${poll.id}`, + removeOnComplete: true, + removeOnFail: 50 + } + ); + } + + return { pollId: poll.id, messageId: message.id }; +} + +export async function handlePollVote( + interaction: ButtonInteraction, + context: BotContext +): Promise { + const parsed = parsePollButtonId(interaction.customId); + const locale = await getGuildLocale(context.prisma, interaction.guildId); + + if (!parsed || !interaction.guildId) { + await interaction.reply({ content: t(locale, 'utility.poll.notFound'), ephemeral: true }); + return; + } + + const poll = await context.prisma.poll.findUnique({ + where: { id: parsed.pollId }, + include: { votes: true } + }); + + if (!poll || poll.closedAt) { + await interaction.reply({ content: t(locale, 'utility.poll.closed'), ephemeral: true }); + return; + } + + const options = parseOptionsJson(poll.options); + if (parsed.optionIndex < 0 || parsed.optionIndex >= options.length) { + await interaction.reply({ content: t(locale, 'utility.poll.invalidOption'), ephemeral: true }); + return; + } + + const userId = interaction.user.id; + const existingVotes = poll.votes.filter((v) => v.userId === userId); + + if (!poll.multiSelect) { + if (existingVotes.some((v) => v.optionIndex === parsed.optionIndex)) { + await interaction.reply({ content: t(locale, 'utility.poll.alreadyVoted'), ephemeral: true }); + return; + } + await context.prisma.pollVote.deleteMany({ + where: { pollId: poll.id, userId } + }); + await context.prisma.pollVote.create({ + data: { pollId: poll.id, userId, optionIndex: parsed.optionIndex } + }); + } else { + const existing = existingVotes.find((v) => v.optionIndex === parsed.optionIndex); + if (existing) { + await context.prisma.pollVote.delete({ where: { id: existing.id } }); + } else { + await context.prisma.pollVote.create({ + data: { pollId: poll.id, userId, optionIndex: parsed.optionIndex } + }); + } + } + + const embed = await buildPollEmbed(context, poll.id, locale); + const components = buildPollButtons(poll.id, options); + await interaction.update({ embeds: [embed], components }); + + if (!poll.anonymous) { + await interaction.followUp({ + content: tf(locale, 'utility.poll.voted', { option: options[parsed.optionIndex] }), + ephemeral: true + }); + } +} + +export async function closePoll(context: BotContext, pollId: string): Promise { + const poll = await context.prisma.poll.findUnique({ where: { id: pollId } }); + if (!poll || poll.closedAt) { + return; + } + + await context.prisma.poll.update({ + where: { id: pollId }, + data: { closedAt: new Date() } + }); + + if (!poll.messageId) { + return; + } + + const locale = await getGuildLocale(context.prisma, poll.guildId); + const options = parseOptionsJson(poll.options); + const embed = await buildPollEmbed(context, pollId, locale); + const components = buildPollButtons(pollId, options, true); + + try { + const channel = (await context.client.channels.fetch(poll.channelId)) as TextChannel; + const message = await channel.messages.fetch(poll.messageId); + await message.edit({ embeds: [embed], components }); + } catch { + // Message may have been deleted + } +} diff --git a/apps/bot/src/modules/utility/reminders.ts b/apps/bot/src/modules/utility/reminders.ts new file mode 100644 index 0000000..9d095a9 --- /dev/null +++ b/apps/bot/src/modules/utility/reminders.ts @@ -0,0 +1,142 @@ +import { type TextChannel } from 'discord.js'; +import { t, tf } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { ensureGuild } from '../../guild.js'; +import { reminderQueue } from '../../queues.js'; +import { parseWhen, UtilityError } from './service.js'; + +export async function createReminder( + context: BotContext, + params: { + guildId: string | null; + userId: string; + channelId: string; + content: string; + whenInput: string; + recurringCron?: string | null; + } +): Promise { + if (params.guildId) { + await ensureGuild(context.prisma, params.guildId); + } + + const remindAt = parseWhen(params.whenInput); + const delayMs = remindAt.getTime() - Date.now(); + + const reminder = await context.prisma.reminder.create({ + data: { + guildId: params.guildId, + userId: params.userId, + channelId: params.channelId, + content: params.content, + remindAt, + recurringCron: params.recurringCron ?? null + } + }); + + const jobOptions: { + jobId: string; + removeOnComplete: boolean; + removeOnFail: number; + delay?: number; + repeat?: { pattern: string }; + } = { + jobId: `reminder-${reminder.id}`, + removeOnComplete: true, + removeOnFail: 50 + }; + + if (params.recurringCron) { + jobOptions.repeat = { pattern: params.recurringCron }; + } else { + jobOptions.delay = Math.max(0, delayMs); + } + + const job = await reminderQueue.add( + 'reminderSend', + { reminderId: reminder.id }, + jobOptions + ); + + await context.prisma.reminder.update({ + where: { id: reminder.id }, + data: { jobId: String(job.id) } + }); + + return reminder.id; +} + +export async function listReminders( + context: BotContext, + userId: string, + locale: 'de' | 'en' +): Promise { + const reminders = await context.prisma.reminder.findMany({ + where: { userId }, + orderBy: { remindAt: 'asc' }, + take: 20 + }); + + if (reminders.length === 0) { + return t(locale, 'utility.reminders.none'); + } + + return reminders + .map((r) => + tf(locale, 'utility.reminders.line', { + id: r.id.slice(0, 8), + time: ``, + content: r.content.slice(0, 80) + }) + ) + .join('\n'); +} + +export async function deleteReminder( + context: BotContext, + userId: string, + reminderIdPrefix: string +): Promise { + const reminders = await context.prisma.reminder.findMany({ + where: { userId } + }); + const match = reminders.find((r) => r.id.startsWith(reminderIdPrefix)); + if (!match) { + return false; + } + + if (match.jobId) { + const job = await reminderQueue.getJob(match.jobId); + if (job) { + await job.remove(); + } + } + + await context.prisma.reminder.delete({ where: { id: match.id } }); + return true; +} + +export async function sendReminder(context: BotContext, reminderId: string): Promise { + const reminder = await context.prisma.reminder.findUnique({ where: { id: reminderId } }); + if (!reminder) { + return; + } + + try { + const channel = (await context.client.channels.fetch(reminder.channelId)) as TextChannel; + await channel.send({ + content: `<@${reminder.userId}> ${reminder.content}` + }); + } catch { + throw new UtilityError('reminder_channel_unavailable'); + } + + if (!reminder.recurringCron) { + await context.prisma.reminder.delete({ where: { id: reminderId } }); + } else { + await context.prisma.reminder.update({ + where: { id: reminderId }, + data: { remindAt: new Date(Date.now() + 60_000) } + }); + } +} diff --git a/apps/bot/src/modules/utility/service.ts b/apps/bot/src/modules/utility/service.ts new file mode 100644 index 0000000..a7bb03e --- /dev/null +++ b/apps/bot/src/modules/utility/service.ts @@ -0,0 +1,241 @@ +import { + ChannelType, + EmbedBuilder, + type Guild, + type GuildMember, + type Role, + type GuildBasedChannel, + type User +} from 'discord.js'; +import { parseDuration } from '../moderation/duration.js'; + +export class UtilityError extends Error { + constructor(public readonly code: string) { + super(code); + this.name = 'UtilityError'; + } +} + +export function parsePollOptions(input: string): string[] { + const options = input + .split(',') + .map((o) => o.trim()) + .filter((o) => o.length > 0); + if (options.length < 2 || options.length > 5) { + throw new UtilityError('invalid_poll_options'); + } + return options; +} + +export function parseWhen(input: string): Date { + const trimmed = input.trim(); + if (/^\d+[smhd]$/i.test(trimmed)) { + const ms = parseDuration(trimmed); + return new Date(Date.now() + ms); + } + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed) || parsed <= Date.now()) { + throw new UtilityError('invalid_when'); + } + return new Date(parsed); +} + +export function parseTimestampInput(input: string): number { + const trimmed = input.trim(); + if (/^\d+[smhd]$/i.test(trimmed)) { + return Date.now() + parseDuration(trimmed); + } + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed)) { + throw new UtilityError('invalid_datetime'); + } + return parsed; +} + +export function formatDiscordTimestamps(unixSeconds: number): string { + return [ + ``, + ``, + ``, + ``, + ``, + ``, + `` + ].join('\n'); +} + +export function buildUserInfoEmbed( + user: User, + member: GuildMember | null, + locale: 'de' | 'en' +): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(user.tag) + .setThumbnail(user.displayAvatarURL({ size: 256 })) + .setColor(0x6366f1) + .addFields( + { name: 'ID', value: user.id, inline: true }, + { + name: locale === 'de' ? 'Erstellt' : 'Created', + value: ``, + inline: true + } + ); + + if (member) { + embed.addFields( + { + name: locale === 'de' ? 'Beigetreten' : 'Joined', + value: member.joinedTimestamp + ? `` + : '—', + inline: true + }, + { + name: locale === 'de' ? 'Rollen' : 'Roles', + value: + member.roles.cache + .filter((r) => r.id !== member.guild.id) + .sort((a, b) => b.position - a.position) + .map((r) => r.toString()) + .slice(0, 15) + .join(', ') || '—', + inline: false + } + ); + } + + return embed; +} + +export function buildServerInfoEmbed(guild: Guild, locale: 'de' | 'en'): EmbedBuilder { + return new EmbedBuilder() + .setTitle(guild.name) + .setThumbnail(guild.iconURL({ size: 256 })) + .setColor(0x6366f1) + .addFields( + { name: 'ID', value: guild.id, inline: true }, + { + name: locale === 'de' ? 'Mitglieder' : 'Members', + value: String(guild.memberCount), + inline: true + }, + { + name: locale === 'de' ? 'Erstellt' : 'Created', + value: guild.createdTimestamp + ? `` + : '—', + inline: true + }, + { + name: locale === 'de' ? 'Besitzer' : 'Owner', + value: `<@${guild.ownerId}>`, + inline: true + }, + { + name: locale === 'de' ? 'Boosts' : 'Boosts', + value: String(guild.premiumSubscriptionCount ?? 0), + inline: true + }, + { + name: locale === 'de' ? 'KanĂ€le' : 'Channels', + value: String(guild.channels.cache.size), + inline: true + } + ); +} + +export function buildRoleInfoEmbed(role: Role, locale: 'de' | 'en'): EmbedBuilder { + return new EmbedBuilder() + .setTitle(role.name) + .setColor(role.color || 0x6366f1) + .addFields( + { name: 'ID', value: role.id, inline: true }, + { + name: locale === 'de' ? 'Mitglieder' : 'Members', + value: String(role.members.size), + inline: true + }, + { + name: locale === 'de' ? 'Erstellt' : 'Created', + value: role.createdTimestamp + ? `` + : '—', + inline: true + }, + { + name: locale === 'de' ? 'Farbe' : 'Color', + value: role.hexColor, + inline: true + }, + { + name: locale === 'de' ? 'Position' : 'Position', + value: String(role.position), + inline: true + }, + { + name: locale === 'de' ? 'ErwĂ€hnbar' : 'Mentionable', + value: role.mentionable ? '✓' : '✗', + inline: true + } + ); +} + +export function buildChannelInfoEmbed( + channel: GuildBasedChannel, + locale: 'de' | 'en' +): EmbedBuilder { + const typeLabel = + channel.type === ChannelType.GuildText + ? locale === 'de' + ? 'Text' + : 'Text' + : channel.type === ChannelType.GuildVoice + ? locale === 'de' + ? 'Voice' + : 'Voice' + : String(channel.type); + + const embed = new EmbedBuilder() + .setTitle(channel.name) + .setColor(0x6366f1) + .addFields( + { name: 'ID', value: channel.id, inline: true }, + { name: locale === 'de' ? 'Typ' : 'Type', value: typeLabel, inline: true }, + { + name: locale === 'de' ? 'Erstellt' : 'Created', + value: channel.createdTimestamp + ? `` + : '—', + inline: true + } + ); + + if ('topic' in channel && channel.topic) { + embed.addFields({ + name: locale === 'de' ? 'Thema' : 'Topic', + value: channel.topic.slice(0, 1024), + inline: false + }); + } + + return embed; +} + +export function parseHexColor(input: string): number | null { + const cleaned = input.replace(/^#/, '').trim(); + if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) { + return null; + } + return Number.parseInt(cleaned, 16); +} + +export const CUSTOM_EMOJI_REGEX = //; + +export function extractFirstCustomEmoji(content: string): { name: string; id: string } | null { + const match = content.match(CUSTOM_EMOJI_REGEX); + if (!match) { + return null; + } + return { name: match[1], id: match[2] }; +} diff --git a/apps/bot/src/modules/utility/snipe.ts b/apps/bot/src/modules/utility/snipe.ts new file mode 100644 index 0000000..112be8f --- /dev/null +++ b/apps/bot/src/modules/utility/snipe.ts @@ -0,0 +1,130 @@ +import { EmbedBuilder, type Message, type PartialMessage } from 'discord.js'; +import { t, tf } from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; + +const SNIPE_TTL_SECONDS = 3600; +const DELETE_KEY_PREFIX = 'util:snipe:delete:'; +const EDIT_KEY_PREFIX = 'util:snipe:edit:'; + +type SnipePayload = { + authorId: string; + authorTag: string; + content: string; + createdAt: number; + attachmentUrl?: string; +}; + +function snipeKey(prefix: string, channelId: string): string { + return `${prefix}${channelId}`; +} + +async function storeSnipe( + redis: BotContext['redis'], + prefix: string, + channelId: string, + payload: SnipePayload +): Promise { + await redis.set(snipeKey(prefix, channelId), JSON.stringify(payload), 'EX', SNIPE_TTL_SECONDS); +} + +async function getSnipe( + redis: BotContext['redis'], + prefix: string, + channelId: string +): Promise { + const raw = await redis.get(snipeKey(prefix, channelId)); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as SnipePayload; + } catch { + return null; + } +} + +function messageToPayload(message: Message | PartialMessage): SnipePayload { + return { + authorId: message.author?.id ?? 'unknown', + authorTag: message.author?.tag ?? 'Unknown', + content: message.content?.slice(0, 2000) ?? '', + createdAt: message.createdTimestamp ?? Date.now(), + attachmentUrl: message.attachments.first()?.url + }; +} + +export function registerSnipeEvents(context: BotContext): void { + context.client.on('messageDelete', async (message) => { + if (message.author?.bot || !message.guildId || !message.channelId) { + return; + } + if (!message.content && message.attachments.size === 0) { + return; + } + await storeSnipe(context.redis, DELETE_KEY_PREFIX, message.channelId, messageToPayload(message)); + }); + + context.client.on('messageUpdate', async (oldMessage, newMessage) => { + if (oldMessage.author?.bot || !oldMessage.guildId || !oldMessage.channelId) { + return; + } + const oldContent = oldMessage.content ?? ''; + const newContent = newMessage.content ?? ''; + if (!oldContent || oldContent === newContent) { + return; + } + await storeSnipe(context.redis, EDIT_KEY_PREFIX, oldMessage.channelId, messageToPayload(oldMessage)); + }); +} + +export async function getDeletedSnipe( + context: BotContext, + channelId: string, + locale: 'de' | 'en' +): Promise<{ embed: EmbedBuilder; privacyNote: string } | null> { + const payload = await getSnipe(context.redis, DELETE_KEY_PREFIX, channelId); + if (!payload) { + return null; + } + return buildSnipeReply(payload, locale, 'delete'); +} + +export async function getEditedSnipe( + context: BotContext, + channelId: string, + locale: 'de' | 'en' +): Promise<{ embed: EmbedBuilder; privacyNote: string } | null> { + const payload = await getSnipe(context.redis, EDIT_KEY_PREFIX, channelId); + if (!payload) { + return null; + } + return buildSnipeReply(payload, locale, 'edit'); +} + +function buildSnipeReply( + payload: SnipePayload, + locale: 'de' | 'en', + type: 'delete' | 'edit' +): { embed: EmbedBuilder; privacyNote: string } { + const embed = new EmbedBuilder() + .setColor(0x6366f1) + .setAuthor({ name: payload.authorTag }) + .setDescription(payload.content || '—') + .setTimestamp(payload.createdAt); + + if (payload.attachmentUrl) { + embed.setImage(payload.attachmentUrl); + } + + embed.setFooter({ + text: tf(locale, type === 'delete' ? 'utility.snipe.deletedAt' : 'utility.snipe.editedAt', { + user: `<@${payload.authorId}>` + }) + }); + + return { + embed, + privacyNote: t(locale, 'utility.snipe.privacy') + }; +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index 2193aba..6242129 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -9,3 +9,5 @@ export const automodQueueName = 'automod'; export const automodQueue = new Queue(automodQueueName, { connection: redis }); export const verificationQueueName = 'verification'; export const verificationQueue = new Queue(verificationQueueName, { connection: redis }); +export const reminderQueueName = 'reminders'; +export const reminderQueue = new Queue(reminderQueueName, { connection: redis }); diff --git a/apps/bot/src/types.ts b/apps/bot/src/types.ts index a26dced..420468a 100644 --- a/apps/bot/src/types.ts +++ b/apps/bot/src/types.ts @@ -1,5 +1,9 @@ import type { PrismaClient } from '@prisma/client'; -import type { ChatInputCommandInteraction, Client } from 'discord.js'; +import type { + ChatInputCommandInteraction, + Client, + MessageContextMenuCommandInteraction +} from 'discord.js'; import type { Redis } from 'ioredis'; export type BotContext = { @@ -12,3 +16,11 @@ export type SlashCommand = { data: { name: string; toJSON: () => unknown }; execute: (interaction: ChatInputCommandInteraction, context: BotContext) => Promise; }; + +export type ContextMenuCommand = { + data: { name: string; toJSON: () => unknown }; + execute: ( + interaction: MessageContextMenuCommandInteraction, + context: BotContext + ) => Promise; +}; diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 1961acd..e3a9356 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -1,140 +1,182 @@ -# Nexumi – Phasen-Tracking - -Dieses Dokument hĂ€lt den aktuellen Implementierungsstand fest. Es wird bei jedem Arbeitsfortschritt aktualisiert. - -## Assets - -- Bot-Logo vorhanden: `docs/logo/nexumi-logo.svg` - -## Phase 1 – Fundament (Status: abgeschlossen, manuelle Discord-Tests ausstehend) - -### Bereits abgeschlossen - -- Monorepo-Basis mit Workspaces: - - `apps/bot` - - `packages/shared` -- Basis-Tooling: - - TypeScript strict - - ESLint - - Prettier - - Vitest - - Turbo -- Docker-Compose-Grundstack: - - `postgres` mit Healthcheck - - `redis` mit Healthcheck - - `bot` - - `webui`-Scaffold-Service (temporĂ€r) - - `backups` Volume angebunden -- Prisma-Grundmodell inkl. Moderationsdaten: - - `Guild` - - `GuildSettings` - - `User` - - `Case` - - `Warning` - - `ModNote` - - `EscalationRule` -- Bot-Grundarchitektur: - - Sharding-Startpfad - - Command-Registrierung - - Command-Routing - - zentraler Permission-Check - - Basis-i18n in `@nexumi/shared` - - Guild-Locale-Auflösung ĂŒber DB-Settings mit Fallback auf Env-Default - - Health-Endpoint (`/health`) und token-geschĂŒtztes `/metrics`-Scaffold -- BullMQ-Integration: - - Moderation-Queue - - Temp-Ban-Expire-Job - - tĂ€glicher Backup-Job (`pg_dump`) - - Backup-Retention-Cleanup -- Moderation als Referenzmodul (implementiert): - - `/ban`, `/unban`, `/kick`, `/timeout`, `/untimeout` - - `/warn add|list|remove|clear` - - `/warn escalation-set|escalation-list|escalation-remove` - - `/purge` (Filter: User, Bots, Links, Attachments, Regex) - - `/slowmode`, `/lock`, `/unlock` - - `/nick set|reset` - - `/case view|edit|delete` - - `/modnote add|list` - - user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`) - - Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations) - - BestĂ€tigungsflow fĂŒr destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons - - standardisierte Case-Audit-Metadaten ĂŒber `@nexumi/shared` (`buildCaseMetadata`) -- Docker-Build/Runtime-Fixes fĂŒr E2E: - - `.dockerignore` ergĂ€nzt - - Prisma-Migration beim Bot-Start (`prisma migrate deploy`) - - Bot-Healthcheck auf `/health` -- E2E-Verifikation (automatisiert, lokal): - - `docker compose up -d --build` erfolgreich - - Postgres/Redis healthy - - Migration `20260722123000_init` angewendet - - Bot online (`Nexumi#9122`), 13 Slash-Commands registriert - - Bot-Container healthy -- Tests (aktuell vorhanden): - - `packages/shared/src/i18n.test.ts` - - `packages/shared/src/audit.test.ts` - - `apps/bot/src/modules/moderation/duration.test.ts` - -### Manuelle Discord-Tests (noch offen) - -- Bot auf Test-Server einladen (falls noch nicht geschehen) -- Slash-Commands testen: `/ban`, `/warn add`, `/purge`, `/case view` -- BestĂ€tigungsbuttons bei `/ban` und `/purge` prĂŒfen - -## Phase 2 – AutoMod, Logging, Welcome, Verifizierung (Status: implementiert, manuelle Tests ausstehend) - -### Abgeschlossen - -- Prisma-Erweiterung + Migration `20260722140000_phase2_modules`: - - `AutoModConfig`, `AutoModRule` - - `LoggingConfig`, `LogChannel` - - `WelcomeConfig` - - `VerificationConfig` -- Shared-Schemas und Hilfsfunktionen (`packages/shared/src/phase2.ts`) -- **AutoMod** (`apps/bot/src/modules/automod/`): - - Filter: Spam, Massen-Mentions, Caps, Invite-Links, externe Links, Wortfilter, Duplikate, Emoji-Spam, Zalgo, Phishing-Blockliste - - Standardregeln werden pro Guild beim ersten Zugriff angelegt - - Anti-Raid (Join-Rate → Lockdown) - - Anti-Nuke (Massen-Bans/Channel-Löschungen → Rechteentzug) - - `/automod status` - - Phishing-Listen-Refresh via BullMQ (alle 6h + beim Start) -- **Logging** (`apps/bot/src/modules/logging/`): - - Event-Handler fĂŒr alle SPEC-Events (Nachrichten, Member, Bans, Rollen, KanĂ€le, Voice, Invites, Emoji/Sticker, Threads, Guild-Update) - - Mod-Action-Log verknĂŒpft mit Case-System (`logModAction` in `createCase`) - - Ignore-Listen (KanĂ€le, Rollen, Bots) ĂŒber DB - - Log-Kanal-Zuordnung ĂŒber `LogChannel`-Tabelle (Konfiguration kĂŒnftig WebUI) -- **Welcome/Leave** (`apps/bot/src/modules/welcome/`): - - Text-, Embed- und Bild-Karten (Canvas via `@napi-rs/canvas`) - - Platzhalter-System - - Autoroles (User/Bots getrennt) - - Welcome-DM - - `/welcome test`, `/welcome preview` -- **Verifizierung** (`apps/bot/src/modules/verification/`): - - `/verify setup`, `/verify panel` - - Button-Verify und Captcha-Verify (Captcha-Seite unter `/verify/captcha` am Health-Server) - - Mindest-Accountalter, Fehlschlag-Aktionen (Kick/Ban/None) - - Unverified-Rolle bei Join -- Bot-Intents erweitert: - - `GuildMembers`, `GuildMessages`, `MessageContent`, `GuildModeration`, `GuildVoiceStates`, `GuildEmojisAndStickers`, `GuildInvites` -- Neue Env-Variable: `PUBLIC_BASE_URL` (Captcha-Links) -- Tests: `packages/shared/src/phase2.test.ts` - -### Manuelle Discord-Tests (noch offen) - -- **Privileged Intents** im Developer Portal aktivieren: - - `SERVER MEMBERS INTENT` - - `MESSAGE CONTENT INTENT` -- AutoMod: Spam-/Invite-Testnachrichten senden -- `/automod status` prĂŒfen -- Logging: Log-KanĂ€le per DB/ spĂ€ter WebUI setzen, Events auslösen -- `/welcome preview` und `/welcome test` (Konfiguration zunĂ€chst per DB oder spĂ€ter WebUI) -- `/verify setup` + `/verify panel`, Button- und Captcha-Flow testen -- `PUBLIC_BASE_URL` auf erreichbare URL setzen (fĂŒr Captcha außerhalb localhost) - -### Hinweise - -- Welcome-/Logging-Konfiguration erfolgt in Phase 2 ĂŒber die Datenbank; die WebUI-Editoren kommen in Phase 7. -- Captcha-Seite lĂ€uft am Bot-Health-Port (`HEALTH_PORT`); in Produktion muss `PUBLIC_BASE_URL` darauf zeigen oder ĂŒber Reverse-Proxy geroutet werden. - -## NĂ€chster geplanter Schritt - -- Manuelle Phase-2-Tests auf Test-Server, dann nach Freigabe Phase 3 (Leveling, Economy, Utility, Fun). +# Nexumi – Phasen-Tracking + +Dieses Dokument hĂ€lt den aktuellen Implementierungsstand fest. Es wird bei jedem Arbeitsfortschritt aktualisiert. + +## Assets + +- Bot-Logo vorhanden: `docs/logo/nexumi-logo.svg` + +## Phase 1 – Fundament (Status: abgeschlossen, manuelle Discord-Tests ausstehend) + +### Bereits abgeschlossen + +- Monorepo-Basis mit Workspaces: + - `apps/bot` + - `packages/shared` +- Basis-Tooling: + - TypeScript strict + - ESLint + - Prettier + - Vitest + - Turbo +- Docker-Compose-Grundstack: + - `postgres` mit Healthcheck + - `redis` mit Healthcheck + - `bot` + - `webui`-Scaffold-Service (temporĂ€r) + - `backups` Volume angebunden +- Prisma-Grundmodell inkl. Moderationsdaten: + - `Guild` + - `GuildSettings` + - `User` + - `Case` + - `Warning` + - `ModNote` + - `EscalationRule` +- Bot-Grundarchitektur: + - Sharding-Startpfad + - Command-Registrierung + - Command-Routing + - zentraler Permission-Check + - Basis-i18n in `@nexumi/shared` + - Guild-Locale-Auflösung ĂŒber DB-Settings mit Fallback auf Env-Default + - Health-Endpoint (`/health`) und token-geschĂŒtztes `/metrics`-Scaffold +- BullMQ-Integration: + - Moderation-Queue + - Temp-Ban-Expire-Job + - tĂ€glicher Backup-Job (`pg_dump`) + - Backup-Retention-Cleanup +- Moderation als Referenzmodul (implementiert): + - `/ban`, `/unban`, `/kick`, `/timeout`, `/untimeout` + - `/warn add|list|remove|clear` + - `/warn escalation-set|escalation-list|escalation-remove` + - `/purge` (Filter: User, Bots, Links, Attachments, Regex) + - `/slowmode`, `/lock`, `/unlock` + - `/nick set|reset` + - `/case view|edit|delete` + - `/modnote add|list` + - user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`) + - Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations) + - BestĂ€tigungsflow fĂŒr destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons + - standardisierte Case-Audit-Metadaten ĂŒber `@nexumi/shared` (`buildCaseMetadata`) +- Docker-Build/Runtime-Fixes fĂŒr E2E: + - `.dockerignore` ergĂ€nzt + - Prisma-Migration beim Bot-Start (`prisma migrate deploy`) + - Bot-Healthcheck auf `/health` +- E2E-Verifikation (automatisiert, lokal): + - `docker compose up -d --build` erfolgreich + - Postgres/Redis healthy + - Migration `20260722123000_init` angewendet + - Bot online (`Nexumi#9122`), 13 Slash-Commands registriert + - Bot-Container healthy +- Tests (aktuell vorhanden): + - `packages/shared/src/i18n.test.ts` + - `packages/shared/src/audit.test.ts` + - `apps/bot/src/modules/moderation/duration.test.ts` + +### Manuelle Discord-Tests (noch offen) + +- Bot auf Test-Server einladen (falls noch nicht geschehen) +- Slash-Commands testen: `/ban`, `/warn add`, `/purge`, `/case view` +- BestĂ€tigungsbuttons bei `/ban` und `/purge` prĂŒfen + +## Phase 2 – AutoMod, Logging, Welcome, Verifizierung (Status: implementiert, manuelle Tests ausstehend) + +### Abgeschlossen + +- Prisma-Erweiterung + Migration `20260722140000_phase2_modules`: + - `AutoModConfig`, `AutoModRule` + - `LoggingConfig`, `LogChannel` + - `WelcomeConfig` + - `VerificationConfig` +- Shared-Schemas und Hilfsfunktionen (`packages/shared/src/phase2.ts`) +- **AutoMod** (`apps/bot/src/modules/automod/`): + - Filter: Spam, Massen-Mentions, Caps, Invite-Links, externe Links, Wortfilter, Duplikate, Emoji-Spam, Zalgo, Phishing-Blockliste + - Standardregeln werden pro Guild beim ersten Zugriff angelegt + - Anti-Raid (Join-Rate → Lockdown) + - Anti-Nuke (Massen-Bans/Channel-Löschungen → Rechteentzug) + - `/automod status` + - Phishing-Listen-Refresh via BullMQ (alle 6h + beim Start) +- **Logging** (`apps/bot/src/modules/logging/`): + - Event-Handler fĂŒr alle SPEC-Events (Nachrichten, Member, Bans, Rollen, KanĂ€le, Voice, Invites, Emoji/Sticker, Threads, Guild-Update) + - Mod-Action-Log verknĂŒpft mit Case-System (`logModAction` in `createCase`) + - Ignore-Listen (KanĂ€le, Rollen, Bots) ĂŒber DB + - Log-Kanal-Zuordnung ĂŒber `LogChannel`-Tabelle (Konfiguration kĂŒnftig WebUI) +- **Welcome/Leave** (`apps/bot/src/modules/welcome/`): + - Text-, Embed- und Bild-Karten (Canvas via `@napi-rs/canvas`) + - Platzhalter-System + - Autoroles (User/Bots getrennt) + - Welcome-DM + - `/welcome test`, `/welcome preview` +- **Verifizierung** (`apps/bot/src/modules/verification/`): + - `/verify setup`, `/verify panel` + - Button-Verify und Captcha-Verify (Captcha-Seite unter `/verify/captcha` am Health-Server) + - Mindest-Accountalter, Fehlschlag-Aktionen (Kick/Ban/None) + - Unverified-Rolle bei Join +- Bot-Intents erweitert: + - `GuildMembers`, `GuildMessages`, `MessageContent`, `GuildModeration`, `GuildVoiceStates`, `GuildEmojisAndStickers`, `GuildInvites` +- Neue Env-Variable: `PUBLIC_BASE_URL` (Captcha-Links) +- Tests: `packages/shared/src/phase2.test.ts` + +### Manuelle Discord-Tests (noch offen) + +- **Privileged Intents** im Developer Portal aktivieren: + - `SERVER MEMBERS INTENT` + - `MESSAGE CONTENT INTENT` +- AutoMod: Spam-/Invite-Testnachrichten senden +- `/automod status` prĂŒfen +- Logging: Log-KanĂ€le per DB/ spĂ€ter WebUI setzen, Events auslösen +- `/welcome preview` und `/welcome test` (Konfiguration zunĂ€chst per DB oder spĂ€ter WebUI) +- `/verify setup` + `/verify panel`, Button- und Captcha-Flow testen +- `PUBLIC_BASE_URL` auf erreichbare URL setzen (fĂŒr Captcha außerhalb localhost) + +### Hinweise + +- Welcome-/Logging-Konfiguration erfolgt in Phase 2 ĂŒber die Datenbank; die WebUI-Editoren kommen in Phase 7. +- Captcha-Seite lĂ€uft am Bot-Health-Port (`HEALTH_PORT`); in Produktion muss `PUBLIC_BASE_URL` darauf zeigen oder ĂŒber Reverse-Proxy geroutet werden. + +## Phase 3 – Leveling, Economy, Utility, Fun (Status: in Arbeit) + +### Utility (`apps/bot/src/modules/utility/`) – implementiert + +- Info-Commands: `/userinfo`, `/serverinfo`, `/roleinfo`, `/channelinfo`, `/avatar`, `/banner` +- `/poll create` mit Buttons, DB (`Poll`/`PollVote`), optionaler Ablauf via BullMQ (`pollClose`) +- `/remindme`, `/reminders list|delete` mit BullMQ (`reminderSend`) +- `/afk set` mit Auto-Clear und Mention-Antwort +- `/emoji add|remove|steal`, `/sticker add` +- `/timestamp`, Context-Menu **Translate** (MyMemory API) +- `/snipe`, `/editsnipe` (Redis, TTL 1 h, Datenschutz-Hinweis) +- `/embed builder` (Modal) +- Queue: `reminderQueue` in `queues.ts`, Worker in `jobs.ts` +- i18n-Keys in `@nexumi/shared` (de/en) + `command-locales.ts` +- **Manuelles Wiring durch Parent noch nötig** (siehe Modul-README / Agent-Anweisung): Commands, Context-MenĂŒs, Events, Interaction-Handler in `index.ts`/`commands.ts` + +### Fun & Games (`apps/bot/src/modules/fun/`) – implementiert + +- Einfache Commands: `/8ball`, `/dice`, `/flip` (nicht `/coinflip` – Economy), `/rps`, `/choose` +- Button-Spiele: `/trivia`, `/tictactoe`, `/connect4`, `/hangman` (Redis-Spielzustand, TTL) +- Medien: `/meme`, `/cat`, `/dog` (API-Fetch; abschaltbar via Redis `fun:config:{guildId}` → `{mediaEnabled}`) +- Exports fĂŒr Wiring: `funCommands`, `isFunButton`, `handleFunButton` aus `index.ts` +- i18n-Keys + Command-Locales in `@nexumi/shared` (de/en) +- **Manuelles Wiring durch Parent noch nötig**: `funCommands` in `commands.ts`, `isFunButton`/`handleFunButton` in `index.ts` + +### Manuelle Discord-Tests Fun (offen) + +- `/flip` vs. Economy `/coinflip` (kein Namenskonflikt) +- Trivia: nur Initiator kann antworten, Ablauf nach 2 min +- Tic-Tac-Toe / Connect 4: ZĂŒge nur fĂŒr Teilnehmer, ephemerale Fehler +- Hangman: Buchstaben-Buttons, Gewinn/Verlust +- `/meme`, `/cat`, `/dog`; Medien deaktivieren via Redis-Config testen + +### Manuelle Discord-Tests Utility (offen) + +- Poll mit/ohne Dauer, Mehrfachauswahl, anonym +- Reminder einmalig und mit Cron +- AFK setzen, erwĂ€hnen, zurĂŒckschreiben +- Snipe nach Löschen/Bearbeiten +- Embed-Builder-Modal +- Translate Context-Menu auf Nachricht + +## NĂ€chster geplanter Schritt + +- Utility- und Fun-Wiring abschließen, dann Leveling/Economy/Fun manuell testen; Phase-3-Freigabe nach grĂŒnem Build/Lint/Tests. diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index 0be9c46..985c990 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -274,6 +274,414 @@ export const commandLocales = { 'verify.panel.options.channel': { de: 'Optionaler Kanal fĂŒr das Panel', en: 'Optional channel for the panel' + }, + 'leveling.rank.description': { + de: 'Rangkarte eines Mitglieds anzeigen', + en: 'Show a member rank card' + }, + 'leveling.rank.options.user': { + de: 'Mitglied (Standard: du)', + en: 'Member (defaults to you)' + }, + 'leveling.leaderboard.description': { + de: 'Top 10 Mitglieder nach XP', + en: 'Top 10 members by XP' + }, + 'leveling.xp.description': { + de: 'XP verwalten (Admin)', + en: 'Manage XP (admin)' + }, + 'leveling.xp.give.description': { + de: 'XP vergeben', + en: 'Give XP' + }, + 'leveling.xp.remove.description': { + de: 'XP entfernen', + en: 'Remove XP' + }, + 'leveling.xp.reset.description': { + de: 'XP zurĂŒcksetzen', + en: 'Reset XP' + }, + 'leveling.xp.options.user': { + de: 'Zielmitglied', + en: 'Target member' + }, + 'leveling.xp.options.amount': { + de: 'XP-Betrag', + en: 'XP amount' + }, + 'economy.balance.description': { + de: 'Kontostand anzeigen', + en: 'Show balance' + }, + 'economy.daily.description': { + de: 'TĂ€gliche Belohnung abholen', + en: 'Claim daily reward' + }, + 'economy.weekly.description': { + de: 'Wöchentliche Belohnung abholen', + en: 'Claim weekly reward' + }, + 'economy.work.description': { + de: 'Arbeiten und Geld verdienen', + en: 'Work to earn money' + }, + 'economy.pay.description': { + de: 'Geld an einen Nutzer senden', + en: 'Send money to a user' + }, + 'economy.gamble.description': { + de: '50/50 um deinen Einsatz spielen', + en: '50/50 gamble your bet' + }, + 'economy.slots.description': { + de: 'Spielautomaten spielen', + en: 'Play the slot machine' + }, + 'economy.blackjack.description': { + de: 'Blackjack spielen', + en: 'Play blackjack' + }, + 'economy.coinflip.description': { + de: 'Kopf oder Zahl um Geld wetten', + en: 'Bet on heads or tails' + }, + 'economy.shop.description': { + de: 'Server-Shop', + en: 'Server shop' + }, + 'economy.shop.view.description': { + de: 'Shop-Artikel anzeigen', + en: 'View shop items' + }, + 'economy.shop.buy.description': { + de: 'Artikel kaufen', + en: 'Buy an item' + }, + 'economy.shop.options.item_id': { + de: 'Artikel-ID aus /shop view', + en: 'Item ID from /shop view' + }, + 'economy.inventory.description': { + de: 'Dein Inventar anzeigen', + en: 'Show your inventory' + }, + 'economy.eco.description': { + de: 'Economy-Verwaltung', + en: 'Economy management' + }, + 'economy.eco.leaderboard.description': { + de: 'Reichsten Nutzer anzeigen', + en: 'Show richest users' + }, + 'economy.eco.give.description': { + de: 'Geld vergeben (Admin)', + en: 'Give money (admin)' + }, + 'economy.eco.remove.description': { + de: 'Geld entfernen (Admin)', + en: 'Remove money (admin)' + }, + 'economy.eco.reset.description': { + de: 'Nutzer-Economy zurĂŒcksetzen (Admin)', + en: 'Reset user economy (admin)' + }, + 'economy.common.options.amount': { + de: 'Betrag', + en: 'Amount' + }, + 'economy.common.options.user': { + de: 'Nutzer', + en: 'User' + }, + 'economy.common.options.userOptional': { + de: 'Optionaler Nutzer (Standard: du)', + en: 'Optional user (defaults to you)' + }, + 'economy.common.options.target': { + de: 'Zielnutzer', + en: 'Target user' + }, + 'economy.coinflip.options.choice': { + de: 'Kopf oder Zahl', + en: 'Heads or tails' + }, + 'economy.choice.heads': { + de: 'Kopf', + en: 'Heads' + }, + 'economy.choice.tails': { + de: 'Zahl', + en: 'Tails' + }, + 'utility.userinfo.description': { + de: 'Nutzerinformationen anzeigen', + en: 'Show user information' + }, + 'utility.serverinfo.description': { + de: 'Serverinformationen anzeigen', + en: 'Show server information' + }, + 'utility.roleinfo.description': { + de: 'Rolleninformationen anzeigen', + en: 'Show role information' + }, + 'utility.roleinfo.options.role': { + de: 'Rolle', + en: 'Role' + }, + 'utility.channelinfo.description': { + de: 'Kanalinformationen anzeigen', + en: 'Show channel information' + }, + 'utility.avatar.description': { + de: 'Avatar anzeigen', + en: 'Show avatar' + }, + 'utility.banner.description': { + de: 'Banner anzeigen', + en: 'Show banner' + }, + 'utility.common.options.userOptional': { + de: 'Optionaler Nutzer (Standard: du)', + en: 'Optional user (defaults to you)' + }, + 'utility.common.options.channelOptional': { + de: 'Optionaler Kanal (Standard: aktueller Kanal)', + en: 'Optional channel (defaults to current)' + }, + 'utility.common.options.attachment': { + de: 'Bild-Anhang', + en: 'Image attachment' + }, + 'utility.common.options.url': { + de: 'Bild-URL', + en: 'Image URL' + }, + 'utility.common.options.name': { + de: 'Name', + en: 'Name' + }, + 'utility.poll.description': { + de: 'Umfragen erstellen', + en: 'Create polls' + }, + 'utility.poll.create.description': { + de: 'Neue Umfrage erstellen', + en: 'Create a new poll' + }, + 'utility.poll.options.question': { + de: 'Frage', + en: 'Question' + }, + 'utility.poll.options.options': { + de: 'Optionen (kommagetrennt, 2–5)', + en: 'Options (comma-separated, 2–5)' + }, + 'utility.poll.options.multiselect': { + de: 'Mehrfachauswahl erlauben', + en: 'Allow multi-select' + }, + 'utility.poll.options.anonymous': { + de: 'Anonyme Abstimmung', + en: 'Anonymous voting' + }, + 'utility.poll.options.duration': { + de: 'Optionale Dauer (z. B. 1h)', + en: 'Optional duration (e.g. 1h)' + }, + 'utility.remindme.description': { + de: 'Erinnerung setzen', + en: 'Set a reminder' + }, + 'utility.remindme.options.when': { + de: 'Wann (z. B. 30m oder ISO-Datum)', + en: 'When (e.g. 30m or ISO datetime)' + }, + 'utility.remindme.options.content': { + de: 'Erinnerungstext', + en: 'Reminder content' + }, + 'utility.remindme.options.recurring_cron': { + de: 'Optional: Cron fĂŒr wiederkehrende Erinnerung', + en: 'Optional cron for recurring reminder' + }, + 'utility.reminders.description': { + de: 'Erinnerungen verwalten', + en: 'Manage reminders' + }, + 'utility.reminders.list.description': { + de: 'Erinnerungen auflisten', + en: 'List reminders' + }, + 'utility.reminders.delete.description': { + de: 'Erinnerung löschen', + en: 'Delete a reminder' + }, + 'utility.reminders.options.id': { + de: 'Erinnerungs-ID (PrĂ€fix reicht)', + en: 'Reminder ID (prefix is enough)' + }, + 'utility.afk.description': { + de: 'AFK-Status setzen', + en: 'Set AFK status' + }, + 'utility.afk.set.description': { + de: 'Als AFK markieren', + en: 'Mark as AFK' + }, + 'utility.afk.options.reason': { + de: 'Optionaler AFK-Grund', + en: 'Optional AFK reason' + }, + 'utility.emoji.description': { + de: 'Emojis verwalten', + en: 'Manage emojis' + }, + 'utility.emoji.add.description': { + de: 'Emoji hinzufĂŒgen', + en: 'Add emoji' + }, + 'utility.emoji.remove.description': { + de: 'Emoji entfernen', + en: 'Remove emoji' + }, + 'utility.emoji.steal.description': { + de: 'Emoji aus Nachricht ĂŒbernehmen', + en: 'Steal emoji from message' + }, + 'utility.emoji.options.emoji': { + de: 'Emoji', + en: 'Emoji' + }, + 'utility.emoji.options.message_id': { + de: 'Nachrichten-ID', + en: 'Message ID' + }, + 'utility.emoji.options.url': { + de: 'Emoji-URL', + en: 'Emoji URL' + }, + 'utility.sticker.description': { + de: 'Sticker verwalten', + en: 'Manage stickers' + }, + 'utility.sticker.add.description': { + de: 'Sticker hinzufĂŒgen', + en: 'Add sticker' + }, + 'utility.sticker.options.url': { + de: 'Sticker-URL', + en: 'Sticker URL' + }, + 'utility.timestamp.description': { + de: 'Discord-Timestamp-Formate generieren', + en: 'Generate Discord timestamp formats' + }, + 'utility.timestamp.options.datetime': { + de: 'Datum/Uhrzeit (ISO oder relative Dauer)', + en: 'Datetime (ISO or relative duration)' + }, + 'utility.snipe.description': { + de: 'Zuletzt gelöschte Nachricht anzeigen', + en: 'Show last deleted message' + }, + 'utility.editsnipe.description': { + de: 'Zuletzt bearbeitete Nachricht anzeigen', + en: 'Show last edited message' + }, + 'utility.embed.description': { + de: 'Embeds erstellen', + en: 'Create embeds' + }, + 'utility.embed.builder.description': { + de: 'Embed per Modal bauen und senden', + en: 'Build and send an embed via modal' + }, + 'fun.8ball.description': { + de: 'Magische 8-Ball-Antwort auf deine Frage', + en: 'Magic 8-ball answer to your question' + }, + 'fun.8ball.options.question': { + de: 'Deine Frage', + en: 'Your question' + }, + 'fun.dice.description': { + de: 'WĂŒrfel werfen', + en: 'Roll dice' + }, + 'fun.dice.options.sides': { + de: 'Anzahl Seiten (Standard: 6)', + en: 'Number of sides (default: 6)' + }, + 'fun.dice.options.count': { + de: 'Anzahl WĂŒrfe (Standard: 1, max. 10)', + en: 'Number of rolls (default: 1, max 10)' + }, + 'fun.flip.description': { + de: 'Kostenloser MĂŒnzwurf (Kopf oder Zahl)', + en: 'Free coin flip (heads or tails)' + }, + 'fun.rps.description': { + de: 'Schere, Stein, Papier gegen den Bot', + en: 'Rock, paper, scissors against the bot' + }, + 'fun.rps.options.choice': { + de: 'Deine Wahl', + en: 'Your choice' + }, + 'fun.rps.choice.rock': { + de: 'Stein', + en: 'Rock' + }, + 'fun.rps.choice.paper': { + de: 'Papier', + en: 'Paper' + }, + 'fun.rps.choice.scissors': { + de: 'Schere', + en: 'Scissors' + }, + 'fun.choose.description': { + de: 'ZufĂ€llige Auswahl aus Optionen', + en: 'Random choice from options' + }, + 'fun.choose.options.options': { + de: 'Optionen (kommagetrennt)', + en: 'Options (comma-separated)' + }, + 'fun.trivia.description': { + de: 'Trivia-Frage mit Multiple Choice', + en: 'Trivia question with multiple choice' + }, + 'fun.tictactoe.description': { + de: 'Tic-Tac-Toe gegen einen anderen Nutzer', + en: 'Play Tic-Tac-Toe against another user' + }, + 'fun.connect4.description': { + de: 'Connect 4 gegen einen anderen Nutzer', + en: 'Play Connect 4 against another user' + }, + 'fun.hangman.description': { + de: 'Hangman mit Buchstaben-Buttons', + en: 'Hangman with letter buttons' + }, + 'fun.meme.description': { + de: 'ZufĂ€lliges Meme laden', + en: 'Fetch a random meme' + }, + 'fun.cat.description': { + de: 'ZufĂ€lliges Katzenbild laden', + en: 'Fetch a random cat image' + }, + 'fun.dog.description': { + de: 'ZufĂ€lliges Hundebild laden', + en: 'Fetch a random dog image' + }, + 'fun.common.options.opponent': { + de: 'Gegner', + en: 'Opponent' } } as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index aabe670..c564baf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './audit.js'; export * from './command-locales.js'; export * from './discord-localization.js'; export * from './phase2.js'; +export * from './phase3.js'; export const LocaleSchema = z.enum(['de', 'en']); export type Locale = z.infer; @@ -92,7 +93,152 @@ const de: Dictionary = { 'verification.error.generic': 'Verifizierung fehlgeschlagen.', 'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.', 'verification.captcha.invalid': 'Captcha ungĂŒltig oder abgelaufen.', - 'verification.captcha.title': 'Nexumi Verifizierung' + 'verification.captcha.title': 'Nexumi Verifizierung', + 'leveling.disabled': 'Leveling ist auf diesem Server deaktiviert.', + 'leveling.rank.title': 'Rangkarte', + 'leveling.leaderboard.title': 'Leaderboard', + 'leveling.leaderboard.empty': 'Noch keine XP-Daten vorhanden.', + 'leveling.leaderboard.entry': '{rank}. {user} — Level {level} ({xp} XP)', + 'leveling.leaderboard.footer': 'Top {count} Mitglieder', + 'leveling.levelUp.default': 'GlĂŒckwunsch {user}, du hast Level {level} erreicht!', + 'leveling.xp.give.success': '{amount} XP an {user} vergeben. Neues Level: {level}.', + 'leveling.xp.remove.success': '{amount} XP von {user} entfernt. Neues Level: {level}.', + 'leveling.xp.reset.success': 'XP von {user} zurĂŒckgesetzt.', + 'leveling.xp.invalidAmount': 'Der Betrag muss grĂ¶ĂŸer als 0 sein.', + 'leveling.user.notFound': 'Nutzer nicht auf diesem Server gefunden.', + 'economy.error.disabled': 'Das Economy-Modul ist auf diesem Server deaktiviert.', + 'economy.error.insufficient': 'Du hast nicht genug Geld.', + 'economy.error.cooldown': 'Cooldown aktiv. Versuche es in {seconds} Sekunden erneut.', + 'economy.error.invalid_amount': 'UngĂŒltiger Betrag.', + 'economy.error.self_transfer': 'Du kannst dir nicht selbst Geld senden.', + 'economy.error.not_found': 'Eintrag nicht gefunden.', + 'economy.error.out_of_stock': 'Dieser Artikel ist ausverkauft.', + 'economy.error.role_assign_failed': 'Kauf erfolgreich, aber die Rolle konnte nicht vergeben werden.', + 'economy.error.game_in_progress': 'Du hast bereits ein laufendes Blackjack-Spiel.', + 'economy.balance.result': 'Kontostand von {user}: {amount}', + 'economy.daily.success': 'TĂ€gliche Belohnung: {amount}. Neuer Kontostand: {balance}', + 'economy.weekly.success': 'Wöchentliche Belohnung: {amount}. Neuer Kontostand: {balance}', + 'economy.work.success': 'Du hast {amount} verdient. Neuer Kontostand: {balance}', + 'economy.pay.success': 'Du hast {amount} an {user} gesendet. Dein Kontostand: {balance}', + 'economy.gamble.win': 'Gewonnen! +{amount}. Kontostand: {balance}', + 'economy.gamble.lose': 'Verloren! -{amount}. Kontostand: {balance}', + 'economy.slots.win': '{reels}\nGewinn: {payout}. Kontostand: {balance}', + 'economy.slots.lose': '{reels}\nKein Gewinn. Kontostand: {balance}', + 'economy.coinflip.win': '{result} – du hattest {choice} gewĂ€hlt. Gewonnen! +{amount}. Kontostand: {balance}', + 'economy.coinflip.lose': '{result} – du hattest {choice} gewĂ€hlt. Verloren! -{amount}. Kontostand: {balance}', + 'economy.choice.heads': 'Kopf', + 'economy.choice.tails': 'Zahl', + 'economy.shop.empty': 'Der Shop ist leer.', + 'economy.shop.noDescription': 'Keine Beschreibung', + 'economy.shop.itemLine': '**{name}** (`{id}`) – {price} | Bestand: {stock}\n{description}', + 'economy.shop.purchased': 'Du hast **{name}** gekauft. Kontostand: {balance}', + 'economy.inventory.empty': 'Dein Inventar ist leer.', + 'economy.inventory.itemLine': '- {name} × {quantity}', + 'economy.leaderboard.empty': 'Noch keine EintrĂ€ge auf der Bestenliste.', + 'economy.leaderboard.line': '{rank}. {user} – {amount}', + 'economy.admin.give': '{amount} an {user} vergeben. Neuer Kontostand: {balance}', + 'economy.admin.remove': '{amount} von {user} entfernt. Neuer Kontostand: {balance}', + 'economy.admin.reset': 'Economy-Daten von {user} zurĂŒckgesetzt.', + 'economy.blackjack.title': 'Blackjack', + 'economy.blackjack.hand': 'Einsatz: {bet}\n\n**Du:** {playerHand} ({playerTotal})\n**Dealer:** {dealerHand} ({dealerTotal})', + 'economy.blackjack.button.hit': 'Hit', + 'economy.blackjack.button.stand': 'Stand', + 'economy.blackjack.outcome.win': 'Du gewinnst {bet}!', + 'economy.blackjack.outcome.lose': 'Du verlierst deinen Einsatz.', + 'economy.blackjack.outcome.push': 'Unentschieden – Einsatz zurĂŒck.', + 'economy.blackjack.outcome.blackjack': 'Blackjack! Du gewinnst {bet}!', + 'economy.blackjack.expired': 'Dieses Blackjack-Spiel ist abgelaufen oder existiert nicht mehr.', + 'economy.blackjack.notOwner': 'Nur der Spieler kann diese Buttons nutzen.', + 'utility.error.invalid_when': 'UngĂŒltiger Zeitpunkt. Nutze z. B. 30m oder ein ISO-Datum in der Zukunft.', + 'utility.error.invalid_datetime': 'UngĂŒltiges Datum/Uhrzeit.', + 'utility.error.invalid_poll_options': 'Es werden 2–5 Optionen benötigt (kommagetrennt).', + 'utility.error.channel_not_found': 'Kanal nicht gefunden.', + 'utility.error.role_not_found': 'Rolle nicht gefunden.', + 'utility.error.poll_not_found': 'Umfrage nicht gefunden.', + 'utility.error.reminder_channel_unavailable': 'Erinnerungskanal nicht erreichbar.', + 'utility.poll.created': 'Umfrage erstellt.', + 'utility.poll.closed': 'Diese Umfrage ist geschlossen.', + 'utility.poll.notFound': 'Umfrage nicht gefunden.', + 'utility.poll.invalidOption': 'UngĂŒltige Option.', + 'utility.poll.alreadyVoted': 'Du hast bereits abgestimmt.', + 'utility.poll.voted': 'Stimme fĂŒr **{option}** gezĂ€hlt.', + 'utility.poll.votes': 'Stimmen', + 'utility.poll.endsAt': 'Endet {time}', + 'utility.poll.multiSelect': 'Mehrfachauswahl', + 'utility.poll.multiSelectHint': 'Du kannst mehrere Optionen wĂ€hlen.', + 'utility.remindme.created': 'Erinnerung erstellt (ID: `{id}`).', + 'utility.reminders.none': 'Keine Erinnerungen.', + 'utility.reminders.line': '`{id}` — {time}: {content}', + 'utility.reminders.deleted': 'Erinnerung gelöscht.', + 'utility.reminders.notFound': 'Erinnerung nicht gefunden.', + 'utility.afk.set': 'AFK gesetzt. Grund: {reason}', + 'utility.afk.welcomeBack': 'Willkommen zurĂŒck! Du warst AFK: {reason}', + 'utility.afk.mention': '{user} ist AFK: {reason}', + 'utility.avatar.title': 'Avatar von {user}', + 'utility.banner.title': 'Banner von {user}', + 'utility.banner.none': 'Kein Banner gesetzt.', + 'utility.emoji.noSource': 'Anhang oder URL erforderlich.', + 'utility.emoji.added': 'Emoji {emoji} hinzugefĂŒgt.', + 'utility.emoji.removed': 'Emoji entfernt.', + 'utility.emoji.notFound': 'Emoji nicht gefunden.', + 'utility.emoji.messageNotFound': 'Nachricht nicht gefunden.', + 'utility.emoji.noEmojiInMessage': 'Kein Custom-Emoji in der Nachricht.', + 'utility.emoji.stolen': 'Emoji {emoji} gestohlen.', + 'utility.sticker.noSource': 'Anhang oder URL erforderlich.', + 'utility.sticker.added': 'Sticker **{name}** hinzugefĂŒgt.', + 'utility.embed.sent': 'Embed gesendet.', + 'utility.embed.invalidColor': 'UngĂŒltige Farbe. Nutze z. B. 6366F1.', + 'utility.snipe.none': 'Nichts zum Anzeigen.', + 'utility.snipe.privacy': + 'Datenschutz: Snipe speichert gelöschte/bearbeitete Nachrichten kurzzeitig (1 h) in Redis.', + 'utility.snipe.deletedAt': 'Gelöschte Nachricht von {user}', + 'utility.snipe.editedAt': 'Bearbeitete Nachricht von {user}', + 'utility.translate.noContent': 'Kein ĂŒbersetzbare Textinhalt.', + 'utility.translate.failed': 'Übersetzung fehlgeschlagen.', + 'utility.translate.result': '**Übersetzung:** {text}', + 'fun.8ball.result': '**Frage:** {question}\n**Antwort:** {answer}', + 'fun.dice.result': 'WĂŒrfel ({sides} Seiten): {rolls}', + 'fun.flip.result': 'Die MĂŒnze zeigt: **{result}**', + 'fun.rps.win': 'Du wĂ€hltest **{player}**, ich **{bot}**. Du gewinnst!', + 'fun.rps.lose': 'Du wĂ€hltest **{player}**, ich **{bot}**. Du verlierst!', + 'fun.rps.draw': 'Beide wĂ€hlten **{player}**. Unentschieden!', + 'fun.rps.choice.rock': 'Stein', + 'fun.rps.choice.paper': 'Papier', + 'fun.rps.choice.scissors': 'Schere', + 'fun.choose.result': 'Ich wĂ€hle: **{choice}**', + 'fun.choose.tooFew': 'Gib mindestens zwei Optionen an (kommagetrennt).', + 'fun.trivia.title': 'Trivia', + 'fun.trivia.question': '**{question}**\n\n{options}', + 'fun.trivia.footer': 'Nur {user} kann antworten.', + 'fun.trivia.correct': '✅ Richtig!', + 'fun.trivia.incorrect': '❌ Falsch!', + 'fun.trivia.notOwner': 'Nur der Initiator kann diese Frage beantworten.', + 'fun.tictactoe.title': 'Tic-Tac-Toe', + 'fun.tictactoe.board': '❌: {x} | ⭕: {o}\n\n{board}', + 'fun.tictactoe.challenge': '{challenger} fordert {opponent} zu Tic-Tac-Toe heraus!', + 'fun.tictactoe.turn': 'Am Zug: {user}', + 'fun.tictactoe.won': '{user} gewinnt!', + 'fun.tictactoe.draw': 'Unentschieden!', + 'fun.tictactoe.invalidMove': 'UngĂŒltiger Zug.', + 'fun.connect4.title': 'Connect 4', + 'fun.connect4.board': '🔮: {red} | 🟡: {yellow}\n\n{board}', + 'fun.connect4.challenge': '{challenger} fordert {opponent} zu Connect 4 heraus!', + 'fun.connect4.turn': 'Am Zug: {user}', + 'fun.connect4.won': '{user} gewinnt!', + 'fun.connect4.draw': 'Unentschieden!', + 'fun.connect4.invalidMove': 'Diese Spalte ist voll.', + 'fun.hangman.title': 'Hangman', + 'fun.hangman.progress': 'Wort: {word}\nFehler: {wrong}/{max}\nGeraten: {guessed}', + 'fun.hangman.won': 'Gewonnen! Das Wort war **{word}**.', + 'fun.hangman.lost': 'Verloren! Das Wort war **{word}**.', + 'fun.common.notParticipant': 'Du bist nicht an diesem Spiel beteiligt.', + 'fun.common.expired': 'Dieses Spiel ist abgelaufen oder existiert nicht mehr.', + 'fun.common.selfChallenge': 'Du kannst dich nicht selbst herausfordern.', + 'fun.common.botChallenge': 'Du kannst keinen Bot herausfordern.', + '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' }; const en: Dictionary = { 'generic.noPermission': 'You do not have permission for this action.', @@ -148,7 +294,152 @@ const en: Dictionary = { 'verification.error.generic': 'Verification failed.', 'verification.captcha.success': 'Captcha passed. You have been verified.', 'verification.captcha.invalid': 'Captcha invalid or expired.', - 'verification.captcha.title': 'Nexumi Verification' + 'verification.captcha.title': 'Nexumi Verification', + 'leveling.disabled': 'Leveling is disabled on this server.', + 'leveling.rank.title': 'Rank card', + 'leveling.leaderboard.title': 'Leaderboard', + 'leveling.leaderboard.empty': 'No XP data yet.', + 'leveling.leaderboard.entry': '{rank}. {user} — Level {level} ({xp} XP)', + 'leveling.leaderboard.footer': 'Top {count} members', + 'leveling.levelUp.default': 'Congratulations {user}, you reached level {level}!', + 'leveling.xp.give.success': 'Gave {amount} XP to {user}. New level: {level}.', + 'leveling.xp.remove.success': 'Removed {amount} XP from {user}. New level: {level}.', + 'leveling.xp.reset.success': 'Reset XP for {user}.', + 'leveling.xp.invalidAmount': 'Amount must be greater than 0.', + 'leveling.user.notFound': 'User not found on this server.', + 'economy.error.disabled': 'The economy module is disabled on this server.', + 'economy.error.insufficient': 'You do not have enough money.', + 'economy.error.cooldown': 'Cooldown active. Try again in {seconds} seconds.', + 'economy.error.invalid_amount': 'Invalid amount.', + 'economy.error.self_transfer': 'You cannot send money to yourself.', + 'economy.error.not_found': 'Entry not found.', + 'economy.error.out_of_stock': 'This item is out of stock.', + 'economy.error.role_assign_failed': 'Purchase succeeded, but the role could not be assigned.', + 'economy.error.game_in_progress': 'You already have an active blackjack game.', + 'economy.balance.result': 'Balance of {user}: {amount}', + 'economy.daily.success': 'Daily reward: {amount}. New balance: {balance}', + 'economy.weekly.success': 'Weekly reward: {amount}. New balance: {balance}', + 'economy.work.success': 'You earned {amount}. New balance: {balance}', + 'economy.pay.success': 'You sent {amount} to {user}. Your balance: {balance}', + 'economy.gamble.win': 'You won! +{amount}. Balance: {balance}', + 'economy.gamble.lose': 'You lost! -{amount}. Balance: {balance}', + 'economy.slots.win': '{reels}\nWinnings: {payout}. Balance: {balance}', + 'economy.slots.lose': '{reels}\nNo win. Balance: {balance}', + 'economy.coinflip.win': '{result} – you picked {choice}. You won! +{amount}. Balance: {balance}', + 'economy.coinflip.lose': '{result} – you picked {choice}. You lost! -{amount}. Balance: {balance}', + 'economy.choice.heads': 'Heads', + 'economy.choice.tails': 'Tails', + 'economy.shop.empty': 'The shop is empty.', + 'economy.shop.noDescription': 'No description', + 'economy.shop.itemLine': '**{name}** (`{id}`) – {price} | Stock: {stock}\n{description}', + 'economy.shop.purchased': 'You purchased **{name}**. Balance: {balance}', + 'economy.inventory.empty': 'Your inventory is empty.', + 'economy.inventory.itemLine': '- {name} × {quantity}', + 'economy.leaderboard.empty': 'No leaderboard entries yet.', + 'economy.leaderboard.line': '{rank}. {user} – {amount}', + 'economy.admin.give': 'Gave {amount} to {user}. New balance: {balance}', + 'economy.admin.remove': 'Removed {amount} from {user}. New balance: {balance}', + 'economy.admin.reset': 'Reset economy data for {user}.', + 'economy.blackjack.title': 'Blackjack', + 'economy.blackjack.hand': 'Bet: {bet}\n\n**You:** {playerHand} ({playerTotal})\n**Dealer:** {dealerHand} ({dealerTotal})', + 'economy.blackjack.button.hit': 'Hit', + 'economy.blackjack.button.stand': 'Stand', + 'economy.blackjack.outcome.win': 'You win {bet}!', + 'economy.blackjack.outcome.lose': 'You lose your bet.', + 'economy.blackjack.outcome.push': 'Push – bet returned.', + 'economy.blackjack.outcome.blackjack': 'Blackjack! You win {bet}!', + 'economy.blackjack.expired': 'This blackjack game expired or no longer exists.', + 'economy.blackjack.notOwner': 'Only the player can use these buttons.', + 'utility.error.invalid_when': 'Invalid time. Use e.g. 30m or a future ISO datetime.', + 'utility.error.invalid_datetime': 'Invalid date/time.', + 'utility.error.invalid_poll_options': 'Provide 2–5 comma-separated options.', + 'utility.error.channel_not_found': 'Channel not found.', + 'utility.error.role_not_found': 'Role not found.', + 'utility.error.poll_not_found': 'Poll not found.', + 'utility.error.reminder_channel_unavailable': 'Reminder channel unavailable.', + 'utility.poll.created': 'Poll created.', + 'utility.poll.closed': 'This poll is closed.', + 'utility.poll.notFound': 'Poll not found.', + 'utility.poll.invalidOption': 'Invalid option.', + 'utility.poll.alreadyVoted': 'You already voted.', + 'utility.poll.voted': 'Vote counted for **{option}**.', + 'utility.poll.votes': 'votes', + 'utility.poll.endsAt': 'Ends {time}', + 'utility.poll.multiSelect': 'Multi-select', + 'utility.poll.multiSelectHint': 'You may select multiple options.', + 'utility.remindme.created': 'Reminder created (ID: `{id}`).', + 'utility.reminders.none': 'No reminders.', + 'utility.reminders.line': '`{id}` — {time}: {content}', + 'utility.reminders.deleted': 'Reminder deleted.', + 'utility.reminders.notFound': 'Reminder not found.', + 'utility.afk.set': 'AFK set. Reason: {reason}', + 'utility.afk.welcomeBack': 'Welcome back! You were AFK: {reason}', + 'utility.afk.mention': '{user} is AFK: {reason}', + 'utility.avatar.title': 'Avatar of {user}', + 'utility.banner.title': 'Banner of {user}', + 'utility.banner.none': 'No banner set.', + 'utility.emoji.noSource': 'Attachment or URL required.', + 'utility.emoji.added': 'Added emoji {emoji}.', + 'utility.emoji.removed': 'Emoji removed.', + 'utility.emoji.notFound': 'Emoji not found.', + 'utility.emoji.messageNotFound': 'Message not found.', + 'utility.emoji.noEmojiInMessage': 'No custom emoji in message.', + 'utility.emoji.stolen': 'Stole emoji {emoji}.', + 'utility.sticker.noSource': 'Attachment or URL required.', + 'utility.sticker.added': 'Added sticker **{name}**.', + 'utility.embed.sent': 'Embed sent.', + 'utility.embed.invalidColor': 'Invalid color. Use e.g. 6366F1.', + 'utility.snipe.none': 'Nothing to show.', + 'utility.snipe.privacy': + 'Privacy: Snipe stores deleted/edited messages briefly (1 h) in Redis.', + 'utility.snipe.deletedAt': 'Deleted message by {user}', + 'utility.snipe.editedAt': 'Edited message by {user}', + 'utility.translate.noContent': 'No translatable text content.', + 'utility.translate.failed': 'Translation failed.', + 'utility.translate.result': '**Translation:** {text}', + 'fun.8ball.result': '**Question:** {question}\n**Answer:** {answer}', + 'fun.dice.result': 'Dice ({sides} sides): {rolls}', + 'fun.flip.result': 'The coin shows: **{result}**', + 'fun.rps.win': 'You picked **{player}**, I picked **{bot}**. You win!', + 'fun.rps.lose': 'You picked **{player}**, I picked **{bot}**. You lose!', + 'fun.rps.draw': 'Both picked **{player}**. Draw!', + 'fun.rps.choice.rock': 'Rock', + 'fun.rps.choice.paper': 'Paper', + 'fun.rps.choice.scissors': 'Scissors', + 'fun.choose.result': 'I choose: **{choice}**', + 'fun.choose.tooFew': 'Provide at least two comma-separated options.', + 'fun.trivia.title': 'Trivia', + 'fun.trivia.question': '**{question}**\n\n{options}', + 'fun.trivia.footer': 'Only {user} can answer.', + 'fun.trivia.correct': '✅ Correct!', + 'fun.trivia.incorrect': '❌ Wrong!', + 'fun.trivia.notOwner': 'Only the initiator can answer this question.', + 'fun.tictactoe.title': 'Tic-Tac-Toe', + 'fun.tictactoe.board': '❌: {x} | ⭕: {o}\n\n{board}', + 'fun.tictactoe.challenge': '{challenger} challenges {opponent} to Tic-Tac-Toe!', + 'fun.tictactoe.turn': 'Turn: {user}', + 'fun.tictactoe.won': '{user} wins!', + 'fun.tictactoe.draw': 'Draw!', + 'fun.tictactoe.invalidMove': 'Invalid move.', + 'fun.connect4.title': 'Connect 4', + 'fun.connect4.board': '🔮: {red} | 🟡: {yellow}\n\n{board}', + 'fun.connect4.challenge': '{challenger} challenges {opponent} to Connect 4!', + 'fun.connect4.turn': 'Turn: {user}', + 'fun.connect4.won': '{user} wins!', + 'fun.connect4.draw': 'Draw!', + 'fun.connect4.invalidMove': 'This column is full.', + 'fun.hangman.title': 'Hangman', + 'fun.hangman.progress': 'Word: {word}\nWrong: {wrong}/{max}\nGuessed: {guessed}', + 'fun.hangman.won': 'You won! The word was **{word}**.', + 'fun.hangman.lost': 'You lost! The word was **{word}**.', + 'fun.common.notParticipant': 'You are not a participant in this game.', + 'fun.common.expired': 'This game expired or no longer exists.', + 'fun.common.selfChallenge': 'You cannot challenge yourself.', + 'fun.common.botChallenge': 'You cannot challenge a bot.', + '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' }; const locales: Record = { de, en }; diff --git a/packages/shared/src/phase3.ts b/packages/shared/src/phase3.ts new file mode 100644 index 0000000..56ae373 --- /dev/null +++ b/packages/shared/src/phase3.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +/** XP required to go from level L to L+1. */ +export function xpForNextLevel(level: number): number { + return 5 * level * level + 50 * level + 100; +} + +/** Total XP needed to reach a given level from 0. */ +export function totalXpForLevel(level: number): number { + let total = 0; + for (let i = 0; i < level; i += 1) { + total += xpForNextLevel(i); + } + return total; +} + +/** Derive level from cumulative XP. */ +export function levelFromXp(xp: number): number { + let level = 0; + let remaining = Math.max(0, xp); + while (remaining >= xpForNextLevel(level)) { + remaining -= xpForNextLevel(level); + level += 1; + if (level > 1000) { + break; + } + } + return level; +} + +export function progressInLevel(xp: number): { level: number; intoLevel: number; needed: number } { + const level = levelFromXp(xp); + const base = totalXpForLevel(level); + const intoLevel = xp - base; + const needed = xpForNextLevel(level); + return { level, intoLevel, needed }; +} + +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export const LevelUpModeSchema = z.enum(['CHANNEL', 'DM', 'OFF']); +export type LevelUpMode = z.infer; + +export const MultiplierMapSchema = z.record(z.string(), z.number().positive()); +export type MultiplierMap = z.infer; + +export function parseMultiplierMap(raw: unknown): MultiplierMap { + const parsed = MultiplierMapSchema.safeParse(raw); + return parsed.success ? parsed.data : {}; +} + +export function formatCurrency(amount: number, symbol: string, name: string): string { + return `${symbol} ${amount.toLocaleString()} ${name}`; +} + +export const EIGHT_BALL_ANSWERS = { + de: [ + 'Ja.', + 'Nein.', + 'Vielleicht.', + 'Frag spĂ€ter nochmal.', + 'Sieht gut aus.', + 'Zweifelhaft.', + 'Auf keinen Fall.', + 'Absolut.' + ], + en: [ + 'Yes.', + 'No.', + 'Maybe.', + 'Ask again later.', + 'Looks good.', + 'Doubtful.', + 'Absolutely not.', + 'Absolutely.' + ] +} as const; + +export const TRIVIA_QUESTIONS = [ + { + q: { de: 'Welche Farbe hat der Himmel an einem klaren Tag?', en: 'What color is the sky on a clear day?' }, + options: { de: ['Blau', 'GrĂŒn', 'Rot', 'Gelb'], en: ['Blue', 'Green', 'Red', 'Yellow'] }, + answer: 0 + }, + { + q: { de: 'Wie viele Kontinente gibt es?', en: 'How many continents are there?' }, + options: { de: ['5', '6', '7', '8'], en: ['5', '6', '7', '8'] }, + answer: 2 + }, + { + q: { de: 'Welches Tier ist ein SĂ€ugetier?', en: 'Which animal is a mammal?' }, + options: { de: ['Hai', 'Delphin', 'Thunfisch', 'Lachs'], en: ['Shark', 'Dolphin', 'Tuna', 'Salmon'] }, + answer: 1 + }, + { + q: { de: 'Was ist 2 + 2?', en: 'What is 2 + 2?' }, + options: { de: ['3', '4', '5', '22'], en: ['3', '4', '5', '22'] }, + answer: 1 + }, + { + q: { de: 'Hauptstadt von Frankreich?', en: 'Capital of France?' }, + options: { de: ['Berlin', 'Madrid', 'Paris', 'Rom'], en: ['Berlin', 'Madrid', 'Paris', 'Rome'] }, + answer: 2 + } +] as const; + +export const HANGMAN_WORDS = { + de: ['DISCORD', 'NEXUMI', 'SERVER', 'MODERATION', 'BOT', 'KANAL', 'ROLLE'], + en: ['DISCORD', 'NEXUMI', 'SERVER', 'MODERATION', 'BOT', 'CHANNEL', 'ROLE'] +} as const;