mirror of
https://github.com/xaionaro-go/streamctl.git
synced 2025-10-31 02:46:27 +08:00
921 lines
23 KiB
Go
921 lines
23 KiB
Go
package twitch
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/facebookincubator/go-belt/tool/experimental/errmon"
|
|
"github.com/facebookincubator/go-belt/tool/logger"
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/nicklaw5/helix/v2"
|
|
"github.com/xaionaro-go/streamctl/pkg/buildvars"
|
|
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
|
|
"github.com/xaionaro-go/streamctl/pkg/observability"
|
|
"github.com/xaionaro-go/streamctl/pkg/secret"
|
|
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
|
|
"github.com/xaionaro-go/streamctl/pkg/xsync"
|
|
)
|
|
|
|
type Twitch struct {
|
|
closeCtx context.Context
|
|
closeFn context.CancelFunc
|
|
chatHandler *ChatHandler
|
|
client *helix.Client
|
|
config Config
|
|
broadcasterID string
|
|
lazyInitOnce sync.Once
|
|
saveCfgFn func(Config) error
|
|
tokenLocker xsync.Mutex
|
|
prepareLocker xsync.Mutex
|
|
clientID string
|
|
clientSecret secret.String
|
|
}
|
|
|
|
const twitchDebug = false
|
|
|
|
var _ streamcontrol.StreamController[StreamProfile] = (*Twitch)(nil)
|
|
|
|
func New(
|
|
ctx context.Context,
|
|
cfg Config,
|
|
saveCfgFn func(Config) error,
|
|
) (*Twitch, error) {
|
|
if cfg.Config.Channel == "" {
|
|
return nil, fmt.Errorf("'channel' is not set")
|
|
}
|
|
clientID := valueOrDefault(cfg.Config.ClientID, buildvars.TwitchClientID)
|
|
clientSecret := valueOrDefault(cfg.Config.ClientSecret.Get(), buildvars.TwitchClientID)
|
|
if clientID == "" || clientSecret == "" {
|
|
return nil, fmt.Errorf(
|
|
"'clientid' or/and 'clientsecret' is/are not set; go to https://dev.twitch.tv/console/apps/create and create an app if it not created, yet",
|
|
)
|
|
}
|
|
|
|
getPortsFn := cfg.Config.GetOAuthListenPorts
|
|
if getPortsFn == nil {
|
|
// TODO: find a way to adjust the OAuth ports dynamically without re-creating the Twitch client.
|
|
return nil, fmt.Errorf("the function GetOAuthListenPorts is not set")
|
|
}
|
|
var oauthPorts []uint16
|
|
for {
|
|
oauthPorts = getPortsFn()
|
|
if len(oauthPorts) != 0 {
|
|
break
|
|
}
|
|
logger.Debugf(ctx, "waiting for somebody to provide an OAuthListenerPort")
|
|
time.Sleep(time.Second)
|
|
}
|
|
if len(oauthPorts) == 0 {
|
|
return nil, fmt.Errorf("the function GetOAuthListenPorts returned zero ports")
|
|
}
|
|
|
|
ctx, closeFn := context.WithCancel(ctx)
|
|
t := &Twitch{
|
|
closeCtx: ctx,
|
|
closeFn: closeFn,
|
|
config: cfg,
|
|
saveCfgFn: saveCfgFn,
|
|
clientID: clientID,
|
|
clientSecret: secret.New(clientSecret),
|
|
}
|
|
|
|
h, err := NewChatHandler(ctx, cfg.Config.Channel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to initialize a chat handler for channel '%s': %w", cfg.Config.Channel, err)
|
|
}
|
|
t.chatHandler = h
|
|
|
|
client, err := t.getClient(ctx, oauthPorts[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.client = client
|
|
var prevTokenUpdate time.Time
|
|
client.OnUserAccessTokenRefreshed(func(newAccessToken, newRefreshToken string) {
|
|
if twitchDebug == true {
|
|
logger.Debugf(ctx, "got new tokens, verifying")
|
|
_, err := client.GetChannelInformation(&helix.GetChannelInformationParams{
|
|
BroadcasterIDs: []string{
|
|
cfg.Config.Channel,
|
|
},
|
|
})
|
|
if err != nil {
|
|
logger.Errorf(ctx, "the token is apparently invalid: %w", err)
|
|
}
|
|
}
|
|
logger.Debugf(ctx, "saving the new tokens")
|
|
cfg.Config.UserAccessToken.Set(newAccessToken)
|
|
cfg.Config.RefreshToken.Set(newRefreshToken)
|
|
err = saveCfgFn(cfg)
|
|
errmon.ObserveErrorCtx(ctx, err)
|
|
now := time.Now()
|
|
if now.Sub(prevTokenUpdate) < time.Second*30 {
|
|
logger.Errorf(
|
|
ctx,
|
|
"updating the token too often, most likely it won't help, so asking to re-authenticate",
|
|
)
|
|
t.prepareLocker.Do(ctx, func() {
|
|
t.client.SetAppAccessToken("")
|
|
t.client.SetUserAccessToken("")
|
|
t.client.SetRefreshToken("")
|
|
prevTokenUpdate = time.Time{}
|
|
err := t.getNewToken(ctx)
|
|
errmon.ObserveErrorCtx(ctx, err)
|
|
})
|
|
return
|
|
}
|
|
prevTokenUpdate = now
|
|
})
|
|
logger.Debugf(ctx, "initialized a client")
|
|
return t, nil
|
|
}
|
|
|
|
func getUserID(
|
|
_ context.Context,
|
|
client *helix.Client,
|
|
login string,
|
|
) (string, error) {
|
|
resp, err := client.GetUsers(&helix.UsersParams{
|
|
Logins: []string{login},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("unable to query user info: %w", err)
|
|
}
|
|
if len(resp.Data.Users) != 1 {
|
|
return "", fmt.Errorf(
|
|
"expected 1 user with login, but received %d users",
|
|
len(resp.Data.Users),
|
|
)
|
|
}
|
|
return resp.Data.Users[0].ID, nil
|
|
}
|
|
|
|
func (t *Twitch) prepare(ctx context.Context) error {
|
|
logger.Tracef(ctx, "prepare")
|
|
defer logger.Tracef(ctx, "/prepare")
|
|
return xsync.DoA1R1(ctx, &t.prepareLocker, t.prepareNoLock, ctx)
|
|
}
|
|
|
|
func (t *Twitch) prepareNoLock(ctx context.Context) error {
|
|
err := t.getTokenIfNeeded(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
t.lazyInitOnce.Do(func() {
|
|
if t.broadcasterID != "" {
|
|
return
|
|
}
|
|
t.broadcasterID, err = getUserID(ctx, t.client, t.config.Config.Channel)
|
|
if err != nil {
|
|
return
|
|
}
|
|
logger.Debugf(
|
|
ctx,
|
|
"broadcaster_id: %s (login: %s)",
|
|
t.broadcasterID,
|
|
t.config.Config.Channel,
|
|
)
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (t *Twitch) Close() error {
|
|
t.closeFn()
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) editChannelInfo(
|
|
ctx context.Context,
|
|
params *helix.EditChannelInformationParams,
|
|
) error {
|
|
logger.Debugf(ctx, "editChannelInfo(ctx, %#+v)", params)
|
|
defer logger.Debugf(ctx, "/editChannelInfo(ctx, %#+v)", params)
|
|
|
|
if params == nil {
|
|
return fmt.Errorf("params == nil")
|
|
}
|
|
params.BroadcasterID = t.broadcasterID
|
|
resp, err := t.client.EditChannelInformation(params)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to update the channel info (%#+v): %w", *params, err)
|
|
}
|
|
if resp.ErrorStatus != 0 {
|
|
return fmt.Errorf(
|
|
"unable to update the channel info (%#+v), the response reported an error: %d %v: %v",
|
|
*params,
|
|
resp.ErrorStatus,
|
|
resp.Error,
|
|
resp.ErrorMessage,
|
|
)
|
|
}
|
|
logger.Debugf(ctx, "success")
|
|
return nil
|
|
}
|
|
|
|
type SaveProfileHandler interface {
|
|
SaveProfile(context.Context, StreamProfile) error
|
|
}
|
|
|
|
func removeNonAlphanumeric(input string) string {
|
|
var builder strings.Builder
|
|
for _, r := range input {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
builder.WriteRune(r)
|
|
}
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
// truncateStringByByteLength
|
|
func truncateStringByByteLength(input string, byteLength int) string {
|
|
byteSlice := []byte(input)
|
|
|
|
if len(byteSlice) <= byteLength {
|
|
return input
|
|
}
|
|
|
|
truncationPoint := byteLength
|
|
for !utf8.Valid(byteSlice[:truncationPoint]) {
|
|
truncationPoint--
|
|
}
|
|
|
|
return string(byteSlice[:truncationPoint])
|
|
}
|
|
|
|
func (t *Twitch) ApplyProfile(
|
|
ctx context.Context,
|
|
profile StreamProfile,
|
|
customArgs ...any,
|
|
) error {
|
|
logger.Debugf(ctx, "ApplyProfile")
|
|
defer logger.Debugf(ctx, "/ApplyProfile")
|
|
|
|
t.prepare(ctx)
|
|
|
|
if profile.CategoryName != nil {
|
|
if profile.CategoryID != nil {
|
|
logger.Warnf(
|
|
ctx,
|
|
"both category name and ID are set; these are contradicting stream profile settings; prioritizing the name",
|
|
)
|
|
}
|
|
categoryID, err := t.getCategoryID(ctx, *profile.CategoryName)
|
|
if err == nil {
|
|
profile.CategoryID = &categoryID
|
|
profile.CategoryName = nil
|
|
saveProfile(ctx, profile, customArgs...)
|
|
} else {
|
|
logger.Errorf(ctx, "unable to get the category ID: %v", err)
|
|
}
|
|
}
|
|
|
|
tags := make([]string, 0, len(profile.Tags))
|
|
for _, tag := range profile.Tags {
|
|
tag = removeNonAlphanumeric(tag)
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
tag = truncateStringByByteLength(
|
|
tag,
|
|
25,
|
|
) // see also: https://github.com/twitchdev/issues/issues/789
|
|
tags = append(tags, tag)
|
|
}
|
|
|
|
params := &helix.EditChannelInformationParams{}
|
|
|
|
hasParams := false
|
|
if tags != nil {
|
|
logger.Debugf(ctx, "has tags")
|
|
if len(tags) == 0 {
|
|
logger.Warnf(
|
|
ctx,
|
|
"unfortunately, there is a bug in the helix lib, which does not allow to set zero tags, so adding tag 'stream' to the list of tags as a placeholder",
|
|
)
|
|
params.Tags = []string{"English"}
|
|
} else {
|
|
params.Tags = tags
|
|
}
|
|
hasParams = true
|
|
}
|
|
if profile.Language != nil {
|
|
logger.Debugf(ctx, "has language")
|
|
params.BroadcasterLanguage = *profile.Language
|
|
hasParams = true
|
|
}
|
|
if profile.CategoryID != nil {
|
|
logger.Debugf(ctx, "has CategoryID")
|
|
params.GameID = *profile.CategoryID
|
|
hasParams = true
|
|
}
|
|
if !hasParams {
|
|
logger.Debugf(ctx, "no parameters, so skipping")
|
|
return nil
|
|
}
|
|
return t.editChannelInfo(ctx, params)
|
|
}
|
|
|
|
func saveProfile(ctx context.Context, profile StreamProfile, customArgs ...any) {
|
|
for _, arg := range customArgs {
|
|
saver, ok := arg.(SaveProfileHandler)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := saver.SaveProfile(ctx, profile); err != nil {
|
|
logger.Errorf(ctx, "unable to save profile: %v: %#+v", err, profile)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Twitch) getCategoryID(
|
|
ctx context.Context,
|
|
categoryName string,
|
|
) (string, error) {
|
|
logger.Debugf(ctx, "getCategoryID")
|
|
defer logger.Debugf(ctx, "/getCategoryID")
|
|
|
|
resp, err := t.client.GetGames(&helix.GamesParams{
|
|
Names: []string{categoryName},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"unable to query the category info (of name '%s'): %w",
|
|
categoryName,
|
|
err,
|
|
)
|
|
}
|
|
|
|
if len(resp.Data.Games) != 1 {
|
|
return "", fmt.Errorf("expected exactly 1 result, but received %d", len(resp.Data.Games))
|
|
}
|
|
categoryInfo := resp.Data.Games[0]
|
|
|
|
return categoryInfo.ID, nil
|
|
}
|
|
|
|
func (t *Twitch) SetTitle(
|
|
ctx context.Context,
|
|
title string,
|
|
) error {
|
|
t.prepare(ctx)
|
|
return t.editChannelInfo(ctx, &helix.EditChannelInformationParams{
|
|
Title: title,
|
|
})
|
|
}
|
|
|
|
func (t *Twitch) SetDescription(
|
|
ctx context.Context,
|
|
description string,
|
|
) error {
|
|
// Twitch streams has no description:
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) InsertAdsCuePoint(
|
|
ctx context.Context,
|
|
ts time.Time,
|
|
duration time.Duration,
|
|
) error {
|
|
// Unfortunately, we do not support sending ads cues.
|
|
// So nothing to do here:
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) Flush(
|
|
ctx context.Context,
|
|
) error {
|
|
// Unfortunately, we do not support sending accumulated changes, and we change things immediately right away.
|
|
// So nothing to do here:
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) StartStream(
|
|
ctx context.Context,
|
|
title string,
|
|
description string,
|
|
profile StreamProfile,
|
|
customArgs ...any,
|
|
) (_err error) {
|
|
logger.Debugf(ctx, "StartStream")
|
|
defer func() { logger.Debugf(ctx, "/StartStream: %v", _err) }()
|
|
|
|
t.prepare(ctx)
|
|
var result error
|
|
if err := t.SetTitle(ctx, title); err != nil {
|
|
result = multierror.Append(result, fmt.Errorf("unable to set title: %w", err))
|
|
}
|
|
if err := t.SetDescription(ctx, description); err != nil {
|
|
result = multierror.Append(result, fmt.Errorf("unable to set description: %w", err))
|
|
}
|
|
if err := t.ApplyProfile(ctx, profile, customArgs...); err != nil {
|
|
result = multierror.Append(
|
|
result,
|
|
fmt.Errorf("unable to apply the stream-specific profile: %w", err),
|
|
)
|
|
}
|
|
return multierror.Append(result).ErrorOrNil()
|
|
}
|
|
|
|
func (t *Twitch) EndStream(
|
|
ctx context.Context,
|
|
) error {
|
|
// Twitch ends a stream automatically, nothing to do:
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) GetStreamStatus(
|
|
ctx context.Context,
|
|
) (*streamcontrol.StreamStatus, error) {
|
|
logger.Debugf(ctx, "GetStreamStatus")
|
|
defer logger.Debugf(ctx, "/GetStreamStatus")
|
|
|
|
t.prepare(ctx)
|
|
// Twitch ends a stream automatically, nothing to do:
|
|
reply, err := t.client.GetStreams(&helix.StreamsParams{
|
|
UserIDs: []string{t.broadcasterID},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to request streams: %w", err)
|
|
}
|
|
|
|
if len(reply.Data.Streams) == 0 {
|
|
return &streamcontrol.StreamStatus{
|
|
IsActive: false,
|
|
}, nil
|
|
}
|
|
|
|
if len(reply.Data.Streams) > 1 {
|
|
return nil, fmt.Errorf("received %d streams instead of 1", len(reply.Data.Streams))
|
|
}
|
|
stream := reply.Data.Streams[0]
|
|
|
|
return &streamcontrol.StreamStatus{
|
|
IsActive: true,
|
|
StartedAt: &stream.StartedAt,
|
|
ViewersCount: ptr(uint(stream.ViewerCount)),
|
|
}, nil
|
|
}
|
|
|
|
func (t *Twitch) getTokenIfNeeded(
|
|
ctx context.Context,
|
|
) error {
|
|
switch t.config.Config.AuthType {
|
|
case "user":
|
|
t.tokenLocker.Do(ctx, func() {
|
|
if t.client.GetUserAccessToken() == "" {
|
|
t.client.SetUserAccessToken(t.config.Config.UserAccessToken.Get())
|
|
}
|
|
if t.client.GetRefreshToken() == "" {
|
|
t.client.SetRefreshToken(t.config.Config.RefreshToken.Get())
|
|
}
|
|
})
|
|
if t.client.GetUserAccessToken() != "" {
|
|
return nil
|
|
}
|
|
case "app":
|
|
t.tokenLocker.Do(ctx, func() {
|
|
t.client.SetAppAccessToken(t.config.Config.AppAccessToken.Get())
|
|
})
|
|
if t.client.GetAppAccessToken() != "" {
|
|
logger.Debugf(ctx, "already have an app access token")
|
|
return nil
|
|
}
|
|
logger.Debugf(ctx, "do not have an app access token")
|
|
}
|
|
|
|
logger.Infof(ctx, "getting a new token")
|
|
return t.getNewToken(ctx)
|
|
}
|
|
|
|
func (t *Twitch) getNewToken(
|
|
ctx context.Context,
|
|
) error {
|
|
logger.Debugf(ctx, "getNewToken")
|
|
defer logger.Debugf(ctx, "/getNewToken")
|
|
|
|
return xsync.DoR1(ctx, &t.tokenLocker, func() error {
|
|
switch t.config.Config.AuthType {
|
|
case "user":
|
|
err := t.getNewTokenByUser(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("getting user-token error: %w", err)
|
|
}
|
|
return nil
|
|
case "app":
|
|
err := t.getNewTokenByApp(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("getting app-token error: %w", err)
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("invalid AuthType: <%s>", t.config.Config.AuthType)
|
|
}
|
|
})
|
|
}
|
|
|
|
func authRedirectURI(listenPort uint16) string {
|
|
return fmt.Sprintf("http://localhost:%d/", listenPort)
|
|
}
|
|
|
|
func (t *Twitch) getNewClientCode(
|
|
ctx context.Context,
|
|
) (_err error) {
|
|
logger.Debugf(ctx, "getNewClientCode")
|
|
defer func() { logger.Debugf(ctx, "/getNewClientCode: %v", _err) }()
|
|
|
|
oauthHandler := t.config.Config.CustomOAuthHandler
|
|
if oauthHandler == nil {
|
|
oauthHandler = oauthhandler.OAuth2HandlerViaCLI
|
|
}
|
|
|
|
ctx, ctxCancelFunc := context.WithCancel(ctx)
|
|
cancelFunc := func() {
|
|
logger.Debugf(ctx, "cancelling the context")
|
|
ctxCancelFunc()
|
|
}
|
|
|
|
var errWg sync.WaitGroup
|
|
var resultErr error
|
|
errCh := make(chan error)
|
|
errWg.Add(1)
|
|
observability.Go(ctx, func() {
|
|
errWg.Done()
|
|
for err := range errCh {
|
|
errmon.ObserveErrorCtx(ctx, err)
|
|
resultErr = multierror.Append(resultErr, err)
|
|
}
|
|
})
|
|
|
|
alreadyListening := map[uint16]struct{}{}
|
|
var wg sync.WaitGroup
|
|
success := false
|
|
|
|
startHandlerForPort := func(listenPort uint16) {
|
|
if _, ok := alreadyListening[listenPort]; ok {
|
|
return
|
|
}
|
|
alreadyListening[listenPort] = struct{}{}
|
|
|
|
logger.Debugf(ctx, "starting the oauth handler at port %d", listenPort)
|
|
wg.Add(1)
|
|
{
|
|
listenPort := listenPort
|
|
observability.Go(ctx, func() {
|
|
defer logger.Debugf(ctx, "ended the oauth handler at port %d", listenPort)
|
|
defer wg.Done()
|
|
authURL := GetAuthorizationURL(
|
|
&helix.AuthorizationURLParams{
|
|
ResponseType: "code", // or "token"
|
|
Scopes: []string{
|
|
"channel:manage:broadcast",
|
|
"moderator:manage:chat_messages",
|
|
"moderator:manage:banned_users",
|
|
},
|
|
},
|
|
t.clientID,
|
|
authRedirectURI(listenPort),
|
|
)
|
|
|
|
arg := oauthhandler.OAuthHandlerArgument{
|
|
AuthURL: authURL,
|
|
ListenPort: listenPort,
|
|
ExchangeFn: func(code string) (_err error) {
|
|
logger.Debugf(ctx, "ExchangeFn()")
|
|
defer func() { logger.Debugf(ctx, "/ExchangeFn(): %v", _err) }()
|
|
if code == "" {
|
|
return fmt.Errorf("code is empty")
|
|
}
|
|
t.config.Config.ClientCode.Set(code)
|
|
err := t.saveCfgFn(t.config)
|
|
errmon.ObserveErrorCtx(ctx, err)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
err := oauthHandler(ctx, arg)
|
|
if err != nil {
|
|
errCh <- fmt.Errorf("unable to get or exchange the oauth code to a token: %w", err)
|
|
return
|
|
}
|
|
cancelFunc()
|
|
success = true
|
|
})
|
|
}
|
|
}
|
|
|
|
// TODO: either support only one port as in New, or support multiple
|
|
// ports as we do below
|
|
getPortsFn := t.config.Config.GetOAuthListenPorts
|
|
if getPortsFn == nil {
|
|
return fmt.Errorf("the function GetOAuthListenPorts is not set")
|
|
}
|
|
|
|
for _, listenPort := range getPortsFn() {
|
|
startHandlerForPort(listenPort)
|
|
}
|
|
|
|
wg.Add(1)
|
|
observability.Go(ctx, func() {
|
|
defer wg.Done()
|
|
t := time.NewTicker(time.Second)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
ports := getPortsFn()
|
|
logger.Tracef(ctx, "oauth listener ports: %#+v", ports)
|
|
|
|
for _, listenPort := range ports {
|
|
startHandlerForPort(listenPort)
|
|
}
|
|
}
|
|
})
|
|
|
|
observability.Go(ctx, func() {
|
|
wg.Wait()
|
|
close(errCh)
|
|
})
|
|
<-ctx.Done()
|
|
logger.Debugf(ctx, "did successfully took a new client code? -- %v", success)
|
|
if !success {
|
|
errWg.Wait()
|
|
return resultErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) getNewTokenByUser(
|
|
ctx context.Context,
|
|
) error {
|
|
logger.Debugf(ctx, "getNewTokenByUser")
|
|
defer logger.Debugf(ctx, "/getNewTokenByUser")
|
|
|
|
if t.config.Config.ClientCode.Get() == "" {
|
|
err := t.getNewClientCode(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to get client code: %w", err)
|
|
}
|
|
}
|
|
|
|
if t.config.Config.ClientCode.Get() == "" {
|
|
return fmt.Errorf("internal error: ClientCode is empty")
|
|
}
|
|
|
|
logger.Debugf(ctx, "requesting user access token...")
|
|
resp, err := t.client.RequestUserAccessToken(t.config.Config.ClientCode.Get())
|
|
logger.Debugf(ctx, "requesting user access token result: %#+v %v", resp, err)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to get user access token: %w", err)
|
|
}
|
|
if resp.ErrorStatus != 0 {
|
|
return fmt.Errorf(
|
|
"unable to query: %d %v: %v",
|
|
resp.ErrorStatus,
|
|
resp.Error,
|
|
resp.ErrorMessage,
|
|
)
|
|
}
|
|
t.client.SetUserAccessToken(resp.Data.AccessToken)
|
|
t.client.SetRefreshToken(resp.Data.RefreshToken)
|
|
t.config.Config.ClientCode.Set("")
|
|
t.config.Config.UserAccessToken.Set(resp.Data.AccessToken)
|
|
t.config.Config.RefreshToken.Set(resp.Data.RefreshToken)
|
|
err = t.saveCfgFn(t.config)
|
|
errmon.ObserveErrorCtx(ctx, err)
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) getNewTokenByApp(
|
|
ctx context.Context,
|
|
) error {
|
|
logger.Debugf(ctx, "getNewTokenByApp")
|
|
defer logger.Debugf(ctx, "/getNewTokenByApp")
|
|
|
|
resp, err := t.client.RequestAppAccessToken(nil)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to get app access token: %w", err)
|
|
}
|
|
if resp.ErrorStatus != 0 {
|
|
return fmt.Errorf(
|
|
"unable to get app access token (the response contains an error): %d %v: %v",
|
|
resp.ErrorStatus,
|
|
resp.Error,
|
|
resp.ErrorMessage,
|
|
)
|
|
}
|
|
logger.Debugf(ctx, "setting the app access token")
|
|
t.client.SetAppAccessToken(resp.Data.AccessToken)
|
|
t.config.Config.AppAccessToken.Set(resp.Data.AccessToken)
|
|
err = t.saveCfgFn(t.config)
|
|
errmon.ObserveErrorCtx(ctx, err)
|
|
return nil
|
|
}
|
|
|
|
func (t *Twitch) getClient(
|
|
ctx context.Context,
|
|
oauthListenPort uint16,
|
|
) (*helix.Client, error) {
|
|
logger.Debugf(ctx, "getClient(ctx, %#+v, %v)", t.config, oauthListenPort)
|
|
defer logger.Debugf(ctx, "/getClient")
|
|
|
|
options := &helix.Options{
|
|
ClientID: t.clientID,
|
|
ClientSecret: t.clientSecret.Get(),
|
|
RedirectURI: authRedirectURI(oauthListenPort), // TODO: delete this hardcode
|
|
}
|
|
client, err := helix.NewClient(options)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create a helix client object: %w", err)
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func GetAuthorizationURL(
|
|
params *helix.AuthorizationURLParams,
|
|
clientID string,
|
|
redirectURI string,
|
|
) string {
|
|
url := helix.AuthBaseURL + "/authorize"
|
|
url += "?response_type=" + params.ResponseType
|
|
url += "&client_id=" + clientID
|
|
url += "&redirect_uri=" + redirectURI
|
|
|
|
if params.State != "" {
|
|
url += "&state=" + params.State
|
|
}
|
|
|
|
if params.ForceVerify {
|
|
url += "&force_verify=true"
|
|
}
|
|
|
|
if len(params.Scopes) != 0 {
|
|
url += "&scope=" + strings.Join(params.Scopes, "%20")
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
func (t *Twitch) GetAllCategories(
|
|
ctx context.Context,
|
|
) ([]helix.Game, error) {
|
|
logger.Debugf(ctx, "GetAllCategories")
|
|
defer logger.Debugf(ctx, "/GetAllCategories")
|
|
|
|
t.prepare(ctx)
|
|
categoriesMap := map[string]helix.Game{}
|
|
var pagination *helix.Pagination
|
|
for {
|
|
params := &helix.TopGamesParams{
|
|
First: 100,
|
|
}
|
|
if pagination != nil {
|
|
params.After = pagination.Cursor
|
|
}
|
|
|
|
logger.FromCtx(ctx).Tracef("requesting top games with params: %#+v", *params)
|
|
resp, err := t.client.GetTopGames(params)
|
|
logger.FromCtx(ctx).Tracef("requesting top games result: e:%v; resp:%#+v", err, resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to get the list of games: %w", err)
|
|
}
|
|
|
|
if len(resp.Data.Games) == 0 {
|
|
break
|
|
}
|
|
|
|
newCategoriesCount := 0
|
|
for _, g := range resp.Data.Games {
|
|
if oldG, ok := categoriesMap[g.ID]; ok {
|
|
logger.Tracef(ctx, "got a duplicate game: %#+v == %#+v", g, oldG)
|
|
continue
|
|
}
|
|
categoriesMap[g.ID] = g
|
|
newCategoriesCount++
|
|
}
|
|
|
|
if newCategoriesCount == 0 {
|
|
break
|
|
}
|
|
|
|
pagination = &resp.Data.Pagination
|
|
logger.FromCtx(ctx).
|
|
Tracef("I have %d categories now; new categories: %d", len(categoriesMap), newCategoriesCount)
|
|
}
|
|
logger.FromCtx(ctx).Tracef("%d categories in total")
|
|
|
|
allCategories := make([]helix.Game, 0, len(categoriesMap))
|
|
for _, c := range categoriesMap {
|
|
allCategories = append(allCategories, c)
|
|
}
|
|
|
|
return allCategories, nil
|
|
}
|
|
|
|
func (t *Twitch) GetChatMessagesChan(
|
|
ctx context.Context,
|
|
) (<-chan streamcontrol.ChatMessage, error) {
|
|
logger.Debugf(ctx, "GetChatMessagesChan")
|
|
defer logger.Debugf(ctx, "/GetChatMessagesChan")
|
|
|
|
outCh := make(chan streamcontrol.ChatMessage)
|
|
observability.Go(ctx, func() {
|
|
defer func() {
|
|
logger.Debugf(ctx, "closing the messages channel")
|
|
close(outCh)
|
|
}()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case ev, ok := <-t.chatHandler.MessagesChan():
|
|
if !ok {
|
|
logger.Debugf(ctx, "the input channel is closed")
|
|
return
|
|
}
|
|
outCh <- ev
|
|
}
|
|
}
|
|
})
|
|
|
|
return outCh, nil
|
|
}
|
|
|
|
func (t *Twitch) SendChatMessage(ctx context.Context, message string) (_ret error) {
|
|
logger.Debugf(ctx, "SendChatMessage(ctx, '%s')", message)
|
|
defer func() { logger.Debugf(ctx, "/SendChatMessage(ctx, '%s'): %v", message, _ret) }()
|
|
|
|
t.prepare(ctx)
|
|
|
|
_, err := t.client.SendChatMessage(&helix.SendChatMessageParams{
|
|
BroadcasterID: t.broadcasterID,
|
|
SenderID: t.broadcasterID,
|
|
Message: message,
|
|
})
|
|
return err
|
|
}
|
|
func (t *Twitch) RemoveChatMessage(
|
|
ctx context.Context,
|
|
messageID streamcontrol.ChatMessageID,
|
|
) (_ret error) {
|
|
logger.Debugf(ctx, "RemoveChatMessage(ctx, '%s')", messageID)
|
|
defer func() { logger.Debugf(ctx, "/RemoveChatMessage(ctx, '%s'): %v", messageID, _ret) }()
|
|
|
|
t.prepare(ctx)
|
|
|
|
_, err := t.client.DeleteChatMessage(&helix.DeleteChatMessageParams{
|
|
BroadcasterID: t.broadcasterID,
|
|
ModeratorID: t.broadcasterID,
|
|
MessageID: string(messageID),
|
|
})
|
|
return err
|
|
}
|
|
func (t *Twitch) BanUser(
|
|
ctx context.Context,
|
|
userID streamcontrol.ChatUserID,
|
|
reason string,
|
|
deadline time.Time,
|
|
) (_err error) {
|
|
logger.Debugf(ctx, "BanUser(ctx, '%s', '%s', %v)", userID, reason, deadline)
|
|
defer func() { logger.Debugf(ctx, "/BanUser(ctx, '%s', '%s', %v): %v", userID, reason, deadline, _err) }()
|
|
|
|
t.prepare(ctx)
|
|
|
|
duration := 0
|
|
if !deadline.IsZero() {
|
|
duration = int(time.Until(deadline).Seconds())
|
|
}
|
|
_, err := t.client.BanUser(&helix.BanUserParams{
|
|
BroadcasterID: t.broadcasterID,
|
|
ModeratorId: t.broadcasterID,
|
|
Body: helix.BanUserRequestBody{
|
|
Duration: duration,
|
|
Reason: reason,
|
|
UserId: string(userID),
|
|
},
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (t *Twitch) IsCapable(
|
|
ctx context.Context,
|
|
cap streamcontrol.Capability,
|
|
) bool {
|
|
switch cap {
|
|
case streamcontrol.CapabilitySendChatMessage:
|
|
return true
|
|
case streamcontrol.CapabilityDeleteChatMessage:
|
|
return true
|
|
case streamcontrol.CapabilityBanUser:
|
|
return true
|
|
}
|
|
return false
|
|
}
|