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.
This commit is contained in:
smueller
2026-07-22 12:49:17 +02:00
parent 2518119257
commit fa5910ec43
38 changed files with 6759 additions and 144 deletions

View File

@@ -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;

View File

@@ -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])
}

View File

@@ -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<TempBanJob>(
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<ReminderSendJob | PollCloseJob>(
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<void> {

View File

@@ -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<void> {
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;
}
}

View File

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

View File

@@ -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<string | null> {
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<string, string | number>
): Promise<void> {
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
];

View File

@@ -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<void> {
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<BlackjackState | null> {
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<void> {
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<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>[] }> {
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<ButtonBuilder>[]; 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 };
}

View File

@@ -0,0 +1,2 @@
export { economyCommands } from './commands.js';
export { isBlackjackButton, handleBlackjackButton } from './blackjack-buttons.js';

View File

@@ -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<EconomyConfig> {
await ensureGuild(prisma as PrismaClient, guildId);
return prisma.economyConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function assertEconomyEnabled(prisma: PrismaClient, guildId: string): Promise<EconomyConfig> {
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<MemberEconomy> {
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<number> {
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<MemberEconomy> {
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<MemberEconomy> {
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<number> {
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<number> {
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<number> {
const member = await ensureMemberEconomy(prisma, guildId, userId);
return cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS);
}
export async function listShopItems(prisma: PrismaClient, guildId: string): Promise<ShopItem[]> {
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<MemberEconomy> {
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<MemberEconomy> {
return deductBalance(prisma, guildId, userId, amount);
}
export async function adminResetEconomy(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<void> {
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);
}

View File

@@ -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<void> {
await interaction.reply({ content: t(locale, 'fun.common.notParticipant'), ephemeral: true });
}
async function replyExpired(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise<void> {
await interaction.reply({ content: t(locale, 'fun.common.expired'), ephemeral: true });
}
async function handleTriviaButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}

View File

@@ -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;

View File

@@ -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<string | null> {
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<void> {
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
];

View File

@@ -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<typeof FunConfigSchema>;
export async function getFunConfig(redis: Redis, guildId: string): Promise<FunConfig> {
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<void> {
await redis.set(`${FUN_CONFIG_PREFIX}${guildId}`, JSON.stringify(config));
}

View File

@@ -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<void> {
await redis.set(key, JSON.stringify(value), 'EX', ttl);
}
async function loadJson<T>(redis: BotContext['redis'], key: string): Promise<T | null> {
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<void> {
await saveJson(context.redis, `${TRIVIA_KEY_PREFIX}${session.userId}`, session, TRIVIA_TTL_SECONDS);
}
export async function loadTriviaSession(context: BotContext, userId: string): Promise<TriviaSession | null> {
return loadJson<TriviaSession>(context.redis, `${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function deleteTriviaSession(context: BotContext, userId: string): Promise<void> {
await context.redis.del(`${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function saveTicTacToeState(context: BotContext, state: TicTacToeState): Promise<void> {
await saveJson(context.redis, `${TTT_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadTicTacToeState(context: BotContext, gameId: string): Promise<TicTacToeState | null> {
return loadJson<TicTacToeState>(context.redis, `${TTT_KEY_PREFIX}${gameId}`);
}
export async function deleteTicTacToeState(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${TTT_KEY_PREFIX}${gameId}`);
}
export async function saveConnect4State(context: BotContext, state: Connect4State): Promise<void> {
await saveJson(context.redis, `${C4_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadConnect4State(context: BotContext, gameId: string): Promise<Connect4State | null> {
return loadJson<Connect4State>(context.redis, `${C4_KEY_PREFIX}${gameId}`);
}
export async function deleteConnect4State(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${C4_KEY_PREFIX}${gameId}`);
}
export async function saveHangmanState(context: BotContext, state: HangmanState): Promise<void> {
await saveJson(context.redis, `${HM_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadHangmanState(context: BotContext, gameId: string): Promise<HangmanState | null> {
return loadJson<HangmanState>(context.redis, `${HM_KEY_PREFIX}${gameId}`);
}
export async function deleteHangmanState(context: BotContext, gameId: string): Promise<void> {
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<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let row = 0; row < 3; row += 1) {
const builder = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const row = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const { letters, page, totalPages } = hangmanLettersForPage(state);
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let i = 0; i < letters.length; i += 5) {
const chunk = letters.slice(i, i + 5);
const row = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>();
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<ButtonBuilder>[] {
const labels = ['A', 'B', 'C', 'D'];
const row = new ActionRowBuilder<ButtonBuilder>();
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 };
}

View File

@@ -0,0 +1,2 @@
export { funCommands } from './commands.js';
export { isFunButton, handleFunButton } from './buttons.js';

View File

@@ -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<void> {
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<string> {
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<string> {
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;
}

View File

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

View File

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

View File

@@ -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<void> {
await context.redis.set(voiceKey(guildId, userId), String(Date.now()));
}
async function handleVoiceLeave(
context: BotContext,
guildId: string,
userId: string,
channelId: string
): Promise<void> {
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');
}
});
}

View File

@@ -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<Buffer> {
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');
}

View File

@@ -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<LevelingConfig> {
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<MemberLevel> {
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<MemberLevel> {
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<XpChangeResult | null> {
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<XpChangeResult> {
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<XpChangeResult | null> {
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<XpChangeResult> {
return setXp(context, guild, member, 0, channelId);
}
export async function getLeaderboard(
prisma: BotContext['prisma'],
guildId: string,
limit = 10
): Promise<MemberLevel[]> {
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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
});
}

View File

@@ -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<void> {
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
}
});
}

View File

@@ -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<void> {
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<unknown>;
}): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(EMBED_MODAL_ID)
.setTitle('Embed Builder')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('title')
.setLabel('Title')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setMaxLength(256)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('description')
.setLabel('Description')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
.setMaxLength(4000)
),
new ActionRowBuilder<TextInputBuilder>().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<void> {
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 });
}

View File

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

View File

@@ -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<boolean> {
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<void> {
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(`<a:${stolen.name}:${stolen.id}>`);
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
];

View File

@@ -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<string | null> {
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];

View File

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

View File

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

View File

@@ -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<EmbedBuilder> {
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<number, number>();
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: `<t:${Math.floor(poll.endsAt.getTime() / 1000)}:R>`
})
});
}
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<ButtonBuilder>[] {
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
let currentRow = new ActionRowBuilder<ButtonBuilder>();
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<ButtonBuilder>();
}
});
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<void> {
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<void> {
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
}
}

View File

@@ -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<string> {
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<string> {
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: `<t:${Math.floor(r.remindAt.getTime() / 1000)}:R>`,
content: r.content.slice(0, 80)
})
)
.join('\n');
}
export async function deleteReminder(
context: BotContext,
userId: string,
reminderIdPrefix: string
): Promise<boolean> {
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<void> {
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) }
});
}
}

View File

@@ -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 [
`<t:${unixSeconds}:t>`,
`<t:${unixSeconds}:T>`,
`<t:${unixSeconds}:d>`,
`<t:${unixSeconds}:D>`,
`<t:${unixSeconds}:f>`,
`<t:${unixSeconds}:F>`,
`<t:${unixSeconds}:R>`
].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: `<t:${Math.floor(user.createdTimestamp / 1000)}:F>`,
inline: true
}
);
if (member) {
embed.addFields(
{
name: locale === 'de' ? 'Beigetreten' : 'Joined',
value: member.joinedTimestamp
? `<t:${Math.floor(member.joinedTimestamp / 1000)}:F>`
: '—',
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
? `<t:${Math.floor(guild.createdTimestamp / 1000)}:F>`
: '—',
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
? `<t:${Math.floor(role.createdTimestamp / 1000)}:F>`
: '—',
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
? `<t:${Math.floor(channel.createdTimestamp / 1000)}:F>`
: '—',
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 = /<a?:(\w+):(\d+)>/;
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] };
}

View File

@@ -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<void> {
await redis.set(snipeKey(prefix, channelId), JSON.stringify(payload), 'EX', SNIPE_TTL_SECONDS);
}
async function getSnipe(
redis: BotContext['redis'],
prefix: string,
channelId: string
): Promise<SnipePayload | null> {
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')
};
}

View File

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

View File

@@ -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<void>;
};
export type ContextMenuCommand = {
data: { name: string; toJSON: () => unknown };
execute: (
interaction: MessageContextMenuCommandInteraction,
context: BotContext
) => Promise<void>;
};

View File

@@ -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
# 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.

View File

@@ -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, 25)',
en: 'Options (comma-separated, 25)'
},
'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;

View File

@@ -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<typeof LocaleSchema>;
@@ -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 25 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 25 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<Locale, Dictionary> = { de, en };

View File

@@ -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<typeof LevelUpModeSchema>;
export const MultiplierMapSchema = z.record(z.string(), z.number().positive());
export type MultiplierMap = z.infer<typeof MultiplierMapSchema>;
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;