Files
streamctl/pkg/streamcontrol/twitch/twitch.go
2024-10-31 00:11:04 +00:00

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
}