improve twitch oauth resilience
This commit is contained in:
parent
accf60ed74
commit
a24fbfc96e
2 changed files with 47 additions and 45 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue