feat: hover translation (Google, Chrome endpoint with gtx fallback), LuckPerms prefixes in chat, restyled messages
Some checks are pending
Build & Test / Build LoChat (push) Waiting to run
Some checks are pending
Build & Test / Build LoChat (push) Waiting to run
This commit is contained in:
parent
bd39118b14
commit
260986ff11
9 changed files with 396 additions and 81 deletions
|
|
@ -60,6 +60,8 @@ public final class LoChat extends JavaPlugin {
|
|||
configManager.getTranslationEndpoint(),
|
||||
configManager.getTranslationApiKey(),
|
||||
configManager.isTranslationEnabled(),
|
||||
configManager.getTranslationProvider(),
|
||||
configManager.getTranslationTimeoutMs(),
|
||||
getLogger()
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -249,6 +249,26 @@ public class ConfigManager {
|
|||
return accessor.getTranslationApiKey();
|
||||
}
|
||||
|
||||
public String getTranslationProvider() {
|
||||
return accessor.getTranslationProvider();
|
||||
}
|
||||
|
||||
public String getTranslationMode() {
|
||||
return accessor.getTranslationMode();
|
||||
}
|
||||
|
||||
public int getTranslationTimeoutMs() {
|
||||
return accessor.getTranslationTimeoutMs();
|
||||
}
|
||||
|
||||
public java.util.List<String> getTranslationHoverFormat() {
|
||||
return accessor.getTranslationHoverFormat();
|
||||
}
|
||||
|
||||
public String getTranslationBypassPermission() {
|
||||
return accessor.getTranslationBypassPermission();
|
||||
}
|
||||
|
||||
// ========== LoPreff integration ==========
|
||||
|
||||
public boolean isLopreffEnabled() {
|
||||
|
|
|
|||
|
|
@ -150,6 +150,29 @@ public class ConfigAccessor {
|
|||
return config.getString("chat.translation.api-key", "");
|
||||
}
|
||||
|
||||
/** "libretranslate" (needs endpoint and usually a key) or "google" (free, no key). */
|
||||
public String getTranslationProvider() {
|
||||
return config.getString("chat.translation.provider", "libretranslate");
|
||||
}
|
||||
|
||||
/** "hover" shows the translation when hovering the message, "actionbar" shows it above the hotbar. */
|
||||
public String getTranslationMode() {
|
||||
return config.getString("chat.translation.mode", "hover");
|
||||
}
|
||||
|
||||
public int getTranslationTimeoutMs() {
|
||||
return config.getInt("chat.translation.timeout-ms", 3000);
|
||||
}
|
||||
|
||||
public java.util.List<String> getTranslationHoverFormat() {
|
||||
java.util.List<String> lines = config.getStringList("chat.translation.hover-format");
|
||||
return lines.isEmpty() ? java.util.List.of("&7Translation:", "&f{translation}") : lines;
|
||||
}
|
||||
|
||||
public String getTranslationBypassPermission() {
|
||||
return config.getString("chat.translation.bypass-permission", "lochat.translate.bypass");
|
||||
}
|
||||
|
||||
// ========== LoPreff integration ==========
|
||||
|
||||
public boolean isLopreffEnabled() {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import com.loki.lochat.compat.Msg;
|
|||
import com.loki.lochat.core.filter.AdvancedMessageFilter;
|
||||
import com.loki.lochat.core.filter.FilterResult;
|
||||
import com.loki.lochat.renderer.EnhancedChatRenderer;
|
||||
import com.loki.lochat.translate.TranslationService;
|
||||
import com.loki.lochat.utils.format.ChatFormatter;
|
||||
import com.loki.lochat.utils.format.LuckPermsAffixes;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
|
|
@ -80,6 +82,7 @@ public class ChatEventListener implements Listener {
|
|||
Component displayName = buildDisplayName(sender);
|
||||
Set<Player> recipients = new HashSet<>(event.getRecipients());
|
||||
event.setCancelled(true);
|
||||
prefetchTranslation(sender, messageComponent, recipients);
|
||||
for (Player viewer : recipients) {
|
||||
Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
|
||||
Msg.send(viewer, rendered);
|
||||
|
|
@ -132,6 +135,7 @@ public class ChatEventListener implements Listener {
|
|||
Component displayName = buildDisplayName(sender);
|
||||
Set<Player> recipients = new HashSet<>(event.getRecipients());
|
||||
event.setCancelled(true);
|
||||
prefetchTranslation(sender, messageComponent, recipients);
|
||||
for (Player viewer : recipients) {
|
||||
Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
|
||||
Msg.send(viewer, rendered);
|
||||
|
|
@ -226,9 +230,37 @@ public class ChatEventListener implements Listener {
|
|||
if (legacyName == null || legacyName.isBlank()) {
|
||||
return Component.text(sender.getName());
|
||||
}
|
||||
// LuckPerms prefix/suffix are added separately ({player_prefix}/{player_suffix}), do not show them twice
|
||||
legacyName = LuckPermsAffixes.withoutAffixes(legacyName, LuckPermsAffixes.prefix(sender),
|
||||
LuckPermsAffixes.suffix(sender));
|
||||
return ChatFormatter.parse(legacyName);
|
||||
}
|
||||
|
||||
/** Translates the message for every viewer language before rendering (runs off the main thread only). */
|
||||
private void prefetchTranslation(Player sender, Component messageComponent, Set<Player> recipients) {
|
||||
try {
|
||||
LoChat loChat = (LoChat) plugin;
|
||||
var cfg = loChat.getConfigManager();
|
||||
TranslationService ts = loChat.getTranslationService();
|
||||
if (!cfg.isTranslationEnabled() || !ts.isEnabled() || Bukkit.isPrimaryThread()
|
||||
|| "actionbar".equalsIgnoreCase(cfg.getTranslationMode())
|
||||
|| sender.hasPermission(cfg.getTranslationBypassPermission())) {
|
||||
return;
|
||||
}
|
||||
Set<String> languages = new HashSet<>();
|
||||
for (Player viewer : recipients) {
|
||||
if (!viewer.equals(sender)) {
|
||||
languages.add(TranslationService.localeToLanguageCode(viewer.getLocale()));
|
||||
}
|
||||
}
|
||||
if (!languages.isEmpty()) {
|
||||
ts.prefetch(Msg.plain(messageComponent), languages, cfg.getTranslationTimeoutMs());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().fine("Translation prefetch failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void recordStatistics(LoChat loChat, Player sender, boolean isGlobal) {
|
||||
playerService.recordMessage(sender.getUniqueId(), isGlobal ? "global" : "local");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,12 +64,7 @@ public class EnhancedChatRenderer {
|
|||
|
||||
if (viewer != null) {
|
||||
processed = mentionHandler.processMentions(processed, source, viewer);
|
||||
if (cfg.isTranslationEnabled()) {
|
||||
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
|
||||
if (loChat.getTranslationService().isEnabled()) {
|
||||
sendTranslationAsync(viewer, PlainTextComponentSerializer.plainText().serialize(processed));
|
||||
}
|
||||
}
|
||||
processed = applyTranslation(source, viewer, message, processed);
|
||||
}
|
||||
|
||||
// Получаем формат из конфига
|
||||
|
|
@ -124,7 +119,7 @@ public class EnhancedChatRenderer {
|
|||
|
||||
while (matcher.find()) {
|
||||
if (matcher.start() > lastEnd) {
|
||||
result = result.append(Component.text(format.substring(lastEnd, matcher.start())));
|
||||
result = result.append(ChatFormatter.parse(format.substring(lastEnd, matcher.start())));
|
||||
}
|
||||
|
||||
String placeholder = matcher.group(1);
|
||||
|
|
@ -134,7 +129,7 @@ public class EnhancedChatRenderer {
|
|||
}
|
||||
|
||||
if (lastEnd < format.length()) {
|
||||
result = result.append(Component.text(format.substring(lastEnd)));
|
||||
result = result.append(ChatFormatter.parse(format.substring(lastEnd)));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -156,6 +151,11 @@ public class EnhancedChatRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
String luckPermsPrefix = com.loki.lochat.utils.format.LuckPermsAffixes.prefix(player);
|
||||
if (!luckPermsPrefix.isEmpty()) {
|
||||
return com.loki.lochat.utils.format.ChatFormatter.parse(luckPermsPrefix);
|
||||
}
|
||||
|
||||
return Component.empty();
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +170,11 @@ public class EnhancedChatRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
String luckPermsSuffix = com.loki.lochat.utils.format.LuckPermsAffixes.suffix(player);
|
||||
if (!luckPermsSuffix.isEmpty()) {
|
||||
return com.loki.lochat.utils.format.ChatFormatter.parse(luckPermsSuffix);
|
||||
}
|
||||
|
||||
return Component.empty();
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +244,50 @@ public class EnhancedChatRenderer {
|
|||
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the translation into the viewer's client language: as hover text over the message (default)
|
||||
* or in the action bar. The translation must already be cached (see ChatEventListener#prefetchTranslation).
|
||||
*/
|
||||
private Component applyTranslation(Player source, Player viewer, Component original, Component processed) {
|
||||
if (!cfg.isTranslationEnabled() || viewer.equals(source)) {
|
||||
return processed;
|
||||
}
|
||||
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
|
||||
TranslationService ts = loChat.getTranslationService();
|
||||
if (!ts.isEnabled() || source.hasPermission(cfg.getTranslationBypassPermission())) {
|
||||
return processed;
|
||||
}
|
||||
String plain = PlainTextComponentSerializer.plainText().serialize(original);
|
||||
if (plain.isBlank()) {
|
||||
return processed;
|
||||
}
|
||||
if ("actionbar".equalsIgnoreCase(cfg.getTranslationMode())) {
|
||||
sendTranslationAsync(viewer, plain);
|
||||
return processed;
|
||||
}
|
||||
String translated = ts.getCachedTranslation(plain, viewer.getLocale());
|
||||
if (translated == null || translated.isBlank() || translated.equalsIgnoreCase(plain)) {
|
||||
return processed;
|
||||
}
|
||||
Component hover = Component.empty();
|
||||
boolean first = true;
|
||||
for (String line : cfg.getTranslationHoverFormat()) {
|
||||
if (!first) {
|
||||
hover = hover.append(Component.newline());
|
||||
}
|
||||
first = false;
|
||||
int idx = line.indexOf("{translation}");
|
||||
if (idx < 0) {
|
||||
hover = hover.append(ChatFormatter.parse(line));
|
||||
} else {
|
||||
hover = hover.append(ChatFormatter.parse(line.substring(0, idx)))
|
||||
.append(Component.text(translated))
|
||||
.append(ChatFormatter.parse(line.substring(idx + "{translation}".length())));
|
||||
}
|
||||
}
|
||||
return processed.hoverEvent(HoverEvent.showText(hover));
|
||||
}
|
||||
|
||||
private void sendTranslationAsync(Player viewer, String plainText) {
|
||||
if (plainText.isBlank()) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -33,11 +33,20 @@ public class TranslationService {
|
|||
private final String endpoint;
|
||||
private final String apiKey;
|
||||
private final boolean enabled;
|
||||
private final String provider;
|
||||
private final int timeoutMs;
|
||||
|
||||
public TranslationService(String endpoint, String apiKey, boolean enabled, Logger logger) {
|
||||
this(endpoint, apiKey, enabled, "libretranslate", 3000, logger);
|
||||
}
|
||||
|
||||
public TranslationService(String endpoint, String apiKey, boolean enabled, String provider, int timeoutMs,
|
||||
Logger logger) {
|
||||
this.endpoint = endpoint != null ? endpoint.replaceAll("/+$", "") : "";
|
||||
this.apiKey = apiKey;
|
||||
this.enabled = enabled;
|
||||
this.provider = provider == null ? "libretranslate" : provider.toLowerCase(Locale.ROOT);
|
||||
this.timeoutMs = Math.max(500, timeoutMs);
|
||||
this.logger = logger;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(TIMEOUT)
|
||||
|
|
@ -59,8 +68,37 @@ public class TranslationService {
|
|||
});
|
||||
}
|
||||
|
||||
private boolean isGoogle() {
|
||||
return "google".equals(provider);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled && !endpoint.isBlank();
|
||||
return enabled && (isGoogle() || !endpoint.isBlank());
|
||||
}
|
||||
|
||||
/** Cached translation for the given language, or null if it has not been translated yet. */
|
||||
public String getCachedTranslation(String text, String language) {
|
||||
return getCached(text, localeToLanguageCode(language));
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the text into every given language in parallel and waits up to timeoutMs.
|
||||
* Call it off the main thread; the results end up in the cache.
|
||||
*/
|
||||
public void prefetch(String text, java.util.Collection<String> languages, long timeoutMs) {
|
||||
if (!isEnabled() || text == null || text.isBlank()) {
|
||||
return;
|
||||
}
|
||||
java.util.List<CompletableFuture<String>> futures = new java.util.ArrayList<>();
|
||||
for (String language : languages) {
|
||||
futures.add(translateAsync(text, language));
|
||||
}
|
||||
try {
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||
.get(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
} catch (Exception e) {
|
||||
// timeout or failure: the message is simply sent without a translation
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<String> translateAsync(String text, String targetLanguage) {
|
||||
|
|
@ -89,7 +127,91 @@ public class TranslationService {
|
|||
}
|
||||
}
|
||||
|
||||
private volatile long gtxBlockedUntil;
|
||||
private volatile long chromeBlockedUntil;
|
||||
|
||||
/**
|
||||
* The gtx endpoint gets IP-blocked (HTTP 429 / captcha page) very quickly, so the Chrome dictionary
|
||||
* endpoint is tried first and gtx is only a fallback; a blocked endpoint is skipped for a minute.
|
||||
*/
|
||||
private String doFetchGoogle(String text, String targetLang) throws Exception {
|
||||
long now = System.currentTimeMillis();
|
||||
String q = java.net.URLEncoder.encode(text, StandardCharsets.UTF_8);
|
||||
String tl = java.net.URLEncoder.encode(targetLang, StandardCharsets.UTF_8);
|
||||
Exception last = null;
|
||||
if (now >= chromeBlockedUntil) {
|
||||
try {
|
||||
return parseChromeResponse(get("https://clients5.google.com/translate_a/t?client=dict-chrome-ex&sl=auto&tl="
|
||||
+ tl + "&q=" + q));
|
||||
} catch (Exception e) {
|
||||
chromeBlockedUntil = now + 60_000;
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
if (now >= gtxBlockedUntil) {
|
||||
try {
|
||||
return parseGoogleResponse(get("https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&dt=t&tl="
|
||||
+ tl + "&q=" + q));
|
||||
} catch (Exception e) {
|
||||
gtxBlockedUntil = now + 60_000;
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
throw last != null ? last : new RuntimeException("all translation endpoints are on cooldown");
|
||||
}
|
||||
|
||||
private String get(String url) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofMillis(timeoutMs))
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new RuntimeException("Google returned " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
}
|
||||
|
||||
/** Chrome dictionary response: [["translated","detected-language"], ...]. */
|
||||
static String parseChromeResponse(String body) {
|
||||
com.google.gson.JsonArray root = JsonParser.parseString(body).getAsJsonArray();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (com.google.gson.JsonElement segment : root) {
|
||||
if (segment.isJsonArray()) {
|
||||
com.google.gson.JsonArray parts = segment.getAsJsonArray();
|
||||
if (parts.size() > 0 && !parts.get(0).isJsonNull()) {
|
||||
sb.append(parts.get(0).getAsString());
|
||||
}
|
||||
} else if (segment.isJsonPrimitive()) {
|
||||
sb.append(segment.getAsString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sb.length() == 0) {
|
||||
throw new RuntimeException("empty translation response");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Google response: [[["translated","original",...], ...], null, "detected-language"]. */
|
||||
static String parseGoogleResponse(String body) {
|
||||
com.google.gson.JsonArray root = JsonParser.parseString(body).getAsJsonArray();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (com.google.gson.JsonElement segment : root.get(0).getAsJsonArray()) {
|
||||
com.google.gson.JsonArray parts = segment.getAsJsonArray();
|
||||
if (parts.size() > 0 && !parts.get(0).isJsonNull()) {
|
||||
sb.append(parts.get(0).getAsString());
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String doFetch(String text, String targetLang) throws Exception {
|
||||
if (isGoogle()) {
|
||||
return doFetchGoogle(text, targetLang);
|
||||
}
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("q", text);
|
||||
body.addProperty("source", "auto");
|
||||
|
|
@ -141,12 +263,12 @@ public class TranslationService {
|
|||
if (locale == null || locale.isBlank()) {
|
||||
return "en";
|
||||
}
|
||||
String lang = locale.split("_")[0].toLowerCase(Locale.ROOT);
|
||||
return switch (lang) {
|
||||
case "zh" -> "zh-CN";
|
||||
case "zh-tw", "zh-hk" -> "zh-TW";
|
||||
default -> lang;
|
||||
};
|
||||
String normalized = locale.toLowerCase(Locale.ROOT).replace('-', '_');
|
||||
if (normalized.startsWith("zh_tw") || normalized.startsWith("zh_hk")) {
|
||||
return "zh-TW";
|
||||
}
|
||||
String lang = normalized.split("_")[0];
|
||||
return "zh".equals(lang) ? "zh-CN" : lang;
|
||||
}
|
||||
|
||||
public void clearCache() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package com.loki.lochat.utils.format;
|
||||
|
||||
import me.clip.placeholderapi.PlaceholderAPI;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* LuckPerms prefix and suffix through PlaceholderAPI (%luckperms_prefix% / %luckperms_suffix%).
|
||||
* Returns an empty string when PlaceholderAPI or its LuckPerms expansion is not available.
|
||||
*/
|
||||
public final class LuckPermsAffixes {
|
||||
|
||||
private static final Pattern COLOR = Pattern.compile("(?i)(&#[0-9a-f]{6}|[&\u00A7][0-9a-fk-orx])");
|
||||
|
||||
private LuckPermsAffixes() {
|
||||
}
|
||||
|
||||
public static String prefix(Player player) {
|
||||
return resolve(player, "%luckperms_prefix%");
|
||||
}
|
||||
|
||||
public static String suffix(Player player) {
|
||||
return resolve(player, "%luckperms_suffix%");
|
||||
}
|
||||
|
||||
private static String resolve(Player player, String placeholder) {
|
||||
if (player == null || !Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
String result = PlaceholderAPI.setPlaceholders(player, placeholder);
|
||||
if (result == null || result.contains("%")) {
|
||||
return "";
|
||||
}
|
||||
return result;
|
||||
} catch (Exception | LinkageError e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Text without color codes. */
|
||||
public static String strip(String text) {
|
||||
return text == null ? "" : COLOR.matcher(text).replaceAll("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the LuckPerms prefix/suffix from a display name that already contains them
|
||||
* (for example when another plugin put them into the nickname), so they are not shown twice.
|
||||
* Returns the original text if nothing was removed.
|
||||
*/
|
||||
public static String withoutAffixes(String displayName, String prefix, String suffix) {
|
||||
String plain = strip(displayName).trim();
|
||||
String pp = strip(prefix).trim();
|
||||
String sp = strip(suffix).trim();
|
||||
String result = plain;
|
||||
if (!pp.isEmpty() && result.startsWith(pp)) {
|
||||
result = result.substring(pp.length()).trim();
|
||||
}
|
||||
if (!sp.isEmpty() && result.endsWith(sp)) {
|
||||
result = result.substring(0, result.length() - sp.length()).trim();
|
||||
}
|
||||
return result.equals(plain) || result.isEmpty() ? displayName : result;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue