diff --git a/src/features/index.ts b/src/features/index.ts index b89a060..d99e04a 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -4,6 +4,7 @@ import memberUpdateLogger from './memberUpdateLogger' import messagePruner from './messagePruner' import messageReporter from './messageReporter' import nameChecker from './nameChecker' +import nameCleansing from './nameCleansing' import nominations from './nominations' import ping from './ping' import preventEmojiSpam from './preventEmojiSpam' @@ -20,6 +21,7 @@ export default [ messagePruner, messageReporter, nameChecker, + nameCleansing, nominations, ping, preventEmojiSpam, diff --git a/src/features/nameCleansing/checkName.spec.ts b/src/features/nameCleansing/checkName.spec.ts new file mode 100644 index 0000000..add20e7 --- /dev/null +++ b/src/features/nameCleansing/checkName.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest' + +import { + checkDehoisted, + checkExclamationMark, + checkNameAgainstPatterns, + checkZalgo, +} from './checkName' + +test('zalgo', async () => { + expect(checkZalgo('T̴̤̓e̴͎͗ś̶̖t̴͈̓')).toBeTruthy() +}) + +test('zalgo with whitespace', async () => { + expect(checkZalgo('T̴͍̱̑̕ ̵̹̠͋ḛ̷͝ ̴̗̅͠s̵͖̤̀̍ ̵̗̉͗ṫ̴̙̚')).toBeTruthy() +}) + +test('dehoisted', async () => { + expect(checkDehoisted('! Test')).toBeTruthy() +}) + +test('exclamation mark', async () => { + expect(checkExclamationMark('!')).toBeTruthy() +}) + +test('Bad Name', async () => { + expect( + checkNameAgainstPatterns('examplebadname', [{ regexp: 'examplebadname' }]), + ).toBeTruthy() +}) diff --git a/src/features/nameCleansing/checkName.ts b/src/features/nameCleansing/checkName.ts new file mode 100644 index 0000000..89879e6 --- /dev/null +++ b/src/features/nameCleansing/checkName.ts @@ -0,0 +1,45 @@ +import { clean, isZalgo } from 'unzalgo' + +import { Logger } from '@/types/Logger' +import { RuntimeConfigurationSchema } from '@/utils/RuntimeConfigurationSchema' + +import { compiled } from './nameCleansing' + +type Pattern = RuntimeConfigurationSchema['nameChecker']['patterns'][number] + +export function checkNameAgainstPatterns( + name: string, + patterns: Pattern[], + log: Logger = console, +): boolean | undefined { + for (const pattern of patterns) { + try { + let regexp = compiled.get(pattern.regexp) + if (!regexp) { + regexp = new RegExp(pattern.regexp, 'i') + compiled.set(pattern.regexp, regexp) + } + if (regexp.test(name) || regexp.test(name.replaceAll(/\s+/g, ''))) { + return true + } + } catch (error) { + log.error( + `Unable to process pattern "${pattern}" against name "${name}"`, + error, + ) + } + } + return false +} + +export function checkZalgo(name: string) { + return isZalgo(name.trim()) +} + +export function checkDehoisted(name: string) { + return clean(name.trim()).startsWith('!') +} + +export function checkExclamationMark(name: string) { + return clean(name.trim()) === '!' +} diff --git a/src/features/nameCleansing/index.ts b/src/features/nameCleansing/index.ts new file mode 100644 index 0000000..c68c7f4 --- /dev/null +++ b/src/features/nameCleansing/index.ts @@ -0,0 +1,32 @@ +import { Events } from 'discord.js' + +import { definePlugin } from '@/types/definePlugin' + +import { checkName } from './nameCleansing' + +export default definePlugin({ + name: 'nameCleansing', + setup: (pluginContext) => { + pluginContext.addEventHandler({ + eventName: Events.GuildMemberAdd, + execute: async (botContext, member) => { + await checkName(member, botContext) + }, + }) + + pluginContext.addEventHandler({ + eventName: Events.GuildMemberUpdate, + execute: async (botContext, oldMember, newMember) => { + await checkName(newMember, botContext) + }, + }) + + pluginContext.addEventHandler({ + eventName: Events.MessageCreate, + execute: async (botContext, message) => { + if (!message.member) return + await checkName(message.member, botContext) + }, + }) + }, +}) diff --git a/src/features/nameCleansing/nameCleansing.ts b/src/features/nameCleansing/nameCleansing.ts new file mode 100644 index 0000000..7a2cffa --- /dev/null +++ b/src/features/nameCleansing/nameCleansing.ts @@ -0,0 +1,79 @@ +import { GuildMember } from 'discord.js' + +import { clean } from 'unzalgo' + +import { Environment } from '@/config' +import { BotContext } from '@/types/BotContext' + +import { + checkDehoisted, + checkExclamationMark, + checkNameAgainstPatterns, + checkZalgo, +} from './checkName' + +export const compiled = new Map() + +export async function checkName(member: GuildMember, botContext: BotContext) { + const { log, runtimeConfiguration } = botContext + const config = runtimeConfiguration.data.nameCleansing + const configNameList = runtimeConfiguration.data.nameChecker + if (member.guild.id !== Environment.GUILD_ID) return + + const { + enabled, + enabledCheckZalgo, + enabledCheckDehoisted, + enabledCheckExclamationMark, + enabledCheckBadName, + } = config + if (!enabled) { + return + } + + const { patterns } = configNameList + if (!patterns) { + return + } + + if (member.nickname === null) { + return + } + + if (checkZalgo(member.nickname) && enabledCheckZalgo) { + member + .setNickname(clean(member.nickname), 'Zalgo Name Detacted') + .catch((error) => { + log.error( + `Can't change ${member.nickname}'s name (${member.id})`, + error, + ) + }) + } + + if (checkDehoisted(member.nickname) && enabledCheckDehoisted) { + member.setNickname(null, 'Dehoisted Name Detacted').catch((error) => { + log.error(`Can't change ${member.nickname}'s name (${member.id})`, error) + }) + } + + if (checkExclamationMark(member.nickname) && enabledCheckExclamationMark) { + member + .setNickname(null, 'Exclamation Mark Name Detacted') + .catch((error) => { + log.error( + `Can't change ${member.nickname}'s name (${member.id})`, + error, + ) + }) + } + + if ( + checkNameAgainstPatterns(member.nickname, patterns, log) && + enabledCheckBadName + ) { + member.setNickname(null, 'Bad Name Detacted').catch((error) => { + log.error(`Can't change ${member.nickname}'s name (${member.id})`, error) + }) + } +} diff --git a/src/utils/RuntimeConfigurationSchema.ts b/src/utils/RuntimeConfigurationSchema.ts index eb14fe8..74f977c 100644 --- a/src/utils/RuntimeConfigurationSchema.ts +++ b/src/utils/RuntimeConfigurationSchema.ts @@ -10,6 +10,16 @@ export const RuntimeConfigurationSchema = z }) .default({}), + nameCleansing: z + .object({ + enabled: z.boolean().default(false), + enabledCheckZalgo: z.boolean().default(false), + enabledCheckDehoisted: z.boolean().default(false), + enabledCheckExclamationMark: z.boolean().default(false), + enabledCheckBadName: z.boolean().default(false), + }) + .default({}), + nominations: z .object({ enabledRoles: z