- Introduced a new logging event type `VERIFICATION_FAIL` to capture details of failed verification attempts, including reasons and actions taken. - Updated moderation commands to include reasons in success messages for actions like ban, kick, and timeout. - Enhanced the logging of ban and kick actions to include moderator information and reasons fetched from the audit log. - Improved localization for new logging messages in both English and German. - Documented changes in the phase tracking documentation to reflect the new logging capabilities and verification failure handling.
316 lines
9.2 KiB
TypeScript
316 lines
9.2 KiB
TypeScript
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',
|
|
'VERIFICATION_FAIL'
|
|
]);
|
|
export type LogEventType = z.infer<typeof LogEventTypeSchema>;
|
|
|
|
export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE', 'COMPONENTS_V2']);
|
|
export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>;
|
|
|
|
export const LeaveMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']);
|
|
export type LeaveMessageType = z.infer<typeof LeaveMessageTypeSchema>;
|
|
|
|
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<typeof WelcomeEmbedSchema>;
|
|
|
|
/** 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<typeof VerificationModeSchema>;
|
|
|
|
export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']);
|
|
export type VerificationFailAction = z.infer<typeof VerificationFailActionSchema>;
|
|
|
|
export const CaptchaProviderSchema = z.enum([
|
|
'MATH',
|
|
'RECAPTCHA_V2',
|
|
'RECAPTCHA_V3',
|
|
'HCAPTCHA',
|
|
'TURNSTILE'
|
|
]);
|
|
export type CaptchaProvider = z.infer<typeof CaptchaProviderSchema>;
|
|
|
|
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<typeof VerificationSetupSchema>;
|
|
|
|
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, string | number>
|
|
): 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}|<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;
|
|
}
|
|
}
|