diff --git a/src/engine/shared/config_variables_tclient.h b/src/engine/shared/config_variables_tclient.h index a065608fbf8..d1a43690847 100644 --- a/src/engine/shared/config_variables_tclient.h +++ b/src/engine/shared/config_variables_tclient.h @@ -227,11 +227,13 @@ MACRO_CONFIG_COL(TcBgDrawColor, tc_bg_draw_color, 14024576, CFGFLAG_CLIENT | CFG MACRO_CONFIG_INT(TcBgDrawAutoSaveLoad, tc_bg_draw_auto_save_load, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically save and load background drawings") // Translate -MACRO_CONFIG_STR(TcTranslateBackend, tc_translate_backend, 32, "ftapi", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate backends (ftapi, libretranslate)") +MACRO_CONFIG_STR(TcTranslateBackend, tc_translate_backend, 32, "google", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate backends (google, ftapi, libretranslate). ftapi's hosting is defunct as of 2026, kept for people running their own instance") MACRO_CONFIG_STR(TcTranslateTarget, tc_translate_target, 16, "en", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate target language (must be 2 character ISO 639 code)") MACRO_CONFIG_STR(TcTranslateEndpoint, tc_translate_endpoint, 256, "", CFGFLAG_CLIENT | CFGFLAG_SAVE, "For backends which need it, endpoint to use (must be https)") MACRO_CONFIG_STR(TcTranslateKey, tc_translate_key, 256, "", CFGFLAG_CLIENT | CFGFLAG_SAVE, "For backends which need it, api key to use") MACRO_CONFIG_INT(TcTranslateAuto, tc_translate_auto, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically translate messages, only some backends support this (FTApi does not)") +MACRO_CONFIG_INT(TcTranslateOutgoing, tc_translate_outgoing, 0, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Translate your own outgoing chat messages into another language before sending them") +MACRO_CONFIG_STR(TcTranslateOutgoingTarget, tc_translate_outgoing_target, 16, "en", CFGFLAG_CLIENT | CFGFLAG_SAVE, "Language to translate your outgoing messages into (must be 2 character ISO 639 code)") // Animations MACRO_CONFIG_INT(TcAnimateWheelTime, tc_animate_wheel_time, 80, 0, 1000, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Duration of emote and bind wheel animations, in milliseconds (0 == no animation, 1000 = 1 second)") diff --git a/src/game/client/components/chat.cpp b/src/game/client/components/chat.cpp index 4405fce4dd7..fb2ce17d868 100644 --- a/src/game/client/components/chat.cpp +++ b/src/game/client/components/chat.cpp @@ -277,7 +277,14 @@ bool CChat::OnInput(const IInput::CEvent &Event) else if(GameClient()->m_TClient.ChatDoSpecId(m_Input.GetString())) ; // Do nothing as specid was executed else - SendChatQueued(m_Input.GetString()); + { + const char *pMessage = m_Input.GetString(); + // Don't translate server commands (e.g. "/w name msg"), translating would break their syntax + if(g_Config.m_TcTranslateOutgoing && pMessage[0] != '\0' && pMessage[0] != '/') + GameClient()->m_Translate.TranslateOutgoing(m_Mode == MODE_TEAM ? 1 : 0, pMessage); + else + SendChatQueued(pMessage); + } m_pHistoryEntry = nullptr; DisableMode(); GameClient()->OnRelease(); diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index 91610760e34..8e39ab67f08 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -1094,6 +1094,117 @@ void CMenus::RenderSettingsTClientSettings(CUIRect MainView) Ui()->DoEditBox(&s_FinishName, &Button, EditBoxFontSize); s_SectionBoxes.back().h = Column.y - s_SectionBoxes.back().y; + // ***** Translate ***** // + Column.HSplitTop(MarginBetweenSections, nullptr, &Column); + s_SectionBoxes.push_back(Column); + Column.HSplitTop(HeadlineHeight, &Label, &Column); + Ui()->DoLabel(&Label, TCLocalize("Translate"), HeadlineFontSize, TEXTALIGN_ML); + Column.HSplitTop(MarginSmall, nullptr, &Column); + + static const char *s_apTranslateLanguageNames[] = { + "Arabic", "Chinese (Simplified)", "Chinese (Traditional)", "Czech", "Dutch", "English", + "Finnish", "French", "German", "Greek", "Hebrew", "Hindi", "Hungarian", "Italian", + "Japanese", "Korean", "Polish", "Portuguese", "Romanian", "Russian", "Spanish", "Swedish", + "Thai", "Turkish", "Ukrainian", "Vietnamese"}; + static const char *s_apTranslateLanguageCodes[] = { + "ar", "zh", "zh-TW", "cs", "nl", "en", + "fi", "fr", "de", "el", "he", "hi", "hu", "it", + "ja", "ko", "pl", "pt", "ro", "ru", "es", "sv", + "th", "tr", "uk", "vi"}; + const int NumTranslateLanguages = (int)std::size(s_apTranslateLanguageNames); + static_assert(std::size(s_apTranslateLanguageNames) == std::size(s_apTranslateLanguageCodes), "language name/code list length mismatch"); + auto TranslateLanguageIndex = [&](const char *pCode) { + for(int i = 0; i < NumTranslateLanguages; i++) + if(str_comp_nocase(pCode, s_apTranslateLanguageCodes[i]) == 0) + return i; + return 5; // Fall back to English if unset or a custom code we don't have a name for + }; + + { + static std::vector s_TranslateBackendNames = {"Google", "FreeTranslateAPI (defunct)", "LibreTranslate"}; + static const char *s_apTranslateBackendValues[] = {"google", "ftapi", "libretranslate"}; + static CUi::SDropDownState s_TranslateBackendDropDownState; + static CScrollRegion s_TranslateBackendDropDownScrollRegion; + s_TranslateBackendDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_TranslateBackendDropDownScrollRegion; + int BackendSelectedOld = 0; + for(int i = 0; i < (int)std::size(s_apTranslateBackendValues); i++) + { + if(str_comp_nocase(g_Config.m_TcTranslateBackend, s_apTranslateBackendValues[i]) == 0) + BackendSelectedOld = i; + } + CUIRect BackendDropDownRect; + Column.HSplitTop(LineSize, &BackendDropDownRect, &Column); + BackendDropDownRect.VSplitLeft(120.0f, &Label, &BackendDropDownRect); + Ui()->DoLabel(&Label, TCLocalize("Backend: "), FontSize, TEXTALIGN_ML); + const int BackendSelectedNew = Ui()->DoDropDown(&BackendDropDownRect, BackendSelectedOld, s_TranslateBackendNames.data(), s_TranslateBackendNames.size(), s_TranslateBackendDropDownState); + if(BackendSelectedOld != BackendSelectedNew) + str_copy(g_Config.m_TcTranslateBackend, s_apTranslateBackendValues[BackendSelectedNew]); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcTranslateAuto, TCLocalize("Automatically translate incoming messages"), &g_Config.m_TcTranslateAuto, &Column, LineSize); + if(g_Config.m_TcTranslateAuto && str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) + { + Column.HSplitTop(LineSize, &Label, &Column); + Ui()->DoLabel(&Label, TCLocalize("FreeTranslateAPI does not support automatic translation, switch to Google or LibreTranslate"), FontSize * 0.8f, TEXTALIGN_ML); + } + CUIRect IncomingTargetBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &IncomingTargetBox, &Column); + if(g_Config.m_TcTranslateAuto) + { + IncomingTargetBox.HSplitTop(MarginExtraSmall, nullptr, &IncomingTargetBox); + IncomingTargetBox.VSplitMid(&Label, &IncomingTargetBox); + Ui()->DoLabel(&Label, TCLocalize("Translate incoming to:"), FontSize, TEXTALIGN_ML); + static CUi::SDropDownState s_IncomingLangDropDownState; + static CScrollRegion s_IncomingLangDropDownScrollRegion; + s_IncomingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_IncomingLangDropDownScrollRegion; + const int IncomingLangOld = TranslateLanguageIndex(g_Config.m_TcTranslateTarget); + const int IncomingLangNew = Ui()->DoDropDown(&IncomingTargetBox, IncomingLangOld, s_apTranslateLanguageNames, NumTranslateLanguages, s_IncomingLangDropDownState); + if(IncomingLangOld != IncomingLangNew) + str_copy(g_Config.m_TcTranslateTarget, s_apTranslateLanguageCodes[IncomingLangNew]); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcTranslateOutgoing, TCLocalize("Translate your outgoing messages"), &g_Config.m_TcTranslateOutgoing, &Column, LineSize); + CUIRect OutgoingTargetBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &OutgoingTargetBox, &Column); + if(g_Config.m_TcTranslateOutgoing) + { + OutgoingTargetBox.HSplitTop(MarginExtraSmall, nullptr, &OutgoingTargetBox); + OutgoingTargetBox.VSplitMid(&Label, &OutgoingTargetBox); + Ui()->DoLabel(&Label, TCLocalize("Translate outgoing to:"), FontSize, TEXTALIGN_ML); + static CUi::SDropDownState s_OutgoingLangDropDownState; + static CScrollRegion s_OutgoingLangDropDownScrollRegion; + s_OutgoingLangDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_OutgoingLangDropDownScrollRegion; + const int OutgoingLangOld = TranslateLanguageIndex(g_Config.m_TcTranslateOutgoingTarget); + const int OutgoingLangNew = Ui()->DoDropDown(&OutgoingTargetBox, OutgoingLangOld, s_apTranslateLanguageNames, NumTranslateLanguages, s_OutgoingLangDropDownState); + if(OutgoingLangOld != OutgoingLangNew) + str_copy(g_Config.m_TcTranslateOutgoingTarget, s_apTranslateLanguageCodes[OutgoingLangNew]); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) + { + CUIRect EndpointBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &EndpointBox, &Column); + EndpointBox.VSplitMid(&Label, &EndpointBox); + Ui()->DoLabel(&Label, TCLocalize("LibreTranslate endpoint:"), FontSize, TEXTALIGN_ML); + static CLineInput s_TranslateEndpoint(g_Config.m_TcTranslateEndpoint, sizeof(g_Config.m_TcTranslateEndpoint)); + s_TranslateEndpoint.SetEmptyText("localhost:5000/translate"); + Ui()->DoEditBox(&s_TranslateEndpoint, &EndpointBox, EditBoxFontSize); + Column.HSplitTop(MarginExtraSmall, nullptr, &Column); + + CUIRect KeyBox; + Column.HSplitTop(LineSize + MarginExtraSmall, &KeyBox, &Column); + KeyBox.VSplitMid(&Label, &KeyBox); + Ui()->DoLabel(&Label, TCLocalize("LibreTranslate API key:"), FontSize, TEXTALIGN_ML); + static CLineInput s_TranslateKey(g_Config.m_TcTranslateKey, sizeof(g_Config.m_TcTranslateKey)); + s_TranslateKey.SetEmptyText(TCLocalize("Optional")); + Ui()->DoEditBox(&s_TranslateKey, &KeyBox, EditBoxFontSize); + } + Column.HSplitTop(MarginExtraSmall, nullptr, &Column); + s_SectionBoxes.back().h = Column.y - s_SectionBoxes.back().y; + // ***** END OF PAGE 1 SETTINGS ***** // RightView = Column; diff --git a/src/game/client/components/tclient/translate.cpp b/src/game/client/components/tclient/translate.cpp index 75be055b7a8..3d31c3ae96b 100644 --- a/src/game/client/components/tclient/translate.cpp +++ b/src/game/client/components/tclient/translate.cpp @@ -196,7 +196,7 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp { return "LibreTranslate"; } - CTranslateBackendLibretranslate(IHttp &Http, const char *pText) + CTranslateBackendLibretranslate(IHttp &Http, const char *pText, const char *pTargetLang) { CJsonStringWriter Json = CJsonStringWriter(); Json.BeginObject(); @@ -205,7 +205,7 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp Json.WriteAttribute("source"); Json.WriteStrValue("auto"); Json.WriteAttribute("target"); - Json.WriteStrValue(EncodeTarget(g_Config.m_TcTranslateTarget)); + Json.WriteStrValue(EncodeTarget(pTargetLang)); Json.WriteAttribute("format"); Json.WriteStrValue("text"); if(g_Config.m_TcTranslateKey[0] != '\0') @@ -289,12 +289,12 @@ class CTranslateBackendFtapi : public ITranslateBackendHttp { return "FreeTranslateAPI"; } - CTranslateBackendFtapi(IHttp &Http, const char *pText) + CTranslateBackendFtapi(IHttp &Http, const char *pText, const char *pTargetLang) { char aBuf[4096]; str_format(aBuf, sizeof(aBuf), "%s/translate?dl=%s&text=", g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://ftapi.pythonanywhere.com", - EncodeTarget(g_Config.m_TcTranslateTarget)); + EncodeTarget(pTargetLang)); UrlEncode(pText, aBuf + strlen(aBuf), sizeof(aBuf) - strlen(aBuf)); @@ -302,6 +302,95 @@ class CTranslateBackendFtapi : public ITranslateBackendHttp } }; +// Talks directly to Google's free, unofficial (undocumented) translate endpoint. +// This is the same endpoint FreeTranslateAPI (ftapi.pythonanywhere.com, now dead) used to wrap. +class CTranslateBackendGoogle : public ITranslateBackendHttp +{ +private: + bool ParseResponseJson(const json_value *pObj, CTranslateResponse &Out) + { + if(!pObj) + { + str_copy(Out.m_Text, "Response is not JSON"); + return false; + } + if(pObj->type != json_array || json_array_length(pObj) < 3) + { + str_copy(Out.m_Text, "Unexpected response format"); + return false; + } + + const json_value *pSentences = json_array_get(pObj, 0); + if(!pSentences || pSentences->type != json_array) + { + str_copy(Out.m_Text, "No translated sentences"); + return false; + } + + char aText[sizeof(Out.m_Text)] = ""; + const int NumSentences = json_array_length(pSentences); + for(int i = 0; i < NumSentences; i++) + { + const json_value *pSentence = json_array_get(pSentences, i); + if(!pSentence || pSentence->type != json_array || json_array_length(pSentence) < 1) + continue; + const json_value *pChunk = json_array_get(pSentence, 0); + if(!pChunk || pChunk->type != json_string) + continue; + str_append(aText, pChunk->u.string.ptr); + } + if(aText[0] == '\0') + { + str_copy(Out.m_Text, "Empty translation"); + return false; + } + + const json_value *pLanguage = json_array_get(pObj, 2); + if(pLanguage && pLanguage->type == json_string) + str_copy(Out.m_Language, pLanguage->u.string.ptr); + + str_copy(Out.m_Text, aText); + return true; + } + +protected: + bool ParseResponse(CTranslateResponse &Out) override + { + json_value *pObj = m_pHttpRequest->ResultJson(); + bool Res = ParseResponseJson(pObj, Out); + json_value_free(pObj); + return Res; + } + +public: + const char *Name() const override + { + return "Google"; + } + CTranslateBackendGoogle(IHttp &Http, const char *pText, const char *pTargetLang) + { + char aBuf[4096]; + str_format(aBuf, sizeof(aBuf), "%s?client=gtx&sl=auto&dt=t&tl=%s&q=", + g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://translate.googleapis.com/translate_a/single", + EncodeTarget(pTargetLang)); + + UrlEncode(pText, aBuf + strlen(aBuf), sizeof(aBuf) - strlen(aBuf)); + + CreateHttpRequest(Http, aBuf); + } +}; + +static std::unique_ptr CreateTranslateBackend(IHttp &Http, const char *pText, const char *pTargetLang) +{ + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) + return std::make_unique(Http, pText, pTargetLang); + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) + return std::make_unique(Http, pText, pTargetLang); + if(str_comp_nocase(g_Config.m_TcTranslateBackend, "google") == 0) + return std::make_unique(Http, pText, pTargetLang); + return nullptr; +} + void CTranslate::ConTranslate(IConsole::IResult *pResult, void *pUserData) { const char *pName; @@ -398,11 +487,8 @@ void CTranslate::Translate(CChat::CLine &Line, bool ShowProgress) Job.m_pTranslateResponse = std::make_shared(); Job.m_pLine->m_pTranslateResponse = Job.m_pTranslateResponse; - if(str_comp_nocase(g_Config.m_TcTranslateBackend, "libretranslate") == 0) - Job.m_pBackend = std::make_unique(*Http(), Job.m_pLine->m_aText); - else if(str_comp_nocase(g_Config.m_TcTranslateBackend, "ftapi") == 0) - Job.m_pBackend = std::make_unique(*Http(), Job.m_pLine->m_aText); - else + Job.m_pBackend = CreateTranslateBackend(*Http(), Job.m_pLine->m_aText, g_Config.m_TcTranslateTarget); + if(!Job.m_pBackend) { GameClient()->m_Chat.Echo("Invalid translate backend"); return; @@ -428,6 +514,23 @@ void CTranslate::OnRender() { const auto Time = time(); auto ForEach = [&](CTranslateJob &Job) { + if(Job.m_IsOutgoing) + { + const std::optional Done = Job.m_pBackend->Update(*Job.m_pTranslateResponse); + if(!Done.has_value()) + return false; // Keep ongoing tasks + if(*Done && Job.m_pTranslateResponse->m_Text[0] != '\0') + GameClient()->m_Chat.SendChat(Job.m_OutgoingTeam, Job.m_pTranslateResponse->m_Text); + else + { + // Translation failed (or came back empty): don't swallow the message, send it untranslated + char aBuf[sizeof(Job.m_pTranslateResponse->m_Text) + 64]; + str_format(aBuf, sizeof(aBuf), TCLocalize("Translating your message failed, sending it untranslated: %s", "translate"), Job.m_pTranslateResponse->m_Text); + GameClient()->m_Chat.Echo(aBuf); + GameClient()->m_Chat.SendChat(Job.m_OutgoingTeam, Job.m_aOutgoingOriginalText); + } + return true; + } if(Job.m_pLine->m_pTranslateResponse != Job.m_pTranslateResponse) return true; // Not the same line anymore const std::optional Done = Job.m_pBackend->Update(*Job.m_pTranslateResponse); @@ -471,3 +574,29 @@ void CTranslate::AutoTranslate(CChat::CLine &Line) } Translate(Line, false); } + +void CTranslate::TranslateOutgoing(int Team, const char *pText) +{ + if(m_vJobs.size() > 15) + { + // Too many jobs in flight, don't make the player wait: send untranslated + GameClient()->m_Chat.SendChat(Team, pText); + return; + } + + CTranslateJob Job; + Job.m_IsOutgoing = true; + Job.m_OutgoingTeam = Team; + str_copy(Job.m_aOutgoingOriginalText, pText); + Job.m_pTranslateResponse = std::make_shared(); + + Job.m_pBackend = CreateTranslateBackend(*Http(), pText, g_Config.m_TcTranslateOutgoingTarget); + if(!Job.m_pBackend) + { + GameClient()->m_Chat.Echo("Invalid translate backend"); + GameClient()->m_Chat.SendChat(Team, pText); + return; + } + + m_vJobs.emplace_back(std::move(Job)); +} diff --git a/src/game/client/components/tclient/translate.h b/src/game/client/components/tclient/translate.h index d3e14a000b5..e14a35a365d 100644 --- a/src/game/client/components/tclient/translate.h +++ b/src/game/client/components/tclient/translate.h @@ -29,6 +29,10 @@ class CTranslate : public CComponent // For chat translations CChat::CLine *m_pLine = nullptr; std::shared_ptr m_pTranslateResponse = nullptr; + // For outgoing translations (translating our own message before sending it) + bool m_IsOutgoing = false; + int m_OutgoingTeam = 0; + char m_aOutgoingOriginalText[256] = ""; }; std::vector m_vJobs; @@ -46,6 +50,10 @@ class CTranslate : public CComponent void Translate(CChat::CLine &Line, bool ShowProgress = true); void AutoTranslate(CChat::CLine &Line); + + // Translates pText into g_Config.m_TcTranslateOutgoingTarget, then sends the + // result (or the original text if translation fails) as a chat message. + void TranslateOutgoing(int Team, const char *pText); }; #endif