Implement verification panel synchronization with BullMQ integration

- Introduced a new `verificationPanelSync` job to handle posting and updating the verification panel in Discord channels via BullMQ.
- Enhanced the `syncVerificationPanel` function to manage panel updates and error handling, ensuring proper feedback for synchronization issues.
- Updated the WebUI to support verification panel management, including improved error messages for timeout and posting failures.
- Added localization entries for new error messages related to verification panel synchronization in both English and German.
- Refactored existing verification logic to accommodate the new synchronization process, improving overall functionality and user experience.
This commit is contained in:
TheOnlyMace
2026-07-25 15:55:13 +02:00
parent 67ac16d36b
commit ef1803f0ab
10 changed files with 278 additions and 64 deletions

View File

@@ -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);
}
}

View File

@@ -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<SettingsSaveResult> {
async function saveVerification(
guildId: string,
value: VerificationConfigDashboard,
errorMessages: {
timeout: string;
notPosted: string;
network: string;
}
): Promise<SettingsSaveResult> {
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 (
<SettingsForm<VerificationConfigDashboard>
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 }) => (
<div className="space-y-4">

View File

@@ -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<void> {
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;
}

View File

@@ -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;
/**

View File

@@ -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",

View File

@@ -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",