From 626540e7282e25192fcd6fc1072e9f198e188b84 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 24 Jul 2026 03:54:29 +0100 Subject: [PATCH] add twitch API stuff, add follow/sub/cheer labels --- .env.example | 3 + .gitignore | 2 + config/config.go | 13 + go.mod | 2 + go.sum | 4 + main.go | 27 ++ music/music.go | 2 +- music/pages/music.html | 1 - music/public/music.css | 9 +- twitch/api/api.go | 52 ++++ twitch/api/event.go | 177 +++++++++++ twitch/oauth/oauth.go | 77 +++++ twitch/pages/labels.html | 14 + twitch/public/labels.css | 10 + twitch/public/labels.js | 60 ++++ twitch/twitch.go | 657 +++++++++++++++++++++++++++++++++++++++ utils/dotenv/dotenv.go | 36 +++ 17 files changed, 1143 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 config/config.go create mode 100644 twitch/api/api.go create mode 100644 twitch/api/event.go create mode 100644 twitch/oauth/oauth.go create mode 100644 twitch/pages/labels.html create mode 100644 twitch/public/labels.css create mode 100644 twitch/public/labels.js create mode 100644 twitch/twitch.go create mode 100644 utils/dotenv/dotenv.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..61d8920 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +TWITCH_CHANNEL_NAME="" +TWITCH_CLIENT_ID="" +TWITCH_CLIENT_SECRET="" diff --git a/.gitignore b/.gitignore index 710c0af..4ca8b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ tmp/ learning-title.txt +.env +twitch-auth.json diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..4178135 --- /dev/null +++ b/config/config.go @@ -0,0 +1,13 @@ +package config + +import ( + "os" + "path" +) + +var CONFIG_DIR = func() string { + baseDir, _ := os.UserConfigDir() + dir := path.Join(baseDir, "ari-stream-tools") + if err := os.MkdirAll(dir, 0750); err != nil { panic(err) } + return dir +}() diff --git a/go.mod b/go.mod index e29cd74..1d55ea9 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -32,6 +33,7 @@ require ( golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index 882bc10..4b9205d 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -68,6 +70,8 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/main.go b/main.go index 33a9a51..4a0542b 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,8 @@ import ( "codeberg.org/arimelody/ari-stream-tools/learning" "codeberg.org/arimelody/ari-stream-tools/music" + "codeberg.org/arimelody/ari-stream-tools/twitch" + "codeberg.org/arimelody/ari-stream-tools/utils/dotenv" "github.com/gin-gonic/gin" ) @@ -23,6 +25,13 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + envars, err := dotenv.LoadEnv() + if err != nil { + if !os.IsNotExist(err) { + log.Fatalf("Failed to read .env: %v", err) + } + } + gin.SetMode(gin.ReleaseMode) host := DEFAULT_HOST @@ -43,12 +52,30 @@ func main() { TitleFilePath: "learning-title.txt", }) musicService := music.New(ctx) + twitchService, err := twitch.New(ctx, twitch.ServiceOptions{ + Port: port, + ChannelName: dotenv.SafeGet(envars, "TWITCH_CHANNEL_NAME"), + ClientID: dotenv.SafeGet(envars, "TWITCH_CLIENT_ID"), + ClientSecret: dotenv.SafeGet(envars, "TWITCH_CLIENT_SECRET"), + }) + if err != nil { + log.Fatalf("Failed to create Twitch service: %v", err) + } srv := gin.Default() + srv.Use(gin.LoggerWithConfig(gin.LoggerConfig{ + SkipPaths: []string{ + "/twitch/public", + "/music/public", + "/learning/public", + }, + })) learningService.BindRoutes(srv.Group("/learning")) musicService.BindRoutes(srv.Group("/music")) + twitchService.BindRoutes(srv.Group("/twitch")) go musicService.Run(ctx) + go twitchService.Run(ctx) go func() { log.Printf("Now serving at http://%s:%d\n", host, port) failed <- srv.Run(fmt.Sprintf("%s:%d", host, port)) diff --git a/music/music.go b/music/music.go index 3f9c3a5..0a0edb0 100644 --- a/music/music.go +++ b/music/music.go @@ -106,7 +106,7 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) { }) group.GET("/artwork", func(ctx *gin.Context) { - ctx.Header("cache-control", "no-cache") + ctx.Header("cache-control", "no-store") if srv.track == nil || srv.track.ArtworkURL == "" { http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, "public/default-cover-art.png") diff --git a/music/pages/music.html b/music/pages/music.html index 0f854af..d5d3db9 100644 --- a/music/pages/music.html +++ b/music/pages/music.html @@ -26,6 +26,5 @@ -
diff --git a/music/public/music.css b/music/public/music.css index f86e398..12c71a1 100644 --- a/music/public/music.css +++ b/music/public/music.css @@ -65,6 +65,7 @@ p { left: 0; bottom: 0; width: min-content; + max-width: calc(100% - 4em); padding: 2em; } @@ -87,7 +88,7 @@ p { } .title-artist { - max-width: calc(100vw - 2em - 80px); + max-width: calc(100vw - 4em - 80px); } .title { @@ -118,3 +119,9 @@ p { opacity: 0; } } + +#music.switching { + .artwork { + filter: blur(4px); + } +} diff --git a/twitch/api/api.go b/twitch/api/api.go new file mode 100644 index 0000000..dc63ac4 --- /dev/null +++ b/twitch/api/api.go @@ -0,0 +1,52 @@ +package api + +type ( + EventSubMetadata struct { + MessageID string `json:"message_id"` + MessageType string `json:"message_type"` + MessageTimestamp string `json:"message_timestamp"` + } + + EventSubSession struct { + ID string `json:"id"` + Status string `json:"status"` + KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"` + ReconnectURL string `json:"reconnect_url"` + ConnectedAt string `json:"connected_at"` + } + + EventSubSubscriptionTransport struct { + Method string `json:"method"` + SessionID string `json:"session_id"` + } + EventSubSubscription struct { + ID string `json:"id"` + Status string `json:"status"` + Type string `json:"type"` + Version string `json:"version"` + Cost int `json:"cost"` + Condition any `json:"condition"` + Transport EventSubSubscriptionTransport `json:"transport"` + CreatedAt string `json:"connected_at"` + } + + EventSubPayload struct { + Session *EventSubSession `json:"session"` + Subscription *EventSubSubscription `json:"subscription"` + Event any `json:"event"` + } + + EventSubMessage struct { + Metadata EventSubMetadata `json:"metadata"` + Payload EventSubPayload `json:"payload"` + } +) + +type MessageType string +const ( + SESSION_WELCOME MessageType = "session_welcome" + SESSION_KEEPALIVE MessageType = "session_keepalive" + NOTIFICATION MessageType = "notification" + SESSION_RECONNECT MessageType = "session_reconnect" + SESSION_RECOVATION MessageType = "session_revocation" +) diff --git a/twitch/api/event.go b/twitch/api/event.go new file mode 100644 index 0000000..f229f95 --- /dev/null +++ b/twitch/api/event.go @@ -0,0 +1,177 @@ +package api + +type ( + ChannelEvent struct { + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + + BroadcasterID string `json:"broadcaster_user_id"` + BroadcasterLogin string `json:"broadcaster_user_login"` + BroadcasterName string `json:"broadcaster_user_name"` + } + + FollowEvent struct { + ChannelEvent + FollowedAt string `json:"followed_at"` + } + + SubscribeEvent struct { + ChannelEvent + Tier string `json:"tier"` + IsGift bool `json:"is_gift"` + } + + CheerEvent struct { + ChannelEvent + Message string `json:"message"` + Bits int `json:"bits"` + IsAnonymous bool `json:"is_anonymous"` + } + + RaidEvent struct { + FromUserID string `json:"from_broadcaster_user_id"` + FromUserLogin string `json:"from_broadcaster_user_login"` + FromUserName string `json:"from_broadcaster_user_name"` + + ToUserID string `json:"to_broadcaster_user_id"` + ToUserLogin string `json:"to_broadcaster_user_login"` + ToUserName string `json:"to_broadcaster_user_name"` + + Viewers int `json:"viewers"` + } + + Reward struct { + ID string `json:"id"` + Title string `json:"title"` + Cost int `json:"cost"` + Prompt string `json:"prompt"` + } + ChannelPointCustomRewardRedeemEvent struct { + ChannelEvent + ID string `json:"id"` + UserInput string `json:"user_input"` + Status string `json:"status"` + RedeemedAt string `json:"redeemed_at"` + Reward Reward `json:"reward"` + } + + ShoutoutCreate struct { + FromUserID string `json:"broadcaster_user_id"` + FromUserLogin string `json:"broadcaster_user_login"` + FromUserName string `json:"broadcaster_user_name"` + + ToUserID string `json:"to_broadcaster_user_id"` + ToUserLogin string `json:"to_broadcaster_user_login"` + ToUserName string `json:"to_broadcaster_user_name"` + + ModeratorID string `json:"moderator_broadcaster_user_id"` + ModeratorLogin string `json:"moderator_broadcaster_user_login"` + ModeratorName string `json:"moderator_broadcaster_user_name"` + + ViewerCount int `json:"viewer_count"` + StartedAt string `json:"started_at"` + CooldownEndsAt string `json:"cooldown_ends_at"` + TargetCooldownEndsAt string `json:"target_cooldown_ends_at"` + } + + ChatMessageCheermote struct { + Prefix string `json:"prefix"` + Bits int `json:"bits"` + Tier int `json:"tier"` + } + ChatMessageEmote struct { + ID string `json:"id"` + EmoteSetID string `json:"emote_set_id"` + OwnerID string `json:"owner_id"` + Format []string `json:"format"` + } + ChatMessageMention struct { + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + } + ChatMessageGif struct { + GifID string `json:"gif_id"` + Url string `json:"url"` + } + ChatMessageFragment struct { + Type string `json:"type"` + Text string `json:"text"` + Cheermote *ChatMessageCheermote `json:"cheermote"` + Emote *ChatMessageEmote `json:"emote"` + Mention *ChatMessageMention `json:"mention"` + Gif *ChatMessageGif `json:"gif"` + } + ChatMessage struct { + Text string `json:"text"` + Fragments []ChatMessageFragment `json:"fragments"` + } + ChatBadge struct { + SetID string `json:"set_id"` + ID string `json:"id"` + Info string `json:"info"` + } + ChatCheer struct { + Bits int `json:"bits"` + } + ChatReply struct { + ParentMessageID string `json:"parent_message_id"` + ParentMessageBody string `json:"parent_message_body"` + ParentUserID string `json:"parent_user_id"` + ParentUserLogin string `json:"parent_user_login"` + ParentUserName string `json:"parent_user_name"` + + ThreadMessageID string `json:"thread_message_id"` + ThreadUserID string `json:"thread_user_id"` + ThreadUserLogin string `json:"thread_user_login"` + ThreadUserName string `json:"thread_user_name"` + } + SourceBadges struct { + SetID string `json:"set_id"` + ID string `json:"id"` + Info string `json:"info"` + } + ChatEvent struct { + BroadcasterID string `json:"broadcaster_user_id"` + BroadcasterLogin string `json:"broadcaster_user_login"` + BroadcasterName string `json:"broadcaster_user_name"` + + ChatterID string `json:"chatter_user_id"` + ChatterLogin string `json:"chatter_user_login"` + ChatterName string `json:"chatter_user_name"` + + MessageID string `json:"message_id"` + Message ChatMessage `json:"message"` + MessageType string `json:"message_type"` + + Badges []ChatBadge `json:"badges"` + Cheer *ChatCheer `json:"cheer"` + Color string `json:"color"` + Reply *ChatReply `json:"reply"` + + ChannelPointsCustomRewardID string `json:"channel_points_custom_reward_id"` + + SourceBroadcasterUserID string `json:"source_broadcaster_user_id"` + SourceBroadcasterUserName string `json:"source_broadcaster_user_name"` + SourceBroadcasterUserLogin string `json:"source_broadcaster_user_login"` + + SourceMessageID string `json:"source_message_id"` + + SourceBadges *SourceBadges `json:"source_badges"` + + IsSourceOnly bool `json:"is_source_only"` + } + + ChatDeleteEvent struct { + BroadcasterID string `json:"broadcaster_user_id"` + BroadcasterLogin string `json:"broadcaster_user_login"` + BroadcasterName string `json:"broadcaster_user_name"` + + TargetID string `json:"target_user_id"` + TargetLogin string `json:"target_user_login"` + TargetName string `json:"target_user_name"` + + MessageID string `json:"message_id"` + } +) diff --git a/twitch/oauth/oauth.go b/twitch/oauth/oauth.go new file mode 100644 index 0000000..af1234e --- /dev/null +++ b/twitch/oauth/oauth.go @@ -0,0 +1,77 @@ +package oauth + +import ( + "context" + "crypto/rand" + "fmt" + "log" + "net/http" + "sync" + + "golang.org/x/oauth2" +) + +func GenerateToken( + ctx *context.Context, + oauth2Config *oauth2.Config, +) (*oauth2.Token, error) { + verifier := oauth2.GenerateVerifier() + + state := rand.Text() + + var token *oauth2.Token + wg := sync.WaitGroup{} + + var server http.Server + server.Addr = "127.0.0.1" + server.Handler = http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + scope := r.URL.Query().Get("scope") + resState := r.URL.Query().Get("state") + + if len(code) == 0 || len(scope) == 0 || resState != state { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + t, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + log.Fatalf("Could not exchange OAuth2 code: %v", err) + http.Error( w, + fmt.Sprintf("Could not exchange OAuth2 code: %v", err), + http.StatusBadRequest, + ) + return + } + + token = t + http.Error( + w, + "Authentication successful! You may now close this tab.", + http.StatusOK, + ) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + wg.Done() + server.Close() + }, + ) + + url := oauth2Config.AuthCodeURL( + state, + oauth2.AccessTypeOffline, + oauth2.S256ChallengeOption(verifier), + ) + log.Printf("Log in with Twitch: %s\n\n", url) + + wg.Add(1) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + return nil, fmt.Errorf("http: %v", err) + } + wg.Wait() + + return token, nil +} diff --git a/twitch/pages/labels.html b/twitch/pages/labels.html new file mode 100644 index 0000000..f6fc010 --- /dev/null +++ b/twitch/pages/labels.html @@ -0,0 +1,14 @@ + + + + + + Twitch Labels + + + + +

+ + + diff --git a/twitch/public/labels.css b/twitch/public/labels.css new file mode 100644 index 0000000..b2a9a49 --- /dev/null +++ b/twitch/public/labels.css @@ -0,0 +1,10 @@ +body { + margin: 0; +} + +#label { + margin: 16px; + font-family: "Inter", sans-serif; + font-size: 64px; + font-weight: bold; +} diff --git a/twitch/public/labels.js b/twitch/public/labels.js new file mode 100644 index 0000000..7577aca --- /dev/null +++ b/twitch/public/labels.js @@ -0,0 +1,60 @@ +const labelEl = document.getElementById("label"); + +const selectedLabel = new URLSearchParams(location.search).get("l"); +const labelLatestFollower = "latest-follower" +const labelLatestSubscriber = "latest-subscriber" +const labelLatestCheer = "latest-cheer" +const allowedLabels = [ + labelLatestFollower, + labelLatestSubscriber, + labelLatestCheer, +] + +/** + * @param {string} selectedLabel + */ +async function sync() { + if (selectedLabel == null) { + const exampleUrl = + location.pathname + + "?l=" + + labelLatestFollower; + + labelEl.innerHTML = + "Needs label to listen to, " + + `such as ${labelLatestFollower}`; + console.error("Needs label to listen to, such as " + exampleUrl); + return; + } + + let eventSource; + try { + eventSource = new EventSource("/twitch/sse?l=" + selectedLabel); + } catch (err) { + console.error("Failed to connect to event source", err); + return; + } + + eventSource.addEventListener("error", () => { + eventSource.close(); + console.error("Connection lost, or an error has occurred."); + console.log("Attempting to reconnect..."); + + // TODO: stop reconnecting after 10 failed attempts + + setTimeout(() => { + sync(); + }, 1000); + }); + + eventSource.addEventListener("update", event => { + console.log(event.data); + labelEl.innerText = event.data; + }); + + eventSource.addEventListener("open", () => { + console.log("Connected."); + }); +} + +sync(); diff --git a/twitch/twitch.go b/twitch/twitch.go new file mode 100644 index 0000000..527bc19 --- /dev/null +++ b/twitch/twitch.go @@ -0,0 +1,657 @@ +package twitch + +import ( + "bytes" + "context" + "crypto/rand" + "embed" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "path" + "strings" + "time" + + "codeberg.org/arimelody/ari-stream-tools/broadcast" + "codeberg.org/arimelody/ari-stream-tools/config" + "codeberg.org/arimelody/ari-stream-tools/twitch/api" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "golang.org/x/oauth2" +) + +type ( + twitchLabel struct { + Text string + C chan string + Broadcast broadcast.BroadcastChannel[string] + } + + twitchLabels struct { + LatestFollower *twitchLabel + LatestSubscriber *twitchLabel + LatestCheer *twitchLabel + } + + ServiceOptions struct { + Port int16 + ChannelName string + ClientID string + ClientSecret string + } + + Service struct { + port int16 + labels *twitchLabels + channelName string + channelID string + clientID string + clientSecret string + oauthConfig *oauth2.Config + oauthState string + oauthToken *oauth2.Token + eventSubSession *api.EventSubSession + } +) + +const ( + TWITCH_EVENTSUB_URL string = "wss://eventsub.wss.twitch.tv/ws" + TWITCH_API_BASE string = "https://api.twitch.tv/helix" + TWITCH_OAUTH2_URL string = "https://id.twitch.tv/oauth2/token" + + LABEL_LATEST_FOLLOWER string = "latest-follower" + LABEL_LATEST_SUBSCRIBER string = "latest-subscriber" + LABEL_LATEST_CHEER string = "latest-cheer" +) + +var DATA_PATH string = path.Join(config.CONFIG_DIR, "twitch") + +//go:embed public +var publicFS embed.FS +//go:embed pages +var pagesFS embed.FS + +func New(ctx context.Context, opts ServiceOptions) (*Service, error) { + latestFollowerC := make(chan string) + latestFollowerBroadcast := broadcast.NewBroadcastChannel( + ctx, latestFollowerC) + latestSubscriberC := make(chan string) + latestSubscriberBroadcast := broadcast.NewBroadcastChannel( + ctx, latestSubscriberC) + latestCheerC := make(chan string) + latestCheerBroadcast := broadcast.NewBroadcastChannel( + ctx, latestCheerC) + + srv := &Service{ + labels: &twitchLabels{ + LatestFollower: &twitchLabel{ + Text: "some_follower", + C: latestFollowerC, + Broadcast: latestFollowerBroadcast, + }, + LatestSubscriber: &twitchLabel{ + Text: "some_subscriber", + C: latestSubscriberC, + Broadcast: latestSubscriberBroadcast, + }, + LatestCheer: &twitchLabel{ + Text: "some_cheer", + C: latestCheerC, + Broadcast: latestCheerBroadcast, + }, + }, + channelName: opts.ChannelName, + clientID: opts.ClientID, + clientSecret: opts.ClientSecret, + oauthConfig: &oauth2.Config{ + ClientID: opts.ClientID, + ClientSecret: opts.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://id.twitch.tv/oauth2/authorize", + TokenURL: "https://id.twitch.tv/oauth2/token", + }, + Scopes: []string{ + "moderator:read:followers", + "user:read:chat", + "user:bot", + "channel:bot", + "channel:read:subscriptions", + "bits:read", + "channel:read:redemptions", + "channel:read:polls", + "channel:read:predictions", + "channel:read:hype_train", + "moderator:read:shoutouts", + }, + RedirectURL: fmt.Sprintf("http://localhost:%d/twitch/auth", opts.Port), + }, + } + + if err := os.MkdirAll(DATA_PATH, 0750); err != nil { panic(err) } + if err := os.MkdirAll(path.Join(DATA_PATH, "state"), 0750); err != nil { panic(err) } + + authFile, err := os.OpenFile(path.Join(DATA_PATH, "twitch-auth.json"), os.O_RDONLY, 0600) + if err != nil { + if !os.IsNotExist(err) { + log.Fatalf("Failed to open twitch-auth.json: %v", err) + } + } else { + srv.oauthToken = &oauth2.Token{} + err = json.NewDecoder(authFile).Decode(srv.oauthToken) + if err != nil { + log.Printf("Failed to read twitch-auth.json: %v", err) + } + } + authFile.Close() + + if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_FOLLOWER)); err == nil { + srv.labels.LatestFollower.Text = string(data) + log.Printf("Loaded latest follower: %s", string(data)) + } + if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_SUBSCRIBER)); err == nil { + srv.labels.LatestSubscriber.Text = string(data) + log.Printf("Loaded latest subscriber: %s", string(data)) + } + if data, err := os.ReadFile(path.Join(DATA_PATH, "state", LABEL_LATEST_CHEER)); err == nil { + srv.labels.LatestCheer.Text = string(data) + log.Printf("Loaded latest cheer: %s", string(data)) + } + + return srv, nil +} + +func (srv *Service) BindRoutes(group *gin.RouterGroup) { + group.GET("/public/*path", func(ctx *gin.Context) { + path := strings.TrimPrefix(ctx.Request.URL.Path, "/twitch/") + http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, path) + }) + + group.GET("/auth", func(ctx *gin.Context) { + code := ctx.Query("code") + scope := ctx.Query("scope") + resState := ctx.Query("state") + + if len(code) == 0 || len(scope) == 0 || resState != srv.oauthState { + ctx.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) + return + } + + token, err := srv.oauthConfig.Exchange(ctx, code) + if err != nil { + log.Printf("Could not exchange OAuth2 code: %v", err) + ctx.String(http.StatusBadRequest, "Could not exchange OAuth2 code.") + return + } + + srv.oauthToken = token + authFile, err := os.OpenFile(path.Join(DATA_PATH, "twitch-auth.json"), os.O_CREATE | os.O_RDWR, 0600) + if err != nil { + log.Printf("Failed to open twitch-auth.json: %v", err) + ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + return + } + defer authFile.Close() + authFile.Truncate(0) + json.NewEncoder(authFile).Encode(srv.oauthToken) + + ctx.String( + http.StatusOK, + "Authentication successful! You may now close this tab.", + ) + + go srv.start(ctx) + }) + + group.GET("/sse", func(ctx *gin.Context) { + ctx.Header("connection", "keep-alive") + + listeningTo := ctx.Query("l") + + var label *twitchLabel + switch listeningTo { + case LABEL_LATEST_FOLLOWER: + label = srv.labels.LatestFollower + case LABEL_LATEST_SUBSCRIBER: + label = srv.labels.LatestSubscriber + case LABEL_LATEST_CHEER: + label = srv.labels.LatestCheer + default: + ctx.String(http.StatusBadRequest, "Unknown label %s", listeningTo) + return + } + + labelUpdate := label.Broadcast.Subscribe() + defer label.Broadcast.Cancel(labelUpdate) + + ctx.SSEvent("update", label.Text) + + ticker := time.NewTicker(10 * time.Millisecond) + ctx.Stream(func(w io.Writer) bool { + select { + case text := <-labelUpdate: + ctx.SSEvent("update", text) + case <-ticker.C: + } + return true + }) + }) + + group.GET("/labels", func(ctx *gin.Context) { + http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/labels.html") + }) +} + +func (srv *Service) Run(ctx context.Context) { + if srv.oauthToken == nil { + srv.oauthState = rand.Text() + authCodeURL := srv.oauthConfig.AuthCodeURL(srv.oauthState) + log.Printf("Log in with Twitch: %s", authCodeURL) + } else { + srv.start(ctx) + } +} + +func (srv *Service) start(ctx context.Context) { + userIDs, err := srv.userIDsFromNames([]string{ srv.channelName }) + if err != nil { + log.Printf( + "Failed to resolve Twitch username \"%s\": %v", + srv.channelName, + err, + ) + return + } + if len(userIDs) == 0 { + fmt.Printf("Failed to resolve username \"%s\", it may not exist?", srv.channelName) + return + } + srv.channelID = userIDs[0] + + log.Printf("Connecting to Twitch as %s...", srv.channelName) + + + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + u, err := url.Parse(TWITCH_EVENTSUB_URL + "?keepalive_timeout_seconds=600") + if err != nil { panic(err) } + + c, _, err := websocket.DefaultDialer.Dial(u.String(), http.Header{ + "Authorization": []string{ "Bearer " + srv.clientSecret }, + }) + if err != nil { + log.Printf("Failed to connect to Twitch: %v", err) + } + defer c.Close() + + failed := make(chan error) + go func() { + for { + _, rawData, err := c.ReadMessage() + if err != nil { failed <- err; return } + + var message api.EventSubMessage + err = json.Unmarshal(rawData, &message) + if err != nil { + failed <- fmt.Errorf("parse JSON: %v", err) + return + } + + if message.Payload.Session != nil { + if err := srv.registerEventSubSession(message.Payload.Session); err != nil { + failed <- fmt.Errorf("register eventsub session: %v", err) + return + } + + srv.subscribeToDefaultEvents() + } + + if message.Metadata.MessageType == string(api.NOTIFICATION) { + if message.Payload.Subscription == nil { continue } + + if err := srv.handleNotification(&message.Payload); err != nil { + log.Printf("Failed to handle %s event: %v", message.Payload.Subscription.Type, err) + } + continue + } + } + }() + + select { + case err := <-failed: + log.Fatalf("Twitch error: %v", err) + case <-ctx.Done(): + } +} + +func (srv *Service) registerEventSubSession(session *api.EventSubSession) error { + srv.eventSubSession = session + return nil +} + +func (srv *Service) handleNotification(payload *api.EventSubPayload) error { + var err error + jsonData, err := json.Marshal(payload.Event) + if err != nil { return err } + + switch payload.Subscription.Type { + + case "channel.follow": + var event api.FollowEvent + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.FollowEvent: %v", err) } + log.Printf("New follow: %s", event.UserLogin) + srv.labels.LatestFollower.Text = event.UserLogin + srv.labels.LatestFollower.C <- event.UserLogin + + if err := os.WriteFile( + path.Join(DATA_PATH, "state", LABEL_LATEST_FOLLOWER), + []byte(event.UserLogin), 0640, + ); err != nil { + return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_FOLLOWER, err) + } + + case "channel.subscribe": + var event api.SubscribeEvent + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.SubscribeEvent: %v", err) } + log.Printf("New subscription: %s", event.UserLogin) + srv.labels.LatestSubscriber.Text = event.UserLogin + srv.labels.LatestSubscriber.C <- event.UserLogin + + if err := os.WriteFile( + path.Join(DATA_PATH, "state", LABEL_LATEST_SUBSCRIBER), + []byte(event.UserLogin), 0640, + ); err != nil { + return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_SUBSCRIBER, err) + } + + case "channel.cheer": + var event api.CheerEvent + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.CheerEvent: %v", err) } + log.Printf("%s cheered x%d bits: %s", event.UserLogin, event.Bits, event.Message) + srv.labels.LatestCheer.Text = event.UserLogin + srv.labels.LatestCheer.C <- event.UserLogin + + if err := os.WriteFile( + path.Join(DATA_PATH, "state", LABEL_LATEST_CHEER), + []byte(event.UserLogin), 0640, + ); err != nil { + return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_CHEER, err) + } + + case "channel.raid": + var event api.RaidEvent + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.RaidEvent: %v", err) } + log.Printf("%s is now raiding with %d viewers!", event.FromUserLogin, event.Viewers) + + case "channel.channel_points_custom_reward_redemption.add": + var event api.ChannelPointCustomRewardRedeemEvent + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.ChannelPointCustomRewardRedeemEvent: %v", err) } + log.Printf( + "%s just redeemed %s for %d channel points.", + event.UserLogin, + event.Reward.Title, + event.Reward.Cost, + ) + + case "channel.shoutout.create": + var event api.ShoutoutCreate + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.ShoutoutCreate: %v", err) } + log.Printf( + "%s gave a shoutout to %s.", + event.FromUserLogin, + event.ToUserLogin, + ) + + case "channel.chat.message": + var event api.ChatEvent + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.ChatEvent: %v", err) } + if event.Cheer != nil { return nil } + log.Printf( + "[%s] %s: %s", + event.MessageID, + event.ChatterLogin, + event.Message.Text, + ) + + case "channel.chat.message_delete": + var event api.ChatDeleteEvent + err := json.Unmarshal(jsonData, &event) + if err != nil { return fmt.Errorf("Failed to cast to api.ChatDeleteEvent: %v", err) } + log.Printf( + "Message %s by %s deleted.", + event.MessageID, + event.TargetLogin, + ) + + } + + return nil +} + +func (srv *Service) subscribeToEvent( + subscriptionType string, + version string, + condition map[string]string, + sessionID string, +) error { + type ( + Transport struct { + Method string `json:"method"` + SessionID string `json:"session_id"` + } + + Request struct { + Type string `json:"type"` + Version string `json:"version"` + Condition map[string]string `json:"condition"` + Transport Transport `json:"transport"` + } + + ResponseData struct { + ID string `json:"id"` + Status string `json:"status"` + Type string `json:"type"` + Version string `json:"version"` + Condition map[string]string `json:"condition"` + CreatedAt string `json:"created_at"` + Transport Transport `json:"transport"` + Cost int `json:"cost"` + } + Response struct { + Data []ResponseData `json:"data"` + Total int `json:"total"` + TotalCost int `json:"total_cost"` + MaxTotalCost int `json:"max_total_cost"` + } + ) + + bodyBytes, err := json.Marshal(Request{ + Type: subscriptionType, + Version: version, + Condition: condition, + Transport: Transport{ + Method: "websocket", + SessionID: sessionID, + }, + }) + body := bytes.NewBuffer(bodyBytes) + + client := http.DefaultClient + req, err := http.NewRequest( + "POST", + TWITCH_API_BASE + "/eventsub/subscriptions", + body, + ) + if err != nil { return err } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Client-Id", srv.clientID) + req.Header.Set("Authorization", "Bearer " + srv.oauthToken.AccessToken) + + res, err := client.Do(req) + if err != nil { return err } + + if res.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(res.Body) + return fmt.Errorf("%s: %s", res.Status, string(body)) + } + + return nil +} + +func (srv *Service) subscribeToDefaultEvents() { + // channel.follow + if err := srv.subscribeToEvent( + "channel.follow", "2", + map[string]string{ + "broadcaster_user_id": srv.channelID, + "moderator_user_id": srv.channelID, + }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.follow: %v", err) + } + + // channel.subscribe + if err := srv.subscribeToEvent( + "channel.subscribe", "1", + map[string]string{ "broadcaster_user_id": srv.channelID }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.subscribe: %v", err) + } + + // channel.cheer + if err := srv.subscribeToEvent( + "channel.cheer", "1", + map[string]string{ "broadcaster_user_id": srv.channelID }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.cheer: %v", err) + } + + // channel.raid + if err := srv.subscribeToEvent( + "channel.raid", "1", + map[string]string{ "to_broadcaster_user_id": srv.channelID }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.raid: %v", err) + } + + // channel.channel_points_custom_reward_redemption.add + if err := srv.subscribeToEvent( + "channel.channel_points_custom_reward_redemption.add", "1", + map[string]string{ "broadcaster_user_id": srv.channelID }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.channel_points_custom_reward_redemption.add: %v", err) + } + + // channel.shoutout.create + if err := srv.subscribeToEvent( + "channel.shoutout.create", "1", + map[string]string{ + "broadcaster_user_id": srv.channelID, + "moderator_user_id": srv.channelID, + }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.shoutout.create: %v", err) + } + + // channel.chat.message + if err := srv.subscribeToEvent( + "channel.chat.message", "1", + map[string]string{ + "broadcaster_user_id": srv.channelID, + "user_id": srv.channelID, + }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.chat.message: %v", err) + } + + // channel.chat.message_delete + if err := srv.subscribeToEvent( + "channel.chat.message_delete", "1", + map[string]string{ + "broadcaster_user_id": srv.channelID, + "user_id": srv.channelID, + }, + srv.eventSubSession.ID, + ); err != nil { + log.Printf("Failed to subscribe to channel.chat.message_delete: %v", err) + } +} + +func (srv *Service) userIDsFromNames(usernames []string) ([]string, error) { + if len(usernames) == 0 { return []string{}, nil } + + url := strings.Builder{} + url.WriteString(TWITCH_API_BASE) + url.WriteString("/users?login=") + url.WriteString(usernames[0]) + for _, username := range usernames { + url.WriteString("&login=") + url.WriteString(username) + } + + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { return nil, err } + + req.Header.Set("Client-Id", srv.clientID) + req.Header.Set("Authorization", "Bearer " + srv.oauthToken.AccessToken) + + res, err := http.DefaultClient.Do(req) + if err != nil { return nil, err } + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + return nil, fmt.Errorf("%s: %s", res.Status, string(body)) + } + + type ( + UserData struct { + ID string `json:"id"` + Login string `json:"login"` + DisplayName string `json:"display_name"` + Type string `json:"type"` + BroadcasterType string `json:"broadcaster_type"` + Description string `json:"description"` + ProfileImageURL string `json:"profile_image_url"` + OfflineImageURL string `json:"offline_image_url"` + ViewCount int `json:"view_count"` + Email string `json:"email"` + CreatedAt string `json:"created_at"` + } + + Response struct { + Users []UserData `json:"data"` + } + ) + data := Response{} + if err := json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + userIDs := []string{} + for _, user := range data.Users { + userIDs = append(userIDs, user.ID) + } + + return userIDs, nil +} diff --git a/utils/dotenv/dotenv.go b/utils/dotenv/dotenv.go new file mode 100644 index 0000000..4e9dbb6 --- /dev/null +++ b/utils/dotenv/dotenv.go @@ -0,0 +1,36 @@ +package dotenv + +import ( + "os" + "strings" +) + +func LoadEnv() (map[string]string, error) { + data, err := os.ReadFile(".env") + if err != nil { return nil, err } + + dataString := string(data) + lines := strings.Split(dataString, "\n") + + envars := map[string]string{} + for _, line := range lines { + splits := strings.Split(line, "=") + if len(splits) < 2 { continue } + + key := splits[0] + value := splits[1] + + if len(value) > 2 && value[0] == '"' && value[len(value) - 1] == '"' { + value = value[1:len(value)-1] + } + + envars[key] = value + } + + return envars, nil +} + +func SafeGet(m map[string]string, k string) string { + value, _ := m[k] + return value +}