mirror of
https://github.com/Luzifer/discord-community.git
synced 2024-12-20 10:21:22 +00:00
Add live announcements
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
5524101e6c
commit
5df755d13b
5 changed files with 283 additions and 0 deletions
|
@ -110,3 +110,30 @@ func (m moduleAttributeStore) String(name string) (string, error) {
|
||||||
|
|
||||||
return "", errValueMismatch
|
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
205
mod_livePosting.go
Normal 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")
|
||||||
|
}
|
|
@ -46,6 +46,9 @@ func (m *modLiveRole) Initialize(crontab *cron.Cron, discord *discordgo.Session,
|
||||||
func (m modLiveRole) addLiveStreamerRole(guildID, userID string, presentRoles []string) error {
|
func (m modLiveRole) addLiveStreamerRole(guildID, userID string, presentRoles []string) error {
|
||||||
// @attr role_streamers_live required string "" Role ID to assign to live streamers
|
// @attr role_streamers_live required string "" Role ID to assign to live streamers
|
||||||
roleID := m.attrs.MustString("role_streamers_live", nil)
|
roleID := m.attrs.MustString("role_streamers_live", nil)
|
||||||
|
if roleID == "" {
|
||||||
|
return errors.New("empty live-role-id")
|
||||||
|
}
|
||||||
if str.StringInSlice(roleID, presentRoles) {
|
if str.StringInSlice(roleID, presentRoles) {
|
||||||
// Already there fine!
|
// Already there fine!
|
||||||
return nil
|
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 {
|
func (m modLiveRole) removeLiveStreamerRole(guildID, userID string, presentRoles []string) error {
|
||||||
roleID := m.attrs.MustString("role_streamers_live", nil)
|
roleID := m.attrs.MustString("role_streamers_live", nil)
|
||||||
|
if roleID == "" {
|
||||||
|
return errors.New("empty live-role-id")
|
||||||
|
}
|
||||||
if !str.StringInSlice(roleID, presentRoles) {
|
if !str.StringInSlice(roleID, presentRoles) {
|
||||||
// Not there: fine!
|
// Not there: fine!
|
||||||
return nil
|
return nil
|
||||||
|
|
32
twitch.go
32
twitch.go
|
@ -74,6 +74,22 @@ type (
|
||||||
Cursor string `json:"cursor"`
|
Cursor string `json:"cursor"`
|
||||||
} `json:"pagination"`
|
} `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 {
|
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) {
|
func (t twitchAdapter) getAppAccessToken(ctx context.Context) (string, error) {
|
||||||
var rData struct {
|
var rData struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
|
|
13
wiki/Home.md
13
wiki/Home.md
|
@ -16,6 +16,19 @@ module_configs:
|
||||||
|
|
||||||
# Modules
|
# 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`
|
## Type: `liverole`
|
||||||
|
|
||||||
Adds live-role to certain group of users if they are streaming on Twitch
|
Adds live-role to certain group of users if they are streaming on Twitch
|
||||||
|
|
Loading…
Reference in a new issue