diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 0e9842f..2c88d2a 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -9,7 +9,10 @@ import type { BotContext } from './types.js'; import { env } from './env.js'; import { refreshPhishingDomains } from './modules/automod/filters.js'; import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js'; -import { completeVerification } from './modules/verification/handlers.js'; +import { + completeVerification, + runVerificationPanelSyncJob +} from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; import { @@ -84,6 +87,10 @@ type VerificationCompleteJob = { captchaProvider?: string | null; }; +type VerificationPanelSyncJob = { + guildId: string; +}; + type ReminderSendJob = { reminderId: string; }; @@ -297,16 +304,20 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'AutoMod job failed'); }); - const verificationWorker = new Worker( + const verificationWorker = new Worker( verificationQueueName, async (job) => { + if (job.name === 'verificationPanelSync') { + return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob); + } if (job.name !== 'verificationComplete') { return; } - await completeVerification(context, job.data.guildId, job.data.userId, { - ipHash: job.data.ipHash, - userAgentHash: job.data.userAgentHash, - captchaProvider: job.data.captchaProvider + const data = job.data as VerificationCompleteJob; + await completeVerification(context, data.guildId, data.userId, { + ipHash: data.ipHash, + userAgentHash: data.userAgentHash, + captchaProvider: data.captchaProvider }); }, { connection: redis } diff --git a/apps/bot/src/modules/verification/commands.ts b/apps/bot/src/modules/verification/commands.ts index 4de4338..764c2e2 100644 --- a/apps/bot/src/modules/verification/commands.ts +++ b/apps/bot/src/modules/verification/commands.ts @@ -1,8 +1,4 @@ -import { - PermissionFlagsBits, - type GuildBasedChannel, - type GuildTextBasedChannel -} from 'discord.js'; +import { PermissionFlagsBits } from 'discord.js'; import { t } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; @@ -10,25 +6,7 @@ import { requirePermission } from '../../permissions.js'; import { ephemeral } from '../../interaction-reply.js'; import { verifyCommandData } from './command-definitions.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) - ); -} +import { syncVerificationPanel, VerificationPanelError } from './handlers.js'; const verifyCommand: SlashCommand = { data: verifyCommandData, @@ -91,31 +69,21 @@ const verifyCommand: SlashCommand = { } const panelChannelOption = interaction.options.getChannel('channel'); - const panelChannel = panelChannelOption - ? await interaction.guild!.channels.fetch(panelChannelOption.id) - : config.channelId - ? await interaction.guild!.channels.fetch(config.channelId) - : null; - 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, locale); - const message = await panelChannel.send(panel); - await context.prisma.verificationConfig.update({ - where: { guildId }, - data: { - panelChannelId: panelChannel.id, - panelMessageId: message.id + try { + await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null); + await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() }); + } catch (error) { + if (error instanceof VerificationPanelError) { + const key = + error.code === 'not_configured' + ? 'verification.notConfigured' + : 'verification.panelMissingPermission'; + await interaction.reply({ content: t(locale, key), ...ephemeral() }); + return; } - }); - await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() }); + throw error; + } } }; diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts index e1c8061..fdec680 100644 --- a/apps/bot/src/modules/verification/handlers.ts +++ b/apps/bot/src/modules/verification/handlers.ts @@ -5,7 +5,9 @@ import { EmbedBuilder, PermissionFlagsBits, type ButtonInteraction, - type GuildMember + type GuildBasedChannel, + type GuildMember, + type GuildTextBasedChannel } from 'discord.js'; import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; @@ -23,6 +25,31 @@ import { env } from '../../env.js'; import { logger } from '../../logger.js'; import { ephemeral } from '../../interaction-reply.js'; +export class VerificationPanelError extends Error { + constructor(public readonly code: 'not_configured' | 'missing_permission') { + super(code); + this.name = 'VerificationPanelError'; + } +} + +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) + ); +} + export type CompleteVerificationOptions = { ipHash?: string | null; userAgentHash?: string | null; @@ -228,6 +255,86 @@ export function buildVerificationPanel( return { embeds: [embed], components: [row] }; } +/** + * Posts or updates the verification panel in the configured channel. + * Used by `/verify panel` and by the dashboard via BullMQ (`verificationPanelSync`). + */ +export async function syncVerificationPanel( + context: BotContext, + guildId: string, + preferredChannelId?: string | null +): Promise<{ panelChannelId: string; panelMessageId: string }> { + const locale = await getGuildLocale(context.prisma, guildId); + const config = await getVerificationConfig(context.prisma, guildId); + + if (!config.enabled || !config.verifiedRoleId) { + throw new VerificationPanelError('not_configured'); + } + + const targetChannelId = preferredChannelId || config.channelId; + if (!targetChannelId) { + throw new VerificationPanelError('not_configured'); + } + + const guild = await context.client.guilds.fetch(guildId); + const me = guild.members.me; + if (!me) { + throw new VerificationPanelError('missing_permission'); + } + + const channel = await guild.channels.fetch(targetChannelId).catch(() => null); + if (!channel || !botCanPostPanel(channel, me.id)) { + throw new VerificationPanelError('missing_permission'); + } + + const panel = buildVerificationPanel(config, locale); + const sameChannel = + Boolean(config.panelMessageId) && config.panelChannelId === channel.id; + + if (sameChannel && config.panelMessageId) { + try { + const existing = await channel.messages.fetch(config.panelMessageId); + await existing.edit(panel); + return { panelChannelId: channel.id, panelMessageId: existing.id }; + } catch (error) { + logger.warn( + { error, guildId, messageId: config.panelMessageId }, + 'Failed to edit verification panel; recreating' + ); + } + } + + if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) { + try { + const oldChannel = await guild.channels.fetch(config.panelChannelId); + if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) { + const oldMessage = await oldChannel.messages.fetch(config.panelMessageId); + await oldMessage.delete(); + } + } catch { + // Previous panel may already be gone + } + } + + const message = await channel.send(panel); + await context.prisma.verificationConfig.update({ + where: { guildId }, + data: { + panelChannelId: channel.id, + panelMessageId: message.id + } + }); + + return { panelChannelId: channel.id, panelMessageId: message.id }; +} + +export async function runVerificationPanelSyncJob( + context: BotContext, + data: { guildId: string } +): Promise<{ panelChannelId: string; panelMessageId: string }> { + return syncVerificationPanel(context, data.guildId); +} + export async function handleVerificationButton( interaction: ButtonInteraction, context: BotContext diff --git a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts index 8d1e109..cdfffcb 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts @@ -2,7 +2,11 @@ import { VerificationConfigDashboardPatchSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; import { writeDashboardAudit } from '@/lib/audit'; -import { getVerificationDashboard, updateVerificationDashboard } from '@/lib/module-configs/verification'; +import { + getVerificationDashboard, + updateVerificationDashboard, + VerificationPanelSyncError +} from '@/lib/module-configs/verification'; import { prisma } from '@/lib/prisma'; interface RouteParams { @@ -45,6 +49,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json(after); } catch (error) { + if (error instanceof VerificationPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/components/modules/verification-form.tsx b/apps/webui/src/components/modules/verification-form.tsx index c88f47f..8b976ca 100644 --- a/apps/webui/src/components/modules/verification-form.tsx +++ b/apps/webui/src/components/modules/verification-form.tsx @@ -13,7 +13,15 @@ import { Switch } from '@/components/ui/switch'; import type { SettingsSaveResult } from '@/components/settings/settings-form'; import { SettingsForm } from '@/components/settings/settings-form'; -async function saveVerification(guildId: string, value: VerificationConfigDashboard): Promise { +async function saveVerification( + guildId: string, + value: VerificationConfigDashboard, + errorMessages: { + timeout: string; + notPosted: string; + network: string; + } +): Promise { try { const response = await fetch(`/api/guilds/${guildId}/verification`, { method: 'PATCH', @@ -21,12 +29,21 @@ async function saveVerification(guildId: string, value: VerificationConfigDashbo body: JSON.stringify(value) }); if (!response.ok) { - const body = (await response.json().catch(() => null)) as { error?: string } | null; + const body = (await response.json().catch(() => null)) as { + error?: string; + code?: string; + } | null; + if (body?.code === 'timeout') { + return { ok: false, error: errorMessages.timeout }; + } + if (body?.code === 'not_posted') { + return { ok: false, error: errorMessages.notPosted }; + } return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` }; } return { ok: true }; } catch { - return { ok: false, error: 'Network error' }; + return { ok: false, error: errorMessages.network }; } } @@ -46,7 +63,13 @@ export function VerificationForm({ return ( initialValue={initialValue} - onSave={(value) => saveVerification(guildId, value)} + onSave={(value) => + saveVerification(guildId, value, { + timeout: t('modulePages.verification.errors.panelTimeout'), + notPosted: t('modulePages.verification.errors.panelNotPosted'), + network: t('common.networkError') + }) + } > {({ value, setValue }) => (
diff --git a/apps/webui/src/lib/module-configs/verification.ts b/apps/webui/src/lib/module-configs/verification.ts index 21ea124..08fe9a8 100644 --- a/apps/webui/src/lib/module-configs/verification.ts +++ b/apps/webui/src/lib/module-configs/verification.ts @@ -2,6 +2,21 @@ import type { CaptchaProvider, VerificationConfigDashboard, VerificationConfigDa import type { Prisma } from '@prisma/client'; import { prisma } from '../prisma'; import { getAvailableCaptchaProviders } from '../captcha'; +import { + addJobAndAwait, + getVerificationQueue, + getVerificationQueueEvents +} from '../queues'; + +export class VerificationPanelSyncError extends Error { + constructor( + message: string, + public readonly code: 'timeout' | 'not_posted' = 'not_posted' + ) { + super(message); + this.name = 'VerificationPanelSyncError'; + } +} async function ensureVerificationConfig(guildId: string) { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); @@ -47,6 +62,46 @@ export async function getVerificationDashboard(guildId: string): Promise<{ }; } +/** + * Dashboard has no discord.js Client, so panel posting is delegated to the bot + * via the `verification` BullMQ queue (`verificationPanelSync`) — same pattern + * as self-role panels / giveaways. + */ +async function syncVerificationPanel(guildId: string): Promise { + const { confirmed, result } = await addJobAndAwait<{ + panelChannelId: string; + panelMessageId: string; + }>( + getVerificationQueue(), + getVerificationQueueEvents(), + 'verificationPanelSync', + { guildId }, + { + jobId: `verification-panel-${guildId}-${Date.now()}`, + removeOnComplete: true, + removeOnFail: 25 + }, + 30_000 + ); + + if (!confirmed || !result?.panelMessageId) { + throw new VerificationPanelSyncError( + confirmed + ? 'Verification panel job finished without posting a Discord message' + : 'The bot did not confirm the verification panel in time. It may still be posting — check the channel shortly.', + confirmed ? 'not_posted' : 'timeout' + ); + } + + const config = await prisma.verificationConfig.findUnique({ where: { guildId } }); + if (!config?.panelMessageId) { + throw new VerificationPanelSyncError( + 'Verification panel job finished without storing a Discord message id', + 'not_posted' + ); + } +} + export async function updateVerificationDashboard( guildId: string, patch: VerificationConfigDashboardPatch @@ -71,5 +126,11 @@ export async function updateVerificationDashboard( } const updated = await prisma.verificationConfig.update({ where: { guildId }, data }); - return toDashboard(updated, getAvailableCaptchaProviders()); + const dashboard = toDashboard(updated, getAvailableCaptchaProviders()); + + if (dashboard.enabled && dashboard.channelId && dashboard.verifiedRoleId) { + await syncVerificationPanel(guildId); + } + + return dashboard; } diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index 22eaa42..105c76d 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -39,6 +39,7 @@ const globalForQueues = globalThis as unknown as { __nexumiSuggestionsQueueEvents?: QueueEvents; __nexumiMessagesQueueEvents?: QueueEvents; __nexumiSelfrolesQueueEvents?: QueueEvents; + __nexumiVerificationQueueEvents?: QueueEvents; }; function queueConnection() { @@ -153,6 +154,13 @@ export function getSelfrolesQueueEvents(): QueueEvents { return globalForQueues.__nexumiSelfrolesQueueEvents; } +export function getVerificationQueueEvents(): QueueEvents { + globalForQueues.__nexumiVerificationQueueEvents ??= new QueueEvents(verificationQueueName, { + connection: queueEventsConnection() + }); + return globalForQueues.__nexumiVerificationQueueEvents; +} + const DEFAULT_WAIT_TIMEOUT_MS = 15_000; /** diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 9214cfe..d285566 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -15,6 +15,7 @@ "unsavedChanges": "Du hast ungespeicherte Änderungen", "saveSuccess": "Änderungen gespeichert", "saveError": "Änderungen konnten nicht gespeichert werden", + "networkError": "Netzwerkfehler", "optional": "Optional", "back": "Zurück", "comingSoon": "Demnächst verfügbar", @@ -562,7 +563,7 @@ }, "verification": { "title": "Verifizierung", - "description": "Modus, Rollen und Anforderungen für die Mitglieder-Verifizierung.", + "description": "Modus, Rollen und Anforderungen. Beim Speichern (aktiviert, mit Kanal und Rolle) wird das Verifizierungs-Panel im gewählten Kanal gepostet bzw. aktualisiert.", "enabledLabel": "Verifizierung aktiviert", "mode": "Modus", "modes": { @@ -594,7 +595,11 @@ "altBlockOnMatchHint": "Nutzt die konfigurierte Fehlschlag-Aktion (Kick/Ban/Keine). Aus = nur markieren, trotzdem verifizieren.", "altIpWindowDays": "IP-Fenster (Tage)", "altInviteWindowHours": "Invite-Cluster-Fenster (Stunden)", - "altMaxAccountsPerInvite": "Max. Accounts pro Invite (Cluster)" + "altMaxAccountsPerInvite": "Max. Accounts pro Invite (Cluster)", + "errors": { + "panelTimeout": "Der Bot hat das Verifizierungs-Panel nicht rechtzeitig bestätigt. Es wird möglicherweise noch gepostet — prüfe den Kanal und die Bot-Berechtigungen, dann speichere erneut.", + "panelNotPosted": "Das Verifizierungs-Panel konnte nicht im Discord-Kanal erstellt werden. Prüfe Bot-Berechtigungen (Kanal sehen, Nachrichten senden, Embeds)." + } }, "leveling": { "title": "Leveling & XP", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index ab3bdc6..a0830ce 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -15,6 +15,7 @@ "unsavedChanges": "You have unsaved changes", "saveSuccess": "Changes saved", "saveError": "Could not save changes", + "networkError": "Network error", "back": "Back", "comingSoon": "Coming soon", "close": "Close", @@ -562,7 +563,7 @@ }, "verification": { "title": "Verification", - "description": "Mode, roles and requirements for member verification.", + "description": "Mode, roles and requirements. Saving (enabled, with channel and role) posts or updates the verification panel in the selected channel.", "enabledLabel": "Verification enabled", "mode": "Mode", "modes": { @@ -594,7 +595,11 @@ "altBlockOnMatchHint": "Uses the configured fail action (Kick/Ban/None). Off = flag only, still verify.", "altIpWindowDays": "IP window (days)", "altInviteWindowHours": "Invite cluster window (hours)", - "altMaxAccountsPerInvite": "Max accounts per invite (cluster)" + "altMaxAccountsPerInvite": "Max accounts per invite (cluster)", + "errors": { + "panelTimeout": "The bot did not confirm the verification panel in time. It may still be posting — check the channel and bot permissions, then save again.", + "panelNotPosted": "The verification panel could not be created in the Discord channel. Check bot permissions (View Channel, Send Messages, Embed Links)." + } }, "leveling": { "title": "Leveling & XP", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 511b5e8..4030de9 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -463,3 +463,19 @@ ## Nächster geplanter Schritt - Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe). + +## Post-Phase – Verification Panel aus WebUI (Status: implementiert) + +### Abgeschlossen (Code) + +- Root cause: Dashboard speicherte nur `VerificationConfig`, ohne Panel in Discord zu posten +- Neuer BullMQ-Job `verificationPanelSync` (Queue `verification`); WebUI wartet bounded auf Bot-Bestätigung +- `syncVerificationPanel` shared für `/verify panel` und Dashboard (Edit bei gleichem Kanal, sonst neu posten) +- i18n DE/EN: Hinweis in Modul-Beschreibung + Fehlertexte bei Panel-Sync-Fail + +### Manuell testen + +- [ ] WebUI: Verifizierung aktivieren, Kanal + Rolle setzen, Speichern → Panel erscheint im Kanal +- [ ] Speichern mit geändertem Modus → bestehendes Panel wird aktualisiert +- [ ] Kanal wechseln und speichern → altes Panel weg, neues im Zielkanal +- [ ] Bot ohne Send-Permission → Fehlermeldung; Config bleibt gespeichert, erneutes Speichern nach Fix