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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user