From d7bc575a19aae3bd4f3abbd02fca4c6f49258d18 Mon Sep 17 00:00:00 2001 From: Fl_GUI Date: Sun, 20 Jul 2025 12:41:05 +0200 Subject: [PATCH] initial commit --- api/chat.go | 44 ++++++++++++++++ api/chat/types.go | 45 +++++++++++++++++ api/client.go | 43 ++++++++++++++++ api/subscribe.go | 59 ++++++++++++++++++++++ api/subscriptions/types.go | 20 ++++++++ api/token.go | 5 ++ api/types.go | 5 ++ api/user.go | 53 ++++++++++++++++++++ api/users/types.go | 11 ++++ auth | 1 + eventsub/bantypes.go | 4 ++ eventsub/chattypes.go | 34 +++++++++++++ eventsub/connection.go | 100 +++++++++++++++++++++++++++++++++++++ eventsub/typemap.go | 6 +++ eventsub/types.go | 60 ++++++++++++++++++++++ eventsub/x/logger/main.go | 15 ++++++ go.mod | 5 ++ go.sum | 2 + 18 files changed, 512 insertions(+) create mode 100644 api/chat.go create mode 100644 api/chat/types.go create mode 100644 api/client.go create mode 100644 api/subscribe.go create mode 100644 api/subscriptions/types.go create mode 100644 api/token.go create mode 100644 api/types.go create mode 100644 api/user.go create mode 100644 api/users/types.go create mode 160000 auth create mode 100644 eventsub/bantypes.go create mode 100644 eventsub/chattypes.go create mode 100644 eventsub/connection.go create mode 100644 eventsub/typemap.go create mode 100644 eventsub/types.go create mode 100644 eventsub/x/logger/main.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/api/chat.go b/api/chat.go new file mode 100644 index 0000000..9eb25b6 --- /dev/null +++ b/api/chat.go @@ -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 index 0000000..7f58572 --- /dev/null +++ b/api/chat/types.go @@ -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 index 0000000..b71e388 --- /dev/null +++ b/api/client.go @@ -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 index 0000000..4dde717 --- /dev/null +++ b/api/subscribe.go @@ -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 index 0000000..330a43d --- /dev/null +++ b/api/subscriptions/types.go @@ -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 index 0000000..ce3fea7 --- /dev/null +++ b/api/token.go @@ -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 index 0000000..7cd0d4b --- /dev/null +++ b/api/types.go @@ -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 index 0000000..82412c6 --- /dev/null +++ b/api/user.go @@ -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 index 0000000..2f3d106 --- /dev/null +++ b/api/users/types.go @@ -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 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 index 0000000..47567db --- /dev/null +++ b/eventsub/bantypes.go @@ -0,0 +1,4 @@ +package eventsub + +type ChannelBanPayload struct { +} diff --git a/eventsub/chattypes.go b/eventsub/chattypes.go new file mode 100644 index 0000000..d84d96b --- /dev/null +++ b/eventsub/chattypes.go @@ -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 index 0000000..0a9e963 --- /dev/null +++ b/eventsub/connection.go @@ -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 index 0000000..0a4a783 --- /dev/null +++ b/eventsub/typemap.go @@ -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 index 0000000..71e318b --- /dev/null +++ b/eventsub/types.go @@ -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 index 0000000..9eb1141 --- /dev/null +++ b/eventsub/x/logger/main.go @@ -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 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 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= -- 2.47.1