Compare commits

...

12 commits

Author SHA1 Message Date
b55e60bcc7
fix: Fix config parsing
Some checks are pending
Build and Push to Docker Hub on push to any branch / docker (push) Waiting to run
2025-10-13 20:58:46 +02:00
1b93bab69f
docs: fix typo 2025-10-13 18:01:55 +02:00
70d36e32d2
docs: clarify versioning 2025-10-13 17:31:15 +02:00
22c01b54b8
chore: Only tag images with branchname if changes are on master branch 2025-10-13 17:27:26 +02:00
9f582e4c75
docs: Add documentation for new and changes features 2025-10-13 17:24:32 +02:00
038d375ae7
refactor!: change config structure to be more readable 2025-10-13 17:02:20 +02:00
f17601c470
chore: add vscode launch config 2025-10-13 16:10:06 +02:00
ac2146af1b
Merge pull request #2 from watn3y/feat/translation
feat/translation
2025-10-13 16:06:02 +02:00
6e4575f298
fix: config parsing 2025-10-13 14:51:36 +02:00
f445fef85a
refactor: change translation config 2025-10-13 14:38:15 +02:00
f3dec30b56
docs: Add documentation for translation feature 2025-10-13 14:37:43 +02:00
1e94b1fc1e
feat: Automatic comment translation using DeepL
Some checks failed
Build and Push to Docker Hub on push to any branch / docker (push) Has been cancelled
2025-10-13 06:08:26 +02:00
15 changed files with 319 additions and 40 deletions

10
.env.example Normal file
View file

@ -0,0 +1,10 @@
STEAMSALTY_LOGLEVEL=
STEAMSALTY_CHATID=
STEAMSALTY_WATCHERS=
STEAMSALTY_SLEEPINTERVAL=
STEAMSALTY_TRANSLATE_ENABLED=
STEAMSALTY_TRANSLATE_LANGUAGE=
STEAMSALTY_TELEGRAM_APITOKEN=
STEAMSALTY_STEAMAPIKEY=
STEAMSALTY_DEEPL_APIKEY=
STEAMSALTY_DEEPL_FREETIER=

View file

@ -8,6 +8,7 @@ on:
- 'go.sum'
- 'Dockerfile'
- '.github/workflows/**'
jobs:
docker:
runs-on: ubuntu-latest
@ -21,8 +22,8 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_TOKEN}}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
@ -30,5 +31,5 @@ jobs:
platforms: linux/amd64,linux/386,linux/arm64,linux/arm/v7,linux/riscv64
push: true
tags: |
${{ github.repository }}:${{ github.ref_name }}
${{ github.repository }}:commit-${{ github.sha }}
${{ github.ref_name == 'master' && format('{0}:{1}', github.repository, github.ref_name) || '' }}

12
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceRoot}"
}
]
}

View file

@ -1,6 +1,6 @@
# SteamSalty
SteamSalty notifies you on telegram about new comments on any steam profile.
SteamSalty notifies you on telegram about new comments on any steam profile **with built in auto translation**.
## Running with Docker Compose
@ -18,11 +18,15 @@ services:
- /etc/localtime:/etc/localtime:ro
environment:
#- STEAMSALTY_LOGLEVEL=
- STEAMSALTY_TELEGRAMAPITOKEN=
- STEAMSALTY_STEAMAPIKEY=
- STEAMSALTY_CHATID=
- STEAMSALTY_WATCHERS=
#- STEAMSALTY_SLEEPINTERVAL=
- STEAMSALTY_SLEEPINTERVAL=
#- STEAMSALTY_TRANSLATE_ENABLED=
#- STEAMSALTY_TRANSLATE_LANGUAGE=
- STEAMSALTY_TELEGRAM_APITOKEN=
- STEAMSALTY_STEAMAPIKEY=
#- STEAMSALTY_DEEPL_APIKEY=
#- STEAMSALTY_DEEPL_FREETIER=
```
## Running on Linux
@ -34,11 +38,21 @@ Grab a release from the [releases page](https://github.com/watn3y/steamsalty/rel
> [!NOTE]
> For development purposes, SteamSalty supports loading environment variables from a .env file placed in the project root directory.
| Variable | Description | Default | Required |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------| ------------------ | -------------- |
| `STEAMSALTY_LOGLEVEL` | LogLevel as described [in the zerolog documentation](https://pkg.go.dev/github.com/rs/zerolog@v1.34.0#readme-simple-leveled-logging-example) | 1 (Info) | ❌ |
| `STEAMSALTY_TELEGRAMAPITOKEN` | Telegram BotToken, get it from [@BotFather on Telegram](https://t.me/BotFather) | None | ✅ |
| `STEAMSALTY_STEAMAPIKEY` | Steam API Key, get it from [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | None | ✅ |
| `STEAMSALTY_CHATID` | Chat to notify about new comments | None | ✅ |
| `STEAMSALTY_WATCHERS` | SteamIDs (in SteamID64 format) to check for new profile comments | None | ✅ |
| `STEAMSALTY_SLEEPINTERVAL` | Amount of time to wait between requests to Steam in seconds | 60 | ❌ |
| Variable | Description | Default | Required | Example |
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------|-----------------------------------------------|
| `STEAMSALTY_LOGLEVEL` | LogLevel as described [in the zerolog documentation](https://pkg.go.dev/github.com/rs/zerolog@v1.34.0#readme-simple-leveled-logging-example) | 1 (Info) | ❌ | 1 |
| `STEAMSALTY_SLEEPINTERVAL` | Amount of time to wait between requests to Steam in seconds | 60 | ❌ | 60 |
| `STEAMSALTY_TRANSLATE_ENABLED` | Whether to enable translation of comments | False | ❌ | True, False |
| `STEAMSALTY_TRANSLATE_LANGUAGE` | Language to translate as described [in the DeepL API documentation](https://developers.deepl.com/docs/getting-started/supported-languages#translation-target-languages) | EN-US | ❌ | EN-US,DE |
| `STEAMSALTY_CHATID` | Chat to notify about new comments | None | ✅ | -1001234567890 |
| `STEAMSALTY_WATCHERS` | SteamIDs (in SteamID64 format) to check for new profile comments | None | ✅ | 76561198012345678,76561198087654321 |
| `STEAMSALTY_TELEGRAMAPITOKEN` | Telegram BotToken, get it from [@BotFather on Telegram](https://t.me/BotFather) | None | ✅ | 1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw |
| `STEAMSALTY_STEAMAPIKEY` | Steam API Key, get it from [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | None | ✅ | A7B3C9D2E5F1A4B8C6D9E2F5A8B1C4D7E0F3A6B9 |
| `STEAMSALTY_DEEPL_APIKEY` | DeepL API Key, get it from [deepl.com/en/your-account/keys](https://www.deepl.com/en/your-account/keys) | None | ❌ | a1b2c3d4-56e7-89f0-a1b2-c3d4e5f6a7b8:fx |
| `STEAMSALTY_DEEPL_FREETIER` | Whether you are using the DeepL Free Tier | True | ❌ | True, False |
## Nice to know
### Semantic Versioning
This project does it's best to follow [Semantic Versioning](https://semver.org/#semantic-versioning-200), however I can't guarantee anything.

View file

@ -1,15 +1,16 @@
package botIO
import (
"watn3y/steamsalty/config"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/rs/zerolog/log"
"watn3y/steamsalty/config"
)
func Authenticate() (tgbotapi.UpdatesChannel, *tgbotapi.BotAPI) {
bot, err := tgbotapi.NewBotAPI(config.BotConfig.TelegramAPIToken)
bot, err := tgbotapi.NewBotAPI(config.BotConfig.Telegram.APIToken)
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

View file

@ -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.Translate.Language = strings.ToUpper(BotConfig.Translate.Language)
log.Info().Msg("Config loaded successfully")
log.Debug().Interface("config", BotConfig).Msg("")

View file

@ -1,10 +1,27 @@
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"`
ChatID int64 `env:"STEAMSALTY_CHATID, required"`
Watchers []uint64 `env:"STEAMSALTY_WATCHERS, required"`
SleepInterval int `env:"STEAMSALTY_SLEEPINTERVAL, default=60"`
Translate struct {
Enabled bool `env:"STEAMSALTY_TRANSLATE_ENABLED, default=False"`
Language string `env:"STEAMSALTY_TRANSLATE_LANGUAGE, default=EN-US"`
}
Telegram struct {
APIToken string `env:"STEAMSALTY_TELEGRAM_APITOKEN, required"`
}
Steam struct {
APIKey string `env:"STEAMSALTY_STEAMAPIKEY, required"`
}
DeepL struct {
APIKey string `env:"STEAMSALTY_DEEPL_APIKEY"`
FreeTier bool `env:"STEAMSALTY_DEEPL_FREETIER, default=True"`
}
}

60
deepL/http.go Normal file
View file

@ -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
}

108
deepL/translate.go Normal file
View file

@ -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.DeepL.FreeTier {
baseURL = baseURLFree
}
client = &apiClient{
authKey: config.BotConfig.DeepL.APIKey,
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.Translate.Language,
}
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.Translate.Language]; !ok {
return fmt.Errorf("Selected language not supported by DeepL")
}
return nil
}

35
deepL/types.go Normal file
View file

@ -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"
)

2
go.mod
View file

@ -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

View file

@ -10,14 +10,16 @@ import (
"github.com/rs/zerolog/log"
"watn3y/steamsalty/config"
deepl "watn3y/steamsalty/deepL"
)
func main() {
println("Starting SteamSalty...")
configureLogger()
config.LoadConfig()
if config.BotConfig.Translate.Enabled {
deepl.Init()
}
bot()

View file

@ -9,7 +9,7 @@ import (
func GetPlayerDetails(steamID uint64) (summary steamapi.PlayerSummary) {
response, err := steamapi.GetPlayerSummaries([]uint64{steamID}, config.BotConfig.SteamAPIKey)
response, err := steamapi.GetPlayerSummaries([]uint64{steamID}, config.BotConfig.Steam.APIKey)
if err != nil {
log.Error().Err(err).Msg("Failed to retrive player summary")
}

View file

@ -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)

View file

@ -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(`<b><a href="%s">%s</a> just commented on <a href="%s">%s</a>'s profile:</b>`, comment.AuthorProfileURL, comment.Author, profileOwner.ProfileURL, profileOwner.PersonaName) + "\n" +
"<blockquote>" + comment.Text + "</blockquote>",
}
if config.BotConfig.Translate.Enabled {
translatedText, translatedTextLanguage, err := deepl.Translate(comment.Text)
if translatedTextLanguage == config.BotConfig.Translate.Language {
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" +
"<blockquote>" + translatedText + "</blockquote>"
}
}
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