第35批:机翻无条件执行;IPv4/IPv6未加白即拦;通用resources模板同步补缺
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m39s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s

This commit is contained in:
youpaishidifu
2026-10-20 13:36:19 +08:00
parent 0fdeb88559
commit afcce6b3e0
3 changed files with 430 additions and 20 deletions
@@ -1122,6 +1122,108 @@ public class ChatFilterManager {
return containsIllegalDomain(probe);
}
// ==================== 35批修订:IPv4/IPv6 地址检测(加白豁免,其余无条件拦) ====================
/** IPv4:四段 0-255,前后用非数字边界包住("1.21.4" 三段版本号不匹配,"1.2.3.4.5" 不匹配) */
private static final Pattern IPV4_PATTERN = Pattern.compile(
"(?<![0-9.])(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"
+ "(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}(?![0-9]|\\.\\d)");
/** IPv6 候选:>=1 个冒号的十六进制/冒号/点串(粗取后 isRealIpv6 严格校验,防 12:34:56 时间误伤) */
private static final Pattern IPV6_CAND = Pattern.compile(
"(?i)(?:[0-9a-f]{0,4}:){1,}[0-9a-f:.]*");
/**
* 提取消息里全部 IPv4/IPv6 地址(严格校验、按出现顺序去重)。
* IPv4 按 0-255 校验;IPv6 先粗取候选再去尾点后校验组数/::唯一性/组格式,
* 时间 "12:34:56"、三段冒号串不会误伤;句末句号("…cbd5.")不吞。
*/
public java.util.List<String> extractIpAddresses(String msg) {
java.util.List<String> out = new ArrayList<>();
if (msg == null || msg.isEmpty()) return out;
Matcher m4 = IPV4_PATTERN.matcher(msg);
while (m4.find()) {
String ip = m4.group();
if (!out.contains(ip)) out.add(ip);
}
Matcher m6 = IPV6_CAND.matcher(msg);
while (m6.find()) {
String cand = m6.group();
// 去尾部句点(英文句号紧跟 IP 时不能因此漏判)
int end = cand.length();
while (end > 0 && cand.charAt(end - 1) == '.') end--;
cand = cand.substring(0, end);
if (cand.isEmpty() || !isRealIpv6(cand)) continue;
if (!out.contains(cand)) out.add(cand);
}
return out;
}
/**
* 消息是否包含「未加白」的 IPv4/IPv6(35批修订口径,用户原话:
* 「对加白的ip和玩家不受影响,其它玩家ipv6、ipv4全部无条件拦下来」)。
* 加白 IP = chat.txt「白名单」节条目(与域名共用同一列表,支持完整 IP 与 *.x.x.x 后缀);
* 消息里的 IP 全部加白才放行,出现任何一个未加白 IP 即返回 true。
* 加白玩家在 onChat 更早的「白名单玩家」早退里豁免,走不到 IP 检测。
*/
public boolean containsNonWhitelistedIp(String msg) {
for (String ip : extractIpAddresses(msg)) {
if (!isWhitelistedIp(ip)) return true;
}
return false;
}
/**
* 加白 IP 判定。不复用 isWhitelisted:它按第一个冒号剥「端口」,
* 对 IPv6(2408:8352::1)会截断成 "2408" 误判未加白 —— 这里不剥端口,
* 提取出来的 IP 本就不含端口;条目支持完整等值与 *.段 后缀通配。
*/
private boolean isWhitelistedIp(String ip) {
if (ip == null || ip.isEmpty()) return false;
String clean = ip.trim().toLowerCase(java.util.Locale.ROOT);
for (String entry : whitelistUrls) {
String e = entry.trim().toLowerCase(java.util.Locale.ROOT);
if (e.isEmpty()) continue;
if (e.startsWith("*.")) {
if (clean.endsWith(e.substring(1))) return true;
} else if (clean.equals(e)) {
return true;
}
}
return false;
}
/** 候选串是否为合法 IPv6(:: 压缩、末段 IPv4-mapped、%scope 尾巴) */
private static boolean isRealIpv6(String t) {
if (t == null || t.isEmpty()) return false;
String s = t;
int pct = s.indexOf('%');
if (pct >= 0) s = s.substring(0, pct); // fe80::1%eth0
int slash = s.indexOf('/');
if (slash >= 0) s = s.substring(0, slash); // CIDR 尾巴
if (s.indexOf(':') < 0) return false;
int dc = s.indexOf("::");
if (dc >= 0 && s.indexOf("::", dc + 1) >= 0) return false; // 两个 :: 非法
String body = dc >= 0
? s.substring(0, dc) + ":" + s.substring(dc + 2) : s;
int groups = 0;
for (String part : body.split(":", -1)) {
if (part.isEmpty()) continue;
if (part.indexOf('.') >= 0) {
// 末段 IPv4 形态(::ffff:192.168.1.1)→ 按 IPv4 校验,计 2 组
if (!part.matches("(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"
+ "(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}")) return false;
groups += 2;
} else {
if (!part.matches("[0-9a-fA-F]{1,4}")) return false;
groups++;
}
if (groups > 8) return false;
}
if (dc >= 0) return groups < 8; // :: 至少压缩 1 组 -> 显式组 <=7
return groups == 8; // 无 :: 必须恰好 8 组
}
public boolean isLikelyDomain(String url) {
int lastDot = url.lastIndexOf('.');
if (lastDot < 0
+277 -3
View File
@@ -958,7 +958,7 @@ public class ConfigManager {
if (t.startsWith("```")) { origHasCfgBlock = true; continue; }
if (!t.startsWith("|")) continue;
String[] cols = t.split("\\|", -1);
if (cols.length < 4) continue;
if (cols.length < 3) continue;
String key = cols[1].trim();
if (key.isEmpty() || key.matches("[-:\\s]+")) continue; // 表头分隔行
origKeys.add(key);
@@ -988,7 +988,7 @@ public class ConfigManager {
}
if (!t.startsWith("|")) continue;
String[] cols = t.split("\\|", -1);
if (cols.length < 4) continue;
if (cols.length < 3) continue;
String key = cols[1].trim();
if (key.isEmpty() || key.matches("[-:\\s]+")) continue;
tplKeys.add(key);
@@ -1028,7 +1028,7 @@ public class ConfigManager {
if (inFence) continue;
if (t.startsWith("|")) {
String[] cols = t.split("\\|", -1);
String key = cols.length >= 4 ? cols[1].trim() : "";
String key = cols.length >= 3 ? cols[1].trim() : "";
boolean isData = !key.isEmpty() && !key.matches("[-:\\s]+");
if (tableEndIdx < 0) {
if (isData || key.matches("[-:\\s]+")) tableEndIdx = i;
@@ -1129,6 +1129,280 @@ public class ConfigManager {
}
}
// ==================== 35批:resources 模板通用同步(全量扫描,缺啥补啥) ====================
/**
* 通用模板同步(35批,用户口径「处理所有我们插件 resources 里的模板文件,如果插件数据目录
* 里的文件内容缺了模板里的一些配置值,立刻备份,提取内容出来,安全释放新的。只插入对应的
* 配置,不能误伤其它内容」):启动时扫描 jar 内全部资源模板,与插件数据目录逐一对照 ——
* ① 数据目录缺该文件 → 直接安全释放新版;
* ② 文件存在但缺模板里的配置值 → 先备份 .bak(逐字节校验)→ 只把缺失项插入,
* 用户已有配置值、自定义键、说明文档一律原样保留(绝不整文件覆盖);
* ③ 内容与模板一致 → 安静跳过(幂等,不产生 .bak)。
* 按扩展名分派:.md → safeUpgradeDoc(只补配置区表格行,说明文档不动);
* .json → 递归缺键补入(Gson 解析失败=用户加了注释/格式特殊 → 跳过不动);
* 纯文本(.txt/.yml 等)→ 缺失数据行追加;未识别类型只记录不改动。
* 任何异常只打日志,绝不影响启动。必须在任何 Manager 读配置文件之前调用(onEnable 早期)。
*/
public void syncAllResourceTemplates() {
try {
java.net.URL loc = ConfigManager.class.getProtectionDomain()
.getCodeSource().getLocation();
if (loc == null) return;
File jar = new File(loc.toURI());
if (!jar.isFile()) return; // 开发态(class 目录)无 jar 可扫,跳过
int released = 0, upgraded = 0, skipped = 0;
try (java.util.jar.JarFile jf = new java.util.jar.JarFile(jar)) {
java.util.Enumeration<java.util.jar.JarEntry> en = jf.entries();
while (en.hasMoreElements()) {
java.util.jar.JarEntry je = en.nextElement();
if (je.isDirectory()) continue;
String name = je.getName();
if (name.startsWith("META-INF/")) continue;
if (name.endsWith(".class")) continue;
if (name.equals("plugin.yml")) continue; // 插件描述文件,不是配置模板
try {
byte[] tpl;
try (InputStream in = jf.getInputStream(je)) {
tpl = readAllBytes(in);
}
if (tpl == null || tpl.length == 0) continue;
File target = new File(dataFolder, name);
if (!target.exists()) {
File parent = target.getParentFile();
if (parent != null) parent.mkdirs();
java.nio.file.Files.write(target.toPath(), tpl);
released++;
continue;
}
byte[] cur = java.nio.file.Files.readAllBytes(target.toPath());
if (Arrays.equals(cur, tpl)) continue; // 与模板一致 → 安静
String label = "通用模板:" + name;
String lower = name.toLowerCase(Locale.ROOT);
boolean ok;
if (lower.endsWith(".md")) {
ok = safeUpgradeDoc(target, readUtf8Lines(tpl), label);
} else if (lower.endsWith(".json")) {
ok = upgradeJsonMissing(target, tpl, label);
} else if (lower.endsWith(".txt") || lower.endsWith(".yml")
|| lower.endsWith(".yaml") || lower.endsWith(".cfg")
|| lower.endsWith(".conf") || lower.endsWith(".properties")
|| lower.endsWith(".csv")) {
ok = upgradeLineList(target, readUtf8Lines(tpl), label);
} else {
logger.info("[" + label + "] 未识别的资源类型,跳过(不改动)");
skipped++;
continue;
}
if (ok) upgraded++;
} catch (Throwable inner) {
skipped++;
logger.warning("[通用模板:" + name + "] 处理失败(已跳过,不影响启动): "
+ inner.getMessage());
}
}
}
if (released > 0 || upgraded > 0) {
logger.info("[通用模板] 扫描完成:首装释放 " + released + " 个文件,安全升级 "
+ upgraded + " 个文件(只补缺失配置,其余原样保留)"
+ (skipped > 0 ? ",跳过 " + skipped + " 个" : ""));
}
} catch (Throwable t) {
// 兜底:模板同步任何异常都不允许影响插件启动
logger.warning("[通用模板] 同步异常(已跳过,不影响启动): " + t.getMessage());
}
}
/** 读 InputStream → 字节数组(jar 资源读取用) */
private static byte[] readAllBytes(InputStream in) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) bos.write(buf, 0, n);
return bos.toByteArray();
}
/** 字节数组 → 行列表(UTF-8,去 BOM,去 CR,去文件末空行) */
private static List<String> readUtf8Lines(byte[] data) {
String s = new String(data, StandardCharsets.UTF_8);
if (!s.isEmpty() && s.charAt(0) == '\uFEFF') s = s.substring(1);
List<String> L = new ArrayList<>();
for (String line : s.split("\n", -1)) {
if (line.endsWith("\r")) line = line.substring(0, line.length() - 1);
L.add(line);
}
if (!L.isEmpty() && L.get(L.size() - 1).isEmpty()) L.remove(L.size() - 1);
return L;
}
/**
* JSON 配置缺键补入(35批):以原文件为基底,递归把模板里 target 缺失的键插进去 ——
* 只插入、绝不覆盖已有值(数组/标量/类型不同一律不碰,用户改过的值原样保留);
* 键名以 "#" 开头的视为模板示例说明(opwl.json「以下是模板文件」节),不回插。
* 原文件 JSON 解析失败(用户加了注释/格式特殊)→ 跳过不动。
* 动手前备份 .bak 并逐字节校验(失败=取消);写后重新解析校验,失败从 .bak 还原。
*
* @return true=本次执行了升级写入
*/
private boolean upgradeJsonMissing(File file, byte[] tplBytes, String label) {
try {
String tplText = new String(tplBytes, StandardCharsets.UTF_8);
if (!tplText.isEmpty() && tplText.charAt(0) == '\uFEFF') tplText = tplText.substring(1);
byte[] cur = java.nio.file.Files.readAllBytes(file.toPath());
String oldText = new String(cur, StandardCharsets.UTF_8);
if (!oldText.isEmpty() && oldText.charAt(0) == '\uFEFF') oldText = oldText.substring(1);
com.google.gson.JsonElement tplEl;
try {
tplEl = com.google.gson.JsonParser.parseString(tplText);
} catch (Exception e) {
logger.warning("[" + label + "] 模板 JSON 解析失败,跳过");
return false;
}
if (!tplEl.isJsonObject()) return false;
com.google.gson.JsonElement oldEl;
try {
oldEl = com.google.gson.JsonParser.parseString(oldText);
} catch (Exception e) {
logger.warning("[" + label + "] 原文件 JSON 解析失败(可能含注释或被改坏),跳过不动");
return false;
}
if (!oldEl.isJsonObject()) {
logger.warning("[" + label + "] 原文件 JSON 顶层不是对象,跳过不动");
return false;
}
com.google.gson.JsonObject root = oldEl.getAsJsonObject();
List<String> added = new ArrayList<>();
if (!mergeJsonMissing(root, tplEl.getAsJsonObject(), "", added)) {
return false; // 无缺失键,幂等
}
// ① 备份原文件 → .bak 并逐字节校验(失败=取消,原文件不动)
File bak = new File(file.getParentFile(), file.getName() + ".bak");
java.nio.file.Files.copy(file.toPath(), bak.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
boolean bakOk = bak.exists() && bak.length() == file.length();
if (bakOk) {
try {
bakOk = Arrays.equals(cur, java.nio.file.Files.readAllBytes(bak.toPath()));
} catch (Exception e) { bakOk = false; }
}
if (!bakOk) {
bak.delete();
logger.warning("[" + label + "] ★升级取消:备份 .bak 校验不通过,原文件未改动");
return false;
}
// ② 写回(pretty + 不转义 HTML,与 OpWhiteListManager 写回同款格式),写后复解析校验
com.google.gson.Gson g = new com.google.gson.GsonBuilder()
.setPrettyPrinting().disableHtmlEscaping().create();
try {
byte[] out = g.toJson(root).getBytes(StandardCharsets.UTF_8);
java.nio.file.Files.write(file.toPath(), out);
com.google.gson.JsonParser.parseString(
new String(out, StandardCharsets.UTF_8)).getAsJsonObject();
} catch (Exception e) {
try {
java.nio.file.Files.copy(bak.toPath(), file.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
logger.warning("[" + label + "] ★升级失败:写入/校验出错,已从 "
+ bak.getName() + " 还原原文件");
} catch (Exception e2) {
logger.severe("[" + label + "] ★升级失败且还原失败!完整备份仍在: "
+ bak.getAbsolutePath());
}
return false;
}
logger.info("[" + label + "] JSON 配置安全交换完成:补入 " + added.size()
+ " 个缺失键 " + added + ",已有配置值与其它内容原样保留;备份: " + bak.getName());
return true;
} catch (Exception e) {
logger.warning("[" + label + "] JSON 配置升级异常(已跳过,保持原文件): " + e.getMessage());
return false;
}
}
/** 递归把模板里 target 缺失的键补进去(只插入不覆盖;数组/标量不递归合并;# 说明键跳过) */
private boolean mergeJsonMissing(com.google.gson.JsonObject target,
com.google.gson.JsonObject tpl, String path, List<String> added) {
boolean changed = false;
for (Map.Entry<String, com.google.gson.JsonElement> en : tpl.entrySet()) {
String k = en.getKey();
if (k.startsWith("#")) continue; // 模板示例说明键,不算配置,不回插
com.google.gson.JsonElement have = target.get(k);
if (have == null || have.isJsonNull()) {
target.add(k, en.getValue().deepCopy());
added.add(path + k);
changed = true;
} else if (have.isJsonObject() && en.getValue().isJsonObject()) {
changed |= mergeJsonMissing(have.getAsJsonObject(),
en.getValue().getAsJsonObject(), path + k + ".", added);
}
// 已存在的键(含数组、标量、类型不同)一律不碰 → 用户值与自定义内容原样保留
}
return changed;
}
/**
* 纯文本行列表缺行补入(35批,illegal_domains.txt 等):原文件没有的模板数据行
* (非空非注释)追加到文件末尾;已有行、用户自加行原样保留。
* 备份/校验/写失败还原与 safeUpgrade 同一套安全约定。
*
* @return true=本次执行了升级写入
*/
private boolean upgradeLineList(File file, List<String> tpl, String label) {
try {
List<String> orig = readUtf8Lines(java.nio.file.Files.readAllBytes(file.toPath()));
Set<String> have = new HashSet<>();
for (String raw : orig) {
String t = raw.trim();
if (!t.isEmpty()) have.add(t);
}
List<String> missing = new ArrayList<>();
for (String raw : tpl) {
String t = raw.trim();
if (t.isEmpty() || t.startsWith("#")) continue;
if (!have.contains(t)) missing.add(raw);
}
if (missing.isEmpty()) return false; // 幂等
File bak = new File(file.getParentFile(), file.getName() + ".bak");
java.nio.file.Files.copy(file.toPath(), bak.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
boolean bakOk = bak.exists() && bak.length() == file.length();
if (bakOk) {
try {
bakOk = Arrays.equals(
java.nio.file.Files.readAllBytes(file.toPath()),
java.nio.file.Files.readAllBytes(bak.toPath()));
} catch (Exception e) { bakOk = false; }
}
if (!bakOk) {
bak.delete();
logger.warning("[" + label + "] ★升级取消:备份 .bak 校验不通过,原文件未改动");
return false;
}
List<String> out = new ArrayList<>(orig);
out.add("");
out.add("# ===== 以下为模板补充行(通用模板同步自动补入,可自行删改) =====");
out.addAll(missing);
if (!writeLines(file, out)) {
try {
java.nio.file.Files.copy(bak.toPath(), file.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
logger.warning("[" + label + "] ★升级失败:写入出错,已从 "
+ bak.getName() + " 还原原文件");
} catch (Exception e2) {
logger.severe("[" + label + "] ★升级失败且还原失败!完整备份仍在: "
+ bak.getAbsolutePath());
}
return false;
}
logger.info("[" + label + "] 行列表安全交换完成:补入 " + missing.size()
+ " 行,已有内容原样保留;备份: " + bak.getName());
return true;
} catch (Exception e) {
logger.warning("[" + label + "] 行列表升级异常(已跳过,保持原文件): " + e.getMessage());
return false;
}
}
private double parseDouble(String s, double def) { try { return Double.parseDouble(s.trim()); } catch (Exception e) { return def; } }
private int parseInt(String s, int def) { try { return Integer.parseInt(s.trim()); } catch (Exception e) { return def; } }
+51 -17
View File
@@ -420,6 +420,11 @@ public class Main extends JavaPlugin
// ===== 1. 基础配置 =====
config = new ConfigManager(getDataFolder());
// ★ 35批:通用模板同步——启动即扫描 jar 全部 resources 模板:数据目录缺文件=安全释放,
// 缺模板里的配置值=先备份 .bak 再只插缺失项(说明文档与用户改过的值原样保留)。
// 必须在任何 Manager 读配置之前(illegal_domains.txt / chest/cfg.json /
// OP白名单/opwl.json / 各 md 配置都由它先对齐)。
config.syncAllResourceTemplates();
config.loadMessages();
config.loadSmtp();
config.loadSettings();
@@ -6074,6 +6079,8 @@ public class Main extends JavaPlugin
// ★ 答对了 → 释放所有缓存消息 + 当前消息
chatFilter.broadcastCachedMessages(p.getName());
Bukkit.broadcastMessage(p.getDisplayName() + ": " + msg);
// 35批:机翻无条件执行——验证通过手动广播的这条也照常追加机翻
tryScheduleChatTranslation(p, msg);
p.sendMessage("§a§l[验证码] §a验证通过!");
e.setCancelled(true);
return;
@@ -6192,11 +6199,19 @@ public class Main extends JavaPlugin
}
// ===== 聊天过滤 - 开关检查 =====
if (!chatFilter.isEnabled()) return;
if (!chatFilter.isEnabled()) {
// 35批:机翻无条件执行——过滤关闭也照常追加英文机翻(机翻是展示功能,不随开关走)
tryScheduleChatTranslation(p, msg);
return;
}
// ===== 白名单玩家 =====
if (chatFilter.isPlayerWhitelisted(
p.getName())) return;
p.getName())) {
// 35批:机翻无条件执行——加白玩家不过滤,但英文机翻照常(机翻没有任何豁免)
tryScheduleChatTranslation(p, msg);
return;
}
// ===== 禁言检查 =====
if (chatFilter.isMuted(p.getName())) {
@@ -6273,6 +6288,24 @@ public class Main extends JavaPlugin
}
}
// ===== IPv4/IPv6 地址检测(35批修订口径:加白 IP / 加白玩家不受影响,
// 其它玩家消息含 IPv4/IPv6 一律无条件拦截)=====
// 加白玩家:上面「白名单玩家」已早退,根本走不到这里;
// 加白 IP:复用 chat.txt「白名单」节条目(isWhitelisted,支持完整 IP 与 *.x.x.x 后缀),
// 消息里的 IP 全部加白才放行,出现任何一个未加白的 IP 即拦。
if (chatFilter != null && chatFilter.containsNonWhitelistedIp(msg)) {
e.setCancelled(true);
p.sendMessage("§c§l[聊天过滤] §7消息包含未加白的 IP 地址,已被拦截");
chatFilter.incrementViolation(p.getName());
int ipCount = chatFilter.getViolationCount(p.getName());
p.sendMessage(chatFilter.msg(
"chat_url_violation",
"count", String.valueOf(ipCount)));
chatFilter.applyPunishment(p, ipCount,
"发送IP地址", "系统");
return;
}
// ===== URL检测 =====
List<String> urls =
chatFilter.extractUrls(msg);
@@ -6373,9 +6406,7 @@ public class Main extends JavaPlugin
// ==================== 聊天过滤辅助方法 ====================
// ===== 英文聊天机翻(28批新增) =====
/** 同一玩家机翻冷却(毫秒),防刷屏触发大量 API 请求 */
private final java.util.Map<String, Long> chatTransCd = new java.util.concurrent.ConcurrentHashMap<>();
// ===== 英文聊天机翻(28批新增,35批去豁免) =====
/** 机翻专用线程池(HTTP 阻塞最多 ~9s,不能占用 ForkJoinPool.commonPool) */
private final java.util.concurrent.ExecutorService chatTransPool =
java.util.concurrent.Executors.newFixedThreadPool(2, r -> {
@@ -6385,12 +6416,14 @@ public class Main extends JavaPlugin
});
/**
* 英文聊天机翻调度(28批,用户定稿口径):
* 英文聊天机翻调度(28批用户定稿口径,35批修正「机翻无条件执行,没有豁免」):
* - 所有消息一律不拦截、原样放行(交 MC 自行处理),本方法只做「追加双语展示」;
* - 仅「整条消息不含任何中文字符」的英文消息(如 what are you doing?)才机翻,
* - 触发口径不变:整条消息不含任何中文字符(如 what are you doing?)才机翻,
* 原始消息由 MC 正常广播之后,再追加一条「[机翻] 原文 → 译文」;
* - 中文消息或中文夹杂英文(如「你需要用water来免伤」)一律原样输出、不做机翻
* 中文/中英夹杂(如「你需要用water来免伤」)一律原样输出、不做机翻
* —— 脱离第三方模型无法判定英文单词在语境中的真实含义,强行翻反而出错。
* - ★ 35批:无条件执行、没有任何豁免 —— 10秒/人冷却与 200 字符上限已删除,
* 每条英文消息都触发机翻(白名单玩家/过滤关闭/验证通过等早退路径也补了调度)。
* 异步:HTTP 翻译在线程池执行(每源3s超时,YOUDAO→JIANXIN→GOOGLE 降级),
* 结果回主线程广播;任何异常静默吞掉,绝不影响聊天主流程。
*/
@@ -6398,26 +6431,21 @@ public class Main extends JavaPlugin
try {
if (p == null || msg == null) return;
String text = msg.trim();
if (text.isEmpty() || text.length() > 200) return;
// 整条消息必须「不含任何中文字符」才机翻(含中文=原样输出)
if (text.isEmpty()) return;
// 触发口径:整条消息必须「不含任何中文字符」才机翻(含中文=原样输出)
boolean hasChinese = false;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c >= '\u4e00' && c <= '\u9fff') { hasChinese = true; break; }
}
if (hasChinese) return;
// 至少要有一个英文字母(纯数字/符号无翻译意义)
// 触发口径:至少一个英文字母(纯数字/符号不算英文消息)
boolean hasLetter = false;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { hasLetter = true; break; }
}
if (!hasLetter) return;
// 冷却防刷(10秒/人)
long now = System.currentTimeMillis();
Long last = chatTransCd.get(p.getName());
if (last != null && now - last < 10_000L) return;
chatTransCd.put(p.getName(), now);
final String original = text;
final String sender = p.getName();
@@ -6443,7 +6471,13 @@ public class Main extends JavaPlugin
if (content == null || content.isEmpty()) return false;
if (chatFilter == null || !chatFilter.isEnabled()) return false;
if (chatFilter.isPlayerWhitelisted(p.getName())) return false;
// 35批修订:私信/告示牌/书本内容同样拦 IP —— 加白 IP 放行,其余 IPv4/IPv6 无条件拦
// (加白玩家已在上一行豁免)
if (chatFilter.containsNonWhitelistedIp(content)) {
p.sendMessage("§c§l[聊天过滤] §7内容包含未加白的 IP 地址,已被拦截");
return true;
}
List<String> urls = chatFilter.extractUrls(content);
if (urls.isEmpty()) return false;