Add live-role module

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2021-07-29 23:26:30 +02:00
parent da6c0b35c2
commit 9026200947
Signed by: luzifer
GPG key ID: 0066F03ED215AD7D
5 changed files with 255 additions and 5 deletions

151
mod_liveRole.go Normal file
View file

@ -0,0 +1,151 @@
package main
import (
"context"
"net/url"
"strings"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/bwmarrin/discordgo"
"github.com/pkg/errors"
"github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus"
)
/*
* @module liverole
* @module_desc Adds live-role to certain group of users if they are streaming on Twitch
*/
func init() {
RegisterModule("liverole", func() module { return &modLiveRole{} })
}
type modLiveRole struct {
attrs moduleAttributeStore
discord *discordgo.Session
}
func (m *modLiveRole) Initialize(crontab *cron.Cron, discord *discordgo.Session, attrs moduleAttributeStore) error {
m.attrs = attrs
m.discord = discord
if err := attrs.Expect(
"role_streamers_live",
"twitch_client_id",
"twitch_client_secret",
); err != nil {
return errors.Wrap(err, "validating attributes")
}
discord.AddHandler(m.handlePresenceUpdate)
return nil
}
func (m modLiveRole) addLiveStreamerRole(guildID, userID string, presentRoles []string) error {
// @attr role_streamers_live required string "" Role ID to assign to live streamers
roleID := m.attrs.MustString("role_streamers_live", nil)
if str.StringInSlice(roleID, presentRoles) {
// Already there fine!
return nil
}
return errors.Wrap(
m.discord.GuildMemberRoleAdd(guildID, userID, roleID),
"adding role",
)
}
func (m modLiveRole) handlePresenceUpdate(d *discordgo.Session, p *discordgo.PresenceUpdate) {
if p.User == nil {
// The frick? Non-user presence?
return
}
logger := log.WithFields(log.Fields{
"user": p.User.ID,
})
member, err := d.GuildMember(p.GuildID, p.User.ID)
if err != nil {
logger.WithError(err).Error("Unable to fetch member status for user")
return
}
// @attr role_streamers optional string "" Only take members with this role into account
roleStreamer := m.attrs.MustString("role_streamers", ptrStringEmpty)
if roleStreamer != "" && !str.StringInSlice(roleStreamer, member.Roles) {
// User is not part of the streamer role
return
}
var exitFunc func(string, string, []string) error = m.removeLiveStreamerRole
defer func() {
if exitFunc != nil {
if err := exitFunc(p.GuildID, p.User.ID, member.Roles); err != nil {
logger.WithError(err).Error("Unable to update live-streamer-role")
}
}
}()
var activity *discordgo.Activity
for _, a := range p.Activities {
if a.Type == discordgo.ActivityTypeStreaming {
activity = a
break
}
}
if activity == nil {
// No streaming activity: Remove role
exitFunc = m.removeLiveStreamerRole
return
}
u, err := url.Parse(activity.URL)
if err != nil {
logger.WithError(err).WithField("url", activity.URL).Warning("Unable to parse activity URL")
exitFunc = m.removeLiveStreamerRole
return
}
if u.Host != "twitch.tv" {
logger.WithError(err).WithField("url", activity.URL).Warning("Activity is not on Twitch")
exitFunc = m.removeLiveStreamerRole
return
}
twitch := newTwitchAdapter(
// @attr twitch_client_id required string "" Twitch client ID the token was issued for
m.attrs.MustString("twitch_client_id", nil),
// @attr twitch_client_secret required string "" Secret for the Twitch app identified with twitch_client_id
m.attrs.MustString("twitch_client_secret", nil),
"", // No User-Token used
)
streams, err := twitch.GetStreamsForUser(context.Background(), strings.TrimLeft(u.Path, "/"))
if err != nil {
logger.WithError(err).WithField("user", strings.TrimLeft(u.Path, "/")).Warning("Unable to fetch streams for user")
exitFunc = m.removeLiveStreamerRole
return
}
if len(streams.Data) > 0 {
exitFunc = m.addLiveStreamerRole
}
}
func (m modLiveRole) removeLiveStreamerRole(guildID, userID string, presentRoles []string) error {
roleID := m.attrs.MustString("role_streamers_live", nil)
if !str.StringInSlice(roleID, presentRoles) {
// Not there: fine!
return nil
}
return errors.Wrap(
m.discord.GuildMemberRoleRemove(guildID, userID, roleID),
"adding role",
)
}

View file

@ -54,6 +54,7 @@ func (m modPresence) cronUpdatePresence() {
twitch := newTwitchAdapter(
// @attr twitch_client_id required string "" Twitch client ID the token was issued for
m.attrs.MustString("twitch_client_id", nil),
"", // No client secret used
// @attr twitch_token required string "" Token for the user the `twitch_channel_id` belongs to
m.attrs.MustString("twitch_token", nil),
)

View file

@ -57,6 +57,7 @@ func (m modStreamSchedule) cronUpdateSchedule() {
twitch := newTwitchAdapter(
// @attr twitch_client_id required string "" Twitch client ID the token was issued for
m.attrs.MustString("twitch_client_id", nil),
"", // No Client Secret used
// @attr twitch_token required string "" Token for the user the `twitch_channel_id` belongs to
m.attrs.MustString("twitch_token", nil),
)

View file

@ -22,9 +22,32 @@ const (
type (
twitchAdapter struct {
clientID string
clientSecret string
token string
}
twitchStreamListing struct {
Data []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
UserLogin string `json:"user_login"`
UserName string `json:"user_name"`
GameID string `json:"game_id"`
GameName string `json:"game_name"`
Type string `json:"type"`
Title string `json:"title"`
ViewerCount int64 `json:"viewer_count"`
StartedAt time.Time `json:"started_at"`
Language string `json:"language"`
ThumbnailURL string `json:"thumbnail_url"`
TagIds []string `json:"tag_ids"`
IsMature bool `json:"is_mature"`
} `json:"data"`
Pagination struct {
Cursor string `json:"cursor"`
} `json:"pagination"`
}
twitchStreamSchedule struct {
Data struct {
Segments []struct {
@ -53,9 +76,10 @@ type (
}
)
func newTwitchAdapter(clientID, token string) *twitchAdapter {
func newTwitchAdapter(clientID, clientSecret, token string) *twitchAdapter {
return &twitchAdapter{
clientID: clientID,
clientSecret: clientSecret,
token: token,
}
}
@ -79,6 +103,60 @@ func (t twitchAdapter) GetChannelStreamSchedule(ctx context.Context, broadcaster
})
}
func (t twitchAdapter) GetStreamsForUser(ctx context.Context, userName string) (*twitchStreamListing, error) {
out := &twitchStreamListing{}
params := make(url.Values)
params.Set("user_login", strings.ToLower(userName))
return out, backoff.NewBackoff().
WithMaxIterations(twitchAPIRequestLimit).
Retry(func() error {
return errors.Wrap(
t.request(ctx, http.MethodGet, "/helix/streams", params, nil, out),
"fetching streams",
)
})
}
func (t twitchAdapter) getAppAccessToken(ctx context.Context) (string, error) {
var rData struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Scope []interface{} `json:"scope"`
TokenType string `json:"token_type"`
}
params := make(url.Values)
params.Set("client_id", "<your client ID>")
params.Set("client_secret", "<your client secret>")
params.Set("grant_type", "client_credentials")
u, _ := url.Parse("https://id.twitch.tv/oauth2/token")
u.RawQuery = params.Encode()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", errors.Wrap(err, "fetching response")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", errors.Wrapf(err, "unexpected status %d and cannot read body", resp.StatusCode)
}
return "", errors.Errorf("unexpected status %d: %s", resp.StatusCode, body)
}
return rData.AccessToken, errors.Wrap(
json.NewDecoder(resp.Body).Decode(&rData),
"decoding response",
)
}
func (t twitchAdapter) request(ctx context.Context, method, path string, params url.Values, body io.Reader, output interface{}) error {
ctxTimed, cancel := context.WithTimeout(ctx, twitchAPIRequestTimeout)
defer cancel()
@ -96,6 +174,14 @@ func (t twitchAdapter) request(ctx context.Context, method, path string, params
req.Header.Set("Authorization", strings.Join([]string{"Bearer", t.token}, " "))
req.Header.Set("Client-Id", t.clientID)
if t.token == "" {
accessToken, err := t.getAppAccessToken(ctx)
if err != nil {
return errors.Wrap(err, "fetching app-access-token")
}
req.Header.Set("Authorization", strings.Join([]string{"Bearer", accessToken}, " "))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "fetching response")

View file

@ -16,6 +16,17 @@ module_configs:
# Modules
## Type: `liverole`
Adds live-role to certain group of users if they are streaming on Twitch
| Attribute | Req. | Type | Default Value | Description |
| --------- | :--: | ---- | ------------- | ----------- |
| `role_streamers_live` | ✅ | string | | Role ID to assign to live streamers |
| `twitch_client_id` | ✅ | string | | Twitch client ID the token was issued for |
| `twitch_client_secret` | ✅ | string | | Secret for the Twitch app identified with twitch_client_id |
| `role_streamers` | | string | | Only take members with this role into account |
## Type: `presence`
Updates the presence status of the bot to display the next stream