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; export const AutoModActionSchema = z.enum(['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN']); export type AutoModAction = z.infer; export const AutoModExceptionsSchema = z.object({ roleIds: z.array(z.string()).default([]), channelIds: z.array(z.string()).default([]) }); export type AutoModExceptions = z.infer; 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; export const AutoModWordListSchema = z.object({ words: z.array(z.string()).default([]), regexPatterns: z.array(z.string()).default([]) }); export type AutoModWordList = z.infer; 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', 'VERIFICATION_FAIL' ]); export type LogEventType = z.infer; export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE', 'COMPONENTS_V2']); export type WelcomeMessageType = z.infer; export const LeaveMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']); export type LeaveMessageType = z.infer; export const WelcomeEmbedSchema = z.object({ title: z.string().max(256).optional(), description: z.string().max(4096).optional(), /** Clickable title URL. */ url: z.string().max(2048).optional(), color: z.number().int().optional(), authorName: z.string().max(256).optional(), authorIconUrl: z.string().max(2048).optional(), authorUrl: z.string().max(2048).optional(), thumbnailUrl: z.string().max(2048).optional(), imageUrl: z.string().max(2048).optional(), footerText: z.string().max(2048).optional(), footerIconUrl: z.string().max(2048).optional(), /** When true, Discord shows the current time in the footer area. */ timestamp: z.boolean().optional() }); export type WelcomeEmbed = z.infer; /** True when the embed has any user-facing content beyond an optional color. */ export function embedHasContent(embed: WelcomeEmbed | null | undefined): boolean { if (!embed) { return false; } return Boolean( embed.title?.trim() || embed.description?.trim() || embed.url?.trim() || embed.authorName?.trim() || embed.authorIconUrl?.trim() || embed.authorUrl?.trim() || embed.thumbnailUrl?.trim() || embed.imageUrl?.trim() || embed.footerText?.trim() || embed.footerIconUrl?.trim() || embed.timestamp ); } /** Text/URL fields of {@link WelcomeEmbed} that pass through {@link mapEmbedTextFields}. */ export type EmbedTextField = | 'title' | 'description' | 'url' | 'authorName' | 'authorIconUrl' | 'authorUrl' | 'thumbnailUrl' | 'imageUrl' | 'footerText' | 'footerIconUrl'; /** * Applies a string renderer to every text/URL field of an embed payload. * Used by Welcome/Tags/Scheduler before handing data to discord.js. * The second argument names the field so callers can e.g. avoid Discord * mentions in title/author/footer (Discord does not resolve them there). */ export function mapEmbedTextFields( embed: WelcomeEmbed, render: (value: string, field: EmbedTextField) => string ): WelcomeEmbed { const map = (value: string | undefined, field: EmbedTextField): string | undefined => { if (!value?.trim()) { return undefined; } return render(value, field); }; return { title: map(embed.title, 'title'), description: map(embed.description, 'description'), url: map(embed.url, 'url'), color: embed.color, authorName: map(embed.authorName, 'authorName'), authorIconUrl: map(embed.authorIconUrl, 'authorIconUrl'), authorUrl: map(embed.authorUrl, 'authorUrl'), thumbnailUrl: map(embed.thumbnailUrl, 'thumbnailUrl'), imageUrl: map(embed.imageUrl, 'imageUrl'), footerText: map(embed.footerText, 'footerText'), footerIconUrl: map(embed.footerIconUrl, 'footerIconUrl'), timestamp: embed.timestamp }; } export const VerificationModeSchema = z.enum(['BUTTON', 'CAPTCHA']); export type VerificationMode = z.infer; export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']); export type VerificationFailAction = z.infer; export const CaptchaProviderSchema = z.enum([ 'MATH', 'RECAPTCHA_V2', 'RECAPTCHA_V3', 'HCAPTCHA', 'TURNSTILE' ]); export type CaptchaProvider = z.infer; export const VerificationSetupSchema = z.object({ channel: z.string().min(1), verifiedRole: z.string().min(1), unverifiedRole: z.string().optional(), mode: VerificationModeSchema.default('BUTTON'), captchaProvider: CaptchaProviderSchema.default('MATH'), minAccountAgeDays: z.number().int().nonnegative().default(0), failAction: VerificationFailActionSchema.default('KICK'), altDetectionEnabled: z.boolean().default(false), altBlockOnMatch: z.boolean().default(true), altIpWindowDays: z.number().int().min(1).max(365).default(30), altInviteWindowHours: z.number().int().min(1).max(720).default(48), altMaxAccountsPerInvite: z.number().int().min(1).max(50).default(3) }); export type VerificationSetup = z.infer; export const WELCOME_PLACEHOLDERS = [ '{user}', '{user.name}', '{user.tag}', '{user.id}', '{server}', '{memberCount}' ] as const; /** * Expands dashboard-friendly channel mentions `["123…"]` into Discord's * native `<#123…>` mention format. Raw `<#id>` mentions are left untouched. */ export function expandBracketChannelMentions(template: string): string { return template.replace(/\["(\d{17,20})"\]/g, '<#$1>'); } export function applyWelcomePlaceholders( template: string, vars: Record ): string { const withVars = Object.entries(vars).reduce( (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), template ); return expandBracketChannelMentions(withVars); } export function countEmojis(content: string): number { const emojiRegex = /(\p{Extended_Pictographic}|\p{Emoji_Presentation}|)/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; } }