]> git.openfl.eu Git - twitch-api.git/commitdiff
initial commit
authorFl_GUI <flor.guilini@hotmail.com>
Sun, 20 Jul 2025 10:41:05 +0000 (12:41 +0200)
committerFl_GUI <flor.guilini@hotmail.com>
Sun, 20 Jul 2025 10:41:05 +0000 (12:41 +0200)
18 files changed:
api/chat.go [new file with mode: 0644]
api/chat/types.go [new file with mode: 0644]
api/client.go [new file with mode: 0644]
api/subscribe.go [new file with mode: 0644]
api/subscriptions/types.go [new file with mode: 0644]
api/token.go [new file with mode: 0644]
api/types.go [new file with mode: 0644]
api/user.go [new file with mode: 0644]
api/users/types.go [new file with mode: 0644]
auth [new submodule]
eventsub/bantypes.go [new file with mode: 0644]
eventsub/chattypes.go [new file with mode: 0644]
eventsub/connection.go [new file with mode: 0644]
eventsub/typemap.go [new file with mode: 0644]
eventsub/types.go [new file with mode: 0644]
eventsub/x/logger/main.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]

diff --git a/api/chat.go b/api/chat.go
new file mode 100644 (file)
index 0000000..9eb25b6
--- /dev/null
@@ -0,0 +1,44 @@
+package api
+
+import (
+       "bytes"
+       "encoding/json"
+       "fmt"
+       "io/ioutil"
+       "net/http"
+
+       "go.openfl.eu/twitch-api/api/chat"
+)
+
+func (c *Client) SendChat(message chat.SendMessage) (*ResponseData[chat.ChatSendResponseData], error) {
+       var b = bytes.Buffer{}
+       err := json.NewEncoder(&b).Encode(message)
+       if err != nil {
+               fmt.Println(err)
+       }
+
+       req, err := http.NewRequest("POST", "https://api.twitch.tv/helix/chat/messages", &b)
+       if err != nil {
+               return nil, err
+       }
+       c.addHeader(req)
+       req.Header.Add("Content-Type", "application/json")
+       resp, err := http.DefaultClient.Do(req)
+       if err != nil {
+               return nil, err
+       }
+       defer resp.Body.Close()
+
+       respBody, err := ioutil.ReadAll(resp.Body)
+       if err != nil {
+               return nil, fmt.Errorf("could not read response body: %w", err)
+       }
+       if resp.StatusCode != http.StatusOK {
+               return nil, fmt.Errorf("could not send message: unexpected response code %s, %s", resp.Status, respBody)
+       }
+
+       res := new(ResponseData[chat.ChatSendResponseData])
+       err = json.Unmarshal(respBody, res)
+
+       return res, err
+}
diff --git a/api/chat/types.go b/api/chat/types.go
new file mode 100644 (file)
index 0000000..7f58572
--- /dev/null
@@ -0,0 +1,45 @@
+package chat
+
+type SendMessage struct {
+       Broadcaster string `json:"broadcaster_id"`
+       Sender      string `json:"sender_id"`
+       Message     string `json:"message"`
+       ReplyTo     string `json:"reply_parent_message_id"`
+}
+
+type ReceiveEvent struct {
+       Message  Message
+       Chatter  string `json:"chatter_user_id"`
+       Username string `json:"chatter_user_name"`
+}
+
+type Message struct {
+       Text      string
+       Fragments []Fragment
+}
+
+type Fragment struct {
+       Type    string
+       Text    string
+       Mention *Mention
+}
+
+type Mention struct {
+       UserId    string `json:"user_id"`
+       UserLogin string `json:"user_login"`
+       UserName  string `json:"user_name"`
+}
+
+type ChatSendResponseData struct {
+       //The message id for the message that was sent.
+       MessageId string `json:"message_id"`
+       //If the message passed all checks and was sent.
+       IsSent bool `json:"is_sent"`
+       //The reason the message was dropped, if any.
+       DropReason struct {
+               //Code for why the message was dropped.
+               Code string `json:"code"`
+               //Message for why the message was dropped.
+               Message string `json:"message"`
+       } `json:"drop_reason"`
+}
diff --git a/api/client.go b/api/client.go
new file mode 100644 (file)
index 0000000..b71e388
--- /dev/null
@@ -0,0 +1,43 @@
+package api
+
+import (
+       "errors"
+       "fmt"
+       "net/http"
+
+       "go.openfl.eu/twitch-api/auth"
+)
+
+type Client struct {
+       baseUrl string
+
+       clientId  auth.ClientId
+       lastToken auth.Token
+}
+
+func NewClient(clientId auth.ClientId, source auth.TokenSource) (*Client, error) {
+       client := new(Client)
+       firstToken, ok := <-source
+       if !ok {
+               return client, errors.New("A first token must be ready before creating a twitch-api/api.Client")
+       }
+
+       client.baseUrl = "https://api.twitch.tv/helix"
+
+       client.clientId = clientId
+       client.lastToken = firstToken
+
+       go func() {
+               for token := range source {
+                       client.lastToken = token
+               }
+       }()
+
+       return client, nil
+}
+
+func (c *Client) addHeader(req *http.Request) {
+       token := c.getToken()
+       req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+       req.Header.Add("Client-Id", c.clientId)
+}
diff --git a/api/subscribe.go b/api/subscribe.go
new file mode 100644 (file)
index 0000000..4dde717
--- /dev/null
@@ -0,0 +1,59 @@
+package api
+
+import (
+       "bytes"
+       "encoding/json"
+       "fmt"
+       "io/ioutil"
+       "net/http"
+
+       "go.openfl.eu/twitch-api/api/subscriptions"
+)
+
+const subscriptionsUrl = "https://api.twitch.tv/helix/eventsub/subscriptions"
+
+//const subscriptionsUrl = "http://localhost:8080/eventsub/subscriptions"
+
+func (c *Client) Subscribe(data subscriptions.NewSubscription) error {
+       body := subscriptions.SubscriptionRequest{
+               data.SubscriptionType,
+               data.Version,
+               data.Condition,
+               subscriptions.SubscriptionTransportRequest{
+                       "websocket",
+                       data.SessionId,
+               },
+       }
+
+       buf := bytes.Buffer{}
+
+       err := json.NewEncoder(&buf).Encode(body)
+       if err != nil {
+               return fmt.Errorf("Could not prepare subscription request for %s: %v", data.SubscriptionType, err)
+       }
+
+       req, err := http.NewRequest(http.MethodPost, subscriptionsUrl, &buf)
+       if err != nil {
+               return fmt.Errorf("Could not make subscription request for %s: %v", data.SubscriptionType, err)
+       }
+
+       c.addHeader(req)
+       req.Header.Add("Content-Type", "application/json")
+
+       resp, err := http.DefaultClient.Do(req)
+       if err != nil {
+               return fmt.Errorf("Could not do subscription request for %s: %v", data.SubscriptionType, err)
+       }
+       defer resp.Body.Close()
+
+       if resp.StatusCode == http.StatusAccepted {
+               return nil
+       }
+
+       responseBody, err := ioutil.ReadAll(resp.Body)
+       if err != nil {
+               responseBody = []byte{}
+       }
+
+       return fmt.Errorf("Failed to make a subscription for %s: %s %s", data.SubscriptionType, resp.Status, string(responseBody))
+}
diff --git a/api/subscriptions/types.go b/api/subscriptions/types.go
new file mode 100644 (file)
index 0000000..330a43d
--- /dev/null
@@ -0,0 +1,20 @@
+package subscriptions
+
+type NewSubscription struct {
+       SessionId        string
+       SubscriptionType string
+       Version          string
+       Condition        any
+}
+
+type SubscriptionRequest struct {
+       Type      string                       `json:"type"`
+       Version   string                       `json:"version"`
+       Condition any                          `json:"condition"`
+       Transport SubscriptionTransportRequest `json:"transport"`
+}
+
+type SubscriptionTransportRequest struct {
+       Method    string `json:"method"`
+       SessionId string `json:"session_id"`
+}
diff --git a/api/token.go b/api/token.go
new file mode 100644 (file)
index 0000000..ce3fea7
--- /dev/null
@@ -0,0 +1,5 @@
+package api
+
+func (c *Client) getToken() string {
+       return c.lastToken
+}
diff --git a/api/types.go b/api/types.go
new file mode 100644 (file)
index 0000000..7cd0d4b
--- /dev/null
@@ -0,0 +1,5 @@
+package api
+
+type ResponseData[T any] struct {
+       Data []T `json:"data"`
+}
diff --git a/api/user.go b/api/user.go
new file mode 100644 (file)
index 0000000..82412c6
--- /dev/null
@@ -0,0 +1,53 @@
+package api
+
+import (
+       "encoding/json"
+       "fmt"
+       "io/ioutil"
+       "net/http"
+       "net/url"
+
+       "go.openfl.eu/twitch-api/api/users"
+)
+
+func (c *Client) GetUsers(ids []string, logins []string) (*users.GetUsersResponse, error) {
+       usersUrl, err := url.Parse("https://api.twitch.tv/helix/users")
+       if err != nil {
+               panic(err)
+       }
+
+       query := make(url.Values)
+       for _, id := range ids {
+               query.Add("id", id)
+       }
+       for _, login := range logins {
+               query.Add("login", login)
+       }
+       usersUrl.RawQuery = query.Encode()
+
+       req, err := http.NewRequest("GET", usersUrl.String(), nil)
+       if err != nil {
+               panic(err)
+       }
+
+       c.addHeader(req)
+
+       resp, err := http.DefaultClient.Do(req)
+       if err != nil {
+               return nil, err
+       }
+       defer resp.Body.Close()
+
+       if resp.StatusCode != http.StatusOK {
+               content, _ := ioutil.ReadAll(resp.Body)
+               return nil, fmt.Errorf("Unexpected response code: %s, %s", resp.Status, content)
+       }
+
+       result := new(users.GetUsersResponse)
+
+       err = json.NewDecoder(resp.Body).Decode(result)
+       if err != nil {
+               panic(err) // bad struct
+       }
+       return result, err
+}
diff --git a/api/users/types.go b/api/users/types.go
new file mode 100644 (file)
index 0000000..2f3d106
--- /dev/null
@@ -0,0 +1,11 @@
+package users
+
+type GetUsersResponse struct {
+       Data []GetUsersData
+}
+
+type GetUsersData struct {
+       Id          string
+       Login       string
+       DisplayName string `json:"display_name"`
+}
diff --git a/auth b/auth
new file mode 160000 (submodule)
index 0000000..db214e5
--- /dev/null
+++ b/auth
@@ -0,0 +1 @@
+Subproject commit db214e5b7a60359ae65fca9d15c812731cb63d7b
diff --git a/eventsub/bantypes.go b/eventsub/bantypes.go
new file mode 100644 (file)
index 0000000..47567db
--- /dev/null
@@ -0,0 +1,4 @@
+package eventsub
+
+type ChannelBanPayload struct {
+}
diff --git a/eventsub/chattypes.go b/eventsub/chattypes.go
new file mode 100644 (file)
index 0000000..d84d96b
--- /dev/null
@@ -0,0 +1,34 @@
+package eventsub
+
+type ChannelChatMessagePayload = WebSocketNotification[ChannelChatMessageEvent]
+
+type ChannelChatMessageEvent struct {
+       BroadcasterUserId    string             `mapstructure:"broadcaster_user_id"`
+       BroadcasterUserName  string             `mapstructure:"broadcaster_user_name"`
+       BroadcasterUserLogin string             `mapstructure:"broadcaster_user_login"`
+       ChatterUserId        string             `mapstructure:"chatter_user_id"`
+       ChatterUserName      string             `mapstructure:"chatter_user_name"`
+       ChatterUserLogin     string             `mapstructure:"chatter_user_login"`
+       MessageId            string             `mapstructure:"message_id"`
+       Message              ChannelChatMessage `mapstructure:"message"`
+       MessageType          string             `mapstructure:"message_type"`
+}
+
+type ChannelChatMessage struct {
+       Text      string                       `mapstructure:"text"`
+       Fragments []ChannelChatMessageFragment `mapstructure:"fragments"`
+}
+
+type ChannelChatMessageFragment struct {
+       Type      string                    `mapstructure:"type"`
+       Text      string                    `mapstructure:"text"`
+       Cheermote map[string]interface{}    `mapstructure:"cheermote"`
+       Emote     map[string]interface{}    `mapstructure:"emote"`
+       Mention   ChannelChatMessageMention `mapstructure:"mention"`
+}
+
+type ChannelChatMessageMention struct {
+       UserId    string `mapstructure:"user_id"`
+       UserName  string `mapstructure:"user_name"`
+       UserLogin string `mapstructure:"user_login"`
+}
diff --git a/eventsub/connection.go b/eventsub/connection.go
new file mode 100644 (file)
index 0000000..0a9e963
--- /dev/null
@@ -0,0 +1,100 @@
+package eventsub
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "net"
+
+       "github.com/mitchellh/mapstructure"
+       "golang.org/x/net/websocket"
+)
+
+var socketUrl = "wss://eventsub.wss.twitch.tv/ws"
+
+//var socketUrl = "ws://127.0.0.1:8080/ws"
+
+type Connection struct {
+       SessionId string
+
+       notifications chan Notification
+
+       connectionCloser io.Closer
+}
+
+func NewConnection() Connection {
+       connection, closer := makeConnection()
+       var welcomeMessage welcome
+       err := connection.Decode(&welcomeMessage)
+       if err != nil {
+               panic(err)
+       }
+
+       if welcomeMessage.Metadata.MessageType != "session_welcome" {
+               panic(fmt.Errorf("First websocket is not a 'session_welcome' but a '%s'", welcomeMessage.Metadata.MessageType))
+       }
+
+       return Connection{
+               welcomeMessage.Payload.Session.Id,
+               readMessages(connection),
+               closer,
+       }
+}
+
+func (c Connection) Notifications() <-chan Notification {
+       return c.notifications
+}
+
+func (c Connection) Close() {
+       close(c.notifications)
+       c.connectionCloser.Close()
+}
+
+func makeConnection() (*json.Decoder, io.Closer) {
+       conn, err := websocket.Dial(socketUrl, "", "http://followers.openfl.eu.local")
+       if err != nil {
+               panic(err)
+       }
+
+       return json.NewDecoder(conn), conn
+}
+
+func readMessages(connection *json.Decoder) chan Notification {
+       messages := make(chan Notification)
+       go func() {
+               for {
+                       var decodeNotif GenericNotification[map[string]interface{}]
+                       err := connection.Decode(&decodeNotif)
+                       if err != nil {
+                               if !errors.Is(err, net.ErrClosed) {
+                                       fmt.Printf("could not read notification: %s", err)
+                               }
+                               return
+                       }
+
+                       var instance interface{}
+                       switch decodeNotif.Metadata.MessageType {
+                       case "notification":
+                               constructor, exists := typemap[decodeNotif.Metadata.SubscriptionType]
+                               if !exists {
+                                       continue
+                               }
+                               instance = constructor()
+                               err = mapstructure.Decode(decodeNotif.Payload, instance)
+                               if err != nil {
+                                       fmt.Println(err)
+                               } else {
+                                       messages <- Notification{decodeNotif.Metadata, instance}
+                               }
+                               // TODO Reconnect and Revocation
+                               /*case "session_keepalive":
+                               default:
+                                       messages <- decodeNotif
+                                       continue
+                               */
+                       }
+               }
+       }()
+       return messages
+}
diff --git a/eventsub/typemap.go b/eventsub/typemap.go
new file mode 100644 (file)
index 0000000..0a4a783
--- /dev/null
@@ -0,0 +1,6 @@
+package eventsub
+
+var typemap = map[string]func() any{
+       "channel.ban":          func() any { return new(ChannelBanPayload) },
+       "channel.chat.message": func() any { return new(ChannelChatMessagePayload) },
+}
diff --git a/eventsub/types.go b/eventsub/types.go
new file mode 100644 (file)
index 0000000..71e318b
--- /dev/null
@@ -0,0 +1,60 @@
+package eventsub
+
+type Notification GenericNotification[interface{}]
+
+type GenericNotification[T any] struct {
+       Metadata Metadata `json:"metadata"`
+       Payload  T        `json:"payload"`
+}
+
+type Metadata struct {
+       MessageId        string `json:"message_id"`
+       MessageType      string `json:"message_type"`
+       MessageTimestamp string `json:"message_timestamp"`
+       SubscriptionType string `json:"subscription_type"`
+}
+
+type WebSocketNotification[T any] struct {
+       //Contains subscription metadata.
+       Subscription Subscription `mapstructure:"subscription"`
+       //The event information. The fields inside this object differ by subscription type.
+       Event T `mapstructure:"event"`
+}
+
+type Subscription struct {
+       //Your client ID.
+       Id string `mapstructure:"id"`
+       //The notification’s subscription type.
+       Type string `mapstructure:"type"`
+       //The version of the subscription.
+       Version string `mapstructure:"version"`
+       //The status of the subscription.
+       Status string `mapstructure:"status"`
+       //How much the subscription counts against your limit. See Subscription Limits for more information.
+       Cost int `mapstructure:"cost"`
+       //Subscription-specific parameters.
+       Condition Condition `mapstructure:"condition"`
+       //The time the notification was created.
+       CreatedAt string `mapstructure:"created_at"`
+}
+
+type Condition struct {
+       // User ID of the broadcaster (channel).
+       broadcasterUserId string `mapstructure:"broadcaster_user_id"`
+       // User ID of the moderator.
+       ModeratorUserId string `mapstructure:"moderator_user_id"`
+}
+
+type welcome GenericNotification[welcomePayload]
+
+type welcomePayload struct {
+       Session session `json:"session"`
+}
+
+type session struct {
+       Id                      string `json:"id"`
+       Status                  string `json:"status"`
+       ConnectedAt             string `json:"connected_at"`
+       KeepaliveTimeoutSeconds int    `json:"keepalive_timeout_seconds"`
+       ReconnectUrl            string `json:"reconnect_url"`
+}
diff --git a/eventsub/x/logger/main.go b/eventsub/x/logger/main.go
new file mode 100644 (file)
index 0000000..9eb1141
--- /dev/null
@@ -0,0 +1,15 @@
+package main
+
+import (
+       "fmt"
+       "reflect"
+
+       "go.openfl.eu/twitch-api/eventsub"
+)
+
+func main() {
+       conn := eventsub.NewConnection()
+       for not := range conn.Notifications() {
+               fmt.Printf("%s %+v\n", not.Metadata.SubscriptionType, reflect.TypeOf(not.Payload))
+       }
+}
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..4d64978
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module go.openfl.eu/twitch-api
+
+go 1.24.0
+
+require golang.org/x/net v0.35.0
diff --git a/go.sum b/go.sum
new file mode 100644 (file)
index 0000000..f4761f9
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=