--- /dev/null
+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
+}
--- /dev/null
+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"`
+}
--- /dev/null
+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)
+}
--- /dev/null
+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))
+}
--- /dev/null
+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"`
+}
--- /dev/null
+package api
+
+func (c *Client) getToken() string {
+ return c.lastToken
+}
--- /dev/null
+package api
+
+type ResponseData[T any] struct {
+ Data []T `json:"data"`
+}
--- /dev/null
+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
+}
--- /dev/null
+package users
+
+type GetUsersResponse struct {
+ Data []GetUsersData
+}
+
+type GetUsersData struct {
+ Id string
+ Login string
+ DisplayName string `json:"display_name"`
+}
--- /dev/null
+Subproject commit db214e5b7a60359ae65fca9d15c812731cb63d7b
--- /dev/null
+package eventsub
+
+type ChannelBanPayload struct {
+}
--- /dev/null
+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"`
+}
--- /dev/null
+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
+}
--- /dev/null
+package eventsub
+
+var typemap = map[string]func() any{
+ "channel.ban": func() any { return new(ChannelBanPayload) },
+ "channel.chat.message": func() any { return new(ChannelChatMessagePayload) },
+}
--- /dev/null
+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"`
+}
--- /dev/null
+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))
+ }
+}
--- /dev/null
+module go.openfl.eu/twitch-api
+
+go 1.24.0
+
+require golang.org/x/net v0.35.0
--- /dev/null
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=