deploy #1
@@ -19,12 +19,12 @@ BACKUP_RETENTION_DAYS=14
|
|||||||
BACKUP_CRON=0 3 * * *
|
BACKUP_CRON=0 3 * * *
|
||||||
BACKUP_DIR=/backups
|
BACKUP_DIR=/backups
|
||||||
HEALTH_PORT=8080
|
HEALTH_PORT=8080
|
||||||
# Bot health stays internal (no published port). Override only if you expose it.
|
# Internal bot health/metrics base (Docker healthcheck). Captcha links use WEBUI_URL.
|
||||||
PUBLIC_BASE_URL=http://localhost:8080
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
TWITCH_CLIENT_ID=
|
TWITCH_CLIENT_ID=
|
||||||
TWITCH_CLIENT_SECRET=
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
# WebUI (apps/webui) – public URL behind Traefik
|
# WebUI (apps/webui) – public URL behind Traefik (also used for captcha verification links)
|
||||||
WEBUI_URL=https://dashboard.nexumi.de
|
WEBUI_URL=https://dashboard.nexumi.de
|
||||||
# Discord Developer Portal OAuth2 redirect:
|
# Discord Developer Portal OAuth2 redirect:
|
||||||
# https://dashboard.nexumi.de/api/auth/callback
|
# https://dashboard.nexumi.de/api/auth/callback
|
||||||
|
|||||||
6
apps/bot/src/interaction-reply.ts
Normal file
6
apps/bot/src/interaction-reply.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { MessageFlags } from 'discord.js';
|
||||||
|
|
||||||
|
/** Discord.js v14+ prefers flags over the deprecated `ephemeral` option. */
|
||||||
|
export function ephemeral(): { flags: typeof MessageFlags.Ephemeral } {
|
||||||
|
return { flags: MessageFlags.Ephemeral };
|
||||||
|
}
|
||||||
@@ -1,21 +1,41 @@
|
|||||||
import { PermissionFlagsBits } from 'discord.js';
|
import {
|
||||||
|
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';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
import { verifyCommandData } from './command-definitions.js';
|
import { verifyCommandData } from './command-definitions.js';
|
||||||
import {
|
import { getVerificationConfig, updateVerificationConfig } from './service.js';
|
||||||
getVerificationConfig,
|
|
||||||
updateVerificationConfig
|
|
||||||
} from './service.js';
|
|
||||||
import { buildVerificationPanel } from './handlers.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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const verifyCommand: SlashCommand = {
|
const verifyCommand: SlashCommand = {
|
||||||
data: verifyCommandData,
|
data: verifyCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!interaction.inGuild()) {
|
if (!interaction.inGuild()) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
@@ -46,13 +66,16 @@ const verifyCommand: SlashCommand = {
|
|||||||
failAction
|
failAction
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getVerificationConfig(context.prisma, guildId);
|
const config = await getVerificationConfig(context.prisma, guildId);
|
||||||
if (!config.enabled || !config.verifiedRoleId) {
|
if (!config.enabled || !config.verifiedRoleId) {
|
||||||
await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'verification.notConfigured'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +86,16 @@ const verifyCommand: SlashCommand = {
|
|||||||
? await interaction.guild!.channels.fetch(config.channelId)
|
? await interaction.guild!.channels.fetch(config.channelId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) {
|
const me = interaction.guild!.members.me;
|
||||||
await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true });
|
if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'verification.panelMissingPermission'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panel = buildVerificationPanel(config);
|
const panel = buildVerificationPanel(config, locale);
|
||||||
const message = await panelChannel.send(panel);
|
const message = await panelChannel.send(panel);
|
||||||
await context.prisma.verificationConfig.update({
|
await context.prisma.verificationConfig.update({
|
||||||
where: { guildId },
|
where: { guildId },
|
||||||
@@ -77,7 +104,7 @@ const verifyCommand: SlashCommand = {
|
|||||||
panelMessageId: message.id
|
panelMessageId: message.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './service.js';
|
} from './service.js';
|
||||||
import { env } from '../../env.js';
|
import { env } from '../../env.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
|
|
||||||
async function applyFailAction(
|
async function applyFailAction(
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
@@ -85,10 +86,13 @@ export async function completeVerification(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVerificationConfig>>) {
|
export function buildVerificationPanel(
|
||||||
|
config: Awaited<ReturnType<typeof getVerificationConfig>>,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
) {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('Verification')
|
.setTitle(t(locale, 'verification.panel.title'))
|
||||||
.setDescription('Click the button below to verify and gain access to the server.')
|
.setDescription(t(locale, 'verification.panel.description'))
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
|
|
||||||
const customId =
|
const customId =
|
||||||
@@ -99,7 +103,11 @@ export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVeri
|
|||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(customId)
|
.setCustomId(customId)
|
||||||
.setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify')
|
.setLabel(
|
||||||
|
config.mode === 'CAPTCHA'
|
||||||
|
? t(locale, 'verification.panel.buttonCaptcha')
|
||||||
|
: t(locale, 'verification.panel.button')
|
||||||
|
)
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,7 +127,7 @@ export async function handleVerificationButton(
|
|||||||
const config = await getVerificationConfig(context.prisma, parsed.guildId);
|
const config = await getVerificationConfig(context.prisma, parsed.guildId);
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.disabled'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,15 +137,16 @@ export async function handleVerificationButton(
|
|||||||
parsed.guildId,
|
parsed.guildId,
|
||||||
interaction.user.id
|
interaction.user.id
|
||||||
);
|
);
|
||||||
const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`;
|
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
|
||||||
|
const url = `${base}/verify/captcha?token=${challenge.token}`;
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'verification.captchaLink', { url }),
|
content: tf(locale, 'verification.captchaLink', { url }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply(ephemeral());
|
||||||
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
|
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await interaction.editReply({ content: t(locale, 'verification.success') });
|
await interaction.editReply({ content: t(locale, 'verification.success') });
|
||||||
|
|||||||
110
apps/webui/src/app/verify/captcha/route.ts
Normal file
110
apps/webui/src/app/verify/captcha/route.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import {
|
||||||
|
deleteCaptchaChallenge,
|
||||||
|
getCaptchaChallenge,
|
||||||
|
hashCaptchaAnswer
|
||||||
|
} from '@/lib/captcha';
|
||||||
|
import { getVerificationQueue } from '@/lib/queues';
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaptchaPage(token: string, question: string, error?: string): string {
|
||||||
|
const errorBlock = error ? `<p style="color:#ef4444">${escapeHtml(error)}</p>` : '';
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Nexumi Verification</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Inter, system-ui, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
|
||||||
|
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
|
||||||
|
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; box-sizing:border-box; }
|
||||||
|
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form method="POST" action="/verify/captcha">
|
||||||
|
<h1>Nexumi Verification</h1>
|
||||||
|
<p>Solve: <strong>${escapeHtml(question)} = ?</strong></p>
|
||||||
|
${errorBlock}
|
||||||
|
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
||||||
|
<input type="number" name="answer" required autofocus />
|
||||||
|
<button type="submit">Verify</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDonePage(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Nexumi Verified</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Inter, system-ui, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
|
||||||
|
div { text-align:center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<h1>Verified</h1>
|
||||||
|
<p>You can return to Discord.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function html(body: string, status = 200): NextResponse {
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request): Promise<NextResponse> {
|
||||||
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return new NextResponse('Missing token', { status: 400 });
|
||||||
|
}
|
||||||
|
const challenge = await getCaptchaChallenge(token);
|
||||||
|
if (!challenge) {
|
||||||
|
return new NextResponse('Captcha expired', { status: 404 });
|
||||||
|
}
|
||||||
|
return html(renderCaptchaPage(token, challenge.question));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
const form = await request.formData();
|
||||||
|
const token = String(form.get('token') ?? '');
|
||||||
|
const answer = String(form.get('answer') ?? '');
|
||||||
|
if (!token || !answer) {
|
||||||
|
return new NextResponse('Missing fields', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = await getCaptchaChallenge(token);
|
||||||
|
if (!challenge) {
|
||||||
|
return new NextResponse('Captcha expired', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
||||||
|
return html(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteCaptchaChallenge(token);
|
||||||
|
await getVerificationQueue().add(
|
||||||
|
'verificationComplete',
|
||||||
|
{ guildId: challenge.guildId, userId: challenge.userId },
|
||||||
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return html(renderDonePage());
|
||||||
|
}
|
||||||
32
apps/webui/src/lib/captcha.ts
Normal file
32
apps/webui/src/lib/captcha.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { redis } from './redis';
|
||||||
|
|
||||||
|
const CAPTCHA_PREFIX = 'verify:captcha:';
|
||||||
|
|
||||||
|
export type CaptchaChallenge = {
|
||||||
|
token: string;
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
question: string;
|
||||||
|
answerHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCaptchaChallenge(token: string): Promise<CaptchaChallenge | null> {
|
||||||
|
const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as CaptchaChallenge;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCaptchaChallenge(token: string): Promise<void> {
|
||||||
|
await redis.del(`${CAPTCHA_PREFIX}${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashCaptchaAnswer(answer: string): string {
|
||||||
|
return createHash('sha256').update(answer.trim()).digest('hex');
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export const scheduleQueueName = 'schedules';
|
|||||||
export const guildBackupQueueName = 'guild-backups';
|
export const guildBackupQueueName = 'guild-backups';
|
||||||
export const suggestionsQueueName = 'suggestions';
|
export const suggestionsQueueName = 'suggestions';
|
||||||
export const messagesQueueName = 'messages';
|
export const messagesQueueName = 'messages';
|
||||||
|
export const verificationQueueName = 'verification';
|
||||||
|
|
||||||
const globalForQueues = globalThis as unknown as {
|
const globalForQueues = globalThis as unknown as {
|
||||||
__nexumiGiveawayQueue?: Queue;
|
__nexumiGiveawayQueue?: Queue;
|
||||||
@@ -26,6 +27,7 @@ const globalForQueues = globalThis as unknown as {
|
|||||||
__nexumiGuildBackupQueue?: Queue;
|
__nexumiGuildBackupQueue?: Queue;
|
||||||
__nexumiSuggestionsQueue?: Queue;
|
__nexumiSuggestionsQueue?: Queue;
|
||||||
__nexumiMessagesQueue?: Queue;
|
__nexumiMessagesQueue?: Queue;
|
||||||
|
__nexumiVerificationQueue?: Queue;
|
||||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
@@ -76,6 +78,13 @@ export function getMessagesQueue(): Queue {
|
|||||||
return globalForQueues.__nexumiMessagesQueue;
|
return globalForQueues.__nexumiMessagesQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVerificationQueue(): Queue {
|
||||||
|
globalForQueues.__nexumiVerificationQueue ??= new Queue(verificationQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiVerificationQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ const de: Dictionary = {
|
|||||||
'verification.notConfigured': 'Verifizierung ist noch nicht eingerichtet. Nutze `/verify setup`.',
|
'verification.notConfigured': 'Verifizierung ist noch nicht eingerichtet. Nutze `/verify setup`.',
|
||||||
'verification.invalidChannel': 'Der Verifizierungskanal ist ungültig.',
|
'verification.invalidChannel': 'Der Verifizierungskanal ist ungültig.',
|
||||||
'verification.panelPosted': 'Verifizierungs-Panel gepostet.',
|
'verification.panelPosted': 'Verifizierungs-Panel gepostet.',
|
||||||
|
'verification.panelMissingPermission':
|
||||||
|
'Dem Bot fehlen Rechte in diesem Kanal (Kanal sehen, Nachrichten senden, Embeds).',
|
||||||
|
'verification.panel.title': 'Verifizierung',
|
||||||
|
'verification.panel.description':
|
||||||
|
'Klicke auf den Button unten, um dich zu verifizieren und Zugang zum Server zu erhalten.',
|
||||||
|
'verification.panel.button': 'Verifizieren',
|
||||||
|
'verification.panel.buttonCaptcha': 'Verifizieren (Captcha)',
|
||||||
'verification.captchaLink': 'Öffne diesen Link zur Captcha-Verifizierung: {url}',
|
'verification.captchaLink': 'Öffne diesen Link zur Captcha-Verifizierung: {url}',
|
||||||
'verification.error.not_configured': 'Verifizierung ist nicht konfiguriert.',
|
'verification.error.not_configured': 'Verifizierung ist nicht konfiguriert.',
|
||||||
'verification.error.member_not_found': 'Mitglied nicht gefunden.',
|
'verification.error.member_not_found': 'Mitglied nicht gefunden.',
|
||||||
@@ -749,6 +756,13 @@ const en: Dictionary = {
|
|||||||
'verification.notConfigured': 'Verification is not set up yet. Use `/verify setup`.',
|
'verification.notConfigured': 'Verification is not set up yet. Use `/verify setup`.',
|
||||||
'verification.invalidChannel': 'The verification channel is invalid.',
|
'verification.invalidChannel': 'The verification channel is invalid.',
|
||||||
'verification.panelPosted': 'Verification panel posted.',
|
'verification.panelPosted': 'Verification panel posted.',
|
||||||
|
'verification.panelMissingPermission':
|
||||||
|
'The bot lacks permissions in that channel (View Channel, Send Messages, Embed Links).',
|
||||||
|
'verification.panel.title': 'Verification',
|
||||||
|
'verification.panel.description':
|
||||||
|
'Click the button below to verify and gain access to the server.',
|
||||||
|
'verification.panel.button': 'Verify',
|
||||||
|
'verification.panel.buttonCaptcha': 'Verify (Captcha)',
|
||||||
'verification.captchaLink': 'Open this link to complete captcha verification: {url}',
|
'verification.captchaLink': 'Open this link to complete captcha verification: {url}',
|
||||||
'verification.error.not_configured': 'Verification is not configured.',
|
'verification.error.not_configured': 'Verification is not configured.',
|
||||||
'verification.error.member_not_found': 'Member not found.',
|
'verification.error.member_not_found': 'Member not found.',
|
||||||
|
|||||||
Reference in New Issue
Block a user