improve twitch oauth resilience

This commit is contained in:
ari melody 2026-07-24 10:45:08 +01:00
parent accf60ed74
commit a24fbfc96e
Signed by: ari
GPG key ID: CF99829C92678188
2 changed files with 47 additions and 45 deletions

View file

@ -44,6 +44,9 @@ type (
type MessageType string type MessageType string
const ( const (
BASE_URL string = "https://api.twitch.tv/helix"
EVENTSUB_URL string = "wss://eventsub.wss.twitch.tv/ws"
SESSION_WELCOME MessageType = "session_welcome" SESSION_WELCOME MessageType = "session_welcome"
SESSION_KEEPALIVE MessageType = "session_keepalive" SESSION_KEEPALIVE MessageType = "session_keepalive"
NOTIFICATION MessageType = "notification" NOTIFICATION MessageType = "notification"

View file

@ -24,6 +24,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/twitch"
) )
type ( type (
@ -64,18 +65,14 @@ type (
) )
const ( 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_FOLLOWER string = "latest-follower"
LABEL_LATEST_SUBSCRIBER string = "latest-subscriber" LABEL_LATEST_SUBSCRIBER string = "latest-subscriber"
LABEL_LATEST_CHEER string = "latest-cheer" LABEL_LATEST_CHEER string = "latest-cheer"
) )
var DATA_PATH string = path.Join(config.CONFIG_DIR, "twitch") var DATA_PATH string = path.Join(config.CONFIG_DIR, "twitch")
var CONFIG_FILENAME string = "twitch-config.json" var CONFIG_FILEPATH string = path.Join(DATA_PATH, "twitch-config.json")
var CONFIG_PATH string = path.Join(DATA_PATH, CONFIG_FILENAME) var AUTH_FILEPATH string = path.Join(DATA_PATH, "twitch-auth")
//go:embed public //go:embed public
var publicFS embed.FS 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) } if err := os.MkdirAll(path.Join(DATA_PATH, "state"), 0750); err != nil { panic(err) }
config := serviceConfig{} config := serviceConfig{}
if configFile, err := os.OpenFile(CONFIG_PATH, os.O_CREATE | os.O_RDWR, 0600); err != nil { if configFile, err := os.OpenFile(CONFIG_FILEPATH, os.O_CREATE | os.O_RDWR, 0600); err != nil {
return nil, fmt.Errorf("open %s: %v", CONFIG_FILENAME, err) return nil, fmt.Errorf("open %s: %v", CONFIG_FILEPATH, err)
} else { } else {
defer configFile.Close() defer configFile.Close()
stat, err := configFile.Stat() 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 { if stat.Size() == 0 {
enc := json.NewEncoder(configFile) enc := json.NewEncoder(configFile)
enc.SetIndent("", "\t") enc.SetIndent("", "\t")
if err := enc.Encode(&config); err != nil { 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 { } 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) ctx, latestCheerC)
srv := &Service{ srv := &Service{
port: opts.Port,
labels: &twitchLabels{ labels: &twitchLabels{
LatestFollower: &twitchLabel{ LatestFollower: &twitchLabel{
Text: "some_follower", Text: "some_follower",
@ -145,10 +143,7 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) {
oauthConfig: &oauth2.Config{ oauthConfig: &oauth2.Config{
ClientID: config.ClientID, ClientID: config.ClientID,
ClientSecret: config.ClientSecret, ClientSecret: config.ClientSecret,
Endpoint: oauth2.Endpoint{ Endpoint: twitch.Endpoint,
AuthURL: "https://id.twitch.tv/oauth2/authorize",
TokenURL: "https://id.twitch.tv/oauth2/token",
},
Scopes: []string{ Scopes: []string{
"moderator:read:followers", "moderator:read:followers",
"user:read:chat", "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) { if !os.IsNotExist(err) {
log.Fatalf("Failed to open twitch-auth.json: %v", err) log.Fatalf("open %s: %v", AUTH_FILEPATH, err)
} }
} else { } else {
defer authFile.Close() defer authFile.Close()
srv.oauthToken = &oauth2.Token{} srv.oauthToken = &oauth2.Token{}
err = json.NewDecoder(authFile).Decode(srv.oauthToken) err = json.NewDecoder(authFile).Decode(srv.oauthToken)
if err != nil { 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) 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) { group.GET("/auth", func(ctx *gin.Context) {
code := ctx.Query("code") code := ctx.Query("code")
scope := ctx.Query("scope") scope := ctx.Query("scope")
resState := ctx.Query("state") resState := ctx.Query("state")
if len(code) == 0 || len(scope) == 0 || resState != srv.oauthState { if len(code) == 0 { ctx.String(http.StatusBadRequest, "code cannot be empty"); return }
ctx.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) if len(scope) == 0 { ctx.String(http.StatusBadRequest, "scope cannot be empty"); return }
return if resState != srv.oauthState { ctx.String(http.StatusBadRequest, "state mismatch"); return }
}
token, err := srv.oauthConfig.Exchange(ctx, code) token, err := srv.oauthConfig.Exchange(ctx, code)
if err != nil { if err != nil {
@ -219,9 +219,9 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
} }
srv.oauthToken = token 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 { 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)) ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return return
} }
@ -277,10 +277,8 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
} }
func (srv *Service) Run(ctx context.Context) { func (srv *Service) Run(ctx context.Context) {
if srv.oauthToken == nil { if srv.oauthToken == nil || !srv.oauthToken.Valid() {
srv.oauthState = rand.Text() log.Printf("Log in with Twitch: http://localhost:%d/twitch/login", srv.port)
authCodeURL := srv.oauthConfig.AuthCodeURL(srv.oauthState)
log.Printf("Log in with Twitch: %s", authCodeURL)
} else { } else {
srv.start(ctx) srv.start(ctx)
} }
@ -301,15 +299,13 @@ func (srv *Service) start(ctx context.Context) {
return return
} }
srv.channelID = userIDs[0] srv.channelID = userIDs[0]
log.Printf("Connecting to Twitch as %s...", srv.channelName)
interrupt := make(chan os.Signal, 1) interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt) 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) } if err != nil { panic(err) }
c, _, err := websocket.DefaultDialer.Dial(u.String(), http.Header{ c, _, err := websocket.DefaultDialer.Dial(u.String(), http.Header{
@ -339,7 +335,9 @@ func (srv *Service) start(ctx context.Context) {
return return
} }
srv.subscribeToDefaultEvents() srv.subscribeToDefaultEvents(ctx)
log.Printf("Connected to Twitch as %s.", srv.channelName)
} }
if message.Metadata.MessageType == string(api.NOTIFICATION) { if message.Metadata.MessageType == string(api.NOTIFICATION) {
@ -472,6 +470,7 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error {
} }
func (srv *Service) subscribeToEvent( func (srv *Service) subscribeToEvent(
ctx context.Context,
subscriptionType string, subscriptionType string,
version string, version string,
condition map[string]string, condition map[string]string,
@ -519,10 +518,10 @@ func (srv *Service) subscribeToEvent(
}) })
body := bytes.NewBuffer(bodyBytes) body := bytes.NewBuffer(bodyBytes)
client := http.DefaultClient client := srv.oauthConfig.Client(ctx, srv.oauthToken)
req, err := http.NewRequest( req, err := http.NewRequest(
"POST", "POST",
TWITCH_API_BASE + "/eventsub/subscriptions", api.BASE_URL + "/eventsub/subscriptions",
body, body,
) )
if err != nil { return err } if err != nil { return err }
@ -542,10 +541,10 @@ func (srv *Service) subscribeToEvent(
return nil return nil
} }
func (srv *Service) subscribeToDefaultEvents() { func (srv *Service) subscribeToDefaultEvents(ctx context.Context) {
// channel.follow // channel.follow
if err := srv.subscribeToEvent( if err := srv.subscribeToEvent(
"channel.follow", "2", ctx, "channel.follow", "2",
map[string]string{ map[string]string{
"broadcaster_user_id": srv.channelID, "broadcaster_user_id": srv.channelID,
"moderator_user_id": srv.channelID, "moderator_user_id": srv.channelID,
@ -557,7 +556,7 @@ func (srv *Service) subscribeToDefaultEvents() {
// channel.subscribe // channel.subscribe
if err := srv.subscribeToEvent( if err := srv.subscribeToEvent(
"channel.subscribe", "1", ctx, "channel.subscribe", "1",
map[string]string{ "broadcaster_user_id": srv.channelID }, map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID, srv.eventSubSession.ID,
); err != nil { ); err != nil {
@ -566,7 +565,7 @@ func (srv *Service) subscribeToDefaultEvents() {
// channel.cheer // channel.cheer
if err := srv.subscribeToEvent( if err := srv.subscribeToEvent(
"channel.cheer", "1", ctx, "channel.cheer", "1",
map[string]string{ "broadcaster_user_id": srv.channelID }, map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID, srv.eventSubSession.ID,
); err != nil { ); err != nil {
@ -575,7 +574,7 @@ func (srv *Service) subscribeToDefaultEvents() {
// channel.raid // channel.raid
if err := srv.subscribeToEvent( if err := srv.subscribeToEvent(
"channel.raid", "1", ctx, "channel.raid", "1",
map[string]string{ "to_broadcaster_user_id": srv.channelID }, map[string]string{ "to_broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID, srv.eventSubSession.ID,
); err != nil { ); err != nil {
@ -584,7 +583,7 @@ func (srv *Service) subscribeToDefaultEvents() {
// channel.channel_points_custom_reward_redemption.add // channel.channel_points_custom_reward_redemption.add
if err := srv.subscribeToEvent( 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 }, map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID, srv.eventSubSession.ID,
); err != nil { ); err != nil {
@ -593,7 +592,7 @@ func (srv *Service) subscribeToDefaultEvents() {
// channel.shoutout.create // channel.shoutout.create
if err := srv.subscribeToEvent( if err := srv.subscribeToEvent(
"channel.shoutout.create", "1", ctx, "channel.shoutout.create", "1",
map[string]string{ map[string]string{
"broadcaster_user_id": srv.channelID, "broadcaster_user_id": srv.channelID,
"moderator_user_id": srv.channelID, "moderator_user_id": srv.channelID,
@ -605,7 +604,7 @@ func (srv *Service) subscribeToDefaultEvents() {
// channel.chat.message // channel.chat.message
if err := srv.subscribeToEvent( if err := srv.subscribeToEvent(
"channel.chat.message", "1", ctx, "channel.chat.message", "1",
map[string]string{ map[string]string{
"broadcaster_user_id": srv.channelID, "broadcaster_user_id": srv.channelID,
"user_id": srv.channelID, "user_id": srv.channelID,
@ -617,7 +616,7 @@ func (srv *Service) subscribeToDefaultEvents() {
// channel.chat.message_delete // channel.chat.message_delete
if err := srv.subscribeToEvent( if err := srv.subscribeToEvent(
"channel.chat.message_delete", "1", ctx, "channel.chat.message_delete", "1",
map[string]string{ map[string]string{
"broadcaster_user_id": srv.channelID, "broadcaster_user_id": srv.channelID,
"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 } if len(usernames) == 0 { return []string{}, nil }
url := strings.Builder{} url := strings.Builder{}
url.WriteString(TWITCH_API_BASE) url.WriteString(api.BASE_URL)
url.WriteString("/users?login=") url.WriteString("/users?login=")
url.WriteString(usernames[0]) url.WriteString(usernames[0])
for _, username := range usernames { for _, username := range usernames {