discord-community/mod_livePosting.go

218 lines
6.2 KiB
Go
Raw Normal View History

package main
import (
"context"
"net/url"
"strconv"
"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
*/
const (
livePostingDefaultStreamFreshness = 5 * time.Minute
livePostingDiscordProfileHeight = 300
livePostingDiscordProfileWidth = 300
livePostingNumberOfMessagesToLoad = 100
livePostingPreviewHeight = 180
livePostingPreviewWidth = 320
livePostingTwitchColor = 0x6441a5
)
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
}
//nolint: gocyclo // One directive too many, makes no sense to split
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 ID
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(livePostingDefaultStreamFreshness))
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), livePostingNumberOfMessagesToLoad, "", "", "")
if err != nil {
return errors.Wrap(err, "fetching previous messages")
}
ignoreTime := m.attrs.MustDuration("stream_freshness", ptrDuration(livePostingDefaultStreamFreshness))
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: livePostingTwitchColor,
Fields: []*discordgo.MessageEmbedField{
{Name: "Game", Value: game},
},
Image: &discordgo.MessageEmbedImage{
URL: strings.NewReplacer("{width}", strconv.Itoa(livePostingPreviewWidth), "{height}", strconv.Itoa(livePostingPreviewHeight)).Replace(previewImage),
Width: livePostingPreviewWidth,
Height: livePostingPreviewHeight,
},
Thumbnail: &discordgo.MessageEmbedThumbnail{
URL: profileImage,
Width: livePostingDiscordProfileWidth,
Height: livePostingDiscordProfileHeight,
},
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")
}