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:
@@ -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;
|
||||||
@@ -20,6 +20,16 @@ model Guild {
|
|||||||
logChannels LogChannel[]
|
logChannels LogChannel[]
|
||||||
welcomeConfig WelcomeConfig?
|
welcomeConfig WelcomeConfig?
|
||||||
verificationConfig VerificationConfig?
|
verificationConfig VerificationConfig?
|
||||||
|
levelingConfig LevelingConfig?
|
||||||
|
memberLevels MemberLevel[]
|
||||||
|
levelRewards LevelReward[]
|
||||||
|
economyConfig EconomyConfig?
|
||||||
|
memberEconomies MemberEconomy[]
|
||||||
|
shopItems ShopItem[]
|
||||||
|
inventoryItems InventoryItem[]
|
||||||
|
polls Poll[]
|
||||||
|
reminders Reminder[]
|
||||||
|
afkStatuses AfkStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model GuildSettings {
|
model GuildSettings {
|
||||||
@@ -203,3 +213,179 @@ model VerificationConfig {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ import type { BotContext } from './types.js';
|
|||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||||
import { completeVerification } from './modules/verification/handlers.js';
|
import { completeVerification } from './modules/verification/handlers.js';
|
||||||
|
import { closePoll } from './modules/utility/poll.js';
|
||||||
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
import {
|
import {
|
||||||
automodQueue,
|
automodQueue,
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
backupQueue,
|
backupQueue,
|
||||||
backupQueueName,
|
backupQueueName,
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
|
reminderQueue,
|
||||||
|
reminderQueueName,
|
||||||
verificationQueueName
|
verificationQueueName
|
||||||
} from './queues.js';
|
} from './queues.js';
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ export {
|
|||||||
backupQueue,
|
backupQueue,
|
||||||
moderationQueue,
|
moderationQueue,
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
|
reminderQueue,
|
||||||
verificationQueue
|
verificationQueue
|
||||||
} from './queues.js';
|
} from './queues.js';
|
||||||
|
|
||||||
@@ -37,6 +42,14 @@ type VerificationCompleteJob = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ReminderSendJob = {
|
||||||
|
reminderId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PollCloseJob = {
|
||||||
|
pollId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function startWorkers(context: BotContext): Worker[] {
|
export function startWorkers(context: BotContext): Worker[] {
|
||||||
const moderationWorker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
@@ -105,7 +118,25 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Verification job failed');
|
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> {
|
export async function ensureRecurringJobs(): Promise<void> {
|
||||||
|
|||||||
50
apps/bot/src/modules/economy/blackjack-buttons.ts
Normal file
50
apps/bot/src/modules/economy/blackjack-buttons.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/bot/src/modules/economy/command-definitions.ts
Normal file
121
apps/bot/src/modules/economy/command-definitions.ts
Normal 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')
|
||||||
|
)
|
||||||
|
);
|
||||||
497
apps/bot/src/modules/economy/commands.ts
Normal file
497
apps/bot/src/modules/economy/commands.ts
Normal 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
|
||||||
|
];
|
||||||
420
apps/bot/src/modules/economy/games.ts
Normal file
420
apps/bot/src/modules/economy/games.ts
Normal 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 };
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/economy/index.ts
Normal file
2
apps/bot/src/modules/economy/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { economyCommands } from './commands.js';
|
||||||
|
export { isBlackjackButton, handleBlackjackButton } from './blackjack-buttons.js';
|
||||||
380
apps/bot/src/modules/economy/service.ts
Normal file
380
apps/bot/src/modules/economy/service.ts
Normal 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);
|
||||||
|
}
|
||||||
234
apps/bot/src/modules/fun/buttons.ts
Normal file
234
apps/bot/src/modules/fun/buttons.ts
Normal 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);
|
||||||
|
}
|
||||||
112
apps/bot/src/modules/fun/command-definitions.ts
Normal file
112
apps/bot/src/modules/fun/command-definitions.ts
Normal 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;
|
||||||
412
apps/bot/src/modules/fun/commands.ts
Normal file
412
apps/bot/src/modules/fun/commands.ts
Normal 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
|
||||||
|
];
|
||||||
23
apps/bot/src/modules/fun/config.ts
Normal file
23
apps/bot/src/modules/fun/config.ts
Normal 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));
|
||||||
|
}
|
||||||
574
apps/bot/src/modules/fun/games.ts
Normal file
574
apps/bot/src/modules/fun/games.ts
Normal 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 };
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/fun/index.ts
Normal file
2
apps/bot/src/modules/fun/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { funCommands } from './commands.js';
|
||||||
|
export { isFunButton, handleFunButton } from './buttons.js';
|
||||||
65
apps/bot/src/modules/fun/media.ts
Normal file
65
apps/bot/src/modules/fun/media.ts
Normal 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;
|
||||||
|
}
|
||||||
49
apps/bot/src/modules/leveling/command-definitions.ts
Normal file
49
apps/bot/src/modules/leveling/command-definitions.ts
Normal 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')
|
||||||
|
)
|
||||||
|
);
|
||||||
160
apps/bot/src/modules/leveling/commands.ts
Normal file
160
apps/bot/src/modules/leveling/commands.ts
Normal 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];
|
||||||
69
apps/bot/src/modules/leveling/events.ts
Normal file
69
apps/bot/src/modules/leveling/events.ts
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
84
apps/bot/src/modules/leveling/rank-card.ts
Normal file
84
apps/bot/src/modules/leveling/rank-card.ts
Normal 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');
|
||||||
|
}
|
||||||
415
apps/bot/src/modules/leveling/service.ts
Normal file
415
apps/bot/src/modules/leveling/service.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
89
apps/bot/src/modules/utility/afk.ts
Normal file
89
apps/bot/src/modules/utility/afk.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
111
apps/bot/src/modules/utility/buttons.ts
Normal file
111
apps/bot/src/modules/utility/buttons.ts
Normal 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 });
|
||||||
|
}
|
||||||
186
apps/bot/src/modules/utility/command-definitions.ts
Normal file
186
apps/bot/src/modules/utility/command-definitions.ts
Normal 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')
|
||||||
|
);
|
||||||
438
apps/bot/src/modules/utility/commands.ts
Normal file
438
apps/bot/src/modules/utility/commands.ts
Normal 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
|
||||||
|
];
|
||||||
50
apps/bot/src/modules/utility/context-menus.ts
Normal file
50
apps/bot/src/modules/utility/context-menus.ts
Normal 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];
|
||||||
8
apps/bot/src/modules/utility/events.ts
Normal file
8
apps/bot/src/modules/utility/events.ts
Normal 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);
|
||||||
|
}
|
||||||
13
apps/bot/src/modules/utility/index.ts
Normal file
13
apps/bot/src/modules/utility/index.ts
Normal 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';
|
||||||
274
apps/bot/src/modules/utility/poll.ts
Normal file
274
apps/bot/src/modules/utility/poll.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
142
apps/bot/src/modules/utility/reminders.ts
Normal file
142
apps/bot/src/modules/utility/reminders.ts
Normal 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) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
241
apps/bot/src/modules/utility/service.ts
Normal file
241
apps/bot/src/modules/utility/service.ts
Normal 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] };
|
||||||
|
}
|
||||||
130
apps/bot/src/modules/utility/snipe.ts
Normal file
130
apps/bot/src/modules/utility/snipe.ts
Normal 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')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,3 +9,5 @@ export const automodQueueName = 'automod';
|
|||||||
export const automodQueue = new Queue(automodQueueName, { connection: redis });
|
export const automodQueue = new Queue(automodQueueName, { connection: redis });
|
||||||
export const verificationQueueName = 'verification';
|
export const verificationQueueName = 'verification';
|
||||||
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });
|
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });
|
||||||
|
export const reminderQueueName = 'reminders';
|
||||||
|
export const reminderQueue = new Queue(reminderQueueName, { connection: redis });
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { PrismaClient } from '@prisma/client';
|
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';
|
import type { Redis } from 'ioredis';
|
||||||
|
|
||||||
export type BotContext = {
|
export type BotContext = {
|
||||||
@@ -12,3 +16,11 @@ export type SlashCommand = {
|
|||||||
data: { name: string; toJSON: () => unknown };
|
data: { name: string; toJSON: () => unknown };
|
||||||
execute: (interaction: ChatInputCommandInteraction, context: BotContext) => Promise<void>;
|
execute: (interaction: ChatInputCommandInteraction, context: BotContext) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ContextMenuCommand = {
|
||||||
|
data: { name: string; toJSON: () => unknown };
|
||||||
|
execute: (
|
||||||
|
interaction: MessageContextMenuCommandInteraction,
|
||||||
|
context: BotContext
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,140 +1,182 @@
|
|||||||
# Nexumi – Phasen-Tracking
|
# Nexumi – Phasen-Tracking
|
||||||
|
|
||||||
|
Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jedem Arbeitsfortschritt aktualisiert.
|
||||||
|
|
||||||
Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jedem Arbeitsfortschritt aktualisiert.
|
## Assets
|
||||||
|
|
||||||
|
- Bot-Logo vorhanden: `docs/logo/nexumi-logo.svg`
|
||||||
|
|
||||||
## Assets
|
## Phase 1 – Fundament (Status: abgeschlossen, manuelle Discord-Tests ausstehend)
|
||||||
|
|
||||||
|
### Bereits abgeschlossen
|
||||||
|
|
||||||
- Bot-Logo vorhanden: `docs/logo/nexumi-logo.svg`
|
- Monorepo-Basis mit Workspaces:
|
||||||
|
- `apps/bot`
|
||||||
|
- `packages/shared`
|
||||||
|
- Basis-Tooling:
|
||||||
## Phase 1 – Fundament (Status: abgeschlossen, manuelle Discord-Tests ausstehend)
|
- TypeScript strict
|
||||||
|
- ESLint
|
||||||
|
- Prettier
|
||||||
|
- Vitest
|
||||||
### Bereits abgeschlossen
|
- Turbo
|
||||||
|
- Docker-Compose-Grundstack:
|
||||||
|
- `postgres` mit Healthcheck
|
||||||
|
- `redis` mit Healthcheck
|
||||||
- Monorepo-Basis mit Workspaces:
|
- `bot`
|
||||||
|
- `webui`-Scaffold-Service (temporär)
|
||||||
- `apps/bot`
|
- `backups` Volume angebunden
|
||||||
|
- Prisma-Grundmodell inkl. Moderationsdaten:
|
||||||
- `packages/shared`
|
- `Guild`
|
||||||
|
- `GuildSettings`
|
||||||
- Basis-Tooling:
|
- `User`
|
||||||
|
- `Case`
|
||||||
- TypeScript strict
|
- `Warning`
|
||||||
|
- `ModNote`
|
||||||
- ESLint
|
- `EscalationRule`
|
||||||
|
- Bot-Grundarchitektur:
|
||||||
- Prettier
|
- Sharding-Startpfad
|
||||||
|
- Command-Registrierung
|
||||||
- Vitest
|
- Command-Routing
|
||||||
|
- zentraler Permission-Check
|
||||||
- Turbo
|
- Basis-i18n in `@nexumi/shared`
|
||||||
|
- Guild-Locale-Auflösung über DB-Settings mit Fallback auf Env-Default
|
||||||
- Docker-Compose-Grundstack:
|
- Health-Endpoint (`/health`) und token-geschütztes `/metrics`-Scaffold
|
||||||
|
- BullMQ-Integration:
|
||||||
- `postgres` mit Healthcheck
|
- Moderation-Queue
|
||||||
|
- Temp-Ban-Expire-Job
|
||||||
- `redis` mit Healthcheck
|
- täglicher Backup-Job (`pg_dump`)
|
||||||
|
- Backup-Retention-Cleanup
|
||||||
- `bot`
|
- Moderation als Referenzmodul (implementiert):
|
||||||
|
- `/ban`, `/unban`, `/kick`, `/timeout`, `/untimeout`
|
||||||
- `webui`-Scaffold-Service (temporär)
|
- `/warn add|list|remove|clear`
|
||||||
|
- `/warn escalation-set|escalation-list|escalation-remove`
|
||||||
- `backups` Volume angebunden
|
- `/purge` (Filter: User, Bots, Links, Attachments, Regex)
|
||||||
|
- `/slowmode`, `/lock`, `/unlock`
|
||||||
- Prisma-Grundmodell inkl. Moderationsdaten:
|
- `/nick set|reset`
|
||||||
|
- `/case view|edit|delete`
|
||||||
- `Guild`
|
- `/modnote add|list`
|
||||||
|
- user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`)
|
||||||
- `GuildSettings`
|
- Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations)
|
||||||
|
- Bestätigungsflow für destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons
|
||||||
- `User`
|
- standardisierte Case-Audit-Metadaten über `@nexumi/shared` (`buildCaseMetadata`)
|
||||||
|
- Docker-Build/Runtime-Fixes für E2E:
|
||||||
- `Case`
|
- `.dockerignore` ergänzt
|
||||||
|
- Prisma-Migration beim Bot-Start (`prisma migrate deploy`)
|
||||||
- `Warning`
|
- Bot-Healthcheck auf `/health`
|
||||||
|
- E2E-Verifikation (automatisiert, lokal):
|
||||||
- `ModNote`
|
- `docker compose up -d --build` erfolgreich
|
||||||
|
- Postgres/Redis healthy
|
||||||
- `EscalationRule`
|
- Migration `20260722123000_init` angewendet
|
||||||
|
- Bot online (`Nexumi#9122`), 13 Slash-Commands registriert
|
||||||
- Bot-Grundarchitektur:
|
- Bot-Container healthy
|
||||||
|
- Tests (aktuell vorhanden):
|
||||||
- Sharding-Startpfad
|
- `packages/shared/src/i18n.test.ts`
|
||||||
|
- `packages/shared/src/audit.test.ts`
|
||||||
- Command-Registrierung
|
- `apps/bot/src/modules/moderation/duration.test.ts`
|
||||||
|
|
||||||
- Command-Routing
|
### Manuelle Discord-Tests (noch offen)
|
||||||
|
|
||||||
- zentraler Permission-Check
|
- Bot auf Test-Server einladen (falls noch nicht geschehen)
|
||||||
|
- Slash-Commands testen: `/ban`, `/warn add`, `/purge`, `/case view`
|
||||||
- Basis-i18n in `@nexumi/shared`
|
- Bestätigungsbuttons bei `/ban` und `/purge` prüfen
|
||||||
|
|
||||||
- Guild-Locale-Auflösung über DB-Settings mit Fallback auf Env-Default
|
## Phase 2 – AutoMod, Logging, Welcome, Verifizierung (Status: implementiert, manuelle Tests ausstehend)
|
||||||
|
|
||||||
- Health-Endpoint (`/health`) und token-geschütztes `/metrics`-Scaffold
|
### Abgeschlossen
|
||||||
|
|
||||||
- BullMQ-Integration:
|
- Prisma-Erweiterung + Migration `20260722140000_phase2_modules`:
|
||||||
|
- `AutoModConfig`, `AutoModRule`
|
||||||
- Moderation-Queue
|
- `LoggingConfig`, `LogChannel`
|
||||||
|
- `WelcomeConfig`
|
||||||
- Temp-Ban-Expire-Job
|
- `VerificationConfig`
|
||||||
|
- Shared-Schemas und Hilfsfunktionen (`packages/shared/src/phase2.ts`)
|
||||||
- täglicher Backup-Job (`pg_dump`)
|
- **AutoMod** (`apps/bot/src/modules/automod/`):
|
||||||
|
- Filter: Spam, Massen-Mentions, Caps, Invite-Links, externe Links, Wortfilter, Duplikate, Emoji-Spam, Zalgo, Phishing-Blockliste
|
||||||
- Backup-Retention-Cleanup
|
- Standardregeln werden pro Guild beim ersten Zugriff angelegt
|
||||||
|
- Anti-Raid (Join-Rate → Lockdown)
|
||||||
- Moderation als Referenzmodul (implementiert):
|
- Anti-Nuke (Massen-Bans/Channel-Löschungen → Rechteentzug)
|
||||||
|
- `/automod status`
|
||||||
- `/ban`, `/unban`, `/kick`, `/timeout`, `/untimeout`
|
- Phishing-Listen-Refresh via BullMQ (alle 6h + beim Start)
|
||||||
|
- **Logging** (`apps/bot/src/modules/logging/`):
|
||||||
- `/warn add|list|remove|clear`
|
- 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`)
|
||||||
- `/warn escalation-set|escalation-list|escalation-remove`
|
- Ignore-Listen (Kanäle, Rollen, Bots) über DB
|
||||||
|
- Log-Kanal-Zuordnung über `LogChannel`-Tabelle (Konfiguration künftig WebUI)
|
||||||
- `/purge` (Filter: User, Bots, Links, Attachments, Regex)
|
- **Welcome/Leave** (`apps/bot/src/modules/welcome/`):
|
||||||
|
- Text-, Embed- und Bild-Karten (Canvas via `@napi-rs/canvas`)
|
||||||
- `/slowmode`, `/lock`, `/unlock`
|
- Platzhalter-System
|
||||||
|
- Autoroles (User/Bots getrennt)
|
||||||
- `/nick set|reset`
|
- Welcome-DM
|
||||||
|
- `/welcome test`, `/welcome preview`
|
||||||
- `/case view|edit|delete`
|
- **Verifizierung** (`apps/bot/src/modules/verification/`):
|
||||||
|
- `/verify setup`, `/verify panel`
|
||||||
- `/modnote add|list`
|
- Button-Verify und Captcha-Verify (Captcha-Seite unter `/verify/captcha` am Health-Server)
|
||||||
|
- Mindest-Accountalter, Fehlschlag-Aktionen (Kick/Ban/None)
|
||||||
- user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`)
|
- Unverified-Rolle bei Join
|
||||||
|
- Bot-Intents erweitert:
|
||||||
- Slash-Command-Beschreibungen und Optionstexte lokalisiert (`de` + `en-US` via Discord-Localizations)
|
- `GuildMembers`, `GuildMessages`, `MessageContent`, `GuildModeration`, `GuildVoiceStates`, `GuildEmojisAndStickers`, `GuildInvites`
|
||||||
|
- Neue Env-Variable: `PUBLIC_BASE_URL` (Captcha-Links)
|
||||||
- Bestätigungsflow für destruktive Aktionen (`/ban`, `/purge`, `/case delete`) mit Buttons
|
- Tests: `packages/shared/src/phase2.test.ts`
|
||||||
|
|
||||||
- standardisierte Case-Audit-Metadaten über `@nexumi/shared` (`buildCaseMetadata`)
|
### Manuelle Discord-Tests (noch offen)
|
||||||
|
|
||||||
- Docker-Build/Runtime-Fixes für E2E:
|
- **Privileged Intents** im Developer Portal aktivieren:
|
||||||
|
- `SERVER MEMBERS INTENT`
|
||||||
- `.dockerignore` ergänzt
|
- `MESSAGE CONTENT INTENT`
|
||||||
|
- AutoMod: Spam-/Invite-Testnachrichten senden
|
||||||
- Prisma-Migration beim Bot-Start (`prisma migrate deploy`)
|
- `/automod status` prüfen
|
||||||
|
- Logging: Log-Kanäle per DB/ später WebUI setzen, Events auslösen
|
||||||
- Bot-Healthcheck auf `/health`
|
- `/welcome preview` und `/welcome test` (Konfiguration zunächst per DB oder später WebUI)
|
||||||
|
- `/verify setup` + `/verify panel`, Button- und Captcha-Flow testen
|
||||||
- E2E-Verifikation (automatisiert, lokal):
|
- `PUBLIC_BASE_URL` auf erreichbare URL setzen (für Captcha außerhalb localhost)
|
||||||
|
|
||||||
- `docker compose up -d --build` erfolgreich
|
### Hinweise
|
||||||
|
|
||||||
- Postgres/Redis healthy
|
- 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.
|
||||||
- Migration `20260722123000_init` angewendet
|
|
||||||
|
## Phase 3 – Leveling, Economy, Utility, Fun (Status: in Arbeit)
|
||||||
- Bot online (`Nexumi#9122`), 13 Slash-Commands registriert
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -274,6 +274,414 @@ export const commandLocales = {
|
|||||||
'verify.panel.options.channel': {
|
'verify.panel.options.channel': {
|
||||||
de: 'Optionaler Kanal für das Panel',
|
de: 'Optionaler Kanal für das Panel',
|
||||||
en: 'Optional channel for the panel'
|
en: 'Optional channel for the panel'
|
||||||
|
},
|
||||||
|
'leveling.rank.description': {
|
||||||
|
de: 'Rangkarte eines Mitglieds anzeigen',
|
||||||
|
en: 'Show a member rank card'
|
||||||
|
},
|
||||||
|
'leveling.rank.options.user': {
|
||||||
|
de: 'Mitglied (Standard: du)',
|
||||||
|
en: 'Member (defaults to you)'
|
||||||
|
},
|
||||||
|
'leveling.leaderboard.description': {
|
||||||
|
de: 'Top 10 Mitglieder nach XP',
|
||||||
|
en: 'Top 10 members by XP'
|
||||||
|
},
|
||||||
|
'leveling.xp.description': {
|
||||||
|
de: 'XP verwalten (Admin)',
|
||||||
|
en: 'Manage XP (admin)'
|
||||||
|
},
|
||||||
|
'leveling.xp.give.description': {
|
||||||
|
de: 'XP vergeben',
|
||||||
|
en: 'Give XP'
|
||||||
|
},
|
||||||
|
'leveling.xp.remove.description': {
|
||||||
|
de: 'XP entfernen',
|
||||||
|
en: 'Remove XP'
|
||||||
|
},
|
||||||
|
'leveling.xp.reset.description': {
|
||||||
|
de: 'XP zurücksetzen',
|
||||||
|
en: 'Reset XP'
|
||||||
|
},
|
||||||
|
'leveling.xp.options.user': {
|
||||||
|
de: 'Zielmitglied',
|
||||||
|
en: 'Target member'
|
||||||
|
},
|
||||||
|
'leveling.xp.options.amount': {
|
||||||
|
de: 'XP-Betrag',
|
||||||
|
en: 'XP amount'
|
||||||
|
},
|
||||||
|
'economy.balance.description': {
|
||||||
|
de: 'Kontostand anzeigen',
|
||||||
|
en: 'Show balance'
|
||||||
|
},
|
||||||
|
'economy.daily.description': {
|
||||||
|
de: 'Tägliche Belohnung abholen',
|
||||||
|
en: 'Claim daily reward'
|
||||||
|
},
|
||||||
|
'economy.weekly.description': {
|
||||||
|
de: 'Wöchentliche Belohnung abholen',
|
||||||
|
en: 'Claim weekly reward'
|
||||||
|
},
|
||||||
|
'economy.work.description': {
|
||||||
|
de: 'Arbeiten und Geld verdienen',
|
||||||
|
en: 'Work to earn money'
|
||||||
|
},
|
||||||
|
'economy.pay.description': {
|
||||||
|
de: 'Geld an einen Nutzer senden',
|
||||||
|
en: 'Send money to a user'
|
||||||
|
},
|
||||||
|
'economy.gamble.description': {
|
||||||
|
de: '50/50 um deinen Einsatz spielen',
|
||||||
|
en: '50/50 gamble your bet'
|
||||||
|
},
|
||||||
|
'economy.slots.description': {
|
||||||
|
de: 'Spielautomaten spielen',
|
||||||
|
en: 'Play the slot machine'
|
||||||
|
},
|
||||||
|
'economy.blackjack.description': {
|
||||||
|
de: 'Blackjack spielen',
|
||||||
|
en: 'Play blackjack'
|
||||||
|
},
|
||||||
|
'economy.coinflip.description': {
|
||||||
|
de: 'Kopf oder Zahl um Geld wetten',
|
||||||
|
en: 'Bet on heads or tails'
|
||||||
|
},
|
||||||
|
'economy.shop.description': {
|
||||||
|
de: 'Server-Shop',
|
||||||
|
en: 'Server shop'
|
||||||
|
},
|
||||||
|
'economy.shop.view.description': {
|
||||||
|
de: 'Shop-Artikel anzeigen',
|
||||||
|
en: 'View shop items'
|
||||||
|
},
|
||||||
|
'economy.shop.buy.description': {
|
||||||
|
de: 'Artikel kaufen',
|
||||||
|
en: 'Buy an item'
|
||||||
|
},
|
||||||
|
'economy.shop.options.item_id': {
|
||||||
|
de: 'Artikel-ID aus /shop view',
|
||||||
|
en: 'Item ID from /shop view'
|
||||||
|
},
|
||||||
|
'economy.inventory.description': {
|
||||||
|
de: 'Dein Inventar anzeigen',
|
||||||
|
en: 'Show your inventory'
|
||||||
|
},
|
||||||
|
'economy.eco.description': {
|
||||||
|
de: 'Economy-Verwaltung',
|
||||||
|
en: 'Economy management'
|
||||||
|
},
|
||||||
|
'economy.eco.leaderboard.description': {
|
||||||
|
de: 'Reichsten Nutzer anzeigen',
|
||||||
|
en: 'Show richest users'
|
||||||
|
},
|
||||||
|
'economy.eco.give.description': {
|
||||||
|
de: 'Geld vergeben (Admin)',
|
||||||
|
en: 'Give money (admin)'
|
||||||
|
},
|
||||||
|
'economy.eco.remove.description': {
|
||||||
|
de: 'Geld entfernen (Admin)',
|
||||||
|
en: 'Remove money (admin)'
|
||||||
|
},
|
||||||
|
'economy.eco.reset.description': {
|
||||||
|
de: 'Nutzer-Economy zurücksetzen (Admin)',
|
||||||
|
en: 'Reset user economy (admin)'
|
||||||
|
},
|
||||||
|
'economy.common.options.amount': {
|
||||||
|
de: 'Betrag',
|
||||||
|
en: 'Amount'
|
||||||
|
},
|
||||||
|
'economy.common.options.user': {
|
||||||
|
de: 'Nutzer',
|
||||||
|
en: 'User'
|
||||||
|
},
|
||||||
|
'economy.common.options.userOptional': {
|
||||||
|
de: 'Optionaler Nutzer (Standard: du)',
|
||||||
|
en: 'Optional user (defaults to you)'
|
||||||
|
},
|
||||||
|
'economy.common.options.target': {
|
||||||
|
de: 'Zielnutzer',
|
||||||
|
en: 'Target user'
|
||||||
|
},
|
||||||
|
'economy.coinflip.options.choice': {
|
||||||
|
de: 'Kopf oder Zahl',
|
||||||
|
en: 'Heads or tails'
|
||||||
|
},
|
||||||
|
'economy.choice.heads': {
|
||||||
|
de: 'Kopf',
|
||||||
|
en: 'Heads'
|
||||||
|
},
|
||||||
|
'economy.choice.tails': {
|
||||||
|
de: 'Zahl',
|
||||||
|
en: 'Tails'
|
||||||
|
},
|
||||||
|
'utility.userinfo.description': {
|
||||||
|
de: 'Nutzerinformationen anzeigen',
|
||||||
|
en: 'Show user information'
|
||||||
|
},
|
||||||
|
'utility.serverinfo.description': {
|
||||||
|
de: 'Serverinformationen anzeigen',
|
||||||
|
en: 'Show server information'
|
||||||
|
},
|
||||||
|
'utility.roleinfo.description': {
|
||||||
|
de: 'Rolleninformationen anzeigen',
|
||||||
|
en: 'Show role information'
|
||||||
|
},
|
||||||
|
'utility.roleinfo.options.role': {
|
||||||
|
de: 'Rolle',
|
||||||
|
en: 'Role'
|
||||||
|
},
|
||||||
|
'utility.channelinfo.description': {
|
||||||
|
de: 'Kanalinformationen anzeigen',
|
||||||
|
en: 'Show channel information'
|
||||||
|
},
|
||||||
|
'utility.avatar.description': {
|
||||||
|
de: 'Avatar anzeigen',
|
||||||
|
en: 'Show avatar'
|
||||||
|
},
|
||||||
|
'utility.banner.description': {
|
||||||
|
de: 'Banner anzeigen',
|
||||||
|
en: 'Show banner'
|
||||||
|
},
|
||||||
|
'utility.common.options.userOptional': {
|
||||||
|
de: 'Optionaler Nutzer (Standard: du)',
|
||||||
|
en: 'Optional user (defaults to you)'
|
||||||
|
},
|
||||||
|
'utility.common.options.channelOptional': {
|
||||||
|
de: 'Optionaler Kanal (Standard: aktueller Kanal)',
|
||||||
|
en: 'Optional channel (defaults to current)'
|
||||||
|
},
|
||||||
|
'utility.common.options.attachment': {
|
||||||
|
de: 'Bild-Anhang',
|
||||||
|
en: 'Image attachment'
|
||||||
|
},
|
||||||
|
'utility.common.options.url': {
|
||||||
|
de: 'Bild-URL',
|
||||||
|
en: 'Image URL'
|
||||||
|
},
|
||||||
|
'utility.common.options.name': {
|
||||||
|
de: 'Name',
|
||||||
|
en: 'Name'
|
||||||
|
},
|
||||||
|
'utility.poll.description': {
|
||||||
|
de: 'Umfragen erstellen',
|
||||||
|
en: 'Create polls'
|
||||||
|
},
|
||||||
|
'utility.poll.create.description': {
|
||||||
|
de: 'Neue Umfrage erstellen',
|
||||||
|
en: 'Create a new poll'
|
||||||
|
},
|
||||||
|
'utility.poll.options.question': {
|
||||||
|
de: 'Frage',
|
||||||
|
en: 'Question'
|
||||||
|
},
|
||||||
|
'utility.poll.options.options': {
|
||||||
|
de: 'Optionen (kommagetrennt, 2–5)',
|
||||||
|
en: 'Options (comma-separated, 2–5)'
|
||||||
|
},
|
||||||
|
'utility.poll.options.multiselect': {
|
||||||
|
de: 'Mehrfachauswahl erlauben',
|
||||||
|
en: 'Allow multi-select'
|
||||||
|
},
|
||||||
|
'utility.poll.options.anonymous': {
|
||||||
|
de: 'Anonyme Abstimmung',
|
||||||
|
en: 'Anonymous voting'
|
||||||
|
},
|
||||||
|
'utility.poll.options.duration': {
|
||||||
|
de: 'Optionale Dauer (z. B. 1h)',
|
||||||
|
en: 'Optional duration (e.g. 1h)'
|
||||||
|
},
|
||||||
|
'utility.remindme.description': {
|
||||||
|
de: 'Erinnerung setzen',
|
||||||
|
en: 'Set a reminder'
|
||||||
|
},
|
||||||
|
'utility.remindme.options.when': {
|
||||||
|
de: 'Wann (z. B. 30m oder ISO-Datum)',
|
||||||
|
en: 'When (e.g. 30m or ISO datetime)'
|
||||||
|
},
|
||||||
|
'utility.remindme.options.content': {
|
||||||
|
de: 'Erinnerungstext',
|
||||||
|
en: 'Reminder content'
|
||||||
|
},
|
||||||
|
'utility.remindme.options.recurring_cron': {
|
||||||
|
de: 'Optional: Cron für wiederkehrende Erinnerung',
|
||||||
|
en: 'Optional cron for recurring reminder'
|
||||||
|
},
|
||||||
|
'utility.reminders.description': {
|
||||||
|
de: 'Erinnerungen verwalten',
|
||||||
|
en: 'Manage reminders'
|
||||||
|
},
|
||||||
|
'utility.reminders.list.description': {
|
||||||
|
de: 'Erinnerungen auflisten',
|
||||||
|
en: 'List reminders'
|
||||||
|
},
|
||||||
|
'utility.reminders.delete.description': {
|
||||||
|
de: 'Erinnerung löschen',
|
||||||
|
en: 'Delete a reminder'
|
||||||
|
},
|
||||||
|
'utility.reminders.options.id': {
|
||||||
|
de: 'Erinnerungs-ID (Präfix reicht)',
|
||||||
|
en: 'Reminder ID (prefix is enough)'
|
||||||
|
},
|
||||||
|
'utility.afk.description': {
|
||||||
|
de: 'AFK-Status setzen',
|
||||||
|
en: 'Set AFK status'
|
||||||
|
},
|
||||||
|
'utility.afk.set.description': {
|
||||||
|
de: 'Als AFK markieren',
|
||||||
|
en: 'Mark as AFK'
|
||||||
|
},
|
||||||
|
'utility.afk.options.reason': {
|
||||||
|
de: 'Optionaler AFK-Grund',
|
||||||
|
en: 'Optional AFK reason'
|
||||||
|
},
|
||||||
|
'utility.emoji.description': {
|
||||||
|
de: 'Emojis verwalten',
|
||||||
|
en: 'Manage emojis'
|
||||||
|
},
|
||||||
|
'utility.emoji.add.description': {
|
||||||
|
de: 'Emoji hinzufügen',
|
||||||
|
en: 'Add emoji'
|
||||||
|
},
|
||||||
|
'utility.emoji.remove.description': {
|
||||||
|
de: 'Emoji entfernen',
|
||||||
|
en: 'Remove emoji'
|
||||||
|
},
|
||||||
|
'utility.emoji.steal.description': {
|
||||||
|
de: 'Emoji aus Nachricht übernehmen',
|
||||||
|
en: 'Steal emoji from message'
|
||||||
|
},
|
||||||
|
'utility.emoji.options.emoji': {
|
||||||
|
de: 'Emoji',
|
||||||
|
en: 'Emoji'
|
||||||
|
},
|
||||||
|
'utility.emoji.options.message_id': {
|
||||||
|
de: 'Nachrichten-ID',
|
||||||
|
en: 'Message ID'
|
||||||
|
},
|
||||||
|
'utility.emoji.options.url': {
|
||||||
|
de: 'Emoji-URL',
|
||||||
|
en: 'Emoji URL'
|
||||||
|
},
|
||||||
|
'utility.sticker.description': {
|
||||||
|
de: 'Sticker verwalten',
|
||||||
|
en: 'Manage stickers'
|
||||||
|
},
|
||||||
|
'utility.sticker.add.description': {
|
||||||
|
de: 'Sticker hinzufügen',
|
||||||
|
en: 'Add sticker'
|
||||||
|
},
|
||||||
|
'utility.sticker.options.url': {
|
||||||
|
de: 'Sticker-URL',
|
||||||
|
en: 'Sticker URL'
|
||||||
|
},
|
||||||
|
'utility.timestamp.description': {
|
||||||
|
de: 'Discord-Timestamp-Formate generieren',
|
||||||
|
en: 'Generate Discord timestamp formats'
|
||||||
|
},
|
||||||
|
'utility.timestamp.options.datetime': {
|
||||||
|
de: 'Datum/Uhrzeit (ISO oder relative Dauer)',
|
||||||
|
en: 'Datetime (ISO or relative duration)'
|
||||||
|
},
|
||||||
|
'utility.snipe.description': {
|
||||||
|
de: 'Zuletzt gelöschte Nachricht anzeigen',
|
||||||
|
en: 'Show last deleted message'
|
||||||
|
},
|
||||||
|
'utility.editsnipe.description': {
|
||||||
|
de: 'Zuletzt bearbeitete Nachricht anzeigen',
|
||||||
|
en: 'Show last edited message'
|
||||||
|
},
|
||||||
|
'utility.embed.description': {
|
||||||
|
de: 'Embeds erstellen',
|
||||||
|
en: 'Create embeds'
|
||||||
|
},
|
||||||
|
'utility.embed.builder.description': {
|
||||||
|
de: 'Embed per Modal bauen und senden',
|
||||||
|
en: 'Build and send an embed via modal'
|
||||||
|
},
|
||||||
|
'fun.8ball.description': {
|
||||||
|
de: 'Magische 8-Ball-Antwort auf deine Frage',
|
||||||
|
en: 'Magic 8-ball answer to your question'
|
||||||
|
},
|
||||||
|
'fun.8ball.options.question': {
|
||||||
|
de: 'Deine Frage',
|
||||||
|
en: 'Your question'
|
||||||
|
},
|
||||||
|
'fun.dice.description': {
|
||||||
|
de: 'Würfel werfen',
|
||||||
|
en: 'Roll dice'
|
||||||
|
},
|
||||||
|
'fun.dice.options.sides': {
|
||||||
|
de: 'Anzahl Seiten (Standard: 6)',
|
||||||
|
en: 'Number of sides (default: 6)'
|
||||||
|
},
|
||||||
|
'fun.dice.options.count': {
|
||||||
|
de: 'Anzahl Würfe (Standard: 1, max. 10)',
|
||||||
|
en: 'Number of rolls (default: 1, max 10)'
|
||||||
|
},
|
||||||
|
'fun.flip.description': {
|
||||||
|
de: 'Kostenloser Münzwurf (Kopf oder Zahl)',
|
||||||
|
en: 'Free coin flip (heads or tails)'
|
||||||
|
},
|
||||||
|
'fun.rps.description': {
|
||||||
|
de: 'Schere, Stein, Papier gegen den Bot',
|
||||||
|
en: 'Rock, paper, scissors against the bot'
|
||||||
|
},
|
||||||
|
'fun.rps.options.choice': {
|
||||||
|
de: 'Deine Wahl',
|
||||||
|
en: 'Your choice'
|
||||||
|
},
|
||||||
|
'fun.rps.choice.rock': {
|
||||||
|
de: 'Stein',
|
||||||
|
en: 'Rock'
|
||||||
|
},
|
||||||
|
'fun.rps.choice.paper': {
|
||||||
|
de: 'Papier',
|
||||||
|
en: 'Paper'
|
||||||
|
},
|
||||||
|
'fun.rps.choice.scissors': {
|
||||||
|
de: 'Schere',
|
||||||
|
en: 'Scissors'
|
||||||
|
},
|
||||||
|
'fun.choose.description': {
|
||||||
|
de: 'Zufällige Auswahl aus Optionen',
|
||||||
|
en: 'Random choice from options'
|
||||||
|
},
|
||||||
|
'fun.choose.options.options': {
|
||||||
|
de: 'Optionen (kommagetrennt)',
|
||||||
|
en: 'Options (comma-separated)'
|
||||||
|
},
|
||||||
|
'fun.trivia.description': {
|
||||||
|
de: 'Trivia-Frage mit Multiple Choice',
|
||||||
|
en: 'Trivia question with multiple choice'
|
||||||
|
},
|
||||||
|
'fun.tictactoe.description': {
|
||||||
|
de: 'Tic-Tac-Toe gegen einen anderen Nutzer',
|
||||||
|
en: 'Play Tic-Tac-Toe against another user'
|
||||||
|
},
|
||||||
|
'fun.connect4.description': {
|
||||||
|
de: 'Connect 4 gegen einen anderen Nutzer',
|
||||||
|
en: 'Play Connect 4 against another user'
|
||||||
|
},
|
||||||
|
'fun.hangman.description': {
|
||||||
|
de: 'Hangman mit Buchstaben-Buttons',
|
||||||
|
en: 'Hangman with letter buttons'
|
||||||
|
},
|
||||||
|
'fun.meme.description': {
|
||||||
|
de: 'Zufälliges Meme laden',
|
||||||
|
en: 'Fetch a random meme'
|
||||||
|
},
|
||||||
|
'fun.cat.description': {
|
||||||
|
de: 'Zufälliges Katzenbild laden',
|
||||||
|
en: 'Fetch a random cat image'
|
||||||
|
},
|
||||||
|
'fun.dog.description': {
|
||||||
|
de: 'Zufälliges Hundebild laden',
|
||||||
|
en: 'Fetch a random dog image'
|
||||||
|
},
|
||||||
|
'fun.common.options.opponent': {
|
||||||
|
de: 'Gegner',
|
||||||
|
en: 'Opponent'
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './audit.js';
|
|||||||
export * from './command-locales.js';
|
export * from './command-locales.js';
|
||||||
export * from './discord-localization.js';
|
export * from './discord-localization.js';
|
||||||
export * from './phase2.js';
|
export * from './phase2.js';
|
||||||
|
export * from './phase3.js';
|
||||||
|
|
||||||
export const LocaleSchema = z.enum(['de', 'en']);
|
export const LocaleSchema = z.enum(['de', 'en']);
|
||||||
export type Locale = z.infer<typeof LocaleSchema>;
|
export type Locale = z.infer<typeof LocaleSchema>;
|
||||||
@@ -92,7 +93,152 @@ const de: Dictionary = {
|
|||||||
'verification.error.generic': 'Verifizierung fehlgeschlagen.',
|
'verification.error.generic': 'Verifizierung fehlgeschlagen.',
|
||||||
'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.',
|
'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.',
|
||||||
'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.',
|
'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.',
|
||||||
'verification.captcha.title': 'Nexumi Verifizierung'
|
'verification.captcha.title': 'Nexumi Verifizierung',
|
||||||
|
'leveling.disabled': 'Leveling ist auf diesem Server deaktiviert.',
|
||||||
|
'leveling.rank.title': 'Rangkarte',
|
||||||
|
'leveling.leaderboard.title': 'Leaderboard',
|
||||||
|
'leveling.leaderboard.empty': 'Noch keine XP-Daten vorhanden.',
|
||||||
|
'leveling.leaderboard.entry': '{rank}. {user} — Level {level} ({xp} XP)',
|
||||||
|
'leveling.leaderboard.footer': 'Top {count} Mitglieder',
|
||||||
|
'leveling.levelUp.default': 'Glückwunsch {user}, du hast Level {level} erreicht!',
|
||||||
|
'leveling.xp.give.success': '{amount} XP an {user} vergeben. Neues Level: {level}.',
|
||||||
|
'leveling.xp.remove.success': '{amount} XP von {user} entfernt. Neues Level: {level}.',
|
||||||
|
'leveling.xp.reset.success': 'XP von {user} zurückgesetzt.',
|
||||||
|
'leveling.xp.invalidAmount': 'Der Betrag muss größer als 0 sein.',
|
||||||
|
'leveling.user.notFound': 'Nutzer nicht auf diesem Server gefunden.',
|
||||||
|
'economy.error.disabled': 'Das Economy-Modul ist auf diesem Server deaktiviert.',
|
||||||
|
'economy.error.insufficient': 'Du hast nicht genug Geld.',
|
||||||
|
'economy.error.cooldown': 'Cooldown aktiv. Versuche es in {seconds} Sekunden erneut.',
|
||||||
|
'economy.error.invalid_amount': 'Ungültiger Betrag.',
|
||||||
|
'economy.error.self_transfer': 'Du kannst dir nicht selbst Geld senden.',
|
||||||
|
'economy.error.not_found': 'Eintrag nicht gefunden.',
|
||||||
|
'economy.error.out_of_stock': 'Dieser Artikel ist ausverkauft.',
|
||||||
|
'economy.error.role_assign_failed': 'Kauf erfolgreich, aber die Rolle konnte nicht vergeben werden.',
|
||||||
|
'economy.error.game_in_progress': 'Du hast bereits ein laufendes Blackjack-Spiel.',
|
||||||
|
'economy.balance.result': 'Kontostand von {user}: {amount}',
|
||||||
|
'economy.daily.success': 'Tägliche Belohnung: {amount}. Neuer Kontostand: {balance}',
|
||||||
|
'economy.weekly.success': 'Wöchentliche Belohnung: {amount}. Neuer Kontostand: {balance}',
|
||||||
|
'economy.work.success': 'Du hast {amount} verdient. Neuer Kontostand: {balance}',
|
||||||
|
'economy.pay.success': 'Du hast {amount} an {user} gesendet. Dein Kontostand: {balance}',
|
||||||
|
'economy.gamble.win': 'Gewonnen! +{amount}. Kontostand: {balance}',
|
||||||
|
'economy.gamble.lose': 'Verloren! -{amount}. Kontostand: {balance}',
|
||||||
|
'economy.slots.win': '{reels}\nGewinn: {payout}. Kontostand: {balance}',
|
||||||
|
'economy.slots.lose': '{reels}\nKein Gewinn. Kontostand: {balance}',
|
||||||
|
'economy.coinflip.win': '{result} – du hattest {choice} gewählt. Gewonnen! +{amount}. Kontostand: {balance}',
|
||||||
|
'economy.coinflip.lose': '{result} – du hattest {choice} gewählt. Verloren! -{amount}. Kontostand: {balance}',
|
||||||
|
'economy.choice.heads': 'Kopf',
|
||||||
|
'economy.choice.tails': 'Zahl',
|
||||||
|
'economy.shop.empty': 'Der Shop ist leer.',
|
||||||
|
'economy.shop.noDescription': 'Keine Beschreibung',
|
||||||
|
'economy.shop.itemLine': '**{name}** (`{id}`) – {price} | Bestand: {stock}\n{description}',
|
||||||
|
'economy.shop.purchased': 'Du hast **{name}** gekauft. Kontostand: {balance}',
|
||||||
|
'economy.inventory.empty': 'Dein Inventar ist leer.',
|
||||||
|
'economy.inventory.itemLine': '- {name} × {quantity}',
|
||||||
|
'economy.leaderboard.empty': 'Noch keine Einträge auf der Bestenliste.',
|
||||||
|
'economy.leaderboard.line': '{rank}. {user} – {amount}',
|
||||||
|
'economy.admin.give': '{amount} an {user} vergeben. Neuer Kontostand: {balance}',
|
||||||
|
'economy.admin.remove': '{amount} von {user} entfernt. Neuer Kontostand: {balance}',
|
||||||
|
'economy.admin.reset': 'Economy-Daten von {user} zurückgesetzt.',
|
||||||
|
'economy.blackjack.title': 'Blackjack',
|
||||||
|
'economy.blackjack.hand': 'Einsatz: {bet}\n\n**Du:** {playerHand} ({playerTotal})\n**Dealer:** {dealerHand} ({dealerTotal})',
|
||||||
|
'economy.blackjack.button.hit': 'Hit',
|
||||||
|
'economy.blackjack.button.stand': 'Stand',
|
||||||
|
'economy.blackjack.outcome.win': 'Du gewinnst {bet}!',
|
||||||
|
'economy.blackjack.outcome.lose': 'Du verlierst deinen Einsatz.',
|
||||||
|
'economy.blackjack.outcome.push': 'Unentschieden – Einsatz zurück.',
|
||||||
|
'economy.blackjack.outcome.blackjack': 'Blackjack! Du gewinnst {bet}!',
|
||||||
|
'economy.blackjack.expired': 'Dieses Blackjack-Spiel ist abgelaufen oder existiert nicht mehr.',
|
||||||
|
'economy.blackjack.notOwner': 'Nur der Spieler kann diese Buttons nutzen.',
|
||||||
|
'utility.error.invalid_when': 'Ungültiger Zeitpunkt. Nutze z. B. 30m oder ein ISO-Datum in der Zukunft.',
|
||||||
|
'utility.error.invalid_datetime': 'Ungültiges Datum/Uhrzeit.',
|
||||||
|
'utility.error.invalid_poll_options': 'Es werden 2–5 Optionen benötigt (kommagetrennt).',
|
||||||
|
'utility.error.channel_not_found': 'Kanal nicht gefunden.',
|
||||||
|
'utility.error.role_not_found': 'Rolle nicht gefunden.',
|
||||||
|
'utility.error.poll_not_found': 'Umfrage nicht gefunden.',
|
||||||
|
'utility.error.reminder_channel_unavailable': 'Erinnerungskanal nicht erreichbar.',
|
||||||
|
'utility.poll.created': 'Umfrage erstellt.',
|
||||||
|
'utility.poll.closed': 'Diese Umfrage ist geschlossen.',
|
||||||
|
'utility.poll.notFound': 'Umfrage nicht gefunden.',
|
||||||
|
'utility.poll.invalidOption': 'Ungültige Option.',
|
||||||
|
'utility.poll.alreadyVoted': 'Du hast bereits abgestimmt.',
|
||||||
|
'utility.poll.voted': 'Stimme für **{option}** gezählt.',
|
||||||
|
'utility.poll.votes': 'Stimmen',
|
||||||
|
'utility.poll.endsAt': 'Endet {time}',
|
||||||
|
'utility.poll.multiSelect': 'Mehrfachauswahl',
|
||||||
|
'utility.poll.multiSelectHint': 'Du kannst mehrere Optionen wählen.',
|
||||||
|
'utility.remindme.created': 'Erinnerung erstellt (ID: `{id}`).',
|
||||||
|
'utility.reminders.none': 'Keine Erinnerungen.',
|
||||||
|
'utility.reminders.line': '`{id}` — {time}: {content}',
|
||||||
|
'utility.reminders.deleted': 'Erinnerung gelöscht.',
|
||||||
|
'utility.reminders.notFound': 'Erinnerung nicht gefunden.',
|
||||||
|
'utility.afk.set': 'AFK gesetzt. Grund: {reason}',
|
||||||
|
'utility.afk.welcomeBack': 'Willkommen zurück! Du warst AFK: {reason}',
|
||||||
|
'utility.afk.mention': '{user} ist AFK: {reason}',
|
||||||
|
'utility.avatar.title': 'Avatar von {user}',
|
||||||
|
'utility.banner.title': 'Banner von {user}',
|
||||||
|
'utility.banner.none': 'Kein Banner gesetzt.',
|
||||||
|
'utility.emoji.noSource': 'Anhang oder URL erforderlich.',
|
||||||
|
'utility.emoji.added': 'Emoji {emoji} hinzugefügt.',
|
||||||
|
'utility.emoji.removed': 'Emoji entfernt.',
|
||||||
|
'utility.emoji.notFound': 'Emoji nicht gefunden.',
|
||||||
|
'utility.emoji.messageNotFound': 'Nachricht nicht gefunden.',
|
||||||
|
'utility.emoji.noEmojiInMessage': 'Kein Custom-Emoji in der Nachricht.',
|
||||||
|
'utility.emoji.stolen': 'Emoji {emoji} gestohlen.',
|
||||||
|
'utility.sticker.noSource': 'Anhang oder URL erforderlich.',
|
||||||
|
'utility.sticker.added': 'Sticker **{name}** hinzugefügt.',
|
||||||
|
'utility.embed.sent': 'Embed gesendet.',
|
||||||
|
'utility.embed.invalidColor': 'Ungültige Farbe. Nutze z. B. 6366F1.',
|
||||||
|
'utility.snipe.none': 'Nichts zum Anzeigen.',
|
||||||
|
'utility.snipe.privacy':
|
||||||
|
'Datenschutz: Snipe speichert gelöschte/bearbeitete Nachrichten kurzzeitig (1 h) in Redis.',
|
||||||
|
'utility.snipe.deletedAt': 'Gelöschte Nachricht von {user}',
|
||||||
|
'utility.snipe.editedAt': 'Bearbeitete Nachricht von {user}',
|
||||||
|
'utility.translate.noContent': 'Kein übersetzbare Textinhalt.',
|
||||||
|
'utility.translate.failed': 'Übersetzung fehlgeschlagen.',
|
||||||
|
'utility.translate.result': '**Übersetzung:** {text}',
|
||||||
|
'fun.8ball.result': '**Frage:** {question}\n**Antwort:** {answer}',
|
||||||
|
'fun.dice.result': 'Würfel ({sides} Seiten): {rolls}',
|
||||||
|
'fun.flip.result': 'Die Münze zeigt: **{result}**',
|
||||||
|
'fun.rps.win': 'Du wähltest **{player}**, ich **{bot}**. Du gewinnst!',
|
||||||
|
'fun.rps.lose': 'Du wähltest **{player}**, ich **{bot}**. Du verlierst!',
|
||||||
|
'fun.rps.draw': 'Beide wählten **{player}**. Unentschieden!',
|
||||||
|
'fun.rps.choice.rock': 'Stein',
|
||||||
|
'fun.rps.choice.paper': 'Papier',
|
||||||
|
'fun.rps.choice.scissors': 'Schere',
|
||||||
|
'fun.choose.result': 'Ich wähle: **{choice}**',
|
||||||
|
'fun.choose.tooFew': 'Gib mindestens zwei Optionen an (kommagetrennt).',
|
||||||
|
'fun.trivia.title': 'Trivia',
|
||||||
|
'fun.trivia.question': '**{question}**\n\n{options}',
|
||||||
|
'fun.trivia.footer': 'Nur {user} kann antworten.',
|
||||||
|
'fun.trivia.correct': '✅ Richtig!',
|
||||||
|
'fun.trivia.incorrect': '❌ Falsch!',
|
||||||
|
'fun.trivia.notOwner': 'Nur der Initiator kann diese Frage beantworten.',
|
||||||
|
'fun.tictactoe.title': 'Tic-Tac-Toe',
|
||||||
|
'fun.tictactoe.board': '❌: {x} | ⭕: {o}\n\n{board}',
|
||||||
|
'fun.tictactoe.challenge': '{challenger} fordert {opponent} zu Tic-Tac-Toe heraus!',
|
||||||
|
'fun.tictactoe.turn': 'Am Zug: {user}',
|
||||||
|
'fun.tictactoe.won': '{user} gewinnt!',
|
||||||
|
'fun.tictactoe.draw': 'Unentschieden!',
|
||||||
|
'fun.tictactoe.invalidMove': 'Ungültiger Zug.',
|
||||||
|
'fun.connect4.title': 'Connect 4',
|
||||||
|
'fun.connect4.board': '🔴: {red} | 🟡: {yellow}\n\n{board}',
|
||||||
|
'fun.connect4.challenge': '{challenger} fordert {opponent} zu Connect 4 heraus!',
|
||||||
|
'fun.connect4.turn': 'Am Zug: {user}',
|
||||||
|
'fun.connect4.won': '{user} gewinnt!',
|
||||||
|
'fun.connect4.draw': 'Unentschieden!',
|
||||||
|
'fun.connect4.invalidMove': 'Diese Spalte ist voll.',
|
||||||
|
'fun.hangman.title': 'Hangman',
|
||||||
|
'fun.hangman.progress': 'Wort: {word}\nFehler: {wrong}/{max}\nGeraten: {guessed}',
|
||||||
|
'fun.hangman.won': 'Gewonnen! Das Wort war **{word}**.',
|
||||||
|
'fun.hangman.lost': 'Verloren! Das Wort war **{word}**.',
|
||||||
|
'fun.common.notParticipant': 'Du bist nicht an diesem Spiel beteiligt.',
|
||||||
|
'fun.common.expired': 'Dieses Spiel ist abgelaufen oder existiert nicht mehr.',
|
||||||
|
'fun.common.selfChallenge': 'Du kannst dich nicht selbst herausfordern.',
|
||||||
|
'fun.common.botChallenge': 'Du kannst keinen Bot herausfordern.',
|
||||||
|
'fun.media.disabled': 'Medien-Commands sind auf diesem Server deaktiviert.',
|
||||||
|
'fun.media.fetchFailed': 'Bild konnte nicht geladen werden. Versuche es später erneut.',
|
||||||
|
'fun.cat.title': 'Random Cat',
|
||||||
|
'fun.dog.title': 'Random Dog'
|
||||||
};
|
};
|
||||||
const en: Dictionary = {
|
const en: Dictionary = {
|
||||||
'generic.noPermission': 'You do not have permission for this action.',
|
'generic.noPermission': 'You do not have permission for this action.',
|
||||||
@@ -148,7 +294,152 @@ const en: Dictionary = {
|
|||||||
'verification.error.generic': 'Verification failed.',
|
'verification.error.generic': 'Verification failed.',
|
||||||
'verification.captcha.success': 'Captcha passed. You have been verified.',
|
'verification.captcha.success': 'Captcha passed. You have been verified.',
|
||||||
'verification.captcha.invalid': 'Captcha invalid or expired.',
|
'verification.captcha.invalid': 'Captcha invalid or expired.',
|
||||||
'verification.captcha.title': 'Nexumi Verification'
|
'verification.captcha.title': 'Nexumi Verification',
|
||||||
|
'leveling.disabled': 'Leveling is disabled on this server.',
|
||||||
|
'leveling.rank.title': 'Rank card',
|
||||||
|
'leveling.leaderboard.title': 'Leaderboard',
|
||||||
|
'leveling.leaderboard.empty': 'No XP data yet.',
|
||||||
|
'leveling.leaderboard.entry': '{rank}. {user} — Level {level} ({xp} XP)',
|
||||||
|
'leveling.leaderboard.footer': 'Top {count} members',
|
||||||
|
'leveling.levelUp.default': 'Congratulations {user}, you reached level {level}!',
|
||||||
|
'leveling.xp.give.success': 'Gave {amount} XP to {user}. New level: {level}.',
|
||||||
|
'leveling.xp.remove.success': 'Removed {amount} XP from {user}. New level: {level}.',
|
||||||
|
'leveling.xp.reset.success': 'Reset XP for {user}.',
|
||||||
|
'leveling.xp.invalidAmount': 'Amount must be greater than 0.',
|
||||||
|
'leveling.user.notFound': 'User not found on this server.',
|
||||||
|
'economy.error.disabled': 'The economy module is disabled on this server.',
|
||||||
|
'economy.error.insufficient': 'You do not have enough money.',
|
||||||
|
'economy.error.cooldown': 'Cooldown active. Try again in {seconds} seconds.',
|
||||||
|
'economy.error.invalid_amount': 'Invalid amount.',
|
||||||
|
'economy.error.self_transfer': 'You cannot send money to yourself.',
|
||||||
|
'economy.error.not_found': 'Entry not found.',
|
||||||
|
'economy.error.out_of_stock': 'This item is out of stock.',
|
||||||
|
'economy.error.role_assign_failed': 'Purchase succeeded, but the role could not be assigned.',
|
||||||
|
'economy.error.game_in_progress': 'You already have an active blackjack game.',
|
||||||
|
'economy.balance.result': 'Balance of {user}: {amount}',
|
||||||
|
'economy.daily.success': 'Daily reward: {amount}. New balance: {balance}',
|
||||||
|
'economy.weekly.success': 'Weekly reward: {amount}. New balance: {balance}',
|
||||||
|
'economy.work.success': 'You earned {amount}. New balance: {balance}',
|
||||||
|
'economy.pay.success': 'You sent {amount} to {user}. Your balance: {balance}',
|
||||||
|
'economy.gamble.win': 'You won! +{amount}. Balance: {balance}',
|
||||||
|
'economy.gamble.lose': 'You lost! -{amount}. Balance: {balance}',
|
||||||
|
'economy.slots.win': '{reels}\nWinnings: {payout}. Balance: {balance}',
|
||||||
|
'economy.slots.lose': '{reels}\nNo win. Balance: {balance}',
|
||||||
|
'economy.coinflip.win': '{result} – you picked {choice}. You won! +{amount}. Balance: {balance}',
|
||||||
|
'economy.coinflip.lose': '{result} – you picked {choice}. You lost! -{amount}. Balance: {balance}',
|
||||||
|
'economy.choice.heads': 'Heads',
|
||||||
|
'economy.choice.tails': 'Tails',
|
||||||
|
'economy.shop.empty': 'The shop is empty.',
|
||||||
|
'economy.shop.noDescription': 'No description',
|
||||||
|
'economy.shop.itemLine': '**{name}** (`{id}`) – {price} | Stock: {stock}\n{description}',
|
||||||
|
'economy.shop.purchased': 'You purchased **{name}**. Balance: {balance}',
|
||||||
|
'economy.inventory.empty': 'Your inventory is empty.',
|
||||||
|
'economy.inventory.itemLine': '- {name} × {quantity}',
|
||||||
|
'economy.leaderboard.empty': 'No leaderboard entries yet.',
|
||||||
|
'economy.leaderboard.line': '{rank}. {user} – {amount}',
|
||||||
|
'economy.admin.give': 'Gave {amount} to {user}. New balance: {balance}',
|
||||||
|
'economy.admin.remove': 'Removed {amount} from {user}. New balance: {balance}',
|
||||||
|
'economy.admin.reset': 'Reset economy data for {user}.',
|
||||||
|
'economy.blackjack.title': 'Blackjack',
|
||||||
|
'economy.blackjack.hand': 'Bet: {bet}\n\n**You:** {playerHand} ({playerTotal})\n**Dealer:** {dealerHand} ({dealerTotal})',
|
||||||
|
'economy.blackjack.button.hit': 'Hit',
|
||||||
|
'economy.blackjack.button.stand': 'Stand',
|
||||||
|
'economy.blackjack.outcome.win': 'You win {bet}!',
|
||||||
|
'economy.blackjack.outcome.lose': 'You lose your bet.',
|
||||||
|
'economy.blackjack.outcome.push': 'Push – bet returned.',
|
||||||
|
'economy.blackjack.outcome.blackjack': 'Blackjack! You win {bet}!',
|
||||||
|
'economy.blackjack.expired': 'This blackjack game expired or no longer exists.',
|
||||||
|
'economy.blackjack.notOwner': 'Only the player can use these buttons.',
|
||||||
|
'utility.error.invalid_when': 'Invalid time. Use e.g. 30m or a future ISO datetime.',
|
||||||
|
'utility.error.invalid_datetime': 'Invalid date/time.',
|
||||||
|
'utility.error.invalid_poll_options': 'Provide 2–5 comma-separated options.',
|
||||||
|
'utility.error.channel_not_found': 'Channel not found.',
|
||||||
|
'utility.error.role_not_found': 'Role not found.',
|
||||||
|
'utility.error.poll_not_found': 'Poll not found.',
|
||||||
|
'utility.error.reminder_channel_unavailable': 'Reminder channel unavailable.',
|
||||||
|
'utility.poll.created': 'Poll created.',
|
||||||
|
'utility.poll.closed': 'This poll is closed.',
|
||||||
|
'utility.poll.notFound': 'Poll not found.',
|
||||||
|
'utility.poll.invalidOption': 'Invalid option.',
|
||||||
|
'utility.poll.alreadyVoted': 'You already voted.',
|
||||||
|
'utility.poll.voted': 'Vote counted for **{option}**.',
|
||||||
|
'utility.poll.votes': 'votes',
|
||||||
|
'utility.poll.endsAt': 'Ends {time}',
|
||||||
|
'utility.poll.multiSelect': 'Multi-select',
|
||||||
|
'utility.poll.multiSelectHint': 'You may select multiple options.',
|
||||||
|
'utility.remindme.created': 'Reminder created (ID: `{id}`).',
|
||||||
|
'utility.reminders.none': 'No reminders.',
|
||||||
|
'utility.reminders.line': '`{id}` — {time}: {content}',
|
||||||
|
'utility.reminders.deleted': 'Reminder deleted.',
|
||||||
|
'utility.reminders.notFound': 'Reminder not found.',
|
||||||
|
'utility.afk.set': 'AFK set. Reason: {reason}',
|
||||||
|
'utility.afk.welcomeBack': 'Welcome back! You were AFK: {reason}',
|
||||||
|
'utility.afk.mention': '{user} is AFK: {reason}',
|
||||||
|
'utility.avatar.title': 'Avatar of {user}',
|
||||||
|
'utility.banner.title': 'Banner of {user}',
|
||||||
|
'utility.banner.none': 'No banner set.',
|
||||||
|
'utility.emoji.noSource': 'Attachment or URL required.',
|
||||||
|
'utility.emoji.added': 'Added emoji {emoji}.',
|
||||||
|
'utility.emoji.removed': 'Emoji removed.',
|
||||||
|
'utility.emoji.notFound': 'Emoji not found.',
|
||||||
|
'utility.emoji.messageNotFound': 'Message not found.',
|
||||||
|
'utility.emoji.noEmojiInMessage': 'No custom emoji in message.',
|
||||||
|
'utility.emoji.stolen': 'Stole emoji {emoji}.',
|
||||||
|
'utility.sticker.noSource': 'Attachment or URL required.',
|
||||||
|
'utility.sticker.added': 'Added sticker **{name}**.',
|
||||||
|
'utility.embed.sent': 'Embed sent.',
|
||||||
|
'utility.embed.invalidColor': 'Invalid color. Use e.g. 6366F1.',
|
||||||
|
'utility.snipe.none': 'Nothing to show.',
|
||||||
|
'utility.snipe.privacy':
|
||||||
|
'Privacy: Snipe stores deleted/edited messages briefly (1 h) in Redis.',
|
||||||
|
'utility.snipe.deletedAt': 'Deleted message by {user}',
|
||||||
|
'utility.snipe.editedAt': 'Edited message by {user}',
|
||||||
|
'utility.translate.noContent': 'No translatable text content.',
|
||||||
|
'utility.translate.failed': 'Translation failed.',
|
||||||
|
'utility.translate.result': '**Translation:** {text}',
|
||||||
|
'fun.8ball.result': '**Question:** {question}\n**Answer:** {answer}',
|
||||||
|
'fun.dice.result': 'Dice ({sides} sides): {rolls}',
|
||||||
|
'fun.flip.result': 'The coin shows: **{result}**',
|
||||||
|
'fun.rps.win': 'You picked **{player}**, I picked **{bot}**. You win!',
|
||||||
|
'fun.rps.lose': 'You picked **{player}**, I picked **{bot}**. You lose!',
|
||||||
|
'fun.rps.draw': 'Both picked **{player}**. Draw!',
|
||||||
|
'fun.rps.choice.rock': 'Rock',
|
||||||
|
'fun.rps.choice.paper': 'Paper',
|
||||||
|
'fun.rps.choice.scissors': 'Scissors',
|
||||||
|
'fun.choose.result': 'I choose: **{choice}**',
|
||||||
|
'fun.choose.tooFew': 'Provide at least two comma-separated options.',
|
||||||
|
'fun.trivia.title': 'Trivia',
|
||||||
|
'fun.trivia.question': '**{question}**\n\n{options}',
|
||||||
|
'fun.trivia.footer': 'Only {user} can answer.',
|
||||||
|
'fun.trivia.correct': '✅ Correct!',
|
||||||
|
'fun.trivia.incorrect': '❌ Wrong!',
|
||||||
|
'fun.trivia.notOwner': 'Only the initiator can answer this question.',
|
||||||
|
'fun.tictactoe.title': 'Tic-Tac-Toe',
|
||||||
|
'fun.tictactoe.board': '❌: {x} | ⭕: {o}\n\n{board}',
|
||||||
|
'fun.tictactoe.challenge': '{challenger} challenges {opponent} to Tic-Tac-Toe!',
|
||||||
|
'fun.tictactoe.turn': 'Turn: {user}',
|
||||||
|
'fun.tictactoe.won': '{user} wins!',
|
||||||
|
'fun.tictactoe.draw': 'Draw!',
|
||||||
|
'fun.tictactoe.invalidMove': 'Invalid move.',
|
||||||
|
'fun.connect4.title': 'Connect 4',
|
||||||
|
'fun.connect4.board': '🔴: {red} | 🟡: {yellow}\n\n{board}',
|
||||||
|
'fun.connect4.challenge': '{challenger} challenges {opponent} to Connect 4!',
|
||||||
|
'fun.connect4.turn': 'Turn: {user}',
|
||||||
|
'fun.connect4.won': '{user} wins!',
|
||||||
|
'fun.connect4.draw': 'Draw!',
|
||||||
|
'fun.connect4.invalidMove': 'This column is full.',
|
||||||
|
'fun.hangman.title': 'Hangman',
|
||||||
|
'fun.hangman.progress': 'Word: {word}\nWrong: {wrong}/{max}\nGuessed: {guessed}',
|
||||||
|
'fun.hangman.won': 'You won! The word was **{word}**.',
|
||||||
|
'fun.hangman.lost': 'You lost! The word was **{word}**.',
|
||||||
|
'fun.common.notParticipant': 'You are not a participant in this game.',
|
||||||
|
'fun.common.expired': 'This game expired or no longer exists.',
|
||||||
|
'fun.common.selfChallenge': 'You cannot challenge yourself.',
|
||||||
|
'fun.common.botChallenge': 'You cannot challenge a bot.',
|
||||||
|
'fun.media.disabled': 'Media commands are disabled on this server.',
|
||||||
|
'fun.media.fetchFailed': 'Could not load image. Try again later.',
|
||||||
|
'fun.cat.title': 'Random Cat',
|
||||||
|
'fun.dog.title': 'Random Dog'
|
||||||
};
|
};
|
||||||
|
|
||||||
const locales: Record<Locale, Dictionary> = { de, en };
|
const locales: Record<Locale, Dictionary> = { de, en };
|
||||||
|
|||||||
112
packages/shared/src/phase3.ts
Normal file
112
packages/shared/src/phase3.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user