mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-10 01:00:05 +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
|
@ -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,
|
||||||
|
|
59
store.go
59
store.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
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) {
|
func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) {
|
||||||
var (
|
var (
|
||||||
buf = new(bytes.Buffer)
|
buf = new(bytes.Buffer)
|
||||||
|
|
200
twitchWatcher.go
200
twitchWatcher.go
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in a new issue