Enhance bot functionality with AutoMod, Logging, Welcome, and Verification features
- Implemented AutoMod capabilities including spam filtering, anti-raid, and anti-nuke actions. - Added Logging module to track moderation actions and events across the bot. - Introduced Welcome module for customizable welcome messages and autoroles. - Developed Verification system with captcha and role assignment features. - Updated Prisma schema to include new models for AutoMod, Logging, Welcome, and Verification. - Enhanced command localization for new features in both German and English. - Improved health server to handle captcha verification requests. - Added new environment variable for public base URL to support captcha links.
This commit is contained in:
@@ -4,7 +4,8 @@ export const CaseAuditSourceSchema = z.enum([
|
||||
'slash_command',
|
||||
'button_confirmation',
|
||||
'escalation',
|
||||
'job'
|
||||
'job',
|
||||
'automod'
|
||||
]);
|
||||
|
||||
export type CaseAuditSource = z.infer<typeof CaseAuditSourceSchema>;
|
||||
|
||||
@@ -214,6 +214,66 @@ export const commandLocales = {
|
||||
'moderation.common.options.target': {
|
||||
de: 'Ziel',
|
||||
en: 'Target'
|
||||
},
|
||||
'automod.description': {
|
||||
de: 'Auto-Moderation verwalten',
|
||||
en: 'Manage auto-moderation'
|
||||
},
|
||||
'automod.status.description': {
|
||||
de: 'Aktive AutoMod-Regeln anzeigen',
|
||||
en: 'Show active AutoMod rules'
|
||||
},
|
||||
'welcome.description': {
|
||||
de: 'Willkommensnachrichten testen',
|
||||
en: 'Test welcome messages'
|
||||
},
|
||||
'welcome.test.description': {
|
||||
de: 'Test-Willkommensnachricht senden',
|
||||
en: 'Send a test welcome message'
|
||||
},
|
||||
'welcome.preview.description': {
|
||||
de: 'Vorschau der Willkommensnachricht',
|
||||
en: 'Preview the welcome message'
|
||||
},
|
||||
'verify.description': {
|
||||
de: 'Verifizierung einrichten',
|
||||
en: 'Configure verification'
|
||||
},
|
||||
'verify.setup.description': {
|
||||
de: 'Verifizierung konfigurieren',
|
||||
en: 'Configure verification'
|
||||
},
|
||||
'verify.setup.options.channel': {
|
||||
de: 'Verifizierungskanal',
|
||||
en: 'Verification channel'
|
||||
},
|
||||
'verify.setup.options.verified_role': {
|
||||
de: 'Rolle nach Verifizierung',
|
||||
en: 'Role after verification'
|
||||
},
|
||||
'verify.setup.options.unverified_role': {
|
||||
de: 'Rolle vor Verifizierung',
|
||||
en: 'Role before verification'
|
||||
},
|
||||
'verify.setup.options.mode': {
|
||||
de: 'Verifizierungsmodus',
|
||||
en: 'Verification mode'
|
||||
},
|
||||
'verify.setup.options.min_account_age_days': {
|
||||
de: 'Mindest-Accountalter in Tagen',
|
||||
en: 'Minimum account age in days'
|
||||
},
|
||||
'verify.setup.options.fail_action': {
|
||||
de: 'Aktion bei Fehlschlag',
|
||||
en: 'Action on failure'
|
||||
},
|
||||
'verify.panel.description': {
|
||||
de: 'Verifizierungs-Panel posten',
|
||||
en: 'Post verification panel'
|
||||
},
|
||||
'verify.panel.options.channel': {
|
||||
de: 'Optionaler Kanal für das Panel',
|
||||
en: 'Optional channel for the panel'
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
export * from './audit.js';
|
||||
export * from './command-locales.js';
|
||||
export * from './discord-localization.js';
|
||||
export * from './phase2.js';
|
||||
|
||||
export const LocaleSchema = z.enum(['de', 'en']);
|
||||
export type Locale = z.infer<typeof LocaleSchema>;
|
||||
@@ -66,7 +67,32 @@ const de: Dictionary = {
|
||||
'moderation.confirm.caseDelete': 'Fall #{caseNumber} wirklich löschen?',
|
||||
'moderation.confirm.expired': 'Bestätigung abgelaufen. Bitte den Befehl erneut ausführen.',
|
||||
'moderation.confirm.cancelled': 'Aktion abgebrochen.',
|
||||
'moderation.confirm.unauthorized': 'Nur der ausführende Moderator kann diese Bestätigung nutzen.'
|
||||
'moderation.confirm.unauthorized': 'Nur der ausführende Moderator kann diese Bestätigung nutzen.',
|
||||
'automod.status.header':
|
||||
'**AutoMod enabled:** {enabled}\n**Anti-raid:** {antiRaid}\n**Anti-nuke:** {antiNuke}\n**Lockdown:** {lockdown}',
|
||||
'automod.status.ruleLine': '- {type} → {action}',
|
||||
'automod.status.noRules': 'Keine aktiven Regeln.',
|
||||
'automod.status.yes': 'ja',
|
||||
'automod.status.no': 'nein',
|
||||
'welcome.notConfigured': 'Willkommensnachricht ist noch nicht konfiguriert.',
|
||||
'welcome.invalidChannel': 'Der konfigurierte Willkommenskanal ist ungültig.',
|
||||
'welcome.testSent': 'Test-Willkommensnachricht gesendet.',
|
||||
'verification.disabled': 'Verifizierung ist deaktiviert.',
|
||||
'verification.success': 'Du wurdest erfolgreich verifiziert.',
|
||||
'verification.setupDone': 'Verifizierung konfiguriert.',
|
||||
'verification.notConfigured': 'Verifizierung ist noch nicht eingerichtet. Nutze `/verify setup`.',
|
||||
'verification.invalidChannel': 'Der Verifizierungskanal ist ungültig.',
|
||||
'verification.panelPosted': 'Verifizierungs-Panel gepostet.',
|
||||
'verification.captchaLink': 'Öffne diesen Link zur Captcha-Verifizierung: {url}',
|
||||
'verification.error.not_configured': 'Verifizierung ist nicht konfiguriert.',
|
||||
'verification.error.member_not_found': 'Mitglied nicht gefunden.',
|
||||
'verification.error.account_too_young': 'Dein Account ist zu jung für die Verifizierung.',
|
||||
'verification.error.missing_permissions': 'Dem Bot fehlen Berechtigungen.',
|
||||
'verification.error.invalid_role': 'Die Verifizierungsrolle ist ungültig.',
|
||||
'verification.error.generic': 'Verifizierung fehlgeschlagen.',
|
||||
'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.',
|
||||
'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.',
|
||||
'verification.captcha.title': 'Nexumi Verifizierung'
|
||||
};
|
||||
const en: Dictionary = {
|
||||
'generic.noPermission': 'You do not have permission for this action.',
|
||||
@@ -97,7 +123,32 @@ const en: Dictionary = {
|
||||
'moderation.confirm.caseDelete': 'Really delete case #{caseNumber}?',
|
||||
'moderation.confirm.expired': 'Confirmation expired. Please run the command again.',
|
||||
'moderation.confirm.cancelled': 'Action cancelled.',
|
||||
'moderation.confirm.unauthorized': 'Only the moderator who started this action can confirm it.'
|
||||
'moderation.confirm.unauthorized': 'Only the moderator who started this action can confirm it.',
|
||||
'automod.status.header':
|
||||
'**AutoMod enabled:** {enabled}\n**Anti-raid:** {antiRaid}\n**Anti-nuke:** {antiNuke}\n**Lockdown:** {lockdown}',
|
||||
'automod.status.ruleLine': '- {type} → {action}',
|
||||
'automod.status.noRules': 'No active rules.',
|
||||
'automod.status.yes': 'yes',
|
||||
'automod.status.no': 'no',
|
||||
'welcome.notConfigured': 'Welcome message is not configured yet.',
|
||||
'welcome.invalidChannel': 'The configured welcome channel is invalid.',
|
||||
'welcome.testSent': 'Test welcome message sent.',
|
||||
'verification.disabled': 'Verification is disabled.',
|
||||
'verification.success': 'You have been verified successfully.',
|
||||
'verification.setupDone': 'Verification configured.',
|
||||
'verification.notConfigured': 'Verification is not set up yet. Use `/verify setup`.',
|
||||
'verification.invalidChannel': 'The verification channel is invalid.',
|
||||
'verification.panelPosted': 'Verification panel posted.',
|
||||
'verification.captchaLink': 'Open this link to complete captcha verification: {url}',
|
||||
'verification.error.not_configured': 'Verification is not configured.',
|
||||
'verification.error.member_not_found': 'Member not found.',
|
||||
'verification.error.account_too_young': 'Your account is too young to verify.',
|
||||
'verification.error.missing_permissions': 'Bot lacks permissions.',
|
||||
'verification.error.invalid_role': 'Verification role is invalid.',
|
||||
'verification.error.generic': 'Verification failed.',
|
||||
'verification.captcha.success': 'Captcha passed. You have been verified.',
|
||||
'verification.captcha.invalid': 'Captcha invalid or expired.',
|
||||
'verification.captcha.title': 'Nexumi Verification'
|
||||
};
|
||||
|
||||
const locales: Record<Locale, Dictionary> = { de, en };
|
||||
|
||||
42
packages/shared/src/phase2.test.ts
Normal file
42
packages/shared/src/phase2.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyWelcomePlaceholders,
|
||||
capsRatio,
|
||||
countEmojis,
|
||||
hasZalgo,
|
||||
isDiscordInvite,
|
||||
normalizeHost
|
||||
} from './phase2.js';
|
||||
|
||||
describe('phase2 helpers', () => {
|
||||
it('replaces welcome placeholders', () => {
|
||||
const result = applyWelcomePlaceholders('Hello {user} on {server}', {
|
||||
user: '<@1>',
|
||||
server: 'Nexumi'
|
||||
});
|
||||
expect(result).toBe('Hello <@1> on Nexumi');
|
||||
});
|
||||
|
||||
it('detects discord invites', () => {
|
||||
expect(isDiscordInvite('join https://discord.gg/test')).toBe(true);
|
||||
expect(isDiscordInvite('hello world')).toBe(false);
|
||||
});
|
||||
|
||||
it('calculates caps ratio', () => {
|
||||
expect(capsRatio('HELLO WORLD')).toBeGreaterThan(50);
|
||||
expect(capsRatio('hello')).toBe(0);
|
||||
});
|
||||
|
||||
it('counts emojis', () => {
|
||||
expect(countEmojis('hello 😀😀')).toBe(2);
|
||||
});
|
||||
|
||||
it('detects zalgo text', () => {
|
||||
expect(hasZalgo('h\u0305e\u0305l\u0305l\u0305o\u0305')).toBe(true);
|
||||
expect(hasZalgo('hello')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes hostnames', () => {
|
||||
expect(normalizeHost('https://www.Example.com/path')).toBe('example.com');
|
||||
});
|
||||
});
|
||||
211
packages/shared/src/phase2.ts
Normal file
211
packages/shared/src/phase2.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AutoModRuleTypeSchema = z.enum([
|
||||
'SPAM',
|
||||
'MASS_MENTION',
|
||||
'CAPS',
|
||||
'INVITE_LINK',
|
||||
'EXTERNAL_LINK',
|
||||
'WORD_FILTER',
|
||||
'DUPLICATE',
|
||||
'EMOJI_SPAM',
|
||||
'ZALGO',
|
||||
'PHISHING'
|
||||
]);
|
||||
export type AutoModRuleType = z.infer<typeof AutoModRuleTypeSchema>;
|
||||
|
||||
export const AutoModActionSchema = z.enum(['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN']);
|
||||
export type AutoModAction = z.infer<typeof AutoModActionSchema>;
|
||||
|
||||
export const AutoModExceptionsSchema = z.object({
|
||||
roleIds: z.array(z.string()).default([]),
|
||||
channelIds: z.array(z.string()).default([])
|
||||
});
|
||||
export type AutoModExceptions = z.infer<typeof AutoModExceptionsSchema>;
|
||||
|
||||
export const AutoModThresholdSchema = z.object({
|
||||
maxMentions: z.number().int().positive().optional(),
|
||||
capsPercent: z.number().min(0).max(100).optional(),
|
||||
minLength: z.number().int().nonnegative().optional(),
|
||||
maxMessages: z.number().int().positive().optional(),
|
||||
windowSeconds: z.number().int().positive().optional(),
|
||||
maxEmojis: z.number().int().positive().optional(),
|
||||
duplicateCount: z.number().int().positive().optional(),
|
||||
linkWhitelist: z.array(z.string()).optional(),
|
||||
linkBlacklist: z.array(z.string()).optional()
|
||||
});
|
||||
export type AutoModThreshold = z.infer<typeof AutoModThresholdSchema>;
|
||||
|
||||
export const AutoModWordListSchema = z.object({
|
||||
words: z.array(z.string()).default([]),
|
||||
regexPatterns: z.array(z.string()).default([])
|
||||
});
|
||||
export type AutoModWordList = z.infer<typeof AutoModWordListSchema>;
|
||||
|
||||
export const DEFAULT_AUTOMOD_RULES: Array<{
|
||||
ruleType: AutoModRuleType;
|
||||
action: AutoModAction;
|
||||
threshold?: AutoModThreshold;
|
||||
wordList?: AutoModWordList;
|
||||
}> = [
|
||||
{
|
||||
ruleType: 'SPAM',
|
||||
action: 'DELETE',
|
||||
threshold: { maxMessages: 5, windowSeconds: 5 }
|
||||
},
|
||||
{
|
||||
ruleType: 'MASS_MENTION',
|
||||
action: 'DELETE',
|
||||
threshold: { maxMentions: 5 }
|
||||
},
|
||||
{
|
||||
ruleType: 'CAPS',
|
||||
action: 'DELETE',
|
||||
threshold: { capsPercent: 70, minLength: 10 }
|
||||
},
|
||||
{
|
||||
ruleType: 'INVITE_LINK',
|
||||
action: 'DELETE'
|
||||
},
|
||||
{
|
||||
ruleType: 'EXTERNAL_LINK',
|
||||
action: 'DELETE',
|
||||
threshold: { linkWhitelist: ['discord.com', 'discord.gg'] }
|
||||
},
|
||||
{
|
||||
ruleType: 'WORD_FILTER',
|
||||
action: 'DELETE',
|
||||
wordList: { words: [], regexPatterns: [] }
|
||||
},
|
||||
{
|
||||
ruleType: 'DUPLICATE',
|
||||
action: 'DELETE',
|
||||
threshold: { duplicateCount: 3, windowSeconds: 30 }
|
||||
},
|
||||
{
|
||||
ruleType: 'EMOJI_SPAM',
|
||||
action: 'DELETE',
|
||||
threshold: { maxEmojis: 10 }
|
||||
},
|
||||
{
|
||||
ruleType: 'ZALGO',
|
||||
action: 'DELETE'
|
||||
},
|
||||
{
|
||||
ruleType: 'PHISHING',
|
||||
action: 'DELETE'
|
||||
}
|
||||
];
|
||||
|
||||
export const LogEventTypeSchema = z.enum([
|
||||
'MESSAGE_EDIT',
|
||||
'MESSAGE_DELETE',
|
||||
'MESSAGE_BULK_DELETE',
|
||||
'MEMBER_JOIN',
|
||||
'MEMBER_LEAVE',
|
||||
'MEMBER_BAN',
|
||||
'MEMBER_UNBAN',
|
||||
'MEMBER_UPDATE',
|
||||
'CHANNEL_CREATE',
|
||||
'CHANNEL_DELETE',
|
||||
'CHANNEL_UPDATE',
|
||||
'VOICE_JOIN',
|
||||
'VOICE_LEAVE',
|
||||
'VOICE_MOVE',
|
||||
'INVITE_CREATE',
|
||||
'EMOJI_CREATE',
|
||||
'EMOJI_UPDATE',
|
||||
'EMOJI_DELETE',
|
||||
'STICKER_CREATE',
|
||||
'STICKER_UPDATE',
|
||||
'STICKER_DELETE',
|
||||
'THREAD_CREATE',
|
||||
'THREAD_UPDATE',
|
||||
'THREAD_DELETE',
|
||||
'GUILD_UPDATE',
|
||||
'MOD_ACTION'
|
||||
]);
|
||||
export type LogEventType = z.infer<typeof LogEventTypeSchema>;
|
||||
|
||||
export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
|
||||
export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>;
|
||||
|
||||
export const WelcomeEmbedSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
color: z.number().int().optional()
|
||||
});
|
||||
export type WelcomeEmbed = z.infer<typeof WelcomeEmbedSchema>;
|
||||
|
||||
export const VerificationModeSchema = z.enum(['BUTTON', 'CAPTCHA']);
|
||||
export type VerificationMode = z.infer<typeof VerificationModeSchema>;
|
||||
|
||||
export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']);
|
||||
export type VerificationFailAction = z.infer<typeof VerificationFailActionSchema>;
|
||||
|
||||
export const VerificationSetupSchema = z.object({
|
||||
channel: z.string().min(1),
|
||||
verifiedRole: z.string().min(1),
|
||||
unverifiedRole: z.string().optional(),
|
||||
mode: VerificationModeSchema.default('BUTTON'),
|
||||
minAccountAgeDays: z.number().int().nonnegative().default(0),
|
||||
failAction: VerificationFailActionSchema.default('KICK')
|
||||
});
|
||||
export type VerificationSetup = z.infer<typeof VerificationSetupSchema>;
|
||||
|
||||
export const WELCOME_PLACEHOLDERS = [
|
||||
'{user}',
|
||||
'{user.name}',
|
||||
'{user.tag}',
|
||||
'{user.id}',
|
||||
'{server}',
|
||||
'{memberCount}'
|
||||
] as const;
|
||||
|
||||
export function applyWelcomePlaceholders(
|
||||
template: string,
|
||||
vars: Record<string, string | number>
|
||||
): string {
|
||||
return Object.entries(vars).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
template
|
||||
);
|
||||
}
|
||||
|
||||
export function countEmojis(content: string): number {
|
||||
const emojiRegex =
|
||||
/(\p{Extended_Pictographic}|\p{Emoji_Presentation}|<a?:\w+:\d+>)/gu;
|
||||
return content.match(emojiRegex)?.length ?? 0;
|
||||
}
|
||||
|
||||
export function capsRatio(content: string): number {
|
||||
const letters = content.replace(/[^a-zA-Z]/g, '');
|
||||
if (letters.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const caps = letters.replace(/[^A-Z]/g, '').length;
|
||||
return (caps / letters.length) * 100;
|
||||
}
|
||||
|
||||
export function hasZalgo(text: string): boolean {
|
||||
return /[\u0300-\u036f\u0489]/.test(text);
|
||||
}
|
||||
|
||||
export function extractUrls(content: string): string[] {
|
||||
const urlRegex = /https?:\/\/[^\s<>]+/gi;
|
||||
return content.match(urlRegex) ?? [];
|
||||
}
|
||||
|
||||
export function isDiscordInvite(content: string): boolean {
|
||||
return /(?:https?:\/\/)?(?:www\.)?(?:discord\.(?:gg|io|me|li)|discordapp\.com\/invite|discord\.com\/invite)\/[^\s]+/i.test(
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeHost(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '').toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user