diff --git a/src/engine/shared/config_variables_tclient.h b/src/engine/shared/config_variables_tclient.h index 837e43d0604..1e160936847 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 backend to use (google, libretranslate)") 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(TcTranslateAuto, tc_translate_auto, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Automatically translate incoming messages") +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 b6d811a666e..49d1ab275f6 100644 --- a/src/game/client/components/chat.cpp +++ b/src/game/client/components/chat.cpp @@ -276,6 +276,8 @@ bool CChat::OnInput(const IInput::CEvent &Event) ; // Do nothing as bindchat was executed else if(GameClient()->m_TClient.ChatDoSpecId(m_Input.GetString())) ; // Do nothing as specid was executed + else if(GameClient()->m_Translate.ChatDoTranslateOutgoing(m_Mode == MODE_TEAM ? 1 : 0, m_Input.GetString())) + ; // Do nothing as outgoing translate was queued else SendChatQueued(m_Input.GetString()); m_pHistoryEntry = nullptr; @@ -680,15 +682,16 @@ void CChat::AddLine(int ClientId, int Team, const char *pLine) return; // TClient - if (([&]() { - if(ClientId == CLIENT_MSG || ClientId == SERVER_MSG) - return false; - for(const auto LocalClientId : GameClient()->m_aLocalIds) - if(LocalClientId == ClientId) - return false; - auto &Re = GameClient()->m_TClient.m_RegexChatIgnore; - return Re.error().empty() && Re.test(pLine); - })()) return; + if(([&]() { + if(ClientId == CLIENT_MSG || ClientId == SERVER_MSG) + return false; + for(const auto LocalClientId : GameClient()->m_aLocalIds) + if(LocalClientId == ClientId) + return false; + auto &Re = GameClient()->m_TClient.m_RegexChatIgnore; + return Re.error().empty() && Re.test(pLine); + })()) + return; // trim right and set maximum length to 256 utf8-characters int Length = 0; diff --git a/src/game/client/components/tclient/menus_tclient.cpp b/src/game/client/components/tclient/menus_tclient.cpp index 65329f39a6c..029ca59dfae 100644 --- a/src/game/client/components/tclient/menus_tclient.cpp +++ b/src/game/client/components/tclient/menus_tclient.cpp @@ -1091,6 +1091,110 @@ 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); + + // Current backend's language table -- Google and LibreTranslate genuinely + // support different language sets (see translate.h/.cpp's + // g_aTranslateBackends), so the dropdowns below are backend-scoped + // rather than one shared list. + int CurrentBackendIndex = 0; + for(size_t i = 0; i < g_aTranslateBackends.size(); i++) + { + if(str_comp_nocase(g_Config.m_TcTranslateBackend, g_aTranslateBackends[i].m_pValue) == 0) + CurrentBackendIndex = (int)i; + } + const STranslateBackendInfo &CurrentBackend = g_aTranslateBackends[CurrentBackendIndex]; + + { + static std::vector s_TranslateBackendNames; + s_TranslateBackendNames.clear(); + for(const STranslateBackendInfo &Backend : g_aTranslateBackends) + s_TranslateBackendNames.push_back(Backend.m_pName); + + static CUi::SDropDownState s_TranslateBackendDropDownState; + static CScrollRegion s_TranslateBackendDropDownScrollRegion; + s_TranslateBackendDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_TranslateBackendDropDownScrollRegion; + 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, CurrentBackendIndex, s_TranslateBackendNames.data(), s_TranslateBackendNames.size(), s_TranslateBackendDropDownState); + if(BackendSelectedNew != CurrentBackendIndex) + str_copy(g_Config.m_TcTranslateBackend, g_aTranslateBackends[BackendSelectedNew].m_pValue); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + DoButton_CheckBoxAutoVMarginAndSet(&g_Config.m_TcTranslateAuto, TCLocalize("Automatically translate incoming messages"), &g_Config.m_TcTranslateAuto, &Column, LineSize); + 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; + static std::vector s_IncomingLangNames; + s_IncomingLangNames.clear(); + for(const STranslateLanguage &Language : CurrentBackend.m_vLanguages) + s_IncomingLangNames.push_back(Language.m_pName); + const int IncomingLangOld = TranslateLanguageIndex(CurrentBackend.m_vLanguages, g_Config.m_TcTranslateTarget); + const int IncomingLangNew = Ui()->DoDropDown(&IncomingTargetBox, IncomingLangOld, s_IncomingLangNames.data(), s_IncomingLangNames.size(), s_IncomingLangDropDownState); + if(IncomingLangOld != IncomingLangNew) + str_copy(g_Config.m_TcTranslateTarget, CurrentBackend.m_vLanguages[IncomingLangNew].m_pCode); + } + 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; + static std::vector s_OutgoingLangNames; + s_OutgoingLangNames.clear(); + for(const STranslateLanguage &Language : CurrentBackend.m_vLanguages) + s_OutgoingLangNames.push_back(Language.m_pName); + const int OutgoingLangOld = TranslateLanguageIndex(CurrentBackend.m_vLanguages, g_Config.m_TcTranslateOutgoingTarget); + const int OutgoingLangNew = Ui()->DoDropDown(&OutgoingTargetBox, OutgoingLangOld, s_OutgoingLangNames.data(), s_OutgoingLangNames.size(), s_OutgoingLangDropDownState); + if(OutgoingLangOld != OutgoingLangNew) + str_copy(g_Config.m_TcTranslateOutgoingTarget, CurrentBackend.m_vLanguages[OutgoingLangNew].m_pCode); + } + Column.HSplitTop(MarginSmall, nullptr, &Column); + + if(CurrentBackend.m_NeedsEndpointConfig) + { + 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..83381199bae 100644 --- a/src/game/client/components/tclient/translate.cpp +++ b/src/game/client/components/tclient/translate.cpp @@ -59,6 +59,7 @@ bool ITranslateBackend::CompareTargets(const char *pA, const char *pB) const class ITranslateBackendHttp : public ITranslateBackend { protected: + using ITranslateBackend::ITranslateBackend; std::shared_ptr m_pHttpRequest = nullptr; virtual bool ParseResponse(CTranslateResponse &Out) = 0; virtual bool ParseHttpError() const { return false; } @@ -74,6 +75,24 @@ class ITranslateBackendHttp : public ITranslateBackend Http.Run(pGet); } + // Same as CreateHttpRequest, but POSTs pBody as a + // application/x-www-form-urlencoded body instead of encoding everything + // into the URL's query string -- keeps the URL itself short (the fixed + // CHttpRequest::m_aUrl buffer is 256 bytes, easily exceeded by a long + // chat message otherwise) and matches what the endpoint actually expects. + void CreateHttpRequestPost(IHttp &Http, const char *pUrl, const char *pBody) + { + auto pPost = std::make_shared(pUrl); + pPost->LogProgress(HTTPLOG::FAILURE); + pPost->FailOnErrorStatus(false); + pPost->Timeout(CTimeout{10000, 0, 500, 10}); + pPost->HeaderString("Content-Type", "application/x-www-form-urlencoded"); + pPost->Post((const unsigned char *)pBody, str_length(pBody)); + + m_pHttpRequest = pPost; + Http.Run(pPost); + } + public: std::optional Update(CTranslateResponse &Out) override { @@ -192,11 +211,8 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp bool ParseHttpError() const override { return true; } public: - const char *Name() const override - { - return "LibreTranslate"; - } - CTranslateBackendLibretranslate(IHttp &Http, const char *pText) + CTranslateBackendLibretranslate(IHttp &Http, const char *pText, const char *pTargetLang, const char *pName) : + ITranslateBackendHttp(pName) { CJsonStringWriter Json = CJsonStringWriter(); Json.BeginObject(); @@ -205,7 +221,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') @@ -220,7 +236,14 @@ class CTranslateBackendLibretranslate : public ITranslateBackendHttp } }; -class CTranslateBackendFtapi : public ITranslateBackendHttp +// Talks directly to Google's free, unofficial (undocumented) translate +// endpoint -- this is the same endpoint the now-defunct FreeTranslateAPI +// (ftapi.pythonanywhere.com) used to wrap, without needing a third-party +// mirror to stay up. See https://github.com/TaterClient/TClient/pull/203 +// for context on why the old FTApi-wrapping backend was removed instead of +// just re-pointed: its hosting is gone, and anyone who wants a self-hosted +// option already has the LibreTranslate backend for that. +class CTranslateBackendGoogle : public ITranslateBackendHttp { private: bool ParseResponseJson(const json_value *pObj, CTranslateResponse &Out) @@ -230,40 +253,42 @@ class CTranslateBackendFtapi : public ITranslateBackendHttp str_copy(Out.m_Text, "Response is not JSON"); return false; } - - if(pObj->type != json_object) + if(pObj->type != json_array || json_array_length(pObj) < 3) { - str_copy(Out.m_Text, "Response is not object"); + str_copy(Out.m_Text, "Unexpected response format"); return false; } - const json_value *pTranslatedText = json_object_get(pObj, "destination-text"); - if(pTranslatedText == &json_value_none) - { - str_copy(Out.m_Text, "No destination-text"); - return false; - } - if(pTranslatedText->type != json_string) + const json_value *pSentences = json_array_get(pObj, 0); + if(!pSentences || pSentences->type != json_array) { - str_copy(Out.m_Text, "destination-text is not string"); + str_copy(Out.m_Text, "No translated sentences"); return false; } - const json_value *pDetectedLanguage = json_object_get(pObj, "source-language"); - if(pDetectedLanguage == &json_value_none) + char aText[sizeof(Out.m_Text)] = ""; + const int NumSentences = json_array_length(pSentences); + for(int i = 0; i < NumSentences; i++) { - str_copy(Out.m_Text, "No source-language"); - return false; + 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(pDetectedLanguage->type != json_string) + if(aText[0] == '\0') { - str_copy(Out.m_Text, "source-language is not string"); + str_copy(Out.m_Text, "Empty translation"); return false; } - str_copy(Out.m_Text, pTranslatedText->u.string.ptr); - str_copy(Out.m_Language, pDetectedLanguage->u.string.ptr); + 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; } @@ -277,31 +302,126 @@ class CTranslateBackendFtapi : public ITranslateBackendHttp } public: - const char *EncodeTarget(const char *pTarget) const override - { - if(!pTarget || pTarget[0] == '\0') - return DefaultConfig::TcTranslateTarget; - if(str_comp_nocase(pTarget, "zh") == 0) - return "zh-cn"; - return pTarget; - } - const char *Name() const override + CTranslateBackendGoogle(IHttp &Http, const char *pText, const char *pTargetLang, const char *pName) : + ITranslateBackendHttp(pName) { - return "FreeTranslateAPI"; - } - CTranslateBackendFtapi(IHttp &Http, const char *pText) - { - 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)); + // Query params go in the POST body, not the URL -- keeps the URL + // itself short and fixed (see CreateHttpRequestPost) regardless of + // how long pText is. + char aBody[4096]; + str_format(aBody, sizeof(aBody), "client=gtx&sl=auto&dt=t&tl=%s&q=", EncodeTarget(pTargetLang)); - UrlEncode(pText, aBuf + strlen(aBuf), sizeof(aBuf) - strlen(aBuf)); + UrlEncode(pText, aBody + strlen(aBody), sizeof(aBody) - strlen(aBody)); - CreateHttpRequest(Http, aBuf); + const char *pEndpoint = g_Config.m_TcTranslateEndpoint[0] != '\0' ? g_Config.m_TcTranslateEndpoint : "https://translate.googleapis.com/translate_a/single"; + CreateHttpRequestPost(Http, pEndpoint, aBody); } }; +// Per-backend supported-language lists for the settings menu -- Google +// supports far more languages than a typical self-hosted LibreTranslate +// instance, so these are deliberately not one shared list. Both are curated +// subsets (Google supports 100+ languages; LibreTranslate's exact set +// depends on which models a given self-hosted instance has installed), not +// a claim of exhaustive coverage. +static const std::vector s_vGoogleLanguages = { + {"Arabic", "ar"}, + {"Bulgarian", "bg"}, + {"Chinese (Simplified)", "zh"}, + {"Chinese (Traditional)", "zh-TW"}, + {"Croatian", "hr"}, + {"Czech", "cs"}, + {"Danish", "da"}, + {"Dutch", "nl"}, + {"English", "en"}, + {"Estonian", "et"}, + {"Finnish", "fi"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "el"}, + {"Hebrew", "he"}, + {"Hindi", "hi"}, + {"Hungarian", "hu"}, + {"Indonesian", "id"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Latvian", "lv"}, + {"Lithuanian", "lt"}, + {"Norwegian", "no"}, + {"Persian", "fa"}, + {"Polish", "pl"}, + {"Portuguese", "pt"}, + {"Romanian", "ro"}, + {"Russian", "ru"}, + {"Serbian", "sr"}, + {"Slovak", "sk"}, + {"Slovenian", "sl"}, + {"Spanish", "es"}, + {"Swedish", "sv"}, + {"Thai", "th"}, + {"Turkish", "tr"}, + {"Ukrainian", "uk"}, + {"Vietnamese", "vi"}, +}; + +static const std::vector s_vLibretranslateLanguages = { + {"Arabic", "ar"}, + {"Chinese (Simplified)", "zh"}, + {"Czech", "cs"}, + {"Dutch", "nl"}, + {"English", "en"}, + {"Finnish", "fi"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "el"}, + {"Hebrew", "he"}, + {"Hindi", "hi"}, + {"Hungarian", "hu"}, + {"Indonesian", "id"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Polish", "pl"}, + {"Portuguese", "pt"}, + {"Russian", "ru"}, + {"Spanish", "es"}, + {"Swedish", "sv"}, + {"Turkish", "tr"}, + {"Ukrainian", "uk"}, + {"Vietnamese", "vi"}, +}; + +const std::array g_aTranslateBackends = {{ + {"google", "Google", s_vGoogleLanguages, false}, + {"libretranslate", "LibreTranslate", s_vLibretranslateLanguages, true}, +}}; + +int TranslateLanguageIndex(const std::vector &vLanguages, const char *pCode) +{ + for(size_t i = 0; i < vLanguages.size(); i++) + if(str_comp_nocase(pCode, vLanguages[i].m_pCode) == 0) + return (int)i; + for(size_t i = 0; i < vLanguages.size(); i++) + if(str_comp_nocase("en", vLanguages[i].m_pCode) == 0) + return (int)i; + return 0; +} + +static std::unique_ptr CreateTranslateBackend(IHttp &Http, const char *pText, const char *pTargetLang) +{ + for(const STranslateBackendInfo &Backend : g_aTranslateBackends) + { + if(str_comp_nocase(g_Config.m_TcTranslateBackend, Backend.m_pValue) != 0) + continue; + if(str_comp_nocase(Backend.m_pValue, "libretranslate") == 0) + return std::make_unique(Http, pText, pTargetLang, Backend.m_pName); + if(str_comp_nocase(Backend.m_pValue, "google") == 0) + return std::make_unique(Http, pText, pTargetLang, Backend.m_pName); + } + return nullptr; +} + void CTranslate::ConTranslate(IConsole::IResult *pResult, void *pUserData) { const char *pName; @@ -398,11 +518,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 +545,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); @@ -463,11 +597,34 @@ void CTranslate::AutoTranslate(CChat::CLine &Line) if(Id >= 0 && Id == Line.m_ClientId) return; } - if(str_comp(g_Config.m_TcTranslateBackend, "ftapi") == 0) + Translate(Line, false); +} + +bool CTranslate::ChatDoTranslateOutgoing(int Team, const char *pText) +{ + // Don't translate server commands (e.g. "/w name msg"), translating would break their syntax + if(!g_Config.m_TcTranslateOutgoing || pText[0] == '\0' || pText[0] == '/') + return false; + + if(m_vJobs.size() > 15) + { + // Too many jobs in flight: don't queue another, let the caller send this one untranslated + return false; + } + + 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) { - // FTAPI quickly gets overloaded, please do not disable this - // It may shut down if we spam it too hard - return; + GameClient()->m_Chat.Echo("Invalid translate backend"); + return false; } - Translate(Line, false); + + m_vJobs.emplace_back(std::move(Job)); + return true; } diff --git a/src/game/client/components/tclient/translate.h b/src/game/client/components/tclient/translate.h index d3e14a000b5..f38491098ef 100644 --- a/src/game/client/components/tclient/translate.h +++ b/src/game/client/components/tclient/translate.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -13,13 +14,51 @@ class CTranslate; class ITranslateBackend { public: + explicit ITranslateBackend(const char *pName) : + m_pName(pName) {} virtual ~ITranslateBackend() = default; virtual const char *EncodeTarget(const char *pTarget) const; virtual bool CompareTargets(const char *pA, const char *pB) const; - virtual const char *Name() const = 0; + // Set once at construction from the matching g_aTranslateBackends entry + // -- backend classes don't hold their own copy of their display name. + const char *Name() const { return m_pName; } virtual std::optional Update(CTranslateResponse &Out) = 0; + +private: + const char *m_pName; +}; + +struct STranslateLanguage +{ + const char *m_pName; + const char *m_pCode; +}; + +// Single source of truth for every available translate backend: its config +// value, its display name (passed into the backend's constructor so +// ITranslateBackend::Name() never drifts from it), and the language list its +// settings-menu dropdown should offer. Different backends genuinely support +// different language sets (Google supports far more than a typical +// self-hosted LibreTranslate instance), so this is per-backend rather than +// one shared list. +struct STranslateBackendInfo +{ + const char *m_pValue; + const char *m_pName; + std::vector m_vLanguages; + // Whether the settings menu should show the endpoint/API-key fields for + // this backend -- data-driven so the menu never has to special-case a + // backend by name. + bool m_NeedsEndpointConfig; }; +extern const std::array g_aTranslateBackends; + +// Finds pCode's index in vLanguages, or whichever entry IS English (found by +// code, not a hardcoded position) if pCode is unset/unrecognised -- stays +// correct no matter how a language list is reordered or edited. +int TranslateLanguageIndex(const std::vector &vLanguages, const char *pCode); + class CTranslate : public CComponent { class CTranslateJob @@ -29,6 +68,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 +89,14 @@ class CTranslate : public CComponent void Translate(CChat::CLine &Line, bool ShowProgress = true); void AutoTranslate(CChat::CLine &Line); + + // If outgoing translation applies to pText (enabled, non-empty, not a + // "/"-prefixed command), queues a translate job that sends the result + // (or the original text if translation fails) as a chat message, and + // returns true. Returns false if the caller should send pText itself -- + // mirrors CTClient::ChatDoSpecId's shape so chat.cpp can chain it as + // another "else if" instead of a nested block. + bool ChatDoTranslateOutgoing(int Team, const char *pText); }; #endif