From a24fbfc96e3222db30ad8bed69538c4ed0672d25 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 24 Jul 2026 10:45:08 +0100 Subject: [PATCH] improve twitch oauth resilience --- twitch/api/api.go | 3 ++ twitch/twitch.go | 89 +++++++++++++++++++++++------------------------ 2 files changed, 47 insertions(+), 45 deletions(-) diff --git a/twitch/api/api.go b/twitch/api/api.go index dc63ac4..24b4cd1 100644 --- a/twitch/api/api.go +++ b/twitch/api/api.go @@ -44,6 +44,9 @@ type ( type MessageType string const ( + BASE_URL string = "https://api.twitch.tv/helix" + EVENTSUB_URL string = "wss://eventsub.wss.twitch.tv/ws" + SESSION_WELCOME MessageType = "session_welcome" SESSION_KEEPALIVE MessageType = "session_keepalive" NOTIFICATION MessageType = "notification" diff --git a/twitch/twitch.go b/twitch/twitch.go index 64a834a..6d85221 100644 --- a/twitch/twitch.go +++ b/twitch/twitch.go @@ -24,6 +24,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "golang.org/x/oauth2" + "golang.org/x/oauth2/twitch" ) type ( @@ -64,18 +65,14 @@ type ( ) 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") -var CONFIG_FILENAME string = "twitch-config.json" -var CONFIG_PATH string = path.Join(DATA_PATH, CONFIG_FILENAME) +var CONFIG_FILEPATH string = path.Join(DATA_PATH, "twitch-config.json") +var AUTH_FILEPATH string = path.Join(DATA_PATH, "twitch-auth") //go:embed public var publicFS embed.FS @@ -87,23 +84,23 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) { if err := os.MkdirAll(path.Join(DATA_PATH, "state"), 0750); err != nil { panic(err) } config := serviceConfig{} - if configFile, err := os.OpenFile(CONFIG_PATH, os.O_CREATE | os.O_RDWR, 0600); err != nil { - return nil, fmt.Errorf("open %s: %v", CONFIG_FILENAME, err) + if configFile, err := os.OpenFile(CONFIG_FILEPATH, os.O_CREATE | os.O_RDWR, 0600); err != nil { + return nil, fmt.Errorf("open %s: %v", CONFIG_FILEPATH, err) } else { defer configFile.Close() stat, err := configFile.Stat() - if err != nil { return nil, fmt.Errorf("stat %s: %v", CONFIG_FILENAME, err) } + if err != nil { return nil, fmt.Errorf("stat %s: %v", CONFIG_FILEPATH, err) } if stat.Size() == 0 { enc := json.NewEncoder(configFile) enc.SetIndent("", "\t") if err := enc.Encode(&config); err != nil { - return nil, fmt.Errorf("write %s: %v", CONFIG_FILENAME, err) + return nil, fmt.Errorf("write %s: %v", CONFIG_FILEPATH, err) } - return nil, fmt.Errorf("Config file is empty: %s", CONFIG_PATH) + return nil, fmt.Errorf("Config file is empty: %s", CONFIG_FILEPATH) } else if err := json.NewDecoder(configFile).Decode(&config); err != nil { - return nil, fmt.Errorf("decode %s: %v", CONFIG_FILENAME, err) + return nil, fmt.Errorf("read %s: %v", CONFIG_FILEPATH, err) } } @@ -122,6 +119,7 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) { ctx, latestCheerC) srv := &Service{ + port: opts.Port, labels: &twitchLabels{ LatestFollower: &twitchLabel{ Text: "some_follower", @@ -145,10 +143,7 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) { oauthConfig: &oauth2.Config{ ClientID: config.ClientID, ClientSecret: config.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://id.twitch.tv/oauth2/authorize", - TokenURL: "https://id.twitch.tv/oauth2/token", - }, + Endpoint: twitch.Endpoint, Scopes: []string{ "moderator:read:followers", "user:read:chat", @@ -166,16 +161,16 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) { }, } - if authFile, err := os.OpenFile(path.Join(DATA_PATH, "twitch-auth.json"), os.O_RDONLY, 0600); err != nil { + if authFile, err := os.OpenFile(AUTH_FILEPATH, os.O_RDONLY, 0600); err != nil { if !os.IsNotExist(err) { - log.Fatalf("Failed to open twitch-auth.json: %v", err) + log.Fatalf("open %s: %v", AUTH_FILEPATH, err) } } else { defer authFile.Close() srv.oauthToken = &oauth2.Token{} err = json.NewDecoder(authFile).Decode(srv.oauthToken) if err != nil { - log.Printf("Failed to read twitch-auth.json: %v", err) + log.Printf("read %s: %v", AUTH_FILEPATH, err) } } @@ -201,15 +196,20 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) { http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, path) }) + group.GET("/login", func(ctx *gin.Context) { + srv.oauthState = rand.Text() + authCodeURL := srv.oauthConfig.AuthCodeURL(srv.oauthState) + ctx.Redirect(http.StatusTemporaryRedirect, authCodeURL) + }) + 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 - } + if len(code) == 0 { ctx.String(http.StatusBadRequest, "code cannot be empty"); return } + if len(scope) == 0 { ctx.String(http.StatusBadRequest, "scope cannot be empty"); return } + if resState != srv.oauthState { ctx.String(http.StatusBadRequest, "state mismatch"); return } token, err := srv.oauthConfig.Exchange(ctx, code) if err != nil { @@ -219,9 +219,9 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) { } srv.oauthToken = token - authFile, err := os.OpenFile(path.Join(DATA_PATH, "twitch-auth.json"), os.O_CREATE | os.O_RDWR, 0600) + authFile, err := os.OpenFile(AUTH_FILEPATH, os.O_CREATE | os.O_RDWR, 0600) if err != nil { - log.Printf("Failed to open twitch-auth.json: %v", err) + log.Printf("open %s: %v", AUTH_FILEPATH, err) ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) return } @@ -277,10 +277,8 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) { } 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) + if srv.oauthToken == nil || !srv.oauthToken.Valid() { + log.Printf("Log in with Twitch: http://localhost:%d/twitch/login", srv.port) } else { srv.start(ctx) } @@ -301,15 +299,13 @@ func (srv *Service) start(ctx context.Context) { 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") + u, err := url.Parse(api.EVENTSUB_URL + "?keepalive_timeout_seconds=600") if err != nil { panic(err) } c, _, err := websocket.DefaultDialer.Dial(u.String(), http.Header{ @@ -339,7 +335,9 @@ func (srv *Service) start(ctx context.Context) { return } - srv.subscribeToDefaultEvents() + srv.subscribeToDefaultEvents(ctx) + + log.Printf("Connected to Twitch as %s.", srv.channelName) } if message.Metadata.MessageType == string(api.NOTIFICATION) { @@ -472,6 +470,7 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error { } func (srv *Service) subscribeToEvent( + ctx context.Context, subscriptionType string, version string, condition map[string]string, @@ -519,10 +518,10 @@ func (srv *Service) subscribeToEvent( }) body := bytes.NewBuffer(bodyBytes) - client := http.DefaultClient + client := srv.oauthConfig.Client(ctx, srv.oauthToken) req, err := http.NewRequest( "POST", - TWITCH_API_BASE + "/eventsub/subscriptions", + api.BASE_URL + "/eventsub/subscriptions", body, ) if err != nil { return err } @@ -542,10 +541,10 @@ func (srv *Service) subscribeToEvent( return nil } -func (srv *Service) subscribeToDefaultEvents() { +func (srv *Service) subscribeToDefaultEvents(ctx context.Context) { // channel.follow if err := srv.subscribeToEvent( - "channel.follow", "2", + ctx, "channel.follow", "2", map[string]string{ "broadcaster_user_id": srv.channelID, "moderator_user_id": srv.channelID, @@ -557,7 +556,7 @@ func (srv *Service) subscribeToDefaultEvents() { // channel.subscribe if err := srv.subscribeToEvent( - "channel.subscribe", "1", + ctx, "channel.subscribe", "1", map[string]string{ "broadcaster_user_id": srv.channelID }, srv.eventSubSession.ID, ); err != nil { @@ -566,7 +565,7 @@ func (srv *Service) subscribeToDefaultEvents() { // channel.cheer if err := srv.subscribeToEvent( - "channel.cheer", "1", + ctx, "channel.cheer", "1", map[string]string{ "broadcaster_user_id": srv.channelID }, srv.eventSubSession.ID, ); err != nil { @@ -575,7 +574,7 @@ func (srv *Service) subscribeToDefaultEvents() { // channel.raid if err := srv.subscribeToEvent( - "channel.raid", "1", + ctx, "channel.raid", "1", map[string]string{ "to_broadcaster_user_id": srv.channelID }, srv.eventSubSession.ID, ); err != nil { @@ -584,7 +583,7 @@ func (srv *Service) subscribeToDefaultEvents() { // channel.channel_points_custom_reward_redemption.add if err := srv.subscribeToEvent( - "channel.channel_points_custom_reward_redemption.add", "1", + ctx, "channel.channel_points_custom_reward_redemption.add", "1", map[string]string{ "broadcaster_user_id": srv.channelID }, srv.eventSubSession.ID, ); err != nil { @@ -593,7 +592,7 @@ func (srv *Service) subscribeToDefaultEvents() { // channel.shoutout.create if err := srv.subscribeToEvent( - "channel.shoutout.create", "1", + ctx, "channel.shoutout.create", "1", map[string]string{ "broadcaster_user_id": srv.channelID, "moderator_user_id": srv.channelID, @@ -605,7 +604,7 @@ func (srv *Service) subscribeToDefaultEvents() { // channel.chat.message if err := srv.subscribeToEvent( - "channel.chat.message", "1", + ctx, "channel.chat.message", "1", map[string]string{ "broadcaster_user_id": srv.channelID, "user_id": srv.channelID, @@ -617,7 +616,7 @@ func (srv *Service) subscribeToDefaultEvents() { // channel.chat.message_delete if err := srv.subscribeToEvent( - "channel.chat.message_delete", "1", + ctx, "channel.chat.message_delete", "1", map[string]string{ "broadcaster_user_id": srv.channelID, "user_id": srv.channelID, @@ -632,7 +631,7 @@ 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(api.BASE_URL) url.WriteString("/users?login=") url.WriteString(usernames[0]) for _, username := range usernames {