From 1e94b1fc1e59f542415901d45647d5f4988fda4f Mon Sep 17 00:00:00 2001 From: Noah Theus Date: Mon, 13 Oct 2025 06:08:26 +0200 Subject: [PATCH] feat: Automatic comment translation using DeepL --- botIO/authenticate.go | 2 +- config/config.go | 11 +++-- config/types.go | 18 ++++--- deepL/http.go | 60 +++++++++++++++++++++++ deepL/translate.go | 108 ++++++++++++++++++++++++++++++++++++++++++ deepL/types.go | 35 ++++++++++++++ go.mod | 2 +- main.go | 8 ++-- steam/http.go | 1 - steam/profile.go | 25 ++++++++-- 10 files changed, 250 insertions(+), 20 deletions(-) create mode 100644 deepL/http.go create mode 100644 deepL/translate.go create mode 100644 deepL/types.go diff --git a/botIO/authenticate.go b/botIO/authenticate.go index 65ce465..eb856c8 100644 --- a/botIO/authenticate.go +++ b/botIO/authenticate.go @@ -9,7 +9,7 @@ import ( func Authenticate() (tgbotapi.UpdatesChannel, *tgbotapi.BotAPI) { bot, err := tgbotapi.NewBotAPI(config.BotConfig.TelegramAPIToken) if err != nil { - log.Panic().Err(err).Msg("Failed to authenticate to Telegram") + log.Fatal().Err(err).Msg("Failed to authenticate to Telegram") } bot.Debug = false diff --git a/config/config.go b/config/config.go index dec9dbb..b8af148 100644 --- a/config/config.go +++ b/config/config.go @@ -2,28 +2,31 @@ package config import ( "context" + "strings" + "github.com/joho/godotenv" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/joho/godotenv" envconfig "github.com/sethvargo/go-envconfig" ) var BotConfig config func LoadConfig() { - - if err := godotenv.Load(); err != nil { + if err := godotenv.Load(); err != nil { log.Info().Err(err).Msg("Failed to load .env file, using the system environment") } else { log.Info().Err(err).Msg(".env file loaded successfully") } if err := envconfig.Process(context.Background(), &BotConfig); err != nil { - log.Panic().Err(err).Msg("Failed to parse config from env variables") + log.Fatal().Err(err).Msg("Failed to parse config from env variables") } zerolog.SetGlobalLevel(zerolog.Level(BotConfig.LogLevel)) + BotConfig.TranslateLanguage = strings.ToUpper(BotConfig.TranslateLanguage) + + log.Info().Msg("Config loaded successfully") log.Debug().Interface("config", BotConfig).Msg("") diff --git a/config/types.go b/config/types.go index 63f916b..f211063 100644 --- a/config/types.go +++ b/config/types.go @@ -1,10 +1,16 @@ package config type config struct { - LogLevel int `env:"STEAMSALTY_LOGLEVEL, default=1"` - TelegramAPIToken string `env:"STEAMSALTY_TELEGRAMAPITOKEN, required"` - SteamAPIKey string `env:"STEAMSALTY_STEAMAPIKEY, required"` - ChatID int64 `env:"STEAMSALTY_CHATID, required"` - Watchers []uint64 `env:"STEAMSALTY_WATCHERS, required"` - SleepInterval int `env:"STEAMSALTY_SLEEPINTERVAL, default=60"` + LogLevel int `env:"STEAMSALTY_LOGLEVEL" default:"1"` + + SleepInterval int `env:"STEAMSALTY_SLEEPINTERVAL" default:"60"` + ChatID int64 `env:"STEAMSALTY_CHATID" required:"true"` + Watchers []uint64 `env:"STEAMSALTY_WATCHERS" required:"true"` + TranslateEnabled bool `env:"STEAMSALTY_TRANSLATE_ENABLED" default:"false"` + TranslateLanguage string `env:"STEAMSALTY_TRANSLATE_LANGUAGE" default:"EN-US"` + + TelegramAPIToken string `env:"STEAMSALTY_TELEGRAMAPITOKEN" required:"true"` + SteamAPIKey string `env:"STEAMSALTY_STEAMAPIKEY" required:"true"` + DeepLAPIKey string `env:"STEAMSALTY_DEEPLAPIKEY"` + DeepLFreeTier bool `env:"STEAMSALTY_DEEPLAPIFREETIER" default:"true"` } diff --git a/deepL/http.go b/deepL/http.go new file mode 100644 index 0000000..c186529 --- /dev/null +++ b/deepL/http.go @@ -0,0 +1,60 @@ +package deepl + +import ( + "bytes" + "io" + "net/http" + + "github.com/rs/zerolog/log" +) + +func get(endpoint string) (responseBody []byte, err error) { + httpReq, _ := http.NewRequest("GET", client.baseURL+endpoint, nil) + httpReq.Header.Set("Authorization", "DeepL-Auth-Key "+client.authKey) + + resp, err := client.httpClient.Do(httpReq) + if err != nil { + log.Error().Err(err).Str("endpoint", endpoint).Msg("Failed to send GET request to DeepL API") + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Error().Str("response", resp.Status).Str("endpoint", endpoint).Msg("Failed to send GET request to DeepL API, non 200 HTTP response") + return nil, err + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Str("endpoint", endpoint).Msg("Failed to read DeepL API response") + return nil, err + } + + return respBody, nil +} + +func post(endpoint string, data []byte) (responseBody []byte, err error) { + httpReq, _ := http.NewRequest("POST", client.baseURL+endpoint, bytes.NewReader(data)) + httpReq.Header.Set("Authorization", "DeepL-Auth-Key "+client.authKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.httpClient.Do(httpReq) + if err != nil { + log.Error().Err(err).Str("endpoint", endpoint).Msg("Failed to send request to POST DeepL API") + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Error().Str("response", resp.Status).Str("endpoint", endpoint).Msg("Failed to send POST request to DeepL API, non 200 HTTP response") + return nil, err + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Str("endpoint", endpoint).Msg("Failed to read DeepL API response") + return nil, err + } + + return respBody, nil +} diff --git a/deepL/translate.go b/deepL/translate.go new file mode 100644 index 0000000..77fff6f --- /dev/null +++ b/deepL/translate.go @@ -0,0 +1,108 @@ +package deepl + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + "watn3y/steamsalty/config" + + "github.com/rs/zerolog/log" +) + +var client *apiClient + +var SourceLanguages map[string]string +var TargetLanguages map[string]string + +func Init() { + log.Info().Msg("Translation is enabled, creating HTTP client for DeepL API") + baseURL := baseURLPro + if config.BotConfig.DeepLFreeTier { + baseURL = baseURLFree + } + + client = &apiClient{ + authKey: config.BotConfig.DeepLAPIKey, + baseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + + err := getAndValidateLanguages() + if err != nil { + log.Fatal().Err(err).Msg("Failed to set up languages") + } +} + +func Translate(text string) (translatedText string, sourceLanguage string, err error) { + log.Debug().Str("text", text).Msg("Starting translation") + req := translateRequest{ + Text: []string{text}, + TargetLang: config.BotConfig.TranslateLanguage, + } + + body, err := json.Marshal(req) + if err != nil { + log.Error().Err(err).Msg("Failed to ") + return "", "", err + } + + respBody, err := post("/translate", body) + if err != nil { + log.Error().Err(err).Msg("Failed to make DeepL API request") + return "", "", err + } + + var result translateResponse + err = json.Unmarshal(respBody, &result) + if err != nil { + log.Error().Err(err).Msg("Failed to parse DeepL API response") + return "", "", err + } + + return result.Translations[0].Text, result.Translations[0].DetectedSourceLanguage, nil +} + +func getAndValidateLanguages() (err error) { + log.Info().Msg("Setting up supported languages") + respBody, err := get("/languages?type=source") + if err != nil { + log.Error().Err(err).Msg("Failed to make DeepL API request") + return err + } + + var parsedResp languagesResponse + err = json.Unmarshal(respBody, &parsedResp) + if err != nil { + log.Error().Err(err).Msg("Failed to parse DeepL API response") + return err + } + + SourceLanguages = make(map[string]string) + for _, l := range parsedResp { + SourceLanguages[l.Language] = l.Name + } + + respBody, err = get("/languages?type=target") + if err != nil { + log.Error().Err(err).Msg("Failed to make DeepL API request") + return err + } + + err = json.Unmarshal(respBody, &parsedResp) + if err != nil { + log.Error().Err(err).Msg("Failed to parse DeepL API response") + return err + } + + TargetLanguages = make(map[string]string) + for _, l := range parsedResp { + TargetLanguages[l.Language] = l.Name + } + + if _, ok := TargetLanguages[config.BotConfig.TranslateLanguage]; !ok { + return fmt.Errorf("Selected language not supported by DeepL") + } + + return nil +} diff --git a/deepL/types.go b/deepL/types.go new file mode 100644 index 0000000..fef02eb --- /dev/null +++ b/deepL/types.go @@ -0,0 +1,35 @@ +package deepl + +import ( + "net/http" +) + +type translateResponse struct { + Translations []struct { + DetectedSourceLanguage string `json:"detected_source_language"` + Text string `json:"text"` + } `json:"translations"` +} + +type translateRequest struct { + Text []string `json:"text"` + TargetLang string `json:"target_lang"` +} + +type languagesResponse []struct { + Language string `json:"language"` + Name string `json:"name"` + SupportsFormality bool `json:"supports_formality"` //unused +} + + +type apiClient struct { + authKey string + baseURL string + httpClient *http.Client +} + +const ( + baseURLPro = "https://api.deepl.com/v2" + baseURLFree = "https://api-free.deepl.com/v2" +) diff --git a/go.mod b/go.mod index 8502be1..8e9eb4f 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,13 @@ require ( github.com/Philipp15b/go-steamapi v0.0.0-20210114153316-ec4fdd23b4c1 github.com/PuerkitoBio/goquery v1.10.3 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/joho/godotenv v1.5.1 github.com/rs/zerolog v1.34.0 github.com/sethvargo/go-envconfig v1.3.0 ) require ( github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect golang.org/x/net v0.43.0 // indirect diff --git a/main.go b/main.go index 345adc1..b25c635 100644 --- a/main.go +++ b/main.go @@ -8,16 +8,18 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" - + "watn3y/steamsalty/config" + deepl "watn3y/steamsalty/deepL" ) func main() { println("Starting SteamSalty...") - configureLogger() - config.LoadConfig() + if config.BotConfig.TranslateEnabled { + deepl.Init() + } bot() diff --git a/steam/http.go b/steam/http.go index 8fca3d0..55eba98 100644 --- a/steam/http.go +++ b/steam/http.go @@ -39,7 +39,6 @@ func GetComments(steamID uint64, start int, count int) (page CommentsPage) { body, err := io.ReadAll(resp.Body) if err != nil { log.Error().Err(err).Msg("Failed to parse comments") - log.Trace().Interface("Body", resp.Body) } err = json.Unmarshal(body, &page) diff --git a/steam/profile.go b/steam/profile.go index 2711cce..93264fc 100644 --- a/steam/profile.go +++ b/steam/profile.go @@ -3,19 +3,18 @@ package steam import ( "fmt" "math" - "strings" "sync" "time" - "watn3y/steamsalty/botIO" "watn3y/steamsalty/config" + deepl "watn3y/steamsalty/deepL" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/rs/zerolog/log" ) -var steamContentCheckText string = "This comment is awaiting analysis by our automated content check system. It will be temporarily hidden until we verify that it does not contain harmful content (e.g. links to websites that attempt to steal information)." +const steamContentCheckText string = "This comment is awaiting analysis by our automated content check system. It will be temporarily hidden until we verify that it does not contain harmful content (e.g. links to websites that attempt to steal information)." func StartWatchers(bot *tgbotapi.BotAPI) { @@ -69,9 +68,27 @@ func watcher(bot *tgbotapi.BotAPI, steamID uint64, sleeptime time.Duration) { Text: fmt.Sprintf(`%s just commented on %s's profile:`, comment.AuthorProfileURL, comment.Author, profileOwner.ProfileURL, profileOwner.PersonaName) + "\n" + "
" + comment.Text + "
", } + + if config.BotConfig.TranslateEnabled { + translatedText, translatedTextLanguage, err := deepl.Translate(comment.Text) + if translatedTextLanguage == config.BotConfig.TranslateLanguage { + continue + } + if err != nil { + log.Error().Err(err).Msg("Failed to translate comment, continuing without translation") + msg.Text += "\n" + "Translation failed" + + } else { + msg.Text += "\n" + + fmt.Sprintf(`Translated from %s:`, deepl.SourceLanguages[translatedTextLanguage]) + "\n" + + "
" + translatedText + "
" + } + + } + log.Info().Interface("Comment", comment).Msg("Notifying about new comment") botIO.SendMessage(msg, bot) - time.Sleep(time.Minute / 20) + time.Sleep(time.Second * 3) //I have no Idea why this is here } newestProcessedComment = currentCommentsPage.TimeLastPost