[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

@ -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,

View file

@ -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
}

View file

@ -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"`

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) {
var (
buf = new(bytes.Buffer)

View file

@ -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
}

View file

@ -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.