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:
@@ -9,7 +9,10 @@ import type { BotContext } from './types.js';
|
|||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||||
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.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 { closePoll } from './modules/utility/poll.js';
|
||||||
import { sendReminder } from './modules/utility/reminders.js';
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +87,10 @@ type VerificationCompleteJob = {
|
|||||||
captchaProvider?: string | null;
|
captchaProvider?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type VerificationPanelSyncJob = {
|
||||||
|
guildId: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ReminderSendJob = {
|
type ReminderSendJob = {
|
||||||
reminderId: string;
|
reminderId: string;
|
||||||
};
|
};
|
||||||
@@ -297,16 +304,20 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
|
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
const verificationWorker = new Worker<VerificationCompleteJob>(
|
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
|
||||||
verificationQueueName,
|
verificationQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
|
if (job.name === 'verificationPanelSync') {
|
||||||
|
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
|
||||||
|
}
|
||||||
if (job.name !== 'verificationComplete') {
|
if (job.name !== 'verificationComplete') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await completeVerification(context, job.data.guildId, job.data.userId, {
|
const data = job.data as VerificationCompleteJob;
|
||||||
ipHash: job.data.ipHash,
|
await completeVerification(context, data.guildId, data.userId, {
|
||||||
userAgentHash: job.data.userAgentHash,
|
ipHash: data.ipHash,
|
||||||
captchaProvider: job.data.captchaProvider
|
userAgentHash: data.userAgentHash,
|
||||||
|
captchaProvider: data.captchaProvider
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
PermissionFlagsBits,
|
|
||||||
type GuildBasedChannel,
|
|
||||||
type GuildTextBasedChannel
|
|
||||||
} from 'discord.js';
|
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
@@ -10,25 +6,7 @@ import { requirePermission } from '../../permissions.js';
|
|||||||
import { ephemeral } from '../../interaction-reply.js';
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
import { verifyCommandData } from './command-definitions.js';
|
import { verifyCommandData } from './command-definitions.js';
|
||||||
import { getVerificationConfig, updateVerificationConfig } from './service.js';
|
import { getVerificationConfig, updateVerificationConfig } from './service.js';
|
||||||
import { buildVerificationPanel } from './handlers.js';
|
import { syncVerificationPanel, VerificationPanelError } 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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyCommand: SlashCommand = {
|
const verifyCommand: SlashCommand = {
|
||||||
data: verifyCommandData,
|
data: verifyCommandData,
|
||||||
@@ -91,31 +69,21 @@ const verifyCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const panelChannelOption = interaction.options.getChannel('channel');
|
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;
|
try {
|
||||||
if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) {
|
await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null);
|
||||||
await interaction.reply({
|
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
|
||||||
content: t(locale, 'verification.panelMissingPermission'),
|
} catch (error) {
|
||||||
...ephemeral()
|
if (error instanceof VerificationPanelError) {
|
||||||
});
|
const key =
|
||||||
return;
|
error.code === 'not_configured'
|
||||||
}
|
? 'verification.notConfigured'
|
||||||
|
: 'verification.panelMissingPermission';
|
||||||
const panel = buildVerificationPanel(config, locale);
|
await interaction.reply({ content: t(locale, key), ...ephemeral() });
|
||||||
const message = await panelChannel.send(panel);
|
return;
|
||||||
await context.prisma.verificationConfig.update({
|
|
||||||
where: { guildId },
|
|
||||||
data: {
|
|
||||||
panelChannelId: panelChannel.id,
|
|
||||||
panelMessageId: message.id
|
|
||||||
}
|
}
|
||||||
});
|
throw error;
|
||||||
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
type ButtonInteraction,
|
type ButtonInteraction,
|
||||||
type GuildMember
|
type GuildBasedChannel,
|
||||||
|
type GuildMember,
|
||||||
|
type GuildTextBasedChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
|
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
@@ -23,6 +25,31 @@ import { env } from '../../env.js';
|
|||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { ephemeral } from '../../interaction-reply.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 = {
|
export type CompleteVerificationOptions = {
|
||||||
ipHash?: string | null;
|
ipHash?: string | null;
|
||||||
userAgentHash?: string | null;
|
userAgentHash?: string | null;
|
||||||
@@ -228,6 +255,86 @@ export function buildVerificationPanel(
|
|||||||
return { embeds: [embed], components: [row] };
|
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(
|
export async function handleVerificationButton(
|
||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { VerificationConfigDashboardPatchSchema } from '@nexumi/shared';
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { writeDashboardAudit } from '@/lib/audit';
|
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';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -45,6 +49,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json(after);
|
return NextResponse.json(after);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof VerificationPanelSyncError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } 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 {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/verification`, {
|
const response = await fetch(`/api/guilds/${guildId}/verification`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -21,12 +29,21 @@ async function saveVerification(guildId: string, value: VerificationConfigDashbo
|
|||||||
body: JSON.stringify(value)
|
body: JSON.stringify(value)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
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: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, error: 'Network error' };
|
return { ok: false, error: errorMessages.network };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +63,13 @@ export function VerificationForm({
|
|||||||
return (
|
return (
|
||||||
<SettingsForm<VerificationConfigDashboard>
|
<SettingsForm<VerificationConfigDashboard>
|
||||||
initialValue={initialValue}
|
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 }) => (
|
{({ value, setValue }) => (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import type { CaptchaProvider, VerificationConfigDashboard, VerificationConfigDa
|
|||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
import { getAvailableCaptchaProviders } from '../captcha';
|
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) {
|
async function ensureVerificationConfig(guildId: string) {
|
||||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
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(
|
export async function updateVerificationDashboard(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
patch: VerificationConfigDashboardPatch
|
patch: VerificationConfigDashboardPatch
|
||||||
@@ -71,5 +126,11 @@ export async function updateVerificationDashboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.verificationConfig.update({ where: { guildId }, data });
|
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;
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
__nexumiMessagesQueueEvents?: QueueEvents;
|
__nexumiMessagesQueueEvents?: QueueEvents;
|
||||||
__nexumiSelfrolesQueueEvents?: QueueEvents;
|
__nexumiSelfrolesQueueEvents?: QueueEvents;
|
||||||
|
__nexumiVerificationQueueEvents?: QueueEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queueConnection() {
|
function queueConnection() {
|
||||||
@@ -153,6 +154,13 @@ export function getSelfrolesQueueEvents(): QueueEvents {
|
|||||||
return globalForQueues.__nexumiSelfrolesQueueEvents;
|
return globalForQueues.__nexumiSelfrolesQueueEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVerificationQueueEvents(): QueueEvents {
|
||||||
|
globalForQueues.__nexumiVerificationQueueEvents ??= new QueueEvents(verificationQueueName, {
|
||||||
|
connection: queueEventsConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiVerificationQueueEvents;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"unsavedChanges": "Du hast ungespeicherte Änderungen",
|
"unsavedChanges": "Du hast ungespeicherte Änderungen",
|
||||||
"saveSuccess": "Änderungen gespeichert",
|
"saveSuccess": "Änderungen gespeichert",
|
||||||
"saveError": "Änderungen konnten nicht gespeichert werden",
|
"saveError": "Änderungen konnten nicht gespeichert werden",
|
||||||
|
"networkError": "Netzwerkfehler",
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"comingSoon": "Demnächst verfügbar",
|
"comingSoon": "Demnächst verfügbar",
|
||||||
@@ -562,7 +563,7 @@
|
|||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"title": "Verifizierung",
|
"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",
|
"enabledLabel": "Verifizierung aktiviert",
|
||||||
"mode": "Modus",
|
"mode": "Modus",
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -594,7 +595,11 @@
|
|||||||
"altBlockOnMatchHint": "Nutzt die konfigurierte Fehlschlag-Aktion (Kick/Ban/Keine). Aus = nur markieren, trotzdem verifizieren.",
|
"altBlockOnMatchHint": "Nutzt die konfigurierte Fehlschlag-Aktion (Kick/Ban/Keine). Aus = nur markieren, trotzdem verifizieren.",
|
||||||
"altIpWindowDays": "IP-Fenster (Tage)",
|
"altIpWindowDays": "IP-Fenster (Tage)",
|
||||||
"altInviteWindowHours": "Invite-Cluster-Fenster (Stunden)",
|
"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": {
|
"leveling": {
|
||||||
"title": "Leveling & XP",
|
"title": "Leveling & XP",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"unsavedChanges": "You have unsaved changes",
|
"unsavedChanges": "You have unsaved changes",
|
||||||
"saveSuccess": "Changes saved",
|
"saveSuccess": "Changes saved",
|
||||||
"saveError": "Could not save changes",
|
"saveError": "Could not save changes",
|
||||||
|
"networkError": "Network error",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"comingSoon": "Coming soon",
|
"comingSoon": "Coming soon",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
@@ -562,7 +563,7 @@
|
|||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"title": "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",
|
"enabledLabel": "Verification enabled",
|
||||||
"mode": "Mode",
|
"mode": "Mode",
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -594,7 +595,11 @@
|
|||||||
"altBlockOnMatchHint": "Uses the configured fail action (Kick/Ban/None). Off = flag only, still verify.",
|
"altBlockOnMatchHint": "Uses the configured fail action (Kick/Ban/None). Off = flag only, still verify.",
|
||||||
"altIpWindowDays": "IP window (days)",
|
"altIpWindowDays": "IP window (days)",
|
||||||
"altInviteWindowHours": "Invite cluster window (hours)",
|
"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": {
|
"leveling": {
|
||||||
"title": "Leveling & XP",
|
"title": "Leveling & XP",
|
||||||
|
|||||||
@@ -463,3 +463,19 @@
|
|||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user