From 78beeaa14b3f8fb825c29a68019f87fe7e6d3ff0 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sat, 25 Dec 2021 00:47:40 +0100 Subject: [PATCH] [core] Add handling for channel point rewards Signed-off-by: Knut Ahlers --- events.go | 34 ++++---- store.go | 59 +++++++++++++ twitch/eventsub.go | 21 +++++ twitch/scopes.go | 6 ++ twitch/twitch.go | 4 + twitchWatcher.go | 210 ++++++++++++++++++++++++++++----------------- wiki/Events.md | 15 ++++ 7 files changed, 256 insertions(+), 93 deletions(-) create mode 100644 twitch/scopes.go diff --git a/events.go b/events.go index 27203d4..2b61ad7 100644 --- a/events.go +++ b/events.go @@ -3,22 +3,23 @@ package main func ptrStr(s string) *string { return &s } var ( - eventTypeBan = ptrStr("ban") - eventTypeBits = ptrStr("bits") - eventTypeClearChat = ptrStr("clearchat") - eventTypeFollow = ptrStr("follow") - eventTypeGiftPaidUpgrade = ptrStr("giftpaidupgrade") - eventTypeHost = ptrStr("host") - eventTypeJoin = ptrStr("join") - eventTypePart = ptrStr("part") - eventTypePermit = ptrStr("permit") - eventTypeRaid = ptrStr("raid") - eventTypeResub = ptrStr("resub") - eventTypeSubgift = ptrStr("subgift") - eventTypeSubmysterygift = ptrStr("submysterygift") - eventTypeSub = ptrStr("sub") - eventTypeTimeout = ptrStr("timeout") - eventTypeWhisper = ptrStr("whisper") + eventTypeBan = ptrStr("ban") + eventTypeBits = ptrStr("bits") + eventTypeChannelPointRedeem = ptrStr("channelpoint_redeem") + eventTypeClearChat = ptrStr("clearchat") + eventTypeFollow = ptrStr("follow") + eventTypeGiftPaidUpgrade = ptrStr("giftpaidupgrade") + eventTypeHost = ptrStr("host") + eventTypeJoin = ptrStr("join") + eventTypePart = ptrStr("part") + eventTypePermit = ptrStr("permit") + eventTypeRaid = ptrStr("raid") + eventTypeResub = ptrStr("resub") + eventTypeSubgift = ptrStr("subgift") + eventTypeSubmysterygift = ptrStr("submysterygift") + eventTypeSub = ptrStr("sub") + eventTypeTimeout = ptrStr("timeout") + eventTypeWhisper = ptrStr("whisper") eventTypeTwitchCategoryUpdate = ptrStr("category_update") eventTypeTwitchStreamOffline = ptrStr("stream_offline") @@ -28,6 +29,7 @@ var ( knownEvents = []*string{ eventTypeBan, eventTypeBits, + eventTypeChannelPointRedeem, eventTypeClearChat, eventTypeFollow, eventTypeGiftPaidUpgrade, diff --git a/store.go b/store.go index ef0af1b..1324930 100644 --- a/store.go +++ b/store.go @@ -11,6 +11,7 @@ import ( "github.com/pkg/errors" + "github.com/Luzifer/go_helpers/v2/str" "github.com/Luzifer/twitch-bot/plugins" ) @@ -23,6 +24,8 @@ type storageFile struct { ModuleStorage map[string]json.RawMessage `json:"module_storage"` + GrantedScopes map[string][]string `json:"granted_scopes"` + EventSubSecret string `json:"event_sub_secret,omitempty"` inMem bool @@ -37,11 +40,22 @@ func newStorageFile(inMemStore bool) *storageFile { ModuleStorage: map[string]json.RawMessage{}, + GrantedScopes: map[string][]string{}, + inMem: inMemStore, lock: new(sync.RWMutex), } } +func (s *storageFile) DeleteGrantedScopes(user string) error { + s.lock.Lock() + defer s.lock.Unlock() + + delete(s.GrantedScopes, user) + + return errors.Wrap(s.Save(), "saving store") +} + func (s *storageFile) DeleteModuleStore(moduleUUID string) error { s.lock.Lock() defer s.lock.Unlock() @@ -174,6 +188,15 @@ func (s *storageFile) Save() error { ) } +func (s *storageFile) SetGrantedScopes(user string, scopes []string) error { + s.lock.Lock() + defer s.lock.Unlock() + + s.GrantedScopes[user] = scopes + + return errors.Wrap(s.Save(), "saving store") +} + func (s *storageFile) SetModuleStore(moduleUUID string, storedObject plugins.StorageMarshaller) error { s.lock.Lock() defer s.lock.Unlock() @@ -227,3 +250,39 @@ func (s *storageFile) UpdateCounter(counter string, value int64, absolute bool) return errors.Wrap(s.Save(), "saving store") } + +func (s *storageFile) UserHasGrantedAnyScope(user string, scopes ...string) bool { + s.lock.RLock() + defer s.lock.RUnlock() + + grantedScopes, ok := s.GrantedScopes[user] + if !ok { + return false + } + + for _, scope := range scopes { + if str.StringInSlice(scope, grantedScopes) { + return true + } + } + + return false +} + +func (s *storageFile) UserHasGrantedScopes(user string, scopes ...string) bool { + s.lock.RLock() + defer s.lock.RUnlock() + + grantedScopes, ok := s.GrantedScopes[user] + if !ok { + return false + } + + for _, scope := range scopes { + if !str.StringInSlice(scope, grantedScopes) { + return false + } + } + + return true +} diff --git a/twitch/eventsub.go b/twitch/eventsub.go index 3768412..b3a7524 100644 --- a/twitch/eventsub.go +++ b/twitch/eventsub.go @@ -46,6 +46,8 @@ const ( EventSubEventTypeChannelUpdate = "channel.update" EventSubEventTypeStreamOffline = "stream.offline" EventSubEventTypeStreamOnline = "stream.online" + + EventSubEventTypeChannelPointCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add" ) type ( @@ -73,6 +75,25 @@ type ( UserID string `json:"user_id,omitempty"` } + EventSubEventChannelPointCustomRewardRedemptionAdd struct { + ID string `json:"id"` + BroadcasterUserID string `json:"broadcaster_user_id"` + BroadcasterUserLogin string `json:"broadcaster_user_login"` + BroadcasterUserName string `json:"broadcaster_user_name"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + UserInput string `json:"user_input"` + Status string `json:"status"` + Reward struct { + ID string `json:"id"` + Title string `json:"title"` + Cost int64 `json:"cost"` + Prompt string `json:"prompt"` + } `json:"reward"` + RedeemedAt time.Time `json:"redeemed_at"` + } + EventSubEventChannelUpdate struct { BroadcasterUserID string `json:"broadcaster_user_id"` BroadcasterUserLogin string `json:"broadcaster_user_login"` diff --git a/twitch/scopes.go b/twitch/scopes.go new file mode 100644 index 0000000..ad85556 --- /dev/null +++ b/twitch/scopes.go @@ -0,0 +1,6 @@ +package twitch + +const ( + ScopeChannelManageRedemptions = "channel:manage:redemptions" + ScopeChannelReadRedemptions = "channel:read:redemptions" +) diff --git a/twitch/twitch.go b/twitch/twitch.go index 59b86a9..cccbba1 100644 --- a/twitch/twitch.go +++ b/twitch/twitch.go @@ -478,6 +478,10 @@ func (c Client) ModifyChannelInformation(ctx context.Context, broadcasterName st ) } +func (c *Client) UpdateToken(token string) { + c.token = token +} + func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) { var ( buf = new(bytes.Buffer) diff --git a/twitchWatcher.go b/twitchWatcher.go index ae7b16b..ad9f660 100644 --- a/twitchWatcher.go +++ b/twitchWatcher.go @@ -82,6 +82,71 @@ func (t *twitchWatcher) RemoveChannel(channel string) error { return nil } +func (t *twitchWatcher) handleEventSubChannelFollow(m json.RawMessage) error { + var payload twitch.EventSubEventFollow + if err := json.Unmarshal(m, &payload); err != nil { + return errors.Wrap(err, "unmarshalling event") + } + + fields := plugins.FieldCollectionFromData(map[string]interface{}{ + "channel": payload.BroadcasterUserLogin, + "followed_at": payload.FollowedAt, + "user_id": payload.UserID, + "user": payload.UserLogin, + }) + + log.WithFields(log.Fields(fields.Data())).Info("User followed") + go handleMessage(ircHdl.Client(), nil, eventTypeFollow, fields) + + return nil +} + +func (t *twitchWatcher) handleEventSubChannelPointCustomRewardRedemptionAdd(m json.RawMessage) error { + var payload twitch.EventSubEventChannelPointCustomRewardRedemptionAdd + if err := json.Unmarshal(m, &payload); err != nil { + return errors.Wrap(err, "unmarshalling event") + } + + fields := plugins.FieldCollectionFromData(map[string]interface{}{ + "channel": payload.BroadcasterUserLogin, + "reward_cost": payload.Reward.Cost, + "reward_id": payload.Reward.ID, + "reward_title": payload.Reward.Title, + "status": payload.Status, + "user_id": payload.UserID, + "user_input": payload.UserInput, + "user": payload.UserLogin, + }) + + log.WithFields(log.Fields(fields.Data())).Info("ChannelPoint reward was redeemed") + go handleMessage(ircHdl.Client(), nil, eventTypeChannelPointRedeem, fields) + + return nil +} + +func (t *twitchWatcher) handleEventSubChannelUpdate(m json.RawMessage) error { + var payload twitch.EventSubEventChannelUpdate + if err := json.Unmarshal(m, &payload); err != nil { + return errors.Wrap(err, "unmarshalling event") + } + + t.triggerUpdate(payload.BroadcasterUserLogin, &payload.Title, &payload.CategoryName, nil) + + return nil +} + +func (t *twitchWatcher) handleEventSubStreamOnOff(isOnline bool) func(json.RawMessage) error { + return func(m json.RawMessage) error { + var payload twitch.EventSubEventFollow + if err := json.Unmarshal(m, &payload); err != nil { + return errors.Wrap(err, "unmarshalling event") + } + + t.triggerUpdate(payload.BroadcasterUserLogin, nil, nil, &isOnline) + return nil + } +} + func (t *twitchWatcher) updateChannelFromAPI(channel string, sendUpdate bool) error { var ( err error @@ -129,91 +194,82 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error return nil, errors.Wrap(err, "resolving channel to user-id") } - unsubCU, err := twitchEventSubClient.RegisterEventSubHooks( - twitch.EventSubEventTypeChannelUpdate, - twitch.EventSubCondition{BroadcasterUserID: userID}, - func(m json.RawMessage) error { - var payload twitch.EventSubEventChannelUpdate - if err := json.Unmarshal(m, &payload); err != nil { - return errors.Wrap(err, "unmarshalling event") + var ( + topicRegistrations = []struct { + Topic string + Condition twitch.EventSubCondition + RequiredScopes []string + AnyScope bool + Hook func(json.RawMessage) error + }{ + { + Topic: twitch.EventSubEventTypeChannelUpdate, + Condition: twitch.EventSubCondition{BroadcasterUserID: userID}, + RequiredScopes: nil, + Hook: t.handleEventSubChannelUpdate, + }, + { + Topic: twitch.EventSubEventTypeStreamOffline, + Condition: twitch.EventSubCondition{BroadcasterUserID: userID}, + RequiredScopes: nil, + Hook: t.handleEventSubStreamOnOff(false), + }, + { + Topic: twitch.EventSubEventTypeStreamOnline, + Condition: twitch.EventSubCondition{BroadcasterUserID: userID}, + RequiredScopes: nil, + Hook: t.handleEventSubStreamOnOff(true), + }, + { + Topic: twitch.EventSubEventTypeChannelFollow, + Condition: twitch.EventSubCondition{BroadcasterUserID: userID}, + RequiredScopes: nil, + Hook: t.handleEventSubChannelFollow, + }, + { + Topic: twitch.EventSubEventTypeChannelPointCustomRewardRedemptionAdd, + Condition: twitch.EventSubCondition{BroadcasterUserID: userID}, + RequiredScopes: []string{twitch.ScopeChannelReadRedemptions, twitch.ScopeChannelManageRedemptions}, + AnyScope: true, + Hook: t.handleEventSubChannelPointCustomRewardRedemptionAdd, + }, + } + unsubHandlers []func() + ) + + for _, tr := range topicRegistrations { + logger := log.WithFields(log.Fields{ + "any": tr.AnyScope, + "channel": channel, + "scopes": tr.RequiredScopes, + "topic": tr.Topic, + }) + + if len(tr.RequiredScopes) > 0 { + fn := store.UserHasGrantedScopes + if tr.AnyScope { + fn = store.UserHasGrantedAnyScope } - t.triggerUpdate(channel, &payload.Title, &payload.CategoryName, nil) - - return nil - }, - ) - if err != nil { - return nil, errors.Wrap(err, "registering channel-update eventsub") - } - - unsubSOff, err := twitchEventSubClient.RegisterEventSubHooks( - twitch.EventSubEventTypeStreamOffline, - twitch.EventSubCondition{BroadcasterUserID: userID}, - func(m json.RawMessage) error { - var payload twitch.EventSubEventStreamOffline - if err := json.Unmarshal(m, &payload); err != nil { - return errors.Wrap(err, "unmarshalling event") + if !fn(channel, tr.RequiredScopes...) { + logger.Debug("Missing scopes for eventsub topic") + continue } + } - t.triggerUpdate(channel, nil, nil, func(v bool) *bool { return &v }(false)) + uf, err := twitchEventSubClient.RegisterEventSubHooks(tr.Topic, tr.Condition, tr.Hook) + if err != nil { + logger.WithError(err).Error("Unable to register topic") + continue + } - return nil - }, - ) - if err != nil { - return nil, errors.Wrap(err, "registering channel-update eventsub") - } - - unsubSOn, err := twitchEventSubClient.RegisterEventSubHooks( - twitch.EventSubEventTypeStreamOnline, - twitch.EventSubCondition{BroadcasterUserID: userID}, - func(m json.RawMessage) error { - var payload twitch.EventSubEventStreamOnline - if err := json.Unmarshal(m, &payload); err != nil { - return errors.Wrap(err, "unmarshalling event") - } - - t.triggerUpdate(channel, nil, nil, func(v bool) *bool { return &v }(true)) - - return nil - }, - ) - if err != nil { - return nil, errors.Wrap(err, "registering channel-update eventsub") - } - - unsubFollow, err := twitchEventSubClient.RegisterEventSubHooks( - twitch.EventSubEventTypeChannelFollow, - twitch.EventSubCondition{BroadcasterUserID: userID}, - func(m json.RawMessage) error { - var payload twitch.EventSubEventFollow - if err := json.Unmarshal(m, &payload); err != nil { - return errors.Wrap(err, "unmarshalling event") - } - - fields := plugins.FieldCollectionFromData(map[string]interface{}{ - "channel": channel, - "followed_at": payload.FollowedAt, - "user_id": payload.UserID, - "user": payload.UserLogin, - }) - - log.WithFields(log.Fields(fields.Data())).Info("User followed") - go handleMessage(ircHdl.Client(), nil, eventTypeFollow, fields) - - return nil - }, - ) - if err != nil { - return nil, errors.Wrap(err, "registering channel-follow eventsub") + unsubHandlers = append(unsubHandlers, uf) } return func() { - unsubCU() - unsubSOff() - unsubSOn() - unsubFollow() + for _, f := range unsubHandlers { + f() + } }, nil } diff --git a/wiki/Events.md b/wiki/Events.md index be3ebc4..919e900 100644 --- a/wiki/Events.md +++ b/wiki/Events.md @@ -31,6 +31,21 @@ Fields: - `category` - The name of the new game / category - `channel` - The channel the event occurred in +## `channelpoint_redeem` + +A custom channel-point reward was redeemed in the given channel. (Only available when EventSub support is available and streamer granted required permissions!) + +Fields: + +- `channel` - The channel the event occurred in +- `reward_cost` - Number of points the user paid for the reward +- `reward_id` - ID of the reward the user redeemed +- `reward_title` - Title of the reward the user redeemed +- `status` - Status of the reward (one of `unknown`, `unfulfilled`, `fulfilled`, and `canceled`) +- `user_id` - The ID of the user who redeemed the reward +- `user_input` - The text the user entered into the input for the reward +- `user` - The login-name of the user who redeemed the reward + ## `clearchat` Moderator action caused chat to be cleared.