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[]
|
||||
welcomeConfig WelcomeConfig?
|
||||
verificationConfig VerificationConfig?
|
||||
levelingConfig LevelingConfig?
|
||||
memberLevels MemberLevel[]
|
||||
levelRewards LevelReward[]
|
||||
economyConfig EconomyConfig?
|
||||
memberEconomies MemberEconomy[]
|
||||
shopItems ShopItem[]
|
||||
inventoryItems InventoryItem[]
|
||||
polls Poll[]
|
||||
reminders Reminder[]
|
||||
afkStatuses AfkStatus[]
|
||||
}
|
||||
|
||||
model GuildSettings {
|
||||
@@ -203,3 +213,179 @@ model VerificationConfig {
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model LevelingConfig {
|
||||
id String @id @default(cuid())
|
||||
guildId String @unique
|
||||
enabled Boolean @default(true)
|
||||
textXpMin Int @default(15)
|
||||
textXpMax Int @default(25)
|
||||
textCooldownSeconds Int @default(60)
|
||||
voiceXpPerMinute Int @default(10)
|
||||
noXpChannelIds String[] @default([])
|
||||
noXpRoleIds String[] @default([])
|
||||
roleMultipliers Json?
|
||||
channelMultipliers Json?
|
||||
levelUpMode String @default("CHANNEL")
|
||||
levelUpChannelId String?
|
||||
levelUpMessage String?
|
||||
stackRewards Boolean @default(true)
|
||||
cardAccentColor String @default("#6366F1")
|
||||
cardBackgroundColor String @default("#111827")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model MemberLevel {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
userId String
|
||||
xp Int @default(0)
|
||||
level Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, userId])
|
||||
@@index([guildId, xp])
|
||||
}
|
||||
|
||||
model LevelReward {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
level Int
|
||||
roleId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, level])
|
||||
@@index([guildId])
|
||||
}
|
||||
|
||||
model EconomyConfig {
|
||||
id String @id @default(cuid())
|
||||
guildId String @unique
|
||||
enabled Boolean @default(true)
|
||||
currencyName String @default("Coins")
|
||||
currencySymbol String @default("🪙")
|
||||
dailyAmount Int @default(200)
|
||||
weeklyAmount Int @default(1000)
|
||||
workMin Int @default(50)
|
||||
workMax Int @default(150)
|
||||
workCooldownSeconds Int @default(3600)
|
||||
startingBalance Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model MemberEconomy {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
userId String
|
||||
balance Int @default(0)
|
||||
lastDailyAt DateTime?
|
||||
lastWeeklyAt DateTime?
|
||||
lastWorkAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, userId])
|
||||
@@index([guildId, balance])
|
||||
}
|
||||
|
||||
model ShopItem {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
name String
|
||||
description String?
|
||||
price Int
|
||||
roleId String?
|
||||
stock Int?
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
inventory InventoryItem[]
|
||||
|
||||
@@index([guildId, enabled])
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
userId String
|
||||
itemId String
|
||||
quantity Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
item ShopItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, userId, itemId])
|
||||
@@index([guildId, userId])
|
||||
}
|
||||
|
||||
model Poll {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
channelId String
|
||||
messageId String?
|
||||
creatorId String
|
||||
question String
|
||||
options Json
|
||||
multiSelect Boolean @default(false)
|
||||
anonymous Boolean @default(false)
|
||||
endsAt DateTime?
|
||||
closedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
votes PollVote[]
|
||||
|
||||
@@index([guildId])
|
||||
}
|
||||
|
||||
model PollVote {
|
||||
id String @id @default(cuid())
|
||||
pollId String
|
||||
userId String
|
||||
optionIndex Int
|
||||
createdAt DateTime @default(now())
|
||||
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([pollId, userId, optionIndex])
|
||||
@@index([pollId])
|
||||
}
|
||||
|
||||
model Reminder {
|
||||
id String @id @default(cuid())
|
||||
guildId String?
|
||||
userId String
|
||||
channelId String
|
||||
content String
|
||||
remindAt DateTime
|
||||
recurringCron String?
|
||||
jobId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([remindAt])
|
||||
}
|
||||
|
||||
model AfkStatus {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
userId String
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([guildId, userId])
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@ import type { BotContext } from './types.js';
|
||||
import { env } from './env.js';
|
||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||
import { completeVerification } from './modules/verification/handlers.js';
|
||||
import { closePoll } from './modules/utility/poll.js';
|
||||
import { sendReminder } from './modules/utility/reminders.js';
|
||||
import {
|
||||
automodQueue,
|
||||
automodQueueName,
|
||||
backupQueue,
|
||||
backupQueueName,
|
||||
moderationQueueName,
|
||||
reminderQueue,
|
||||
reminderQueueName,
|
||||
verificationQueueName
|
||||
} from './queues.js';
|
||||
|
||||
@@ -23,6 +27,7 @@ export {
|
||||
backupQueue,
|
||||
moderationQueue,
|
||||
moderationQueueName,
|
||||
reminderQueue,
|
||||
verificationQueue
|
||||
} from './queues.js';
|
||||
|
||||
@@ -37,6 +42,14 @@ type VerificationCompleteJob = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type ReminderSendJob = {
|
||||
reminderId: string;
|
||||
};
|
||||
|
||||
type PollCloseJob = {
|
||||
pollId: string;
|
||||
};
|
||||
|
||||
export function startWorkers(context: BotContext): Worker[] {
|
||||
const moderationWorker = new Worker<TempBanJob>(
|
||||
moderationQueueName,
|
||||
@@ -105,7 +118,25 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
logger.error({ jobId: job?.id, error }, 'Verification job failed');
|
||||
});
|
||||
|
||||
return [moderationWorker, backupWorker, automodWorker, verificationWorker];
|
||||
const reminderWorker = new Worker<ReminderSendJob | PollCloseJob>(
|
||||
reminderQueueName,
|
||||
async (job) => {
|
||||
if (job.name === 'reminderSend') {
|
||||
await sendReminder(context, (job.data as ReminderSendJob).reminderId);
|
||||
return;
|
||||
}
|
||||
if (job.name === 'pollClose') {
|
||||
await closePoll(context, (job.data as PollCloseJob).pollId);
|
||||
}
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
reminderWorker.on('failed', (job, error) => {
|
||||
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
|
||||
});
|
||||
|
||||
return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker];
|
||||
}
|
||||
|
||||
export async function ensureRecurringJobs(): Promise<void> {
|
||||
|
||||
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 verificationQueueName = 'verification';
|
||||
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 { ChatInputCommandInteraction, Client } from 'discord.js';
|
||||
import type {
|
||||
ChatInputCommandInteraction,
|
||||
Client,
|
||||
MessageContextMenuCommandInteraction
|
||||
} from 'discord.js';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
export type BotContext = {
|
||||
@@ -12,3 +16,11 @@ export type SlashCommand = {
|
||||
data: { name: string; toJSON: () => unknown };
|
||||
execute: (interaction: ChatInputCommandInteraction, context: BotContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export type ContextMenuCommand = {
|
||||
data: { name: string; toJSON: () => unknown };
|
||||
execute: (
|
||||
interaction: MessageContextMenuCommandInteraction,
|
||||
context: BotContext
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user