mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-09 16:50:01 +00:00
[core] Add handling for channel point rewards
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
a9637d03a8
commit
78beeaa14b
7 changed files with 256 additions and 93 deletions
34
events.go
34
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,
|
||||
|
|
59
store.go
59
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
|
||||
}
|
||||
|
|
|
@ -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
6
twitch/scopes.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package twitch
|
||||
|
||||
const (
|
||||
ScopeChannelManageRedemptions = "channel:manage:redemptions"
|
||||
ScopeChannelReadRedemptions = "channel:read:redemptions"
|
||||
)
|
|
@ -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)
|
||||
|
|
210
twitchWatcher.go
210
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
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue