From ffaa1e26fdb2f465183430e7fcd7863c7b290c18 Mon Sep 17 00:00:00 2001 From: smueller Date: Thu, 23 Jul 2026 09:53:44 +0200 Subject: [PATCH] Enhance bot functionality with new features and improvements - Updated package dependencies to include `@sentry/node` for error tracking. - Refactored command routing to incorporate user and guild blacklisting checks, improving command execution safety. - Enhanced health server metrics to include queue waiting times, providing better insights into system performance. - Introduced confirmation handling for guild backup commands, streamlining user interactions. - Improved moderation commands with better error handling and case management, ensuring robust moderation capabilities. - Added new duration parsing functions to handle timeout durations effectively, enhancing moderation features. - Updated localization files to reflect new command structures and user guidance improvements. --- .vscode/settings.json | 3 + apps/bot/package.json | 3 +- apps/bot/src/commands.ts | 126 ++++++-- apps/bot/src/confirmation-store.test.ts | 52 ++++ apps/bot/src/confirmation-store.ts | 57 ++++ apps/bot/src/env.ts | 1 + apps/bot/src/harden.test.ts | 44 +++ apps/bot/src/health.ts | 37 ++- apps/bot/src/hierarchy.ts | 141 +++++++++ apps/bot/src/index.ts | 232 +++++++-------- apps/bot/src/interaction-registry.ts | 154 ++++++++++ apps/bot/src/jobs.ts | 68 ++++- apps/bot/src/metrics.ts | 129 ++++++++ apps/bot/src/module-gates.test.ts | 33 +++ apps/bot/src/module-gates.ts | 275 ++++++++++++++++++ apps/bot/src/modules/guildbackup/commands.ts | 4 +- .../src/modules/guildbackup/confirmations.ts | 111 +++---- apps/bot/src/modules/moderation/actions.ts | 50 ++-- .../modules/moderation/command-definitions.ts | 53 +++- apps/bot/src/modules/moderation/commands.ts | 177 ++++++++--- .../src/modules/moderation/confirmations.ts | 64 ++-- .../src/modules/moderation/duration.test.ts | 31 +- apps/bot/src/modules/moderation/duration.ts | 33 ++- apps/bot/src/modules/moderation/service.ts | 131 ++++++--- apps/bot/src/sentry.ts | 36 +++ apps/bot/src/shard-lifecycle.ts | 37 +++ apps/webui/package.json | 1 + apps/webui/src/instrumentation.ts | 6 + apps/webui/src/lib/sentry.ts | 32 ++ docs/PHASE-TRACKING.md | 25 ++ packages/shared/src/index.ts | 48 +++ 31 files changed, 1831 insertions(+), 363 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 apps/bot/src/confirmation-store.test.ts create mode 100644 apps/bot/src/confirmation-store.ts create mode 100644 apps/bot/src/harden.test.ts create mode 100644 apps/bot/src/hierarchy.ts create mode 100644 apps/bot/src/interaction-registry.ts create mode 100644 apps/bot/src/metrics.ts create mode 100644 apps/bot/src/module-gates.test.ts create mode 100644 apps/bot/src/module-gates.ts create mode 100644 apps/bot/src/sentry.ts create mode 100644 apps/bot/src/shard-lifecycle.ts create mode 100644 apps/webui/src/instrumentation.ts create mode 100644 apps/webui/src/lib/sentry.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5a586b3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.lint.unknownAtRules": "ignore" +} diff --git a/apps/bot/package.json b/apps/bot/package.json index a534f0b..1b57006 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -14,9 +14,10 @@ "profile:banner": "tsx scripts/set-bot-banner.ts" }, "dependencies": { - "@nexumi/shared": "workspace:*", "@napi-rs/canvas": "^0.1.65", + "@nexumi/shared": "workspace:*", "@prisma/client": "^5.22.0", + "@sentry/node": "^10.67.0", "bullmq": "^5.34.0", "discord.js": "^14.17.3", "dotenv": "^16.4.7", diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index 1609f60..4f3b501 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -9,7 +9,15 @@ import { env } from './env.js'; import { logger } from './logger.js'; import { getGuildLocale } from './i18n.js'; import { evaluateCommandGate, type CommandGateReason } from './command-gates.js'; +import { + isGuildBlacklisted, + isModuleEnabled, + isUserBlacklisted, + moduleForCommand +} from './module-gates.js'; import { isMaintenanceMode } from './presence.js'; +import { recordCommandMetric } from './metrics.js'; +import { captureException } from './sentry.js'; import { moderationCommands } from './modules/moderation/commands.js'; import { automodCommands } from './modules/automod/commands.js'; import { welcomeCommands } from './modules/welcome/commands.js'; @@ -116,41 +124,109 @@ function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSecond } export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) { + const started = Date.now(); + let ok = true; const locale = await getGuildLocale(context.prisma, interaction.guildId); - const maintenance = await isMaintenanceMode(context); - const ownerIds = env.OWNER_USER_IDS ?? []; - if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) { - await interaction.reply({ - content: maintenance.message?.trim() || t(locale, 'generic.maintenance'), - ephemeral: true + + try { + if (await isUserBlacklisted(context.prisma, interaction.user.id)) { + await interaction.reply({ + content: t(locale, 'generic.userBlacklisted'), + ephemeral: true + }); + return; + } + + if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) { + await interaction.reply({ + content: t(locale, 'generic.guildBlacklisted'), + ephemeral: true + }); + return; + } + + const maintenance = await isMaintenanceMode(context); + const ownerIds = env.OWNER_USER_IDS ?? []; + if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) { + await interaction.reply({ + content: maintenance.message?.trim() || t(locale, 'generic.maintenance'), + ephemeral: true + }); + return; + } + + const command = map.get(interaction.commandName); + if (!command) { + await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); + return; + } + + const moduleId = moduleForCommand(interaction.commandName); + if (moduleId && interaction.guildId) { + const enabled = await isModuleEnabled( + context.prisma, + context.redis, + interaction.guildId, + moduleId + ); + if (!enabled) { + await interaction.reply({ + content: t(locale, 'generic.moduleDisabled'), + ephemeral: true + }); + return; + } + } + + const gate = await evaluateCommandGate(interaction, context); + if (!gate.ok) { + await interaction.reply({ + content: gateMessage(locale, gate.reason, gate.retryAfterSeconds), + ephemeral: true + }); + return; + } + + await command.execute(interaction, context); + } catch (error) { + ok = false; + captureException(error, { + command: interaction.commandName, + guildId: interaction.guildId, + userId: interaction.user.id }); - return; + throw error; + } finally { + await recordCommandMetric(context.redis, { + ok, + durationMs: Date.now() - started + }).catch(() => undefined); } - - const command = map.get(interaction.commandName); - if (!command) { - await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); - return; - } - - const gate = await evaluateCommandGate(interaction, context); - if (!gate.ok) { - await interaction.reply({ - content: gateMessage(locale, gate.reason, gate.retryAfterSeconds), - ephemeral: true - }); - return; - } - - await command.execute(interaction, context); } export async function routeContextMenu( interaction: MessageContextMenuCommandInteraction, context: BotContext ) { - const command = contextMenuMap.get(interaction.commandName); const locale = await getGuildLocale(context.prisma, interaction.guildId); + + if (await isUserBlacklisted(context.prisma, interaction.user.id)) { + await interaction.reply({ + content: t(locale, 'generic.userBlacklisted'), + ephemeral: true + }); + return; + } + + if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) { + await interaction.reply({ + content: t(locale, 'generic.guildBlacklisted'), + ephemeral: true + }); + return; + } + + const command = contextMenuMap.get(interaction.commandName); if (!command) { await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); return; diff --git a/apps/bot/src/confirmation-store.test.ts b/apps/bot/src/confirmation-store.test.ts new file mode 100644 index 0000000..a4cb2f7 --- /dev/null +++ b/apps/bot/src/confirmation-store.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { Redis } from 'ioredis'; +import { + deleteConfirmation, + getConfirmation, + setConfirmation, + takeConfirmation +} from './confirmation-store.js'; + +function createMemoryRedis(): Redis { + const store = new Map(); + return { + async set(key: string, value: string) { + store.set(key, value); + return 'OK'; + }, + async get(key: string) { + return store.get(key) ?? null; + }, + async del(key: string) { + store.delete(key); + return 1; + } + } as unknown as Redis; +} + +describe('confirmation-store', () => { + it('stores and retrieves pending confirmations', async () => { + const redis = createMemoryRedis(); + await setConfirmation(redis, 'moderation', 'tok-1', { action: 'ban', userId: '1' }, 60); + await expect(getConfirmation(redis, 'moderation', 'tok-1')).resolves.toEqual({ + action: 'ban', + userId: '1' + }); + }); + + it('takeConfirmation deletes the entry', async () => { + const redis = createMemoryRedis(); + await setConfirmation(redis, 'moderation', 'tok-2', { action: 'purge' }, 60); + await expect(takeConfirmation(redis, 'moderation', 'tok-2')).resolves.toEqual({ + action: 'purge' + }); + await expect(getConfirmation(redis, 'moderation', 'tok-2')).resolves.toBeNull(); + }); + + it('deleteConfirmation removes pending state', async () => { + const redis = createMemoryRedis(); + await setConfirmation(redis, 'guildbackup', 'tok-3', { action: 'delete' }, 60); + await deleteConfirmation(redis, 'guildbackup', 'tok-3'); + await expect(getConfirmation(redis, 'guildbackup', 'tok-3')).resolves.toBeNull(); + }); +}); diff --git a/apps/bot/src/confirmation-store.ts b/apps/bot/src/confirmation-store.ts new file mode 100644 index 0000000..c7be97e --- /dev/null +++ b/apps/bot/src/confirmation-store.ts @@ -0,0 +1,57 @@ +import type { Redis } from 'ioredis'; + +function key(namespace: string, token: string): string { + return `confirm:${namespace}:${token}`; +} + +export async function setConfirmation( + redis: Redis, + namespace: string, + token: string, + entry: T, + ttlSeconds: number +): Promise { + await redis.set(key(namespace, token), JSON.stringify(entry), 'EX', ttlSeconds); +} + +export async function getConfirmation( + redis: Redis, + namespace: string, + token: string +): Promise { + const raw = await redis.get(key(namespace, token)); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export async function deleteConfirmation( + redis: Redis, + namespace: string, + token: string +): Promise { + await redis.del(key(namespace, token)); +} + +export async function takeConfirmation( + redis: Redis, + namespace: string, + token: string +): Promise { + const k = key(namespace, token); + const raw = await redis.get(k); + if (!raw) { + return null; + } + await redis.del(k); + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index d1e3a74..7609f91 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -19,6 +19,7 @@ const EnvSchema = z.object({ BACKUP_CRON: z.string().default('0 3 * * *'), BACKUP_DIR: z.string().default('/backups'), METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()), HEALTH_PORT: z.coerce.number().int().positive().default(8080), PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'), TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()), diff --git a/apps/bot/src/harden.test.ts b/apps/bot/src/harden.test.ts new file mode 100644 index 0000000..61fd811 --- /dev/null +++ b/apps/bot/src/harden.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { formatPrometheus, type MetricsSnapshot } from './metrics.js'; + +describe('confirmation custom_id matchers', () => { + it('detects moderation confirm/cancel prefixes', () => { + const isMod = (id: string) => id.startsWith('mod:confirm:') || id.startsWith('mod:cancel:'); + expect(isMod('mod:confirm:abc')).toBe(true); + expect(isMod('mod:cancel:abc')).toBe(true); + expect(isMod('other')).toBe(false); + }); + + it('detects guildbackup confirm/cancel prefixes', () => { + const isBackup = (id: string) => + id.startsWith('gbackup:confirm:delete:') || + id.startsWith('gbackup:confirm:restore:') || + id.startsWith('gbackup:cancel:'); + expect(isBackup('gbackup:confirm:delete:abc')).toBe(true); + expect(isBackup('gbackup:confirm:restore:abc')).toBe(true); + expect(isBackup('gbackup:cancel:abc')).toBe(true); + expect(isBackup('mod:confirm:abc')).toBe(false); + }); +}); + +describe('formatPrometheus', () => { + it('emits core counters and shard gauges', () => { + const snapshot: MetricsSnapshot = { + up: 1, + commandsTotal: 10, + commandsErrors: 2, + commandDurationSumMs: 500, + commandDurationCount: 10, + eventsTotal: 100, + shards: [{ id: 0, ping: 42, guilds: 5, updatedAt: Date.now() }], + queueWaiting: 3 + }; + const body = formatPrometheus(snapshot); + expect(body).toContain('nexumi_up 1'); + expect(body).toContain('nexumi_commands_total 10'); + expect(body).toContain('nexumi_commands_errors_total 2'); + expect(body).toContain('nexumi_guilds{shard="0"} 5'); + expect(body).toContain('nexumi_shard_ping_ms{shard="0"} 42'); + expect(body).toContain('nexumi_queue_waiting 3'); + }); +}); diff --git a/apps/bot/src/health.ts b/apps/bot/src/health.ts index e7ada71..74971f9 100644 --- a/apps/bot/src/health.ts +++ b/apps/bot/src/health.ts @@ -6,7 +6,17 @@ import { getCaptchaChallenge, hashCaptchaAnswer } from './modules/verification/service.js'; -import { verificationQueue } from './queues.js'; +import { + automodQueue, + backupQueue, + birthdayQueue, + feedsQueue, + presenceQueue, + retentionQueue, + ticketQueue, + verificationQueue +} from './queues.js'; +import { collectMetricsSnapshot, formatPrometheus } from './metrics.js'; function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { @@ -99,6 +109,19 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro res.end('

Verified

You can return to Discord.

'); } +async function approximateQueueWaiting(): Promise { + const counts = await Promise.all([ + automodQueue.getWaitingCount(), + backupQueue.getWaitingCount(), + birthdayQueue.getWaitingCount(), + feedsQueue.getWaitingCount(), + presenceQueue.getWaitingCount(), + retentionQueue.getWaitingCount(), + ticketQueue.getWaitingCount() + ]); + return counts.reduce((sum, n) => sum + n, 0); +} + export function startHealthServer(): void { const server = createServer(async (req, res) => { if (!req.url) { @@ -121,8 +144,16 @@ export function startHealthServer(): void { res.end('Unauthorized'); return; } - res.statusCode = 200; - res.end('# Prometheus metrics scaffold\nnexumi_up 1\n'); + try { + const snapshot = await collectMetricsSnapshot(redis); + snapshot.queueWaiting = await approximateQueueWaiting(); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.end(formatPrometheus(snapshot)); + } catch { + res.statusCode = 500; + res.end('metrics error'); + } return; } diff --git a/apps/bot/src/hierarchy.ts b/apps/bot/src/hierarchy.ts new file mode 100644 index 0000000..4fdb0f3 --- /dev/null +++ b/apps/bot/src/hierarchy.ts @@ -0,0 +1,141 @@ +import { + type ChatInputCommandInteraction, + type GuildMember, + type Interaction +} from 'discord.js'; +import type { Locale } from '@nexumi/shared'; +import { t } from '@nexumi/shared'; + +export type HierarchyAction = 'ban' | 'kick' | 'timeout' | 'nick'; + +export type HierarchyOk = { ok: true; member: GuildMember | null }; +export type HierarchyDenied = { ok: false }; + +/** + * Validates that the moderator and bot can act on the target member. + * Replies ephemerally on denial. For bans, `member` may be null if the user + * already left the guild (ban-by-id is still allowed). + */ +export async function assertModerableMember( + interaction: ChatInputCommandInteraction, + targetUserId: string, + locale: Locale, + action: HierarchyAction +): Promise { + const guild = interaction.guild; + if (!guild) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return { ok: false }; + } + + if (targetUserId === interaction.user.id) { + await interaction.reply({ content: t(locale, 'moderation.hierarchy.self'), ephemeral: true }); + return { ok: false }; + } + + if (targetUserId === guild.ownerId) { + await interaction.reply({ content: t(locale, 'moderation.hierarchy.owner'), ephemeral: true }); + return { ok: false }; + } + + let member: GuildMember | null = null; + try { + member = await guild.members.fetch(targetUserId); + } catch { + if (action === 'ban') { + return { ok: true, member: null }; + } + await interaction.reply({ content: t(locale, 'moderation.hierarchy.notMember'), ephemeral: true }); + return { ok: false }; + } + + const moderator = + interaction.member && 'roles' in interaction.member + ? (interaction.member as GuildMember) + : await guild.members.fetch(interaction.user.id); + + if (guild.ownerId !== moderator.id) { + if (member.roles.highest.position >= moderator.roles.highest.position) { + await interaction.reply({ + content: t(locale, 'moderation.hierarchy.moderator'), + ephemeral: true + }); + return { ok: false }; + } + } + + const botOk = + action === 'ban' + ? member.bannable + : action === 'kick' + ? member.kickable + : action === 'timeout' + ? member.moderatable + : member.manageable; + + if (!botOk) { + await interaction.reply({ + content: t(locale, 'moderation.hierarchy.bot'), + ephemeral: true + }); + return { ok: false }; + } + + return { ok: true, member }; +} + +async function replyHierarchyForButton(interaction: Interaction, locale: Locale, key: string): Promise { + if (!interaction.isRepliable()) { + return; + } + const content = t(locale, key); + if (interaction.isButton()) { + await interaction.update({ content, components: [] }); + return; + } + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content, ephemeral: true }); + } else { + await interaction.reply({ content, ephemeral: true }); + } +} + +export async function assertBannableForConfirm( + interaction: Interaction, + locale: Locale, + targetUserId: string +): Promise { + const guild = interaction.guild; + if (!guild) { + await replyHierarchyForButton(interaction, locale, 'generic.guildOnly'); + return false; + } + if (targetUserId === interaction.user.id) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.self'); + return false; + } + if (targetUserId === guild.ownerId) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.owner'); + return false; + } + + try { + const member = await guild.members.fetch(targetUserId); + if (!member.bannable) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.bot'); + return false; + } + const moderator = await guild.members.fetch(interaction.user.id); + if ( + guild.ownerId !== moderator.id && + member.roles.highest.position >= moderator.roles.highest.position + ) { + await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.moderator'); + return false; + } + } catch { + // Target left the guild — ban by id is still fine. + } + + return true; +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 466d35b..25e7e1b 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -14,64 +14,30 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js' import { ensureRecurringJobs, startWorkers } from './jobs.js'; import type { BotContext } from './types.js'; import { startHealthServer } from './health.js'; -import { - handleModerationConfirmation, - isModerationConfirmation -} from './modules/moderation/confirmations.js'; import { getGuildLocale } from './i18n.js'; import { t } from '@nexumi/shared'; import { handleAutoModMessage } from './modules/automod/actions.js'; import { registerLoggingEvents } from './modules/logging/events.js'; import { registerWelcomeEvents } from './modules/welcome/events.js'; import { registerVerificationEvents } from './modules/verification/events.js'; -import { handleVerificationButton } from './modules/verification/handlers.js'; -import { isVerificationButton } from './modules/verification/service.js'; import { registerLevelingEvents } from './modules/leveling/events.js'; -import { - handleBlackjackButton, - isBlackjackButton -} from './modules/economy/blackjack-buttons.js'; -import { - handleEmbedModal, - handlePollButton, - isEmbedModal, - isPollButton, - registerUtilityEvents -} from './modules/utility/index.js'; -import { handleFunButton, isFunButton } from './modules/fun/index.js'; -import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js'; -import { - handleTicketInteraction, - isTicketInteraction, - registerTicketEvents -} from './modules/tickets/index.js'; -import { - handleSelfRoleInteraction, - isSelfRoleInteraction, - registerSelfRoleEvents -} from './modules/selfroles/index.js'; +import { registerUtilityEvents } from './modules/utility/index.js'; +import { registerTicketEvents } from './modules/tickets/index.js'; +import { registerSelfRoleEvents } from './modules/selfroles/index.js'; import { registerTagEvents } from './modules/tags/index.js'; import { registerStarboardEvents } from './modules/starboard/index.js'; -import { - handleSuggestionButton, - isSuggestionButton -} from './modules/suggestions/index.js'; -import { - handleTempVoiceInteraction, - isTempVoiceInteraction, - isTempVoiceModal, - registerTempVoiceEvents -} from './modules/tempvoice/index.js'; +import { registerTempVoiceEvents } from './modules/tempvoice/index.js'; import { registerStatsEvents } from './modules/stats/index.js'; -import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js'; -import { - handleComponentsV2Interaction, - isComponentsV2Interaction -} from './modules/components-v2/index.js'; +import { routeComponentInteraction } from './interaction-registry.js'; +import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js'; +import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js'; +import { publishShardMetrics, recordEventMetric } from './metrics.js'; +import { captureException, initSentry } from './sentry.js'; const isShard = process.argv.includes('--shard'); if (!isShard) { + initSentry('bot-manager'); startHealthServer(); const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, { token: env.BOT_TOKEN, @@ -79,8 +45,25 @@ if (!isShard) { shardArgs: ['--shard'] }); manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned')); + + // Register slash commands once from the manager (not once per shard). + try { + await runOncePerCluster( + 'nexumi:lock:register-commands', + 300, + async () => { + await registerCommands(); + }, + 'Command registration' + ); + } catch (error) { + logger.error({ error }, 'Failed to register application commands from manager'); + captureException(error, { phase: 'registerCommands' }); + } + await manager.spawn(); } else { + initSentry('bot-shard'); const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -103,7 +86,7 @@ if (!isShard) { const context: BotContext = { client, prisma, redis }; startWorkers(context); - await ensureRecurringJobs(); + registerLoggingEvents(context); registerWelcomeEvents(context); registerVerificationEvents(context); @@ -117,12 +100,24 @@ if (!isShard) { registerStatsEvents(context); client.on(Events.ClientReady, async () => { - logger.info({ tag: client.user?.tag }, 'Bot ready'); - try { - await registerCommands(); - } catch (error) { - logger.error({ error }, 'Failed to register application commands'); + logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready'); + + if (isPrimaryShard(client)) { + try { + await runOncePerCluster( + 'nexumi:lock:ensure-recurring-jobs', + 120, + async () => { + await ensureRecurringJobs(); + }, + 'Recurring job registration' + ); + } catch (error) { + logger.error({ error }, 'Failed to ensure recurring jobs'); + captureException(error, { phase: 'ensureRecurringJobs' }); + } } + try { const { applyBotPresence, publishBotStatus } = await import('./presence.js'); await applyBotPresence(context); @@ -130,18 +125,61 @@ if (!isShard) { } catch (error) { logger.warn({ error }, 'Failed to apply initial bot presence/status'); } + + try { + await publishShardMetrics(context.redis, client); + } catch (error) { + logger.warn({ error }, 'Failed to publish initial shard metrics'); + } }); + client.on(Events.GuildCreate, async (guild) => { + try { + if (await isGuildBlacklisted(context.prisma, guild.id)) { + logger.info({ guildId: guild.id }, 'Leaving blacklisted guild'); + await guild.leave(); + } + } catch (error) { + logger.error({ error, guildId: guild.id }, 'GuildCreate blacklist check failed'); + captureException(error, { guildId: guild.id }); + } + }); + + setInterval(() => { + publishShardMetrics(context.redis, client).catch(() => undefined); + }, 30_000); + client.on(Events.MessageCreate, async (message) => { try { + await recordEventMetric(context.redis); + if (message.author.bot) return; + if (await isUserBlacklisted(context.prisma, message.author.id)) return; + if (message.guildId && (await isGuildBlacklisted(context.prisma, message.guildId))) return; await handleAutoModMessage(context, message); } catch (error) { logger.error({ error, messageId: message.id }, 'AutoMod message handler failed'); + captureException(error, { messageId: message.id }); } }); client.on(Events.InteractionCreate, async (interaction: Interaction) => { try { + if ( + interaction.user && + (await isUserBlacklisted(context.prisma, interaction.user.id)) && + !interaction.isChatInputCommand() && + !interaction.isMessageContextMenuCommand() + ) { + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ + content: t(locale, 'generic.userBlacklisted'), + ephemeral: true + }); + } + return; + } + if (interaction.isChatInputCommand()) { await routeCommand(interaction, context); return; @@ -152,93 +190,23 @@ if (!isShard) { return; } - if (interaction.isButton() && isModerationConfirmation(interaction.customId)) { - await handleModerationConfirmation(interaction, context); - return; - } - - if (interaction.isButton() && isVerificationButton(interaction.customId)) { - await handleVerificationButton(interaction, context); - return; - } - - if (interaction.isButton() && isBlackjackButton(interaction.customId)) { - await handleBlackjackButton(interaction, context); - return; - } - - if (interaction.isButton() && isPollButton(interaction.customId)) { - await handlePollButton(interaction, context); - return; - } - - if (interaction.isButton() && isFunButton(interaction.customId)) { - await handleFunButton(interaction, context); - return; - } - - if (interaction.isButton() && isGiveawayButton(interaction.customId)) { - await handleGiveawayButton(interaction, context); - return; - } - if ( - (interaction.isButton() || - interaction.isStringSelectMenu() || - interaction.isModalSubmit()) && - isTicketInteraction(interaction.customId) + interaction.isButton() || + interaction.isStringSelectMenu() || + interaction.isUserSelectMenu() || + interaction.isRoleSelectMenu() || + interaction.isChannelSelectMenu() || + interaction.isMentionableSelectMenu() || + interaction.isModalSubmit() ) { - await handleTicketInteraction(interaction, context); - return; - } - - if ( - (interaction.isButton() || interaction.isStringSelectMenu()) && - isSelfRoleInteraction(interaction.customId) - ) { - await handleSelfRoleInteraction(interaction, context); - return; - } - - if (interaction.isButton() && isSuggestionButton(interaction.customId)) { - await handleSuggestionButton(interaction, context); - return; - } - - if (interaction.isButton() && isTempVoiceInteraction(interaction.customId)) { - await handleTempVoiceInteraction(interaction, context); - return; - } - - if (interaction.isModalSubmit() && isTempVoiceModal(interaction.customId)) { - await handleTempVoiceInteraction(interaction, context); - return; - } - - if (interaction.isButton() && isGuildBackupButton(interaction.customId)) { - await handleGuildBackupButton(interaction, context); - return; - } - - if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) { - await handleEmbedModal(interaction, context); - return; - } - - if ( - (interaction.isButton() || - interaction.isStringSelectMenu() || - interaction.isUserSelectMenu() || - interaction.isRoleSelectMenu() || - interaction.isChannelSelectMenu() || - interaction.isMentionableSelectMenu()) && - isComponentsV2Interaction(interaction.customId) - ) { - await handleComponentsV2Interaction(interaction, context); - return; + await routeComponentInteraction(interaction, context); } } catch (error) { logger.error({ error }, 'Interaction handling failed'); + captureException(error, { + interactionType: interaction.type, + guildId: interaction.guildId + }); const locale = await getGuildLocale(context.prisma, interaction.guildId); const message = t(locale, 'generic.error'); if (interaction.isRepliable()) { diff --git a/apps/bot/src/interaction-registry.ts b/apps/bot/src/interaction-registry.ts new file mode 100644 index 0000000..9f8558f --- /dev/null +++ b/apps/bot/src/interaction-registry.ts @@ -0,0 +1,154 @@ +import type { + ButtonInteraction, + ChannelSelectMenuInteraction, + MentionableSelectMenuInteraction, + ModalSubmitInteraction, + RoleSelectMenuInteraction, + StringSelectMenuInteraction, + UserSelectMenuInteraction +} from 'discord.js'; +import type { BotContext } from './types.js'; +import { + handleModerationConfirmation, + isModerationConfirmation +} from './modules/moderation/confirmations.js'; +import { handleVerificationButton } from './modules/verification/handlers.js'; +import { isVerificationButton } from './modules/verification/service.js'; +import { + handleBlackjackButton, + isBlackjackButton +} from './modules/economy/blackjack-buttons.js'; +import { + handleEmbedModal, + handlePollButton, + isEmbedModal, + isPollButton +} from './modules/utility/index.js'; +import { handleFunButton, isFunButton } from './modules/fun/index.js'; +import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js'; +import { handleTicketInteraction, isTicketInteraction } from './modules/tickets/index.js'; +import { + handleSelfRoleInteraction, + isSelfRoleInteraction +} from './modules/selfroles/index.js'; +import { + handleSuggestionButton, + isSuggestionButton +} from './modules/suggestions/index.js'; +import { + handleTempVoiceInteraction, + isTempVoiceInteraction, + isTempVoiceModal +} from './modules/tempvoice/index.js'; +import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js'; +import { + handleComponentsV2Interaction, + isComponentsV2Interaction +} from './modules/components-v2/index.js'; + +type AnyComponentInteraction = + | ButtonInteraction + | StringSelectMenuInteraction + | UserSelectMenuInteraction + | RoleSelectMenuInteraction + | ChannelSelectMenuInteraction + | MentionableSelectMenuInteraction + | ModalSubmitInteraction; + +type InteractionHandler = { + match: (customId: string) => boolean; + handle: (interaction: AnyComponentInteraction, context: BotContext) => Promise; +}; + +function asButton(interaction: AnyComponentInteraction): ButtonInteraction { + return interaction as ButtonInteraction; +} + +function asModal(interaction: AnyComponentInteraction): ModalSubmitInteraction { + return interaction as ModalSubmitInteraction; +} + +const handlers: InteractionHandler[] = [ + { + match: isModerationConfirmation, + handle: (interaction, context) => handleModerationConfirmation(asButton(interaction), context) + }, + { + match: isVerificationButton, + handle: (interaction, context) => handleVerificationButton(asButton(interaction), context) + }, + { + match: isBlackjackButton, + handle: (interaction, context) => handleBlackjackButton(asButton(interaction), context) + }, + { + match: isPollButton, + handle: (interaction, context) => handlePollButton(asButton(interaction), context) + }, + { + match: isFunButton, + handle: (interaction, context) => handleFunButton(asButton(interaction), context) + }, + { + match: isGiveawayButton, + handle: (interaction, context) => handleGiveawayButton(asButton(interaction), context) + }, + { + match: isTicketInteraction, + handle: (interaction, context) => + handleTicketInteraction( + interaction as Parameters[0], + context + ) + }, + { + match: isSelfRoleInteraction, + handle: (interaction, context) => + handleSelfRoleInteraction( + interaction as Parameters[0], + context + ) + }, + { + match: isSuggestionButton, + handle: (interaction, context) => handleSuggestionButton(asButton(interaction), context) + }, + { + match: (customId) => isTempVoiceInteraction(customId) || isTempVoiceModal(customId), + handle: (interaction, context) => + handleTempVoiceInteraction( + interaction as Parameters[0], + context + ) + }, + { + match: isGuildBackupButton, + handle: (interaction, context) => handleGuildBackupButton(asButton(interaction), context) + }, + { + match: isEmbedModal, + handle: (interaction, context) => handleEmbedModal(asModal(interaction), context) + }, + { + match: isComponentsV2Interaction, + handle: (interaction, context) => + handleComponentsV2Interaction( + interaction as Parameters[0], + context + ) + } +]; + +export async function routeComponentInteraction( + interaction: AnyComponentInteraction, + context: BotContext +): Promise { + const customId = interaction.customId; + for (const handler of handlers) { + if (handler.match(customId)) { + await handler.handle(interaction, context); + return true; + } + } + return false; +} diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 2642871..dbb0c01 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -119,12 +119,70 @@ export function startWorkers(context: BotContext): Worker[] { return; } - const guild = await context.client.guilds.fetch(job.data.guildId); - const me = await guild.members.fetchMe(); - if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { - throw new Error('Missing BanMembers permission for temp ban expiration'); + const { guildId, userId, reason } = job.data; + const guild = context.client.guilds.cache.get(guildId); + if (!guild) { + if (!context.client.shard) { + throw new Error(`Guild ${guildId} not available for temp ban expiration`); + } + const results = await context.client.shard.broadcastEval( + async (c, payload) => { + const g = c.guilds.cache.get(payload.guildId); + if (!g) { + return false; + } + const me = await g.members.fetchMe(); + if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('Missing BanMembers permission for temp ban expiration'); + } + try { + await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired'); + } catch (error) { + const code = + typeof error === 'object' && error && 'code' in error + ? Number((error as { code: unknown }).code) + : 0; + // Unknown ban (10026) — already unbanned + if (code === 10026) { + return true; + } + throw error; + } + return true; + }, + { context: { guildId, userId, reason } } + ); + if (!results.some(Boolean)) { + throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`); + } + } else { + const me = await guild.members.fetchMe(); + if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('Missing BanMembers permission for temp ban expiration'); + } + try { + await guild.members.unban(userId, reason ?? 'Temporary ban expired'); + } catch (error) { + const code = + typeof error === 'object' && error && 'code' in error + ? Number((error as { code: unknown }).code) + : 0; + if (code !== 10026) { + throw error; + } + } } - await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired'); + + const { createCase } = await import('./modules/moderation/service.js'); + await createCase(context, { + guildId, + targetUserId: userId, + moderatorId: context.client.user?.id ?? 'system', + action: 'UNBAN', + reason: reason ?? 'Temporary ban expired', + source: 'job', + metadata: { tempBanExpire: true } + }); }, { connection: redis } ); diff --git a/apps/bot/src/metrics.ts b/apps/bot/src/metrics.ts new file mode 100644 index 0000000..f060350 --- /dev/null +++ b/apps/bot/src/metrics.ts @@ -0,0 +1,129 @@ +import type { Redis } from 'ioredis'; +import type { Client } from 'discord.js'; + +const KEY_COMMANDS_TOTAL = 'nexumi:metrics:commands_total'; +const KEY_COMMANDS_ERRORS = 'nexumi:metrics:commands_errors'; +const KEY_COMMAND_DURATION_SUM = 'nexumi:metrics:command_duration_ms_sum'; +const KEY_COMMAND_DURATION_COUNT = 'nexumi:metrics:command_duration_ms_count'; +const KEY_EVENTS_TOTAL = 'nexumi:metrics:events_total'; +const shardKey = (id: number) => `nexumi:metrics:shard:${id}`; + +export async function recordCommandMetric( + redis: Redis, + opts: { ok: boolean; durationMs: number } +): Promise { + const multi = redis.multi(); + multi.incr(KEY_COMMANDS_TOTAL); + if (!opts.ok) { + multi.incr(KEY_COMMANDS_ERRORS); + } + multi.incrbyfloat(KEY_COMMAND_DURATION_SUM, opts.durationMs); + multi.incr(KEY_COMMAND_DURATION_COUNT); + await multi.exec(); +} + +export async function recordEventMetric(redis: Redis): Promise { + await redis.incr(KEY_EVENTS_TOTAL); +} + +export async function publishShardMetrics(redis: Redis, client: Client): Promise { + const ids = client.shard?.ids ?? [0]; + const ping = client.ws.ping; + const guilds = client.guilds.cache.size; + const payload = JSON.stringify({ + ping, + guilds, + updatedAt: Date.now() + }); + await Promise.all(ids.map((id) => redis.set(shardKey(id), payload, 'EX', 120))); +} + +export type MetricsSnapshot = { + up: 1; + commandsTotal: number; + commandsErrors: number; + commandDurationSumMs: number; + commandDurationCount: number; + eventsTotal: number; + shards: Array<{ id: number; ping: number; guilds: number; updatedAt: number }>; + queueWaiting?: number; +}; + +export async function collectMetricsSnapshot(redis: Redis): Promise { + const [commandsTotal, commandsErrors, durationSum, durationCount, eventsTotal, shardKeys] = + await Promise.all([ + redis.get(KEY_COMMANDS_TOTAL), + redis.get(KEY_COMMANDS_ERRORS), + redis.get(KEY_COMMAND_DURATION_SUM), + redis.get(KEY_COMMAND_DURATION_COUNT), + redis.get(KEY_EVENTS_TOTAL), + redis.keys('nexumi:metrics:shard:*') + ]); + + const shards: MetricsSnapshot['shards'] = []; + if (shardKeys.length > 0) { + const values = await redis.mget(...shardKeys); + for (let i = 0; i < shardKeys.length; i++) { + const id = Number(shardKeys[i]!.replace('nexumi:metrics:shard:', '')); + const raw = values[i]; + if (!raw || Number.isNaN(id)) continue; + try { + const parsed = JSON.parse(raw) as { ping: number; guilds: number; updatedAt: number }; + shards.push({ id, ping: parsed.ping, guilds: parsed.guilds, updatedAt: parsed.updatedAt }); + } catch { + // ignore malformed + } + } + shards.sort((a, b) => a.id - b.id); + } + + return { + up: 1, + commandsTotal: Number(commandsTotal ?? 0), + commandsErrors: Number(commandsErrors ?? 0), + commandDurationSumMs: Number(durationSum ?? 0), + commandDurationCount: Number(durationCount ?? 0), + eventsTotal: Number(eventsTotal ?? 0), + shards + }; +} + +export function formatPrometheus(snapshot: MetricsSnapshot): string { + const lines: string[] = [ + '# HELP nexumi_up 1 if the metrics endpoint is healthy', + '# TYPE nexumi_up gauge', + `nexumi_up ${snapshot.up}`, + '# HELP nexumi_commands_total Total slash commands handled', + '# TYPE nexumi_commands_total counter', + `nexumi_commands_total ${snapshot.commandsTotal}`, + '# HELP nexumi_commands_errors_total Total slash command failures', + '# TYPE nexumi_commands_errors_total counter', + `nexumi_commands_errors_total ${snapshot.commandsErrors}`, + '# HELP nexumi_command_duration_ms_sum Sum of command durations in milliseconds', + '# TYPE nexumi_command_duration_ms_sum counter', + `nexumi_command_duration_ms_sum ${snapshot.commandDurationSumMs}`, + '# HELP nexumi_command_duration_ms_count Count of timed commands', + '# TYPE nexumi_command_duration_ms_count counter', + `nexumi_command_duration_ms_count ${snapshot.commandDurationCount}`, + '# HELP nexumi_events_total Approximate event throughput counter', + '# TYPE nexumi_events_total counter', + `nexumi_events_total ${snapshot.eventsTotal}`, + '# HELP nexumi_guilds Guild count per shard', + '# TYPE nexumi_guilds gauge', + '# HELP nexumi_shard_ping_ms Websocket heartbeat latency per shard', + '# TYPE nexumi_shard_ping_ms gauge' + ]; + + for (const shard of snapshot.shards) { + lines.push(`nexumi_guilds{shard="${shard.id}"} ${shard.guilds}`); + lines.push(`nexumi_shard_ping_ms{shard="${shard.id}"} ${shard.ping}`); + } + + if (snapshot.queueWaiting !== undefined) { + lines.push('# HELP nexumi_queue_waiting Approximate waiting jobs across known queues'); + lines.push('# TYPE nexumi_queue_waiting gauge'); + lines.push(`nexumi_queue_waiting ${snapshot.queueWaiting}`); + } + + return `${lines.join('\n')}\n`; +} diff --git a/apps/bot/src/module-gates.test.ts b/apps/bot/src/module-gates.test.ts new file mode 100644 index 0000000..d68729f --- /dev/null +++ b/apps/bot/src/module-gates.test.ts @@ -0,0 +1,33 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { moduleForCommand } from './module-gates.js'; + +describe('moduleForCommand', () => { + it('maps moderation commands', () => { + expect(moduleForCommand('ban')).toBe('moderation'); + expect(moduleForCommand('warn')).toBe('moderation'); + expect(moduleForCommand('case')).toBe('moderation'); + }); + + it('maps redis-only and prisma modules', () => { + expect(moduleForCommand('giveaway')).toBe('giveaways'); + expect(moduleForCommand('tag')).toBe('tags'); + expect(moduleForCommand('backup')).toBe('guildbackup'); + expect(moduleForCommand('economy' as string)).toBeNull(); + expect(moduleForCommand('balance')).toBe('economy'); + }); + + it('leaves core commands unmapped', () => { + expect(moduleForCommand('help')).toBeNull(); + expect(moduleForCommand('info')).toBeNull(); + expect(moduleForCommand('gdpr')).toBeNull(); + }); +}); + +describe('feature flag rollout hashing', () => { + it('is stable for the same guild/key pair', () => { + const a = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100; + const b = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100; + expect(a).toBe(b); + }); +}); diff --git a/apps/bot/src/module-gates.ts b/apps/bot/src/module-gates.ts new file mode 100644 index 0000000..672d65e --- /dev/null +++ b/apps/bot/src/module-gates.ts @@ -0,0 +1,275 @@ +import type { DashboardModuleId } from '@nexumi/shared'; +import type { PrismaClient } from '@prisma/client'; +import type { Redis } from 'ioredis'; +import { createHash } from 'node:crypto'; + +const REDIS_ONLY_MODULES = new Set([ + 'giveaways', + 'tags', + 'selfroles', + 'feeds', + 'scheduler', + 'guildbackup', + 'messages' +]); + +/** Slash command name → dashboard module. Core/GDPR/utility (except gated) omit. */ +const COMMAND_MODULE_MAP: Record = { + ban: 'moderation', + unban: 'moderation', + kick: 'moderation', + timeout: 'moderation', + untimeout: 'moderation', + warn: 'moderation', + purge: 'moderation', + slowmode: 'moderation', + lock: 'moderation', + unlock: 'moderation', + nick: 'moderation', + case: 'moderation', + modnote: 'moderation', + automod: 'automod', + welcome: 'welcome', + verify: 'verification', + rank: 'leveling', + leaderboard: 'leveling', + xp: 'leveling', + balance: 'economy', + daily: 'economy', + weekly: 'economy', + work: 'economy', + pay: 'economy', + gamble: 'economy', + slots: 'economy', + blackjack: 'economy', + coinflip: 'economy', + shop: 'economy', + inventory: 'economy', + eco: 'economy', + '8ball': 'fun', + dice: 'fun', + rps: 'fun', + choose: 'fun', + trivia: 'fun', + tictactoe: 'fun', + connect4: 'fun', + hangman: 'fun', + meme: 'fun', + cat: 'fun', + dog: 'fun', + giveaway: 'giveaways', + ticket: 'tickets', + selfroles: 'selfroles', + tag: 'tags', + starboard: 'starboard', + suggest: 'suggestions', + suggestion: 'suggestions', + birthday: 'birthdays', + voice: 'tempvoice', + invites: 'stats', + stats: 'stats', + feeds: 'feeds', + schedule: 'scheduler', + announce: 'scheduler', + backup: 'guildbackup', + embed: 'messages' +}; + +function redisModulesKey(guildId: string): string { + return `dashboard:modules:${guildId}`; +} + +function funConfigKey(guildId: string): string { + return `fun:config:${guildId}`; +} + +function hashPercent(input: string): number { + const digest = createHash('sha256').update(input).digest(); + return digest.readUInt32BE(0) % 100; +} + +export function moduleForCommand(commandName: string): DashboardModuleId | null { + return COMMAND_MODULE_MAP[commandName] ?? null; +} + +export async function isFeatureFlagEnabled( + prisma: PrismaClient, + key: string, + guildId: string | null +): Promise { + const flag = await prisma.featureFlag.findUnique({ where: { key } }); + if (!flag) { + return true; + } + if (guildId && flag.whitelistGuildIds.includes(guildId)) { + return true; + } + if (!flag.enabled) { + return false; + } + if (flag.rolloutPercent >= 100) { + return true; + } + if (flag.rolloutPercent <= 0 || !guildId) { + return false; + } + return hashPercent(`${flag.key}:${guildId}`) < flag.rolloutPercent; +} + +async function isRedisModuleEnabled( + redis: Redis, + guildId: string, + moduleId: DashboardModuleId +): Promise { + const value = await redis.hget(redisModulesKey(guildId), moduleId); + if (value === null) { + return true; + } + return value === 'true'; +} + +async function isFunEnabled(redis: Redis, guildId: string): Promise { + const raw = await redis.get(funConfigKey(guildId)); + if (!raw) { + return true; + } + try { + const parsed = JSON.parse(raw) as { enabled?: boolean }; + return parsed.enabled ?? true; + } catch { + return true; + } +} + +export async function isModuleEnabled( + prisma: PrismaClient, + redis: Redis, + guildId: string, + moduleId: DashboardModuleId +): Promise { + const flagOk = await isFeatureFlagEnabled(prisma, moduleId, guildId); + if (!flagOk) { + return false; + } + + if (REDIS_ONLY_MODULES.has(moduleId)) { + return isRedisModuleEnabled(redis, guildId, moduleId); + } + + switch (moduleId) { + case 'moderation': { + const settings = await prisma.guildSettings.findUnique({ + where: { guildId }, + select: { moderationEnabled: true } + }); + return settings?.moderationEnabled ?? true; + } + case 'automod': { + const config = await prisma.autoModConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'logging': { + const config = await prisma.loggingConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'welcome': { + const config = await prisma.welcomeConfig.findUnique({ + where: { guildId }, + select: { welcomeEnabled: true, leaveEnabled: true } + }); + if (!config) { + return true; + } + return Boolean(config.welcomeEnabled || config.leaveEnabled); + } + case 'verification': { + const config = await prisma.verificationConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + case 'leveling': { + const config = await prisma.levelingConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'economy': { + const config = await prisma.economyConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'fun': + return isFunEnabled(redis, guildId); + case 'tickets': { + const config = await prisma.ticketConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'starboard': { + const config = await prisma.starboardConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + case 'suggestions': { + const config = await prisma.suggestionConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'birthdays': { + const config = await prisma.birthdayConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? true; + } + case 'tempvoice': { + const config = await prisma.tempVoiceConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + case 'stats': { + const config = await prisma.statsConfig.findUnique({ + where: { guildId }, + select: { enabled: true } + }); + return config?.enabled ?? false; + } + default: + return true; + } +} + +export async function isUserBlacklisted(prisma: PrismaClient, userId: string): Promise { + const row = await prisma.globalUserBlacklist.findUnique({ + where: { userId }, + select: { id: true } + }); + return Boolean(row); +} + +export async function isGuildBlacklisted(prisma: PrismaClient, guildId: string): Promise { + const row = await prisma.guildBlacklist.findUnique({ + where: { guildId }, + select: { id: true } + }); + return Boolean(row); +} diff --git a/apps/bot/src/modules/guildbackup/commands.ts b/apps/bot/src/modules/guildbackup/commands.ts index 433c930..633e8e6 100644 --- a/apps/bot/src/modules/guildbackup/commands.ts +++ b/apps/bot/src/modules/guildbackup/commands.ts @@ -154,7 +154,7 @@ const backupCommand: SlashCommand = { return; } - await promptDeleteConfirmation(interaction, locale, backupId); + await promptDeleteConfirmation(interaction, context, locale, backupId); return; } @@ -195,7 +195,7 @@ const backupCommand: SlashCommand = { return; } - await promptRestoreConfirmation(interaction, locale, backupId); + await promptRestoreConfirmation(interaction, context, locale, backupId); } } }; diff --git a/apps/bot/src/modules/guildbackup/confirmations.ts b/apps/bot/src/modules/guildbackup/confirmations.ts index 1b046e2..c3a4d73 100644 --- a/apps/bot/src/modules/guildbackup/confirmations.ts +++ b/apps/bot/src/modules/guildbackup/confirmations.ts @@ -9,6 +9,12 @@ import { randomUUID } from 'node:crypto'; import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; +import { + deleteConfirmation, + getConfirmation, + setConfirmation, + takeConfirmation +} from '../../confirmation-store.js'; import { deleteGuildBackup, enqueueGuildBackupRestore, @@ -22,7 +28,8 @@ import { const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:'; const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:'; const CANCEL_PREFIX = 'gbackup:cancel:'; -const TTL_MS = 120_000; +const NAMESPACE = 'guildbackup'; +const TTL_SECONDS = 120; type PendingDelete = { action: 'delete'; @@ -30,7 +37,6 @@ type PendingDelete = { userId: string; guildId: string; locale: Locale; - expiresAt: number; }; type PendingRestore = { @@ -40,22 +46,10 @@ type PendingRestore = { userId: string; guildId: string; locale: Locale; - expiresAt: number; }; type PendingEntry = PendingDelete | PendingRestore; -const pending = new Map(); - -function pruneExpired(): void { - const now = Date.now(); - for (const [token, entry] of pending) { - if (entry.expiresAt <= now) { - pending.delete(token); - } - } -} - function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder[] { return [ new ActionRowBuilder().addComponents( @@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action export async function promptDeleteConfirmation( interaction: ChatInputCommandInteraction, + context: BotContext, locale: Locale, backupId: string ): Promise { - pruneExpired(); const token = randomUUID(); - pending.set(token, { - action: 'delete', - backupId, - userId: interaction.user.id, - guildId: interaction.guildId!, - locale, - expiresAt: Date.now() + TTL_MS - }); + await setConfirmation( + context.redis, + NAMESPACE, + token, + { + action: 'delete', + backupId, + userId: interaction.user.id, + guildId: interaction.guildId!, + locale + } satisfies PendingDelete, + TTL_SECONDS + ); await interaction.reply({ content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }), @@ -96,20 +95,25 @@ export async function promptDeleteConfirmation( export async function promptRestoreConfirmation( interaction: ChatInputCommandInteraction, + context: BotContext, locale: Locale, backupId: string ): Promise { - pruneExpired(); const token = randomUUID(); - pending.set(token, { - action: 'restore', - step: 1, - backupId, - userId: interaction.user.id, - guildId: interaction.guildId!, - locale, - expiresAt: Date.now() + TTL_MS - }); + await setConfirmation( + context.redis, + NAMESPACE, + token, + { + action: 'restore', + step: 1, + backupId, + userId: interaction.user.id, + guildId: interaction.guildId!, + locale + } satisfies PendingRestore, + TTL_SECONDS + ); await interaction.reply({ content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }), @@ -122,8 +126,6 @@ export async function handleGuildBackupButton( interaction: ButtonInteraction, context: BotContext ): Promise { - pruneExpired(); - const customId = interaction.customId; const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX); const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX); @@ -141,48 +143,57 @@ export async function handleGuildBackupButton( : CANCEL_PREFIX.length ); - const entry = pending.get(token); - if (!entry) { + const peeked = await getConfirmation(context.redis, NAMESPACE, token); + if (!peeked) { const locale = await getGuildLocale(context.prisma, interaction.guildId); await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true }); return; } - if (interaction.user.id !== entry.userId) { + if (interaction.user.id !== peeked.userId) { await interaction.reply({ - content: t(entry.locale, 'guildbackup.confirm.unauthorized'), + content: t(peeked.locale, 'guildbackup.confirm.unauthorized'), ephemeral: true }); return; } if (isCancel) { - pending.delete(token); + await deleteConfirmation(context.redis, NAMESPACE, token); await interaction.update({ - content: t(entry.locale, 'guildbackup.confirm.cancelled'), + content: t(peeked.locale, 'guildbackup.confirm.cancelled'), components: [] }); return; } - if (entry.action === 'delete') { - pending.delete(token); + if (peeked.action === 'delete') { + const entry = await takeConfirmation(context.redis, NAMESPACE, token); + if (!entry) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true }); + return; + } await executeDelete(interaction, context, entry); return; } - if (entry.step === 1) { - entry.step = 2; - entry.expiresAt = Date.now() + TTL_MS; - pending.set(token, entry); + if (peeked.step === 1) { + const next: PendingRestore = { ...peeked, step: 2 }; + await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS); await interaction.update({ - content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }), - components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX) + content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }), + components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX) }); return; } - pending.delete(token); + const entry = await takeConfirmation(context.redis, NAMESPACE, token); + if (!entry) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true }); + return; + } await executeRestore(interaction, context, entry); } diff --git a/apps/bot/src/modules/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts index f68524a..1210990 100644 --- a/apps/bot/src/modules/moderation/actions.ts +++ b/apps/bot/src/modules/moderation/actions.ts @@ -1,8 +1,9 @@ import { TextChannel, type ButtonInteraction } from 'discord.js'; -import { type Locale, t } from '@nexumi/shared'; +import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; +import { assertBannableForConfirm } from '../../hierarchy.js'; import { createCase, scheduleTempBanExpire } from './service.js'; -import { parseDuration } from './duration.js'; +import { tryParseDuration } from './duration.js'; type BanPayload = { userId: string; @@ -36,8 +37,24 @@ export async function executeBan( return; } + if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) { + return; + } + + let durationMs: number | null = null; + if (payload.duration) { + durationMs = tryParseDuration(payload.duration); + if (durationMs === null || durationMs <= 0) { + await interaction.update({ + content: t(locale, 'moderation.duration.invalid'), + components: [] + }); + return; + } + } + await guild.members.ban(payload.userId, { reason: payload.reason }); - await createCase(context, { + const modCase = await createCase(context, { guildId: guild.id, targetUserId: payload.userId, moderatorId: interaction.user.id, @@ -47,21 +64,17 @@ export async function executeBan( source: 'button_confirmation', metadata: { confirmed: true, - duration: payload.duration + duration: payload.duration, + durationMs } }); - if (payload.duration) { - await scheduleTempBanExpire( - guild.id, - payload.userId, - parseDuration(payload.duration), - payload.reason - ); + if (durationMs !== null) { + await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason); } await interaction.update({ - content: t(locale, 'moderation.success'), + content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }), components: [] }); } @@ -97,7 +110,7 @@ export async function executePurge( deletedCount = deleted.size; } - await createCase(context, { + const modCase = await createCase(context, { guildId: guild.id, targetUserId: interaction.user.id, moderatorId: interaction.user.id, @@ -122,7 +135,10 @@ export async function executePurge( }); await interaction.update({ - content: t(locale, 'moderation.success'), + content: tf(locale, 'moderation.purge.done', { + deletedCount, + caseNumber: modCase.caseNumber + }), components: [] }); } @@ -143,7 +159,7 @@ export async function executeCaseDelete( where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } } }); - if (!existing) { + if (!existing || existing.deletedAt) { await interaction.update({ content: t(locale, 'moderation.case.notFound'), components: [] @@ -156,7 +172,7 @@ export async function executeCaseDelete( data: { deletedAt: new Date() } }); - await createCase(context, { + const modCase = await createCase(context, { guildId, targetUserId: existing.targetUserId, moderatorId: interaction.user.id, @@ -172,7 +188,7 @@ export async function executeCaseDelete( }); await interaction.update({ - content: t(locale, 'moderation.success'), + content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }), components: [] }); } diff --git a/apps/bot/src/modules/moderation/command-definitions.ts b/apps/bot/src/modules/moderation/command-definitions.ts index 5842a32..eea3827 100644 --- a/apps/bot/src/modules/moderation/command-definitions.ts +++ b/apps/bot/src/modules/moderation/command-definitions.ts @@ -1,4 +1,5 @@ import { + PermissionFlagsBits, SlashCommandBuilder, type SlashCommandStringOption, type SlashCommandUserOption @@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) { } export const banCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('ban'), + new SlashCommandBuilder() + .setName('ban') + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), 'moderation.ban.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) @@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription( ); export const unbanCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('unban'), + new SlashCommandBuilder() + .setName('unban') + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), 'moderation.unban.description' ) .addStringOption((o) => @@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription( .addStringOption((o) => reasonOpt(o)); export const kickCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('kick'), + new SlashCommandBuilder() + .setName('kick') + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), 'moderation.kick.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) .addStringOption((o) => reasonOpt(o)); export const timeoutCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('timeout'), + new SlashCommandBuilder() + .setName('timeout') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.timeout.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) @@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription( .addStringOption((o) => reasonOpt(o)); export const untimeoutCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('untimeout'), + new SlashCommandBuilder() + .setName('untimeout') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.untimeout.description' ) .addUserOption((o) => userOpt(o, 'moderation.ban.options.user')) .addStringOption((o) => reasonOpt(o)); export const warnCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('warn'), + new SlashCommandBuilder() + .setName('warn') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.warn.description' ) .addSubcommand((s) => @@ -123,7 +136,9 @@ export const warnCommandData = applyCommandDescription( ); export const purgeCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('purge'), + new SlashCommandBuilder() + .setName('purge') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), 'moderation.purge.description' ) .addIntegerOption((o) => @@ -138,24 +153,32 @@ export const purgeCommandData = applyCommandDescription( .addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex')); export const slowmodeCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('slowmode'), + new SlashCommandBuilder() + .setName('slowmode') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), 'moderation.slowmode.description' ).addIntegerOption((o) => applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds') ); export const lockCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('lock'), + new SlashCommandBuilder() + .setName('lock') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), 'moderation.lock.description' ); export const unlockCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('unlock'), + new SlashCommandBuilder() + .setName('unlock') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), 'moderation.unlock.description' ); export const nickCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('nick'), + new SlashCommandBuilder() + .setName('nick') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames), 'moderation.nick.description' ) .addSubcommand((s) => @@ -172,7 +195,9 @@ export const nickCommandData = applyCommandDescription( ); export const caseCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('case'), + new SlashCommandBuilder() + .setName('case') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.case.description' ) .addSubcommand((s) => @@ -196,7 +221,9 @@ export const caseCommandData = applyCommandDescription( ); export const modNoteCommandData = applyCommandDescription( - new SlashCommandBuilder().setName('modnote'), + new SlashCommandBuilder() + .setName('modnote') + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), 'moderation.modnote.description' ) .addSubcommand((s) => diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 3ec072d..0db6929 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -2,14 +2,14 @@ import { ChannelType, PermissionFlagsBits, TextChannel, - type ChatInputCommandInteraction, - type GuildMember + type ChatInputCommandInteraction } from 'discord.js'; import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { requirePermission } from '../../permissions.js'; +import { assertModerableMember } from '../../hierarchy.js'; import { applyWarnEscalation, createCase } from './service.js'; -import { parseDuration } from './duration.js'; +import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js'; import { getGuildLocale } from '../../i18n.js'; import { promptDestructiveConfirmation } from './confirmations.js'; import { @@ -51,6 +51,17 @@ async function ensureMod( return requirePermission(interaction, permission, locale); } +async function replySuccessCase( + interaction: ChatInputCommandInteraction, + locale: 'de' | 'en', + caseNumber: number +): Promise { + await interaction.reply({ + content: tf(locale, 'moderation.success.case', { caseNumber }), + ephemeral: true + }); +} + const banCommand: SlashCommand = { data: banCommandData, async execute(interaction, context) { @@ -59,7 +70,22 @@ const banCommand: SlashCommand = { const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); const duration = interaction.options.getString('duration'); - await promptDestructiveConfirmation(interaction, locale, { + + if (duration) { + const durationMs = tryParseDuration(duration); + if (durationMs === null || durationMs <= 0) { + await interaction.reply({ + content: t(locale, 'moderation.duration.invalid'), + ephemeral: true + }); + return; + } + } + + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'ban'); + if (!hierarchy.ok) return; + + await promptDestructiveConfirmation(interaction, context, locale, { action: 'ban', payload: { userId: user.id, @@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = { const userId = interaction.options.getString('user_id', true); const reason = reasonFrom(interaction, locale); await interaction.guild!.members.unban(userId, reason); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: userId, moderatorId: interaction.user.id, @@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = { action: 'UNBAN', reason }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -97,9 +123,10 @@ const kickCommand: SlashCommand = { if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); - const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember; - await member.kick(reason); - await createCase(context, { + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick'); + if (!hierarchy.ok || !hierarchy.member) return; + await hierarchy.member.kick(reason); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -107,7 +134,7 @@ const kickCommand: SlashCommand = { action: 'KICK', reason }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -117,11 +144,21 @@ const timeoutCommand: SlashCommand = { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const user = interaction.options.getUser('user', true); - const duration = parseDuration(interaction.options.getString('duration', true)); + let duration: number; + try { + duration = parseTimeoutDuration(interaction.options.getString('duration', true)); + } catch { + await interaction.reply({ + content: t(locale, 'moderation.duration.invalidTimeout'), + ephemeral: true + }); + return; + } const reason = reasonFrom(interaction, locale); - const member = await interaction.guild!.members.fetch(user.id); - await member.timeout(duration, reason); - await createCase(context, { + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout'); + if (!hierarchy.ok || !hierarchy.member) return; + await hierarchy.member.timeout(duration, reason); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = { reason, metadata: { durationMs: duration } }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -141,9 +178,10 @@ const untimeoutCommand: SlashCommand = { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); - const member = await interaction.guild!.members.fetch(user.id); - await member.timeout(null, reason); - await createCase(context, { + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout'); + if (!hierarchy.ok || !hierarchy.member) return; + await hierarchy.member.timeout(null, reason); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = { action: 'UN_TIMEOUT', reason }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -166,7 +204,19 @@ const warnCommand: SlashCommand = { const action = interaction.options.getString('action', true); const durationInput = interaction.options.getString('duration'); const reason = interaction.options.getString('reason'); - const durationMs = durationInput ? parseDuration(durationInput) : null; + let durationMs: number | null = null; + if (durationInput) { + try { + durationMs = + action === 'TIMEOUT' ? parseTimeoutDuration(durationInput) : parseDuration(durationInput); + } catch { + await interaction.reply({ + content: t(locale, 'moderation.duration.invalid'), + ephemeral: true + }); + return; + } + } if (action === 'TIMEOUT' && !durationMs) { await interaction.reply({ content: t(locale, 'moderation.escalation.durationRequired'), @@ -267,7 +317,8 @@ const warnCommand: SlashCommand = { interaction, context, user.id, - interaction.user.id + interaction.user.id, + locale ); await interaction.reply({ content: escalationResult @@ -300,8 +351,18 @@ const warnCommand: SlashCommand = { if (sub === 'remove') { const warningId = interaction.options.getString('warning_id', true); - const warning = await context.prisma.warning.delete({ where: { id: warningId } }); - await createCase(context, { + const warning = await context.prisma.warning.findFirst({ + where: { id: warningId, guildId: interaction.guildId! } + }); + if (!warning) { + await interaction.reply({ + content: t(locale, 'moderation.warning.notFound'), + ephemeral: true + }); + return; + } + await context.prisma.warning.delete({ where: { id: warning.id } }); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: warning.userId, moderatorId: interaction.user.id, @@ -309,13 +370,15 @@ const warnCommand: SlashCommand = { action: 'WARN_REMOVE', reason: `Warning ${warningId} removed` }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); return; } const user = interaction.options.getUser('user', true); - await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } }); - await createCase(context, { + await context.prisma.warning.deleteMany({ + where: { guildId: interaction.guildId!, userId: user.id } + }); + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -323,7 +386,7 @@ const warnCommand: SlashCommand = { action: 'WARN_CLEAR', reason: 'Warnings cleared' }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -352,7 +415,7 @@ const purgeCommand: SlashCommand = { return; } - await promptDestructiveConfirmation(interaction, locale, { + await promptDestructiveConfirmation(interaction, context, locale, { action: 'purge', payload: { channelId: interaction.channel.id, @@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = { if (interaction.channel instanceof TextChannel) { await interaction.channel.setRateLimitPerUser(seconds); } - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, @@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = { action: 'SLOWMODE', reason: `Set slowmode to ${seconds}s` }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -398,7 +461,7 @@ const lockCommand: SlashCommand = { SendMessages: false }); } - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, @@ -406,7 +469,7 @@ const lockCommand: SlashCommand = { action: 'LOCK', reason: 'Channel locked' }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -420,7 +483,7 @@ const unlockCommand: SlashCommand = { SendMessages: null }); } - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, @@ -428,7 +491,7 @@ const unlockCommand: SlashCommand = { action: 'UNLOCK', reason: 'Channel unlocked' }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } }; @@ -439,11 +502,13 @@ const nickCommand: SlashCommand = { if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return; const sub = interaction.options.getSubcommand(); const user = interaction.options.getUser('user', true); - const member = await interaction.guild!.members.fetch(user.id); + const hierarchy = await assertModerableMember(interaction, user.id, locale, 'nick'); + if (!hierarchy.ok || !hierarchy.member) return; + const member = hierarchy.member; if (sub === 'set') { const nickname = interaction.options.getString('nickname', true); await member.setNickname(nickname); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -451,9 +516,10 @@ const nickCommand: SlashCommand = { action: 'NICK_SET', reason: nickname }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } else { await member.setNickname(null); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -461,8 +527,8 @@ const nickCommand: SlashCommand = { action: 'NICK_RESET', reason: 'Nickname reset' }); + await replySuccessCase(interaction, locale, modCase.caseNumber); } - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); } }; @@ -474,8 +540,12 @@ const caseCommand: SlashCommand = { const sub = interaction.options.getSubcommand(); const caseNumber = interaction.options.getInteger('id', true); if (sub === 'view') { - const found = await context.prisma.case.findUnique({ - where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } } + const found = await context.prisma.case.findFirst({ + where: { + guildId: interaction.guildId!, + caseNumber, + deletedAt: null + } }); await interaction.reply({ content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'), @@ -485,14 +555,31 @@ const caseCommand: SlashCommand = { } if (sub === 'edit') { const reason = interaction.options.getString('reason', true); + const existing = await context.prisma.case.findFirst({ + where: { + guildId: interaction.guildId!, + caseNumber, + deletedAt: null + } + }); + if (!existing) { + await interaction.reply({ + content: t(locale, 'moderation.case.notFound'), + ephemeral: true + }); + return; + } await context.prisma.case.update({ - where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, + where: { id: existing.id }, data: { reason } }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await interaction.reply({ + content: tf(locale, 'moderation.success.case', { caseNumber }), + ephemeral: true + }); return; } - await promptDestructiveConfirmation(interaction, locale, { + await promptDestructiveConfirmation(interaction, context, locale, { action: 'case_delete', payload: { caseNumber } }); @@ -517,7 +604,7 @@ const modNoteCommand: SlashCommand = { note } }); - await createCase(context, { + const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, @@ -525,7 +612,7 @@ const modNoteCommand: SlashCommand = { action: 'MODNOTE_ADD', reason: note }); - await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); + await replySuccessCase(interaction, locale, modCase.caseNumber); return; } const notes = await context.prisma.modNote.findMany({ diff --git a/apps/bot/src/modules/moderation/confirmations.ts b/apps/bot/src/modules/moderation/confirmations.ts index eab1f0f..cdf6537 100644 --- a/apps/bot/src/modules/moderation/confirmations.ts +++ b/apps/bot/src/modules/moderation/confirmations.ts @@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto'; import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; +import { + deleteConfirmation, + getConfirmation, + setConfirmation, + takeConfirmation +} from '../../confirmation-store.js'; import { executeBan, executeCaseDelete, executePurge } from './actions.js'; const CONFIRM_PREFIX = 'mod:confirm:'; const CANCEL_PREFIX = 'mod:cancel:'; -const TTL_MS = 60_000; +const NAMESPACE = 'moderation'; +const TTL_SECONDS = 60; type BanPayload = { userId: string; @@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & { moderatorId: string; guildId: string; locale: Locale; - expiresAt: number; }; -const pending = new Map(); - -function pruneExpired(): void { - const now = Date.now(); - for (const [token, entry] of pending) { - if (entry.expiresAt <= now) { - pending.delete(token); - } - } -} - function confirmationMessage(locale: Locale, entry: PendingConfirmation): string { if (entry.action === 'ban') { return tf(locale, 'moderation.confirm.ban', { @@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder { - pruneExpired(); const token = randomUUID(); - pending.set(token, { - ...entry, - moderatorId: interaction.user.id, - guildId: interaction.guildId!, - locale, - expiresAt: Date.now() + TTL_MS - }); + await setConfirmation( + context.redis, + NAMESPACE, + token, + { + ...entry, + moderatorId: interaction.user.id, + guildId: interaction.guildId!, + locale + } satisfies PendingEntry, + TTL_SECONDS + ); await interaction.reply({ content: confirmationMessage(locale, entry), @@ -113,8 +113,6 @@ export async function handleModerationConfirmation( interaction: ButtonInteraction, context: BotContext ): Promise { - pruneExpired(); - const customId = interaction.customId; const isConfirm = customId.startsWith(CONFIRM_PREFIX); const isCancel = customId.startsWith(CANCEL_PREFIX); @@ -123,31 +121,37 @@ export async function handleModerationConfirmation( } const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length); - const entry = pending.get(token); - if (!entry) { + const peeked = await getConfirmation(context.redis, NAMESPACE, token); + if (!peeked) { const locale = await getGuildLocale(context.prisma, interaction.guildId); await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true }); return; } - if (interaction.user.id !== entry.moderatorId) { + if (interaction.user.id !== peeked.moderatorId) { await interaction.reply({ - content: t(entry.locale, 'moderation.confirm.unauthorized'), + content: t(peeked.locale, 'moderation.confirm.unauthorized'), ephemeral: true }); return; } - pending.delete(token); - if (isCancel) { + await deleteConfirmation(context.redis, NAMESPACE, token); await interaction.update({ - content: t(entry.locale, 'moderation.confirm.cancelled'), + content: t(peeked.locale, 'moderation.confirm.cancelled'), components: [] }); return; } + const entry = await takeConfirmation(context.redis, NAMESPACE, token); + if (!entry) { + const locale = await getGuildLocale(context.prisma, interaction.guildId); + await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true }); + return; + } + if (entry.action === 'ban') { await executeBan(interaction, context, entry.locale, entry.payload); return; diff --git a/apps/bot/src/modules/moderation/duration.test.ts b/apps/bot/src/modules/moderation/duration.test.ts index b275305..9abf571 100644 --- a/apps/bot/src/modules/moderation/duration.test.ts +++ b/apps/bot/src/modules/moderation/duration.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { parseDuration } from './duration.js'; +import { + MAX_TIMEOUT_MS, + parseDuration, + parseTimeoutDuration, + tryParseDuration +} from './duration.js'; describe('parseDuration', () => { it('parses minutes', () => { @@ -14,3 +19,27 @@ describe('parseDuration', () => { expect(() => parseDuration('tomorrow')).toThrow(); }); }); + +describe('parseTimeoutDuration', () => { + it('accepts durations within Discord limit', () => { + expect(parseTimeoutDuration('1d')).toBe(86_400_000); + }); + + it('rejects durations over 28 days', () => { + expect(() => parseTimeoutDuration('30d')).toThrow(/28/); + }); + + it('exposes MAX_TIMEOUT_MS as 28 days', () => { + expect(MAX_TIMEOUT_MS).toBe(28 * 24 * 60 * 60 * 1000); + }); +}); + +describe('tryParseDuration', () => { + it('returns null for invalid input', () => { + expect(tryParseDuration('nope')).toBeNull(); + }); + + it('returns ms for valid input', () => { + expect(tryParseDuration('10s')).toBe(10_000); + }); +}); diff --git a/apps/bot/src/modules/moderation/duration.ts b/apps/bot/src/modules/moderation/duration.ts index abb55cd..d8efbb7 100644 --- a/apps/bot/src/modules/moderation/duration.ts +++ b/apps/bot/src/modules/moderation/duration.ts @@ -1,10 +1,39 @@ +export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000; + +export class DurationParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'DurationParseError'; + } +} + export function parseDuration(input: string): number { const m = input.match(/^(\d+)([smhd])$/i); if (!m) { - throw new Error('Duration must be like 30m, 12h, 7d'); + throw new DurationParseError('Duration must be like 30m, 12h, 7d'); } const amount = Number(m[1]); - const unit = m[2].toLowerCase(); + const unit = m[2]!.toLowerCase(); const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000; return amount * factor; } + +/** Parse a duration and enforce Discord's 28-day timeout maximum. */ +export function parseTimeoutDuration(input: string): number { + const ms = parseDuration(input); + if (ms <= 0) { + throw new DurationParseError('Duration must be positive'); + } + if (ms > MAX_TIMEOUT_MS) { + throw new DurationParseError('Timeout duration cannot exceed 28 days'); + } + return ms; +} + +export function tryParseDuration(input: string): number | null { + try { + return parseDuration(input); + } catch { + return null; + } +} diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index c257764..d7c28ac 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import type { Prisma } from '@prisma/client'; -import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared'; +import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared'; +import { MAX_TIMEOUT_MS } from './duration.js'; type CreateCaseInput = { guildId: string; @@ -22,39 +23,69 @@ export async function createCase(context: BotContext, input: CreateCaseInput) { create: { id: input.guildId } }); - const lastCase = await context.prisma.case.findFirst({ - where: { guildId: input.guildId }, - orderBy: { caseNumber: 'desc' } - }); - const metadata = buildCaseMetadata({ source: input.source ?? 'slash_command', channelId: input.channelId, extras: input.metadata }); - return context.prisma.case.create({ - data: { - guildId: input.guildId, - caseNumber: (lastCase?.caseNumber ?? 0) + 1, - targetUserId: input.targetUserId, - moderatorId: input.moderatorId, - action: input.action, - reason: input.reason, - metadata: metadata as Prisma.InputJsonValue + let created = null; + for (let attempt = 0; attempt < 5; attempt++) { + try { + created = await context.prisma.$transaction( + async (tx) => { + const lastCase = await tx.case.findFirst({ + where: { guildId: input.guildId }, + orderBy: { caseNumber: 'desc' }, + select: { caseNumber: true } + }); + return tx.case.create({ + data: { + guildId: input.guildId, + caseNumber: (lastCase?.caseNumber ?? 0) + 1, + targetUserId: input.targetUserId, + moderatorId: input.moderatorId, + action: input.action, + reason: input.reason, + metadata: metadata as Prisma.InputJsonValue + } + }); + }, + { isolationLevel: 'Serializable' } + ); + break; + } catch (error) { + const code = + typeof error === 'object' && error && 'code' in error + ? String((error as { code: unknown }).code) + : ''; + if (code !== 'P2002' && attempt === 4) { + throw error; + } + if (attempt === 4) { + throw error; + } } - }).then(async (created) => { - const { logModAction } = await import('../logging/events.js'); - await logModAction(context, { - guildId: input.guildId, - caseNumber: created.caseNumber, - action: input.action, - targetUserId: input.targetUserId, - moderatorId: input.moderatorId, - reason: input.reason - }); - return created; + } + + if (!created) { + throw new Error('Failed to create case'); + } + + const { logModAction } = await import('../logging/events.js'); + await logModAction(context, { + guildId: input.guildId, + caseNumber: created.caseNumber, + action: input.action, + targetUserId: input.targetUserId, + moderatorId: input.moderatorId, + reason: input.reason }); + return created; +} + +export function tempBanJobId(guildId: string, userId: string): string { + return `tempBan:${guildId}:${userId}`; } export async function scheduleTempBanExpire( @@ -63,10 +94,17 @@ export async function scheduleTempBanExpire( durationMs: number, reason?: string ) { + const jobId = tempBanJobId(guildId, userId); + const existing = await moderationQueue.getJob(jobId); + if (existing) { + await existing.remove(); + } + await moderationQueue.add( 'tempBanExpire', { guildId, userId, reason: reason ?? null }, { + jobId, delay: durationMs, removeOnComplete: 1000, removeOnFail: 500 @@ -78,7 +116,8 @@ export async function applyWarnEscalation( interaction: ChatInputCommandInteraction, context: BotContext, userId: string, - moderatorId: string + moderatorId: string, + locale: Locale ): Promise { const guildId = interaction.guildId; if (!guildId || !interaction.guild) { @@ -98,16 +137,25 @@ export async function applyWarnEscalation( } const member = await interaction.guild.members.fetch(userId); - const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`; + const reason = + rule.reason ?? + t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount)); if (rule.action === 'TIMEOUT') { if (!rule.durationMs) { - return `Escalation rule for ${warningCount} warns skipped (missing duration).`; + return t(locale, 'moderation.escalation.skippedDuration').replace( + '{warnCount}', + String(warningCount) + ); } if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) { - return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + return t(locale, 'moderation.escalation.skippedPermission').replace( + '{warnCount}', + String(warningCount) + ); } - await member.timeout(rule.durationMs, reason); + const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS); + await member.timeout(durationMs, reason); await createCase(context, { guildId, targetUserId: userId, @@ -116,14 +164,20 @@ export async function applyWarnEscalation( reason, channelId: interaction.channelId ?? undefined, source: 'escalation', - metadata: { escalation: true, durationMs: rule.durationMs, warningCount } + metadata: { escalation: true, durationMs, warningCount } }); - return `Auto escalation applied: timeout (${rule.durationMs} ms).`; + return t(locale, 'moderation.escalation.appliedTimeout').replace( + '{durationMs}', + String(durationMs) + ); } if (rule.action === 'KICK') { if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) { - return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + return t(locale, 'moderation.escalation.skippedPermission').replace( + '{warnCount}', + String(warningCount) + ); } await member.kick(reason); await createCase(context, { @@ -136,11 +190,14 @@ export async function applyWarnEscalation( source: 'escalation', metadata: { escalation: true, warningCount } }); - return 'Auto escalation applied: kick.'; + return t(locale, 'moderation.escalation.appliedKick'); } if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) { - return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`; + return t(locale, 'moderation.escalation.skippedPermission').replace( + '{warnCount}', + String(warningCount) + ); } await interaction.guild.members.ban(userId, { reason }); await createCase(context, { @@ -153,5 +210,5 @@ export async function applyWarnEscalation( source: 'escalation', metadata: { escalation: true, warningCount } }); - return 'Auto escalation applied: ban.'; + return t(locale, 'moderation.escalation.appliedBan'); } diff --git a/apps/bot/src/sentry.ts b/apps/bot/src/sentry.ts new file mode 100644 index 0000000..41f77b8 --- /dev/null +++ b/apps/bot/src/sentry.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import { env } from './env.js'; +import { logger } from './logger.js'; + +let initialized = false; + +export function initSentry(service: 'bot-manager' | 'bot-shard' | 'webui'): void { + if (initialized || !env.SENTRY_DSN) { + return; + } + Sentry.init({ + dsn: env.SENTRY_DSN, + environment: env.NODE_ENV, + release: process.env.npm_package_version + ? `nexumi@${process.env.npm_package_version}` + : 'nexumi@0.1.0', + tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0, + initialScope: { + tags: { service } + } + }); + initialized = true; + logger.info({ service }, 'Sentry initialized'); +} + +export function captureException(error: unknown, context?: Record): void { + if (!env.SENTRY_DSN) { + return; + } + Sentry.withScope((scope) => { + if (context) { + scope.setExtras(context); + } + Sentry.captureException(error); + }); +} diff --git a/apps/bot/src/shard-lifecycle.ts b/apps/bot/src/shard-lifecycle.ts new file mode 100644 index 0000000..eedebb3 --- /dev/null +++ b/apps/bot/src/shard-lifecycle.ts @@ -0,0 +1,37 @@ +import type { Client } from 'discord.js'; +import { redis } from './redis.js'; +import { logger } from './logger.js'; + +/** + * Returns true when this process owns shard 0 (or has no shard manager = single process). + */ +export function isPrimaryShard(client: Client): boolean { + const ids = client.shard?.ids; + if (!ids || ids.length === 0) { + return true; + } + return ids.includes(0); +} + +/** + * Acquires a short-lived Redis lock. Returns true if this caller owns the lock. + */ +export async function acquireOnceLock(lockKey: string, ttlSeconds: number): Promise { + const result = await redis.set(lockKey, String(Date.now()), 'EX', ttlSeconds, 'NX'); + return result === 'OK'; +} + +export async function runOncePerCluster( + lockKey: string, + ttlSeconds: number, + fn: () => Promise, + label: string +): Promise { + const acquired = await acquireOnceLock(lockKey, ttlSeconds); + if (!acquired) { + logger.info({ lockKey }, `${label} skipped (another process holds the lock)`); + return false; + } + await fn(); + return true; +} diff --git a/apps/webui/package.json b/apps/webui/package.json index 910df60..1d07fd6 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-separator": "^1.1.12", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-switch": "^1.3.4", + "@sentry/node": "^10.67.0", "bullmq": "^5.34.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/webui/src/instrumentation.ts b/apps/webui/src/instrumentation.ts new file mode 100644 index 0000000..3104663 --- /dev/null +++ b/apps/webui/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register(): Promise { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { initWebuiSentry } = await import('./lib/sentry'); + initWebuiSentry(); + } +} diff --git a/apps/webui/src/lib/sentry.ts b/apps/webui/src/lib/sentry.ts new file mode 100644 index 0000000..efaa77e --- /dev/null +++ b/apps/webui/src/lib/sentry.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node'; +import { env } from './env'; + +let initialized = false; + +export function initWebuiSentry(): void { + if (initialized || !env.SENTRY_DSN) { + return; + } + Sentry.init({ + dsn: env.SENTRY_DSN, + environment: env.NODE_ENV, + release: 'nexumi-webui@0.1.0', + tracesSampleRate: env.NODE_ENV === 'production' ? 0.1 : 1.0, + initialScope: { + tags: { service: 'webui' } + } + }); + initialized = true; +} + +export function captureWebuiException(error: unknown, context?: Record): void { + if (!env.SENTRY_DSN) { + return; + } + Sentry.withScope((scope) => { + if (context) { + scope.setExtras(context); + } + Sentry.captureException(error); + }); +} diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 347860f..c87875d 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -195,6 +195,31 @@ - Modal-only Components, File-Component, Premium-Buttons (nicht im MVP) - Pixelgenaue Discord-Vorschau im Dashboard +## Post-Phase – Harden (Sharding / Gates / Observability) (Status: implementiert) + +### Abgeschlossen (Code) + +- Confirmations (Moderation + Guildbackup) in Redis mit TTL statt Prozess-`Map` +- Command-Registrierung einmal im Manager (Redis-Lock); Recurring Jobs einmal (Primary-Shard + Lock) +- Modul-Toggles + FeatureFlags + User-/Guild-Blacklists im Bot erzwungen (`routeCommand`, Events, GuildCreate leave) +- Moderation: Hierarchy-Checks, Duration vor Ban-Confirm, Timeout-Cap 28d, warn remove guild-scoped, atomare Case-Nummern, Temp-Ban Job-Dedup + UNBAN-Case, `setDefaultMemberPermissions` +- Sentry (`@sentry/node`) in Bot + WebUI (`instrumentation.ts`); echte Prometheus-Metrics (`/metrics`) +- Interaction-Registry statt if-Kette; Unit-Tests für Duration, Confirm-Store, Module-Gates, Metrics + +### Manuell testen + +- [ ] Ban mit Dauer: Confirm auf anderem Shard / nach Restart innerhalb TTL +- [ ] Dashboard: Modul Moderation aus → `/ban` antwortet „Modul deaktiviert“ +- [ ] Owner: User-Blacklist → Interactions blockiert; Guild-Blacklist → Bot verlässt Server +- [ ] `/metrics` mit Bearer-Token: Counters + Shard-Ping +- [ ] Temp-Ban ablaufen → Unban + Case `UNBAN` + +### Bewusst offen + +- Musik- und KI-Modul (nur auf Zuruf) +- Premium-Zahlungsanbindung +- P4-UX (Server-Lock, Ban deleteMessageSeconds) bewusst nachgelagert + ## Nächster geplanter Schritt - Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe). diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bd88eba..11f72c6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -59,6 +59,9 @@ const de: Dictionary = { 'generic.commandChannelBlocked': 'Dieser Befehl ist in diesem Kanal nicht erlaubt.', 'generic.commandRoleBlocked': 'Du darfst diesen Befehl mit deinen Rollen nicht nutzen.', 'generic.commandCooldown': 'Bitte warte noch {seconds} Sekunden, bevor du diesen Befehl erneut nutzt.', + 'generic.moduleDisabled': 'Dieses Modul ist auf diesem Server deaktiviert.', + 'generic.userBlacklisted': 'Du darfst Nexumi derzeit nicht nutzen.', + 'generic.guildBlacklisted': 'Dieser Server darf Nexumi derzeit nicht nutzen.', 'core.help.title': 'Nexumi Hilfe', 'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:`.', 'core.help.moduleLine': '**{module}**: {commands}', @@ -111,10 +114,23 @@ const de: Dictionary = { 'gdpr.delete.doneBody': 'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.', 'moderation.success': 'Aktion erfolgreich ausgeführt.', + 'moderation.success.case': 'Aktion erfolgreich ausgeführt (Fall #{caseNumber}).', + 'moderation.duration.invalid': 'Ungültige Dauer. Nutze z. B. 30m, 12h oder 7d.', + 'moderation.duration.invalidTimeout': + 'Ungültige Timeout-Dauer. Nutze z. B. 30m oder 1d (max. 28 Tage).', + 'moderation.hierarchy.self': 'Du kannst diese Aktion nicht auf dich selbst anwenden.', + 'moderation.hierarchy.owner': 'Der Server-Owner kann nicht moderiert werden.', + 'moderation.hierarchy.moderator': + 'Du kannst keine Mitglieder moderieren, die gleich oder höher in der Rollenhierarchie stehen.', + 'moderation.hierarchy.bot': + 'Der Bot kann dieses Mitglied wegen der Rollenhierarchie oder fehlender Rechte nicht moderieren.', + 'moderation.hierarchy.notMember': 'Dieses Mitglied ist nicht auf dem Server.', 'moderation.reason.none': 'Kein Grund angegeben', 'moderation.warning.created': 'Verwarnung erstellt: {warningId}', 'moderation.warning.none': 'Keine Verwarnungen vorhanden.', 'moderation.warning.defaultReason': 'Kein Grund', + 'moderation.warning.notFound': 'Verwarnung nicht gefunden.', + 'moderation.purge.done': '{deletedCount} Nachrichten gelöscht (Fall #{caseNumber}).', 'moderation.note.none': 'Keine Notizen vorhanden.', 'moderation.case.notFound': 'Fall nicht gefunden.', 'moderation.escalation.durationRequired': @@ -124,6 +140,14 @@ const de: Dictionary = { 'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.', 'moderation.escalation.removed': 'Eskalationsregel für {warnCount} Verwarnungen entfernt.', + 'moderation.escalation.defaultReason': 'Auto-Eskalation bei {warnCount} Verwarnungen', + 'moderation.escalation.skippedDuration': + 'Eskalationsregel für {warnCount} Verwarnungen übersprungen (fehlende Dauer).', + 'moderation.escalation.skippedPermission': + 'Eskalationsregel für {warnCount} Verwarnungen übersprungen (fehlende Bot-Rechte).', + 'moderation.escalation.appliedTimeout': 'Auto-Eskalation angewendet: Timeout ({durationMs} ms).', + 'moderation.escalation.appliedKick': 'Auto-Eskalation angewendet: Kick.', + 'moderation.escalation.appliedBan': 'Auto-Eskalation angewendet: Ban.', 'moderation.confirm.button.confirm': 'Bestätigen', 'moderation.confirm.button.cancel': 'Abbrechen', 'moderation.confirm.permanent': 'dauerhaft', @@ -612,6 +636,9 @@ const en: Dictionary = { 'generic.commandChannelBlocked': 'This command is not allowed in this channel.', 'generic.commandRoleBlocked': 'You are not allowed to use this command with your roles.', 'generic.commandCooldown': 'Please wait {seconds} more seconds before using this command again.', + 'generic.moduleDisabled': 'This module is disabled on this server.', + 'generic.userBlacklisted': 'You are not allowed to use Nexumi right now.', + 'generic.guildBlacklisted': 'This server is not allowed to use Nexumi right now.', 'core.help.title': 'Nexumi Help', 'core.help.subtitle': 'Commands by module. Optional: `/help module:`.', 'core.help.moduleLine': '**{module}**: {commands}', @@ -664,10 +691,23 @@ const en: Dictionary = { 'gdpr.delete.doneBody': 'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.', 'moderation.success': 'Action executed successfully.', + 'moderation.success.case': 'Action executed successfully (case #{caseNumber}).', + 'moderation.duration.invalid': 'Invalid duration. Use e.g. 30m, 12h, or 7d.', + 'moderation.duration.invalidTimeout': + 'Invalid timeout duration. Use e.g. 30m or 1d (max 28 days).', + 'moderation.hierarchy.self': 'You cannot apply this action to yourself.', + 'moderation.hierarchy.owner': 'The server owner cannot be moderated.', + 'moderation.hierarchy.moderator': + 'You cannot moderate members with equal or higher roles.', + 'moderation.hierarchy.bot': + 'The bot cannot moderate this member due to role hierarchy or missing permissions.', + 'moderation.hierarchy.notMember': 'That member is not on this server.', 'moderation.reason.none': 'No reason provided', 'moderation.warning.created': 'Warning created: {warningId}', 'moderation.warning.none': 'No warnings.', 'moderation.warning.defaultReason': 'No reason', + 'moderation.warning.notFound': 'Warning not found.', + 'moderation.purge.done': 'Deleted {deletedCount} messages (case #{caseNumber}).', 'moderation.note.none': 'No notes.', 'moderation.case.notFound': 'Case not found.', 'moderation.escalation.durationRequired': 'Duration is required for TIMEOUT escalation.', @@ -675,6 +715,14 @@ const en: Dictionary = { 'moderation.escalation.saved': 'Escalation rule saved: {warnCount} warnings => {action}{duration}.', 'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.', + 'moderation.escalation.defaultReason': 'Auto escalation at {warnCount} warnings', + 'moderation.escalation.skippedDuration': + 'Escalation rule for {warnCount} warns skipped (missing duration).', + 'moderation.escalation.skippedPermission': + 'Escalation rule for {warnCount} warns skipped (missing bot permissions).', + 'moderation.escalation.appliedTimeout': 'Auto escalation applied: timeout ({durationMs} ms).', + 'moderation.escalation.appliedKick': 'Auto escalation applied: kick.', + 'moderation.escalation.appliedBan': 'Auto escalation applied: ban.', 'moderation.confirm.button.confirm': 'Confirm', 'moderation.confirm.button.cancel': 'Cancel', 'moderation.confirm.permanent': 'permanent',