From e5e0da737537e58660d07ef4ae0e30e50a97f6f5 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sat, 7 Aug 2021 22:14:01 +0200 Subject: [PATCH] Add reactionrole module. handle optional thumbnail in streamschedule better Signed-off-by: Knut Ahlers --- mod_reactionRole.go | 217 ++++++++++++++++++++++++++++++++++++++++++ mod_streamSchedule.go | 17 ++-- wiki/Home.md | 15 +++ 3 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 mod_reactionRole.go diff --git a/mod_reactionRole.go b/mod_reactionRole.go new file mode 100644 index 0000000..424e0e0 --- /dev/null +++ b/mod_reactionRole.go @@ -0,0 +1,217 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/Luzifer/go_helpers/v2/env" + "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 reactionrole + * @module_desc Creates a post with pre-set reactions and assigns roles on reaction + */ + +func init() { + RegisterModule("reactionrole", func() module { return &modReactionRole{} }) +} + +type modReactionRole struct { + attrs moduleAttributeStore + discord *discordgo.Session + id string +} + +func (m modReactionRole) ID() string { return m.id } + +func (m *modReactionRole) 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", + "embed_title", + "reaction_roles", + ); err != nil { + return errors.Wrap(err, "validating attributes") + } + + discord.AddHandler(m.handleMessageReactionAdd) + discord.AddHandler(m.handleMessageReactionRemove) + + return nil +} + +func (m modReactionRole) Setup() error { + var err error + + // @attr discord_channel_id required string "" ID of the Discord channel to post the message to + channelID := m.attrs.MustString("discord_channel_id", nil) + + msgEmbed := &discordgo.MessageEmbed{ + // @attr embed_color optional int64 "0x2ECC71" Integer representation of the hex color for the embed + Color: int(m.attrs.MustInt64("embed_color", ptrInt64(streamScheduleDefaultColor))), + // @attr embed_description optional string "" Description for the embed block + Description: strings.TrimSpace(m.attrs.MustString("embed_description", ptrStringEmpty)), + Timestamp: time.Now().Format(time.RFC3339), + // @attr embed_title required string "" Title of the embed + Title: m.attrs.MustString("embed_title", nil), + Type: discordgo.EmbedTypeRich, + } + + if m.attrs.MustString("embed_thumbnail_url", ptrStringEmpty) != "" { + msgEmbed.Thumbnail = &discordgo.MessageEmbedThumbnail{ + // @attr embed_thumbnail_url optional string "" Publically hosted image URL to use as thumbnail + URL: m.attrs.MustString("embed_thumbnail_url", ptrStringEmpty), + // @attr embed_thumbnail_width optional int64 "" Width of the thumbnail + Width: int(m.attrs.MustInt64("embed_thumbnail_width", ptrInt64Zero)), + // @attr embed_thumbnail_height optional int64 "" Height of the thumbnail + Height: int(m.attrs.MustInt64("embed_thumbnail_height", ptrInt64Zero)), + } + } + + reactionListRaw, err := m.attrs.StringSlice("reaction_roles") + if err != nil { + return errors.Wrap(err, "getting role list") + } + var reactionList []string + for _, r := range reactionListRaw { + reactionList = append(reactionList, strings.Split(r, "=")[0]) + } + + var managedMsg *discordgo.Message + if err = store.ReadWithLock(m.id, func(a moduleAttributeStore) error { + mid, err := a.String("message_id") + if err == errValueNotSet { + return nil + } + + managedMsg, err = m.discord.ChannelMessage(channelID, mid) + return errors.Wrap(err, "fetching managed message") + }); err != nil && !strings.Contains(err.Error(), "404") { + return err + } + + if managedMsg == nil { + managedMsg, err = m.discord.ChannelMessageSendEmbed(channelID, msgEmbed) + } else if !isDiscordMessageEmbedEqual(managedMsg.Embeds[0], msgEmbed) { + _, err = m.discord.ChannelMessageEditEmbed(channelID, managedMsg.ID, msgEmbed) + } + if err != nil { + return errors.Wrap(err, "updating / creating message") + } + + if err = store.Set(m.id, "message_id", managedMsg.ID); err != nil { + return errors.Wrap(err, "storing managed message id") + } + + var addedReactions []string + + for _, r := range managedMsg.Reactions { + okName := str.StringInSlice(r.Emoji.Name, reactionList) + + compiledName := fmt.Sprintf(":%s:%s", r.Emoji.Name, r.Emoji.ID) + okCode := str.StringInSlice(compiledName, reactionList) + + if !okCode && !okName { + if err = m.discord.MessageReactionsRemoveEmoji(channelID, managedMsg.ID, r.Emoji.ID); err != nil { + return errors.Wrap(err, "removing reaction emoji") + } + continue + } + + addedReactions = append(addedReactions, compiledName, r.Emoji.Name) + } + + for _, emoji := range reactionList { + if !str.StringInSlice(emoji, addedReactions) { + log.WithFields(log.Fields{ + "emote": emoji, + "message": managedMsg.ID, + "module": m.id, + }).Trace("Adding emoji reaction") + if err = m.discord.MessageReactionAdd(channelID, managedMsg.ID, emoji); err != nil { + return errors.Wrap(err, "adding reaction emoji") + } + } + } + + return nil +} + +func (m modReactionRole) extractRoles() (map[string]string, error) { + // @attr reaction_roles required []string "" List of strings in format `emote=role-id` + list, err := m.attrs.StringSlice("reaction_roles") + if err != nil { + return nil, errors.Wrap(err, "getting role list") + } + + return env.ListToMap(list), nil +} + +func (m modReactionRole) handleMessageReaction(d *discordgo.Session, e *discordgo.MessageReaction, add bool) { + if e.UserID == m.discord.State.User.ID { + // Reaction was manipulated by the bot, ignore + return + } + + var ( + err error + messageID string + ) + + if err = store.ReadWithLock(m.id, func(a moduleAttributeStore) error { + messageID, err = a.String("message_id") + return errors.Wrap(err, "reading message ID") + }); err != nil { + log.WithError(err).Error("Unable to get managed message ID") + return + } + + if messageID == "" || e.MessageID != messageID { + // This is not our managed message (or we don't have one), we don't care + return + } + + roles, err := m.extractRoles() + if err != nil { + log.WithError(err).Error("Unable to extract role mapping") + return + } + + for _, check := range []string{e.Emoji.Name, fmt.Sprintf(":%s:%s", e.Emoji.Name, e.Emoji.ID)} { + role, ok := roles[check] + if !ok { + continue + } + + if add { + if err = m.discord.GuildMemberRoleAdd(config.GuildID, e.UserID, strings.Split(role, ":")[0]); err != nil { + log.WithError(err).Error("Unable to add role to user") + } + return + } + + if !strings.HasSuffix(role, ":set") { + if err = m.discord.GuildMemberRoleRemove(config.GuildID, e.UserID, strings.Split(role, ":")[0]); err != nil { + log.WithError(err).Error("Unable to remove role to user") + } + return + } + } +} + +func (m modReactionRole) handleMessageReactionAdd(d *discordgo.Session, e *discordgo.MessageReactionAdd) { + m.handleMessageReaction(d, e.MessageReaction, true) +} + +func (m modReactionRole) handleMessageReactionRemove(d *discordgo.Session, e *discordgo.MessageReactionRemove) { + m.handleMessageReaction(d, e.MessageReaction, false) +} diff --git a/mod_streamSchedule.go b/mod_streamSchedule.go index 03a9aa1..a5f8610 100644 --- a/mod_streamSchedule.go +++ b/mod_streamSchedule.go @@ -91,20 +91,23 @@ func (m modStreamSchedule) cronUpdateSchedule() { // @attr embed_color optional int64 "0x2ECC71" Integer representation of the hex color for the embed Color: int(m.attrs.MustInt64("embed_color", ptrInt64(streamScheduleDefaultColor))), // @attr embed_description optional string "" Description for the embed block - Description: m.attrs.MustString("embed_description", ptrStringEmpty), + Description: strings.TrimSpace(m.attrs.MustString("embed_description", ptrStringEmpty)), Fields: []*discordgo.MessageEmbedField{}, - Thumbnail: &discordgo.MessageEmbedThumbnail{ + Timestamp: time.Now().Format(time.RFC3339), + // @attr embed_title required string "" Title of the embed + Title: m.attrs.MustString("embed_title", nil), + Type: discordgo.EmbedTypeRich, + } + + if m.attrs.MustString("embed_thumbnail_url", ptrStringEmpty) != "" { + msgEmbed.Thumbnail = &discordgo.MessageEmbedThumbnail{ // @attr embed_thumbnail_url optional string "" Publically hosted image URL to use as thumbnail URL: m.attrs.MustString("embed_thumbnail_url", ptrStringEmpty), // @attr embed_thumbnail_width optional int64 "" Width of the thumbnail Width: int(m.attrs.MustInt64("embed_thumbnail_width", ptrInt64Zero)), // @attr embed_thumbnail_height optional int64 "" Height of the thumbnail Height: int(m.attrs.MustInt64("embed_thumbnail_height", ptrInt64Zero)), - }, - Timestamp: time.Now().Format(time.RFC3339), - // @attr embed_title required string "" Title of the embed - Title: m.attrs.MustString("embed_title", nil), - Type: discordgo.EmbedTypeRich, + } } for _, seg := range data.Data.Segments { diff --git a/wiki/Home.md b/wiki/Home.md index fb693bd..dd10c2f 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -106,6 +106,21 @@ Updates the presence status of the bot to display the next stream | `cron` | | string | `* * * * *` | When to execute the module | | `schedule_past_time` | | duration | `15m` | How long in the past should the schedule contain an entry | +## Type: `reactionrole` + +Creates a post with pre-set reactions and assigns roles on reaction + +| Attribute | Req. | Type | Default Value | Description | +| --------- | :--: | ---- | ------------- | ----------- | +| `discord_channel_id` | ✅ | string | | ID of the Discord channel to post the message to | +| `embed_title` | ✅ | string | | Title of the embed | +| `reaction_roles` | ✅ | []string | | List of strings in format `emote=role-id` | +| `embed_color` | | int64 | `0x2ECC71` | Integer representation of the hex color for the embed | +| `embed_description` | | string | | Description for the embed block | +| `embed_thumbnail_height` | | int64 | | Height of the thumbnail | +| `embed_thumbnail_url` | | string | | Publically hosted image URL to use as thumbnail | +| `embed_thumbnail_width` | | int64 | | Width of the thumbnail | + ## Type: `schedule` Posts stream schedule derived from Twitch schedule as embed in Discord channel