diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index ba375e0..fc5e9eb 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -1,4 +1,4 @@ -name: Build & test +name: Build on: push: branches: @@ -17,13 +17,7 @@ jobs: uses: actions/setup-go@v3 with: go-version: '>=1.20.0' - - name: Create config.yml from secrets - run: echo -n "${{ secrets.CONFIG_YML }}" | base64 --decode > config.yml - - name: Create streams.yml from secrets - run: echo -n "${{ secrets.STREAMS_YML }}" | base64 --decode > streams.yml - name: ls run: ls -la - name: Build run: go build -v ./... - - name: Test with the Go CLI - run: go test -v ./... \ No newline at end of file diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 8341c21..b145452 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -18,10 +18,10 @@ jobs: with: go-version: '>=1.20.0' - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v4 + uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: latest + version: '~> v2' args: release --clean env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9a38b1e..e2548af 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,4 @@ dist/ go.work # Non-sample configs -config.yml -streams.yml \ No newline at end of file +config.yml \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f086a41..20c4ac7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -5,7 +5,7 @@ before: hooks: - go mod tidy builds: - - main: ./cmd/streamobserver + - main: . env: - CGO_ENABLED=0 goos: @@ -15,7 +15,6 @@ builds: archives: - files: - config.sample.yml - - streams.sample.yml format_overrides: - goos: windows format: zip diff --git a/README.md b/README.md index 1d19ebd..e458c0f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,5 @@ Go service to poll Twitch and Restreamer streams and notify Telegram chats and g - Get a Telegram bot token from [here](https://t.me/BotFather) - Get a twitch client ID and secret by registering an application [here](https://dev.twitch.tv/console/apps) -- Rename `config.sample.yml` to `config.yml` and enter your credentials -- Rename `streams.sample.yml` to `streams.yml` and enter chats to notify and streams to observe -- Either run via executable or `go run .\cmd\streamobserver\main.go` (pass `-debug` to enable lowest log level) - +- Rename `config.sample.yml` to `config.yml` and enter your credentials and streams to observe +- Either run via executable or `go run .` diff --git a/cmd/streamobserver/main.go b/cmd/streamobserver/main.go deleted file mode 100644 index 5a569d7..0000000 --- a/cmd/streamobserver/main.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "streamobserver/internal/config" - "streamobserver/internal/logger" - "streamobserver/internal/notifier" - "streamobserver/internal/telegram" - "time" - - "github.com/rs/zerolog" -) - -func main() { - // get debug flag, initialize logging - debug := flag.Bool("debug", false, "sets log level to debug") - flag.Parse() - if *debug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - } else { - zerolog.SetGlobalLevel(zerolog.InfoLevel) - } - - confExists, err := config.CheckPresent() - if err != nil || !confExists { - fmt.Fprint(os.Stdout, "ERROR: config.yml and/or streams.yml not found. Press [ENTER] to exit.") - fmt.Scanln() - return - } - - config, err := config.GetConfig() - if err != nil { - logger.Log.Panic().Err(err).Msg("Error reading config.") - } - - logger.InitLog(config.General.JsonLogging) - - // start telegram bot - err = telegram.InitBot(*debug) - if err != nil { - logger.Log.Panic().Err(err).Msg("Error initializing Telegram bot.") - } - - // set up observers from streams config - err = notifier.PopulateObservers() - if err != nil { - logger.Log.Panic().Err(err).Msg("Error populating streams to notify.") - } - - // set up polling ticker - ticker := time.NewTicker(time.Duration(config.General.PollingInterval) * time.Second) - for range ticker.C { - notifier.Notify() - } - -} diff --git a/config.sample.yml b/config.sample.yml index 8a52441..afeab0a 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -1,12 +1,26 @@ +general: + polling_interval: "180s" + request_timeout: "20s" + debug: false + telegram: - apikey: "Telegram-Bot-API-Key" + apikey: "telegram-bot-key" + twitch: - client-id: "Twitch-Client-ID" - client-secret: "Twitch-Client-Secret" -general: - # polling interval to check streams, keep in mind twitch api rate limits - polling-interval: 180 - # chat/group/channel to use for go tests - test-chatid: -1004242424242 - # enable json formatted logging - json-logging: false \ No newline at end of file + client_id: "client-id" + client_secret: "client-secret" + +chats: + # List of chat IDs to notify (private / group) + - chatid: 42424242 + streams: + twitch: + # List of Twitch usernames to observe + - username: "dashducks" + restreamer: + # List of restreamer streams to observe + - baseurl: "https://server.restreamer.tld" + # Channel ID + id: "124278c-5a03-45ca-8302-c322f1127232" + # Optional, for a custom page embedding the restreamer stream + customurl: "https://stream.wrapper.tld" diff --git a/go.mod b/go.mod index 5742efd..b07b2ca 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,34 @@ module streamobserver go 1.20 require ( - github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/go-telegram/bot v1.9.1 + github.com/rs/zerolog v1.33.0 + github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect -) - -require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/rs/zerolog v1.33.0 - github.com/stretchr/testify v1.9.0 - golang.org/x/sys v0.12.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index fbb88b3..88312ec 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,77 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-telegram/bot v1.9.1 h1:4vkNV6vDmEPZaYP7sZYaagOaJyV4GerfOPkjg/Ki5ic= +github.com/go-telegram/bot v1.9.1/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapter/restreamer/restreamer.go b/internal/adapter/restreamer/restreamer.go new file mode 100644 index 0000000..99c468a --- /dev/null +++ b/internal/adapter/restreamer/restreamer.go @@ -0,0 +1,160 @@ +package restreamer + +import ( + "context" + "encoding/json" + "github.com/rs/zerolog/log" + "net/http" + "streamobserver/internal/core/domain" + "streamobserver/internal/core/port" + "sync" +) + +const ( + channelPath = "/channels/" + internalPath = "/memfs/" + embedSuffix = "/oembed.json" + playlistSuffix = ".m3u8" + channelSuffix = ".html" +) + +type StreamInfoProvider struct{} + +var _ = (*port.StreamInfoProvider)(nil) + +type restreamerResponse struct { + Description string `json:"description"` + Username string `json:"author_name"` + ThumbnailURL string `json:"thumbnail_url"` +} + +func checkOnline(ctx context.Context, stream domain.StreamQuery, client *http.Client) (bool, error) { + url := stream.BaseURL + internalPath + stream.UserID + playlistSuffix + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + log.Error().Err(err).Msg("error building http request for restreamer") + return false, err + } + + resp, err := client.Do(req) + if err != nil { + log.Error().Err(err).Msg("get stream online request failed") + return false, err + } + + return resp.StatusCode == http.StatusOK, nil +} + +func fetchInfo(ctx context.Context, stream domain.StreamQuery, client *http.Client) (restreamerResponse, error) { + url := stream.BaseURL + channelPath + stream.UserID + embedSuffix + log.Debug().Str("URL", url).Msg("getting restreamer stream config from URL") + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + log.Error().Err(err).Msg("error building http request for restreamer") + return restreamerResponse{}, err + } + + resp, err := client.Do(req) + if err != nil { + log.Error().Err(err).Msg("get stream info request failed") + return restreamerResponse{}, err + } + + defer resp.Body.Close() + + var restreamerInfo restreamerResponse + err = json.NewDecoder(resp.Body).Decode(&restreamerInfo) + if err != nil { + log.Error().Err(err).Msg("error decoding restreamer stream info") + return restreamerResponse{}, err + } + + return restreamerInfo, nil +} + +func (s *StreamInfoProvider) GetStreamInfos(ctx context.Context, streams []*domain.StreamQuery) ([]domain.StreamInfo, error) { + log.Info().Int("count", len(streams)).Msg("getting info for restreamer streams") + + wg := sync.WaitGroup{} + + wg.Add(len(streams)) + + infos := make([]domain.StreamInfo, 0) + infoCh := make(chan domain.StreamInfo, len(streams)) + errCh := make(chan error, len(streams)) + + client := &http.Client{} + + for _, stream := range streams { + go fetch(ctx, stream, client, infoCh, errCh, &wg) + } + + wg.Wait() + close(infoCh) + close(errCh) + + for err := range errCh { + if err != nil { + log.Error().Err(err).Msg("error getting stream info") + return nil, err + } + } + + for info := range infoCh { + infos = append(infos, info) + } + + return infos, nil +} + +func fetch(ctx context.Context, + query *domain.StreamQuery, + client *http.Client, + stream chan<- domain.StreamInfo, + errs chan<- error, + wg *sync.WaitGroup) { + defer wg.Done() + + online, err := checkOnline(ctx, *query, client) + if err != nil { + log.Error().Err(err).Msgf("error checking if stream %s is online", query.UserID) + errs <- err + return + } + + if !online { + stream <- domain.StreamInfo{ + Query: query, + IsOnline: false, + } + return + } + + info, err := fetchInfo(ctx, *query, client) + if err != nil { + log.Error().Err(err).Msgf("error fetching stream %s info", query.UserID) + errs <- err + return + } + + var url string + if query.CustomURL == "" { + url = query.BaseURL + query.UserID + channelSuffix + } else { + url = query.CustomURL + } + + stream <- domain.StreamInfo{ + Query: query, + Username: info.Username, + Title: info.Description, + URL: url, + ThumbnailURL: info.ThumbnailURL, + IsOnline: true, + } +} + +func (s *StreamInfoProvider) Kind() domain.StreamKind { + return domain.StreamKindRestreamer +} diff --git a/internal/adapter/telegram/telegram.go b/internal/adapter/telegram/telegram.go new file mode 100644 index 0000000..29f48e3 --- /dev/null +++ b/internal/adapter/telegram/telegram.go @@ -0,0 +1,83 @@ +package telegram + +import ( + "context" + "errors" + "fmt" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + "github.com/rs/zerolog/log" + "streamobserver/internal/core/domain" +) + +const ( + twitchPrefix = "https://twitch.tv/" + htmlSuffix = ".html" + liveText = "🔴 LIVE" + offlineText = "❌ OFFLINE" +) + +type Sender struct { + b *bot.Bot +} + +func NewTelegramSender(b *bot.Bot) *Sender { + return &Sender{b: b} +} + +// SendStreamInfo generates a message from a domain.StreamInfo and sends it to a chat ID. +func (s *Sender) SendStreamInfo(ctx context.Context, chatID int64, stream domain.StreamInfo) (int, error) { + + caption := fmt.Sprintf("%s is streaming %s\n%s\n[%s]", stream.Username, stream.Title, stream.URL, liveText) + + ret, err := s.b.SendPhoto(ctx, &bot.SendPhotoParams{ + ChatID: chatID, + Photo: &models.InputFileString{Data: stream.ThumbnailURL}, + Caption: caption, + }) + if err != nil { + log.Error().Err(err).Msg("error sending telegram message") + return -1, err + } + + log.Debug().Interface("Message", ret).Msg("Sent message.") + + if ret.Chat.ID != chatID { + return -1, errors.New("returned invalid chat id") + } + + return ret.ID, nil +} + +// UpdateStreamInfo generates a message from a domain.StreamInfo and sends it to a chat ID. +func (s *Sender) UpdateStreamInfo(ctx context.Context, chatID int64, messageID int, stream domain.StreamInfo) error { + var verb string + var status string + + if stream.IsOnline { + verb = "is" + status = liveText + } else { + verb = "was" + status = offlineText + } + caption := fmt.Sprintf("%s %s streaming %s\n%s\n[%s]", stream.Username, verb, stream.Title, stream.URL, status) + + ret, err := s.b.EditMessageCaption(ctx, &bot.EditMessageCaptionParams{ + ChatID: chatID, + MessageID: messageID, + Caption: caption, + }) + if err != nil { + log.Error().Err(err).Msg("error sending telegram message") + return err + } + + log.Debug().Interface("Message", ret).Msg("Sent message.") + + if ret.Chat.ID != chatID { + return errors.New("returned invalid chat id") + } + + return nil +} diff --git a/internal/adapter/twitch/twitch.go b/internal/adapter/twitch/twitch.go new file mode 100644 index 0000000..7889c5d --- /dev/null +++ b/internal/adapter/twitch/twitch.go @@ -0,0 +1,193 @@ +package twitch + +import ( + "context" + "encoding/json" + "fmt" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + "net/http" + "net/url" + "streamobserver/internal/core/domain" + "streamobserver/internal/core/port" + "strings" + "time" +) + +const ( + widthPlaceholder = "{width}" + heightPlaceholder = "{height}" + twitchTokenURL = "https://id.twitch.tv/oauth2/token" + twitchStreamsURL = "https://api.twitch.tv/helix/streams" + twitchBaseURL = "https://twitch.tv" + twitchMimeType = "application/json" +) + +type StreamInfoProvider struct { + token authToken +} + +var _ = (*port.StreamInfoProvider)(nil) + +type twitchResponse struct { + Data []struct { + Username string `json:"user_login"` + GameName string `json:"game_name"` + Title string `json:"title"` + ThumbnailURL string `json:"thumbnail_url"` + } `json:"data"` +} + +type authToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + tokenCreation time.Time +} + +func formatTwitchPhotoUrl(url string) string { + url = strings.Replace(url, heightPlaceholder, "1080", 1) + return strings.Replace(url, widthPlaceholder, "1920", 1) +} + +func (s *StreamInfoProvider) GetStreamInfos(ctx context.Context, streams []*domain.StreamQuery) ([]domain.StreamInfo, error) { + log.Info().Int("count", len(streams)).Msg("getting info for twitch streams") + + err := s.authenticate(ctx) + if err != nil { + log.Err(err).Msg("error authenticating with twitch") + return nil, err + } + + bearer := "Bearer " + s.token.AccessToken + + base, err := url.Parse(twitchStreamsURL) + if err != nil { + return nil, err + } + + // Query params + params := url.Values{} + params.Add("type", "live") + params.Add("first", "100") + for _, s := range streams { + params.Add("user_login", s.UserID) + } + base.RawQuery = params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil) + if err != nil { + log.Err(err).Msg("error building request for twitch") + return nil, err + } + + req.Header.Set("Authorization", bearer) + req.Header.Add("Accept", twitchMimeType) + req.Header.Add("client-id", viper.GetString("twitch.client_id")) + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + log.Error().Err(err).Msg("error making request to twitch") + return nil, err + } + if resp.StatusCode != http.StatusOK { + log.Error().Int("StatusCode", resp.StatusCode).Interface("Response", resp). + Msg("No HTTP OK from Twitch Helix.") + return nil, err + } + + defer resp.Body.Close() + + var response twitchResponse + + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + log.Err(err).Msg("error decoding response from twitch") + return nil, err + } + + infos := make([]domain.StreamInfo, len(streams)) + + for i, s := range streams { + online := false + log.Debug().Str("id", s.UserID).Msg("checking if stream in response") + for _, data := range response.Data { + if data.Username == s.UserID { + log.Debug().Msg("found, setting info") + infos[i] = domain.StreamInfo{ + Query: s, + Username: data.Username, + Title: fmt.Sprintf("%s: %s", data.GameName, data.Title), + URL: fmt.Sprintf("%s/%s", twitchBaseURL, data.Username), + ThumbnailURL: formatTwitchPhotoUrl(data.ThumbnailURL), + IsOnline: true, + } + online = true + } + if !online { + log.Debug().Msg("not found, setting offline info") + infos[i] = domain.StreamInfo{ + Query: s, + Username: s.UserID, + IsOnline: false, + } + } + } + } + + return infos, nil +} + +func (s *StreamInfoProvider) authenticate(ctx context.Context) error { + log.Debug().Msg("authenticating with twitch API") + if s.token.AccessToken != "" { + log.Debug().Msg("twitch auth token present, checking validity") + expiryTime := s.token.tokenCreation.Add(time.Second * time.Duration(s.token.ExpiresIn)) + if time.Now().Before(expiryTime) { + log.Debug().Msg("token still valid.") + return nil + } + } + + base, err := url.Parse(twitchTokenURL) + if err != nil { + return err + } + + // Query params + params := url.Values{} + params.Add("client_id", viper.GetString("twitch.client_id")) + params.Add("client_secret", viper.GetString("twitch.client_secret")) + params.Add("grant_type", "client_credentials") + base.RawQuery = params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base.String(), nil) + if err != nil { + log.Err(err).Msg("error creating twitch auth request") + return err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Err(err).Msg("error executing twitch auth request") + return err + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&s.token) + s.token.tokenCreation = time.Now() + if err != nil { + log.Err(err).Msg("error parsing twitch auth response") + return err + } + + log.Debug().Msg("successfully authenticated on twitch") + return nil +} + +func (s *StreamInfoProvider) Kind() domain.StreamKind { + return domain.StreamKindTwitch +} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 76c5a4a..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,70 +0,0 @@ -package config - -import ( - "errors" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -// Config contains all the settings parsed from the config file. -type Config struct { - Telegram struct { - ApiKey string `yaml:"apikey"` - } `yaml:"telegram"` - - Twitch `yaml:"twitch"` - - General struct { - PollingInterval int `yaml:"polling-interval"` - TestChatID int64 `yaml:"test-chatid"` - JsonLogging bool `yaml:"json-logging"` - } `yaml:"general"` -} - -type Twitch struct { - ClientID string `yaml:"client-id"` - ClientSecret string `yaml:"client-secret"` -} - -var config *Config - -// GetConfig parses a config.yml file placed in the root execution path containing credentials and settings for the application. -// It returns an object containing the parsed settings. -func GetConfig() (*Config, error) { - - config = &Config{} - - // open config file - p := filepath.FromSlash("./config.yml") - file, err := os.Open(p) - if err != nil { - return nil, err - } - defer file.Close() - - // init new YAML decode - d := yaml.NewDecoder(file) - - // start YAML decoding from file - if err := d.Decode(&config); err != nil { - return nil, err - } - - if (&Config{}) == config { - return nil, errors.New("config is empty") - } - - return config, err -} - -func CheckPresent() (bool, error) { - if _, err := os.Stat("./config.yml"); errors.Is(err, os.ErrNotExist) { - return false, errors.New("config.yml not found") - } - if _, err := os.Stat("./streams.yml"); errors.Is(err, os.ErrNotExist) { - return false, errors.New("config.yml not found") - } - return true, nil -} diff --git a/internal/core/domain/models.go b/internal/core/domain/models.go new file mode 100644 index 0000000..12c2714 --- /dev/null +++ b/internal/core/domain/models.go @@ -0,0 +1,63 @@ +package domain + +type StreamQuery struct { + UserID string + BaseURL string + CustomURL string + Kind StreamKind +} + +func (s StreamQuery) Equals(o StreamQuery) bool { + return s.UserID == o.UserID && + s.BaseURL == o.BaseURL && + s.CustomURL == o.CustomURL && + s.Kind == o.Kind +} + +type StreamKind string + +const ( + StreamKindTwitch StreamKind = "twitch" + StreamKindRestreamer StreamKind = "restreamer" +) + +type StreamInfo struct { + Query *StreamQuery + Username string + Title string + URL string + ThumbnailURL string + IsOnline bool +} + +func (s StreamInfo) Equals(o StreamInfo) bool { + return s.IsOnline == o.IsOnline && + s.Title == o.Title && + s.URL == o.URL && + s.ThumbnailURL == o.ThumbnailURL +} + +type Observer struct { + ChannelID int64 + MessageID int +} + +type ObservedStream struct { + Observers []Observer + LatestInfo StreamInfo + PublishedOfflineStatus bool +} + +type ChatConfig struct { + ChatID int64 `yaml:"chatid"` + Streams struct { + Twitch []struct { + Username string `yaml:"username"` + } `yaml:"twitch"` + Restreamer []struct { + BaseURL string `yaml:"baseurl"` + ID string `yaml:"id"` + CustomURL string `yaml:"customurl"` + } `yaml:"restreamer"` + } `yaml:"streams"` +} diff --git a/internal/core/port/port.go b/internal/core/port/port.go new file mode 100644 index 0000000..214e3d7 --- /dev/null +++ b/internal/core/port/port.go @@ -0,0 +1,32 @@ +package port + +import ( + "context" + "streamobserver/internal/core/domain" +) + +type StreamInfoProvider interface { + // GetStreamInfos takes an array of streams for a single stream service and returns metadata for those that are online. + GetStreamInfos(ctx context.Context, streams []*domain.StreamQuery) ([]domain.StreamInfo, error) + // Kind returns the streaming service fetched by this provider + Kind() domain.StreamKind +} + +type Notifier interface { + // SendStreamInfo sends a message with stream info to a target channel ID + SendStreamInfo(ctx context.Context, target int64, stream domain.StreamInfo) (messageID int, err error) + // UpdateStreamInfo updates a previously sent message ID with stream info + UpdateStreamInfo(ctx context.Context, chatID int64, messageID int, stream domain.StreamInfo) error +} + +type NotificationBroker interface { + // Register adds a target channel ID and a stream to observe NotificationBroker + Register(target int64, query *domain.StreamQuery) + // StartPolling starts the notification routine + StartPolling(ctx context.Context) +} + +type StreamInfoService interface { + // GetStreamInfos retrieves stream info for different providers + GetStreamInfos(ctx context.Context, streams []*domain.StreamQuery) ([]domain.StreamInfo, error) +} diff --git a/internal/core/service/notification.go b/internal/core/service/notification.go new file mode 100644 index 0000000..bb8ad44 --- /dev/null +++ b/internal/core/service/notification.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + "streamobserver/internal/core/domain" + "streamobserver/internal/core/port" + "time" +) + +type NotificationService struct { + notifier port.Notifier + // TODO: combine stream getters into service agnostic interface + streamGetter port.StreamInfoService + streams map[*domain.StreamQuery]domain.ObservedStream +} + +var _ port.NotificationBroker = (*NotificationService)(nil) + +func NewNotificationService(n port.Notifier, m port.StreamInfoService) *NotificationService { + return &NotificationService{ + notifier: n, + streamGetter: m, + streams: make(map[*domain.StreamQuery]domain.ObservedStream), + } +} + +func (n *NotificationService) Register(target int64, query *domain.StreamQuery) { + log.Info().Str("id", query.UserID).Int64("target", target).Msg("registering stream") + + queryFound := false + for k, v := range n.streams { + if k.Equals(*query) { + queryFound = true + targetFound := false + for _, observer := range v.Observers { + if observer.ChannelID == target { + targetFound = true + } + } + if !targetFound { + v.Observers = append(v.Observers, domain.Observer{ + ChannelID: target, + }) + n.streams[k] = v + } + } + } + + if !queryFound { + n.streams[query] = domain.ObservedStream{ + Observers: []domain.Observer{ + { + ChannelID: target, + }, + }, + } + } + + log.Debug().Int("totalObserved", len(n.streams)).Msg("register successful") +} + +func (n *NotificationService) StartPolling(ctx context.Context) { + log.Debug().Msg("starting poll routine") + + for range time.Tick(viper.GetDuration("general.polling_interval")) { + log.Debug().Msg("tick, querying streams") + + queries := make([]*domain.StreamQuery, 0) + for k := range n.streams { + log.Debug().Str("id", k.UserID).Msg("adding stream id to query list") + queries = append(queries, k) + } + + infos, err := n.streamGetter.GetStreamInfos(ctx, queries) + if err != nil { + log.Err(err).Msg("failed to get stream infos") + continue + } + + for _, info := range infos { + s := n.streams[info.Query] + + log.Debug().Str("id", info.Query.UserID).Msg("checking if notification is needed") + + if !s.LatestInfo.Equals(info) { + if !info.IsOnline && s.PublishedOfflineStatus { + continue + } + if info.IsOnline { + s.PublishedOfflineStatus = false + } + + // updated info on offline streams does not contain metadata, fill and send once, clear message ID + if !info.IsOnline && !s.PublishedOfflineStatus { + info = s.LatestInfo + info.IsOnline = false + s.PublishedOfflineStatus = true + } + + log.Info().Str("stream", info.Username).Bool("online", info.IsOnline).Msg("stream status update, notifying") + go n.notify(ctx, &s, info) + + s.LatestInfo = info + n.streams[info.Query] = s + } + } + } +} + +func (n *NotificationService) notify(ctx context.Context, observed *domain.ObservedStream, info domain.StreamInfo) { + for i, observer := range observed.Observers { + log.Info().Int64("target", observer.ChannelID).Str("stream", info.Username).Msg("notifying observer") + if observer.MessageID == 0 { + log.Debug().Int64("observer", observer.ChannelID).Msg("first trigger, sending info") + id, err := n.notifier.SendStreamInfo(ctx, observer.ChannelID, info) + if err != nil { + log.Err(err).Int64("observer", observer.ChannelID).Msg("failed to send info") + } + observed.Observers[i].MessageID = id + } else { + log.Debug().Int64("observer", observer.ChannelID).Msg("later trigger, updating info") + err := n.notifier.UpdateStreamInfo(ctx, observer.ChannelID, observer.MessageID, info) + if err != nil { + log.Err(err).Int64("observer", observer.ChannelID).Msg("failed to update info") + } + } + } + + if observed.PublishedOfflineStatus { + for i, _ := range observed.Observers { + observed.Observers[i].MessageID = 0 + } + } +} diff --git a/internal/core/service/stream.go b/internal/core/service/stream.go new file mode 100644 index 0000000..cfab15d --- /dev/null +++ b/internal/core/service/stream.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + "streamobserver/internal/core/domain" + "streamobserver/internal/core/port" +) + +type StreamService struct { + twitchGetter port.StreamInfoProvider + restreamerGetter port.StreamInfoProvider +} + +var _ port.StreamInfoService = (*StreamService)(nil) + +func NewStreamService(getters ...port.StreamInfoProvider) *StreamService { + srv := &StreamService{} + + for _, getter := range getters { + if getter.Kind() == domain.StreamKindTwitch { + srv.twitchGetter = getter + } else if getter.Kind() == domain.StreamKindRestreamer { + srv.restreamerGetter = getter + } + } + + return srv +} + +func (ss *StreamService) GetStreamInfos(ctx context.Context, streams []*domain.StreamQuery) ([]domain.StreamInfo, error) { + ctx, cancel := context.WithTimeout(ctx, viper.GetDuration("general.request_timeout")) + defer cancel() + + infos := make([]domain.StreamInfo, 0) + twitchStreams := make([]*domain.StreamQuery, 0) + restreamerStreams := make([]*domain.StreamQuery, 0) + + for _, stream := range streams { + if stream.Kind == domain.StreamKindTwitch { + twitchStreams = append(twitchStreams, stream) + } else if stream.Kind == domain.StreamKindRestreamer { + restreamerStreams = append(restreamerStreams, stream) + } + } + + log.Info(). + Int("twitchStreamCount", len(twitchStreams)). + Int("restreamerStreamCount", len(restreamerStreams)). + Msg("getting stream infos") + + if len(twitchStreams) > 0 { + twitchInfos, err := ss.twitchGetter.GetStreamInfos(ctx, twitchStreams) + if err != nil { + log.Err(err).Msg("unable to fetch twitch streams") + return nil, err + } + infos = append(infos, twitchInfos...) + } + + if len(restreamerStreams) > 0 { + restreamerInfos, err := ss.restreamerGetter.GetStreamInfos(ctx, restreamerStreams) + if err != nil { + log.Err(err).Msg("unable to fetch restream streams") + return nil, err + } + infos = append(infos, restreamerInfos...) + } + + return infos, nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go deleted file mode 100644 index 81693bd..0000000 --- a/internal/logger/logger.go +++ /dev/null @@ -1,24 +0,0 @@ -package logger - -import ( - "os" - - "github.com/rs/zerolog" -) - -// Log is the global logger for the application. -var Log zerolog.Logger - -// InitLog initializes the global logger, setting the log level according to the debug parameter. -func InitLog(jsonLogging bool) { - consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout} - - multi := zerolog.MultiLevelWriter(consoleWriter, os.Stdout) - - if jsonLogging { - Log = zerolog.New(multi).With().Timestamp().Logger() - } else { - Log = zerolog.New(consoleWriter).With().Timestamp().Logger() - } - -} diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go deleted file mode 100644 index ca3b758..0000000 --- a/internal/notifier/notifier.go +++ /dev/null @@ -1,212 +0,0 @@ -package notifier - -import ( - "errors" - "os" - "path/filepath" - "streamobserver/internal/logger" - "streamobserver/internal/restreamer" - "streamobserver/internal/telegram" - "streamobserver/internal/twitch" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "gopkg.in/yaml.v3" -) - -type telegramChat struct { - twitchStreams []twitchStream - restreamerStreams []restreamerStream - chatID int64 -} - -type twitchStream struct { - username string - title string - game string - status bool - notified bool - notificationMessage tgbotapi.Message -} - -type restreamerStream struct { - stream restreamer.Stream - status bool - notified bool - notificationMessage tgbotapi.Message -} - -type notifierConfig struct { - Chats []*chatConfig `yaml:"chats"` -} - -type chatConfig struct { - ChatID int64 `yaml:"chatid"` - Streams struct { - Twitch []struct { - Username string `yaml:"username"` - } `yaml:"twitch"` - - Restreamer []struct { - BaseURL string `yaml:"baseurl"` - ID string `yaml:"id"` - CustomURL string `yaml:"customurl"` - } `yaml:"restreamer"` - } `yaml:"streams"` -} - -var chats = []telegramChat{} - -// PopulateObservers parses the streams config file. -func PopulateObservers() error { - config := ¬ifierConfig{} - - // Open streams.yml - p := filepath.FromSlash("./streams.yml") - logger.Log.Debug().Str("Path", p).Msg("Reading streams config from disk") - file, err := os.Open(p) - if err != nil { - return err - } - defer file.Close() - - // Decode YAML - d := yaml.NewDecoder(file) - if err := d.Decode(&config); err != nil { - return err - } - - // Chats from Config - for _, s := range config.Chats { - chat := &telegramChat{} - chat.chatID = s.ChatID - // Twitch streams for each chat - for _, tw := range s.Streams.Twitch { - twitchData := new(twitchStream) - twitchData.username = tw.Username - twitchData.notified = false - twitchData.status = false - - chat.twitchStreams = append(chat.twitchStreams, *twitchData) - } - - // Restreamer streams for each chat - for _, rs := range s.Streams.Restreamer { - restreamerData := new(restreamerStream) - restreamerData.stream = restreamer.Stream{ - BaseURL: rs.BaseURL, - ID: rs.ID, - CustomURL: rs.CustomURL, - } - restreamerData.notified = false - restreamerData.status = false - chat.restreamerStreams = append(chat.restreamerStreams, *restreamerData) - } - - chats = append(chats, *chat) - } - - if len(chats) == 0 || (len(chats[0].restreamerStreams) == 0 && len(chats[0].twitchStreams) == 0) { - return errors.New("no chats/streams loaded") - } - - return nil -} - -// Notify starts the notification process and checks the configured streams for updates. -func Notify() { - logger.Log.Debug().Msg("Started notify iteration") - for i := range chats { - for j := range chats[i].twitchStreams { - checkAndNotifyTwitch(&chats[i].twitchStreams[j], chats[i].chatID) - } - for j := range chats[i].restreamerStreams { - checkAndNotifyRestreamer(&chats[i].restreamerStreams[j], chats[i].chatID) - } - } - -} - -func checkAndNotifyTwitch(streamToCheck *twitchStream, chatID int64) { - logger.Log.Info().Str("Channel", streamToCheck.username).Msg("Twitch: Checking status") - - // Get stream status and metadata from API - streamResponse, err := twitch.GetStreams([]string{streamToCheck.username}) - if err != nil { - logger.Log.Error().Err(errors.New("error getting info from Twitch API")) - return - } - - // List populated means stream is online - if len(streamResponse) > 0 { - if !streamToCheck.status { - logger.Log.Debug().Str("Channel", streamToCheck.username).Msg("Online and status has changed.") - streamToCheck.status = true - if !streamToCheck.notified { - logger.Log.Debug().Str("Channel", streamToCheck.username).Msg("Status change and notification needed.") - var err error - streamToCheck.notificationMessage, err = telegram.SendTwitchStreamInfo(chatID, streamResponse[0]) - if err != nil { - logger.Log.Error().Int64("ChatID", chatID).Str("Channel", streamToCheck.username).Err(err).Msg("Could not notify about channel status.") - streamToCheck.notified = false - streamToCheck.status = false - } - streamToCheck.notified = true - streamToCheck.game = streamResponse[0].GameName - streamToCheck.title = streamResponse[0].Title - } - } else { - logger.Log.Debug().Str("Channel", streamToCheck.username).Msg("Online and status has not changed.") - if streamToCheck.game != streamResponse[0].GameName || streamToCheck.title != streamResponse[0].Title { - telegram.SendUpdateTwitchStreamInfo(chatID, streamToCheck.notificationMessage, streamResponse[0]) - } - - } - } else { - if streamToCheck.status && streamToCheck.notified && streamToCheck.notificationMessage.MessageID != 0 { - telegram.SendUpdateStreamOffline(streamToCheck.notificationMessage, chatID) - } - logger.Log.Debug().Str("Channel", streamToCheck.username).Msg("Channel offline.") - streamToCheck.status = false - streamToCheck.notified = false - } -} - -func checkAndNotifyRestreamer(streamToCheck *restreamerStream, chatID int64) { - logger.Log.Info().Str("Channel", streamToCheck.stream.ID).Msg("Restreamer: Checking status") - online, err := restreamer.CheckStreamLive(streamToCheck.stream) - if err != nil { - logger.Log.Error().Err(err).Msg("error getting stream status from restreamer") - return - } - if online { - streamInfo, err := restreamer.GetStreamInfo(streamToCheck.stream) - if err != nil { - logger.Log.Error().Err(err).Msg("error getting stream info from restreamer") - return - } - if !streamToCheck.status { - logger.Log.Debug().Str("Channel", streamInfo.UserName).Msg("Online and status has changed.") - streamToCheck.status = true - if !streamToCheck.notified { - logger.Log.Debug().Str("Channel", streamInfo.UserName).Msg("Status change and notification needed.") - var err error - streamToCheck.notificationMessage, err = telegram.SendRestreamerStreamInfo(chatID, streamInfo, streamToCheck.stream) - if err != nil { - logger.Log.Error().Int64("ChatID", chatID).Str("Channel", streamToCheck.stream.ID).Err(err).Msg("Could not notify about channel status.") - streamToCheck.notified = false - streamToCheck.status = false - } - streamToCheck.notified = true - } - } else { - logger.Log.Debug().Str("Channel", streamInfo.UserName).Msg("Online and status has not changed.") - } - } else { - if streamToCheck.status && streamToCheck.notified && streamToCheck.notificationMessage.MessageID != 0 { - telegram.SendUpdateStreamOffline(streamToCheck.notificationMessage, chatID) - } - logger.Log.Debug().Str("Channel", streamToCheck.stream.ID).Msg("Channel offline.") - streamToCheck.status = false - streamToCheck.notified = false - } -} diff --git a/internal/notifier/notifier_test.go b/internal/notifier/notifier_test.go deleted file mode 100644 index a3f88bd..0000000 --- a/internal/notifier/notifier_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package notifier - -import ( - "os" - "path" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" -) - -func init() { - // make sure we're in project root for tests - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - -func TestPopulateObservers(t *testing.T) { - PopulateObservers() - assert.NotEmpty(t, chats, "chat slice should not be empty after reading config") -} diff --git a/internal/restreamer/restreamer.go b/internal/restreamer/restreamer.go deleted file mode 100644 index 5b1d468..0000000 --- a/internal/restreamer/restreamer.go +++ /dev/null @@ -1,62 +0,0 @@ -package restreamer - -import ( - "encoding/json" - "net/http" - "streamobserver/internal/logger" -) - -// StreamInfo contains Restreamer stream metadata -type StreamInfo struct { - Description string `json:"description"` - UserName string `json:"author_name"` - ThumbnailURL string `json:"thumbnail_url"` -} - -// Stream contains the basic info used to poll a stream from Restreamer -type Stream struct { - BaseURL string - ID string - CustomURL string -} - -const channelPath = "/channels/" -const internalPath = "/memfs/" -const embedSuffix = "/oembed.json" -const playlistSuffix = ".m3u8" - -// CheckStreamLive returns if a Restreamer stream is online -func CheckStreamLive(stream Stream) (bool, error) { - url := stream.BaseURL + internalPath + stream.ID + playlistSuffix - logger.Log.Debug().Str("URL", url).Msg("Restreamer: Checking URL for HTTP OK") - resp, err := http.Get(url) - if err != nil { - logger.Log.Error().Err(err).Msg("Error making HTTP request to restreamer") - return false, err - } - - defer resp.Body.Close() - return resp.StatusCode == http.StatusOK, nil -} - -// GetStreamInfo returns the metadata of a stream, if online -func GetStreamInfo(stream Stream) (StreamInfo, error) { - - url := stream.BaseURL + channelPath + stream.ID + embedSuffix - logger.Log.Debug().Str("URL", url).Msg("Restreamer: Getting stream config from URL") - resp, err := http.Get(url) - if err != nil { - logger.Log.Error().Err(err).Msg("Error making HTTP stream info request to restreamer") - return StreamInfo{}, err - } - - defer resp.Body.Close() - var streamInfo StreamInfo - err = json.NewDecoder(resp.Body).Decode(&streamInfo) - if err != nil { - logger.Log.Error().Err(err).Msg("Error decoding restreamer stream info") - return StreamInfo{}, err - } - logger.Log.Debug().Interface("Info", streamInfo).Msg("Received Stream Info") - return streamInfo, nil -} diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go deleted file mode 100644 index c847daa..0000000 --- a/internal/telegram/telegram.go +++ /dev/null @@ -1,168 +0,0 @@ -package telegram - -import ( - "errors" - "streamobserver/internal/config" - "streamobserver/internal/logger" - "streamobserver/internal/restreamer" - "streamobserver/internal/twitch" - "streamobserver/internal/util" - "strings" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" -) - -var bot *tgbotapi.BotAPI - -const ( - twitchPrefix = "https://twitch.tv/" - htmlSuffix = ".html" - liveText = "🔴 LIVE" - offlineText = "❌ OFFLINE" -) - -// InitBot initializes the Telegram bot to send updates with. -func InitBot(debug bool) error { - config, err := config.GetConfig() - if err != nil { - return err - } - - bot, err = tgbotapi.NewBotAPI(config.Telegram.ApiKey) - if err != nil { - return err - } - - bot.Debug = debug - logger.Log.Info().Msgf("Authorized on Telegram account %s", bot.Self.UserName) - return nil -} - -// SendTwitchStreamInfo generates a message from a Twitch stream struct and sends it to a chat ID. -func SendTwitchStreamInfo(chatID int64, stream twitch.Stream) (tgbotapi.Message, error) { - if bot == nil { - logger.Log.Error().Msg("Bot not initialized.") - return tgbotapi.Message{}, errors.New("bot not initialized") - } - - util.FormatTwitchPhotoUrl(&stream.ThumbnailURL) - caption := "" + stream.UserName + " is streaming " + stream.GameName + ": " + stream.Title + "\n" + twitchPrefix + stream.UserName + " [" + liveText + "]" - - photoMessage, err := createPhotoMessage(caption, chatID, stream.ThumbnailURL) - if err != nil { - return tgbotapi.Message{}, errors.New("could not send message") - } - - ret, err := bot.Send(photoMessage) - logger.Log.Debug().Interface("Message", ret).Msg("Sent message.") - - if err != nil { - logger.Log.Error().Err(err).Msg("error sending Telegram message") - return tgbotapi.Message{}, err - } - if ret.Chat.ID != chatID { - return tgbotapi.Message{}, errors.New("error sending Telegram message") - } - - return ret, nil -} - -// SendRestreamerStreamInfo generates a message from a Restreamer stream struct and sends it to a chat ID. -func SendRestreamerStreamInfo(chatID int64, streamInfo restreamer.StreamInfo, stream restreamer.Stream) (tgbotapi.Message, error) { - if bot == nil { - logger.Log.Error().Msg("Bot not initialized.") - return tgbotapi.Message{}, errors.New("bot not initialized") - } - - var streamLink string - if stream.CustomURL == "" { - streamLink = stream.BaseURL + "/" + stream.ID + htmlSuffix - } else { - streamLink = stream.CustomURL - } - - caption := "" + streamInfo.UserName + " is streaming: " + streamInfo.Description + "\n" + streamLink + " [" + liveText + "]" - - photoMessage, err := createPhotoMessage(caption, chatID, streamInfo.ThumbnailURL) - if err != nil { - return tgbotapi.Message{}, errors.New("could not send message") - } - - ret, err := bot.Send(photoMessage) - logger.Log.Debug().Interface("Message", ret).Msg("Sent message.") - - if err != nil { - logger.Log.Error().Err(err).Msg("error sending Telegram message") - return tgbotapi.Message{}, err - } - if ret.Chat.ID != chatID { - return tgbotapi.Message{}, errors.New("error sending Telegram message") - } - - return ret, nil -} - -// SendUpdateTwitchStreamInfo updates a previously sent message with new stream info. -func SendUpdateTwitchStreamInfo(chatID int64, message tgbotapi.Message, stream twitch.Stream) (tgbotapi.Message, error) { - if bot == nil { - logger.Log.Error().Msg("Bot not initialized.") - return tgbotapi.Message{}, errors.New("bot not initialized") - } - - newcaption := "" + stream.UserName + " is streaming " + stream.GameName + ": " + stream.Title + "\n" + twitchPrefix + stream.UserName + " [" + liveText + "]" - - config := tgbotapi.NewEditMessageCaption(chatID, message.MessageID, newcaption) - config.ParseMode = tgbotapi.ModeHTML - ret, err := bot.Send(config) - - if err != nil { - logger.Log.Error().Err(err).Msg("error updating Telegram message") - return tgbotapi.Message{}, err - } - if ret.Chat.ID != chatID { - return tgbotapi.Message{}, errors.New("error updating Telegram message") - } - - return ret, nil -} - -// SendUpdateStreamOffline takes a previously sent message and edits it to reflect the changed stream status. -func SendUpdateStreamOffline(message tgbotapi.Message, chatID int64) (tgbotapi.Message, error) { - logger.Log.Debug().Interface("Message", message).Msg("Updating Message") - - newtext := strings.Replace(message.Caption, liveText, offlineText, 1) - newtext = strings.Replace(newtext, "is streaming", "was streaming", 1) - config := tgbotapi.NewEditMessageCaption(chatID, message.MessageID, newtext) - config.ParseMode = tgbotapi.ModeHTML - config.CaptionEntities = message.CaptionEntities - - ret, err := bot.Send(config) - - if err != nil { - logger.Log.Error().Err(err).Msg("error updating Telegram message") - return tgbotapi.Message{}, err - } - if ret.Chat.ID != chatID { - return tgbotapi.Message{}, errors.New("error updating Telegram message") - } - - return ret, nil -} - -func createPhotoMessage(caption string, chatID int64, url string) (tgbotapi.PhotoConfig, error) { - photoBytes, err := util.GetPhotoFromUrl(url) - if err != nil { - logger.Log.Error().Err(err).Msg("Could not send photo on Telegram") - return tgbotapi.PhotoConfig{}, errors.New("could not retrieve photo") - } - - photoFileBytes := tgbotapi.FileBytes{ - Name: "picture", - Bytes: photoBytes, - } - config := tgbotapi.NewPhoto(chatID, photoFileBytes) - config.Caption = caption - config.ParseMode = tgbotapi.ModeHTML - return config, nil - -} diff --git a/internal/telegram/telegram_test.go b/internal/telegram/telegram_test.go deleted file mode 100644 index 7bc5b96..0000000 --- a/internal/telegram/telegram_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package telegram - -import ( - "errors" - "os" - "path" - "runtime" - "streamobserver/internal/config" - "streamobserver/internal/logger" - "streamobserver/internal/restreamer" - "streamobserver/internal/twitch" - "testing" - - "github.com/stretchr/testify/assert" -) - -var testchatid *int64 - -var testRestreamerInfo = &restreamer.StreamInfo{ - Description: "testdesc", - UserName: "testchannel", - ThumbnailURL: "https://via.placeholder.com/960.jpg", -} - -var testTwitchStream = &twitch.Stream{ - UserName: "testchannel", - GameName: "testgame", - ThumbnailURL: "https://via.placeholder.com/{height}.jpg", - Title: "[test] (streamobserver) title", -} - -var testRestreamerStream = &restreamer.Stream{ - BaseURL: "https://teststream.tld", - ID: "testid", - CustomURL: "testcustomurl", -} - -func init() { - // make sure we're in project root for tests - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } - InitBot(true) - - config, err := config.GetConfig() - if err != nil { - logger.Log.Panic().Err(err) - return - } - - if config == nil { - logger.Log.Panic().Err(errors.New("got empty config")) - return - } - testchatid = &config.General.TestChatID -} - -func TestCreatePhotoMessageNotImage(t *testing.T) { - // Testing broken URL - _, err := createPhotoMessage("test", 42, "https://via.placeholder.com/notanimage") - expectedError := "could not retrieve photo" - - if assert.Error(t, err, "should report error on broken image URL") { - assert.Equal(t, expectedError, err.Error(), "error message should reflect image retrieval issue") - } -} - -func TestCreatePhotoMessageBrokenUrl(t *testing.T) { - // Testing 404 URL - _, err := createPhotoMessage("test", 42, "http://notfound.tld/image.jpg") - expectedError := "could not retrieve photo" - - if assert.Error(t, err, "should report error on non-image URL") { - assert.Equal(t, expectedError, err.Error(), "error message should reflect image retrieval issue") - } -} - -func TestCreatePhotoMessageValid(t *testing.T) { - // Testing created Photo Config - testcaption := "testcaption" - testid := int64(42) - result, err := createPhotoMessage(testcaption, testid, "https://via.placeholder.com/300.jpg") - - if assert.NoError(t, err) { - assert.Equal(t, testcaption, result.Caption, "config should return expected caption") - assert.Equal(t, testid, result.ChatID, "config should return expected chatID") - assert.True(t, result.File.NeedsUpload(), "not yet send message should indicate file upload bool") - } -} - -func TestSendTwitchStreamInfo(t *testing.T) { - - result, err := SendTwitchStreamInfo(*testchatid, *testTwitchStream) - - if assert.NoError(t, err, "correctly formatted send request should not throw error") { - assert.Equal(t, *testchatid, result.Chat.ID, "response from server should match chatID") - assert.Contains(t, result.Caption, testTwitchStream.Title, "message should contain title") - assert.Contains(t, result.Caption, testTwitchStream.UserName, "message should contain title") - assert.Contains(t, result.Caption, testTwitchStream.GameName, "message should contain game name") - } -} - -func TestSendTwitchStreamWithBadRequest(t *testing.T) { - teststream := twitch.Stream{} - _, err := SendTwitchStreamInfo(*testchatid, teststream) - - expectedError := "could not send message" - - if assert.Error(t, err, "empty stream info should return error on send request") { - assert.Equal(t, expectedError, err.Error(), "request should report back correct error") - } -} - -func TestSendTwitchStreamWithBadChatId(t *testing.T) { - testchatid := int64(42) - - res, _ := SendTwitchStreamInfo(testchatid, *testTwitchStream) - - assert.Nil(t, res.Chat, "bad chatID should return no chat in response") -} - -func TestSendRestreamerStreamInfo(t *testing.T) { - - result, err := SendRestreamerStreamInfo(*testchatid, *testRestreamerInfo, *testRestreamerStream) - - if assert.NoError(t, err, "correctly formatted send request should not throw error") { - assert.Equal(t, *testchatid, result.Chat.ID, "response from server should match chatID") - } -} - -func TestSendRestreamerStreamWithBadRequest(t *testing.T) { - testchatid := int64(0) - teststream := restreamer.Stream{} - teststreaminfo := restreamer.StreamInfo{} - - _, err := SendRestreamerStreamInfo(testchatid, teststreaminfo, teststream) - - expectedError := "could not send message" - - if assert.Error(t, err, "empty stream info should return error on send request") { - assert.Equal(t, expectedError, err.Error(), "request should report back correct error") - } -} - -func TestSendRestreamerStreamWithBadChatId(t *testing.T) { - testchatid := int64(42) - - res, _ := SendRestreamerStreamInfo(testchatid, *testRestreamerInfo, *testRestreamerStream) - - assert.Nil(t, res.Chat, "bad chatID should return no chat in response") -} - -func TestUpdateMessageStreamOffline(t *testing.T) { - result, err := SendRestreamerStreamInfo(*testchatid, *testRestreamerInfo, *testRestreamerStream) - - if assert.NoError(t, err, "sending message with valid request should not return error") { - assert.Equal(t, *testchatid, result.Chat.ID, "response should contain equal chatID") - } - - result, err = SendUpdateStreamOffline(result, *testchatid) - - expectedString := "❌ OFFLINE" - if assert.NoError(t, err, "updating message with valid request should not return error") { - assert.Equal(t, *testchatid, result.Chat.ID, "updated response should contain equal chatID") - assert.Contains(t, result.Caption, expectedString, "response should contain updated substring") - } - -} - -func TestSendUpdateTwitchStreamInfo(t *testing.T) { - stream := *testTwitchStream - result, err := SendTwitchStreamInfo(*testchatid, stream) - - if assert.NoError(t, err, "sending message with valid request should not return error") { - assert.Equal(t, *testchatid, result.Chat.ID, "response should contain equal chatID") - } - - stream.GameName = "New Game" - stream.Title = "New Title" - - result, err = SendUpdateTwitchStreamInfo(*testchatid, result, stream) - - expectedStringGame := "New Game" - expectedStringTitle := "New Title" - - if assert.NoError(t, err, "updating message with valid request should not return error") { - assert.Equal(t, *testchatid, result.Chat.ID, "updated response should contain equal chatID") - assert.Contains(t, result.Caption, expectedStringGame, "response should contain updated title string") - assert.Contains(t, result.Caption, expectedStringTitle, "response should contain updated game string") - } -} diff --git a/internal/twitch/twitch.go b/internal/twitch/twitch.go deleted file mode 100644 index cc500fa..0000000 --- a/internal/twitch/twitch.go +++ /dev/null @@ -1,159 +0,0 @@ -package twitch - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/url" - "streamobserver/internal/config" - "streamobserver/internal/logger" - "time" -) - -type Stream struct { - UserName string `json:"user_name"` - GameName string `json:"game_name"` - Title string `json:"title"` - ThumbnailURL string `json:"thumbnail_url"` -} -type pagination struct { - Cursor string `json:"cursor"` -} - -type streamResponse struct { - Data []Stream `json:"data"` -} - -type authToken struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - TokenType string `json:"token_type"` - tokenCreation time.Time -} - -const ( - twitchTokenURL = "https://id.twitch.tv/oauth2/token" - twitchStreamsURL = "https://api.twitch.tv/helix/streams" - twitchMimeType = "application/json" -) - -var token *authToken -var configuration *config.Twitch - -// GetStreams takes an array of Twitch usernames and returns metadata for those that are online. -func GetStreams(usernames []string) ([]Stream, error) { - authenticate() - - bearer := "Bearer " + token.AccessToken - - base, err := url.Parse(twitchStreamsURL) - if err != nil { - return nil, err - } - - // Query params - params := url.Values{} - for _, s := range usernames { - params.Add("user_login", s) - } - base.RawQuery = params.Encode() - - req, err := http.NewRequest("GET", base.String(), bytes.NewBuffer(nil)) - if err != nil { - logger.Log.Error().Err(err) - return nil, err - } - - req.Header.Set("Authorization", bearer) - req.Header.Add("Accept", twitchMimeType) - req.Header.Add("client-id", configuration.ClientID) - - client := &http.Client{} - - defer req.Body.Close() - resp, err := client.Do(req) - if err != nil { - logger.Log.Error().Err(err) - return nil, err - } - if resp.StatusCode != http.StatusOK { - logger.Log.Error().Int("StatusCode", resp.StatusCode).Interface("Response", resp).Msg("No HTTP OK from Twitch Helix.") - return nil, err - } - - var streams streamResponse - - err = json.NewDecoder(resp.Body).Decode(&streams) - if err != nil { - logger.Log.Fatal().Err(err) - return nil, err - } - - for _, s := range streams.Data { - logger.Log.Debug().Interface("Info", s).Msg("Received Stream Info") - } - - return streams.Data, nil -} - -func authenticate() { - - if configuration == nil { - readConfig() - } - - logger.Log.Debug().Msg("Authenticating with Twitch API") - if token != nil && token.ExpiresIn != 0 { - logger.Log.Debug().Msg("Twitch auth token present. Checking validity.") - expiryTime := token.tokenCreation.Add(time.Second * time.Duration(token.ExpiresIn)) - if time.Now().Before(expiryTime) { - logger.Log.Debug().Msg("Token still valid.") - return - } - } - - values := map[string]string{ - "client_id": configuration.ClientID, - "client_secret": configuration.ClientSecret, - "grant_type": "client_credentials"} - json_data, err := json.Marshal(values) - - if err != nil { - logger.Log.Fatal().Err(err) - } - - resp, err := http.Post(twitchTokenURL, twitchMimeType, - bytes.NewBuffer(json_data)) - if err != nil { - logger.Log.Fatal().Err(err) - } - - defer resp.Body.Close() - err = json.NewDecoder(resp.Body).Decode(&token) - token.tokenCreation = time.Now() - if err != nil { - logger.Log.Fatal().Err(err) - return - } - logger.Log.Info().Msg("Successfully authenticated on Twitch. ") -} - -func readConfig() bool { - - var err error - config, err := config.GetConfig() - if err != nil { - logger.Log.Panic().Err(err) - return false - } - - if config == nil { - logger.Log.Panic().Err(errors.New("got empty config")) - return false - } - - configuration = &config.Twitch - - return true -} diff --git a/internal/twitch/twitch_test.go b/internal/twitch/twitch_test.go deleted file mode 100644 index 74752c1..0000000 --- a/internal/twitch/twitch_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package twitch - -import ( - "os" - "path" - "runtime" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func init() { - // make sure we're in project root for tests - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "../..") - err := os.Chdir(dir) - if err != nil { - panic(err) - } -} - -func TestReadConfig(t *testing.T) { - readConfig() - - assert.NotNil(t, configuration) - assert.NotEmpty(t, configuration.ClientID) -} - -func TestAuthenticate(t *testing.T) { - authenticate() - - assert.NotNil(t, token) - assert.NotEmpty(t, token.AccessToken) -} - -func TestAuthenticateWithValidToken(t *testing.T) { - - token = &authToken{ - tokenCreation: time.Now(), - ExpiresIn: 1500, - AccessToken: "foo", - } - authenticate() - - assert.Equal(t, "foo", token.AccessToken) -} diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 5ccf796..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,45 +0,0 @@ -package util - -import ( - "errors" - "io" - "net/http" - "streamobserver/internal/logger" - "strings" -) - -const ( - widthPlaceholder = "{width}" - heightPlaceholder = "{height}" -) - -func GetPhotoFromUrl(url string) ([]byte, error) { - logger.Log.Debug().Msg("Getting image from URL") - response, e := http.Get(url) - if e != nil { - logger.Log.Error().Err(e) - return nil, errors.New("could not fetch image from URL") - } - if response.StatusCode != http.StatusOK { - return nil, errors.New("could not fetch image from URL") - } - - if !strings.Contains(response.Header.Get("content-type"), "image") { - return nil, errors.New("invalid image response") - } - - defer response.Body.Close() - - imageBytes, err := io.ReadAll(response.Body) - if err != nil { - logger.Log.Error().Err(err) - return nil, errors.New("could not read image bytes") - } - - return imageBytes, nil -} - -func FormatTwitchPhotoUrl(url *string) { - *url = strings.Replace(*url, heightPlaceholder, "1080", 1) - *url = strings.Replace(*url, widthPlaceholder, "1920", 1) -} diff --git a/internal/util/util_test.go b/internal/util/util_test.go deleted file mode 100644 index 5597fef..0000000 --- a/internal/util/util_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package util - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFormatTwitchPhotoUrl(t *testing.T) { - - result := "test-{width}x{height}.jpg" - FormatTwitchPhotoUrl(&result) - expected := "test-1920x1080.jpg" - - assert.Equal(t, expected, result, "dimensions should be present") -} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7454330 --- /dev/null +++ b/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "github.com/go-telegram/bot" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + "streamobserver/internal/adapter/restreamer" + "streamobserver/internal/adapter/telegram" + "streamobserver/internal/adapter/twitch" + "streamobserver/internal/core/domain" + "streamobserver/internal/core/service" +) + +func main() { + log.Info().Str("author", "davidramiro").Msg("starting streamobserver") + + if viper.GetBool("general.debug") { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } else { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + } + + log.Info().Msg("initializing telegram bot") + viper.AddConfigPath(".") + err := viper.ReadInConfig() + if err != nil { + log.Panic().Err(err).Msg("failed to read config") + } + + token := viper.GetString("telegram.apikey") + + b, err := bot.New(token) + if err != nil { + log.Panic().Err(err).Msg("failed initializing telegram bot") + } + + sender := telegram.NewTelegramSender(b) + ta := &twitch.StreamInfoProvider{} + ra := &restreamer.StreamInfoProvider{} + + streamService := service.NewStreamService(ta, ra) + + notificationService := service.NewNotificationService(sender, streamService) + + var chats []domain.ChatConfig + err = viper.UnmarshalKey("chats", &chats) + if err != nil { + log.Panic().Err(err).Msg("failed to unmarshal config") + } + + for _, chat := range chats { + for _, restreamerConfig := range chat.Streams.Restreamer { + notificationService.Register(chat.ChatID, &domain.StreamQuery{ + UserID: restreamerConfig.ID, + BaseURL: restreamerConfig.BaseURL, + CustomURL: restreamerConfig.CustomURL, + Kind: domain.StreamKindRestreamer, + }) + } + for _, twitchConfig := range chat.Streams.Twitch { + notificationService.Register(chat.ChatID, &domain.StreamQuery{ + UserID: twitchConfig.Username, + Kind: domain.StreamKindTwitch, + }) + } + } + + notificationService.StartPolling(context.Background()) +} diff --git a/streams.sample.yml b/streams.sample.yml deleted file mode 100644 index 6910254..0000000 --- a/streams.sample.yml +++ /dev/null @@ -1,22 +0,0 @@ -chats: - # List of chat IDs to notify (private / group) - - chatid: 4242424242 - streams: - twitch: - # List of Twitch usernames to observe - - username: "johndoe" - - username: "janedoe" - restreamer: - # List of restreamer instances to observe - # Server base URL - - baseurl: "https://stream.yourserver.tld" - # Channel ID - id: "channel-uuid" - - chatid: -10042424242 - streams: - twitch: - - username: "johndoe" - - username: "janedoe" - restreamer: - - baseurl: "https://stream.yourserver.tld" - id: "channel-uuid" \ No newline at end of file