Files
streamctl/pkg/streamcontrol/kick/kick.go
Dmitrii Okunev eb84df6561
Some checks failed
rolling-release / build (push) Has been cancelled
rolling-release / rolling-release (push) Has been cancelled
Revert back the secret package
2025-10-05 16:42:13 +01:00

1014 lines
28 KiB
Go

package kick
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/facebookincubator/go-belt"
"github.com/facebookincubator/go-belt/tool/logger"
"github.com/google/uuid"
"github.com/scorfly/gokick"
"github.com/xaionaro-go/kickcom"
"github.com/xaionaro-go/observability"
"github.com/xaionaro-go/streamctl/pkg/oauthhandler"
"github.com/xaionaro-go/streamctl/pkg/secret"
"github.com/xaionaro-go/streamctl/pkg/streamcontrol"
"github.com/xaionaro-go/xsync"
)
const (
debugUseMockClient = false
)
type ReverseEngClient interface {
ChatClientOBSOLETE
}
type Kick struct {
CloseCtx context.Context
CloseFn context.CancelFunc
Channel *kickcom.ChannelV1
Client *Client
ClientOBSOLETE *kickcom.Kick
ChatHandler *ChatHandlerOBSOLETE
ChatHandlerLocker xsync.CtxLocker
CurrentConfig Config
CurrentConfigLocker xsync.Mutex
SaveCfgFn func(Config) error
PrepareLocker xsync.Mutex
lazyInitOnce sync.Once
getAccessTokenLocker xsync.Mutex
}
var _ streamcontrol.StreamController[StreamProfile] = (*Kick)(nil)
func New(
ctx context.Context,
cfg Config,
saveCfgFn func(Config) error,
) (*Kick, error) {
ctx = belt.WithField(ctx, "controller", ID)
if cfg.Config.Channel == "" {
return nil, fmt.Errorf("channel is not set")
}
clientOld, err := kickcom.New()
if err != nil {
return nil, fmt.Errorf("unable to initialize the old client: %w", err)
}
client, err := getClient(cfg.Config)
if err != nil {
return nil, fmt.Errorf("unable to initialize the client: %w", err)
}
ctx, closeFn := context.WithCancel(ctx)
k := &Kick{
CloseCtx: ctx,
CloseFn: closeFn,
ChatHandlerLocker: make(xsync.CtxLocker, 1),
CurrentConfig: cfg,
ClientOBSOLETE: clientOld,
SaveCfgFn: saveCfgFn,
}
k.SetClient(client)
client.OnUserAccessTokenRefreshed(k.onUserAccessTokenRefreshed)
observability.Go(ctx, func(ctx context.Context) {
k.keepAliveLoop(ctx)
})
return k, nil
}
func getClient(
cfg PlatformSpecificConfig,
) (Client, error) {
if debugUseMockClient {
return newClientMock(), nil
}
client, err := newClient(&gokick.ClientOptions{
UserAccessToken: cfg.UserAccessToken.Get(),
UserRefreshToken: cfg.RefreshToken.Get(),
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret.Get(),
})
if err != nil {
return nil, err
}
return client, nil
}
func (k *Kick) onUserAccessTokenRefreshed(
userAccessToken string,
refreshToken string,
) {
ctx := context.TODO()
logger.Debugf(ctx, "onUserAccessTokenRefreshed")
defer logger.Debugf(ctx, "/onUserAccessTokenRefreshed")
k.CurrentConfigLocker.Do(ctx, func() {
logger.Infof(ctx, "UserAccessToken had been refreshed")
k.CurrentConfig.Config.UserAccessToken.Set(userAccessToken)
k.CurrentConfig.Config.RefreshToken.Set(refreshToken)
err := k.SaveCfgFn(k.CurrentConfig)
if err != nil {
logger.Errorf(ctx, "unable to save the config: %v", err)
}
})
}
func (k *Kick) keepAliveLoop(
ctx context.Context,
) {
logger.Debugf(ctx, "keepAliveLoop")
defer func() { logger.Debugf(ctx, "/keepAliveLoop") }()
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
if k.Channel == nil { // TODO: fix non-atomicity
logger.Warnf(ctx, "channel info is not set, yet")
time.Sleep(time.Second)
continue
}
_, err := k.getLivestreams(
k.CloseCtx,
gokick.NewLivestreamListFilter().SetBroadcasterUserIDs(int(k.Channel.UserID)),
)
if err != nil {
logger.Errorf(ctx, "unable to get my stream status: %v", err)
time.Sleep(time.Second)
continue
}
select {
case <-k.CloseCtx.Done():
return
case <-t.C:
}
}
}
func (k *Kick) initChatHandler(
ctx context.Context,
) error {
return xsync.DoA1R1(ctx, &k.ChatHandlerLocker, k.initChatHandlerNoLock, ctx)
}
func (k *Kick) initChatHandlerNoLock(
ctx context.Context,
) error {
chatHandler, err := k.newChatHandlerOBSOLETE(ctx, k.CurrentConfig.Config.Channel, k.onChatHandlerClose)
if err == nil {
k.ChatHandler = chatHandler
return nil
}
for {
logger.Errorf(ctx, "unable to initialize chat handler: %v", err)
time.Sleep(time.Second)
select {
case <-k.CloseCtx.Done():
logger.Debugf(ctx, "initChatHandler: cancelled (case #1)")
return fmt.Errorf("k.CloseCtx is closed: %w", k.CloseCtx.Err())
case <-ctx.Done():
logger.Debugf(ctx, "initChatHandler: cancelled (case #2)")
return fmt.Errorf("ctx is closed: %w", ctx.Err())
default:
}
chatHandler, err = k.newChatHandlerOBSOLETE(ctx, k.CurrentConfig.Config.Channel, k.onChatHandlerClose)
if err != nil {
logger.Debugf(ctx, "initChatHandler: unable to create a new chat handler: %v", err)
continue
}
k.ChatHandler = chatHandler
return nil
}
}
func (k *Kick) onChatHandlerClose(
ctx context.Context,
h *ChatHandlerOBSOLETE,
) {
xsync.DoA2(ctx, &k.ChatHandlerLocker, k.onChatHandlerCloseNoLock, ctx, h)
}
func (k *Kick) onChatHandlerCloseNoLock(
ctx context.Context,
h *ChatHandlerOBSOLETE,
) {
if h != k.ChatHandler {
logger.Errorf(ctx, "chat handler was already replaced")
return
}
select {
case <-ctx.Done():
return
case <-k.CloseCtx.Done():
return
case <-time.After(time.Second):
}
for {
select {
case <-ctx.Done():
return
case <-k.CloseCtx.Done():
return
default:
}
chatHandler, err := k.newChatHandlerOBSOLETE(ctx, k.CurrentConfig.Config.Channel, k.onChatHandlerClose)
if err != nil {
logger.Errorf(ctx, "unable to initialize a chat handler: %w", err)
time.Sleep(time.Second)
continue
}
k.ChatHandler = chatHandler
}
}
func authRedirectURI(listenPort uint16) string {
return fmt.Sprintf("http://localhost:%d/oauth/kick/callback", listenPort)
}
func (k *Kick) getAccessToken(
ctx context.Context,
) (_err error) {
logger.Tracef(ctx, "getAccessToken")
defer func() { logger.Tracef(ctx, "/getAccessToken: %v", _err) }()
return xsync.DoR1(ctx, &k.getAccessTokenLocker, func() error {
return k.getAccessTokenNoLock(ctx)
})
}
func (k *Kick) getAccessTokenNoLock(
ctx context.Context,
) (_err error) {
logger.Tracef(ctx, "getAccessTokenNoLock")
defer func() { logger.Tracef(ctx, "/getAccessTokenNoLock: %v", _err) }()
getPortsFn := k.CurrentConfig.Config.GetOAuthListenPorts
if getPortsFn == nil {
// TODO: find a way to adjust the OAuth ports dynamically without re-creating the Kick client.
return 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 OAuthListenerPorts")
time.Sleep(time.Second)
}
if len(oauthPorts) < 2 {
// we require two ports, because the first port is used by Twitch
// TODO: remove all this ugly hardcodes
return fmt.Errorf("the function GetOAuthListenPorts returned less than 2 ports (%d)", len(oauthPorts))
}
listenPort := oauthPorts[1] // TODO: remove this hardcode [1]; it currently exists to use the different port from what we use for Twitch authentication
redirectURL := authRedirectURI(listenPort)
codeVerifier := uuid.New().String() // random string
codeVerifierSHA256 := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.URLEncoding.EncodeToString(codeVerifierSHA256[:])
scopes := []gokick.Scope{
gokick.ScopeUserRead,
gokick.ScopeChannelRead,
gokick.ScopeChannelWrite,
gokick.ScopeChatWrite,
gokick.ScopeStremkeyRead,
gokick.ScopeEventSubscribe,
gokick.ScopeModerationBan,
}
logger.Debugf(ctx, "scopes: %v", scopes)
authURL, err := k.GetClient().GetAuthorize(
redirectURL,
"EMPTY",
codeChallenge,
scopes,
)
if err != nil {
return fmt.Errorf("unable to get an authorization endpoint URL: %w", err)
}
err = k.CurrentConfig.Config.CustomOAuthHandler(ctx, oauthhandler.OAuthHandlerArgument{
AuthURL: authURL,
ListenPort: listenPort,
ExchangeFn: func(
ctx context.Context,
code string,
) error {
now := time.Now()
token, err := k.GetClient().GetToken(ctx, redirectURL, code, codeVerifier)
if err != nil {
return fmt.Errorf("unable to get an access token: %w", err)
}
return k.setToken(ctx, token, now)
},
})
if err != nil {
return fmt.Errorf("an error occurred during the authorization procedure: %w", err)
}
return nil
}
func (k *Kick) setToken(
ctx context.Context,
token gokick.TokenResponse,
now time.Time,
) (_err error) {
logger.Tracef(ctx, "setToken")
defer func() { logger.Tracef(ctx, "/setToken: %v", _err) }()
logger.Infof(ctx, "new UserAccessToken was set")
if client := k.GetClient(); client != nil {
client.SetUserAccessToken(token.AccessToken)
client.SetUserRefreshToken(token.AccessToken)
}
k.CurrentConfig.Config.UserAccessToken.Set(token.AccessToken)
k.CurrentConfig.Config.UserAccessTokenExpiresAt = now.Add(time.Second * time.Duration(token.ExpiresIn))
k.CurrentConfig.Config.RefreshToken.Set(token.RefreshToken)
logger.Tracef(ctx, "'%v' '%v' '%v'", k.CurrentConfig.Config.UserAccessToken.Get(), k.CurrentConfig.Config.UserAccessTokenExpiresAt, k.CurrentConfig.Config.RefreshToken.Get())
err := k.SaveCfgFn(k.CurrentConfig)
if err != nil {
return fmt.Errorf("unable to save the config: %w", err)
}
return nil
}
func (k *Kick) Close() (_err error) {
ctx := context.Background()
logger.Debugf(ctx, "Close(ctx)")
defer func() { logger.Debugf(ctx, "/Close(ctx): %v", _err) }()
k.CloseFn()
return nil
}
func (k *Kick) SetTitle(ctx context.Context, title string) (err error) {
logger.Debugf(ctx, "SetTitle(ctx, '%s')", title)
defer func() { logger.Debugf(ctx, "/SetTitle(ctx, '%s'): %v", title, err) }()
if err := k.prepare(ctx); err != nil {
return fmt.Errorf("unable to get a prepared client: %w", err)
}
_, err = k.GetClient().UpdateStreamTitle(ctx, title)
return
}
func (k *Kick) SetDescription(ctx context.Context, description string) error {
logger.Warnf(ctx, "not implemented yet")
return nil
}
func (k *Kick) InsertAdsCuePoint(ctx context.Context, ts time.Time, duration time.Duration) error {
logger.Warnf(ctx, "not implemented yet")
return nil
}
func (k *Kick) Flush(ctx context.Context) error {
return nil
}
func (k *Kick) EndStream(ctx context.Context) error {
// Kick ends a stream automatically, nothing to do:
return nil
}
func (k *Kick) getLivestreams(
ctx context.Context,
filter gokick.LivestreamListFilter,
) (_ret *gokick.LivestreamsResponseWrapper, _err error) {
logger.Debugf(ctx, "getLivestreams")
defer func() { logger.Debugf(ctx, "/getLivestreams: %v, %v", _ret, _err) }()
if err := k.prepare(ctx); err != nil {
return nil, fmt.Errorf("unable to get a prepared client: %w", err)
}
client := k.GetClient()
if client == nil {
return nil, fmt.Errorf("client is not initialized")
}
resp, err := client.GetLivestreams(k.CloseCtx, filter)
if err != nil {
return nil, fmt.Errorf("unable to get my stream status: %w", err)
}
return &resp, nil
}
func (k *Kick) GetStreamStatus(
ctx context.Context,
) (_ret *streamcontrol.StreamStatus, _err error) {
logger.Debugf(ctx, "GetStreamStatus")
defer func() { logger.Debugf(ctx, "/GetStreamStatus: %v, %v", _ret, _err) }()
if err := k.prepare(ctx); err != nil {
return nil, fmt.Errorf("unable to get a prepared client: %w", err)
}
//resp, err := k.Client.GetLivestreams(ctx, gokick.NewLivestreamListFilter().SetBroadcasterUserIDs(k.Channel.BroadcasterUserID))
info, err := k.ClientOBSOLETE.GetLivestreamV2(ctx, k.Channel.Slug)
if err != nil {
err := fmt.Errorf("unable to request stream status using the reverse-engineering lib: %w", err)
logger.Errorf(ctx, "%v", err)
streamStatus, err2 := k.getStreamStatusUsingNormalClient(ctx)
if err2 == nil {
return streamStatus, nil
}
return nil, errors.Join(
err,
fmt.Errorf("unable to request stream status using the normal lib: %w", err),
)
}
/*if len(resp.Result) > 1 {
return nil, fmt.Errorf("expected livestream status of one channel (or no channels), but received for %d channels", len(resp.Result))
}*/
logger.Tracef(ctx, "the received livestream status is: %s", spew.Sdump(info))
if info.Data == nil {
return &streamcontrol.StreamStatus{
IsActive: false,
ViewersCount: nil,
StartedAt: nil,
CustomData: nil,
}, nil
}
/*var startedAtPtr *time.Time
startedAt, err := ParseTimestamp(info.StartedAt)
if err == nil {
startedAtPtr = &startedAt
} else {
logger.Errorf(ctx, "unable to parse '%s' as a timestamp: %v", info.StartedAt, err)
}*/
return &streamcontrol.StreamStatus{
IsActive: true,
ViewersCount: ptr(uint(info.Data.Viewers)),
StartedAt: &info.Data.CreatedAt,
CustomData: info,
}, nil
}
const timeLayout = "2006-01-02T15:04:05-0700"
const timeLayoutFallback = time.RFC3339
func ParseTimestamp(s string) (time.Time, error) {
ts, err0 := time.Parse(timeLayout, s)
if err0 == nil {
return ts, nil
}
ts, err1 := time.Parse(timeLayoutFallback, s)
if err1 == nil {
return ts, nil
}
return time.Now(), errors.Join(err0, err1)
}
func (k *Kick) getStreamStatusUsingNormalClient(
ctx context.Context,
) (_ret *streamcontrol.StreamStatus, _err error) {
logger.Debugf(ctx, "getStreamStatusUsingNormalClient")
defer func() { logger.Debugf(ctx, "/getStreamStatusUsingNormalClient: %v %v", _ret, _err) }()
if err := k.prepare(ctx); err != nil {
return nil, fmt.Errorf("unable to get a prepared client: %w", err)
}
resp, err := k.GetClient().GetChannels(
ctx,
gokick.NewChannelListFilter().SetBroadcasterUserIDs([]int{int(k.Channel.UserID)}),
)
if err != nil {
return nil, fmt.Errorf("unable to get channels info")
}
if len(resp.Result) != 1 {
return nil, fmt.Errorf("expected to get info about one channel, but received about %d channels", len(resp.Result))
}
chanInfo := resp.Result[0]
if !chanInfo.Stream.IsLive {
return &streamcontrol.StreamStatus{
IsActive: false,
ViewersCount: nil,
StartedAt: nil,
CustomData: nil,
}, nil
}
var startedAt *time.Time
if chanInfo.Stream.StartTime != "" {
v, err := time.Parse(time.RFC3339Nano, chanInfo.Stream.StartTime)
if err != nil {
logger.Errorf(ctx, "unable to parse date '%s': %w", chanInfo.Stream.StartTime, err)
}
startedAt = &v
}
return &streamcontrol.StreamStatus{
IsActive: true,
ViewersCount: ptr(uint(chanInfo.Stream.ViewerCount)),
StartedAt: startedAt,
CustomData: CustomData{
Key: secret.New(chanInfo.Stream.Key),
URL: chanInfo.Stream.URL,
IsMature: chanInfo.Stream.IsMature,
Language: chanInfo.Stream.Language,
},
}, nil
}
func (k *Kick) GetAllCategories(
ctx context.Context,
) (_ret []kickcom.CategoryV1Short, _err error) {
logger.Debugf(ctx, "GetAllCategories")
defer func() { logger.Debugf(ctx, "/GetAllCategories: len:%d, %v", len(_ret), _err) }()
//reply, err := k.Client.GetCategories(ctx, gokick.NewCategoryListFilter())
reply, err := k.ClientOBSOLETE.GetSubcategoriesV1(ctx)
if err != nil {
return nil, fmt.Errorf("unable to get subcategories: %w", err)
}
return *reply, nil
}
func (k *Kick) tryGetChatHandler(
ctx context.Context,
) *ChatHandlerOBSOLETE {
return xsync.DoR1(ctx, &k.ChatHandlerLocker, func() *ChatHandlerOBSOLETE {
return k.ChatHandler
})
}
func (k *Kick) getChatHandler(
ctx context.Context,
) *ChatHandlerOBSOLETE {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
chatHandler := k.tryGetChatHandler(ctx)
if chatHandler != nil {
return chatHandler
}
logger.Warnf(ctx, "unable to get chat handler")
select {
case <-k.CloseCtx.Done():
return nil
case <-ctx.Done():
return nil
case <-t.C:
}
}
}
func (k *Kick) GetChatMessagesChan(
ctx context.Context,
) (<-chan streamcontrol.ChatMessage, error) {
logger.Debugf(ctx, "GetChatMessagesChan")
defer func() { logger.Debugf(ctx, "/GetChatMessagesChan") }()
if err := k.prepare(ctx); err != nil {
return nil, fmt.Errorf("unable to get a prepared client: %w", err)
}
outCh := make(chan streamcontrol.ChatMessage)
observability.Go(ctx, func(ctx context.Context) {
defer func() {
logger.Debugf(ctx, "closing the messages channel")
close(outCh)
}()
for {
err := k.prepare(ctx)
if err != nil {
logger.Errorf(ctx, "unable to get a prepared client: %w", err)
time.Sleep(time.Second)
continue
}
break
}
logger.Debugf(ctx, "GetChatMessagesChan: client is ready")
for {
chatHandler := k.getChatHandler(ctx)
if chatHandler == nil {
logger.Debugf(ctx, "getting of chat handler was cancelled: %v %v", ctx.Err(), k.CloseCtx.Err())
return
}
logger.Tracef(ctx, "GetChatMessagesChan: waiting for a message")
select {
case <-k.CloseCtx.Done():
return
case <-ctx.Done():
return
case ev, ok := <-chatHandler.MessagesChan():
if !ok {
logger.Debugf(ctx, "the input channel is closed")
continue
}
logger.Tracef(ctx, "GetChatMessagesChan: received a message")
outCh <- ev
}
}
})
return outCh, nil
}
func (k *Kick) SendChatMessage(ctx context.Context, message string) (_err error) {
logger.Debugf(ctx, "SendChatMessage(ctx, '%s')", message)
defer func() { logger.Debugf(ctx, "/SendChatMessage(ctx, '%s'): %v", message, _err) }()
if err := k.prepare(ctx); err != nil {
return fmt.Errorf("unable to get a prepared client: %w", err)
}
resp, err := k.GetClient().SendChatMessage(ctx, ptr(int(k.Channel.UserID)), message, nil, gokick.MessageTypeUser)
logger.Debugf(ctx, "SendChatMessage(ctx, '%s'): %#+v", message, resp)
return err
}
func (k *Kick) RemoveChatMessage(ctx context.Context, messageID streamcontrol.ChatMessageID) error {
logger.Warnf(ctx, "not implemented yet")
return nil
}
func (k *Kick) BanUser(
ctx context.Context,
userID streamcontrol.ChatUserID,
reason string,
deadline time.Time,
) (_err error) {
logger.Debugf(ctx, "BanUser(ctx, %d, '%s', %v): %#+v", userID, reason, deadline)
defer func() { logger.Debugf(ctx, "/BanUser(ctx, %d, '%s', %v): %#+v", userID, reason, deadline, _err) }()
if err := k.prepare(ctx); err != nil {
return fmt.Errorf("unable to get a prepared client: %w", err)
}
userIDInt, err := strconv.ParseInt(string(userID), 10, 64)
if err != nil {
return fmt.Errorf("unable to convert the user ID: %w", err)
}
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
var duration *int
if !deadline.IsZero() {
duration = ptr(int(time.Until(deadline).Minutes() + 0.5))
if *duration <= 0 {
logger.Warnf(ctx, "the requested ban interval is not greater than zero: %d", *duration)
return nil
}
}
resp, err := k.GetClient().BanUser(ctx, int(k.Channel.UserID), int(userIDInt), duration, reasonPtr)
logger.Debugf(ctx, "BanUser(ctx, %d, '%s', %v): %#+v", userID, reason, deadline, resp)
return err
}
func (k *Kick) ApplyProfile(
ctx context.Context,
profile StreamProfile,
customArgs ...any,
) (_err error) {
logger.Debugf(ctx, "ApplyProfile")
defer func() { logger.Debugf(ctx, "/ApplyProfile: %v", _err) }()
if err := k.prepare(ctx); err != nil {
return fmt.Errorf("unable to get a prepared client: %w", err)
}
var result []error
if profile.CategoryID != nil {
logger.Debugf(ctx, "has a CategoryID")
_, err := k.GetClient().UpdateStreamCategory(ctx, int(*profile.CategoryID))
if err != nil {
result = append(result, fmt.Errorf("unable to update the category: %w", err))
}
}
return errors.Join(result...)
}
func (k *Kick) 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) }()
if err := k.prepare(ctx); err != nil {
return fmt.Errorf("unable to get a prepared client: %w", err)
}
var result []error
if err := k.SetTitle(ctx, title); err != nil {
result = append(result, fmt.Errorf("unable to set title: %w", err))
}
if err := k.SetDescription(ctx, description); err != nil {
result = append(result, fmt.Errorf("unable to set description: %w", err))
}
if err := k.ApplyProfile(ctx, profile, customArgs...); err != nil {
result = append(
result,
fmt.Errorf("unable to apply the stream-specific profile: %w", err),
)
}
return errors.Join(result...)
}
func (k *Kick) prepare(ctx context.Context) (_err error) {
logger.Tracef(ctx, "prepare")
defer func() { logger.Tracef(ctx, "/prepare: %v", _err) }()
return xsync.DoA1R1(ctx, &k.PrepareLocker, k.prepareNoLock, ctx)
}
func (k *Kick) prepareNoLock(ctx context.Context) error {
if k == nil {
return fmt.Errorf("k == nil")
}
if k.ClientOBSOLETE == nil {
return fmt.Errorf("k.ClientOBSOLETE == nil")
}
err := k.getAccessTokenIfNeeded(ctx)
if err != nil {
return fmt.Errorf("getAccessTokenIfNeeded: %w", err)
}
k.lazyInitOnce.Do(func() {
if err = k.initChannelInfo(ctx); err != nil {
err = fmt.Errorf("initChannelInfo: %w", err)
return
}
observability.Go(ctx, func(ctx context.Context) {
if err = k.initChatHandler(ctx); err != nil {
logger.Errorf(ctx, "initChatHandler: %v", err)
}
})
})
return err
}
func (k *Kick) initChannelInfo(
ctx context.Context,
) error {
//var channel *gokick.ChannelResponse
cache := CacheFromCtx(ctx)
if chanInfo := cache.GetChanInfo(); chanInfo != nil && chanInfo.Slug == k.CurrentConfig.Config.Channel {
logger.Debugf(ctx, "reuse the cache, instead of querying channel info")
k.Channel = chanInfo
return nil
}
for {
slug := k.CurrentConfig.Config.Channel
chanInfo, err := k.ClientOBSOLETE.GetChannelV1(ctx, slug)
//channelResp, err := k.Client.GetChannels(ctx, gokick.NewChannelListFilter().SetSlug([]string{slug}))
if err != nil {
logger.Errorf(ctx, "unable to get the channel info (slug: '%s'): %v", slug, err)
time.Sleep(time.Second)
continue
}
/*if len(channelResp.Result) != 1 {
logger.Errorf(ctx, "expected to find one channel with name '%s', but found %d", slug, len(channelResp.Result))
time.Sleep(30 * time.Second)
continue
}
channel = &channelResp.Result[0]*/
if cache != nil {
cache.SetChanInfo(chanInfo)
}
k.Channel = chanInfo
return nil
}
}
func (k *Kick) getAccessTokenIfNeeded(
ctx context.Context,
) (_err error) {
logger.Tracef(ctx, "getAccessTokenIfNeeded")
defer func() { logger.Tracef(ctx, "/getAccessTokenIfNeeded: %v", _err) }()
if time.Now().After(k.CurrentConfig.Config.UserAccessTokenExpiresAt.Add(-30 * time.Second)) {
if k.CurrentConfig.Config.RefreshToken.Get() != "" {
if err := k.refreshAccessToken(ctx); err != nil {
return fmt.Errorf("unable to refresh the access token: %w", err)
}
}
}
if k.CurrentConfig.Config.UserAccessToken.Get() != "" {
return nil
}
err := k.getAccessToken(ctx)
if err != nil {
return fmt.Errorf("unable to get access token: %w", err)
}
return nil
}
func (k *Kick) refreshAccessToken(
ctx context.Context,
) (_err error) {
logger.Tracef(ctx, "refreshAccessToken")
defer func() { logger.Tracef(ctx, "/refreshAccessToken: %v", _err) }()
resp, err := k.GetClient().RefreshToken(ctx, k.CurrentConfig.Config.RefreshToken.Get())
if err != nil {
logger.Errorf(ctx, "unable to refresh the token: %v", err)
if getErr := k.getAccessToken(ctx); getErr != nil {
return fmt.Errorf("unable to refresh access token (%w); and unable to get a new access token (%w)", err, getErr)
}
return nil
}
err = k.setToken(ctx, resp, time.Now())
if err != nil {
return fmt.Errorf("unable to set access token: %w", err)
}
return nil
}
func (k *Kick) IsCapable(
ctx context.Context,
cap streamcontrol.Capability,
) bool {
switch cap {
case streamcontrol.CapabilitySendChatMessage:
return true
case streamcontrol.CapabilityDeleteChatMessage:
return false
case streamcontrol.CapabilityBanUser:
return true
case streamcontrol.CapabilityShoutout:
return true
case streamcontrol.CapabilityIsChannelStreaming:
return false
case streamcontrol.CapabilityRaid:
return false
}
return false
}
func (k *Kick) IsChannelStreaming(
ctx context.Context,
chanID streamcontrol.ChatUserID,
) (_ret bool, _err error) {
logger.Debugf(ctx, "IsChannelStreaming(ctx, '%s')", chanID)
defer func() { logger.Debugf(ctx, "/IsChannelStreaming(ctx, '%s'): %v", chanID, _ret, _err) }()
if err := k.prepare(ctx); err != nil {
return false, fmt.Errorf("unable to get a prepared client: %w", err)
}
return false, fmt.Errorf("not implemented")
}
func (k *Kick) RaidTo(
ctx context.Context,
chanID streamcontrol.ChatUserID,
) (_err error) {
logger.Debugf(ctx, "RaidTo(ctx, '%s')", chanID)
defer func() { logger.Debugf(ctx, "/RaidTo(ctx, '%s'): %v", chanID, _err) }()
if err := k.prepare(ctx); err != nil {
return fmt.Errorf("unable to get a prepared client: %w", err)
}
return fmt.Errorf("not implemented")
}
func (k *Kick) getChanInfoViaOldClient(
ctx context.Context,
idOrLogin streamcontrol.ChatUserID,
) (_ret *gokick.ChannelResponse, _err error) {
logger.Debugf(ctx, "getChanInfoViaOldClient(ctx, '%s')")
defer func() { logger.Debugf(ctx, "/getChanInfoViaOldClient(ctx, '%s'): %v %v", _ret, _err) }()
chanInfo, err := k.ClientOBSOLETE.GetChannelV1(ctx, string(idOrLogin))
if err != nil {
return nil, fmt.Errorf("unable to get chan info of '%s': %w", idOrLogin, err)
}
result := &gokick.ChannelResponse{
BannerPicture: chanInfo.BannerImage.URL,
BroadcasterUserID: int(chanInfo.UserID),
Slug: chanInfo.Slug,
StreamTitle: chanInfo.Livestream.SessionTitle,
}
if len(chanInfo.RecentCategories) > 0 {
cat := chanInfo.RecentCategories[0]
result.Category = gokick.CategoryResponse{
ID: int(cat.ID),
Name: cat.Name,
Thumbnail: cat.Category.Icon,
}
}
return result, nil
}
func (k *Kick) getChanInfo(
ctx context.Context,
idOrLogin streamcontrol.ChatUserID,
) (_ret *gokick.ChannelResponse, _err error) {
logger.Debugf(ctx, "getChanInfo(ctx, '%s')")
defer func() { logger.Debugf(ctx, "/getChanInfo(ctx, '%s'): %v %v", _ret, _err) }()
id, idConvErr := strconv.ParseInt(string(idOrLogin), 10, 64)
client := k.GetClient()
if client == nil {
err := fmt.Errorf("kick client is not initialized")
if idConvErr != nil {
logger.Errorf(ctx, "%v", err)
return k.getChanInfoViaOldClient(ctx, idOrLogin)
}
return nil, err
}
if idConvErr == nil {
resp, err := client.GetChannels(ctx, gokick.NewChannelListFilter().SetBroadcasterUserIDs([]int{int(id)}))
if err != nil {
return nil, fmt.Errorf("unable to request channel info by id %d: %w", id, err)
}
if len(resp.Result) != 0 {
return &resp.Result[0], nil
}
}
resp, err := client.GetChannels(ctx, gokick.NewChannelListFilter().SetSlug([]string{string(idOrLogin)}))
if err != nil {
logger.Errorf(ctx, "unable to request channel info by slug '%s': %v", idOrLogin, err)
return k.getChanInfoViaOldClient(ctx, idOrLogin) // TODO: use an multierror to combine errors from both variants
}
if len(resp.Result) == 0 {
return nil, fmt.Errorf("user with slug or ID '%s' is not found", idOrLogin)
}
return &resp.Result[0], nil
}
func (k *Kick) Shoutout(
ctx context.Context,
idOrLogin streamcontrol.ChatUserID,
) (_err error) {
logger.Debugf(ctx, "Shoutout(ctx, '%s')", idOrLogin)
defer func() { logger.Debugf(ctx, "/Shoutout(ctx, '%s'): %v", idOrLogin, _err) }()
if err := k.prepare(ctx); err != nil {
return fmt.Errorf("unable to get a prepared client: %w", err)
}
chanInfo, err := k.getChanInfo(ctx, idOrLogin)
if err != nil {
return fmt.Errorf("unable to get channel info ('%s'): %w", idOrLogin, err)
}
return k.sendShoutoutMessage(ctx, *chanInfo)
}
func (k *Kick) sendShoutoutMessage(
ctx context.Context,
chanInfo gokick.ChannelResponse,
) (_err error) {
logger.Debugf(ctx, "sendShoutoutMessage(ctx, '%s')", spew.Sdump(chanInfo))
defer func() { logger.Debugf(ctx, "/sendShoutoutMessage(ctx, '%s'): %v", spew.Sdump(chanInfo), _err) }()
var message []string
message = append(message, fmt.Sprintf("Shoutout to %s!", chanInfo.Slug))
if chanInfo.StreamTitle != "" {
message = append(message, fmt.Sprintf("Their latest stream: '%s'.", chanInfo.StreamTitle))
}
message = append(message, fmt.Sprintf("Take a look at their channel and click that follow button! https://kick.com/%s", chanInfo.Slug))
err := k.SendChatMessage(ctx, strings.Join(message, " "))
if err != nil {
return fmt.Errorf("unable to send the message (case #1): %w", err)
}
return nil
}