diff --git a/src/main/java/com/lambda/mixin/render/ChatHudMixin.java b/src/main/java/com/lambda/mixin/render/ChatHudMixin.java index 025b9f849..890624a8d 100644 --- a/src/main/java/com/lambda/mixin/render/ChatHudMixin.java +++ b/src/main/java/com/lambda/mixin/render/ChatHudMixin.java @@ -17,15 +17,15 @@ package com.lambda.mixin.render; -import com.lambda.module.modules.client.LambdaMoji; +import com.lambda.event.EventFlow; +import com.lambda.event.events.ChatEvent; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; -import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; -import net.minecraft.client.font.TextRenderer; -import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.hud.ChatHud; -import net.minecraft.text.OrderedText; +import net.minecraft.client.gui.hud.MessageIndicator; +import net.minecraft.network.message.MessageSignatureData; +import net.minecraft.text.Text; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; @Mixin(ChatHud.class) public class ChatHudMixin { @@ -41,4 +41,12 @@ public class ChatHudMixin { // int wrapRenderCall(DrawContext instance, TextRenderer textRenderer, OrderedText text, int x, int y, int color, Operation original) { // return original.call(instance, textRenderer, LambdaMoji.INSTANCE.parse(text, x, y, color), 0, y, 16777215 + (color << 24)); // } + + @WrapMethod(method = "addMessage(Lnet/minecraft/text/Text;Lnet/minecraft/network/message/MessageSignatureData;Lnet/minecraft/client/gui/hud/MessageIndicator;)V") + void wrapAddMessage(Text message, MessageSignatureData signatureData, MessageIndicator indicator, Operation original) { + var event = new ChatEvent.Message(message, signatureData, indicator); + + if (!EventFlow.post(event).isCanceled()) + original.call(event.getMessage(), event.getSignature(), event.getIndicator()); + } } diff --git a/src/main/kotlin/com/lambda/config/groups/ReplaceConfig.kt b/src/main/kotlin/com/lambda/config/groups/ReplaceConfig.kt new file mode 100644 index 000000000..f67407d17 --- /dev/null +++ b/src/main/kotlin/com/lambda/config/groups/ReplaceConfig.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.config.groups + +import com.lambda.util.Describable + +interface ReplaceConfig { + val action: ActionStrategy + val replace: ReplaceStrategy + + val enabled: Boolean get() = action != ActionStrategy.None + + enum class ActionStrategy(override val description: String) : Describable { + Hide("Hides the message. Will override other strategies."), + Delete("Deletes the matching part off the message."), + Replace("Replace the matching string in the message with one of the following replace strategy."), + None("Don't do anything."), + } + + enum class ReplaceStrategy(val block: (String) -> String) { + CensorAll({ it.replaceRange(0.. if (i % 2 == 0) acc + char else "$acc*" } }), + KeepFirst({ if (it.length <= 1) it else it.replaceRange(1, it.length, "*".repeat(it.length - 1)) }), + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/event/events/ChatEvent.kt b/src/main/kotlin/com/lambda/event/events/ChatEvent.kt new file mode 100644 index 000000000..ed40475b7 --- /dev/null +++ b/src/main/kotlin/com/lambda/event/events/ChatEvent.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.event.events + +import com.lambda.event.Event +import com.lambda.event.callback.Cancellable +import net.minecraft.client.gui.hud.MessageIndicator +import net.minecraft.network.message.MessageSignatureData +import net.minecraft.text.Text + +sealed class ChatEvent { + class Message( + var message: Text, + var signature: MessageSignatureData?, + var indicator: MessageIndicator?, + ) : Event, Cancellable() +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/module/modules/chat/AntiSpam.kt b/src/main/kotlin/com/lambda/module/modules/chat/AntiSpam.kt new file mode 100644 index 000000000..7a9a9756f --- /dev/null +++ b/src/main/kotlin/com/lambda/module/modules/chat/AntiSpam.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.module.modules.chat + +import com.lambda.config.Configurable +import com.lambda.config.SettingGroup +import com.lambda.config.applyEdits +import com.lambda.config.groups.ReplaceConfig +import com.lambda.event.events.ChatEvent +import com.lambda.event.listener.SafeListener.Companion.listen +import com.lambda.friend.FriendManager +import com.lambda.module.Module +import com.lambda.module.tag.ModuleTag +import com.lambda.util.ChatUtils.addresses +import com.lambda.util.ChatUtils.colors +import com.lambda.util.ChatUtils.discord +import com.lambda.util.ChatUtils.hex +import com.lambda.util.ChatUtils.sexual +import com.lambda.util.ChatUtils.slurs +import com.lambda.util.ChatUtils.swears +import com.lambda.util.ChatUtils.toAscii +import com.lambda.util.NamedEnum +import com.lambda.util.text.MessageDirection +import com.lambda.util.text.MessageParser +import com.lambda.util.text.MessageType +import net.minecraft.text.Text + +object AntiSpam : Module( + name = "AntiSpam", + description = "Keeps your chat clean", + tag = ModuleTag.CHAT, +) { + private val fancyChats by setting("Replace Fancy Chat", false) + + private val filterSelf by setting("Ignore Self", true) + private val filterFriends by setting("Ignore Friends", true) + private val filterSystem by setting("Filter System Messages", false) + private val filterDms by setting("Filter DMs", true) + + private val ignoreSystem by setting("Ignore System", false) + private val ignoreDms by setting("Ignore DMs", false) + + private val detectSlurs = ReplaceSettings("Slurs", this, Group.Slurs) + private val detectSwears = ReplaceSettings("Swears", this, Group.Swears) + private val detectSexual = ReplaceSettings("Sexual", this, Group.Sexual) + private val detectDiscord = ReplaceSettings("Discord", this, Group.Discord) + .apply { applyEdits { editTyped(::action) { defaultValue(ReplaceConfig.ActionStrategy.Hide) } } } + private val detectAddresses = ReplaceSettings("Addresses", this, Group.Addresses) + .apply { applyEdits { editTyped(::action) { defaultValue(ReplaceConfig.ActionStrategy.Hide) } } } + private val detectHexBypass = ReplaceSettings("Hex", this, Group.Hex) + .apply { applyEdits { editTyped(::action) { defaultValue(ReplaceConfig.ActionStrategy.Hide) } } } + private val detectColors = ReplaceSettings("Colors", this, Group.Colors) + .apply { applyEdits { editTyped(::action) { defaultValue(ReplaceConfig.ActionStrategy.None) } } } + + enum class Group(override val displayName: String) : NamedEnum { + General("General"), + Slurs("Slurs"), + Swears("Swears"), + Sexual("Sexual"), + Discord("Discord Invites"), + Addresses("IPs and Addresses"), + Hex("Hex Bypass"), + Colors("Color Prefixes") + } + + init { + listen { event -> + var raw = event.message.string + val author = MessageParser.playerName(raw) + + if ( + ignoreSystem && !MessageType.Both.matches(raw) && !MessageDirection.Both.matches(raw) || + ignoreDms && MessageDirection.Receive.matches(raw) + ) return@listen + + val slurMatches = slurs.takeIf { detectSlurs.enabled }.orEmpty().flatMap { it.findAll(raw).toList().reversed() } + val swearMatches = swears.takeIf { detectSwears.enabled }.orEmpty().flatMap { it.findAll(raw).toList().reversed() } + val sexualMatches = sexual.takeIf { detectSexual.enabled }.orEmpty().flatMap { it.findAll(raw).toList().reversed() } + val discordMatches = discord.takeIf { detectDiscord.enabled }.orEmpty().flatMap { it.findAll(raw).toList().reversed() } + val addressMatches = addresses.takeIf { detectAddresses.enabled }.orEmpty().flatMap { it.findAll(raw).toList().reversed() } + val hexMatches = hex.takeIf { detectHexBypass.enabled }.orEmpty().flatMap { it.findAll(raw).toList().reversed() } + val colorMatches = colors.takeIf { detectColors.enabled }.orEmpty().flatMap { it.findAll(raw).toList().reversed() } + + var cancelled = false + var hasMatches = false + + fun doMatch(replace: ReplaceConfig, matches: Sequence) { + if ( + cancelled || + filterSystem && !MessageType.Both.matches(raw) && !MessageDirection.Both.matches(raw) || + filterDms && MessageDirection.Receive.matches(raw) || + filterFriends && author?.let { FriendManager.isFriend(it) } == true || + filterSelf && MessageType.Self.matches(raw) + ) return + + when (replace.action) { + ReplaceConfig.ActionStrategy.Hide -> matches.firstOrNull()?.let { event.cancel(); cancelled = true } // If there's one detection, nuke the whole damn thang + ReplaceConfig.ActionStrategy.Delete -> matches + .forEach { raw = raw.replaceRange(it.range, ""); hasMatches = true } + ReplaceConfig.ActionStrategy.Replace -> matches + .forEach { raw = raw.replaceRange(it.range, replace.replace.block(it.value)); hasMatches = true } + ReplaceConfig.ActionStrategy.None -> {} + } + } + + doMatch(detectSlurs, slurMatches) + doMatch(detectSwears, swearMatches) + doMatch(detectSexual, sexualMatches) + doMatch(detectDiscord, discordMatches) + doMatch(detectAddresses, addressMatches) + doMatch(detectHexBypass, hexMatches) + doMatch(detectColors, colorMatches) + + if (cancelled) return@listen event.cancel() + if (!hasMatches) return@listen + + event.message = Text.of(if (fancyChats) raw.toAscii else raw) + } + } + + class ReplaceSettings( + name: String, + c: Configurable, + baseGroup: NamedEnum, + ) : ReplaceConfig, SettingGroup(c) { + override val action by setting("$name Action Strategy", ReplaceConfig.ActionStrategy.Replace).group(baseGroup) + override val replace by setting("$name Replace Strategy", ReplaceConfig.ReplaceStrategy.CensorAll) { action == ReplaceConfig.ActionStrategy.Replace }.group(baseGroup) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt b/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt index 6124b260c..499f56d07 100644 --- a/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt +++ b/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt @@ -40,12 +40,13 @@ data class ModuleTag(override val name: String) : Nameable { val MOVEMENT = ModuleTag("Movement") val RENDER = ModuleTag("Render") val PLAYER = ModuleTag("Player") + val CHAT = ModuleTag("Chat") val CLIENT = ModuleTag("Client") val NETWORK = ModuleTag("Network") val DEBUG = ModuleTag("Debug") val HUD = ModuleTag("Hud") - val defaults = setOf(COMBAT, MOVEMENT, RENDER, PLAYER, NETWORK, CLIENT, HUD) + val defaults = setOf(COMBAT, MOVEMENT, RENDER, PLAYER, NETWORK, CHAT, CLIENT, HUD) val shownTags = defaults.toMutableSet() diff --git a/src/main/kotlin/com/lambda/util/ChatUtils.kt b/src/main/kotlin/com/lambda/util/ChatUtils.kt new file mode 100644 index 000000000..e07726e7c --- /dev/null +++ b/src/main/kotlin/com/lambda/util/ChatUtils.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.util + +object ChatUtils { + val slurs = sequenceOf("\\bch[i1l]nks?\\b", "\\bc[o0]{2}ns?\\b", "f[a@4](g{1,2}|qq)([e3il1o0]t{1,2}(ry|r[i1l]e)?)?\\b", "\\bk[il1y]k[e3](ry|r[i1l]e)?s?\\b", "\\b(s[a4]nd)?n[ila4o10][gq]{1,2}(l[e3]t|[e3]r|[a4]|n[o0]g)?s?\\b", "\\btr[a4]n{1,2}([il1][e3]|y|[e3]r)s?\\b").map { Regex(it, RegexOption.IGNORE_CASE) } + val swears = sequenceOf("fuck(er)?", "shit", "cunt", "puss(ie|y)", "bitch", "twat").map { Regex(it, RegexOption.IGNORE_CASE) } + val sexual = sequenceOf("^cum[s\\$]?$", "cumm?[i1]ng", "h[o0]rny", "mast(e|ur)b(8|ait|ate)").map { Regex(it, RegexOption.IGNORE_CASE) } + val discord = sequenceOf("(http(s)?:\\/\\/)?(discord)?(\\.)?gg(\\/| ).\\S{1,25}", "(http(s)?:\\/\\/)?(discord)?(\\.)?com\\/invite(\\/| ).\\S{1,25}", "(dsc)?(\\.)?gg(\\/| ).\\S{1,25}").map { Regex(it, RegexOption.IGNORE_CASE) } + val addresses = sequenceOf("^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}(:\\d{1,5}$)?", "^(\\[?)(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?(\\])?(:\\d{1,5}$)?$").map { Regex(it, RegexOption.IGNORE_CASE) } + val hex = sequenceOf("\\s([A-Fa-f0-9]+){5,10}$").map { Regex(it) } + val colors = sequenceOf(">", "`").map { Regex(it) } + + val fancyToAscii = mapOf( + '!' to '!', + '"' to '"', + '#' to '#', + '$' to '$', + '%' to '%', + '&' to '&', + ''' to '\'', + '(' to '(', + ')' to ')', + '*' to '*', + '+' to '+', + ',' to ',', + '-' to '-', + '.' to '.', + '/' to '/', + '\' to '\\', + '|' to '|', + ':' to ':', + ';' to ';', + '<' to '<', + '=' to '=', + '>' to '>', + '?' to '?', + '@' to '@', + '[' to '[', + ']' to ']', + '{' to '{', + '}' to '}', + '~' to '~', + '^' to '^', + '_' to '_', + '`' to '`', + '0' to '0', + '1' to '1', + '2' to '2', + '3' to '3', + '4' to '4', + '5' to '5', + '6' to '6', + '7' to '7', + '8' to '8', + '9' to '9', + 'A' to 'A', + 'B' to 'B', + 'C' to 'C', + 'D' to 'D', + 'E' to 'E', + 'F' to 'F', + 'G' to 'G', + 'H' to 'H', + 'I' to 'I', + 'J' to 'J', + 'K' to 'K', + 'L' to 'L', + 'M' to 'M', + 'N' to 'N', + 'O' to 'O', + 'P' to 'P', + 'Q' to 'Q', + 'R' to 'R', + 'S' to 'S', + 'T' to 'T', + 'U' to 'U', + 'V' to 'V', + 'W' to 'W', + 'X' to 'X', + 'Y' to 'Y', + 'Z' to 'Z', + 'ᴀ' to 'a', + 'ʙ' to 'b', + 'c' to 'c', + 'ᴅ' to 'd', + 'ᴇ' to 'e', + 'ꜰ' to 'f', + 'ɢ' to 'g', + 'ʜ' to 'h', + 'ɪ' to 'i', + 'ᴊ' to 'j', + 'ᴋ' to 'k', + 'ʟ' to 'l', + 'ᴍ' to 'm', + 'ɴ' to 'n', + 'ᴏ' to 'o', + 'ᴩ' to 'p', + 'q' to 'q', + 'ʀ' to 'r', + 'ꜱ' to 's', + 'ᴛ' to 't', + 'ᴜ' to 'u', + 'ᴠ' to 'v', + 'ᴡ' to 'w', + 'x' to 'x', + 'y' to 'y', + 'ᴢ' to 'z', + 'a' to 'a', + 'b' to 'b', + 'c' to 'c', + 'd' to 'd', + 'e' to 'e', + 'f' to 'f', + 'g' to 'g', + 'h' to 'h', + 'i' to 'i', + 'j' to 'j', + 'k' to 'k', + 'l' to 'l', + 'm' to 'm', + 'n' to 'n', + 'o' to 'o', + 'p' to 'p', + 'q' to 'q', + 'r' to 'r', + 's' to 's', + 't' to 't', + 'u' to 'u', + 'v' to 'v', + 'w' to 'w', + 'x' to 'x', + 'y' to 'y', + 'z' to 'z', + ) + val asciiToFancy = fancyToAscii.entries.associate { (key, value) -> value to key } + val asciiToLeet = mapOf('a' to '4', 'e' to '3', 'g' to '6', 'l' to '1', 'i' to '1', 'o' to '0', 's' to '$', 't' to '7') + + val String.toFancy get() = buildString { this@toFancy.forEach { append(asciiToFancy.getOrDefault(it, it)) } } + val String.toAscii get() = buildString { this@toAscii.forEach { append(fancyToAscii.getOrDefault(it, it)) } } + val String.toLeet get() = buildString { this@toLeet.forEach { append(asciiToLeet.getOrDefault(it, it)) } } + val String.toGreen get() = ">$this" + val String.toBlue get() = "`$this" + + val String.toUwu get() = + replace("my", "mai") + .replace("friend", "fwend") + .replace("small", "smol") + .replace("cute", "cyute") + .replace("very", "vewy") + .replace("ove", "uv") + .replace("no", "nu") + .replace("you", "yew") + .replace("the", "da") + .replace("is", "ish") + .replace('r', 'w') + .replace("ve", "v") + .replace('l', 'w') +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/util/text/Detections.kt b/src/main/kotlin/com/lambda/util/text/Detections.kt new file mode 100644 index 000000000..4ddf02d7e --- /dev/null +++ b/src/main/kotlin/com/lambda/util/text/Detections.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.util.text + +import baritone.api.BaritoneAPI +import com.lambda.Lambda.mc +import com.lambda.command.CommandRegistry +import com.lambda.command.commands.PrefixCommand +import kotlin.math.max +import kotlin.text.substring + +val playerRegex = "^<(.+)>".toRegex() + +interface Detector { + fun matches(input: CharSequence): Boolean +} + +interface RemovableDetector { + fun removedOrNull(input: CharSequence): CharSequence? +} + +interface PlayerDetector { + fun playerName(input: CharSequence): String? +} + +interface RegexDetector : Detector, RemovableDetector { + val regexes: Array + + fun result(input: CharSequence) = regexes.find { it.containsMatchIn(input) } + + override fun matches(input: CharSequence) = regexes.any { it.containsMatchIn(input) } + + override fun removedOrNull(input: CharSequence) = + result(input) + ?.replace(input, "") + ?.takeIf { it.isNotBlank() } +} + +object MessageParser : PlayerDetector, RemovableDetector { + override fun playerName(input: CharSequence) = + playerRegex.find(input)?.value?.drop(1)?.dropLast(1) + + override fun removedOrNull(input: CharSequence) = + input.replace(playerRegex, "") +} + + +enum class MessageType : Detector, PlayerDetector, RemovableDetector { + Self { + override fun matches(input: CharSequence) = + input.startsWith("<${mc.gameProfile.name}>") + + override fun playerName(input: CharSequence): String? = + mc.gameProfile.name + }, + Others { + override fun matches(input: CharSequence) = playerName(input) != null + + override fun playerName(input: CharSequence) = + playerRegex.find(input)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() && it != name }?.drop(1)?.dropLast(1) + }, + Both { + override fun matches(input: CharSequence) = input.contains(playerRegex) + + override fun playerName(input: CharSequence) = + playerRegex.find(input)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() }?.drop(1)?.dropLast(1) + }; + + override fun removedOrNull(input: CharSequence) = + playerName(input)?.let { input.removePrefix("<$it>") } +} + +enum class MessageDirection(override vararg val regexes: Regex) : RegexDetector, PlayerDetector { + Sent("^To (.+?): ".toRegex(RegexOption.IGNORE_CASE)), + Receive( + "^(.+?) whispers( to you)?: ".toRegex(), + "^\\[?(.+?)( )?->( )?.+?]?( )?:? ".toRegex(), + "^From (.+?): ".toRegex(RegexOption.IGNORE_CASE), + "^. (.+?) » .w+? » ".toRegex() + ), + Both(*Sent.regexes, *Receive.regexes); + + override fun playerName(input: CharSequence) = + result(input)?.find(input)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() } +} \ No newline at end of file