diff --git a/mod_liveRole.go b/mod_liveRole.go new file mode 100644 index 0000000..cef27cb --- /dev/null +++ b/mod_liveRole.go @@ -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", + ) +} diff --git a/mod_presence.go b/mod_presence.go index 8225c23..a13ef93 100644 --- a/mod_presence.go +++ b/mod_presence.go @@ -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), ) diff --git a/mod_streamSchedule.go b/mod_streamSchedule.go index 2451126..aa7f030 100644 --- a/mod_streamSchedule.go +++ b/mod_streamSchedule.go @@ -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), ) diff --git a/twitch.go b/twitch.go index b3894ee..624e31b 100644 --- a/twitch.go +++ b/twitch.go @@ -21,8 +21,31 @@ const ( type ( twitchAdapter struct { - clientID string - token string + 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 { @@ -53,10 +76,11 @@ type ( } ) -func newTwitchAdapter(clientID, token string) *twitchAdapter { +func newTwitchAdapter(clientID, clientSecret, token string) *twitchAdapter { return &twitchAdapter{ - clientID: clientID, - token: token, + 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", "") + params.Set("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") diff --git a/wiki/Home.md b/wiki/Home.md index 1755cc4..93a362a 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -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