feat: enhance anti-spam logic to allow messages between player interactions

This commit is contained in:
iceBear67 committed 2026-07-22 17:16:31 +08:00
1 parent 394ed6846f
commit 796372f8c4
3 files changed
+38 -15

No files matched your search

@@ -22,7 +22,8 @@ import java.util.UUID;
* <ul>
* <li><b>频率限制</b>:{@link #WINDOW_MILLIS} 毫秒内最多放行 {@link #MAX_MESSAGES} 条。</li>
* <li><b>重复拦截</b>:与上一条内容相同(忽略大小写与首尾空白)且间隔不足
* {@link #DUPLICATE_WINDOW_MILLIS} 毫秒的消息被拦截。</li>
* {@link #DUPLICATE_WINDOW_MILLIS} 毫秒的消息被拦截。<br>
* 但若在这两条之间有<b>其他人</b>发过公屏消息(对话已经推进),则重复内容放行。</li>
* </ul>
* 判定完全在服务端进行,走 Fabric {@code ALLOW_CHAT_MESSAGE} 钩子——返回 false 即取消该条消息。
* 只影响公屏聊天;命令(如 /msg)走的是 {@code ALLOW_COMMAND_MESSAGE},不受影响。
@@ -39,6 +40,8 @@ public class AntiSpamModule extends ServerModule {
// 仅在服务端主线程访问(聊天与断线回调都在主线程),无需并发容器。
private final Map<UUID, Tracker> trackers = new HashMap<>();
// 全局放行消息计数器,每放行一条公屏消息 +1。用于判断某玩家两次发言之间是否有其他人插话。
private long messageSeq;
@Override
public void onInitialize() {
@@ -53,13 +56,14 @@ public class AntiSpamModule extends ServerModule {
var tracker = trackers.computeIfAbsent(sender.getUUID(), ignored -> new Tracker());
var content = message.signedContent().strip();
var reasonKey = tracker.check(content, now);
var reasonKey = tracker.check(content, now, messageSeq);
if (reasonKey != null) {
// 被拦截的消息不计入放行窗口、也不刷新「上一条」,以免刷屏者借此绕过判定。
warn(sender, tracker, now, reasonKey);
return false;
}
tracker.accept(content, now);
// 先自增再记录:tracker 存下的是「本条消息之后」的全局序号。
tracker.accept(content, now, ++messageSeq);
return true;
}
@@ -74,13 +78,19 @@ public class AntiSpamModule extends ServerModule {
private final Deque<Long> recent = new ArrayDeque<>();
private String lastContent = "";
private long lastContentTime;
/** 上一条放行消息时的全局序号;若之后全局序号增长,说明有其他人插过话。 */
private long lastSeq;
private long lastWarnTime;
/** @return 拦截原因的翻译键,{@code null} 表示放行。 */
String check(String content, long now) {
/**
* @param seq 当前全局放行序号;等于 {@link #lastSeq} 表示自本玩家上次发言以来无人插话。
* @return 拦截原因的翻译键,{@code null} 表示放行。
*/
String check(String content, long now, long seq) {
if (!content.isEmpty()
&& content.equalsIgnoreCase(lastContent)
&& now - lastContentTime < DUPLICATE_WINDOW_MILLIS) {
&& now - lastContentTime < DUPLICATE_WINDOW_MILLIS
&& seq == lastSeq) {
return "message.sfcraft.antispam.duplicate";
}
while (!recent.isEmpty() && now - recent.peekFirst() >= WINDOW_MILLIS) {
@@ -92,10 +102,11 @@ public class AntiSpamModule extends ServerModule {
return null;
}
void accept(String content, long now) {
void accept(String content, long now, long seq) {
recent.addLast(now);
lastContent = content;
lastContentTime = now;
lastSeq = seq;
}
boolean canWarn(long now) {
@@ -71,7 +71,7 @@ public class ChatPingModule extends ServerModule implements ChatDecorator {
var r = match.group();
if (r.isEmpty()) continue;
final var d = r.substring(1).toLowerCase();
return playerNames.stream()
return "@"+playerNames.stream()
.filter(it -> it.toLowerCase().startsWith(d))
.peek(it -> foundPlayers.add(serverSupplier.get().getPlayerList().getPlayerByName(it)))
.findFirst()
@@ -1,15 +1,21 @@
package io.ib67.sfcraft.module.chat.decorator;
import com.google.inject.Inject;
import io.ib67.kiwi.routine.Fail;
import io.ib67.kiwi.routine.Result;
import io.ib67.kiwi.routine.Some;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.registry.chat.SimpleMessageDecorator;
import net.minecraft.network.chat.ChatDecorator;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import java.util.Optional;
public class ChatShowoffModule extends ServerModule implements ChatDecorator {
@Inject
SimpleMessageDecorator messageDecorator;
@@ -24,15 +30,21 @@ public class ChatShowoffModule extends ServerModule implements ChatDecorator {
if (!isEnabled()) {
return message;
}
if(player == null || player.getMainHandItem().is(Items.AIR)) { return message; }
return ReplaceHelper.replace(player, message, ".item", this::generateShowoffText);
if (player == null) {
return message;
}
private Component generateShowoffText(ServerPlayer serverPlayer) {
var item = serverPlayer.getMainHandItem();
return item.count() > 1 ?
var comp = generateShowoffText(player.getMainHandItem(), player)
.orElseGet(generateShowoffText(player.getOffhandItem(), player)::result);
if (comp == null) return message;
return ReplaceHelper.replace(player, message, ".item", p -> comp);
}
private Result<Component> generateShowoffText(@Nullable ItemStack item, ServerPlayer serverPlayer) {
if (item == null || item.is(Items.AIR)) return Fail.none();
return new Some<>(item.count() > 1 ?
Component.literal(" " + item.count() + "x ").append(item.getDisplayName())
: Component.literal(" ").append(item.getDisplayName());
: Component.literal(" ").append(item.getDisplayName()));
}
}