Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/main/java/com/lambda/mixin/render/ChatHudMixin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,4 +41,12 @@ public class ChatHudMixin {
// int wrapRenderCall(DrawContext instance, TextRenderer textRenderer, OrderedText text, int x, int y, int color, Operation<Integer> 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<Void> original) {
var event = new ChatEvent.Message(message, signatureData, indicator);

if (!EventFlow.post(event).isCanceled())
original.call(event.getMessage(), event.getSignature(), event.getIndicator());
}
}
40 changes: 40 additions & 0 deletions src/main/kotlin/com/lambda/config/groups/ReplaceConfig.kt
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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..<it.length, "*".repeat(it.length))}),
CensorHalf({ it.foldIndexed("") { i, acc, char -> if (i % 2 == 0) acc + char else "$acc*" } }),
Comment thread
emyfops marked this conversation as resolved.
Comment thread
emyfops marked this conversation as resolved.
KeepFirst({ it.replaceRange(1..<it.length, "*".repeat(it.length-1))}),
Comment thread
emyfops marked this conversation as resolved.
Outdated
Comment thread
emyfops marked this conversation as resolved.
Outdated
}
}
32 changes: 32 additions & 0 deletions src/main/kotlin/com/lambda/event/events/ChatEvent.kt
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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()
}
104 changes: 104 additions & 0 deletions src/main/kotlin/com/lambda/module/modules/chat/AntiSpam.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

package com.lambda.module.modules.chat

import com.lambda.config.Configurable
import com.lambda.config.SettingGroup
import com.lambda.config.groups.ReplaceConfig
import com.lambda.event.events.ChatEvent
import com.lambda.event.listener.SafeListener.Companion.listen
import com.lambda.module.Module
import com.lambda.module.tag.ModuleTag
import com.lambda.util.NamedEnum
import net.minecraft.text.Text

object AntiSpam : Module(
name = "AntiSpam",
description = "Keeps your chat clean",
tag = ModuleTag.CHAT,
) {
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 detectAddresses = ReplaceSettings("Addresses", this, Group.Addresses)

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) }
val swears = sequenceOf("fuck(er)?", "shit", "cunt", "puss(ie|y)", "bitch", "twat").map { Regex(it) }
val sexual = sequenceOf("^cum[s\\$]?$", "cumm?[i1]ng", "h[o0]rny", "mast(e|ur)b(8|ait|ate)").map { Regex(it) }
val addresses = sequenceOf("^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)$", "^(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?$", "[A-Za-z0-9-]{1,63}\\.[A-Za-z]{2,6}$").map { Regex(it) }
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The visibility of these regex pattern properties should be private. They are implementation details of the AntiSpam module and don't need to be exposed publicly. Making them private would improve encapsulation and prevent external code from depending on these internal patterns.

Suggested change
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) }
val swears = sequenceOf("fuck(er)?", "shit", "cunt", "puss(ie|y)", "bitch", "twat").map { Regex(it) }
val sexual = sequenceOf("^cum[s\\$]?$", "cumm?[i1]ng", "h[o0]rny", "mast(e|ur)b(8|ait|ate)").map { Regex(it) }
val addresses = sequenceOf("^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)$", "^(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?$", "[A-Za-z0-9-]{1,63}\\.[A-Za-z]{2,6}$").map { Regex(it) }
private 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) }
private val swears = sequenceOf("fuck(er)?", "shit", "cunt", "puss(ie|y)", "bitch", "twat").map { Regex(it) }
private val sexual = sequenceOf("^cum[s\\$]?$", "cumm?[i1]ng", "h[o0]rny", "mast(e|ur)b(8|ait|ate)").map { Regex(it) }
private val addresses = sequenceOf("^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)$", "^(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?$", "[A-Za-z0-9-]{1,63}\\.[A-Za-z]{2,6}$").map { Regex(it) }

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex patterns are applied with case-sensitive matching by default. This means variations like "FUCK", "Fuck", or "FuCk" won't be detected. Add the RegexOption.IGNORE_CASE option when creating the Regex instances to ensure case-insensitive matching for more effective filtering.

Suggested change
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) }
val swears = sequenceOf("fuck(er)?", "shit", "cunt", "puss(ie|y)", "bitch", "twat").map { Regex(it) }
val sexual = sequenceOf("^cum[s\\$]?$", "cumm?[i1]ng", "h[o0]rny", "mast(e|ur)b(8|ait|ate)").map { Regex(it) }
val addresses = sequenceOf("^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)$", "^(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?$", "[A-Za-z0-9-]{1,63}\\.[A-Za-z]{2,6}$").map { Regex(it) }
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 addresses = sequenceOf("^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)$", "^(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?$", "[A-Za-z0-9-]{1,63}\\.[A-Za-z]{2,6}$").map { Regex(it, RegexOption.IGNORE_CASE) }

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex patterns are recompiled on every property access because map { Regex(it) } returns a Sequence that is lazily evaluated. This means every time these properties are accessed, the regex patterns are recreated. Convert these to lists or sets using .map { Regex(it) }.toList() to compile the patterns once and reuse them, which will significantly improve performance for frequent chat filtering.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The domain regex pattern is too permissive and will match invalid domains. It lacks anchors and will match substrings within larger text. For example, "hello-world.com" in the middle of a sentence would match. Additionally, it doesn't validate proper domain structure (e.g., it would match "-.com" or "a.toolongtld"). Consider adding word boundary anchors and more strict validation for domain names.

Suggested change
val addresses = sequenceOf("^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)$", "^(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?$", "[A-Za-z0-9-]{1,63}\\.[A-Za-z]{2,6}$").map { Regex(it) }
val addresses = sequenceOf(
"^((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)$",
"^(\\:\\:)?[0-9a-fA-F]{1,4}(\\:\\:?[0-9a-fA-F]{1,4}){0,7}(\\:\\:)?$",
"^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}$"
).map { Regex(it) }

Copilot uses AI. Check for mistakes.

enum class Group(override val displayName: String) : NamedEnum {
Slurs("Slurs"),
Swears("Swears"),
Sexual("Sexual"),
Addresses("IPs and Addresses"),
}

init {
listen<ChatEvent.Message> { event ->
val author = event.message.string.substringBefore(' ')
var content = event.message.string.substringAfter(' ')
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message parsing logic assumes that the message format is always "author message", splitting by the first space. This will fail or behave incorrectly when:

  1. The message doesn't contain a space (single word messages)
  2. The author name contains spaces
  3. System messages that don't follow the "author message" format

This could lead to incorrect filtering or loss of message content. Consider using Minecraft's Text API properly to extract player names and message content, or add validation to ensure the message format matches expectations before parsing.

Copilot uses AI. Check for mistakes.

val slurMatches = slurs.takeIf { detectSlurs.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }

val swearMatches = swears.takeIf { detectSwears.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }

val sexualMatches = sexual.takeIf { detectSexual.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }

val addressMatches = addresses.takeIf { detectAddresses.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }

var cancelled = false
var hasMatches = false

fun doMatch(replace: ReplaceConfig, matches: Sequence<MatchResult>) {
if (cancelled) 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 { content = content.replaceRange(it.range, ""); hasMatches = true }
ReplaceConfig.ActionStrategy.Replace -> matches
.forEach { content = content.replaceRange(it.range, replace.replace.block(it.value)); hasMatches = true }
ReplaceConfig.ActionStrategy.None -> {}
}
}

doMatch(detectSlurs, slurMatches)
doMatch(detectSwears, swearMatches)
doMatch(detectSexual, sexualMatches)
doMatch(detectAddresses, addressMatches)
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling doMatch with multiple groups in sequence can lead to incorrect replacements. When detectSlurs processes and modifies the content string, the match ranges from detectSwears, detectSexual, and detectAddresses become invalid since they were calculated on the original content. This will cause replacements to occur at wrong positions or potentially throw exceptions.

Consider either:

  1. Collecting all matches first before any modifications
  2. Recalculating matches after each modification
  3. Processing all regex patterns together in a single pass
Suggested change
val slurMatches = slurs.takeIf { detectSlurs.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }
val swearMatches = swears.takeIf { detectSwears.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }
val sexualMatches = sexual.takeIf { detectSexual.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }
val addressMatches = addresses.takeIf { detectAddresses.enabled }.orEmpty()
.flatMap { it.findAll(content).toList().reversed() }
var cancelled = false
var hasMatches = false
fun doMatch(replace: ReplaceConfig, matches: Sequence<MatchResult>) {
if (cancelled) 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 { content = content.replaceRange(it.range, ""); hasMatches = true }
ReplaceConfig.ActionStrategy.Replace -> matches
.forEach { content = content.replaceRange(it.range, replace.replace.block(it.value)); hasMatches = true }
ReplaceConfig.ActionStrategy.None -> {}
}
}
doMatch(detectSlurs, slurMatches)
doMatch(detectSwears, swearMatches)
doMatch(detectSexual, sexualMatches)
doMatch(detectAddresses, addressMatches)
var cancelled = false
var hasMatches = false
fun doMatch(replace: ReplaceSettings, patterns: Iterable<Regex>) {
if (cancelled || !replace.enabled) return
// Collect all matches on the current content and process from right to left
val matches = patterns
.flatMap { it.findAll(content).toList() }
.sortedByDescending { it.range.first }
if (matches.isEmpty()) return
when (replace.action) {
// If there's one detection, nuke the whole damn thang
ReplaceConfig.ActionStrategy.Hide -> {
event.cancel()
cancelled = true
}
ReplaceConfig.ActionStrategy.Delete -> {
for (match in matches) {
content = content.replaceRange(match.range, "")
hasMatches = true
}
}
ReplaceConfig.ActionStrategy.Replace -> {
for (match in matches) {
content = content.replaceRange(match.range, replace.replace.block(match.value))
hasMatches = true
}
}
ReplaceConfig.ActionStrategy.None -> {}
}
}
doMatch(detectSlurs, slurs)
doMatch(detectSwears, swears)
doMatch(detectSexual, sexual)
doMatch(detectAddresses, addresses)

Copilot uses AI. Check for mistakes.

if (!hasMatches) return@listen

event.message = Text.of("$author $content")
}
}

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)
}
}
3 changes: 2 additions & 1 deletion src/main/kotlin/com/lambda/module/tag/ModuleTag.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down