import type { CaptchaProvider, VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared'; 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 } }); return prisma.verificationConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); } function toDashboard( config: Awaited>, available: CaptchaProvider[] ): VerificationConfigDashboard { const rawProvider = (config.captchaProvider as CaptchaProvider) ?? 'MATH'; const captchaProvider = available.includes(rawProvider) ? rawProvider : 'MATH'; return { enabled: config.enabled, channelId: config.channelId ?? '', verifiedRoleId: config.verifiedRoleId ?? '', unverifiedRoleId: config.unverifiedRoleId ?? '', mode: config.mode as VerificationConfigDashboard['mode'], captchaProvider, minAccountAgeDays: config.minAccountAgeDays, failAction: config.failAction as VerificationConfigDashboard['failAction'], altDetectionEnabled: config.altDetectionEnabled, altBlockOnMatch: config.altBlockOnMatch, altIpWindowDays: config.altIpWindowDays, altInviteWindowHours: config.altInviteWindowHours, altMaxAccountsPerInvite: config.altMaxAccountsPerInvite }; } export async function getVerificationDashboard(guildId: string): Promise<{ config: VerificationConfigDashboard; availableCaptchaProviders: CaptchaProvider[]; }> { const config = await ensureVerificationConfig(guildId); const availableCaptchaProviders = getAvailableCaptchaProviders(); return { config: toDashboard(config, availableCaptchaProviders), availableCaptchaProviders }; } /** * 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 ): Promise { await ensureVerificationConfig(guildId); const available = new Set(getAvailableCaptchaProviders()); if (patch.captchaProvider !== undefined && !available.has(patch.captchaProvider)) { throw new Error(`Captcha provider ${patch.captchaProvider} is not configured in .env`); } const { channelId, verifiedRoleId, unverifiedRoleId, ...rest } = patch; const data: Prisma.VerificationConfigUpdateInput = { ...rest }; if (channelId !== undefined) { data.channelId = channelId === '' ? null : channelId; } if (verifiedRoleId !== undefined) { data.verifiedRoleId = verifiedRoleId === '' ? null : verifiedRoleId; } if (unverifiedRoleId !== undefined) { data.unverifiedRoleId = unverifiedRoleId === '' ? null : unverifiedRoleId; } const updated = await prisma.verificationConfig.update({ where: { guildId }, data }); const dashboard = toDashboard(updated, getAvailableCaptchaProviders()); if (dashboard.enabled && dashboard.channelId && dashboard.verifiedRoleId) { await syncVerificationPanel(guildId); } return dashboard; }