Add live announcements

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2021-07-30 02:08:39 +02:00
parent 5524101e6c
commit 5df755d13b
Signed by: luzifer
GPG key ID: 0066F03ED215AD7D
5 changed files with 283 additions and 0 deletions

View file

@ -110,3 +110,30 @@ func (m moduleAttributeStore) String(name string) (string, error) {
return "", errValueMismatch
}
func (m moduleAttributeStore) StringSlice(name string) ([]string, error) {
v, ok := m[name]
if !ok {
return nil, errValueNotSet
}
switch v.(type) {
case []string:
return v.([]string), nil
case []interface{}:
var out []string
for _, iv := range v.([]interface{}) {
sv, ok := iv.(string)
if !ok {
return nil, errors.New("value in slice was not string")
}
out = append(out, sv)
}
return out, nil
}
return nil, errValueMismatch
}

205
mod_livePosting.go Normal file
View file

@ -0,0 +1,205 @@
package main
import (
"context"
"net/url"
"strings"
"time"
"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 liveposting
* @module_desc Announces stream live status based on Discord streaming status
*/
func init() {
RegisterModule("liveposting", func() module { return &modLivePosting{} })
}
type modLivePosting struct {
attrs moduleAttributeStore
discord *discordgo.Session
}
func (m *modLivePosting) Initialize(crontab *cron.Cron, discord *discordgo.Session, attrs moduleAttributeStore) error {
m.attrs = attrs
m.discord = discord
if err := attrs.Expect(
"discord_channel_id",
"post_text",
"whitelisted_role",
"twitch_client_id",
"twitch_client_secret",
); err != nil {
return errors.Wrap(err, "validating attributes")
}
discord.AddHandler(m.handlePresenceUpdate)
return nil
}
func (m modLivePosting) 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 whitelisted_role optional string "" Only post for members of this role
whitelistedRole := m.attrs.MustString("whitelisted_role", ptrStringEmpty)
if whitelistedRole != "" && !str.StringInSlice(whitelistedRole, member.Roles) {
// User is not allowed for this config
return
}
var activity *discordgo.Activity
for _, a := range p.Activities {
if a.Type == discordgo.ActivityTypeStreaming {
activity = a
break
}
}
if activity == nil {
// No streaming activity: Do nothing
return
}
u, err := url.Parse(activity.URL)
if err != nil {
logger.WithError(err).WithField("url", activity.URL).Warning("Unable to parse activity URL")
return
}
if u.Host != "www.twitch.tv" {
logger.WithError(err).WithField("url", activity.URL).Debug("Activity is not on Twitch")
return
}
twitchUsername := strings.TrimLeft(u.Path, "/")
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
)
users, err := twitch.GetUserByUsername(context.Background(), twitchUsername)
if err != nil {
logger.WithError(err).WithField("user", twitchUsername).Warning("Unable to fetch details for user")
return
}
if l := len(users.Data); l != 1 {
logger.WithError(err).WithField("url", activity.URL).Warning("Unable to fetch user for login")
return
}
streams, err := twitch.GetStreamsForUser(context.Background(), twitchUsername)
if err != nil {
logger.WithError(err).WithField("user", twitchUsername).Debug("Unable to fetch streams for user")
return
}
if l := len(streams.Data); l != 1 {
logger.WithError(err).WithField("url", activity.URL).Debug("Unable to fetch streams for login")
return
}
// @attr stream_freshness optional duration "5m" How long after stream start to post shoutout
ignoreTime := m.attrs.MustDuration("stream_freshness", ptrDuration(5*time.Minute))
if streams.Data[0].StartedAt.Add(ignoreTime).Before(time.Now()) {
// Stream is too old, don't annoounce
return
}
if err = m.sendLivePost(
users.Data[0].Login,
users.Data[0].DisplayName,
streams.Data[0].Title,
streams.Data[0].GameName,
streams.Data[0].ThumbnailURL,
users.Data[0].ProfileImageURL,
); err != nil {
logger.WithError(err).WithField("url", activity.URL).Error("Unable to send post")
return
}
}
func (m modLivePosting) sendLivePost(username, displayName, title, game, previewImage, profileImage string) error {
postText := strings.NewReplacer(
"${displayname}", displayName,
"${username}", username,
).Replace(
// @attr post_text required string "" Message to post to channel use `${displayname}` and `${username}` as placeholders
m.attrs.MustString("post_text", nil),
)
// @attr discord_channel_id required string "" ID of the Discord channel to post the message to
msgs, err := m.discord.ChannelMessages(m.attrs.MustString("discord_channel_id", nil), 100, "", "", "")
if err != nil {
return errors.Wrap(err, "fetching previous messages")
}
ignoreTime := m.attrs.MustDuration("stream_freshness", ptrDuration(5*time.Minute))
for _, msg := range msgs {
mt, err := msg.Timestamp.Parse()
if err != nil {
return errors.Wrap(err, "parsing message timestamp")
}
if msg.Content == postText && time.Since(mt) < ignoreTime {
return nil
}
}
msgEmbed := &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{
Name: displayName,
IconURL: profileImage,
},
Color: 0x6441a5,
Fields: []*discordgo.MessageEmbedField{
{Name: "Game", Value: game},
},
Image: &discordgo.MessageEmbedImage{
URL: strings.NewReplacer("{width}", "320", "{height}", "180").Replace(previewImage),
Width: 320,
Height: 180,
},
Thumbnail: &discordgo.MessageEmbedThumbnail{
URL: profileImage,
Width: 300,
Height: 300,
},
Title: title,
Type: discordgo.EmbedTypeRich,
URL: strings.Join([]string{"https://www.twitch.tv", username}, "/"),
}
_, err = m.discord.ChannelMessageSendComplex(m.attrs.MustString("discord_channel_id", nil), &discordgo.MessageSend{
Content: postText,
Embed: msgEmbed,
})
return errors.Wrap(err, "sending message")
}

View file

@ -46,6 +46,9 @@ func (m *modLiveRole) Initialize(crontab *cron.Cron, discord *discordgo.Session,
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 roleID == "" {
return errors.New("empty live-role-id")
}
if str.StringInSlice(roleID, presentRoles) {
// Already there fine!
return nil
@ -139,6 +142,9 @@ func (m modLiveRole) handlePresenceUpdate(d *discordgo.Session, p *discordgo.Pre
func (m modLiveRole) removeLiveStreamerRole(guildID, userID string, presentRoles []string) error {
roleID := m.attrs.MustString("role_streamers_live", nil)
if roleID == "" {
return errors.New("empty live-role-id")
}
if !str.StringInSlice(roleID, presentRoles) {
// Not there: fine!
return nil

View file

@ -74,6 +74,22 @@ type (
Cursor string `json:"cursor"`
} `json:"pagination"`
}
twitchUserListing struct {
Data []struct {
ID string `json:"id"`
Login string `json:"login"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
BroadcasterType string `json:"broadcaster_type"`
Description string `json:"description"`
ProfileImageURL string `json:"profile_image_url"`
OfflineImageURL string `json:"offline_image_url"`
ViewCount int64 `json:"view_count"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
} `json:"data"`
}
)
func newTwitchAdapter(clientID, clientSecret, token string) *twitchAdapter {
@ -119,6 +135,22 @@ func (t twitchAdapter) GetStreamsForUser(ctx context.Context, userName string) (
})
}
func (t twitchAdapter) GetUserByUsername(ctx context.Context, userName string) (*twitchUserListing, error) {
out := &twitchUserListing{}
params := make(url.Values)
params.Set("login", strings.ToLower(userName))
return out, backoff.NewBackoff().
WithMaxIterations(twitchAPIRequestLimit).
Retry(func() error {
return errors.Wrap(
t.request(ctx, http.MethodGet, "/helix/users", params, nil, out),
"fetching user",
)
})
}
func (t twitchAdapter) getAppAccessToken(ctx context.Context) (string, error) {
var rData struct {
AccessToken string `json:"access_token"`

View file

@ -16,6 +16,19 @@ module_configs:
# Modules
## Type: `liveposting`
Announces stream live status based on Discord streaming status
| Attribute | Req. | Type | Default Value | Description |
| --------- | :--: | ---- | ------------- | ----------- |
| `discord_channel_id` | ✅ | string | | ID of the Discord channel to post the message to |
| `post_text` | ✅ | string | | Message to post to channel use `${displayname}` and `${username}` as placeholders |
| `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 |
| `stream_freshness` | | duration | `5m` | How long after stream start to post shoutout |
| `whitelisted_role` | | string | | Only post for members of this role |
## Type: `liverole`
Adds live-role to certain group of users if they are streaming on Twitch