feat: add chat message translation via LibreTranslate

Adds optional translation feature that shows translated chat messages
on hover in each viewer's Minecraft client language.

- TranslationService with LibreTranslate API support and caching
- Config options: enabled, endpoint, api-key
- Hover translation on chat messages in EnhancedChatRenderer
- Disabled by default, supports any LibreTranslate-compatible endpoint
This commit is contained in:
loki5512344 2026-07-06 00:01:59 +02:00
parent 4737d52cdd
commit 25cd6c3e88
7 changed files with 237 additions and 1 deletions

View file

@ -15,6 +15,7 @@ import com.loki.lochat.gradient.GradientModule;
import com.loki.lochat.integrations.DiscordIntegration;
import com.loki.lochat.managers.AutoMessageManager;
import com.loki.lochat.managers.CustomCommandManager;
import com.loki.lochat.translate.TranslationService;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
@ -31,6 +32,7 @@ public final class LoChat extends JavaPlugin {
private ServiceRegistry serviceRegistry;
private ConfigManager configManager;
private MessageConfig messageConfig;
private TranslationService translationService;
private GradientModule gradientModule;
private AutoMessageManager autoMessageManager;
private CustomCommandManager customCommandManager;
@ -53,6 +55,12 @@ public final class LoChat extends JavaPlugin {
// Конфиги
configManager = initializer.initConfigs();
messageConfig = initializer.initMessageConfig();
translationService = new TranslationService(
configManager.getTranslationEndpoint(),
configManager.getTranslationApiKey(),
configManager.isTranslationEnabled(),
getLogger()
);
// Service Registry (SOLID DI)
serviceRegistry = initializer.initServiceRegistry(configManager, messageConfig);
@ -141,4 +149,8 @@ public final class LoChat extends JavaPlugin {
public DiscordIntegration getDiscordIntegration() {
return discordIntegration;
}
public TranslationService getTranslationService() {
return translationService;
}
}

View file

@ -227,6 +227,20 @@ public class ConfigManager {
return accessor.getAnnouncementTitleDuration();
}
// ========== Chat Translation ==========
public boolean isTranslationEnabled() {
return accessor.isTranslationEnabled();
}
public String getTranslationEndpoint() {
return accessor.getTranslationEndpoint();
}
public String getTranslationApiKey() {
return accessor.getTranslationApiKey();
}
// ========== LoPreff integration ==========
public boolean isLopreffEnabled() {

View file

@ -136,6 +136,20 @@ public class ConfigAccessor {
return config.getInt("announcements.title-duration", 3);
}
// ========== Chat Translation ==========
public boolean isTranslationEnabled() {
return config.getBoolean("chat.translation.enabled", false);
}
public String getTranslationEndpoint() {
return config.getString("chat.translation.endpoint", "https://libretranslate.com");
}
public String getTranslationApiKey() {
return config.getString("chat.translation.api-key", "");
}
// ========== LoPreff integration ==========
public boolean isLopreffEnabled() {

View file

@ -20,7 +20,7 @@ public class ConfigLoader {
public void updateConfig() {
int currentVersion = config.getInt("config-version", 1);
int requiredVersion = 5;
int requiredVersion = 6;
if (currentVersion < requiredVersion) {
plugin.getLogger().info("Обновление конфига с версии " + currentVersion + " до " + requiredVersion);
@ -51,6 +51,9 @@ public class ConfigLoader {
setIfMissing("lopreff.use-gradient-name", true);
setIfMissing("lopreff.use-custom-prefix", true);
setIfMissing("moderation.voice-mute-console-command", "");
setIfMissing("chat.translation.enabled", false);
setIfMissing("chat.translation.endpoint", "https://libretranslate.com");
setIfMissing("chat.translation.api-key", "");
}
private void setIfMissing(String path, Object value) {

View file

@ -3,6 +3,7 @@ package com.loki.lochat.renderer;
import com.loki.lochat.renderer.components.EmojiComponent;
import com.loki.lochat.renderer.components.GradientBuilder;
import com.loki.lochat.renderer.components.UrlProcessor;
import com.loki.lochat.translate.TranslationService;
import com.loki.lochat.utils.format.TextFormatter;
import com.loki.lochat.utils.player.MentionHandler;
@ -12,10 +13,12 @@ import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -52,6 +55,7 @@ public class EnhancedChatRenderer implements ChatRenderer {
if (viewer instanceof Player vp) {
processed = mentionHandler.processMentions(processed, source, vp);
processed = applyTranslation(processed, vp);
}
// Получаем формат из конфига
@ -198,6 +202,66 @@ public class EnhancedChatRenderer implements ChatRenderer {
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
}
private Component applyTranslation(Component message, Player viewer) {
if (!cfg.isTranslationEnabled()) {
return message;
}
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
TranslationService ts = loChat.getTranslationService();
if (!ts.isEnabled()) {
return message;
}
String plainText = PlainTextComponentSerializer.plainText().serialize(message);
if (plainText.isBlank()) {
return message;
}
String viewerLang = viewer.locale().getLanguage();
String translated = ts.translate(plainText, viewerLang);
if (translated.equals(plainText)) {
return message;
}
String langName = LANGUAGE_NAMES.getOrDefault(viewerLang, viewerLang.toUpperCase(java.util.Locale.ROOT));
Component hover = Component.text()
.append(com.loki.lochat.utils.format.ChatFormatter.parse(
"&#AAAAAA[" + langName + "]&#FFFFFF "))
.append(Component.text(translated))
.build();
return message.hoverEvent(HoverEvent.showText(hover));
}
private static final Map<String, String> LANGUAGE_NAMES = new HashMap<>();
static {
LANGUAGE_NAMES.put("en", "EN");
LANGUAGE_NAMES.put("ru", "RU");
LANGUAGE_NAMES.put("de", "DE");
LANGUAGE_NAMES.put("fr", "FR");
LANGUAGE_NAMES.put("es", "ES");
LANGUAGE_NAMES.put("pt", "PT");
LANGUAGE_NAMES.put("it", "IT");
LANGUAGE_NAMES.put("nl", "NL");
LANGUAGE_NAMES.put("pl", "PL");
LANGUAGE_NAMES.put("uk", "UA");
LANGUAGE_NAMES.put("zh", "CN");
LANGUAGE_NAMES.put("ja", "JP");
LANGUAGE_NAMES.put("ko", "KR");
LANGUAGE_NAMES.put("ar", "AR");
LANGUAGE_NAMES.put("tr", "TR");
LANGUAGE_NAMES.put("vi", "VN");
LANGUAGE_NAMES.put("th", "TH");
LANGUAGE_NAMES.put("cs", "CS");
LANGUAGE_NAMES.put("sv", "SV");
LANGUAGE_NAMES.put("da", "DA");
LANGUAGE_NAMES.put("fi", "FI");
LANGUAGE_NAMES.put("el", "EL");
LANGUAGE_NAMES.put("hu", "HU");
LANGUAGE_NAMES.put("ro", "RO");
LANGUAGE_NAMES.put("no", "NO");
LANGUAGE_NAMES.put("sk", "SK");
LANGUAGE_NAMES.put("bg", "BG");
LANGUAGE_NAMES.put("hr", "HR");
LANGUAGE_NAMES.put("sr", "SR");
}
private TextColor parseColor(String hex) {
if (hex == null || hex.isBlank()) {
return FALLBACK_COLOR;

View file

@ -0,0 +1,117 @@
package com.loki.lochat.translate;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
public class TranslationService {
private static final Duration TIMEOUT = Duration.ofSeconds(10);
private final Logger logger;
private final HttpClient httpClient;
private final Gson gson;
private final Map<String, Map<String, String>> cache = new ConcurrentHashMap<>();
private final String endpoint;
private final String apiKey;
private final boolean enabled;
public TranslationService(String endpoint, String apiKey, boolean enabled, Logger logger) {
this.endpoint = endpoint != null ? endpoint.replaceAll("/+$", "") : "";
this.apiKey = apiKey;
this.enabled = enabled;
this.logger = logger;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(TIMEOUT)
.build();
this.gson = new Gson();
}
public boolean isEnabled() {
return enabled && !endpoint.isBlank();
}
public String translate(String text, String targetLanguage) {
if (!isEnabled() || text == null || text.isBlank()) {
return text;
}
String langCode = localeToLanguageCode(targetLanguage);
String cacheKey = text.intern();
Map<String, String> langMap = cache.get(cacheKey);
if (langMap != null) {
String cached = langMap.get(langCode);
if (cached != null) {
return cached;
}
}
try {
String translation = fetchTranslation(text, langCode);
cache.computeIfAbsent(cacheKey, k -> new ConcurrentHashMap<>()).put(langCode, translation);
return translation;
} catch (Exception e) {
logger.warning("Translation failed for \"" + abbreviate(text) + "\" to " + langCode + ": " + e.getMessage());
return text;
}
}
private String fetchTranslation(String text, String targetLang) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("q", text);
body.addProperty("source", "auto");
body.addProperty("target", targetLang);
body.addProperty("format", "text");
if (apiKey != null && !apiKey.isBlank()) {
body.addProperty("api_key", apiKey);
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint + "/translate"))
.timeout(TIMEOUT)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("API returned " + response.statusCode() + ": " + response.body());
}
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
return json.get("translatedText").getAsString();
}
public static String localeToLanguageCode(String locale) {
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;
};
}
public void clearCache() {
cache.clear();
}
private static String abbreviate(String text) {
return text.length() > 40 ? text.substring(0, 37) + "..." : text;
}
}

View file

@ -27,6 +27,18 @@ chat:
max-message-length: 2000
clear-lines: 100
# ── Chat Translation ──────
# Translates chat messages on hover into each viewer's Minecraft client language.
# Uses LibreTranslate API (free, open-source).
# Public instances: https://libretranslate.com, https://translate.argosopentech.com
# You can self-host: https://github.com/LibreTranslate/LibreTranslate
translation:
enabled: false
# API endpoint URL (LibreTranslate-compatible)
endpoint: "https://libretranslate.com"
# Optional API key if the endpoint requires authentication
api-key: ""
# ── Chat Appearance ────────────────────────────────────────────────────────────
appearance: