2021-07-30 00:08:39 +00:00
package main
import (
"context"
2021-11-05 15:22:37 +00:00
"encoding/base64"
2021-07-30 00:08:39 +00:00
"net/url"
2021-07-31 18:58:11 +00:00
"strconv"
2021-07-30 00:08:39 +00:00
"strings"
2021-08-23 20:34:27 +00:00
"sync"
2021-07-30 00:08:39 +00:00
"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
* /
2021-07-31 18:58:11 +00:00
const (
livePostingDefaultStreamFreshness = 5 * time . Minute
livePostingDiscordProfileHeight = 300
livePostingDiscordProfileWidth = 300
livePostingNumberOfMessagesToLoad = 100
2021-08-28 19:25:14 +00:00
livePostingPreviewHeight = 720
livePostingPreviewWidth = 1280
2021-07-31 18:58:11 +00:00
livePostingTwitchColor = 0x6441a5
)
2021-07-30 00:08:39 +00:00
func init ( ) {
RegisterModule ( "liveposting" , func ( ) module { return & modLivePosting { } } )
}
type modLivePosting struct {
attrs moduleAttributeStore
discord * discordgo . Session
2021-08-07 14:16:14 +00:00
id string
2021-08-23 20:34:27 +00:00
lock sync . Mutex
2021-07-30 00:08:39 +00:00
}
2021-08-23 20:34:27 +00:00
func ( m * modLivePosting ) ID ( ) string { return m . id }
2021-08-07 14:16:14 +00:00
func ( m * modLivePosting ) Initialize ( id string , crontab * cron . Cron , discord * discordgo . Session , attrs moduleAttributeStore ) error {
2021-07-30 00:08:39 +00:00
m . attrs = attrs
m . discord = discord
2021-08-07 14:16:14 +00:00
m . id = id
2021-07-30 00:08:39 +00:00
if err := attrs . Expect (
"discord_channel_id" ,
"post_text" ,
"twitch_client_id" ,
"twitch_client_secret" ,
) ; err != nil {
return errors . Wrap ( err , "validating attributes" )
}
2021-08-01 00:13:05 +00:00
// @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
}
2021-08-23 20:34:27 +00:00
func ( m * modLivePosting ) Setup ( ) error { return nil }
2021-08-07 13:24:11 +00:00
2021-08-23 20:34:27 +00:00
func ( m * modLivePosting ) cronFetchChannelStatus ( ) {
2021-08-01 00:13:05 +00:00
// @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" )
}
}
2021-08-23 20:34:27 +00:00
func ( m * modLivePosting ) fetchAndPostForUsername ( usernames ... string ) error {
2021-08-01 00:13:05 +00:00
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" )
2021-08-06 19:12:29 +00:00
// @attr stream_freshness optional duration "5m" How long after stream start to post shoutout
streamFreshness := m . attrs . MustDuration ( "stream_freshness" , ptrDuration ( livePostingDefaultStreamFreshness ) )
2021-08-01 00:13:05 +00:00
for _ , stream := range streams . Data {
for _ , user := range users . Data {
if user . ID != stream . UserID {
continue
}
2021-08-06 19:42:51 +00:00
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 {
2021-08-01 00:13:05 +00:00
// Stream is too old, don't annoounce
2021-08-06 19:42:51 +00:00
continue
2021-08-01 00:13:05 +00:00
}
if err = m . sendLivePost (
user . Login ,
user . DisplayName ,
stream . Title ,
stream . GameName ,
stream . ThumbnailURL ,
user . ProfileImageURL ,
) ; err != nil {
return errors . Wrap ( err , "sending post" )
}
}
}
2021-07-30 00:08:39 +00:00
return nil
}
2021-08-23 20:34:27 +00:00
func ( m * modLivePosting ) handlePresenceUpdate ( d * discordgo . Session , p * discordgo . PresenceUpdate ) {
2021-07-30 00:08:39 +00:00
if p . User == nil {
// The frick? Non-user presence?
return
}
2021-08-04 15:22:18 +00:00
if p . GuildID != config . GuildID {
// Bot is in multiple guilds, we don't have a config for this one
return
}
2021-07-30 00:08:39 +00:00
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
}
2021-07-31 21:22:10 +00:00
// @attr whitelisted_role optional string "" Only post for members of this role ID
2021-07-30 00:08:39 +00:00
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 , "/" )
2021-08-01 00:13:05 +00:00
if err = m . fetchAndPostForUsername ( twitchUsername ) ; err != nil {
logger . WithError ( err ) . WithField ( "url" , activity . URL ) . Error ( "Unable to fetch info / post live posting" )
2021-07-30 00:08:39 +00:00
return
}
}
2021-11-05 15:38:24 +00:00
//nolint:funlen // Makes no sense to split just for 2 lines
2021-08-23 20:34:27 +00:00
func ( m * modLivePosting ) sendLivePost ( username , displayName , title , game , previewImage , profileImage string ) error {
m . lock . Lock ( )
defer m . lock . Unlock ( )
2021-08-31 08:54:05 +00:00
logger := log . WithFields ( log . Fields {
"user" : username ,
"game" : game ,
} )
2021-07-30 00:08:39 +00:00
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
2021-08-07 14:28:22 +00:00
channelID := m . attrs . MustString ( "discord_channel_id" , nil )
msgs , err := m . discord . ChannelMessages ( channelID , livePostingNumberOfMessagesToLoad , "" , "" , "" )
2021-07-30 00:08:39 +00:00
if err != nil {
return errors . Wrap ( err , "fetching previous messages" )
}
2021-07-31 18:58:11 +00:00
ignoreTime := m . attrs . MustDuration ( "stream_freshness" , ptrDuration ( livePostingDefaultStreamFreshness ) )
2021-07-30 00:08:39 +00:00
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 {
2021-08-31 08:54:05 +00:00
logger . Debug ( "Not creating live-post, it's already there" )
2021-07-30 00:08:39 +00:00
return nil
}
}
2021-08-04 13:11:35 +00:00
// 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 ) ,
)
2021-08-06 12:42:23 +00:00
if err != nil {
return errors . Wrap ( err , "parsing stream preview URL" )
}
2021-08-04 13:11:35 +00:00
previewImageQuery := previewImageURL . Query ( )
previewImageQuery . Add ( "_discordNoCache" , time . Now ( ) . Format ( time . RFC3339 ) )
previewImageURL . RawQuery = previewImageQuery . Encode ( )
2021-10-26 15:13:13 +00:00
// @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 ( ) != "" {
2021-11-05 15:22:37 +00:00
// 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 ( ) ) )
2021-10-26 15:13:13 +00:00
previewImageURL = proxy
}
2021-07-30 00:08:39 +00:00
msgEmbed := & discordgo . MessageEmbed {
Author : & discordgo . MessageEmbedAuthor {
Name : displayName ,
IconURL : profileImage ,
} ,
2021-07-31 18:58:11 +00:00
Color : livePostingTwitchColor ,
2021-07-30 00:08:39 +00:00
Fields : [ ] * discordgo . MessageEmbedField {
{ Name : "Game" , Value : game } ,
} ,
Image : & discordgo . MessageEmbedImage {
2021-08-04 13:11:35 +00:00
URL : previewImageURL . String ( ) ,
2021-07-31 18:58:11 +00:00
Width : livePostingPreviewWidth ,
Height : livePostingPreviewHeight ,
2021-07-30 00:08:39 +00:00
} ,
Thumbnail : & discordgo . MessageEmbedThumbnail {
URL : profileImage ,
2021-07-31 18:58:11 +00:00
Width : livePostingDiscordProfileWidth ,
Height : livePostingDiscordProfileHeight ,
2021-07-30 00:08:39 +00:00
} ,
Title : title ,
Type : discordgo . EmbedTypeRich ,
URL : strings . Join ( [ ] string { "https://www.twitch.tv" , username } , "/" ) ,
}
2021-08-31 08:54:05 +00:00
logger . Debug ( "Creating live-post" )
2021-08-07 14:28:22 +00:00
msg , err := m . discord . ChannelMessageSendComplex ( channelID , & discordgo . MessageSend {
2021-07-30 00:08:39 +00:00
Content : postText ,
Embed : msgEmbed ,
} )
2021-08-07 14:28:22 +00:00
if err != nil {
return errors . Wrap ( err , "sending message" )
}
2021-07-30 00:08:39 +00:00
2021-08-07 14:28:22 +00:00
// @attr auto_publish optional bool "false" Automatically publish (crosspost) the message to followers of the channel
if m . attrs . MustBool ( "auto_publish" , ptrBoolFalse ) {
2021-08-31 08:54:05 +00:00
logger . Debug ( "Auto-Publishing live-post" )
2021-08-07 14:28:22 +00:00
if _ , err = m . discord . ChannelMessageCrosspost ( channelID , msg . ID ) ; err != nil {
return errors . Wrap ( err , "publishing message" )
}
}
return nil
2021-07-30 00:08:39 +00:00
}