twitchapon

[Twi]tch [Cha]nnel [Po]ints Rewards Redemption Listener
git clone git://bsandro.tech/twitchapon
Log | Files | Refs | README | LICENSE

commit 3dc65a4a7a4eebfdc34bbedd778019da3b9334cf
Author: bsandro <[email protected]>
Date:   Sun,  9 May 2021 05:32:36 +0300

Initial commit

Diffstat:
A.gitignore | 5+++++
ALICENSE | 24++++++++++++++++++++++++
AREADME | 18++++++++++++++++++
Aapp.go | 44++++++++++++++++++++++++++++++++++++++++++++
Aauth.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aclient.go | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconfig.go | 38++++++++++++++++++++++++++++++++++++++
Aconfig_sample.json | 10++++++++++
Ago.mod | 5+++++
Ago.sum | 7+++++++
Alisten.go | 33+++++++++++++++++++++++++++++++++
Alocal_server.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aping.go | 28++++++++++++++++++++++++++++
Auser.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 453 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.swp +ws_client +config.json +vendor diff --git a/LICENSE b/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2021, bsandro. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README b/README @@ -0,0 +1,18 @@ +TWITCHAPON - [Twi]tch [Cha]nnel [Po]ints Rewards Redemption Listener + +Daemon/server that uses PubSub websockets for subscribing to Twitch channel +points redemption events. Events can be proxied as http-requests further into +other (local) service that handles rendering for a twitch overlay or smth. + +Authorization mechanism for that kind of stuff is somewhat tricky - you have +to use a full-fledged OAuth process to get an auth token, it involves opening +web-browser to click an "allow" button and to move through (javascript-based) +redirects properly. Thankfully we can use a locally started server as a final +vantage point that receives and processes the verification code. + +Further info on underlying mechanism: https://dev.twitch.tv/docs/pubsub + +The only external requirement is x/net/websocket library. + +Project was born as a quick hack to play around with Go and try out some +interaction possibilities for twitch streams. diff --git a/app.go b/app.go @@ -0,0 +1,44 @@ +package main + +import ( + "golang.org/x/net/websocket" + "sync" +) + +type TApp struct { + Connection *websocket.Conn + WaitGroup sync.WaitGroup + Config TConfig + User *TUser + Auth *TAuth +} + +func (app *TApp) InitConfig() error { + if app.Config.IsInited() { + return nil + } else { + return app.Config.Init() + } +} + +func (app *TApp) InitAuth(auth_code string) error { + if app.Auth == nil { + app.Auth = new(TAuth) + } + if app.Auth.IsInited() { + return nil + } else { + return app.Auth.Init(auth_code) + } +} + +func (app *TApp) InitUser() error { + if app.User == nil { + app.User = new(TUser) + } + if app.User.IsInited() { + return nil + } else { + return app.User.Init() + } +} diff --git a/auth.go b/auth.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +type TAuth struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn uint64 `json:"expires_in"` + Scope []string `json:"scope"` + TokenType string `json:"token_type"` + Message string `json:"message"` +} + +func (auth *TAuth) Init(auth_code string) error { + const auth_request_url string = "https://id.twitch.tv/oauth2/token?client_id=%s&client_secret=%s&grant_type=authorization_code&code=%s&redirect_uri=http://%s" + response, err := http.Post(fmt.Sprintf(auth_request_url, App.Config.ClientId, App.Config.ClientSecret, auth_code, App.Config.LocalServer), "", nil) + if err != nil { + return err + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } + fmt.Println("Auth response:", string(body)) + if err := json.Unmarshal(body, auth); err != nil { + return err + } + fmt.Println("Auth response parsed:", auth) + if len(auth.AccessToken) == 0 { + fmt.Println("no access token") + if len(auth.Message) > 0 { + fmt.Println("msg:", auth.Message) + return errors.New(auth.Message) + } else { + return errors.New("Auth error") + } + } + return nil +} + +func (auth *TAuth) IsInited() bool { + return auth != nil && len(auth.AccessToken) > 0 +} diff --git a/client.go b/client.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "golang.org/x/net/websocket" + "log" + "net/http" +) + +type TMessage struct { + Type string `json:"type"` + Data struct { + Topic string `json:"topic"` + Message string `json:"message"` + } `json:"data"` +} + +var App TApp + +func ListenJob() { + for App.Connection != nil { + var msg TMessage + if err := websocket.JSON.Receive(App.Connection, &msg); err != nil { + log.Fatal(err) + } + fmt.Println("Received message:", msg) + // ignore PING and RESPONSE for now + if msg.Type != "MESSAGE" { + continue + } + fmt.Println("App.Config.AnimServer:", App.Config.AnimServer) + if resp, err := http.Get(App.Config.AnimServer); err == nil { + resp.Body.Close() + } else { + fmt.Println("Error while making the query to the animation service") + } + } + + App.WaitGroup.Done() +} + +func CloseConn() { + if App.Connection != nil { + App.Connection.Close() + App.Connection = nil + } +} + +func main() { + const code_request_url string = "https://id.twitch.tv/oauth2/authorize?client_id=%s&redirect_uri=http%%3A%%2F%%2F%s&response_type=code&scope=channel:read:redemptions\n" + + err := App.InitConfig() + if err != nil { + log.Fatal(err) + } + + fmt.Printf(code_request_url, App.Config.ClientId, App.Config.LocalServer) + + url := fmt.Sprintf("wss://%s:%s/", App.Config.WssServer, App.Config.WssPort) + App.Connection, err = websocket.Dial(url, "", App.Config.WssOrigin) + if err != nil { + log.Fatal(err) + } + defer CloseConn() + + App.WaitGroup.Add(3) + go ListenJob() + go PingJob() + go LocalServerJob() + + App.WaitGroup.Wait() +} diff --git a/config.go b/config.go @@ -0,0 +1,38 @@ +package main + +import ( + "encoding/json" + "os" +) + +type TConfig struct { + LocalServer string `json:"local_server"` + WssServer string `json:"wss_server"` + WssPort string `json:"wss_port"` + WssOrigin string `json:"wss_origin"` + AnimServer string `json:"anim_server"` + UserName string `json:"user_name"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +func (conf *TConfig) Init() error { + config_name := "config.json" + if len(os.Args) > 1 { + config_name = os.Args[1] + } + config_data, err := os.ReadFile(config_name) + if err != nil { + return err + } + err = json.Unmarshal(config_data, conf) + if err != nil { + return err + } + + return nil +} + +func (conf *TConfig) IsInited() bool { + return conf != nil && len(conf.ClientId) > 0 +} diff --git a/config_sample.json b/config_sample.json @@ -0,0 +1,10 @@ +{ + "local_server": "localhost:8081", + "wss_server": "pubsub-edge.twitch.tv", + "wss_port": "443", + "wss_origin": "http://localhost:8081", + "anim_server": "http://localhost:2345", + "user_name": "bsandro", + "client_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "client_secret": "xxxxxxxxxxxxxxxxxxxxxxx" +} diff --git a/go.mod b/go.mod @@ -0,0 +1,5 @@ +module ws_client + +go 1.16 + +require golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 diff --git a/go.sum b/go.sum @@ -0,0 +1,7 @@ +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/listen.go b/listen.go @@ -0,0 +1,33 @@ +package main + +import ( + "encoding/json" + "fmt" + "golang.org/x/net/websocket" +) + +type TListenRequest struct { + MsgType string `json:"type"` + // Nonce string `json:"nonce"` // optional + Data struct { + Topics []string `json:"topics"` + AuthToken string `json:"auth_token"` + } `json:"data"` +} + +func OnListen(auth_token string, user_id string) interface{} { + var req TListenRequest + req.MsgType = "LISTEN" + req.Data.AuthToken = auth_token + req.Data.Topics = append(req.Data.Topics, fmt.Sprintf("channel-points-channel-v1.%s", user_id)) + req_text, _ := json.Marshal(req) + fmt.Println("LISTEN message:", string(req_text)) + return &req +} + +func RequestListen() error { + msg := OnListen(App.Auth.AccessToken, App.User.Id) + websocket.JSON.Send(App.Connection, msg) + fmt.Println("Sending message:", msg) + return nil +} diff --git a/local_server.go b/local_server.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" +) + +func LocalServerJob() { + http.HandleFunc("/", HandleHttpRequest) + err := http.ListenAndServe(App.Config.LocalServer, nil) + if err != nil { + log.Fatal(err) + } + App.WaitGroup.Done() +} + +func HandleHttpRequest(w http.ResponseWriter, req *http.Request) { + var resp string + defer func() { + io.WriteString(w, resp) + }() + + codes, exists := req.URL.Query()["code"] + if exists && len(codes) > 0 { + auth_code := codes[0] + fmt.Printf("Got code: %s\n", auth_code) + + err := App.InitAuth(auth_code) + if err != nil { + resp = err.Error() + return + } + fmt.Println("Auth init ok", App.Auth.AccessToken) + + err = App.InitUser() + if err != nil { + resp = err.Error() + return + } + fmt.Println("User init ok", App.User.Id) + + RequestListen() + resp = "OK" + + } else { + resp = "Error: no code provided" + } +} diff --git a/ping.go b/ping.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "golang.org/x/net/websocket" + "time" +) + +type TPingMsg struct { + MsgType string `json:"type"` +} + +func OnPing() (interface{}, error) { + var res TPingMsg + res.MsgType = "PING" + return res, nil +} + +func PingJob() { + for App.Connection != nil { + msg, _ := OnPing() + websocket.JSON.Send(App.Connection, msg) + fmt.Println("Sending message:", msg) + time.Sleep(4 * time.Minute) + } + + App.WaitGroup.Done() +} diff --git a/user.go b/user.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +const users_request_url string = "https://api.twitch.tv/helix/users?login=%s" + +type TUser struct { + Id string `json:"id"` + BroadcasterType string `json:"broadcaster_type"` + Name string `json:"display_name"` + Login string `json:"login"` + Description string `json:"description"` + OfflineImageUrl string `json:"offline_image_url"` + ProfileIMageUrl string `json:"profile_image_url"` + UserType string `json:"type"` + ViewCount int `json:"view_count"` + CreatedAt string `json:"created_at"` +} + +type TUsersResponse struct { + Data []TUser `json:"data"` +} + +func getUser(user_name string, user *TUser) error { + client := &http.Client{} + request, err := http.NewRequest("GET", fmt.Sprintf(users_request_url, user_name), nil) + if err != nil { + return err + } + request.Header.Add("Client-Id", App.Config.ClientId) + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", App.Auth.AccessToken)) + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } + fmt.Println("users response:", string(body)) + + var users TUsersResponse + if err := json.Unmarshal(body, &users); err != nil { + return err + } + fmt.Println("users response parsed:", users) + if len(users.Data) == 0 { + return errors.New("getUser(): no users returned") + } + *user = users.Data[0] + + return nil +} + +func (user *TUser) Init() error { + err := getUser(App.Config.UserName, user) + return err +} + +func (user *TUser) IsInited() bool { + return user != nil && len(user.Id) > 0 +}