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
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"

View file

@ -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 {