From e24f99ad383b50ab91abd0352e83341ebbd0e1e2 Mon Sep 17 00:00:00 2001 From: smueller Date: Thu, 23 Jul 2026 10:54:21 +0200 Subject: [PATCH] Enhance verification module with improved command handling and localization - Updated the verification command to utilize a new `ephemeral` utility for consistent ephemeral message replies. - Refactored the `buildVerificationPanel` function to include localization support for panel titles and descriptions. - Added permission checks to ensure the bot can post in the designated verification channel. - Introduced a new verification queue in the queue management system for better handling of verification tasks. - Updated localization files to include new keys for verification-related messages in both English and German. --- .env.example | 4 +- apps/bot/src/interaction-reply.ts | 6 + apps/bot/src/modules/verification/commands.ts | 51 ++++++-- apps/bot/src/modules/verification/handlers.ts | 25 ++-- apps/webui/src/app/verify/captcha/route.ts | 110 ++++++++++++++++++ apps/webui/src/lib/captcha.ts | 32 +++++ apps/webui/src/lib/queues.ts | 9 ++ packages/shared/src/index.ts | 14 +++ 8 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 apps/bot/src/interaction-reply.ts create mode 100644 apps/webui/src/app/verify/captcha/route.ts create mode 100644 apps/webui/src/lib/captcha.ts diff --git a/.env.example b/.env.example index 846f90c..5aec645 100644 --- a/.env.example +++ b/.env.example @@ -19,12 +19,12 @@ BACKUP_RETENTION_DAYS=14 BACKUP_CRON=0 3 * * * BACKUP_DIR=/backups HEALTH_PORT=8080 -# Bot health stays internal (no published port). Override only if you expose it. +# Internal bot health/metrics base (Docker healthcheck). Captcha links use WEBUI_URL. PUBLIC_BASE_URL=http://localhost:8080 TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= -# WebUI (apps/webui) – public URL behind Traefik +# WebUI (apps/webui) – public URL behind Traefik (also used for captcha verification links) WEBUI_URL=https://dashboard.nexumi.de # Discord Developer Portal OAuth2 redirect: # https://dashboard.nexumi.de/api/auth/callback diff --git a/apps/bot/src/interaction-reply.ts b/apps/bot/src/interaction-reply.ts new file mode 100644 index 0000000..11120ae --- /dev/null +++ b/apps/bot/src/interaction-reply.ts @@ -0,0 +1,6 @@ +import { MessageFlags } from 'discord.js'; + +/** Discord.js v14+ prefers flags over the deprecated `ephemeral` option. */ +export function ephemeral(): { flags: typeof MessageFlags.Ephemeral } { + return { flags: MessageFlags.Ephemeral }; +} diff --git a/apps/bot/src/modules/verification/commands.ts b/apps/bot/src/modules/verification/commands.ts index 5d938af..ddd4ac9 100644 --- a/apps/bot/src/modules/verification/commands.ts +++ b/apps/bot/src/modules/verification/commands.ts @@ -1,21 +1,41 @@ -import { PermissionFlagsBits } from 'discord.js'; +import { + PermissionFlagsBits, + type GuildBasedChannel, + type GuildTextBasedChannel +} from 'discord.js'; import { t } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { requirePermission } from '../../permissions.js'; +import { ephemeral } from '../../interaction-reply.js'; import { verifyCommandData } from './command-definitions.js'; -import { - getVerificationConfig, - updateVerificationConfig -} from './service.js'; +import { getVerificationConfig, updateVerificationConfig } from './service.js'; import { buildVerificationPanel } from './handlers.js'; +function botCanPostPanel( + channel: GuildBasedChannel, + meId: string +): channel is GuildTextBasedChannel { + if (!channel.isTextBased() || channel.isDMBased()) { + return false; + } + const permissions = channel.permissionsFor(meId); + if (!permissions) { + return false; + } + return ( + permissions.has(PermissionFlagsBits.ViewChannel) && + permissions.has(PermissionFlagsBits.SendMessages) && + permissions.has(PermissionFlagsBits.EmbedLinks) + ); +} + const verifyCommand: SlashCommand = { data: verifyCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!interaction.inGuild()) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return; } if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { @@ -46,13 +66,16 @@ const verifyCommand: SlashCommand = { failAction }); - await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() }); return; } const config = await getVerificationConfig(context.prisma, guildId); if (!config.enabled || !config.verifiedRoleId) { - await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true }); + await interaction.reply({ + content: t(locale, 'verification.notConfigured'), + ...ephemeral() + }); return; } @@ -63,12 +86,16 @@ const verifyCommand: SlashCommand = { ? await interaction.guild!.channels.fetch(config.channelId) : null; - if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) { - await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true }); + const me = interaction.guild!.members.me; + if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) { + await interaction.reply({ + content: t(locale, 'verification.panelMissingPermission'), + ...ephemeral() + }); return; } - const panel = buildVerificationPanel(config); + const panel = buildVerificationPanel(config, locale); const message = await panelChannel.send(panel); await context.prisma.verificationConfig.update({ where: { guildId }, @@ -77,7 +104,7 @@ const verifyCommand: SlashCommand = { panelMessageId: message.id } }); - await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() }); } }; diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts index a75026f..2e0590d 100644 --- a/apps/bot/src/modules/verification/handlers.ts +++ b/apps/bot/src/modules/verification/handlers.ts @@ -20,6 +20,7 @@ import { } from './service.js'; import { env } from '../../env.js'; import { logger } from '../../logger.js'; +import { ephemeral } from '../../interaction-reply.js'; async function applyFailAction( member: GuildMember, @@ -85,10 +86,13 @@ export async function completeVerification( return { ok: true }; } -export function buildVerificationPanel(config: Awaited>) { +export function buildVerificationPanel( + config: Awaited>, + locale: 'de' | 'en' +) { const embed = new EmbedBuilder() - .setTitle('Verification') - .setDescription('Click the button below to verify and gain access to the server.') + .setTitle(t(locale, 'verification.panel.title')) + .setDescription(t(locale, 'verification.panel.description')) .setColor(0x6366f1); const customId = @@ -99,7 +103,11 @@ export function buildVerificationPanel(config: Awaited().addComponents( new ButtonBuilder() .setCustomId(customId) - .setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify') + .setLabel( + config.mode === 'CAPTCHA' + ? t(locale, 'verification.panel.buttonCaptcha') + : t(locale, 'verification.panel.button') + ) .setStyle(ButtonStyle.Success) ); @@ -119,7 +127,7 @@ export async function handleVerificationButton( const config = await getVerificationConfig(context.prisma, parsed.guildId); if (!config.enabled) { - await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'verification.disabled'), ...ephemeral() }); return; } @@ -129,15 +137,16 @@ export async function handleVerificationButton( parsed.guildId, interaction.user.id ); - const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`; + const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, ''); + const url = `${base}/verify/captcha?token=${challenge.token}`; await interaction.reply({ content: tf(locale, 'verification.captchaLink', { url }), - ephemeral: true + ...ephemeral() }); return; } - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply(ephemeral()); const result = await completeVerification(context, parsed.guildId, interaction.user.id); if (result.ok) { await interaction.editReply({ content: t(locale, 'verification.success') }); diff --git a/apps/webui/src/app/verify/captcha/route.ts b/apps/webui/src/app/verify/captcha/route.ts new file mode 100644 index 0000000..63992ff --- /dev/null +++ b/apps/webui/src/app/verify/captcha/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server'; +import { + deleteCaptchaChallenge, + getCaptchaChallenge, + hashCaptchaAnswer +} from '@/lib/captcha'; +import { getVerificationQueue } from '@/lib/queues'; + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function renderCaptchaPage(token: string, question: string, error?: string): string { + const errorBlock = error ? `

${escapeHtml(error)}

` : ''; + return ` + + + + + Nexumi Verification + + + +
+

Nexumi Verification

+

Solve: ${escapeHtml(question)} = ?

+ ${errorBlock} + + + +
+ +`; +} + +function renderDonePage(): string { + return ` + + + + + Nexumi Verified + + + +
+

Verified

+

You can return to Discord.

+
+ +`; +} + +function html(body: string, status = 200): NextResponse { + return new NextResponse(body, { + status, + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); +} + +export async function GET(request: Request): Promise { + const token = new URL(request.url).searchParams.get('token'); + if (!token) { + return new NextResponse('Missing token', { status: 400 }); + } + const challenge = await getCaptchaChallenge(token); + if (!challenge) { + return new NextResponse('Captcha expired', { status: 404 }); + } + return html(renderCaptchaPage(token, challenge.question)); +} + +export async function POST(request: Request): Promise { + const form = await request.formData(); + const token = String(form.get('token') ?? ''); + const answer = String(form.get('answer') ?? ''); + if (!token || !answer) { + return new NextResponse('Missing fields', { status: 400 }); + } + + const challenge = await getCaptchaChallenge(token); + if (!challenge) { + return new NextResponse('Captcha expired', { status: 404 }); + } + + if (hashCaptchaAnswer(answer) !== challenge.answerHash) { + return html(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.')); + } + + await deleteCaptchaChallenge(token); + await getVerificationQueue().add( + 'verificationComplete', + { guildId: challenge.guildId, userId: challenge.userId }, + { removeOnComplete: 1000, removeOnFail: 500 } + ); + + return html(renderDonePage()); +} diff --git a/apps/webui/src/lib/captcha.ts b/apps/webui/src/lib/captcha.ts new file mode 100644 index 0000000..40dc848 --- /dev/null +++ b/apps/webui/src/lib/captcha.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto'; +import { redis } from './redis'; + +const CAPTCHA_PREFIX = 'verify:captcha:'; + +export type CaptchaChallenge = { + token: string; + guildId: string; + userId: string; + question: string; + answerHash: string; +}; + +export async function getCaptchaChallenge(token: string): Promise { + const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as CaptchaChallenge; + } catch { + return null; + } +} + +export async function deleteCaptchaChallenge(token: string): Promise { + await redis.del(`${CAPTCHA_PREFIX}${token}`); +} + +export function hashCaptchaAnswer(answer: string): string { + return createHash('sha256').update(answer.trim()).digest('hex'); +} diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index e627c49..ae23b59 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -19,6 +19,7 @@ export const scheduleQueueName = 'schedules'; export const guildBackupQueueName = 'guild-backups'; export const suggestionsQueueName = 'suggestions'; export const messagesQueueName = 'messages'; +export const verificationQueueName = 'verification'; const globalForQueues = globalThis as unknown as { __nexumiGiveawayQueue?: Queue; @@ -26,6 +27,7 @@ const globalForQueues = globalThis as unknown as { __nexumiGuildBackupQueue?: Queue; __nexumiSuggestionsQueue?: Queue; __nexumiMessagesQueue?: Queue; + __nexumiVerificationQueue?: Queue; __nexumiGiveawayQueueEvents?: QueueEvents; __nexumiGuildBackupQueueEvents?: QueueEvents; __nexumiSuggestionsQueueEvents?: QueueEvents; @@ -76,6 +78,13 @@ export function getMessagesQueue(): Queue { return globalForQueues.__nexumiMessagesQueue; } +export function getVerificationQueue(): Queue { + globalForQueues.__nexumiVerificationQueue ??= new Queue(verificationQueueName, { + connection: queueConnection() + }); + return globalForQueues.__nexumiVerificationQueue; +} + /** * QueueEvents instances are only needed for jobs where the dashboard needs * to wait for the bot to finish (e.g. ending a giveaway so the winners list diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 11f72c6..70f7bb1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -173,6 +173,13 @@ const de: Dictionary = { 'verification.notConfigured': 'Verifizierung ist noch nicht eingerichtet. Nutze `/verify setup`.', 'verification.invalidChannel': 'Der Verifizierungskanal ist ungültig.', 'verification.panelPosted': 'Verifizierungs-Panel gepostet.', + 'verification.panelMissingPermission': + 'Dem Bot fehlen Rechte in diesem Kanal (Kanal sehen, Nachrichten senden, Embeds).', + 'verification.panel.title': 'Verifizierung', + 'verification.panel.description': + 'Klicke auf den Button unten, um dich zu verifizieren und Zugang zum Server zu erhalten.', + 'verification.panel.button': 'Verifizieren', + 'verification.panel.buttonCaptcha': 'Verifizieren (Captcha)', 'verification.captchaLink': 'Öffne diesen Link zur Captcha-Verifizierung: {url}', 'verification.error.not_configured': 'Verifizierung ist nicht konfiguriert.', 'verification.error.member_not_found': 'Mitglied nicht gefunden.', @@ -749,6 +756,13 @@ const en: Dictionary = { 'verification.notConfigured': 'Verification is not set up yet. Use `/verify setup`.', 'verification.invalidChannel': 'The verification channel is invalid.', 'verification.panelPosted': 'Verification panel posted.', + 'verification.panelMissingPermission': + 'The bot lacks permissions in that channel (View Channel, Send Messages, Embed Links).', + 'verification.panel.title': 'Verification', + 'verification.panel.description': + 'Click the button below to verify and gain access to the server.', + 'verification.panel.button': 'Verify', + 'verification.panel.buttonCaptcha': 'Verify (Captcha)', 'verification.captchaLink': 'Open this link to complete captcha verification: {url}', 'verification.error.not_configured': 'Verification is not configured.', 'verification.error.member_not_found': 'Member not found.',