Skip to content

feat(translate): Add automatic message translation to player language#335

Open
SolverNA wants to merge 29 commits into
Flectone:masterfrom
SolverNA:master
Open

feat(translate): Add automatic message translation to player language#335
SolverNA wants to merge 29 commits into
Flectone:masterfrom
SolverNA:master

Conversation

@SolverNA
Copy link
Copy Markdown

Summary

This PR implements automatic message translation based on player's client language settings. Messages are now automatically translated to each player's locale, with an option to toggle between original and
translation.

Changes

  • TranslatedMessage model: Stores original text and all translations by locale
  • Auto-translation system: Translates messages once per unique locale (not per player)
  • Toggle command: /toggleoriginal to switch between original and translated message
  • Optimized API usage: 100 players with 4 languages = 4 API requests (not 100!)
  • Updated localizations: Button now toggles original/translation instead of creating new message

How it works

  1. Player sends message in their language (e.g., "Hello" in en_us)
  2. System detects all unique locales on server (e.g., ru_ru, de_de, fr_fr)
  3. Translates message once per locale (3 API requests total)
  4. Each player receives message in their language
  5. Click button on message to see original text

Configuration

Requires language.by-player: true in config.yml and translation API setup (Google/Yandex/DeepL).

Testing

Tested with multiple players using different client languages. Translation and toggle functionality working as expected.

Note

Paper/Bukkit module has pre-existing compilation errors (missing Paper-specific classes). Core module compiles successfully. Fabric, BungeeCord, and Velocity versions work perfectly.

@TheFaser
Copy link
Copy Markdown
Member

Разве такой автоматический перевод не добавляет задержку в чат у сообщений?

@SolverNA
Copy link
Copy Markdown
Author

Да сег сделаю асинхронный перевод

@TheFaser
Copy link
Copy Markdown
Member

Так разве всёравно не будет задержки в сообщениях? Ведь после отправки сообщения оно должно быть переведено через API, а это даст задержку в отправлении сообщения

@SolverNA
Copy link
Copy Markdown
Author

Нет, мы не будем делать одним потоком всё. Сообщения отправляем как обычно оригиналом, и асинхронно отправляем запрос на перевод, перевод приходит, изменяем сообщение на переведённое.

@TheFaser
Copy link
Copy Markdown
Member

Идею я твою понял, как будет время я посмотрю твой PR, некоторые моменты можно упростить

SolverNA added 3 commits May 13, 2026 17:59
  - Created TranslatedMessage model to store all message translations
  - Extended HistoryMessage to support original/translation toggle
  - Added /toggleoriginal command to switch between original and translation
  - Implemented automatic translation to all unique server locales (API request optimization)
  - Updated localizations: button now toggles original/translation instead of creating new message
  - Added PulseAutoTranslateListener to handle auto-translation in MessagePrepareEvent
  - Modified DeleteModule to work with translations and toggling

  Now each message is automatically translated to the receiver's language.
  Button on message allows switching between original and translation.
  - Created TranslationCacheService with MyMemory API integration
  - Added TRANSLATION_CACHE (24h, 50000 entries) for persistent translation storage
  - Integrated MyMemory API as fallback before main translation service
  - Converted translateToAllLocales to async with CompletableFuture for parallel translations
  - Added useMyMemory config option (default: true)
  - Added MYMEMORY service type to translation services
  - Updated configs for minecraft and hytale platforms

  Benefits:
  - No chat delay: original message sent immediately, translations load in background
  - MyMemory provides free cached translations (10k requests/day)
  - Parallel translation requests instead of sequential
  - Local cache reduces API calls for repeated phrases
  - ToggleOriginalModule: use registerCustomCommand instead of registerCommand
    so /toggleoriginal is registered as a top-level command
  - DeleteModule: prevent double-save of the same message UUID in history
  - PulseAutoTranslateListener: replace ConcurrentHashMap with 30s Guava Cache
    so all receivers get translation, not just the first one;
    fix unsafe FEntity cast with instanceof check
  - TranslationCacheService: recreate ExecutorService on shutdown() so async
    translations survive plugin reload; reduce thread pool from 10 to 4
  - TranslateModule: remove dead Map<UUID,TranslatedMessage> (memory leak)
@TheFaser
Copy link
Copy Markdown
Member

Меня пока что смущает, что ты делаешь это с помощью ИИ, хоть он и понимает контекст проекта, но в некоторых местах избыточные проверки

public String translate(FPlayer fPlayer, String source, String target, String text) {
return switch (config().service()) {
// Check cache first if MyMemory is enabled
if (config().useMyMemory() != null && config().useMyMemory()) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

проверка на null бесполезна, оно всегда будет существовать, если вписано в файл конфигурации

private final MessageDispatcher messageDispatcher;
private final ModuleController moduleController;
private final ModuleCommandController commandModuleController;
private final net.flectone.pulse.service.TranslationCacheService translationCacheService;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

зачем указывать полный путь к классу?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

меня тоже бесит эта фигня в иишках)

@SolverNA
Copy link
Copy Markdown
Author

я крч хотел с ии набросать а потом подправить сейчас крч довожу потом буду патчить

@TheFaser
Copy link
Copy Markdown
Member

я крч хотел с ии набросать а потом подправить сейчас крч довожу потом буду патчить

В целом мне твоя идея нравится, но тебе нужно это доработать.

  1. Удали хранение переводов в облаке, оставь только в кэше
  2. Я не думаю, что стоит добавлять прям отдельную команду для этого, почему бы не сделать /translateto toggle вариант у команды? Просто проверять перед переводом значение
  3. Не знаю зачем, но ты пытаешься что-то делать с Delete модулем, это бесполезно т.к. Delete модуль самостоятелен и должен работать без внешних вмешательств

Comment on lines +11 to +46
public record HistoryMessage(
UUID uuid,
Component component,
@Nullable TranslatedMessage translatedMessage,
boolean showOriginal
) {

public HistoryMessage(UUID uuid, Component component) {
this(uuid, component, null, false);
}

public HistoryMessage(UUID uuid, Component component, TranslatedMessage translatedMessage) {
this(uuid, component, translatedMessage, false);
}

/**
* Get the component to display based on showOriginal flag and player locale.
*/
public Component getDisplayComponent(String playerLocale) {
if (translatedMessage == null) {
return component;
}

if (showOriginal) {
return translatedMessage.getOriginal();
}

return translatedMessage.getTranslation(playerLocale);
}

/**
* Check if this message has translations.
*/
public boolean hasTranslations() {
return translatedMessage != null;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

мне до сих пор не понятно, зачем ты трогаешь delete модуль, если он никак не связан с твоей идеей. Он будет правильно отрабатывать в любом случае без всех этих проверок, потому что сообщения приходят пользователю через пакеты, которые FlectonePulse слушает

DEEPL,
GOOGLE,
YANDEX
YANDEX,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

зачем useMyMemory и отдельный сервис MYMEMORY?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

кажется я понял, ты сохраняешь переведённые сообщения в облаке, чтобы их не переводить, но у этого есть одно но: у тебя в любом случае это HTTP запрос, который занимает время и смысла от того, что ты это сохраняешь в облаке нет + сообщения редко повторяться будут.

Хранить переводы в облаке плохая идея, нужно только локально в кэше это делать, rate limit сложно будет получить от API перевода

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mymemory это сервис где есть уже готовые переводы для коротких фраз какбы онлайн кеш и мы вначале обрашаемся в локальный кеш потом в mymemory и уже потом запрос на перевод через api переводчиков

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mymemory это сервис где есть уже готовые переводы для коротких фраз какбы онлайн кеш и мы вначале обрашаемся в локальный кеш потом в mymemory и уже потом запрос на перевод через api переводчиков

Так а смысл, это тоже HTTP запрос, который будет задерживать отправление сообщения. Перевод текста моментальный от API, максимум можно бояться rate limit, но этого сложного достичь

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можешь, конечно, реализацию эту оставить, но тогда её не нужно вписывать как enum, оставь только булеан

}
in.close();

// Parse JSON response: {"responseData":{"translatedText":"..."}}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

в проекте есть gson, можно создать обёртку record и использовать gson.fromJson без костыльных парсингов

@TheFaser
Copy link
Copy Markdown
Member

TheFaser commented May 13, 2026

Гитхаб наспамил, ща пофиксим, во, норм

SolverNA added 2 commits May 14, 2026 14:23
event.withMessage(translatedComponent) replaced the fully formatted
component (player nickname + colors + ⇄ button) with Component.text(plain_text),
causing the chat to display a plain, unformatted message.

Now we save event.message() (full format) to the translations history
for toggleOriginal to work without affecting the message itself.
…ine.

Logs at every stage for diagnosing why translation isn't working:
- PulseAutoTranslateListener: PrepareEvent/SendEvent (inputs, skip reasons, cache miss/hits)
- TranslateModule: translateToAllLocales (online players, locales, async startup), addTag (button resolver)
- TranslationCacheService: HTTP requests to MyMemory (URL, code, body), cache GET/PUT, async tasks
- PulseTranslateListener: FormattingEvent with flag parsing

[AutoTranslate] and [Translate] prefixes for convenient grepping in server.log.
@SolverNA
Copy link
Copy Markdown
Author

SolverNA commented May 14, 2026

Привет! Разобрался, зачем ИИ использовал deleteModule.

Я изначально думал, что плагин умеет изменять существующие сообщения в чате, и хотел использовать эту возможность так: оригинальное сообщение после асинхронного перевода заменяется на переведённое, а кнопка «Показать оригинал» — возвращает его обратно. Но посмотрел, как плагин работает на самом деле, и оказалось, что перевод приходит отдельным сообщением, а не редактирует существующее. Моя же идея была именно в изменении сообщения прямо в истории чата.


Архитектура, которую предложил ИИ:

Создаём ChatHistoryService, который хранит историю чата. Для эмуляции «редактирования» сообщения делаем следующее:

  1. Находим нужное сообщение в истории.
  2. Подменяем его текст (оригинал ↔ перевод).
  3. Переотправляем игроку историю чата вплоть до конца истории чата.

Это создаёт эффект изменения сообщения на клиенте, хотя технически это переотправка истории.

Дополнительно — для оптимизации храним не полную историю для каждого игрока, а только пометки: должен ли конкретный игрок «видеть» то или иное сообщение в оригинале или переводе. Принцип похож на то, как устроен Git — полная история общая, а состояние для каждого игрока восстанавливается по этим меткам.


Есть небольшие технические вопросы:

  • Будет ли переотправка истории заметна игрокам визуально?
  • Насколько это корректный подход с архитектурной точки зрения?
  • Не создаст ли это нагрузку на сервер при множественной переотправки истории игрокам?

@SolverNA
Copy link
Copy Markdown
Author

Структурировал через ИИ, чтобы было понятно. Мой текст просто оставляет желать лучшего.

@TheFaser
Copy link
Copy Markdown
Member

В майнкрафте нельзя изменять отправленное сообщение, потому что на клиенте это просто отрендеренный текст и там нет динамики, это как txt файл, только в красивой обёртке.

Идея тоже прикольная, чтобы "изменять" переведённое сообщение, но не будет ли это слишком нагружать и сервер, и клиента не знаю. Если ты правда хочешь это довести до конца, то тебе не нужно изменять DeleteModule, ты должен реализовать свой механизм, который можешь по аналогии скопировать из delete модуля, потому что FlectonePulse имеет модульную структуру и каждые модули независимы друг от друга

SolverNA added 7 commits May 14, 2026 16:38
  Move per-receiver chat history (playersHistory, cachedComponents) and the
  brute-force chat replay mechanism (sendUpdate) out of DeleteModule into a
  new shared ChatHistoryService.

  DeleteModule keeps its delete-specific logic (addTag, remove, toggleOriginal)
  but delegates all history storage and replay operations to ChatHistoryService.
  Public API is preserved — PulseDeleteListener, PulseAutoTranslateListener and
  ToggleOriginalModule continue to compile and behave identically.

  This sets up decoupling for the auto-translate feature in the follow-up
  commit: TranslateModule will use ChatHistoryService directly instead of
  reaching into DeleteModule (which was a layering violation flagged by the
  plugin author).
  Per plugin author's request: keep modules self-contained, no cross-module
  dependencies. The previous translate feature reached into DeleteModule
  (save/sendUpdate/toggleOriginal) which violated the plugin's modular design.

  Reverts the following author-owned files to caa9551:
    - DeleteModule, HistoryMessage (delete/model)
    - TranslateModule, PulseTranslateListener
    - TranslatetoModule, Command, CommandModule
    - YAML configs and localizations (translate.action button restored to
      author's /translateto idiom, /toggleoriginal command removed)

  Removes ChatHistoryService (introduced in fedfc9c) — author rejected the
  shared-service approach; each module must own its state.

  Removes user-created files that depended on the removed extensions:
    - ToggleOriginalModule (called deleteModule.toggleOriginal)
    - PulseAutoTranslateListener (called translateModule.translateToAllLocales
      and deleteModule.save with translatedMessage)
    - TranslatedMessage record

  Preserved for the self-contained rebuild in follow-up commits:
    - TranslationCacheService (standalone HTTP + cache, plus the %7C URL fix)
    - CacheName.TRANSLATION_CACHE and ModuleName.COMMAND_TOGGLEORIGINAL
      (1-line additions needed for the rebuild)
  TranslateModule now owns the full auto-translate flow without depending on
  DeleteModule. Chat history mechanism is copied by analogy (per module author's
  guidance: modules must stay independent, duplicate code over shared state).

  Flow:
    1. Player writes — original is dispatched immediately to all receivers (no
       blocking, no delay).
    2. PulseAutoTranslateListener.onMessagePrepareEvent kicks off async translation
       for every unique online locale other than the sender's, via
       TranslationCacheService.translateWithMyMemoryAsync (chain comes next commit).
    3. PulseAutoTranslateListener.onMessageSendEvent records the original formatted
       component into TranslateModule's own per-receiver history together with the
       TranslatedMessage that will be populated by callbacks.
    4. When each async translation lands, TranslateModule.replayForLocale triggers
       a chat redraw for every online receiver whose locale matches — they had
       the original; now they see the translation.

  Per-receiver behavior:
    - Same locale as sender → no translation, no toggle button (nothing to toggle).
    - Different locale → sees translation after async lands, button toggles to
      original via /toggleoriginal <message-uuid>.
    - Console/non-player sender → no button (auto-translate only handles player chat).

  ToggleOriginalModule recreated to call TranslateModule.toggleOriginal directly —
  no DeleteModule reference. Reuses Deletemessage command config (cooldown / aliases
  / permission) since the plugin has no dedicated config record for this command.

  YAML: translate.action button now runs /toggleoriginal <message> (en_us and
  ru_ru localizations) — replaces the author's original /translateto idiom for
  the auto-translate variant of the feature.

  History length is borrowed from delete module config — both modules push out
  the same number of empty newlines for the brute-force redraw.
  Startup crash: CacheRegistry iterates every CacheName enum value and looks
  up its config in cache().types(). The TRANSLATION_CACHE enum value survived
  the revert (it's needed by TranslationCacheService) but the corresponding
  config.yml stanza was lost when author's resource files were restored.

  Re-add the same TRANSLATION_CACHE block originally introduced in ccccadc:
    invalidate_on_reload: false
    duration: 24 HOURS
    size: 50000

  Applied to both minecraft and hytale default configs.
…howing as \uXXXX

  MyMemory returns non-ASCII characters as JSON unicode escapes (e.g. Cyrillic
  'и' arrives as the literal 5 chars \u0438). The hand-rolled JSON parser
  only handled \n \t \\ \" escapes — \uXXXX fell through unchanged, so
  the cache and chat ended up showing the raw escape sequences.

  Replaced the manual indexOf-based parser with Gson, which handles all JSON
  string escapes uniformly. Also drops ~70 lines of custom parsing/escaping
  code.

  Project already pulls in Gson 2.8.0 as a shaded dep, so used the older
  'new JsonParser().parse(...)' API for compatibility (parseString static
  method was added in 2.8.6).
…rillic correctly

  Two related fixes that surfaced together during testing:

  1) JSON unicode escape — MyMemory returns non-ASCII as \uXXXX in the JSON
     payload (Cyrillic, CJK, etc.). The hand-rolled parser only handled \n
     \t \\ \" escapes, so translations like 'и' arrived as the literal 6
     chars '\u0438'. Replaced the custom parser with Gson (already a project
     dep) which decodes every JSON string escape uniformly.

  2) Plain-text replay — after a translation landed, sendUpdate redrew chat
     using Component.text(translation), losing the chat format (nickname,
     colors, ⇄ button). Now translations are kept as plain strings inside
     TranslatedMessage; the rendered component is produced lazily in
     TranslateHistoryMessage.getDisplayComponent via Component.replaceText —
     swap original text literal with translation inside the existing component
     tree. No pipeline rebuild needed; all surrounding formatting stays intact.

  TranslateHistoryMessage now carries the raw player text alongside the
  formatted component so replaceText has a literal to match. PulseAutoTranslateListener
  passes eventMetadata.message() into save() for this purpose.

  Map type in TranslatedMessage.translations changed from <String,Component>
  to <String,String> — Components are no longer stored, only the text strings.
…n via config

  MyMemory has been returning poor translations during testing — e.g.
  'привет как дела' → 'salam necesen' (Azerbaijani), short English phrases
  echoed back unchanged, plus a 1000 word/day per-IP rate limit. Switch the
  default auto-translate provider to Google Translate's free public gtx
  endpoint (no API key required).

  Config: message.format.translate.use_my_memory (default false).
    - false → Google Translate (translate.googleapis.com/translate_a/single)
    - true  → MyMemory (kept available for users who prefer it)

  TranslationCacheService now exposes translateWithGoogleAsync alongside the
  existing translateWithMyMemoryAsync; both share a small submitAsync helper
  that handles cache lookup, executor submission, and post-success cache PUT.
  Google response parsed with Gson — the gtx endpoint returns a nested array
  [[['translated','original',null,null,1]],null,'en',...]; we concatenate the
  first item of every segment for multi-sentence translations.

  Also: skip the chat redraw when translation comes back equal to the
  original — saves a useless replay for messages MyMemory/Google can't
  translate (mostly proper nouns and short phrases). The toggle button stays
  visible (it's baked into the formatted component at build time) but
  clicking it is a visual no-op in this case, since both states show the
  same text.
SolverNA added 16 commits May 14, 2026 18:21
  Two related changes so chat redraw preserves the entire visible chat and so
  toggle behaviour is debuggable.

  Full chat capture (the 'complete chat dump' the user asked for):
    - Listener captures every chat-like event a player sees, not just
      auto-translated player chat. New @Pulse handler for MessageReceiveEvent
      saves the component (server messages, join/quit, vanilla broadcasts,
      other plugins' messages) into TranslateModule's own per-player history.
    - Existing onMessageSendEvent unconditionally records every CHAT-destination
      send into history (translatedMessage may be null — recorded anyway).
    - selfOriginatedComponents dedups against plugin-originated components so
      a single chat line isn't saved twice (once on send, once on receive).
    - onPlayerQuit clears the player's history to free memory.

  Without this, sendUpdate's brute redraw rebuilds the visible chat using
  only TranslateModule's history; anything not in that history (server
  messages between chat lines) got pushed off-screen.

  Toggle logging:
    - TranslateModule.toggleOriginal logs every branch — player lookup,
      history presence, entry search, hasTranslations check, showOriginal
      flip, sendUpdate trigger. Distinct codepath for 'entry found but no
      translation' vs 'entry not found', so we can tell from logs whether
      server messages (no translation) are being clicked vs. real chat.
    - sendUpdate logs receiver name, locale, history size and number of
      empty newlines being spammed.

  All log lines tagged [Toggle] for easy grep.
…atches

  Provider chain
    Config replaces the use_my_memory boolean with an ordered list:
      format.translate.providers: [GOOGLE, MYMEMORY, DEEPL, YANDEX]
    TranslationCacheService.translateAsync iterates providers in order, returning
    the first response that's non-null/non-empty AND differs from the input text.
    Any other outcome (HTTP 429, exception, integration disabled returning input
    verbatim, etc.) moves on to the next provider. All-failed logs a WARNING and
    the chain returns null.

    Adds DEEPL/YANDEX support via integrationModuleProvider — they delegate to
    existing IntegrationModule.deeplTranslate/yandexTranslate which already
    handle disabled-state by returning the input text (we treat that as a fail
    and continue the chain). No API keys → simply not in the chain.

    Empty providers list logs a WARNING with a copy-paste config example and
    auto-translate is disabled for that call.

  In-flight de-duplication
    TranslationCacheService now keeps a (source, target, text) → CompletableFuture
    map. Concurrent calls for the same key share the same future instead of
    hitting the API twice. The map entry is removed in whenComplete.

  PrepareEvent dedupe (mitigation for double chat dispatch)
    PulseAutoTranslateListener tracks (sender, locale, text) seen within the
    last 1 second and skips duplicates. Each chat message on Paper/Purpur fires
    the prepare flow twice with different UUIDs — root cause still under
    investigation, but this kills the user-visible symptoms (2x API calls,
    2x save into history, 2x sendUpdate replay duplicating chat lines).
…try; Yandex

   IAM token

  Cache fallback
    TranslateModule.sendUpdate now pre-fills TranslatedMessage.translations
    from the global TranslationCacheService cache before drawing. A message
    whose initial async translation failed (429, network, etc.) will pick up
    from the global TranslationCacheService cache before drawing. A message
    whose initial async translation failed (429, network, etc.) will pick up
    the cached value next time anything triggers a redraw — without firing
    another API call.

  Toggle as retry
    When the player flips a message to 'show translation' and no usable
    translation exists for their locale, toggleOriginal kicks off a fresh
    translateAsync via the configured provider chain. On success the result
    is stored in the per-message map and sendUpdate redraws. Debounced via
    the existing in-flight dedup in TranslationCacheService — back-to-back
    clicks don't spam the API.

  Yandex IAM token
    YandexIntegration.hook prefers a new 'iamToken' config field over the
    deprecated 'token' (OAuth) field. Yandex Cloud is deprecating OAuth by
    end of 2026 and recent translate-API requests already return
    UNAUTHENTICATED for OAuth tokens.

    To migrate: get an IAM token via 'yc iam create-token' (12h TTL) and
    set it under integration.yandex.iamToken in config/integration.yml.
    Keeping 'token' empty + 'iamToken' filled uses the modern path.
    Existing 'token' (OAuth) still works as fallback for backward compat.
  Replaces the per-player Map<UUID, List<TranslateHistoryMessage>> with a
  single time-ordered global list plus a viewer set on each entry. A message
  seen by N players is stored once with a N-sized viewer set instead of N
  duplicated entries with their own Component trees.

  Memory: on a 100-player server with 100 messages of history, drops from
  ~10000 entries to ~100. Component tree (the heaviest field) is shared.

  Per-player toggle state (showOriginal) was previously a field on each
  history entry — now lives in a separate map TranslateModule.playerOriginalToggles:
  Map<playerUUID, Set<messageUUID>>. A player's set contains the message UUIDs
  they've flipped to 'show original'.

  TranslateHistoryMessage.getDisplayComponent now takes showOriginal as a
  parameter (passed by sendUpdate from the player's toggle set) instead of
  reading it off the record.

  save() looks up by UUID and adds receiver to viewers if the entry already
  exists (one Component per message, no duplication on per-receiver save).
  sendUpdate() filters globalHistory by viewer membership before drawing.
  clearHistory() on player quit removes them from every viewer set and drops
  their toggle map; entries themselves stay for other players.

  FIFO trim of the oldest when globalHistory > historyLength().
  Toggle button was disappearing after the global-history refactor: the first
  MessageSendEvent to land for a chat (often the sender themselves, same
  locale → no button by addTag's locale check) saved a buttonless Component
  into the entry. Subsequent SendEvents for receivers of other locales hit
  the existing-entry branch in save() and only added themselves to viewers,
  leaving the buttonless Component in place — so foreign-locale receivers
  never saw a toggle button.

  Fix: store a Map<locale, Component> per entry instead of a single shared
  Component. Each receiver's per-locale Component variant is recorded the
  first time someone of that locale saves the message. getDisplayComponent
  now picks the variant matching the receiver's locale.

  Memory stays bounded — ≤ N entries × M unique locales, not × players. On a
  typical server with 2-4 locales that's a 25-50x reduction vs the old
  per-player history without losing per-locale button visibility.

  Also: integration.yml now ships an iam_token field under yandex. The Java
  code already preferred it over OAuth in the previous commit, but the field
  wasn't surfaced in the default YAML, so users had to add it manually.
  Same change applied to the hytale variant.
  The IAM path required users to regenerate a 12-hour token from yc CLI and
  plug it into integration.yml — too much friction for a chat feature. User
  prefers to defer Yandex entirely and rely on Google + MyMemory for now.

  Reverts:
    - Integration.Yandex record: drop iamToken field, back to (enable, token, folderId)
    - YandexIntegration.hook: back to OAuth-only path
    - integration.yml (minecraft, hytale): drop iam_token YAML field

  Yandex still works for users with valid OAuth tokens; once Yandex Cloud
  fully deprecates OAuth end of 2026, the integration will need to be revisited.
  All [AutoTranslate] and [Toggle] info-level logs are now silent unless the
  JVM was started with -Dflectonepulse.debug=true (the existing plugin-wide
  debug flag from FLogger.DEBUG_ENABLED). On a production server they were
  spamming several lines per chat message; now they only appear when the
  operator explicitly opts in.

  Implementation: added fLogger.debug(...) and fLogger.debug(fmt, args...)
  to FLogger — internal guard on DEBUG_ENABLED, otherwise no-op. Replaced
  every fLogger.info("[AutoTranslate]"...) and fLogger.info("[Toggle]"...)
  call across TranslateModule, PulseAutoTranslateListener and
  TranslationCacheService with fLogger.debug(...).

  Warnings (provider errors, all-providers-failed, parse failures, etc.)
  stay on fLogger.warning — real problems must surface even without the
  debug flag.
…s opt-out

  New config flag message.format.translate.auto (default true) splits the
  feature into two operational modes, cleanly separating our async-translate
  work from the original /translateto-on-click flow the author shipped.

    auto: true  (default)
      Our path: PulseAutoTranslateListener runs async translation chains on
      chat, saves to history, the ⇄ button issues /toggleoriginal which
      flips between translation and original via brute-redraw replay.

    auto: false
      Author's classic path: the listener bails out at every entry point
      (no async, no history, no toggle). The ⇄ button issues
      /translateto <src> <dst> <uuid> which synchronously translates on
      click and dispatches the translation as a new chat line — exactly
      what shipped before the auto-translate feature.

  YAML changes:
    - message.format.translate: + 'auto: true' field
    - localization.message.format.translate: + 'action_manual' field next
      to 'action'; one is picked at runtime based on the auto flag
    - en_us / ru_ru / hytale defaults populated with both templates

  Code changes:
    - Message.Format.Translate record: + Boolean auto
    - Localization.*.Translate record: + String actionManual
    - TranslateModule.addTag branches on config().auto():
        true  → simple <message>=UUID substitution, /toggleoriginal flow
        false → author's <language>/<language>/<message> parsing,
                /translateto flow (saveMessage UUID lookup intact)
    - PulseAutoTranslateListener.isAutoMode() guard at every handler
      entry; non-CHAT/non-FPlayer/destination checks come after
…it race

  Bug: when a translation was already cached, the receiver saw the ORIGINAL
  text on first display, and only on the *next* chat message did the prior
  one update to the translated version.

  Cause: translateAsync's cache-HIT path completes synchronously inside the
  caller thread → thenAccept fires immediately → replayForLocale → sendUpdate
  runs before MessageSendEvent has saved the entry to global history. Empty
  visible history → replay shows nothing useful → original goes out. Next
  chat message triggers another replay; by then history is populated and the
  fix retroactively shows up for the previous line.

  Fix: at MessageSendEvent, do a synchronous TranslationCacheService.get for
  (sourceLang, receiverLocale, originalText). On hit, apply the translation
  to event.message() directly via Component.replaceText and emit a withMessage
  override — the receiver sees the translation in the initial display, no
  replay needed.

  The save-to-history call uses the unmodified original Component so the
  toggle 'show original' branch keeps working — translated variants are
  rebuilt on demand from the cached translation + original component via
  replaceText in getDisplayComponent.

  Also: TranslateModule.getCachedTranslation exposed as a thin delegate over
  TranslationCacheService.get, so listener code doesn't reach into the cache
  service directly.
  When translateAsync resolves synchronously via cache HIT, the .thenAccept
  callback runs inline on the PrepareEvent thread — before any MessageSendEvent
  has a chance to fire. The resulting replayForLocale call re-renders the
  stale per-receiver history (which does NOT yet contain this message), then
  SendEvent dispatches the actual new chat. The receiver ends up with the old
  visible chat lines duplicated, immediately followed by the new translated
  line — visually 'hello hello' or 'hello привет hello'.

  Fix: do a synchronous cache lookup at the top of translateToAllLocales,
  per target locale. On hit, populate TranslatedMessage.translations directly
  and return — no async, no replay. The cache-hit path for the current message
  is handled by the existing sync check in PulseAutoTranslateListener.onMessageSendEvent
  which applies replaceText to the outgoing Component. Replay remains useful
  only for the cache-MISS path, where older history entries need a redraw
  once the chain resolves the translation.
  When the same text is sent in rapid succession (< 1s apart), the listener's
  1-second dedup window suppresses every PrepareEvent after the first. The
  chat messages themselves still flow through MessageSendEvent — but with no
  preparedTranslations entry, onMessageSendEvent used to bail out early and
  the receiver saw the original text instead of the cached translation. User
  observed this as 'hello привет hello hello привет' patterns: first send
  translates, immediate repeats fall back to original.

  Fix: drop the early bail. Always derive sourceLang — from the prepared
  TranslatedMessage when available, otherwise from sender.getSetting(LOCALE)
  — and do a synchronous cache lookup against TranslationCacheService. Cache
  HIT → apply via Component.replaceText and withMessage exactly as the prepared
  path does.

  History save still gates on translatedMessage != null: dedup-skipped messages
  get the visual translation but don't enter globalHistory, keeping the buffer
  free of identical spam. Toggle on those messages no-ops in /toggleoriginal
  (logged as 'no translations') — acceptable since the toggle UI is for
  ad-hoc clicks, not rapid-fire spam.
… diagnosis

  Adds per-entry debug logging through TranslateModule's history service so
  the 'clicking lower duplicate toggles upper line' bug can be diagnosed
  from logs alone.

  [History.save] — for every save call:
    uuid, receiver, locale, originalText, hasTranslatedMessage flag,
    mode (CREATE vs UPDATE existing entry), new viewers count, global size.
    Tells us whether two visually-duplicate chat lines map to one or two
    separate history entries.

  [History.addTag] — for every translation tag resolution:
    whether button is hidden (sender non-FPlayer / same locale) and why,
    which UUID gets embedded into the /toggleoriginal action for each
    receiver. Confirms each visible button on a chat line carries its
    own message UUID.

  [Toggle] sendUpdate per-entry — during chat redraw:
    index, total visibleEntries, each entry's uuid, text, current
    showOriginal flag, translation string for the receiver's locale.
    Shows exactly which entries the receiver sees redrawn and in what
    state — so a 'lower toggle changed upper' observation can be cross-
    referenced against which UUIDs are actually being painted.

  Gated through fLogger.debug — silent without -Dflectonepulse.debug=true.
  MessageReceiveEvent fires for every chat packet a player receives — including
  packet echoes of the plugin's own chat dispatches that the isCached/cachedComponents
  filter misses (different Component reference identity due to internal rebuild
  on the receive path). These echoes show up in the log as save() calls with
  text='' and bloat the global history: a single chat exchange ends up with
  2-3x the entries needed, half of them blank, all printed during brute redraw.

  Fix: render the incoming component to plain text via PlainTextComponentSerializer
  and skip the save when the result is blank. The visible chat doesn't depend on
  this listener's records, the history is purely for replay reconstruction, and
  replay rendering of an empty-component line just emits an empty chat row anyway.

  Reduces 'visibleEntries' in toggle redraws by roughly half on a typical
  conversation, which directly addresses the user-reported 'why are there so
  many duplicates after I click show original' complaint.
  Root cause of phantom 'text=' history entries appearing after every chat:
  onMessageSendEvent saved originalComponent into selfOriginatedComponents
  (via save's needToCache=true). But when a cached translation was applied,
  event.withMessage(translatedComponent) made the outgoing packet carry
  translatedComponent — a different Component instance. The packet's
  PacketSendEvent → MessageReceiveEvent fired with translatedComponent, but
  isCached() compares by reference and only found originalComponent → dedup
  silently failed → onMessageReceiveEvent saved a second history entry with
  empty originalText.

  empty originalText.

  Fix:
    - New TranslateModule.markSelfOriginated(Component) — registers a
      Component for ReceiveEvent dedup separately from save().
    - onMessageSendEvent now calls save(...) with needToCache=false (history
      stores originalComponent for the showOriginal toggle path) and then
      markSelfOriginated(event.message()) AFTER any withMessage(...) — so
      whatever variant the packet actually carries is the one we register.

  For the cache-miss path (no withMessage applied) event.message() ==
  originalComponent, so behavior is unchanged.

  Identified by analysing PulseDeleteListener: the author's module doesn't
  modify event.message() between save() and the packet send, so its
  cached/sent components are always identical and dedup just works. Our
  translation-applying listener needed the extra step.
…p-skipped and system messages

  Previously the listener bailed out of save() whenever preparedTranslations
  didn't contain a TranslatedMessage for the current messageUUID. Two real-world
  chat events have a null TranslatedMessage and were silently dropped from the
  history:

    1. Dedup-skipped rapid repeats. PrepareEvent's 1-second per-(sender,text)
       dedup cache prevents the second through Nth same-content chat from
       populating preparedTranslations. The SendEvent still fires for each
       receiver, but with my previous code skipped save() because
       translatedMessage was null. Net effect: a player who typed 'hello' six
       times in two seconds got one history entry; replay/redraw on toggle
       showed only that one line, losing five visible chat events.

    2. System messages — join, quit, /give output, plugin announcements that
       dispatch through messageDispatcher with a non-FPlayer sender. PrepareEvent
       bails out at the FPlayer instanceof check, no TranslatedMessage is ever
       created, and the SendEvent listener used to skip save(). Chat redraw
       pushed these off-screen with the brute-newline spam.

  Fix: save() runs unconditionally now. For the dedup-skipped path where the
  cache HIT for this receiver's locale, the listener synthesizes a
  TranslatedMessage on the fly (originalLang + receiverLocale entries
  populated) so toggle works on those entries too. System messages save with
  translatedMessage=null — they show as original in any redraw and are not
  toggleable, which is correct since there's nothing to switch between.

  needToCache stays false at the save call: the outgoing packet's dedup
  component is registered separately via markSelfOriginated(event.message())
  right after, capturing the result of any prior withMessage(translatedComponent)
  substitution. ReceiveEvent on the echo-back sees the exact reference and
  skips, no duplicate entries.
  Bug surfaced as half the history entries having translationForLocale=<none>
  in sendUpdate logs even though the cache had a valid translation. Clicking
  toggle on those entries logged 'has no translations (server/system message)'
  and did nothing visually — user thought toggle was 'lagging'.

  Root cause: save() runs once per receiver, and per-receiver behavior in
  PulseAutoTranslateListener.onMessageSendEvent diverges based on whether
  receiverLocale == sourceLang. For the SAME-locale receiver (the sender's own
  view) the listener never enters the cache-lookup branch and passes a null
  TranslatedMessage to save(). For the DIFFERENT-locale receiver it synthesizes
  one. Save order isn't guaranteed: when the same-locale save runs first it
  CREATEs the entry with a null TM; the subsequent UPDATE path silently
  discarded the incoming non-null TM. Half the entries stayed un-toggleable.

  Fix: in the UPDATE branch, if existing.translatedMessage is null but the
  incoming one isn't, replace the entry via withTranslatedMessage (records
  are immutable; list.set keeps the global order intact). If both are non-null
  the incoming translations map is merged via putAll — keeps any per-locale
  entries the existing one already has and adds new ones.

  This matches the user's intended architecture: the global history *holds*
  the translations alongside originalText and viewers, rather than relying on
  the global cache as the sole source of truth at render time.
@SolverNA
Copy link
Copy Markdown
Author

Привет! Я реализовал всё, что планировал: сообщения теперь переводятся полностью автоматически, и переключение между оригиналом и переводом изменяет только текст сообщения.

Также реализовал задумку с сохранением общей истории полученных сообщений: теперь к каждому сообщению присваиваются получатели, благодаря чему история чата восстанавливается для каждого игрока индивидуально.

Можешь проверить. Единственный нюанс текст над головой пока не переводятся. Это отдельный модуль, и для его асинхронного перевода придётся сильно изменять логику модуля.

Copy link
Copy Markdown
Member

@TheFaser TheFaser left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ещё в целом старайся делать меньше комментариев и java doc, здесь это не очень уместно, потому что нет стандарта сейчас у меня, который я бы придерживался для обычных модулей. Максимум можешь оставить комменты для понимания происходящего внутри методов, но не после каждого слова

Comment on lines +82 to +83
fLogger.debug("[AutoTranslate] PrepareEvent: skip uuid=%s — duplicate of recent message (sender=%s text='%s')",
messageUUID, sender.name(), message);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

я не думаю, что debug имеет смысл, слишком много спама в консоль, даже учитывая, что он только при включённом флаге. Я не реализовывал систему дебага впринципе, потому что не вижу в этом большого смысла

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

я просто отстраивал систему на дебаге ну если не нужно то могу убрать или сократить

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

я просто отстраивал систему на дебаге ну если не нужно то могу убрать или сократить

Сейчас лучше убрать дебаг полностью, над ним нужно думать отдельно, как о глобальной системе в проекте

Comment on lines +55 to +65
private final List<TranslateHistoryMessage> globalHistory = new java.util.ArrayList<>();

/**
* Per-player toggle state: which message UUIDs that player has flipped to
* "show original". Stored separately from the history entries because it's
* per-player; the entries themselves are shared.
*/
private final Map<UUID, Set<UUID>> playerOriginalToggles = new ConcurrentHashMap<>();

/** Components originated by FlectonePulse itself — dedup against MessageReceiveEvent. */
private final List<Component> selfOriginatedComponents = new CopyOnWriteArrayList<>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

зачем это здесь? Тебе нужна только 1 мапа, где будет ключом UUID - receiver, а значением сообщение готовое, тут ты слишном намудрил

Comment on lines +75 to +76
String urlString = "https://api.mymemory.translated.net/get?q=" + encodedText
+ "&langpair=" + normalizedSource + "%7C" + normalizedTarget;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

должна быть константа для url

URI uri = new URI(urlString);
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", "FlectonePulse/1.9.4");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

есть WebUtil, там указан User Agent

Comment on lines +128 to +130
duration: 24
time_unit: "HOURS"
size: 50000
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

24 часа? это слишком много. Кэш создан чтобы удаляться через время и не занимать память просто так, а ты по сути из кэша превратил его в обычную мапу, 1 час максимум

Comment on lines +276 to +283
# auto: true → async pre-translation + /toggleoriginal button
# auto: false → author's classic /translateto click-to-translate mode
auto: true
providers:
- GOOGLE
- MYMEMORY
- DEEPL
- YANDEX
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

зачем? это указывается в команде tranlateto, для чего это дублирование?

Comment on lines +649 to +651
action: " <click:run_command:\"/toggleoriginal <message>\"><hover:show_text:\"<fcolor:2>Show original/translation\"><fcolor:1><sprite_or:⇄:gui:icon/language>"
# Used when message.format.translate.auto: false (classic /translateto mode)
action_manual: " <click:run_command:\"/translateto <language> <language> <message>\"><hover:show_text:\"<fcolor:2>Translate message\"><fcolor:1><sprite_or:⇄:gui:icon/language>"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

над названием параметров нужно точно подумать, должно быть понятно что это за формат

Comment on lines +534 to +541
synchronized (globalHistory) {
for (TranslateHistoryMessage e : globalHistory) {
if (e.uuid().equals(messageUUID)) {
entry = e;
break;
}
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

как я написал выше, тебе нужна только одна мапа, без этого листа


/** True when message.format.translate.auto is enabled (default). */
private boolean isAutoMode() {
return !Boolean.FALSE.equals(translateModule.config().auto());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

просто проверяй translateModule.config().auto() без всяких Boolean.FALSE/TRUE, это нейронка и IDEA думают, что они могут быть null, но нет, они не могут быть такими, иначе плагин не включится

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants