commit 3dc65a4a7a4eebfdc34bbedd778019da3b9334cf
Author: bsandro <[email protected]>
Date: Sun, 9 May 2021 05:32:36 +0300
Initial commit
Diffstat:
A | .gitignore | | | 5 | +++++ |
A | LICENSE | | | 24 | ++++++++++++++++++++++++ |
A | README | | | 18 | ++++++++++++++++++ |
A | app.go | | | 44 | ++++++++++++++++++++++++++++++++++++++++++++ |
A | auth.go | | | 50 | ++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | client.go | | | 72 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | config.go | | | 38 | ++++++++++++++++++++++++++++++++++++++ |
A | config_sample.json | | | 10 | ++++++++++ |
A | go.mod | | | 5 | +++++ |
A | go.sum | | | 7 | +++++++ |
A | listen.go | | | 33 | +++++++++++++++++++++++++++++++++ |
A | local_server.go | | | 50 | ++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | ping.go | | | 28 | ++++++++++++++++++++++++++++ |
A | user.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
+}