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