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

@@ -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<VerificationCompleteJob>(
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
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 }

View File

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

View File

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