discord-community/mod_livePosting.go
Knut Ahlers 2b20335fc8
Update dependencies
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2022-03-03 18:47:47 +01:00

345 lines
10 KiB
Go

package main
import (
"context"
"encoding/base64"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/pkg/errors"
"github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus"
"github.com/Luzifer/go_helpers/v2/str"
)
/*
* @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 = 720
livePostingPreviewWidth = 1280
livePostingTwitchColor = 0x6441a5
)
func init() {
RegisterModule("liveposting", func() module { return &modLivePosting{} })
}
type modLivePosting struct {
attrs moduleAttributeStore
discord *discordgo.Session
id string
lock sync.Mutex
}
func (m *modLivePosting) ID() string { return m.id }
func (m *modLivePosting) Initialize(id string, crontab *cron.Cron, discord *discordgo.Session, attrs moduleAttributeStore) error {
m.attrs = attrs
m.discord = discord
m.id = id
if err := attrs.Expect(
"discord_channel_id",
"post_text",
"twitch_client_id",
"twitch_client_secret",
); err != nil {
return errors.Wrap(err, "validating attributes")
}
// @attr disable_presence optional bool "false" Disable posting live-postings for discord presence changes
if !attrs.MustBool("disable_presence", ptrBoolFalse) {
discord.AddHandler(m.handlePresenceUpdate)
}
// @attr cron optional string "*/5 * * * *" Fetch live status of `poll_usernames` (set to empty string to disable): keep this below `stream_freshness` or you might miss streams
if cronDirective := attrs.MustString("cron", ptrString("*/5 * * * *")); cronDirective != "" {
if _, err := crontab.AddFunc(cronDirective, m.cronFetchChannelStatus); err != nil {
return errors.Wrap(err, "adding cron function")
}
}
return nil
}
func (m *modLivePosting) Setup() error { return nil }
func (m *modLivePosting) cronFetchChannelStatus() {
// @attr poll_usernames optional []string "[]" Check these usernames for active streams when executing the `cron` (at most 100 users can be checked)
usernames, err := m.attrs.StringSlice("poll_usernames")
switch err {
case nil:
// We got a list of users
case errValueNotSet:
// There is no list of users
return
default:
log.WithError(err).Error("Unable to get poll_usernames list")
return
}
log.WithField("entries", len(usernames)).Trace("Fetching streams for users (cron)")
if err = m.fetchAndPostForUsername(usernames...); err != nil {
log.WithError(err).Error("Unable to post status for users")
}
}
func (m *modLivePosting) fetchAndPostForUsername(usernames ...string) error {
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(), usernames...)
if err != nil {
return errors.Wrap(err, "fetching twitch user details")
}
streams, err := twitch.GetStreamsForUser(context.Background(), usernames...)
if err != nil {
return errors.Wrap(err, "fetching streams for user")
}
log.WithFields(log.Fields{
"streams": len(streams.Data),
"users": len(users.Data),
}).Trace("Found active streams from users")
// @attr stream_freshness optional duration "5m" How long after stream start to post shoutout
streamFreshness := m.attrs.MustDuration("stream_freshness", ptrDuration(livePostingDefaultStreamFreshness))
for _, stream := range streams.Data {
for _, user := range users.Data {
if user.ID != stream.UserID {
continue
}
isFresh := time.Since(stream.StartedAt) <= streamFreshness
log.WithFields(log.Fields{
"isFresh": isFresh,
"started_at": stream.StartedAt,
"user": user.DisplayName,
}).Trace("Found user / stream combination")
if !isFresh {
// Stream is too old, don't annoounce
continue
}
if err = m.sendLivePost(
user.Login,
user.DisplayName,
stream.Title,
stream.GameName,
stream.ThumbnailURL,
user.ProfileImageURL,
); err != nil {
return errors.Wrap(err, "sending post")
}
}
}
return nil
}
func (m *modLivePosting) handlePresenceUpdate(d *discordgo.Session, p *discordgo.PresenceUpdate) {
if p.User == nil {
// The frick? Non-user presence?
return
}
if p.GuildID != config.GuildID {
// Bot is in multiple guilds, we don't have a config for this one
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, "/")
if err = m.fetchAndPostForUsername(twitchUsername); err != nil {
logger.WithError(err).WithField("url", activity.URL).Error("Unable to fetch info / post live posting")
return
}
}
//nolint:funlen // Makes no sense to split just for 2 lines
func (m *modLivePosting) sendLivePost(username, displayName, title, game, previewImage, profileImage string) error {
m.lock.Lock()
defer m.lock.Unlock()
logger := log.WithFields(log.Fields{
"user": username,
"game": game,
})
// @attr post_text required string "" Message to post to channel use `${displayname}` and `${username}` as placeholders
postTemplateDefault := m.attrs.MustString("post_text", nil)
postText := strings.NewReplacer(
"${displayname}", displayName,
"${username}", username,
).Replace(
// @attr post_text_{username} optional string "" Override the default `post_text` with this one (e.g. `post_text_luziferus: "${displayName} is now live"`)
m.attrs.MustString(fmt.Sprintf("post_text_%s", strings.ToLower(username)), &postTemplateDefault),
)
// @attr discord_channel_id required string "" ID of the Discord channel to post the message to
channelID := m.attrs.MustString("discord_channel_id", nil)
msgs, err := m.discord.ChannelMessages(channelID, livePostingNumberOfMessagesToLoad, "", "", "")
if err != nil {
return errors.Wrap(err, "fetching previous messages")
}
ignoreTime := m.attrs.MustDuration("stream_freshness", ptrDuration(livePostingDefaultStreamFreshness))
for _, msg := range msgs {
if msg.Content != postText {
// Post is for another channel / is another message
continue
}
if time.Since(msg.Timestamp) < ignoreTime {
// Message is still fresh
logger.Debug("Not creating live-post, it's already there")
return nil
}
// @attr remove_old optional bool "false" If set to `true` older message with same content will be deleted
if !m.attrs.MustBool("remove_old", ptrBoolFalse) {
// We're not allowed to purge the old message
continue
}
// Purge the old message
if err = m.discord.ChannelMessageDelete(channelID, msg.ID); err != nil {
return errors.Wrap(err, "deleting old message")
}
}
// Discord caches the images and the URLs do not change every time
// so we force Discord to load a new image every time
previewImageURL, err := url.Parse(
strings.NewReplacer(
"{width}", strconv.Itoa(livePostingPreviewWidth),
"{height}", strconv.Itoa(livePostingPreviewHeight),
).Replace(previewImage),
)
if err != nil {
return errors.Wrap(err, "parsing stream preview URL")
}
previewImageQuery := previewImageURL.Query()
previewImageQuery.Add("_discordNoCache", time.Now().Format(time.RFC3339))
previewImageURL.RawQuery = previewImageQuery.Encode()
// @attr preserve_proxy optional string "" URL prefix of a Luzifer/preserve proxy to cache stream preview for longer
if proxy, err := url.Parse(m.attrs.MustString("preserve_proxy", ptrStringEmpty)); err == nil && proxy.String() != "" {
// Discord screws up the plain-text URL format, so we need to use the b64-format
proxy.Path = "/b64:" + base64.URLEncoding.EncodeToString([]byte(previewImageURL.String()))
previewImageURL = proxy
}
msgEmbed := &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{
Name: displayName,
IconURL: profileImage,
},
Color: livePostingTwitchColor,
Fields: []*discordgo.MessageEmbedField{
{Name: "Game", Value: game},
},
Image: &discordgo.MessageEmbedImage{
URL: previewImageURL.String(),
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}, "/"),
}
logger.Debug("Creating live-post")
msg, err := m.discord.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: postText,
Embed: msgEmbed,
})
if err != nil {
return errors.Wrap(err, "sending message")
}
// @attr auto_publish optional bool "false" Automatically publish (crosspost) the message to followers of the channel
if m.attrs.MustBool("auto_publish", ptrBoolFalse) {
logger.Debug("Auto-Publishing live-post")
if _, err = m.discord.ChannelMessageCrosspost(channelID, msg.ID); err != nil {
return errors.Wrap(err, "publishing message")
}
}
return nil
}