Implement leveling and economy features with associated models and commands

- Added new models for leveling and economy functionalities in the Prisma schema, including LevelingConfig, MemberLevel, LevelReward, EconomyConfig, MemberEconomy, ShopItem, and InventoryItem.
- Introduced commands for managing XP, economy transactions, and shop interactions, enhancing user engagement.
- Updated job handling to include reminders and poll closing functionalities.
- Enhanced localization support for new commands and features in both German and English.
- Improved the bot's job processing capabilities with a dedicated reminder worker for scheduled tasks.
This commit is contained in:
smueller
2026-07-22 12:49:17 +02:00
parent 2518119257
commit fa5910ec43
38 changed files with 6759 additions and 144 deletions

View File

@@ -0,0 +1,50 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
BLACKJACK_BUTTON_PREFIX,
hitBlackjack,
parseBlackjackButtonCustomId,
standBlackjack
} from './games.js';
import { EconomyError } from './service.js';
export function isBlackjackButton(customId: string): boolean {
return customId.startsWith(BLACKJACK_BUTTON_PREFIX);
}
export async function handleBlackjackButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const parsed = parseBlackjackButtonCustomId(interaction.customId);
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!parsed || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== parsed.userId) {
await interaction.reply({ content: t(locale, 'economy.blackjack.notOwner'), ephemeral: true });
return;
}
try {
if (parsed.action === 'hit') {
const result = await hitBlackjack(context, parsed.guildId, parsed.userId, locale);
await interaction.update({ embeds: [result.embed], components: result.components });
return;
}
const result = await standBlackjack(context, parsed.guildId, parsed.userId, locale);
await interaction.update({ embeds: [result.embed], components: [] });
} catch (error) {
if (error instanceof EconomyError && error.code === 'not_found') {
await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true });
return;
}
throw error;
}
}

View File

@@ -0,0 +1,121 @@
import {
SlashCommandBuilder,
type SlashCommandIntegerOption,
type SlashCommandStringOption,
type SlashCommandUserOption
} from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice,
type CommandLocaleKey
} from '@nexumi/shared';
function amountOpt(o: SlashCommandIntegerOption, key: CommandLocaleKey = 'economy.common.options.amount') {
return applyOptionDescription(o.setName('amount').setRequired(true).setMinValue(1), key);
}
function userOpt(o: SlashCommandUserOption, key: CommandLocaleKey = 'economy.common.options.user') {
return applyOptionDescription(o.setName('user').setRequired(true), key);
}
function optionalUserOpt(o: SlashCommandUserOption) {
return applyOptionDescription(o.setName('user'), 'economy.common.options.userOptional');
}
function choiceOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('choice').setRequired(true), 'economy.coinflip.options.choice').addChoices(
localizedChoice('economy.choice.heads', 'heads'),
localizedChoice('economy.choice.tails', 'tails')
);
}
export const balanceCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('balance'),
'economy.balance.description'
).addUserOption((o) => optionalUserOpt(o));
export const dailyCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('daily'),
'economy.daily.description'
);
export const weeklyCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('weekly'),
'economy.weekly.description'
);
export const workCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('work'),
'economy.work.description'
);
export const payCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('pay'),
'economy.pay.description'
)
.addUserOption((o) => userOpt(o))
.addIntegerOption((o) => amountOpt(o));
export const gambleCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('gamble'),
'economy.gamble.description'
).addIntegerOption((o) => amountOpt(o));
export const slotsCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('slots'),
'economy.slots.description'
).addIntegerOption((o) => amountOpt(o));
export const blackjackCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('blackjack'),
'economy.blackjack.description'
).addIntegerOption((o) => amountOpt(o));
export const coinflipCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('coinflip'),
'economy.coinflip.description'
)
.addIntegerOption((o) => amountOpt(o))
.addStringOption((o) => choiceOpt(o));
export const shopCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('shop'),
'economy.shop.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('view'), 'economy.shop.view.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('buy'), 'economy.shop.buy.description').addStringOption((o) =>
applyOptionDescription(o.setName('item_id').setRequired(true), 'economy.shop.options.item_id')
)
);
export const inventoryCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('inventory'),
'economy.inventory.description'
);
export const ecoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('eco'),
'economy.eco.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('leaderboard'), 'economy.eco.leaderboard.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('give'), 'economy.eco.give.description')
.addUserOption((o) => userOpt(o, 'economy.common.options.target'))
.addIntegerOption((o) => amountOpt(o))
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'economy.eco.remove.description')
.addUserOption((o) => userOpt(o, 'economy.common.options.target'))
.addIntegerOption((o) => amountOpt(o))
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('reset'), 'economy.eco.reset.description').addUserOption((o) =>
userOpt(o, 'economy.common.options.target')
)
);

View File

@@ -0,0 +1,497 @@
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import { formatCurrency, randomInt, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import {
balanceCommandData,
blackjackCommandData,
coinflipCommandData,
dailyCommandData,
ecoCommandData,
gambleCommandData,
inventoryCommandData,
payCommandData,
shopCommandData,
slotsCommandData,
weeklyCommandData,
workCommandData
} from './command-definitions.js';
import {
adminGiveBalance,
adminRemoveBalance,
adminResetEconomy,
assertEconomyEnabled,
buyShopItem,
claimDaily,
claimWeekly,
claimWork,
EconomyError,
formatCooldownSeconds,
getBalance,
getDailyCooldownRemaining,
getEconomyConfig,
getInventory,
getLeaderboard,
getWeeklyCooldownRemaining,
getWorkCooldownRemaining,
listShopItems,
transferBalance
} from './service.js';
import { playCoinflipBet, playGamble, playSlots, startBlackjack, type CoinflipChoice } from './games.js';
async function ensureGuildCommand(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en'
): Promise<string | null> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return null;
}
return interaction.guildId!;
}
async function handleEconomyError(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
error: unknown,
extras?: Record<string, string | number>
): Promise<void> {
if (!(error instanceof EconomyError)) {
throw error;
}
const key = `economy.error.${error.code}` as const;
const content =
error.code === 'cooldown' && extras?.seconds !== undefined
? tf(locale, 'economy.error.cooldown', { seconds: extras.seconds })
: t(locale, key);
await interaction.reply({ content, ephemeral: true });
}
function formatMoney(locale: 'de' | 'en', amount: number, symbol: string, name: string): string {
return formatCurrency(amount, symbol, name);
}
const balanceCommand: SlashCommand = {
data: balanceCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await assertEconomyEnabled(context.prisma, guildId);
const target = interaction.options.getUser('user') ?? interaction.user;
const balance = await getBalance(context.prisma, guildId, target.id);
await interaction.reply({
content: tf(locale, 'economy.balance.result', {
user: `<@${target.id}>`,
amount: formatMoney(locale, balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const dailyCommand: SlashCommand = {
data: dailyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await claimDaily(context.prisma, guildId, interaction.user.id);
await interaction.reply({
content: tf(locale, 'economy.daily.success', {
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
if (error instanceof EconomyError && error.code === 'cooldown') {
const seconds = formatCooldownSeconds(
await getDailyCooldownRemaining(context.prisma, guildId, interaction.user.id)
);
await handleEconomyError(interaction, locale, error, { seconds });
return;
}
await handleEconomyError(interaction, locale, error);
}
}
};
const weeklyCommand: SlashCommand = {
data: weeklyCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await claimWeekly(context.prisma, guildId, interaction.user.id);
await interaction.reply({
content: tf(locale, 'economy.weekly.success', {
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
if (error instanceof EconomyError && error.code === 'cooldown') {
const seconds = formatCooldownSeconds(
await getWeeklyCooldownRemaining(context.prisma, guildId, interaction.user.id)
);
await handleEconomyError(interaction, locale, error, { seconds });
return;
}
await handleEconomyError(interaction, locale, error);
}
}
};
const workCommand: SlashCommand = {
data: workCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const earned = randomInt(config.workMin, config.workMax);
const result = await claimWork(context.prisma, guildId, interaction.user.id, earned);
await interaction.reply({
content: tf(locale, 'economy.work.success', {
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
if (error instanceof EconomyError && error.code === 'cooldown') {
const seconds = formatCooldownSeconds(
await getWorkCooldownRemaining(context.prisma, guildId, interaction.user.id)
);
await handleEconomyError(interaction, locale, error, { seconds });
return;
}
await handleEconomyError(interaction, locale, error);
}
}
};
const payCommand: SlashCommand = {
data: payCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const target = interaction.options.getUser('user', true);
const amount = interaction.options.getInteger('amount', true);
try {
await assertEconomyEnabled(context.prisma, guildId);
const config = await getEconomyConfig(context.prisma, guildId);
const { sender } = await transferBalance(
context.prisma,
guildId,
interaction.user.id,
target.id,
amount
);
await interaction.reply({
content: tf(locale, 'economy.pay.success', {
user: `<@${target.id}>`,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, sender.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const gambleCommand: SlashCommand = {
data: gambleCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await playGamble(context, guildId, interaction.user.id, amount);
await interaction.reply({
content: tf(locale, result.won ? 'economy.gamble.win' : 'economy.gamble.lose', {
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const slotsCommand: SlashCommand = {
data: slotsCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await playSlots(context, guildId, interaction.user.id, amount);
const key = result.payout > 0 ? 'economy.slots.win' : 'economy.slots.lose';
await interaction.reply({
content: tf(locale, key, {
reels: result.reels.join(' | '),
payout: formatMoney(locale, result.payout, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const blackjackCommand: SlashCommand = {
data: blackjackCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
try {
const { embed, components } = await startBlackjack(
context,
guildId,
interaction.user.id,
amount,
locale
);
await interaction.reply({ embeds: [embed], components });
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const coinflipCommand: SlashCommand = {
data: coinflipCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const amount = interaction.options.getInteger('amount', true);
const choice = interaction.options.getString('choice', true) as CoinflipChoice;
try {
const config = await getEconomyConfig(context.prisma, guildId);
const result = await playCoinflipBet(context, guildId, interaction.user.id, amount, choice);
const choiceLabel = t(locale, `economy.choice.${choice}`);
const resultLabel = t(locale, `economy.choice.${result.result}`);
await interaction.reply({
content: tf(locale, result.won ? 'economy.coinflip.win' : 'economy.coinflip.lose', {
choice: choiceLabel,
result: resultLabel,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const shopCommand: SlashCommand = {
data: shopCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const sub = interaction.options.getSubcommand();
try {
const config = await getEconomyConfig(context.prisma, guildId);
if (sub === 'view') {
const items = await listShopItems(context.prisma, guildId);
const content =
items.length === 0
? t(locale, 'economy.shop.empty')
: items
.map((item) =>
tf(locale, 'economy.shop.itemLine', {
id: item.id,
name: item.name,
price: formatMoney(locale, item.price, config.currencySymbol, config.currencyName),
stock: item.stock === null ? '∞' : String(item.stock),
description: item.description ?? t(locale, 'economy.shop.noDescription')
})
)
.join('\n');
await interaction.reply({ content, ephemeral: true });
return;
}
const itemId = interaction.options.getString('item_id', true);
const purchase = await buyShopItem(context, guildId, interaction.user.id, itemId);
await interaction.reply({
content: tf(locale, 'economy.shop.purchased', {
name: purchase.item.name,
balance: formatMoney(locale, purchase.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const inventoryCommand: SlashCommand = {
data: inventoryCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
try {
const entries = await getInventory(context.prisma, guildId, interaction.user.id);
const content =
entries.length === 0
? t(locale, 'economy.inventory.empty')
: entries
.map((entry) =>
tf(locale, 'economy.inventory.itemLine', {
name: entry.item.name,
quantity: entry.quantity
})
)
.join('\n');
await interaction.reply({ content, ephemeral: true });
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
const ecoCommand: SlashCommand = {
data: ecoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuildCommand(interaction, locale);
if (!guildId) return;
const sub = interaction.options.getSubcommand();
if (sub === 'leaderboard') {
try {
const config = await getEconomyConfig(context.prisma, guildId);
const rows = await getLeaderboard(context.prisma, guildId);
const content =
rows.length === 0
? t(locale, 'economy.leaderboard.empty')
: rows
.map((row, index) =>
tf(locale, 'economy.leaderboard.line', {
rank: index + 1,
user: `<@${row.userId}>`,
amount: formatMoney(locale, row.balance, config.currencySymbol, config.currencyName)
})
)
.join('\n');
await interaction.reply({ content, ephemeral: true });
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const target = interaction.options.getUser('user', true);
try {
const config = await getEconomyConfig(context.prisma, guildId);
if (sub === 'give') {
const amount = interaction.options.getInteger('amount', true);
const updated = await adminGiveBalance(context.prisma, guildId, target.id, amount);
await interaction.reply({
content: tf(locale, 'economy.admin.give', {
user: `<@${target.id}>`,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const amount = interaction.options.getInteger('amount', true);
const updated = await adminRemoveBalance(context.prisma, guildId, target.id, amount);
await interaction.reply({
content: tf(locale, 'economy.admin.remove', {
user: `<@${target.id}>`,
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName)
}),
ephemeral: true
});
return;
}
await adminResetEconomy(context.prisma, guildId, target.id);
await interaction.reply({
content: tf(locale, 'economy.admin.reset', {
user: `<@${target.id}>`
}),
ephemeral: true
});
} catch (error) {
await handleEconomyError(interaction, locale, error);
}
}
};
export const economyCommands: SlashCommand[] = [
balanceCommand,
dailyCommand,
weeklyCommand,
workCommand,
payCommand,
gambleCommand,
slotsCommand,
blackjackCommand,
coinflipCommand,
shopCommand,
inventoryCommand,
ecoCommand
];

View File

@@ -0,0 +1,420 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type APIEmbed } from 'discord.js';
import { formatCurrency, randomInt } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import type { Locale } from '@nexumi/shared';
import { tf } from '@nexumi/shared';
import {
addBalance,
assertEconomyEnabled,
deductBalance,
EconomyError,
getEconomyConfig
} from './service.js';
const BLACKJACK_TTL_SECONDS = 300;
const BLACKJACK_KEY_PREFIX = 'eco:bj:';
export const BLACKJACK_BUTTON_PREFIX = 'eco:bj:';
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] as const;
const SUITS = ['H', 'D', 'C', 'S'] as const;
type Card = `${(typeof RANKS)[number]}${(typeof SUITS)[number]}`;
export type BlackjackState = {
userId: string;
guildId: string;
bet: number;
deck: Card[];
playerHand: Card[];
dealerHand: Card[];
finished: boolean;
};
const SLOT_SYMBOLS = ['🍒', '🍋', '🔔', '⭐', '💎', '7⃣'] as const;
function buildDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push(`${rank}${suit}`);
}
}
for (let i = deck.length - 1; i > 0; i -= 1) {
const j = randomInt(0, i);
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
}
return deck;
}
function cardValue(card: Card): number {
const rank = card.slice(0, card.length - 1);
if (rank === 'A') {
return 11;
}
if (rank === 'K' || rank === 'Q' || rank === 'J') {
return 10;
}
return Number(rank);
}
export function handValue(hand: Card[]): number {
let total = hand.reduce((sum, card) => sum + cardValue(card), 0);
let aces = hand.filter((card) => card.startsWith('A')).length;
while (total > 21 && aces > 0) {
total -= 10;
aces -= 1;
}
return total;
}
function isBlackjack(hand: Card[]): boolean {
return hand.length === 2 && handValue(hand) === 21;
}
function formatHand(hand: Card[], hideFirst = false): string {
if (hideFirst && hand.length > 0) {
return `🂠 ${hand.slice(1).join(' ')}`;
}
return hand.join(' ');
}
function blackjackKey(guildId: string, userId: string): string {
return `${BLACKJACK_KEY_PREFIX}${guildId}:${userId}`;
}
async function saveBlackjackState(context: BotContext, state: BlackjackState): Promise<void> {
await context.redis.set(
blackjackKey(state.guildId, state.userId),
JSON.stringify(state),
'EX',
BLACKJACK_TTL_SECONDS
);
}
async function loadBlackjackState(
context: BotContext,
guildId: string,
userId: string
): Promise<BlackjackState | null> {
const raw = await context.redis.get(blackjackKey(guildId, userId));
if (!raw) {
return null;
}
return JSON.parse(raw) as BlackjackState;
}
async function deleteBlackjackState(context: BotContext, guildId: string, userId: string): Promise<void> {
await context.redis.del(blackjackKey(guildId, userId));
}
function drawCard(state: BlackjackState): Card {
const card = state.deck.pop();
if (!card) {
throw new Error('Blackjack deck empty');
}
return card;
}
export function buildBlackjackButtons(locale: Locale, guildId: string, userId: string): ActionRowBuilder<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}hit:${guildId}:${userId}`)
.setLabel(tf(locale, 'economy.blackjack.button.hit', {}))
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}stand:${guildId}:${userId}`)
.setLabel(tf(locale, 'economy.blackjack.button.stand', {}))
.setStyle(ButtonStyle.Secondary)
);
}
export function buildBlackjackEmbed(
locale: Locale,
state: BlackjackState,
config: { currencySymbol: string; currencyName: string },
revealDealer: boolean,
outcome?: 'win' | 'lose' | 'push' | 'blackjack'
): APIEmbed {
const playerTotal = handValue(state.playerHand);
const dealerTotal = revealDealer ? handValue(state.dealerHand) : handValue(state.dealerHand.slice(1));
let description = tf(locale, 'economy.blackjack.hand', {
playerHand: formatHand(state.playerHand),
playerTotal: String(playerTotal),
dealerHand: formatHand(state.dealerHand, !revealDealer),
dealerTotal: revealDealer ? String(dealerTotal) : '?',
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
});
if (outcome) {
const outcomeKey = `economy.blackjack.outcome.${outcome}` as const;
description += `\n\n${tf(locale, outcomeKey, {
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
})}`;
}
return {
title: tf(locale, 'economy.blackjack.title', {}),
description,
color: outcome === 'win' || outcome === 'blackjack' ? 0x22c55e : outcome === 'push' ? 0xeab308 : 0x6366f1
};
}
async function finishBlackjack(
context: BotContext,
state: BlackjackState
): Promise<{ state: BlackjackState; outcome: 'win' | 'lose' | 'push' | 'blackjack'; payout: number }> {
const playerTotal = handValue(state.playerHand);
const dealerTotal = handValue(state.dealerHand);
let outcome: 'win' | 'lose' | 'push' | 'blackjack';
let payout = 0;
if (playerTotal > 21) {
outcome = 'lose';
} else if (isBlackjack(state.playerHand) && !isBlackjack(state.dealerHand)) {
outcome = 'blackjack';
payout = Math.floor(state.bet * 2.5);
} else if (dealerTotal > 21 || playerTotal > dealerTotal) {
outcome = 'win';
payout = state.bet * 2;
} else if (playerTotal === dealerTotal) {
outcome = 'push';
payout = state.bet;
} else {
outcome = 'lose';
}
if (payout > 0) {
await addBalance(context.prisma, state.guildId, state.userId, payout);
}
state.finished = true;
await deleteBlackjackState(context, state.guildId, state.userId);
return { state, outcome, payout };
}
export async function startBlackjack(
context: BotContext,
guildId: string,
userId: string,
bet: number,
locale: Locale
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[] }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(bet) || bet <= 0) {
throw new EconomyError('invalid_amount');
}
const existing = await loadBlackjackState(context, guildId, userId);
if (existing && !existing.finished) {
throw new EconomyError('game_in_progress');
}
await deductBalance(context.prisma, guildId, userId, bet);
const state: BlackjackState = {
userId,
guildId,
bet,
deck: buildDeck(),
playerHand: [],
dealerHand: [],
finished: false
};
state.playerHand.push(drawCard(state), drawCard(state));
state.dealerHand.push(drawCard(state), drawCard(state));
const config = await getEconomyConfig(context.prisma, guildId);
if (isBlackjack(state.playerHand) || isBlackjack(state.dealerHand)) {
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
components: []
};
}
await saveBlackjackState(context, state);
return {
embed: buildBlackjackEmbed(locale, state, config, false),
components: [buildBlackjackButtons(locale, guildId, userId)]
};
}
export async function hitBlackjack(
context: BotContext,
guildId: string,
userId: string,
locale: Locale
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[]; finished: boolean }> {
const state = await loadBlackjackState(context, guildId, userId);
if (!state || state.finished) {
throw new EconomyError('not_found');
}
if (state.userId !== userId) {
throw new EconomyError('not_found');
}
state.playerHand.push(drawCard(state));
const config = await getEconomyConfig(context.prisma, guildId);
if (handValue(state.playerHand) > 21) {
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
components: [],
finished: true
};
}
await saveBlackjackState(context, state);
return {
embed: buildBlackjackEmbed(locale, state, config, false),
components: [buildBlackjackButtons(locale, guildId, userId)],
finished: false
};
}
export async function standBlackjack(
context: BotContext,
guildId: string,
userId: string,
locale: Locale
): Promise<{ embed: APIEmbed; finished: true }> {
const state = await loadBlackjackState(context, guildId, userId);
if (!state || state.finished) {
throw new EconomyError('not_found');
}
if (state.userId !== userId) {
throw new EconomyError('not_found');
}
while (handValue(state.dealerHand) < 17) {
state.dealerHand.push(drawCard(state));
}
const config = await getEconomyConfig(context.prisma, guildId);
const { state: finished, outcome } = await finishBlackjack(context, state);
return {
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
finished: true
};
}
export async function playGamble(
context: BotContext,
guildId: string,
userId: string,
amount: number
): Promise<{ won: boolean; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const won = Math.random() < 0.5;
if (won) {
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
return { won: true, balance: updated.balance };
}
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
return { won: false, balance: member.balance };
}
export async function playSlots(
context: BotContext,
guildId: string,
userId: string,
amount: number
): Promise<{ reels: string[]; multiplier: number; payout: number; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const reels = [
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!
];
let multiplier = 0;
if (reels[0] === reels[1] && reels[1] === reels[2]) {
multiplier = reels[0] === '7⃣' ? 10 : reels[0] === '💎' ? 5 : 3;
} else if (reels[0] === reels[1] || reels[1] === reels[2] || reels[0] === reels[2]) {
multiplier = 1.5;
}
const payout = Math.floor(amount * multiplier);
let balance: number;
if (payout > 0) {
const updated = await addBalance(context.prisma, guildId, userId, payout);
balance = updated.balance;
} else {
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
balance = member.balance;
}
return { reels, multiplier, payout, balance };
}
export type CoinflipChoice = 'heads' | 'tails';
export async function playCoinflipBet(
context: BotContext,
guildId: string,
userId: string,
amount: number,
choice: CoinflipChoice
): Promise<{ result: CoinflipChoice; won: boolean; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
await deductBalance(context.prisma, guildId, userId, amount);
const result: CoinflipChoice = Math.random() < 0.5 ? 'heads' : 'tails';
const won = result === choice;
if (won) {
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
return { result, won: true, balance: updated.balance };
}
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
where: { guildId_userId: { guildId, userId } }
});
return { result, won: false, balance: member.balance };
}
export function parseBlackjackButtonCustomId(customId: string): {
action: 'hit' | 'stand';
guildId: string;
userId: string;
} | null {
if (!customId.startsWith(BLACKJACK_BUTTON_PREFIX)) {
return null;
}
const parts = customId.slice(BLACKJACK_BUTTON_PREFIX.length).split(':');
if (parts.length !== 3) {
return null;
}
const [action, guildId, userId] = parts;
if (action !== 'hit' && action !== 'stand') {
return null;
}
if (!guildId || !userId) {
return null;
}
return { action, guildId, userId };
}

View File

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

View File

@@ -0,0 +1,380 @@
import type { EconomyConfig, MemberEconomy, Prisma, PrismaClient, ShopItem } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import type { BotContext } from '../../types.js';
type DbClient = PrismaClient | Prisma.TransactionClient;
export type EconomyErrorCode =
| 'disabled'
| 'insufficient'
| 'cooldown'
| 'invalid_amount'
| 'self_transfer'
| 'not_found'
| 'out_of_stock'
| 'role_assign_failed'
| 'game_in_progress';
export class EconomyError extends Error {
constructor(public readonly code: EconomyErrorCode) {
super(code);
this.name = 'EconomyError';
}
}
const DAILY_COOLDOWN_MS = 24 * 60 * 60 * 1000;
const WEEKLY_COOLDOWN_MS = 7 * DAILY_COOLDOWN_MS;
export async function getEconomyConfig(
prisma: DbClient,
guildId: string
): Promise<EconomyConfig> {
await ensureGuild(prisma as PrismaClient, guildId);
return prisma.economyConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function assertEconomyEnabled(prisma: PrismaClient, guildId: string): Promise<EconomyConfig> {
const config = await getEconomyConfig(prisma, guildId);
if (!config.enabled) {
throw new EconomyError('disabled');
}
return config;
}
export async function ensureMemberEconomy(
prisma: DbClient,
guildId: string,
userId: string
): Promise<MemberEconomy> {
const config = await getEconomyConfig(prisma, guildId);
return prisma.memberEconomy.upsert({
where: { guildId_userId: { guildId, userId } },
update: {},
create: {
guildId,
userId,
balance: config.startingBalance
}
});
}
export async function getBalance(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const member = await ensureMemberEconomy(prisma, guildId, userId);
return member.balance;
}
function assertPositiveAmount(amount: number): void {
if (!Number.isInteger(amount) || amount <= 0) {
throw new EconomyError('invalid_amount');
}
}
export async function addBalance(
prisma: DbClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
await ensureMemberEconomy(prisma, guildId, userId);
return prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: { balance: { increment: amount } }
});
}
export async function deductBalance(
prisma: DbClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
assertPositiveAmount(amount);
const member = await ensureMemberEconomy(prisma, guildId, userId);
if (member.balance < amount) {
throw new EconomyError('insufficient');
}
return prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: { balance: { decrement: amount } }
});
}
export async function transferBalance(
prisma: PrismaClient,
guildId: string,
fromUserId: string,
toUserId: string,
amount: number
): Promise<{ sender: MemberEconomy; receiver: MemberEconomy }> {
assertPositiveAmount(amount);
if (fromUserId === toUserId) {
throw new EconomyError('self_transfer');
}
return prisma.$transaction(async (tx) => {
const sender = await ensureMemberEconomy(tx, guildId, fromUserId);
if (sender.balance < amount) {
throw new EconomyError('insufficient');
}
await ensureMemberEconomy(tx, guildId, toUserId);
const updatedSender = await tx.memberEconomy.update({
where: { guildId_userId: { guildId, userId: fromUserId } },
data: { balance: { decrement: amount } }
});
const updatedReceiver = await tx.memberEconomy.update({
where: { guildId_userId: { guildId, userId: toUserId } },
data: { balance: { increment: amount } }
});
return { sender: updatedSender, receiver: updatedReceiver };
});
}
function cooldownRemainingMs(lastAt: Date | null, cooldownMs: number): number {
if (!lastAt) {
return 0;
}
const elapsed = Date.now() - lastAt.getTime();
return Math.max(0, cooldownMs - elapsed);
}
export async function claimDaily(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<{ amount: number; balance: number }> {
const config = await assertEconomyEnabled(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
const remaining = cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS);
if (remaining > 0) {
throw new EconomyError('cooldown');
}
const updated = await prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: {
balance: { increment: config.dailyAmount },
lastDailyAt: new Date()
}
});
return { amount: config.dailyAmount, balance: updated.balance };
}
export async function claimWeekly(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<{ amount: number; balance: number }> {
const config = await assertEconomyEnabled(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
const remaining = cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS);
if (remaining > 0) {
throw new EconomyError('cooldown');
}
const updated = await prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: {
balance: { increment: config.weeklyAmount },
lastWeeklyAt: new Date()
}
});
return { amount: config.weeklyAmount, balance: updated.balance };
}
export async function claimWork(
prisma: PrismaClient,
guildId: string,
userId: string,
earned: number
): Promise<{ amount: number; balance: number }> {
await assertEconomyEnabled(prisma, guildId);
const config = await getEconomyConfig(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
const cooldownMs = config.workCooldownSeconds * 1000;
const remaining = cooldownRemainingMs(member.lastWorkAt, cooldownMs);
if (remaining > 0) {
throw new EconomyError('cooldown');
}
const updated = await prisma.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: {
balance: { increment: earned },
lastWorkAt: new Date()
}
});
return { amount: earned, balance: updated.balance };
}
export async function getWorkCooldownRemaining(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const config = await getEconomyConfig(prisma, guildId);
const member = await ensureMemberEconomy(prisma, guildId, userId);
return cooldownRemainingMs(member.lastWorkAt, config.workCooldownSeconds * 1000);
}
export async function getDailyCooldownRemaining(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const member = await ensureMemberEconomy(prisma, guildId, userId);
return cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS);
}
export async function getWeeklyCooldownRemaining(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<number> {
const member = await ensureMemberEconomy(prisma, guildId, userId);
return cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS);
}
export async function listShopItems(prisma: PrismaClient, guildId: string): Promise<ShopItem[]> {
await assertEconomyEnabled(prisma, guildId);
return prisma.shopItem.findMany({
where: { guildId, enabled: true },
orderBy: { price: 'asc' }
});
}
export async function buyShopItem(
context: BotContext,
guildId: string,
userId: string,
itemId: string
): Promise<{ item: ShopItem; balance: number }> {
await assertEconomyEnabled(context.prisma, guildId);
return context.prisma.$transaction(async (tx) => {
const item = await tx.shopItem.findFirst({
where: { id: itemId, guildId, enabled: true }
});
if (!item) {
throw new EconomyError('not_found');
}
if (item.stock !== null && item.stock <= 0) {
throw new EconomyError('out_of_stock');
}
const member = await ensureMemberEconomy(tx, guildId, userId);
if (member.balance < item.price) {
throw new EconomyError('insufficient');
}
const updatedMember = await tx.memberEconomy.update({
where: { guildId_userId: { guildId, userId } },
data: { balance: { decrement: item.price } }
});
await tx.inventoryItem.upsert({
where: { guildId_userId_itemId: { guildId, userId, itemId: item.id } },
update: { quantity: { increment: 1 } },
create: { guildId, userId, itemId: item.id, quantity: 1 }
});
if (item.stock !== null) {
await tx.shopItem.update({
where: { id: item.id },
data: { stock: { decrement: 1 } }
});
}
if (item.roleId) {
const guild = context.client.guilds.cache.get(guildId) ?? (await context.client.guilds.fetch(guildId));
const memberObj = await guild.members.fetch(userId);
try {
await memberObj.roles.add(item.roleId, 'Economy shop purchase');
} catch {
throw new EconomyError('role_assign_failed');
}
}
return { item, balance: updatedMember.balance };
});
}
export async function getInventory(
prisma: PrismaClient,
guildId: string,
userId: string
) {
await assertEconomyEnabled(prisma, guildId);
return prisma.inventoryItem.findMany({
where: { guildId, userId },
include: { item: true },
orderBy: { createdAt: 'desc' }
});
}
export async function getLeaderboard(prisma: PrismaClient, guildId: string, limit = 10) {
await assertEconomyEnabled(prisma, guildId);
return prisma.memberEconomy.findMany({
where: { guildId },
orderBy: { balance: 'desc' },
take: limit
});
}
export async function adminGiveBalance(
prisma: PrismaClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
assertPositiveAmount(amount);
await assertEconomyEnabled(prisma, guildId);
return addBalance(prisma, guildId, userId, amount);
}
export async function adminRemoveBalance(
prisma: PrismaClient,
guildId: string,
userId: string,
amount: number
): Promise<MemberEconomy> {
return deductBalance(prisma, guildId, userId, amount);
}
export async function adminResetEconomy(
prisma: PrismaClient,
guildId: string,
userId: string
): Promise<void> {
await assertEconomyEnabled(prisma, guildId);
const config = await getEconomyConfig(prisma, guildId);
await prisma.$transaction([
prisma.memberEconomy.upsert({
where: { guildId_userId: { guildId, userId } },
update: {
balance: config.startingBalance,
lastDailyAt: null,
lastWeeklyAt: null,
lastWorkAt: null
},
create: {
guildId,
userId,
balance: config.startingBalance
}
}),
prisma.inventoryItem.deleteMany({ where: { guildId, userId } })
]);
}
export function formatCooldownSeconds(ms: number): number {
return Math.ceil(ms / 1000);
}

View File

@@ -0,0 +1,234 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
applyConnect4Move,
applyHangmanGuess,
applyTicTacToeMove,
buildConnect4Components,
buildConnect4Embed,
buildHangmanComponents,
buildHangmanEmbed,
buildTicTacToeComponents,
buildTicTacToeEmbed,
currentConnect4PlayerId,
currentTicTacToePlayerId,
deleteConnect4State,
deleteHangmanState,
deleteTicTacToeState,
deleteTriviaSession,
FUN_BUTTON_PREFIX,
loadConnect4State,
loadHangmanState,
loadTicTacToeState,
loadTriviaSession,
parseConnect4Button,
parseHangmanButton,
parseTicTacToeButton,
parseTriviaButton,
saveConnect4State,
saveHangmanState,
saveTicTacToeState
} from './games.js';
export function isFunButton(customId: string): boolean {
return customId.startsWith(FUN_BUTTON_PREFIX);
}
async function replyNotParticipant(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise<void> {
await interaction.reply({ content: t(locale, 'fun.common.notParticipant'), ephemeral: true });
}
async function replyExpired(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise<void> {
await interaction.reply({ content: t(locale, 'fun.common.expired'), ephemeral: true });
}
async function handleTriviaButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTriviaButton(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== parsed.userId) {
await interaction.reply({ content: t(locale, 'fun.trivia.notOwner'), ephemeral: true });
return;
}
const session = await loadTriviaSession(context, parsed.userId);
if (!session) {
await replyExpired(interaction, locale);
return;
}
const correct = parsed.answerIndex === parsed.correctIndex;
await deleteTriviaSession(context, parsed.userId);
await interaction.update({
embeds: interaction.message.embeds,
components: [],
content: correct ? t(locale, 'fun.trivia.correct') : t(locale, 'fun.trivia.incorrect')
});
}
async function handleTicTacToeButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTicTacToeButton(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
const state = await loadTicTacToeState(context, parsed.gameId);
if (!state || state.status !== 'active') {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== currentTicTacToePlayerId(state)) {
await replyNotParticipant(interaction, locale);
return;
}
const next = applyTicTacToeMove(state, parsed.cell);
if (next.board[parsed.cell] === state.board[parsed.cell]) {
await interaction.reply({ content: t(locale, 'fun.tictactoe.invalidMove'), ephemeral: true });
return;
}
if (next.status !== 'active') {
await deleteTicTacToeState(context, parsed.gameId);
} else {
await saveTicTacToeState(context, next);
}
await interaction.update({
embeds: [buildTicTacToeEmbed(locale, next)],
components: buildTicTacToeComponents(next)
});
}
async function handleConnect4Button(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseConnect4Button(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
const state = await loadConnect4State(context, parsed.gameId);
if (!state || state.status !== 'active') {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== currentConnect4PlayerId(state)) {
await replyNotParticipant(interaction, locale);
return;
}
const next = applyConnect4Move(state, parsed.col);
const changed = next.board.some((row, rowIndex) =>
row.some((cell, colIndex) => cell !== state.board[rowIndex]![colIndex])
);
if (!changed) {
await interaction.reply({ content: t(locale, 'fun.connect4.invalidMove'), ephemeral: true });
return;
}
if (next.status !== 'active') {
await deleteConnect4State(context, parsed.gameId);
} else {
await saveConnect4State(context, next);
}
await interaction.update({
embeds: [buildConnect4Embed(locale, next)],
components: buildConnect4Components(next)
});
}
async function handleHangmanButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseHangmanButton(interaction.customId);
if (!parsed) {
await replyExpired(interaction, locale);
return;
}
const state = await loadHangmanState(context, parsed.gameId);
if (!state) {
await replyExpired(interaction, locale);
return;
}
if (interaction.user.id !== state.userId) {
await replyNotParticipant(interaction, locale);
return;
}
if (parsed.page !== undefined) {
const next = { ...state, letterPage: parsed.page };
await saveHangmanState(context, next);
await interaction.update({
embeds: [buildHangmanEmbed(locale, next)],
components: buildHangmanComponents(next)
});
return;
}
if (!parsed.letter || state.status !== 'active') {
await replyExpired(interaction, locale);
return;
}
const next = applyHangmanGuess(state, parsed.letter);
if (next.status !== 'active') {
await deleteHangmanState(context, parsed.gameId);
} else {
await saveHangmanState(context, next);
}
await interaction.update({
embeds: [buildHangmanEmbed(locale, next)],
components: buildHangmanComponents(next)
});
}
export async function handleFunButton(interaction: ButtonInteraction, context: BotContext): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const parts = interaction.customId.split(':');
if (parts[1] === 'trivia') {
await handleTriviaButton(interaction, context, locale);
return;
}
if (parts[1] === 'ttt') {
await handleTicTacToeButton(interaction, context, locale);
return;
}
if (parts[1] === 'c4') {
await handleConnect4Button(interaction, context, locale);
return;
}
if (parts[1] === 'hm') {
await handleHangmanButton(interaction, context, locale);
return;
}
await replyExpired(interaction, locale);
}

View File

@@ -0,0 +1,112 @@
import {
SlashCommandBuilder,
type SlashCommandIntegerOption,
type SlashCommandStringOption,
type SlashCommandUserOption
} from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice,
type CommandLocaleKey
} from '@nexumi/shared';
function questionOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('question').setRequired(true), 'fun.8ball.options.question');
}
function sidesOpt(o: SlashCommandIntegerOption) {
return applyOptionDescription(
o.setName('sides').setMinValue(2).setMaxValue(100),
'fun.dice.options.sides'
);
}
function countOpt(o: SlashCommandIntegerOption) {
return applyOptionDescription(
o.setName('count').setMinValue(1).setMaxValue(10),
'fun.dice.options.count'
);
}
function rpsChoiceOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('choice').setRequired(true), 'fun.rps.options.choice').addChoices(
localizedChoice('fun.rps.choice.rock', 'rock'),
localizedChoice('fun.rps.choice.paper', 'paper'),
localizedChoice('fun.rps.choice.scissors', 'scissors')
);
}
function optionsOpt(o: SlashCommandStringOption) {
return applyOptionDescription(o.setName('options').setRequired(true), 'fun.choose.options.options');
}
function opponentOpt(o: SlashCommandUserOption) {
return applyOptionDescription(o.setName('user').setRequired(true), 'fun.common.options.opponent');
}
export const eightBallCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('8ball'),
'fun.8ball.description'
).addStringOption((o) => questionOpt(o));
export const diceCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('dice'),
'fun.dice.description'
)
.addIntegerOption((o) => sidesOpt(o))
.addIntegerOption((o) => countOpt(o));
export const flipCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('flip'),
'fun.flip.description'
);
export const rpsCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('rps'),
'fun.rps.description'
).addStringOption((o) => rpsChoiceOpt(o));
export const chooseCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('choose'),
'fun.choose.description'
).addStringOption((o) => optionsOpt(o));
export const triviaCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('trivia'),
'fun.trivia.description'
);
export const tictactoeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('tictactoe'),
'fun.tictactoe.description'
).addUserOption((o) => opponentOpt(o));
export const connect4CommandData = applyCommandDescription(
new SlashCommandBuilder().setName('connect4'),
'fun.connect4.description'
).addUserOption((o) => opponentOpt(o));
export const hangmanCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('hangman'),
'fun.hangman.description'
);
export const memeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('meme'),
'fun.meme.description'
);
export const catCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('cat'),
'fun.cat.description'
);
export const dogCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('dog'),
'fun.dog.description'
);
export type RpsChoice = 'rock' | 'paper' | 'scissors';
export type FunCommandLocaleKey = CommandLocaleKey;

View File

@@ -0,0 +1,412 @@
import { EmbedBuilder, type ChatInputCommandInteraction } from 'discord.js';
import { EIGHT_BALL_ANSWERS, randomInt, TRIVIA_QUESTIONS, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import type { RpsChoice } from './command-definitions.js';
import {
catCommandData,
chooseCommandData,
connect4CommandData,
diceCommandData,
dogCommandData,
eightBallCommandData,
flipCommandData,
hangmanCommandData,
memeCommandData,
rpsCommandData,
tictactoeCommandData,
triviaCommandData
} from './command-definitions.js';
import {
buildConnect4Components,
buildConnect4Embed,
buildHangmanComponents,
buildHangmanEmbed,
buildTicTacToeComponents,
buildTicTacToeEmbed,
buildTriviaComponents,
buildTriviaEmbed,
createConnect4Board,
createGameId,
pickHangmanWord,
saveConnect4State,
saveHangmanState,
saveTicTacToeState,
saveTriviaSession
} from './games.js';
import {
assertMediaEnabled,
fetchCatImage,
fetchDogImage,
fetchMeme,
FunMediaError
} from './media.js';
const RPS_CHOICES: RpsChoice[] = ['rock', 'paper', 'scissors'];
function rpsWinner(a: RpsChoice, b: RpsChoice): 'player' | 'bot' | 'draw' {
if (a === b) {
return 'draw';
}
if (
(a === 'rock' && b === 'scissors') ||
(a === 'paper' && b === 'rock') ||
(a === 'scissors' && b === 'paper')
) {
return 'player';
}
return 'bot';
}
async function ensureGuild(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en'
): Promise<string | null> {
if (!interaction.inGuild() || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return null;
}
return interaction.guildId;
}
function parseChooseOptions(raw: string): string[] {
return raw
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
const eightBallCommand: SlashCommand = {
data: eightBallCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const question = interaction.options.getString('question', true);
const answers = EIGHT_BALL_ANSWERS[locale];
const answer = answers[randomInt(0, answers.length - 1)]!;
await interaction.reply({
content: tf(locale, 'fun.8ball.result', { question, answer }),
ephemeral: false
});
}
};
const diceCommand: SlashCommand = {
data: diceCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const sides = interaction.options.getInteger('sides') ?? 6;
const count = interaction.options.getInteger('count') ?? 1;
const rolls = Array.from({ length: count }, () => randomInt(1, sides));
await interaction.reply({
content: tf(locale, 'fun.dice.result', {
rolls: rolls.join(', '),
sides: String(sides)
}),
ephemeral: false
});
}
};
const flipCommand: SlashCommand = {
data: flipCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const result = Math.random() < 0.5 ? 'heads' : 'tails';
const label = t(locale, `economy.choice.${result}`);
await interaction.reply({
content: tf(locale, 'fun.flip.result', { result: label }),
ephemeral: false
});
}
};
const rpsCommand: SlashCommand = {
data: rpsCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const playerChoice = interaction.options.getString('choice', true) as RpsChoice;
const botChoice = RPS_CHOICES[randomInt(0, RPS_CHOICES.length - 1)]!;
const outcome = rpsWinner(playerChoice, botChoice);
const key =
outcome === 'draw'
? 'fun.rps.draw'
: outcome === 'player'
? 'fun.rps.win'
: 'fun.rps.lose';
await interaction.reply({
content: tf(locale, key, {
player: t(locale, `fun.rps.choice.${playerChoice}`),
bot: t(locale, `fun.rps.choice.${botChoice}`)
}),
ephemeral: false
});
}
};
const chooseCommand: SlashCommand = {
data: chooseCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const raw = interaction.options.getString('options', true);
const options = parseChooseOptions(raw);
if (options.length < 2) {
await interaction.reply({ content: t(locale, 'fun.choose.tooFew'), ephemeral: true });
return;
}
const picked = options[randomInt(0, options.length - 1)]!;
await interaction.reply({
content: tf(locale, 'fun.choose.result', { choice: picked }),
ephemeral: false
});
}
};
const triviaCommand: SlashCommand = {
data: triviaCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const questionIndex = randomInt(0, TRIVIA_QUESTIONS.length - 1);
const question = TRIVIA_QUESTIONS[questionIndex]!;
const correctIndex = question.answer;
await saveTriviaSession(context, {
userId: interaction.user.id,
guildId,
questionIndex,
correctIndex
});
await interaction.reply({
embeds: [buildTriviaEmbed(locale, question, interaction.user.id)],
components: buildTriviaComponents(interaction.user.id, correctIndex, locale, question)
});
}
};
const tictactoeCommand: SlashCommand = {
data: tictactoeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId || !interaction.channel) return;
const opponent = interaction.options.getUser('user', true);
if (opponent.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true });
return;
}
if (opponent.bot) {
await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true });
return;
}
const gameId = createGameId();
const state = {
gameId,
guildId,
channelId: interaction.channel.id,
playerX: interaction.user.id,
playerO: opponent.id,
board: Array.from({ length: 9 }, () => null) as Array<'X' | 'O' | null>,
current: 'X' as const,
status: 'active' as const
};
await saveTicTacToeState(context, state);
await interaction.reply({
content: tf(locale, 'fun.tictactoe.challenge', {
challenger: `<@${interaction.user.id}>`,
opponent: `<@${opponent.id}>`
}),
embeds: [buildTicTacToeEmbed(locale, state)],
components: buildTicTacToeComponents(state)
});
}
};
const connect4Command: SlashCommand = {
data: connect4CommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId || !interaction.channel) return;
const opponent = interaction.options.getUser('user', true);
if (opponent.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true });
return;
}
if (opponent.bot) {
await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true });
return;
}
const gameId = createGameId();
const state = {
gameId,
guildId,
channelId: interaction.channel.id,
playerRed: interaction.user.id,
playerYellow: opponent.id,
board: createConnect4Board(),
current: 'R' as const,
status: 'active' as const
};
await saveConnect4State(context, state);
await interaction.reply({
content: tf(locale, 'fun.connect4.challenge', {
challenger: `<@${interaction.user.id}>`,
opponent: `<@${opponent.id}>`
}),
embeds: [buildConnect4Embed(locale, state)],
components: buildConnect4Components(state)
});
}
};
const hangmanCommand: SlashCommand = {
data: hangmanCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
const gameId = createGameId();
const state = {
gameId,
guildId,
userId: interaction.user.id,
word: pickHangmanWord(locale),
guessed: [] as string[],
wrongGuesses: 0,
maxWrong: 6,
status: 'active' as const,
letterPage: 0
};
await saveHangmanState(context, state);
await interaction.reply({
embeds: [buildHangmanEmbed(locale, state)],
components: buildHangmanComponents(state)
});
}
};
async function handleMediaError(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
error: unknown
): Promise<void> {
if (error instanceof FunMediaError) {
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
await interaction.reply({ content: t(locale, key), ephemeral: true });
return;
}
throw error;
}
const memeCommand: SlashCommand = {
data: memeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
try {
await assertMediaEnabled(context.redis, guildId);
const meme = await fetchMeme();
const embed = new EmbedBuilder()
.setTitle(meme.title)
.setImage(meme.url)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
}
};
const catCommand: SlashCommand = {
data: catCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
try {
await assertMediaEnabled(context.redis, guildId);
const url = await fetchCatImage();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.cat.title'))
.setImage(url)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
}
};
const dogCommand: SlashCommand = {
data: dogCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const guildId = await ensureGuild(interaction, locale);
if (!guildId) return;
try {
await assertMediaEnabled(context.redis, guildId);
const url = await fetchDogImage();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.dog.title'))
.setImage(url)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
}
};
export const funCommands: SlashCommand[] = [
eightBallCommand,
diceCommand,
flipCommand,
rpsCommand,
chooseCommand,
triviaCommand,
tictactoeCommand,
connect4Command,
hangmanCommand,
memeCommand,
catCommand,
dogCommand
];

View File

@@ -0,0 +1,23 @@
import type { Redis } from 'ioredis';
import { z } from 'zod';
const FUN_CONFIG_PREFIX = 'fun:config:';
export const FunConfigSchema = z.object({
mediaEnabled: z.boolean().default(true)
});
export type FunConfig = z.infer<typeof FunConfigSchema>;
export async function getFunConfig(redis: Redis, guildId: string): Promise<FunConfig> {
const raw = await redis.get(`${FUN_CONFIG_PREFIX}${guildId}`);
if (!raw) {
return { mediaEnabled: true };
}
const parsed = FunConfigSchema.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : { mediaEnabled: true };
}
export async function setFunConfig(redis: Redis, guildId: string, config: FunConfig): Promise<void> {
await redis.set(`${FUN_CONFIG_PREFIX}${guildId}`, JSON.stringify(config));
}

View File

@@ -0,0 +1,574 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type APIEmbed
} from 'discord.js';
import { HANGMAN_WORDS, randomInt, TRIVIA_QUESTIONS, type Locale, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
export const FUN_BUTTON_PREFIX = 'fun:';
export const TRIVIA_TTL_SECONDS = 120;
export const GAME_TTL_SECONDS = 3600;
const TRIVIA_KEY_PREFIX = 'fun:trivia:';
const TTT_KEY_PREFIX = 'fun:ttt:';
const C4_KEY_PREFIX = 'fun:c4:';
const HM_KEY_PREFIX = 'fun:hm:';
export type TicTacToeMark = 'X' | 'O';
export type TicTacToeCell = TicTacToeMark | null;
export type TicTacToeState = {
gameId: string;
guildId: string;
channelId: string;
playerX: string;
playerO: string;
board: TicTacToeCell[];
current: TicTacToeMark;
status: 'active' | 'draw' | 'won';
winner?: TicTacToeMark;
};
export type Connect4Mark = 'R' | 'Y';
export type Connect4Cell = Connect4Mark | null;
export type Connect4State = {
gameId: string;
guildId: string;
channelId: string;
playerRed: string;
playerYellow: string;
board: Connect4Cell[][];
current: Connect4Mark;
status: 'active' | 'draw' | 'won';
winner?: Connect4Mark;
};
export type HangmanState = {
gameId: string;
guildId: string;
userId: string;
word: string;
guessed: string[];
wrongGuesses: number;
maxWrong: number;
status: 'active' | 'won' | 'lost';
letterPage: number;
};
export type TriviaSession = {
userId: string;
guildId: string;
questionIndex: number;
correctIndex: number;
};
const TTT_LINES = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6]
] as const;
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
export function createGameId(): string {
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
}
export function checkTicTacToeWinner(board: TicTacToeCell[]): TicTacToeMark | null {
for (const [a, b, c] of TTT_LINES) {
const mark = board[a];
if (mark && mark === board[b] && mark === board[c]) {
return mark;
}
}
return null;
}
export function currentTicTacToePlayerId(state: TicTacToeState): string {
return state.current === 'X' ? state.playerX : state.playerO;
}
export function applyTicTacToeMove(state: TicTacToeState, cell: number): TicTacToeState {
if (state.status !== 'active' || state.board[cell] !== null) {
return state;
}
const board = [...state.board];
board[cell] = state.current;
const winner = checkTicTacToeWinner(board);
if (winner) {
return { ...state, board, status: 'won', winner };
}
if (board.every((value) => value !== null)) {
return { ...state, board, status: 'draw' };
}
return { ...state, board, current: state.current === 'X' ? 'O' : 'X' };
}
export function createConnect4Board(): Connect4Cell[][] {
return Array.from({ length: 6 }, () => Array.from({ length: 7 }, () => null));
}
function connect4DropRow(board: Connect4Cell[][], col: number): number {
for (let row = board.length - 1; row >= 0; row -= 1) {
if (board[row]![col] === null) {
return row;
}
}
return -1;
}
function connect4ColumnFull(board: Connect4Cell[][], col: number): boolean {
return board[0]![col] !== null;
}
export function applyConnect4Move(state: Connect4State, col: number): Connect4State {
if (state.status !== 'active' || col < 0 || col >= 7 || connect4ColumnFull(state.board, col)) {
return state;
}
const row = connect4DropRow(state.board, col);
if (row < 0) {
return state;
}
const board = state.board.map((line) => [...line]);
board[row]![col] = state.current;
const winner = checkConnect4Winner(board);
if (winner) {
return { ...state, board, status: 'won', winner };
}
if (board.every((line) => line.every((cell) => cell !== null))) {
return { ...state, board, status: 'draw' };
}
return { ...state, board, current: state.current === 'R' ? 'Y' : 'R' };
}
export function checkConnect4Winner(board: Connect4Cell[][]): Connect4Mark | null {
const directions = [
[0, 1],
[1, 0],
[1, 1],
[1, -1]
] as const;
for (let row = 0; row < 6; row += 1) {
for (let col = 0; col < 7; col += 1) {
const start = board[row]![col];
if (!start) {
continue;
}
for (const [dr, dc] of directions) {
let matches = 1;
for (let step = 1; step < 4; step += 1) {
const next = board[row + dr * step]?.[col + dc * step];
if (next !== start) {
break;
}
matches += 1;
}
if (matches >= 4) {
return start;
}
}
}
}
return null;
}
export function currentConnect4PlayerId(state: Connect4State): string {
return state.current === 'R' ? state.playerRed : state.playerYellow;
}
export function pickHangmanWord(locale: Locale): string {
const words = HANGMAN_WORDS[locale];
return words[randomInt(0, words.length - 1)]!;
}
export function renderHangmanWord(word: string, guessed: string[]): string {
return word
.split('')
.map((letter) => (guessed.includes(letter) ? letter : '⬜'))
.join(' ');
}
export function applyHangmanGuess(state: HangmanState, letter: string): HangmanState {
const normalized = letter.toUpperCase();
if (
state.status !== 'active' ||
normalized.length !== 1 ||
!/^[A-Z]$/.test(normalized) ||
state.guessed.includes(normalized)
) {
return state;
}
const guessed = [...state.guessed, normalized];
const inWord = state.word.includes(normalized);
const wrongGuesses = inWord ? state.wrongGuesses : state.wrongGuesses + 1;
const revealed = state.word.split('').every((char) => guessed.includes(char));
if (revealed) {
return { ...state, guessed, wrongGuesses, status: 'won' };
}
if (wrongGuesses >= state.maxWrong) {
return { ...state, guessed, wrongGuesses, status: 'lost' };
}
return { ...state, guessed, wrongGuesses };
}
async function saveJson(redis: BotContext['redis'], key: string, value: unknown, ttl: number): Promise<void> {
await redis.set(key, JSON.stringify(value), 'EX', ttl);
}
async function loadJson<T>(redis: BotContext['redis'], key: string): Promise<T | null> {
const raw = await redis.get(key);
if (!raw) {
return null;
}
return JSON.parse(raw) as T;
}
export async function saveTriviaSession(context: BotContext, session: TriviaSession): Promise<void> {
await saveJson(context.redis, `${TRIVIA_KEY_PREFIX}${session.userId}`, session, TRIVIA_TTL_SECONDS);
}
export async function loadTriviaSession(context: BotContext, userId: string): Promise<TriviaSession | null> {
return loadJson<TriviaSession>(context.redis, `${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function deleteTriviaSession(context: BotContext, userId: string): Promise<void> {
await context.redis.del(`${TRIVIA_KEY_PREFIX}${userId}`);
}
export async function saveTicTacToeState(context: BotContext, state: TicTacToeState): Promise<void> {
await saveJson(context.redis, `${TTT_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadTicTacToeState(context: BotContext, gameId: string): Promise<TicTacToeState | null> {
return loadJson<TicTacToeState>(context.redis, `${TTT_KEY_PREFIX}${gameId}`);
}
export async function deleteTicTacToeState(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${TTT_KEY_PREFIX}${gameId}`);
}
export async function saveConnect4State(context: BotContext, state: Connect4State): Promise<void> {
await saveJson(context.redis, `${C4_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadConnect4State(context: BotContext, gameId: string): Promise<Connect4State | null> {
return loadJson<Connect4State>(context.redis, `${C4_KEY_PREFIX}${gameId}`);
}
export async function deleteConnect4State(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${C4_KEY_PREFIX}${gameId}`);
}
export async function saveHangmanState(context: BotContext, state: HangmanState): Promise<void> {
await saveJson(context.redis, `${HM_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
}
export async function loadHangmanState(context: BotContext, gameId: string): Promise<HangmanState | null> {
return loadJson<HangmanState>(context.redis, `${HM_KEY_PREFIX}${gameId}`);
}
export async function deleteHangmanState(context: BotContext, gameId: string): Promise<void> {
await context.redis.del(`${HM_KEY_PREFIX}${gameId}`);
}
function tttCellLabel(cell: TicTacToeCell): string {
if (cell === 'X') return '❌';
if (cell === 'O') return '⭕';
return '⬜';
}
export function buildTicTacToeEmbed(locale: Locale, state: TicTacToeState): APIEmbed {
const boardLines = [0, 1, 2]
.map((row) => state.board.slice(row * 3, row * 3 + 3).map(tttCellLabel).join(' '))
.join('\n');
let description = tf(locale, 'fun.tictactoe.board', {
x: `<@${state.playerX}>`,
o: `<@${state.playerO}>`,
board: boardLines
});
if (state.status === 'won' && state.winner) {
const winnerId = state.winner === 'X' ? state.playerX : state.playerO;
description += `\n\n${tf(locale, 'fun.tictactoe.won', { user: `<@${winnerId}>` })}`;
} else if (state.status === 'draw') {
description += `\n\n${tf(locale, 'fun.tictactoe.draw', {})}`;
} else {
description += `\n\n${tf(locale, 'fun.tictactoe.turn', {
user: `<@${currentTicTacToePlayerId(state)}>`
})}`;
}
return { title: tf(locale, 'fun.tictactoe.title', {}), description, color: 0x6366f1 };
}
export function buildTicTacToeComponents(state: TicTacToeState): ActionRowBuilder<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let row = 0; row < 3; row += 1) {
const builder = new ActionRowBuilder<ButtonBuilder>();
for (let col = 0; col < 3; col += 1) {
const cell = row * 3 + col;
const mark = state.board[cell];
builder.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}ttt:${state.gameId}:${cell}`)
.setLabel(mark ?? String(cell + 1))
.setStyle(mark ? ButtonStyle.Secondary : ButtonStyle.Primary)
.setDisabled(mark !== null)
);
}
rows.push(builder);
}
return rows;
}
function c4CellLabel(cell: Connect4Cell): string {
if (cell === 'R') return '🔴';
if (cell === 'Y') return '🟡';
return '⚪';
}
export function buildConnect4Embed(locale: Locale, state: Connect4State): APIEmbed {
const boardText = [...state.board]
.reverse()
.map((row) => row.map(c4CellLabel).join(''))
.join('\n');
let description = tf(locale, 'fun.connect4.board', {
red: `<@${state.playerRed}>`,
yellow: `<@${state.playerYellow}>`,
board: boardText
});
if (state.status === 'won' && state.winner) {
const winnerId = state.winner === 'R' ? state.playerRed : state.playerYellow;
description += `\n\n${tf(locale, 'fun.connect4.won', { user: `<@${winnerId}>` })}`;
} else if (state.status === 'draw') {
description += `\n\n${tf(locale, 'fun.connect4.draw', {})}`;
} else {
description += `\n\n${tf(locale, 'fun.connect4.turn', {
user: `<@${currentConnect4PlayerId(state)}>`
})}`;
}
return { title: tf(locale, 'fun.connect4.title', {}), description, color: 0x6366f1 };
}
export function buildConnect4Components(state: Connect4State): ActionRowBuilder<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const row = new ActionRowBuilder<ButtonBuilder>();
for (let col = 0; col < 7; col += 1) {
row.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}c4:${state.gameId}:${col}`)
.setLabel(String(col + 1))
.setStyle(ButtonStyle.Primary)
.setDisabled(connect4ColumnFull(state.board, col))
);
}
return [row];
}
export function buildHangmanEmbed(locale: Locale, state: HangmanState): APIEmbed {
let description = tf(locale, 'fun.hangman.progress', {
word: renderHangmanWord(state.word, state.guessed),
wrong: state.wrongGuesses,
max: state.maxWrong,
guessed: state.guessed.length > 0 ? state.guessed.join(', ') : '—'
});
if (state.status === 'won') {
description += `\n\n${tf(locale, 'fun.hangman.won', { word: state.word })}`;
} else if (state.status === 'lost') {
description += `\n\n${tf(locale, 'fun.hangman.lost', { word: state.word })}`;
}
return { title: tf(locale, 'fun.hangman.title', {}), description, color: 0x6366f1 };
}
function hangmanLettersForPage(state: HangmanState): { letters: string[]; page: number; totalPages: number } {
const remaining = ALPHABET.filter((letter) => !state.guessed.includes(letter));
const lettersPerPage = 24;
const totalPages = Math.max(1, Math.ceil(remaining.length / lettersPerPage));
const page = Math.min(state.letterPage, totalPages - 1);
const start = page * lettersPerPage;
return {
letters: remaining.slice(start, start + lettersPerPage),
page,
totalPages
};
}
export function buildHangmanComponents(state: HangmanState): ActionRowBuilder<ButtonBuilder>[] {
if (state.status !== 'active') {
return [];
}
const { letters, page, totalPages } = hangmanLettersForPage(state);
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
for (let i = 0; i < letters.length; i += 5) {
const chunk = letters.slice(i, i + 5);
const row = new ActionRowBuilder<ButtonBuilder>();
for (const letter of chunk) {
row.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:${letter}`)
.setLabel(letter)
.setStyle(ButtonStyle.Primary)
);
}
rows.push(row);
if (rows.length >= 4) {
break;
}
}
const navRow = new ActionRowBuilder<ButtonBuilder>();
if (totalPages > 1) {
navRow.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.max(0, page - 1)}`)
.setLabel('◀')
.setStyle(ButtonStyle.Secondary)
.setDisabled(page <= 0),
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.min(totalPages - 1, page + 1)}`)
.setLabel('▶')
.setStyle(ButtonStyle.Secondary)
.setDisabled(page >= totalPages - 1)
);
}
if (navRow.components.length > 0) {
rows.push(navRow);
}
return rows.slice(0, 5);
}
export function pickTriviaQuestion(): (typeof TRIVIA_QUESTIONS)[number] {
return TRIVIA_QUESTIONS[randomInt(0, TRIVIA_QUESTIONS.length - 1)]!;
}
export function buildTriviaEmbed(
locale: Locale,
question: (typeof TRIVIA_QUESTIONS)[number],
userId: string
): APIEmbed {
const options = question.options[locale]
.map((option, index) => `${String.fromCharCode(65 + index)}. ${option}`)
.join('\n');
return {
title: tf(locale, 'fun.trivia.title', {}),
description: tf(locale, 'fun.trivia.question', {
question: question.q[locale],
options
}),
footer: { text: tf(locale, 'fun.trivia.footer', { user: `<@${userId}>` }) },
color: 0x6366f1
};
}
export function buildTriviaComponents(
userId: string,
correctIndex: number,
locale: Locale,
question: (typeof TRIVIA_QUESTIONS)[number]
): ActionRowBuilder<ButtonBuilder>[] {
const labels = ['A', 'B', 'C', 'D'];
const row = new ActionRowBuilder<ButtonBuilder>();
for (let index = 0; index < question.options[locale].length; index += 1) {
row.addComponents(
new ButtonBuilder()
.setCustomId(`${FUN_BUTTON_PREFIX}trivia:${userId}:${index}:${correctIndex}`)
.setLabel(`${labels[index]!}. ${question.options[locale][index]!.slice(0, 20)}`)
.setStyle(ButtonStyle.Primary)
);
}
return [row];
}
export function parseTriviaButton(customId: string): {
userId: string;
answerIndex: number;
correctIndex: number;
} | null {
const parts = customId.split(':');
if (parts.length !== 5 || parts[0] !== 'fun' || parts[1] !== 'trivia') {
return null;
}
const userId = parts[2];
const answerIndex = Number(parts[3]);
const correctIndex = Number(parts[4]);
if (!userId || Number.isNaN(answerIndex) || Number.isNaN(correctIndex)) {
return null;
}
return { userId, answerIndex, correctIndex };
}
export function parseTicTacToeButton(customId: string): { gameId: string; cell: number } | null {
const parts = customId.split(':');
if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'ttt') {
return null;
}
const gameId = parts[2];
const cell = Number(parts[3]);
if (!gameId || Number.isNaN(cell) || cell < 0 || cell > 8) {
return null;
}
return { gameId, cell };
}
export function parseConnect4Button(customId: string): { gameId: string; col: number } | null {
const parts = customId.split(':');
if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'c4') {
return null;
}
const gameId = parts[2];
const col = Number(parts[3]);
if (!gameId || Number.isNaN(col) || col < 0 || col > 6) {
return null;
}
return { gameId, col };
}
export function parseHangmanButton(
customId: string
): { gameId: string; letter?: string; page?: number } | null {
const parts = customId.split(':');
if (parts.length < 4 || parts[0] !== 'fun' || parts[1] !== 'hm') {
return null;
}
const gameId = parts[2];
if (!gameId) {
return null;
}
if (parts[3] === 'page') {
const page = Number(parts[4]);
if (Number.isNaN(page) || page < 0) {
return null;
}
return { gameId, page };
}
const letter = parts[3]?.toUpperCase();
if (!letter || !/^[A-Z]$/.test(letter)) {
return null;
}
return { gameId, letter };
}

View File

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

View File

@@ -0,0 +1,65 @@
import type { Redis } from 'ioredis';
import { getFunConfig } from './config.js';
export class FunMediaError extends Error {
constructor(public readonly code: 'disabled' | 'fetch_failed') {
super(code);
this.name = 'FunMediaError';
}
}
type MemeApiResponse = {
url?: string;
title?: string;
};
type CatApiResponse = Array<{ url?: string }>;
type DogApiResponse = {
message?: string;
status?: string;
};
export async function assertMediaEnabled(redis: Redis, guildId: string): Promise<void> {
const config = await getFunConfig(redis, guildId);
if (!config.mediaEnabled) {
throw new FunMediaError('disabled');
}
}
export async function fetchMeme(): Promise<{ url: string; title: string }> {
const response = await fetch('https://meme-api.com/gimme');
if (!response.ok) {
throw new FunMediaError('fetch_failed');
}
const data = (await response.json()) as MemeApiResponse;
if (!data.url) {
throw new FunMediaError('fetch_failed');
}
return { url: data.url, title: data.title ?? 'Meme' };
}
export async function fetchCatImage(): Promise<string> {
const response = await fetch('https://api.thecatapi.com/v1/images/search');
if (!response.ok) {
throw new FunMediaError('fetch_failed');
}
const data = (await response.json()) as CatApiResponse;
const url = data[0]?.url;
if (!url) {
throw new FunMediaError('fetch_failed');
}
return url;
}
export async function fetchDogImage(): Promise<string> {
const response = await fetch('https://dog.ceo/api/breeds/image/random');
if (!response.ok) {
throw new FunMediaError('fetch_failed');
}
const data = (await response.json()) as DogApiResponse;
if (data.status !== 'success' || !data.message) {
throw new FunMediaError('fetch_failed');
}
return data.message;
}

View File

@@ -0,0 +1,49 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
export const rankCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('rank'),
'leveling.rank.description'
).addUserOption((option) =>
applyOptionDescription(option.setName('user'), 'leveling.rank.options.user')
);
export const leaderboardCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('leaderboard'),
'leveling.leaderboard.description'
);
export const xpCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('xp'),
'leveling.xp.description'
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('give'), 'leveling.xp.give.description')
.addUserOption((option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
.addIntegerOption((option) =>
applyOptionDescription(
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
'leveling.xp.options.amount'
)
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('remove'), 'leveling.xp.remove.description')
.addUserOption((option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
.addIntegerOption((option) =>
applyOptionDescription(
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
'leveling.xp.options.amount'
)
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('reset'), 'leveling.xp.reset.description').addUserOption(
(option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
);

View File

@@ -0,0 +1,160 @@
import { AttachmentBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import {
addXp,
formatLeaderboardLine,
getConfig,
getLeaderboard,
getOrCreateMemberLevel,
getRankPosition,
memberProgress,
removeXp,
resetXp
} from './service.js';
import { renderRankCard } from './rank-card.js';
import {
leaderboardCommandData,
rankCommandData,
xpCommandData
} from './command-definitions.js';
const rankCommand: SlashCommand = {
data: rankCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null);
if (!member) {
await interaction.reply({ content: t(locale, 'leveling.user.notFound'), ephemeral: true });
return;
}
const config = await getConfig(context.prisma, interaction.guild.id);
const memberLevel = await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id);
const rank = await getRankPosition(context.prisma, interaction.guild.id, member.id);
const progress = memberProgress(memberLevel);
const buffer = await renderRankCard({
displayName: member.displayName,
avatarUrl: member.user.displayAvatarURL({ extension: 'png', size: 256 }),
level: progress.level,
rank,
intoLevel: progress.intoLevel,
needed: progress.needed,
totalXp: memberLevel.xp,
accentColor: config.cardAccentColor,
backgroundColor: config.cardBackgroundColor
});
const attachment = new AttachmentBuilder(buffer, { name: 'rank.png' });
await interaction.reply({ files: [attachment] });
}
};
const leaderboardCommand: SlashCommand = {
data: leaderboardCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const entries = await getLeaderboard(context.prisma, interaction.guild.id, 10);
if (entries.length === 0) {
await interaction.reply({ content: t(locale, 'leveling.leaderboard.empty'), ephemeral: true });
return;
}
const lines = await Promise.all(
entries.map(async (entry, index) => {
const user = await context.client.users.fetch(entry.userId).catch(() => null);
const label = user ? `<@${entry.userId}>` : entry.userId;
return formatLeaderboardLine(locale, index + 1, label, entry.level, entry.xp);
})
);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'leveling.leaderboard.title'))
.setDescription(lines.join('\n'))
.setFooter({ text: tf(locale, 'leveling.leaderboard.footer', { count: entries.length }) })
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
}
};
const xpCommand: SlashCommand = {
data: xpCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true);
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
if (!member) {
await interaction.reply({ content: t(locale, 'leveling.user.notFound'), ephemeral: true });
return;
}
const channelId = interaction.channelId ?? undefined;
if (sub === 'give') {
const amount = interaction.options.getInteger('amount', true);
const result = await addXp(context, interaction.guild, member, amount, {
channelId,
force: true
});
const level = result?.newLevel ?? (await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id)).level;
await interaction.reply({
content: tf(locale, 'leveling.xp.give.success', {
amount,
user: `<@${member.id}>`,
level
}),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const amount = interaction.options.getInteger('amount', true);
const result = await removeXp(context, interaction.guild, member, amount, channelId);
const level =
result?.newLevel ??
(await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id)).level;
await interaction.reply({
content: tf(locale, 'leveling.xp.remove.success', {
amount,
user: `<@${member.id}>`,
level
}),
ephemeral: true
});
return;
}
await resetXp(context, interaction.guild, member, channelId);
await interaction.reply({
content: tf(locale, 'leveling.xp.reset.success', { user: `<@${member.id}>` }),
ephemeral: true
});
}
};
export const levelingCommands: SlashCommand[] = [rankCommand, leaderboardCommand, xpCommand];

View File

@@ -0,0 +1,69 @@
import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js';
import {
awardVoiceXpForSession,
handleTextXpMessage,
voiceKey
} from './service.js';
async function handleVoiceJoin(context: BotContext, guildId: string, userId: string): Promise<void> {
await context.redis.set(voiceKey(guildId, userId), String(Date.now()));
}
async function handleVoiceLeave(
context: BotContext,
guildId: string,
userId: string,
channelId: string
): Promise<void> {
const key = voiceKey(guildId, userId);
const joinedAt = await context.redis.get(key);
await context.redis.del(key);
if (!joinedAt) {
return;
}
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
return;
}
await awardVoiceXpForSession(context, guild, member, Number(joinedAt), channelId);
}
export function registerLevelingEvents(context: BotContext): void {
context.client.on('messageCreate', async (message) => {
try {
if (!message.guild || message.author.bot || !message.member) {
return;
}
await handleTextXpMessage(context, message.member, message.channel.id);
} catch (error) {
logger.error({ error, messageId: message.id }, 'Leveling message handler failed');
}
});
context.client.on('voiceStateUpdate', async (oldState, newState) => {
try {
const guildId = newState.guild.id;
const userId = newState.id;
const oldChannelId = oldState.channelId;
const newChannelId = newState.channelId;
if (oldChannelId && oldChannelId !== newChannelId) {
await handleVoiceLeave(context, guildId, userId, oldChannelId);
}
if (newChannelId && newChannelId !== oldChannelId) {
await handleVoiceJoin(context, guildId, userId);
}
} catch (error) {
logger.error({ error, userId: newState.id }, 'Leveling voice handler failed');
}
});
}

View File

@@ -0,0 +1,84 @@
import { createCanvas, loadImage } from '@napi-rs/canvas';
export type RankCardInput = {
displayName: string;
avatarUrl: string;
level: number;
rank: number;
intoLevel: number;
needed: number;
totalXp: number;
accentColor: string;
backgroundColor: string;
};
function parseHexColor(color: string, fallback: string): string {
return /^#[0-9A-Fa-f]{6}$/.test(color) ? color : fallback;
}
export async function renderRankCard(input: RankCardInput): Promise<Buffer> {
const width = 934;
const height = 282;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
const background = parseHexColor(input.backgroundColor, '#111827');
const accent = parseHexColor(input.accentColor, '#6366F1');
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = accent;
ctx.fillRect(0, 0, width, 6);
const avatarSize = 128;
const avatarX = 36;
const avatarY = 77;
const avatar = await loadImage(input.avatarUrl);
ctx.save();
ctx.beginPath();
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
ctx.strokeStyle = accent;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 + 2, 0, Math.PI * 2);
ctx.stroke();
const textX = 200;
ctx.fillStyle = '#F9FAFB';
ctx.font = 'bold 32px sans-serif';
const name = input.displayName.slice(0, 28);
ctx.fillText(name, textX, 110);
ctx.fillStyle = '#D1D5DB';
ctx.font = '22px sans-serif';
ctx.fillText(`Rank #${input.rank}`, textX, 145);
ctx.fillText(`Level ${input.level}`, textX, 175);
ctx.fillText(`${input.totalXp.toLocaleString()} XP`, textX, 205);
const barX = 520;
const barY = 120;
const barWidth = 370;
const barHeight = 28;
const progress = input.needed > 0 ? Math.min(1, input.intoLevel / input.needed) : 0;
ctx.fillStyle = '#1F2937';
ctx.fillRect(barX, barY, barWidth, barHeight);
if (progress > 0) {
ctx.fillStyle = accent;
ctx.fillRect(barX, barY, Math.max(1, barWidth * progress), barHeight);
}
ctx.fillStyle = '#E5E7EB';
ctx.font = '18px sans-serif';
ctx.fillText(`${input.intoLevel.toLocaleString()} / ${input.needed.toLocaleString()} XP`, barX, barY + 52);
return canvas.toBuffer('image/png');
}

View File

@@ -0,0 +1,415 @@
import type { LevelingConfig, MemberLevel } from '@prisma/client';
import {
levelFromXp,
parseMultiplierMap,
progressInLevel,
randomInt,
t,
tf,
type LevelUpMode
} from '@nexumi/shared';
import {
PermissionFlagsBits,
type Guild,
type GuildMember,
type SendableChannels
} from 'discord.js';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
const COOLDOWN_PREFIX = 'leveling:cd:';
const VOICE_PREFIX = 'leveling:voice:';
export function cooldownKey(guildId: string, userId: string): string {
return `${COOLDOWN_PREFIX}${guildId}:${userId}`;
}
export function voiceKey(guildId: string, userId: string): string {
return `${VOICE_PREFIX}${guildId}:${userId}`;
}
export async function getConfig(
prisma: BotContext['prisma'],
guildId: string
): Promise<LevelingConfig> {
await ensureGuild(prisma, guildId);
return prisma.levelingConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function getOrCreateMemberLevel(
prisma: BotContext['prisma'],
guildId: string,
userId: string
): Promise<MemberLevel> {
await ensureGuild(prisma, guildId);
return prisma.memberLevel.upsert({
where: { guildId_userId: { guildId, userId } },
update: {},
create: { guildId, userId, xp: 0, level: 0 }
});
}
function resolveRoleMultiplier(config: LevelingConfig, member: GuildMember): number {
const multipliers = parseMultiplierMap(config.roleMultipliers);
let highest = 1;
for (const role of member.roles.cache.values()) {
const value = multipliers[role.id];
if (value !== undefined && value > highest) {
highest = value;
}
}
return highest;
}
function resolveChannelMultiplier(config: LevelingConfig, channelId: string): number {
const multipliers = parseMultiplierMap(config.channelMultipliers);
return multipliers[channelId] ?? 1;
}
export function calculateTextXp(
config: LevelingConfig,
member: GuildMember,
channelId: string
): number {
const base = randomInt(config.textXpMin, config.textXpMax);
const roleMultiplier = resolveRoleMultiplier(config, member);
const channelMultiplier = resolveChannelMultiplier(config, channelId);
return Math.max(1, Math.round(base * roleMultiplier * channelMultiplier));
}
export function calculateVoiceXp(config: LevelingConfig, minutes: number): number {
if (minutes <= 0) {
return 0;
}
return config.voiceXpPerMinute * minutes;
}
export function isNoXpMember(config: LevelingConfig, member: GuildMember): boolean {
return config.noXpRoleIds.some((roleId) => member.roles.cache.has(roleId));
}
export function isNoXpChannel(config: LevelingConfig, channelId: string): boolean {
return config.noXpChannelIds.includes(channelId);
}
export type XpChangeResult = {
memberLevel: MemberLevel;
previousLevel: number;
newLevel: number;
xpDelta: number;
leveledUp: boolean;
};
async function persistMemberXp(
prisma: BotContext['prisma'],
guildId: string,
userId: string,
xp: number
): Promise<MemberLevel> {
const level = levelFromXp(xp);
return prisma.memberLevel.upsert({
where: { guildId_userId: { guildId, userId } },
update: { xp, level },
create: { guildId, userId, xp, level }
});
}
export async function addXp(
context: BotContext,
guild: Guild,
member: GuildMember,
amount: number,
options?: { channelId?: string; skipNotifications?: boolean; force?: boolean }
): Promise<XpChangeResult | null> {
if (amount <= 0) {
return null;
}
const config = await getConfig(context.prisma, guild.id);
if (!config.enabled && !options?.force) {
return null;
}
const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id);
const previousLevel = existing.level;
const newXp = existing.xp + amount;
const memberLevel = await persistMemberXp(context.prisma, guild.id, member.id, newXp);
const leveledUp = memberLevel.level > previousLevel;
if (leveledUp && !options?.skipNotifications) {
await applyLevelRewards(context, guild, member, previousLevel, memberLevel.level, config);
await sendLevelUpNotification(context, guild, member, memberLevel.level, config, options?.channelId);
}
return {
memberLevel,
previousLevel,
newLevel: memberLevel.level,
xpDelta: amount,
leveledUp
};
}
export async function setXp(
context: BotContext,
guild: Guild,
member: GuildMember,
xp: number,
channelId?: string
): Promise<XpChangeResult> {
const config = await getConfig(context.prisma, guild.id);
const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id);
const previousLevel = existing.level;
const clampedXp = Math.max(0, xp);
const memberLevel = await persistMemberXp(context.prisma, guild.id, member.id, clampedXp);
const leveledUp = memberLevel.level > previousLevel;
if (leveledUp) {
await applyLevelRewards(context, guild, member, previousLevel, memberLevel.level, config);
await sendLevelUpNotification(context, guild, member, memberLevel.level, config, channelId);
}
return {
memberLevel,
previousLevel,
newLevel: memberLevel.level,
xpDelta: clampedXp - existing.xp,
leveledUp
};
}
export async function removeXp(
context: BotContext,
guild: Guild,
member: GuildMember,
amount: number,
channelId?: string
): Promise<XpChangeResult | null> {
if (amount <= 0) {
return null;
}
const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id);
return setXp(context, guild, member, Math.max(0, existing.xp - amount), channelId);
}
export async function resetXp(
context: BotContext,
guild: Guild,
member: GuildMember,
channelId?: string
): Promise<XpChangeResult> {
return setXp(context, guild, member, 0, channelId);
}
export async function getLeaderboard(
prisma: BotContext['prisma'],
guildId: string,
limit = 10
): Promise<MemberLevel[]> {
return prisma.memberLevel.findMany({
where: { guildId },
orderBy: [{ xp: 'desc' }, { updatedAt: 'asc' }],
take: limit
});
}
export async function getRankPosition(
prisma: BotContext['prisma'],
guildId: string,
userId: string
): Promise<number> {
const member = await getOrCreateMemberLevel(prisma, guildId, userId);
const higher = await prisma.memberLevel.count({
where: { guildId, xp: { gt: member.xp } }
});
return higher + 1;
}
export async function applyLevelRewards(
context: BotContext,
guild: Guild,
member: GuildMember,
previousLevel: number,
newLevel: number,
config?: LevelingConfig
): Promise<void> {
if (newLevel <= previousLevel) {
return;
}
const levelingConfig = config ?? (await getConfig(context.prisma, guild.id));
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return;
}
const rewards = await context.prisma.levelReward.findMany({
where: { guildId: guild.id },
orderBy: { level: 'asc' }
});
if (rewards.length === 0) {
return;
}
const rewardRoleIds = new Set(rewards.map((reward) => reward.roleId));
const assignable = (roleId: string) => {
const role = guild.roles.cache.get(roleId);
return role && role.id !== guild.id && role.position < me.roles.highest.position;
};
const rolesToAdd: string[] = [];
if (levelingConfig.stackRewards) {
for (let level = previousLevel + 1; level <= newLevel; level += 1) {
const reward = rewards.find((entry) => entry.level === level);
if (reward && assignable(reward.roleId)) {
rolesToAdd.push(reward.roleId);
}
}
} else {
const reward = rewards.find((entry) => entry.level === newLevel);
const removable = rewards
.map((entry) => entry.roleId)
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
if (removable.length > 0) {
await member.roles.remove(removable).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to remove level reward roles');
});
}
if (reward && assignable(reward.roleId)) {
rolesToAdd.push(reward.roleId);
}
}
if (rolesToAdd.length > 0) {
await member.roles.add([...new Set(rolesToAdd)]).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to assign level reward roles');
});
}
}
function renderLevelUpMessage(
template: string,
member: GuildMember,
level: number
): string {
return template
.replaceAll('{user}', `<@${member.id}>`)
.replaceAll('{username}', member.displayName)
.replaceAll('{level}', String(level));
}
async function sendLevelUpNotification(
context: BotContext,
guild: Guild,
member: GuildMember,
level: number,
config: LevelingConfig,
fallbackChannelId?: string
): Promise<void> {
const mode = config.levelUpMode as LevelUpMode;
if (mode === 'OFF') {
return;
}
const locale = await getGuildLocale(context.prisma, guild.id);
const template = config.levelUpMessage ?? t(locale, 'leveling.levelUp.default');
const content = renderLevelUpMessage(template, member, level);
if (mode === 'DM') {
await member.send({ content }).catch(() => undefined);
return;
}
const channelId = config.levelUpChannelId ?? fallbackChannelId;
if (!channelId) {
return;
}
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
return;
}
await (channel as SendableChannels).send({ content }).catch((error) => {
logger.warn({ error, channelId }, 'Failed to send level-up message');
});
}
export async function awardVoiceXpForSession(
context: BotContext,
guild: Guild,
member: GuildMember,
joinedAtMs: number,
channelId: string
): Promise<void> {
const config = await getConfig(context.prisma, guild.id);
if (!config.enabled || isNoXpMember(config, member) || isNoXpChannel(config, channelId)) {
return;
}
const minutes = Math.floor((Date.now() - joinedAtMs) / 60_000);
const baseXp = calculateVoiceXp(config, minutes);
if (baseXp <= 0) {
return;
}
const roleMultiplier = resolveRoleMultiplier(config, member);
const channelMultiplier = resolveChannelMultiplier(config, channelId);
const amount = Math.max(1, Math.round(baseXp * roleMultiplier * channelMultiplier));
await addXp(context, guild, member, amount, { channelId });
}
export async function handleTextXpMessage(
context: BotContext,
member: GuildMember,
channelId: string
): Promise<void> {
if (member.user.bot) {
return;
}
const config = await getConfig(context.prisma, member.guild.id);
if (!config.enabled || isNoXpMember(config, member) || isNoXpChannel(config, channelId)) {
return;
}
const key = cooldownKey(member.guild.id, member.id);
const onCooldown = await context.redis.get(key);
if (onCooldown) {
return;
}
const amount = calculateTextXp(config, member, channelId);
const result = await addXp(context, member.guild, member, amount, { channelId });
if (result) {
await context.redis.set(key, '1', 'EX', config.textCooldownSeconds);
}
}
export function memberProgress(member: MemberLevel) {
return progressInLevel(member.xp);
}
export function formatLeaderboardLine(
locale: 'de' | 'en',
rank: number,
userLabel: string,
level: number,
xp: number
): string {
return tf(locale, 'leveling.leaderboard.entry', {
rank,
user: userLabel,
level,
xp
});
}

View File

@@ -0,0 +1,89 @@
import { type Message } from 'discord.js';
import { tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { ensureGuild } from '../../guild.js';
import { getGuildLocale } from '../../i18n.js';
export async function setAfk(
context: BotContext,
guildId: string,
userId: string,
reason: string | null
): Promise<void> {
await ensureGuild(context.prisma, guildId);
await context.prisma.afkStatus.upsert({
where: { guildId_userId: { guildId, userId } },
update: { reason },
create: { guildId, userId, reason }
});
}
export async function clearAfk(
context: BotContext,
guildId: string,
userId: string
): Promise<{ reason: string | null } | null> {
const status = await context.prisma.afkStatus.findUnique({
where: { guildId_userId: { guildId, userId } }
});
if (!status) {
return null;
}
await context.prisma.afkStatus.delete({
where: { guildId_userId: { guildId, userId } }
});
return { reason: status.reason };
}
export async function getAfkStatus(
context: BotContext,
guildId: string,
userId: string
): Promise<{ reason: string | null } | null> {
const status = await context.prisma.afkStatus.findUnique({
where: { guildId_userId: { guildId, userId } }
});
if (!status) {
return null;
}
return { reason: status.reason };
}
export function registerAfkEvents(context: BotContext): void {
context.client.on('messageCreate', async (message: Message) => {
if (message.author.bot || !message.guildId) {
return;
}
try {
const locale = await getGuildLocale(context.prisma, message.guildId);
const cleared = await clearAfk(context, message.guildId, message.author.id);
if (cleared) {
await message.reply({
content: tf(locale, 'utility.afk.welcomeBack', {
reason: cleared.reason ?? '—'
}),
allowedMentions: { repliedUser: false }
}).catch(() => undefined);
}
const mentions = message.mentions.users.filter((u) => !u.bot && u.id !== message.author.id);
for (const [, user] of mentions) {
const afk = await getAfkStatus(context, message.guildId, user.id);
if (!afk) {
continue;
}
await message.reply({
content: tf(locale, 'utility.afk.mention', {
user: `<@${user.id}>`,
reason: afk.reason ?? '—'
}),
allowedMentions: { users: [user.id] }
}).catch(() => undefined);
}
} catch {
// Non-critical
}
});
}

View File

@@ -0,0 +1,111 @@
import {
ActionRowBuilder,
EmbedBuilder,
ModalBuilder,
PermissionFlagsBits,
TextInputBuilder,
TextInputStyle,
type ButtonInteraction,
type ModalSubmitInteraction,
type TextChannel
} from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { POLL_BUTTON_PREFIX } from './poll.js';
import { handlePollVote } from './poll.js';
import { parseHexColor } from './service.js';
export { POLL_BUTTON_PREFIX, pollButtonId } from './poll.js';
export function isPollButton(customId: string): boolean {
return customId.startsWith(POLL_BUTTON_PREFIX);
}
export async function handlePollButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
await handlePollVote(interaction, context);
}
export const EMBED_MODAL_ID = 'util:embed:modal';
export function isEmbedModal(customId: string): boolean {
return customId === EMBED_MODAL_ID;
}
export async function showEmbedBuilderModal(interaction: {
showModal: (modal: ModalBuilder) => Promise<unknown>;
}): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(EMBED_MODAL_ID)
.setTitle('Embed Builder')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('title')
.setLabel('Title')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setMaxLength(256)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('description')
.setLabel('Description')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
.setMaxLength(4000)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('color')
.setLabel('Color (hex, e.g. 6366F1)')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setMaxLength(7)
)
);
await interaction.showModal(modal);
}
export async function handleEmbedModal(
interaction: ModalSubmitInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.channelId) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
await interaction.reply({ content: t(locale, 'generic.noPermission'), ephemeral: true });
return;
}
const title = interaction.fields.getTextInputValue('title') || undefined;
const description = interaction.fields.getTextInputValue('description');
const colorInput = interaction.fields.getTextInputValue('color');
const parsedColor = colorInput ? parseHexColor(colorInput) : 0x6366f1;
if (colorInput && parsedColor === null) {
await interaction.reply({ content: t(locale, 'utility.embed.invalidColor'), ephemeral: true });
return;
}
const embed = new EmbedBuilder()
.setDescription(description)
.setColor(parsedColor ?? 0x6366f1);
if (title) {
embed.setTitle(title);
}
const channel = (await context.client.channels.fetch(interaction.channelId)) as TextChannel;
await channel.send({ embeds: [embed] });
await interaction.reply({ content: t(locale, 'utility.embed.sent'), ephemeral: true });
}

View File

@@ -0,0 +1,186 @@
import {
SlashCommandBuilder,
type SlashCommandAttachmentOption,
type SlashCommandBooleanOption,
type SlashCommandChannelOption,
type SlashCommandRoleOption,
type SlashCommandStringOption,
type SlashCommandUserOption
} from 'discord.js';
import {
applyCommandDescription,
applyOptionDescription,
type CommandLocaleKey
} from '@nexumi/shared';
function userOpt(o: SlashCommandUserOption, key: CommandLocaleKey = 'utility.common.options.userOptional') {
return applyOptionDescription(o.setName('user'), key);
}
function channelOpt(o: SlashCommandChannelOption) {
return applyOptionDescription(o.setName('channel'), 'utility.common.options.channelOptional');
}
function roleOpt(o: SlashCommandRoleOption) {
return applyOptionDescription(o.setName('role').setRequired(true), 'utility.roleinfo.options.role');
}
function attachmentOpt(o: SlashCommandAttachmentOption) {
return applyOptionDescription(o.setName('attachment'), 'utility.common.options.attachment');
}
function urlOpt(o: SlashCommandStringOption, key: CommandLocaleKey = 'utility.common.options.url') {
return applyOptionDescription(o.setName('url'), key);
}
function nameOpt(o: SlashCommandStringOption, required = true) {
const option = o.setName('name');
if (required) {
option.setRequired(true);
}
return applyOptionDescription(option, 'utility.common.options.name');
}
export const userinfoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('userinfo'),
'utility.userinfo.description'
).addUserOption((o) => userOpt(o));
export const serverinfoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('serverinfo'),
'utility.serverinfo.description'
);
export const roleinfoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('roleinfo'),
'utility.roleinfo.description'
).addRoleOption((o) => roleOpt(o));
export const channelinfoCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('channelinfo'),
'utility.channelinfo.description'
).addChannelOption((o) => channelOpt(o));
export const avatarCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('avatar'),
'utility.avatar.description'
).addUserOption((o) => userOpt(o));
export const bannerCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('banner'),
'utility.banner.description'
).addUserOption((o) => userOpt(o));
export const pollCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('poll'),
'utility.poll.description'
).addSubcommand((s) =>
applyCommandDescription(s.setName('create'), 'utility.poll.create.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('question').setRequired(true), 'utility.poll.options.question')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('options').setRequired(true), 'utility.poll.options.options')
)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('multiselect'), 'utility.poll.options.multiselect')
)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('anonymous'), 'utility.poll.options.anonymous')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'utility.poll.options.duration')
)
);
export const remindmeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('remindme'),
'utility.remindme.description'
)
.addStringOption((o) =>
applyOptionDescription(o.setName('when').setRequired(true), 'utility.remindme.options.when')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('content').setRequired(true), 'utility.remindme.options.content')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('recurring_cron'), 'utility.remindme.options.recurring_cron')
);
export const remindersCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('reminders'),
'utility.reminders.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('list'), 'utility.reminders.list.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('delete'), 'utility.reminders.delete.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'utility.reminders.options.id')
)
);
export const afkCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('afk'),
'utility.afk.description'
).addSubcommand((s) =>
applyCommandDescription(s.setName('set'), 'utility.afk.set.description').addStringOption((o) =>
applyOptionDescription(o.setName('reason'), 'utility.afk.options.reason')
)
);
export const emojiCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('emoji'),
'utility.emoji.description'
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('add'), 'utility.emoji.add.description')
.addStringOption((o) => nameOpt(o))
.addAttachmentOption((o) => attachmentOpt(o))
.addStringOption((o) => urlOpt(o, 'utility.emoji.options.url'))
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'utility.emoji.remove.description').addStringOption((o) =>
applyOptionDescription(o.setName('emoji').setRequired(true), 'utility.emoji.options.emoji')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('steal'), 'utility.emoji.steal.description').addStringOption((o) =>
applyOptionDescription(o.setName('message_id').setRequired(true), 'utility.emoji.options.message_id')
)
);
export const stickerCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('sticker'),
'utility.sticker.description'
).addSubcommand((s) =>
applyCommandDescription(s.setName('add'), 'utility.sticker.add.description')
.addStringOption((o) => nameOpt(o))
.addAttachmentOption((o) => attachmentOpt(o))
.addStringOption((o) => urlOpt(o, 'utility.sticker.options.url'))
);
export const timestampCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('timestamp'),
'utility.timestamp.description'
).addStringOption((o) =>
applyOptionDescription(o.setName('datetime').setRequired(true), 'utility.timestamp.options.datetime')
);
export const snipeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('snipe'),
'utility.snipe.description'
);
export const editsnipeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('editsnipe'),
'utility.editsnipe.description'
);
export const embedCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('embed'),
'utility.embed.description'
).addSubcommand((s) =>
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
);

View File

@@ -0,0 +1,438 @@
import {
EmbedBuilder,
PermissionFlagsBits,
type ChatInputCommandInteraction,
type GuildBasedChannel
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { showEmbedBuilderModal } from './buttons.js';
import {
afkCommandData,
avatarCommandData,
bannerCommandData,
channelinfoCommandData,
editsnipeCommandData,
embedCommandData,
emojiCommandData,
pollCommandData,
remindmeCommandData,
remindersCommandData,
roleinfoCommandData,
serverinfoCommandData,
snipeCommandData,
stickerCommandData,
timestampCommandData,
userinfoCommandData
} from './command-definitions.js';
import { createPoll } from './poll.js';
import { createReminder, deleteReminder, listReminders } from './reminders.js';
import { setAfk } from './afk.js';
import { getDeletedSnipe, getEditedSnipe } from './snipe.js';
import {
UtilityError,
buildChannelInfoEmbed,
buildRoleInfoEmbed,
buildServerInfoEmbed,
buildUserInfoEmbed,
extractFirstCustomEmoji,
formatDiscordTimestamps,
parseTimestampInput
} from './service.js';
async function ensureGuildCommand(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return true;
}
async function handleUtilityError(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
error: unknown
): Promise<void> {
if (error instanceof UtilityError) {
await interaction.reply({
content: t(locale, `utility.error.${error.code}` as 'utility.error.invalid_when'),
ephemeral: true
});
return;
}
throw error;
}
const userinfoCommand: SlashCommand = {
data: userinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const user = interaction.options.getUser('user') ?? interaction.user;
const member = await interaction.guild!.members.fetch(user.id).catch(() => null);
const embed = buildUserInfoEmbed(user, member, locale);
await interaction.reply({ embeds: [embed] });
}
};
const serverinfoCommand: SlashCommand = {
data: serverinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const embed = buildServerInfoEmbed(interaction.guild!, locale);
await interaction.reply({ embeds: [embed] });
}
};
const roleinfoCommand: SlashCommand = {
data: roleinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const role = interaction.options.getRole('role', true);
const guildRole =
interaction.guild!.roles.cache.get(role.id) ?? (await interaction.guild!.roles.fetch(role.id));
if (!guildRole) {
await interaction.reply({ content: t(locale, 'utility.error.role_not_found'), ephemeral: true });
return;
}
await interaction.reply({ embeds: [buildRoleInfoEmbed(guildRole, locale)] });
}
};
const channelinfoCommand: SlashCommand = {
data: channelinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const channel = interaction.options.getChannel('channel') ?? interaction.channel;
if (!channel || !('guild' in channel)) {
await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true });
return;
}
await interaction.reply({ embeds: [buildChannelInfoEmbed(channel as GuildBasedChannel, locale)] });
}
};
const avatarCommand: SlashCommand = {
data: avatarCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const user = interaction.options.getUser('user') ?? interaction.user;
const embed = new EmbedBuilder()
.setTitle(tf(locale, 'utility.avatar.title', { user: user.tag }))
.setImage(user.displayAvatarURL({ size: 512 }))
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
}
};
const bannerCommand: SlashCommand = {
data: bannerCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const user = interaction.options.getUser('user') ?? interaction.user;
const fetched = await context.client.users.fetch(user.id, { force: true });
const banner = fetched.bannerURL({ size: 512 });
if (!banner) {
await interaction.reply({ content: t(locale, 'utility.banner.none'), ephemeral: true });
return;
}
const embed = new EmbedBuilder()
.setTitle(tf(locale, 'utility.banner.title', { user: fetched.tag }))
.setImage(banner)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
}
};
const pollCommand: SlashCommand = {
data: pollCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const question = interaction.options.getString('question', true);
const optionsInput = interaction.options.getString('options', true);
const multiSelect = interaction.options.getBoolean('multiselect') ?? false;
const anonymous = interaction.options.getBoolean('anonymous') ?? false;
const duration = interaction.options.getString('duration');
try {
await createPoll(
context,
{
guildId: interaction.guildId!,
channelId: interaction.channelId!,
creatorId: interaction.user.id,
question,
optionsInput,
multiSelect,
anonymous,
durationInput: duration
},
locale
);
await interaction.reply({ content: t(locale, 'utility.poll.created'), ephemeral: true });
} catch (error) {
await handleUtilityError(interaction, locale, error);
}
}
};
const remindmeCommand: SlashCommand = {
data: remindmeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const whenInput = interaction.options.getString('when', true);
const content = interaction.options.getString('content', true);
const recurringCron = interaction.options.getString('recurring_cron');
try {
const id = await createReminder(context, {
guildId: interaction.guildId,
userId: interaction.user.id,
channelId: interaction.channelId!,
content,
whenInput,
recurringCron
});
await interaction.reply({
content: tf(locale, 'utility.remindme.created', { id: id.slice(0, 8) }),
ephemeral: true
});
} catch (error) {
await handleUtilityError(interaction, locale, error);
}
}
};
const remindersCommand: SlashCommand = {
data: remindersCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const sub = interaction.options.getSubcommand();
if (sub === 'list') {
const content = await listReminders(context, interaction.user.id, locale);
await interaction.reply({ content, ephemeral: true });
return;
}
const id = interaction.options.getString('id', true);
const deleted = await deleteReminder(context, interaction.user.id, id);
await interaction.reply({
content: t(locale, deleted ? 'utility.reminders.deleted' : 'utility.reminders.notFound'),
ephemeral: true
});
}
};
const afkCommand: SlashCommand = {
data: afkCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const reason = interaction.options.getString('reason');
await setAfk(context, interaction.guildId!, interaction.user.id, reason);
await interaction.reply({
content: tf(locale, 'utility.afk.set', { reason: reason ?? '—' }),
ephemeral: true
});
}
};
const emojiCommand: SlashCommand = {
data: emojiCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
return;
}
const sub = interaction.options.getSubcommand();
if (sub === 'add') {
const name = interaction.options.getString('name', true);
const attachment = interaction.options.getAttachment('attachment');
const url = interaction.options.getString('url') ?? attachment?.url;
if (!url) {
await interaction.reply({ content: t(locale, 'utility.emoji.noSource'), ephemeral: true });
return;
}
const emoji = await interaction.guild!.emojis.create({ attachment: url, name });
await interaction.reply({
content: tf(locale, 'utility.emoji.added', { emoji: emoji.toString() }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const input = interaction.options.getString('emoji', true);
const match = input.match(/:(\d+)>$/) ?? input.match(/^(\d+)$/);
const emojiId = match?.[1] ?? input;
const emoji = interaction.guild!.emojis.cache.get(emojiId);
if (!emoji) {
await interaction.reply({ content: t(locale, 'utility.emoji.notFound'), ephemeral: true });
return;
}
await emoji.delete();
await interaction.reply({ content: t(locale, 'utility.emoji.removed'), ephemeral: true });
return;
}
const messageId = interaction.options.getString('message_id', true);
const channel = interaction.channel;
if (!channel?.isTextBased()) {
await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true });
return;
}
const message = await channel.messages.fetch(messageId).catch(() => null);
if (!message) {
await interaction.reply({ content: t(locale, 'utility.emoji.messageNotFound'), ephemeral: true });
return;
}
const stolen = extractFirstCustomEmoji(message.content);
if (!stolen) {
await interaction.reply({ content: t(locale, 'utility.emoji.noEmojiInMessage'), ephemeral: true });
return;
}
const isAnimated = message.content.includes(`<a:${stolen.name}:${stolen.id}>`);
const emoji = await interaction.guild!.emojis.create({
attachment: `https://cdn.discordapp.com/emojis/${stolen.id}.${isAnimated ? 'gif' : 'png'}`,
name: stolen.name
});
await interaction.reply({
content: tf(locale, 'utility.emoji.stolen', { emoji: emoji.toString() }),
ephemeral: true
});
}
};
const stickerCommand: SlashCommand = {
data: stickerCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
return;
}
const name = interaction.options.getString('name', true);
const attachment = interaction.options.getAttachment('attachment');
const url = interaction.options.getString('url') ?? attachment?.url;
if (!url) {
await interaction.reply({ content: t(locale, 'utility.sticker.noSource'), ephemeral: true });
return;
}
const sticker = await interaction.guild!.stickers.create({
file: url,
name,
tags: name
});
await interaction.reply({
content: tf(locale, 'utility.sticker.added', { name: sticker.name }),
ephemeral: true
});
}
};
const timestampCommand: SlashCommand = {
data: timestampCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const input = interaction.options.getString('datetime', true);
try {
const ms = parseTimestampInput(input);
const unix = Math.floor(ms / 1000);
await interaction.reply({
content: formatDiscordTimestamps(unix),
ephemeral: true
});
} catch (error) {
await handleUtilityError(interaction, locale, error);
}
}
};
const snipeCommand: SlashCommand = {
data: snipeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const result = await getDeletedSnipe(context, interaction.channelId!, locale);
if (!result) {
await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true });
return;
}
await interaction.reply({
embeds: [result.embed],
content: result.privacyNote,
ephemeral: true
});
}
};
const editsnipeCommand: SlashCommand = {
data: editsnipeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const result = await getEditedSnipe(context, interaction.channelId!, locale);
if (!result) {
await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true });
return;
}
await interaction.reply({
embeds: [result.embed],
content: result.privacyNote,
ephemeral: true
});
}
};
const embedCommand: SlashCommand = {
data: embedCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
return;
}
await showEmbedBuilderModal(interaction);
}
};
export const utilityCommands: SlashCommand[] = [
userinfoCommand,
serverinfoCommand,
roleinfoCommand,
channelinfoCommand,
avatarCommand,
bannerCommand,
pollCommand,
remindmeCommand,
remindersCommand,
afkCommand,
emojiCommand,
stickerCommand,
timestampCommand,
snipeCommand,
editsnipeCommand,
embedCommand
];

View File

@@ -0,0 +1,50 @@
import { ContextMenuCommandBuilder, ApplicationCommandType } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext, ContextMenuCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
async function translateText(text: string, targetLang: string): Promise<string | null> {
const url = new URL('https://api.mymemory.translated.net/get');
url.searchParams.set('q', text.slice(0, 500));
url.searchParams.set('langpair', `auto|${targetLang}`);
const response = await fetch(url.toString());
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
responseData?: { translatedText?: string };
};
return data.responseData?.translatedText ?? null;
}
const translateContextMenu: ContextMenuCommand = {
data: new ContextMenuCommandBuilder()
.setName('Translate')
.setType(ApplicationCommandType.Message),
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const content = interaction.targetMessage.content;
if (!content?.trim()) {
await interaction.reply({ content: t(locale, 'utility.translate.noContent'), ephemeral: true });
return;
}
const targetLang = locale === 'de' ? 'de' : 'en';
const translated = await translateText(content, targetLang);
if (!translated) {
await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true });
return;
}
await interaction.reply({
content: t(locale, 'utility.translate.result').replace('{text}', translated),
ephemeral: true
});
}
};
export const utilityContextMenus: ContextMenuCommand[] = [translateContextMenu];

View File

@@ -0,0 +1,8 @@
import type { BotContext } from '../../types.js';
import { registerAfkEvents } from './afk.js';
import { registerSnipeEvents } from './snipe.js';
export function registerUtilityEvents(context: BotContext): void {
registerSnipeEvents(context);
registerAfkEvents(context);
}

View File

@@ -0,0 +1,13 @@
export { utilityCommands } from './commands.js';
export { utilityContextMenus } from './context-menus.js';
export { registerUtilityEvents } from './events.js';
export {
isPollButton,
handlePollButton,
isEmbedModal,
handleEmbedModal
} from './buttons.js';
export { registerAfkEvents } from './afk.js';
export { registerSnipeEvents } from './snipe.js';
export { closePoll } from './poll.js';
export { sendReminder } from './reminders.js';

View File

@@ -0,0 +1,274 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
type ButtonInteraction,
type TextChannel
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { ensureGuild } from '../../guild.js';
import { getGuildLocale } from '../../i18n.js';
import { reminderQueue } from '../../queues.js';
import { parseDuration } from '../moderation/duration.js';
import { UtilityError, parsePollOptions } from './service.js';
export const POLL_BUTTON_PREFIX = 'util:poll:';
export function pollButtonId(pollId: string, optionIndex: number): string {
return `${POLL_BUTTON_PREFIX}${pollId}:${optionIndex}`;
}
export function parsePollButtonId(customId: string): { pollId: string; optionIndex: number } | null {
if (!customId.startsWith(POLL_BUTTON_PREFIX)) {
return null;
}
const rest = customId.slice(POLL_BUTTON_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) {
return null;
}
const pollId = rest.slice(0, lastColon);
const optionIndex = Number.parseInt(rest.slice(lastColon + 1), 10);
if (!pollId || Number.isNaN(optionIndex)) {
return null;
}
return { pollId, optionIndex };
}
function parseOptionsJson(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.filter((item): item is string => typeof item === 'string');
}
export async function buildPollEmbed(
context: BotContext,
pollId: string,
locale: 'de' | 'en'
): Promise<EmbedBuilder> {
const poll = await context.prisma.poll.findUnique({
where: { id: pollId },
include: { votes: true }
});
if (!poll) {
throw new UtilityError('poll_not_found');
}
const options = parseOptionsJson(poll.options);
const voteCounts = new Map<number, number>();
for (const vote of poll.votes) {
voteCounts.set(vote.optionIndex, (voteCounts.get(vote.optionIndex) ?? 0) + 1);
}
const lines = options.map((option, index) => {
const count = voteCounts.get(index) ?? 0;
return `${index + 1}. **${option}** — ${count} ${t(locale, 'utility.poll.votes')}`;
});
const embed = new EmbedBuilder()
.setTitle(poll.question)
.setDescription(lines.join('\n'))
.setColor(0x6366f1);
if (poll.closedAt) {
embed.setFooter({ text: t(locale, 'utility.poll.closed') });
} else if (poll.endsAt) {
embed.setFooter({
text: tf(locale, 'utility.poll.endsAt', {
time: `<t:${Math.floor(poll.endsAt.getTime() / 1000)}:R>`
})
});
}
if (poll.multiSelect) {
embed.addFields({
name: t(locale, 'utility.poll.multiSelect'),
value: t(locale, 'utility.poll.multiSelectHint'),
inline: false
});
}
return embed;
}
export function buildPollButtons(pollId: string, options: string[], disabled = false): ActionRowBuilder<ButtonBuilder>[] {
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
let currentRow = new ActionRowBuilder<ButtonBuilder>();
options.forEach((option, index) => {
const button = new ButtonBuilder()
.setCustomId(pollButtonId(pollId, index))
.setLabel(option.slice(0, 80))
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled);
currentRow.addComponents(button);
if ((index + 1) % 5 === 0 || index === options.length - 1) {
rows.push(currentRow);
currentRow = new ActionRowBuilder<ButtonBuilder>();
}
});
return rows;
}
export async function createPoll(
context: BotContext,
params: {
guildId: string;
channelId: string;
creatorId: string;
question: string;
optionsInput: string;
multiSelect: boolean;
anonymous: boolean;
durationInput?: string | null;
},
locale: 'de' | 'en'
): Promise<{ pollId: string; messageId: string }> {
await ensureGuild(context.prisma, params.guildId);
const options = parsePollOptions(params.optionsInput);
let endsAt: Date | null = null;
let delayMs: number | null = null;
if (params.durationInput) {
delayMs = parseDuration(params.durationInput);
endsAt = new Date(Date.now() + delayMs);
}
const poll = await context.prisma.poll.create({
data: {
guildId: params.guildId,
channelId: params.channelId,
creatorId: params.creatorId,
question: params.question,
options,
multiSelect: params.multiSelect,
anonymous: params.anonymous,
endsAt
}
});
const embed = await buildPollEmbed(context, poll.id, locale);
const components = buildPollButtons(poll.id, options);
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({ embeds: [embed], components });
await context.prisma.poll.update({
where: { id: poll.id },
data: { messageId: message.id }
});
if (delayMs !== null) {
await reminderQueue.add(
'pollClose',
{ pollId: poll.id },
{
delay: delayMs,
jobId: `poll-close-${poll.id}`,
removeOnComplete: true,
removeOnFail: 50
}
);
}
return { pollId: poll.id, messageId: message.id };
}
export async function handlePollVote(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const parsed = parsePollButtonId(interaction.customId);
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!parsed || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'utility.poll.notFound'), ephemeral: true });
return;
}
const poll = await context.prisma.poll.findUnique({
where: { id: parsed.pollId },
include: { votes: true }
});
if (!poll || poll.closedAt) {
await interaction.reply({ content: t(locale, 'utility.poll.closed'), ephemeral: true });
return;
}
const options = parseOptionsJson(poll.options);
if (parsed.optionIndex < 0 || parsed.optionIndex >= options.length) {
await interaction.reply({ content: t(locale, 'utility.poll.invalidOption'), ephemeral: true });
return;
}
const userId = interaction.user.id;
const existingVotes = poll.votes.filter((v) => v.userId === userId);
if (!poll.multiSelect) {
if (existingVotes.some((v) => v.optionIndex === parsed.optionIndex)) {
await interaction.reply({ content: t(locale, 'utility.poll.alreadyVoted'), ephemeral: true });
return;
}
await context.prisma.pollVote.deleteMany({
where: { pollId: poll.id, userId }
});
await context.prisma.pollVote.create({
data: { pollId: poll.id, userId, optionIndex: parsed.optionIndex }
});
} else {
const existing = existingVotes.find((v) => v.optionIndex === parsed.optionIndex);
if (existing) {
await context.prisma.pollVote.delete({ where: { id: existing.id } });
} else {
await context.prisma.pollVote.create({
data: { pollId: poll.id, userId, optionIndex: parsed.optionIndex }
});
}
}
const embed = await buildPollEmbed(context, poll.id, locale);
const components = buildPollButtons(poll.id, options);
await interaction.update({ embeds: [embed], components });
if (!poll.anonymous) {
await interaction.followUp({
content: tf(locale, 'utility.poll.voted', { option: options[parsed.optionIndex] }),
ephemeral: true
});
}
}
export async function closePoll(context: BotContext, pollId: string): Promise<void> {
const poll = await context.prisma.poll.findUnique({ where: { id: pollId } });
if (!poll || poll.closedAt) {
return;
}
await context.prisma.poll.update({
where: { id: pollId },
data: { closedAt: new Date() }
});
if (!poll.messageId) {
return;
}
const locale = await getGuildLocale(context.prisma, poll.guildId);
const options = parseOptionsJson(poll.options);
const embed = await buildPollEmbed(context, pollId, locale);
const components = buildPollButtons(pollId, options, true);
try {
const channel = (await context.client.channels.fetch(poll.channelId)) as TextChannel;
const message = await channel.messages.fetch(poll.messageId);
await message.edit({ embeds: [embed], components });
} catch {
// Message may have been deleted
}
}

View File

@@ -0,0 +1,142 @@
import { type TextChannel } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { ensureGuild } from '../../guild.js';
import { reminderQueue } from '../../queues.js';
import { parseWhen, UtilityError } from './service.js';
export async function createReminder(
context: BotContext,
params: {
guildId: string | null;
userId: string;
channelId: string;
content: string;
whenInput: string;
recurringCron?: string | null;
}
): Promise<string> {
if (params.guildId) {
await ensureGuild(context.prisma, params.guildId);
}
const remindAt = parseWhen(params.whenInput);
const delayMs = remindAt.getTime() - Date.now();
const reminder = await context.prisma.reminder.create({
data: {
guildId: params.guildId,
userId: params.userId,
channelId: params.channelId,
content: params.content,
remindAt,
recurringCron: params.recurringCron ?? null
}
});
const jobOptions: {
jobId: string;
removeOnComplete: boolean;
removeOnFail: number;
delay?: number;
repeat?: { pattern: string };
} = {
jobId: `reminder-${reminder.id}`,
removeOnComplete: true,
removeOnFail: 50
};
if (params.recurringCron) {
jobOptions.repeat = { pattern: params.recurringCron };
} else {
jobOptions.delay = Math.max(0, delayMs);
}
const job = await reminderQueue.add(
'reminderSend',
{ reminderId: reminder.id },
jobOptions
);
await context.prisma.reminder.update({
where: { id: reminder.id },
data: { jobId: String(job.id) }
});
return reminder.id;
}
export async function listReminders(
context: BotContext,
userId: string,
locale: 'de' | 'en'
): Promise<string> {
const reminders = await context.prisma.reminder.findMany({
where: { userId },
orderBy: { remindAt: 'asc' },
take: 20
});
if (reminders.length === 0) {
return t(locale, 'utility.reminders.none');
}
return reminders
.map((r) =>
tf(locale, 'utility.reminders.line', {
id: r.id.slice(0, 8),
time: `<t:${Math.floor(r.remindAt.getTime() / 1000)}:R>`,
content: r.content.slice(0, 80)
})
)
.join('\n');
}
export async function deleteReminder(
context: BotContext,
userId: string,
reminderIdPrefix: string
): Promise<boolean> {
const reminders = await context.prisma.reminder.findMany({
where: { userId }
});
const match = reminders.find((r) => r.id.startsWith(reminderIdPrefix));
if (!match) {
return false;
}
if (match.jobId) {
const job = await reminderQueue.getJob(match.jobId);
if (job) {
await job.remove();
}
}
await context.prisma.reminder.delete({ where: { id: match.id } });
return true;
}
export async function sendReminder(context: BotContext, reminderId: string): Promise<void> {
const reminder = await context.prisma.reminder.findUnique({ where: { id: reminderId } });
if (!reminder) {
return;
}
try {
const channel = (await context.client.channels.fetch(reminder.channelId)) as TextChannel;
await channel.send({
content: `<@${reminder.userId}> ${reminder.content}`
});
} catch {
throw new UtilityError('reminder_channel_unavailable');
}
if (!reminder.recurringCron) {
await context.prisma.reminder.delete({ where: { id: reminderId } });
} else {
await context.prisma.reminder.update({
where: { id: reminderId },
data: { remindAt: new Date(Date.now() + 60_000) }
});
}
}

View File

@@ -0,0 +1,241 @@
import {
ChannelType,
EmbedBuilder,
type Guild,
type GuildMember,
type Role,
type GuildBasedChannel,
type User
} from 'discord.js';
import { parseDuration } from '../moderation/duration.js';
export class UtilityError extends Error {
constructor(public readonly code: string) {
super(code);
this.name = 'UtilityError';
}
}
export function parsePollOptions(input: string): string[] {
const options = input
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0);
if (options.length < 2 || options.length > 5) {
throw new UtilityError('invalid_poll_options');
}
return options;
}
export function parseWhen(input: string): Date {
const trimmed = input.trim();
if (/^\d+[smhd]$/i.test(trimmed)) {
const ms = parseDuration(trimmed);
return new Date(Date.now() + ms);
}
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed) || parsed <= Date.now()) {
throw new UtilityError('invalid_when');
}
return new Date(parsed);
}
export function parseTimestampInput(input: string): number {
const trimmed = input.trim();
if (/^\d+[smhd]$/i.test(trimmed)) {
return Date.now() + parseDuration(trimmed);
}
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed)) {
throw new UtilityError('invalid_datetime');
}
return parsed;
}
export function formatDiscordTimestamps(unixSeconds: number): string {
return [
`<t:${unixSeconds}:t>`,
`<t:${unixSeconds}:T>`,
`<t:${unixSeconds}:d>`,
`<t:${unixSeconds}:D>`,
`<t:${unixSeconds}:f>`,
`<t:${unixSeconds}:F>`,
`<t:${unixSeconds}:R>`
].join('\n');
}
export function buildUserInfoEmbed(
user: User,
member: GuildMember | null,
locale: 'de' | 'en'
): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle(user.tag)
.setThumbnail(user.displayAvatarURL({ size: 256 }))
.setColor(0x6366f1)
.addFields(
{ name: 'ID', value: user.id, inline: true },
{
name: locale === 'de' ? 'Erstellt' : 'Created',
value: `<t:${Math.floor(user.createdTimestamp / 1000)}:F>`,
inline: true
}
);
if (member) {
embed.addFields(
{
name: locale === 'de' ? 'Beigetreten' : 'Joined',
value: member.joinedTimestamp
? `<t:${Math.floor(member.joinedTimestamp / 1000)}:F>`
: '—',
inline: true
},
{
name: locale === 'de' ? 'Rollen' : 'Roles',
value:
member.roles.cache
.filter((r) => r.id !== member.guild.id)
.sort((a, b) => b.position - a.position)
.map((r) => r.toString())
.slice(0, 15)
.join(', ') || '—',
inline: false
}
);
}
return embed;
}
export function buildServerInfoEmbed(guild: Guild, locale: 'de' | 'en'): EmbedBuilder {
return new EmbedBuilder()
.setTitle(guild.name)
.setThumbnail(guild.iconURL({ size: 256 }))
.setColor(0x6366f1)
.addFields(
{ name: 'ID', value: guild.id, inline: true },
{
name: locale === 'de' ? 'Mitglieder' : 'Members',
value: String(guild.memberCount),
inline: true
},
{
name: locale === 'de' ? 'Erstellt' : 'Created',
value: guild.createdTimestamp
? `<t:${Math.floor(guild.createdTimestamp / 1000)}:F>`
: '—',
inline: true
},
{
name: locale === 'de' ? 'Besitzer' : 'Owner',
value: `<@${guild.ownerId}>`,
inline: true
},
{
name: locale === 'de' ? 'Boosts' : 'Boosts',
value: String(guild.premiumSubscriptionCount ?? 0),
inline: true
},
{
name: locale === 'de' ? 'Kanäle' : 'Channels',
value: String(guild.channels.cache.size),
inline: true
}
);
}
export function buildRoleInfoEmbed(role: Role, locale: 'de' | 'en'): EmbedBuilder {
return new EmbedBuilder()
.setTitle(role.name)
.setColor(role.color || 0x6366f1)
.addFields(
{ name: 'ID', value: role.id, inline: true },
{
name: locale === 'de' ? 'Mitglieder' : 'Members',
value: String(role.members.size),
inline: true
},
{
name: locale === 'de' ? 'Erstellt' : 'Created',
value: role.createdTimestamp
? `<t:${Math.floor(role.createdTimestamp / 1000)}:F>`
: '—',
inline: true
},
{
name: locale === 'de' ? 'Farbe' : 'Color',
value: role.hexColor,
inline: true
},
{
name: locale === 'de' ? 'Position' : 'Position',
value: String(role.position),
inline: true
},
{
name: locale === 'de' ? 'Erwähnbar' : 'Mentionable',
value: role.mentionable ? '✓' : '✗',
inline: true
}
);
}
export function buildChannelInfoEmbed(
channel: GuildBasedChannel,
locale: 'de' | 'en'
): EmbedBuilder {
const typeLabel =
channel.type === ChannelType.GuildText
? locale === 'de'
? 'Text'
: 'Text'
: channel.type === ChannelType.GuildVoice
? locale === 'de'
? 'Voice'
: 'Voice'
: String(channel.type);
const embed = new EmbedBuilder()
.setTitle(channel.name)
.setColor(0x6366f1)
.addFields(
{ name: 'ID', value: channel.id, inline: true },
{ name: locale === 'de' ? 'Typ' : 'Type', value: typeLabel, inline: true },
{
name: locale === 'de' ? 'Erstellt' : 'Created',
value: channel.createdTimestamp
? `<t:${Math.floor(channel.createdTimestamp / 1000)}:F>`
: '—',
inline: true
}
);
if ('topic' in channel && channel.topic) {
embed.addFields({
name: locale === 'de' ? 'Thema' : 'Topic',
value: channel.topic.slice(0, 1024),
inline: false
});
}
return embed;
}
export function parseHexColor(input: string): number | null {
const cleaned = input.replace(/^#/, '').trim();
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) {
return null;
}
return Number.parseInt(cleaned, 16);
}
export const CUSTOM_EMOJI_REGEX = /<a?:(\w+):(\d+)>/;
export function extractFirstCustomEmoji(content: string): { name: string; id: string } | null {
const match = content.match(CUSTOM_EMOJI_REGEX);
if (!match) {
return null;
}
return { name: match[1], id: match[2] };
}

View File

@@ -0,0 +1,130 @@
import { EmbedBuilder, type Message, type PartialMessage } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
const SNIPE_TTL_SECONDS = 3600;
const DELETE_KEY_PREFIX = 'util:snipe:delete:';
const EDIT_KEY_PREFIX = 'util:snipe:edit:';
type SnipePayload = {
authorId: string;
authorTag: string;
content: string;
createdAt: number;
attachmentUrl?: string;
};
function snipeKey(prefix: string, channelId: string): string {
return `${prefix}${channelId}`;
}
async function storeSnipe(
redis: BotContext['redis'],
prefix: string,
channelId: string,
payload: SnipePayload
): Promise<void> {
await redis.set(snipeKey(prefix, channelId), JSON.stringify(payload), 'EX', SNIPE_TTL_SECONDS);
}
async function getSnipe(
redis: BotContext['redis'],
prefix: string,
channelId: string
): Promise<SnipePayload | null> {
const raw = await redis.get(snipeKey(prefix, channelId));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as SnipePayload;
} catch {
return null;
}
}
function messageToPayload(message: Message | PartialMessage): SnipePayload {
return {
authorId: message.author?.id ?? 'unknown',
authorTag: message.author?.tag ?? 'Unknown',
content: message.content?.slice(0, 2000) ?? '',
createdAt: message.createdTimestamp ?? Date.now(),
attachmentUrl: message.attachments.first()?.url
};
}
export function registerSnipeEvents(context: BotContext): void {
context.client.on('messageDelete', async (message) => {
if (message.author?.bot || !message.guildId || !message.channelId) {
return;
}
if (!message.content && message.attachments.size === 0) {
return;
}
await storeSnipe(context.redis, DELETE_KEY_PREFIX, message.channelId, messageToPayload(message));
});
context.client.on('messageUpdate', async (oldMessage, newMessage) => {
if (oldMessage.author?.bot || !oldMessage.guildId || !oldMessage.channelId) {
return;
}
const oldContent = oldMessage.content ?? '';
const newContent = newMessage.content ?? '';
if (!oldContent || oldContent === newContent) {
return;
}
await storeSnipe(context.redis, EDIT_KEY_PREFIX, oldMessage.channelId, messageToPayload(oldMessage));
});
}
export async function getDeletedSnipe(
context: BotContext,
channelId: string,
locale: 'de' | 'en'
): Promise<{ embed: EmbedBuilder; privacyNote: string } | null> {
const payload = await getSnipe(context.redis, DELETE_KEY_PREFIX, channelId);
if (!payload) {
return null;
}
return buildSnipeReply(payload, locale, 'delete');
}
export async function getEditedSnipe(
context: BotContext,
channelId: string,
locale: 'de' | 'en'
): Promise<{ embed: EmbedBuilder; privacyNote: string } | null> {
const payload = await getSnipe(context.redis, EDIT_KEY_PREFIX, channelId);
if (!payload) {
return null;
}
return buildSnipeReply(payload, locale, 'edit');
}
function buildSnipeReply(
payload: SnipePayload,
locale: 'de' | 'en',
type: 'delete' | 'edit'
): { embed: EmbedBuilder; privacyNote: string } {
const embed = new EmbedBuilder()
.setColor(0x6366f1)
.setAuthor({ name: payload.authorTag })
.setDescription(payload.content || '—')
.setTimestamp(payload.createdAt);
if (payload.attachmentUrl) {
embed.setImage(payload.attachmentUrl);
}
embed.setFooter({
text: tf(locale, type === 'delete' ? 'utility.snipe.deletedAt' : 'utility.snipe.editedAt', {
user: `<@${payload.authorId}>`
})
});
return {
embed,
privacyNote: t(locale, 'utility.snipe.privacy')
};
}