[core] Add handling for channel point rewards

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2021-12-25 00:47:40 +01:00
parent a9637d03a8
commit 78beeaa14b
Signed by: luzifer
GPG key ID: 0066F03ED215AD7D
7 changed files with 256 additions and 93 deletions

View file

@ -5,6 +5,7 @@ func ptrStr(s string) *string { return &s }
var ( var (
eventTypeBan = ptrStr("ban") eventTypeBan = ptrStr("ban")
eventTypeBits = ptrStr("bits") eventTypeBits = ptrStr("bits")
eventTypeChannelPointRedeem = ptrStr("channelpoint_redeem")
eventTypeClearChat = ptrStr("clearchat") eventTypeClearChat = ptrStr("clearchat")
eventTypeFollow = ptrStr("follow") eventTypeFollow = ptrStr("follow")
eventTypeGiftPaidUpgrade = ptrStr("giftpaidupgrade") eventTypeGiftPaidUpgrade = ptrStr("giftpaidupgrade")
@ -28,6 +29,7 @@ var (
knownEvents = []*string{ knownEvents = []*string{
eventTypeBan, eventTypeBan,
eventTypeBits, eventTypeBits,
eventTypeChannelPointRedeem,
eventTypeClearChat, eventTypeClearChat,
eventTypeFollow, eventTypeFollow,
eventTypeGiftPaidUpgrade, eventTypeGiftPaidUpgrade,

View file

@ -11,6 +11,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/twitch-bot/plugins" "github.com/Luzifer/twitch-bot/plugins"
) )
@ -23,6 +24,8 @@ type storageFile struct {
ModuleStorage map[string]json.RawMessage `json:"module_storage"` ModuleStorage map[string]json.RawMessage `json:"module_storage"`
GrantedScopes map[string][]string `json:"granted_scopes"`
EventSubSecret string `json:"event_sub_secret,omitempty"` EventSubSecret string `json:"event_sub_secret,omitempty"`
inMem bool inMem bool
@ -37,11 +40,22 @@ func newStorageFile(inMemStore bool) *storageFile {
ModuleStorage: map[string]json.RawMessage{}, ModuleStorage: map[string]json.RawMessage{},
GrantedScopes: map[string][]string{},
inMem: inMemStore, inMem: inMemStore,
lock: new(sync.RWMutex), 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 { func (s *storageFile) DeleteModuleStore(moduleUUID string) error {
s.lock.Lock() s.lock.Lock()
defer s.lock.Unlock() 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 { func (s *storageFile) SetModuleStore(moduleUUID string, storedObject plugins.StorageMarshaller) error {
s.lock.Lock() s.lock.Lock()
defer s.lock.Unlock() 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") 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
}

View file

@ -46,6 +46,8 @@ const (
EventSubEventTypeChannelUpdate = "channel.update" EventSubEventTypeChannelUpdate = "channel.update"
EventSubEventTypeStreamOffline = "stream.offline" EventSubEventTypeStreamOffline = "stream.offline"
EventSubEventTypeStreamOnline = "stream.online" EventSubEventTypeStreamOnline = "stream.online"
EventSubEventTypeChannelPointCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add"
) )
type ( type (
@ -73,6 +75,25 @@ type (
UserID string `json:"user_id,omitempty"` 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 { EventSubEventChannelUpdate struct {
BroadcasterUserID string `json:"broadcaster_user_id"` BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"` BroadcasterUserLogin string `json:"broadcaster_user_login"`

6
twitch/scopes.go Normal file
View file

@ -0,0 +1,6 @@
package twitch
const (
ScopeChannelManageRedemptions = "channel:manage:redemptions"
ScopeChannelReadRedemptions = "channel:read:redemptions"
)

View file

@ -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) { func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) {
var ( var (
buf = new(bytes.Buffer) buf = new(bytes.Buffer)

View file

@ -82,6 +82,71 @@ func (t *twitchWatcher) RemoveChannel(channel string) error {
return nil 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 { func (t *twitchWatcher) updateChannelFromAPI(channel string, sendUpdate bool) error {
var ( var (
err error err error
@ -129,91 +194,82 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error
return nil, errors.Wrap(err, "resolving channel to user-id") return nil, errors.Wrap(err, "resolving channel to user-id")
} }
unsubCU, err := twitchEventSubClient.RegisterEventSubHooks( var (
twitch.EventSubEventTypeChannelUpdate, topicRegistrations = []struct {
twitch.EventSubCondition{BroadcasterUserID: userID}, Topic string
func(m json.RawMessage) error { Condition twitch.EventSubCondition
var payload twitch.EventSubEventChannelUpdate RequiredScopes []string
if err := json.Unmarshal(m, &payload); err != nil { AnyScope bool
return errors.Wrap(err, "unmarshalling event") Hook func(json.RawMessage) error
} }{
{
t.triggerUpdate(channel, &payload.Title, &payload.CategoryName, nil) Topic: twitch.EventSubEventTypeChannelUpdate,
Condition: twitch.EventSubCondition{BroadcasterUserID: userID},
return nil RequiredScopes: nil,
Hook: t.handleEventSubChannelUpdate,
}, },
) {
if err != nil { Topic: twitch.EventSubEventTypeStreamOffline,
return nil, errors.Wrap(err, "registering channel-update eventsub") Condition: twitch.EventSubCondition{BroadcasterUserID: userID},
} RequiredScopes: nil,
Hook: t.handleEventSubStreamOnOff(false),
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")
}
t.triggerUpdate(channel, nil, nil, func(v bool) *bool { return &v }(false))
return nil
}, },
) {
if err != nil { Topic: twitch.EventSubEventTypeStreamOnline,
return nil, errors.Wrap(err, "registering channel-update eventsub") Condition: twitch.EventSubCondition{BroadcasterUserID: userID},
} RequiredScopes: nil,
Hook: t.handleEventSubStreamOnOff(true),
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
}, },
{
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()
) )
if err != nil {
return nil, errors.Wrap(err, "registering channel-update eventsub")
}
unsubFollow, err := twitchEventSubClient.RegisterEventSubHooks( for _, tr := range topicRegistrations {
twitch.EventSubEventTypeChannelFollow, logger := log.WithFields(log.Fields{
twitch.EventSubCondition{BroadcasterUserID: userID}, "any": tr.AnyScope,
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, "channel": channel,
"followed_at": payload.FollowedAt, "scopes": tr.RequiredScopes,
"user_id": payload.UserID, "topic": tr.Topic,
"user": payload.UserLogin,
}) })
log.WithFields(log.Fields(fields.Data())).Info("User followed") if len(tr.RequiredScopes) > 0 {
go handleMessage(ircHdl.Client(), nil, eventTypeFollow, fields) fn := store.UserHasGrantedScopes
if tr.AnyScope {
fn = store.UserHasGrantedAnyScope
}
return nil if !fn(channel, tr.RequiredScopes...) {
}, logger.Debug("Missing scopes for eventsub topic")
) continue
}
}
uf, err := twitchEventSubClient.RegisterEventSubHooks(tr.Topic, tr.Condition, tr.Hook)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "registering channel-follow eventsub") logger.WithError(err).Error("Unable to register topic")
continue
}
unsubHandlers = append(unsubHandlers, uf)
} }
return func() { return func() {
unsubCU() for _, f := range unsubHandlers {
unsubSOff() f()
unsubSOn() }
unsubFollow()
}, nil }, nil
} }

View file

@ -31,6 +31,21 @@ Fields:
- `category` - The name of the new game / category - `category` - The name of the new game / category
- `channel` - The channel the event occurred in - `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` ## `clearchat`
Moderator action caused chat to be cleared. Moderator action caused chat to be cleared.