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