diff --git a/.gitignore b/.gitignore index 9bc3a8f..8c74cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ cake4everybot # configuration files for tokens, access keys and other connection data *env.yaml + +# runtime config files (as described in config.yml) +modules/secretsanta/backlist.json +modules/secretsanta/players.json +twitch/prizes.json +twitch/times.json diff --git a/config.yaml b/config.yaml index 5ae1f5e..06107ea 100644 --- a/config.yaml +++ b/config.yaml @@ -33,26 +33,65 @@ event: adventcalendar: images: data/images/adventcalendar - # Name: The name of this emoji, e.g. '🎅', '❤️' when a default emoji - # ID: The snowflake ID if when a custom emoji - # Animated: Whether this emoji is animated. Defaults to false - emoji.name: ✅ - #emoji.id: - #emoji.animated: true + + secretsanta: + # the filepath for the players + players: modules/secretsanta/players.json + # the filepath for the blacklist + # the blacklist maps a user ID to a list of user IDs that should not be matched + blacklist: modules/secretsanta/backlist.json twitch_giveaway: # The amount of points a single giveaway ticket costs. ticket_cost: 1000 - # Cooldown in minutes before beeing able to by another ticket + # Cooldown in minutes before beeing able to buy another ticket cooldown: 15 # the filepath for of the json giveaway prizes prizes: twitch/prizes.json # the filepath for storing the giveaway cooldown times times: twitch/times.json + emoji: + # Configuration for emojis used by the bot + # Name: The name of this emoji, e.g. '🎅', '❤️' when a default emoji + # ID: The snowflake ID when a custom emoji + # Animated: Whether this emoji is animated. Defaults to false if not set + + # Voting for yes + vote.yes: + name: 👍 + #id: + #animated: true + # Voting for no + vote.no: + name: 👎 + #id: + #animated: true + # Emoji for entering the advent calendar giveaway + adventcalendar: + name: ✅ + #id: + #animated: true + secretsanta: vote.yes + secretsanta.invite.show_match: + name: 🎁 + #id: + #animated: true + secretsanta.invite.set_address: + name: 🏠 + #id: + #animated: true + secretsanta.invite.delete: + name: 🗑️ + #id: + #animated: true + secretsanta.invite.nudge_match: + name: 👉 + #id: + #animated: true + webserver: favicon: webserver/favicon.png - birthday_hour: 8 # Time to trigger daily birthday check (24h format) twitch: name: c4e_bot diff --git a/data/lang/de.yaml b/data/lang/de.yaml index 3b268b5..5733840 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -123,6 +123,50 @@ discord.command: msg.winner.details: "__Gewinner: %s__\nTickets: %d/24\nGewinnchance: %.2f%%" msg.winner.congratulation: "Herzlichen Glückwunsch, %s! :heart:\nFrohe Weihnachten an alle!" + secretsanta: + base: Wichteln + display: Wichteln + + title: Wichteln + + msg.setup.no_reactions: Diese Nachricht hat keine Reaktionen. Nur Leute, die mit %s reagiert haben, werden eingeschlossen. + msg.setup.not_enough_reactions: Nicht genug Reaktionen um zu starten. Es werden mindestens %d Reaktionen benötigt. + msg.setup.users: Teilnehmer + msg.setup.match_error: Fehler beim Auslosen + msg.setup.invite: Einladen + msg.setup.invite.error: "Einladung(en) konnte(n) nicht verschickt werden an:%s" + msg.setup.error: "%d Einladungen konnten nicht verschickt werden." + msg.setup.success: Einladungen wurden verschickt! + + msg.invite.title: Einladung zum Wichteln. + msg.invite.description: Du nimmst am Cake4Everyone Wichteln teil. Klicke die Knöpfe unten, um deinen Partner zu sehen und deine Adresse einzutragen. + msg.invite.set_address.match: Dein Partner hat eine Adresse eingetragen + msg.invite.button.show_match: Partner anzeigen + msg.invite.show_match.title: Dein Partner ist %s + msg.invite.show_match.description: Bitte breite ein Wichtelgeschenk vor und schicke es ihm/ihr. Halte dich dabei an unsere vereinbarten Regeln. + msg.invite.show_match.address: Adresse + msg.invite.show_match.address_not_set: Dein Partner hat noch keine Adresse eingetragen. + + Falls schon einige Tage vergangen sind, kannst du den "Anstupsen" Knopf drücken. Dein Partner bekommt daraufhin einen Hinweis die Adresse einzutragen. + msg.invite.show_match.nudge_description: Wenn du vermutest, dass die eingetragene Adresse einen Fehler enthält, kannst du unten den den "Anstupsen" Knopf drücken. Dein Partner bekommt daraufhin einen Hinweis die Adresse zu aktualisieren. + msg.invite.set_address: Deine Adresse wurde eingetragen + msg.invite.button.set_address: Deine Adresse eintragen + msg.invite.set_address.changed: Deine Adresse wurde aktualisiert auf + msg.invite.set_address.not_changed: Deine Adresse wurde nicht aktualisiert, weil es die gleiche ist wie vorher. + msg.invite.set_address.match_updated: Dein Partner hat gerade seine/ihre Adresse aktualisiert. Das ist nur um dich zu informieren - Boop Boop 🤖 + msg.invite.button.delete: Schließen + msg.invite.button.nudge_match: Anstupsen + msg.invite.nudge_match.confirm: Bist du sicher, dass du deinen Partner anstupsen möchtest? + + Bestätige, dass du deinen Partner anstupsen möchtest. Er/Sie bekommt dann einen Hinweis die Adresse zu aktualisieren. + msg.invite.nudge_match.success: Dein Partner wurde erfolgreich angestupst + msg.invite.nudge_match.pending: Du hast deinen Partner angestupst. Ich melde mich, sobald er/sie die Adresse aktualisiert hat. + msg.invite.nudge_received: Dein Wichtel hat dich anstupst. Bitte trage eine Adresse ein falls du es noch nicht gemacht hast oder überprüfe deine bereits eingetragene Adresse ob sie eventuell einen Tippfehler oder ähnliches enthält. + + msg.invite.modal.set_address.title: Deine Adresse eintragen + msg.invite.modal.set_address.label: Deine Adresse wird deinem Wichtel angezeigt + msg.invite.modal.set_address.placeholder: "Max Mustermann\nMusterstraße 1\n12345 Musterstadt" + module: adventcalendar: post.message: Noch %d Mal schlafen bis Heilig Abend! Heute öffnet sich das **Türchen %d**. diff --git a/data/lang/en.yaml b/data/lang/en.yaml index 03b5734..3e0e369 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -123,6 +123,50 @@ discord.command: msg.winner.details: "__Winner: %s__\nTickets: %d/24\nProbability of winning: %.2f%%" msg.winner.congratulation: "Congratulations, %s! :heart:\nMerry XMas everyone!" + secretsanta: + base: Secret Santa + display: Secret Santa + + title: Secret Santa + + msg.setup.no_reactions: This message doesn't have any vote reactions. Only members who reated with %s are included. + msg.setup.not_enough_reactions: Not enough votes to start a game. At least %d votes are required. + msg.setup.users: Members + msg.setup.match_error: Failed to match + msg.setup.invite: Invite + msg.setup.invite.error: "Failed to send invite(s) to:%s" + msg.setup.error: Failed to send %d invites. + msg.setup.success: Invites sent! + + msg.invite.title: Invite for secret santa. + msg.invite.description: You are participating in Cake4Everyone Secret Santa. Click the buttons below to see your partner and set your address. + msg.invite.set_address.match: Your partner has set an address + msg.invite.button.show_match: Show partner + msg.invite.show_match.title: Your partner is %s + msg.invite.show_match.description: Please prepare and send them a secret gift according to our agreed rules. + msg.invite.show_match.address: Address + msg.invite.show_match.address_not_set: Your partner has not set an address yet. + + If some days passed, you can click the "Nudge" button below. Your partner will receive a notification saying that they enter their address. + msg.invite.show_match.nudge_description: If you think the entered address is wrong, you can click the "Nudge" button below. Your partner will receive a notification saying that they should update their address. + msg.invite.set_address: Your address is set + msg.invite.button.set_address: Set your address + msg.invite.set_address.changed: Your address was updated to + msg.invite.set_address.not_changed: Your address was not updated, because it is the same as before. + msg.invite.set_address.match_updated: Your partner just updated their address. This is just to inform you - Beep Boop 🤖 + msg.invite.button.delete: Close + msg.invite.button.nudge_match: Nudge + msg.invite.nudge_match.confirm: Are you sure you want to nudge your partner? + + Please confirm that you want to nudge your partner. They will receive a notification that they should update their address. + msg.invite.nudge_match.success: Your partner was nudged! + msg.invite.nudge_match.pending: You nudged your partner. I will notify you, when they updated their address. + msg.invite.nudge_received: Your santa nudged you. Please enter an address if you haven't done it yet or check your already entered address for any typos. + + msg.invite.modal.set_address.title: Set your address + msg.invite.modal.set_address.label: Your address will be shown your secret santa + msg.invite.modal.set_address.placeholder: "Mr. John Doe\n123 Main Street\nAnytown, USA 12345" + module: adventcalendar: post.message: Just sleep %d more times! Its time for **door %d**. diff --git a/event/command/commandBase.go b/event/command/commandBase.go index e89f770..ee709e0 100644 --- a/event/command/commandBase.go +++ b/event/command/commandBase.go @@ -18,6 +18,7 @@ import ( "cake4everybot/modules/adventcalendar" "cake4everybot/modules/birthday" "cake4everybot/modules/info" + "cake4everybot/modules/secretsanta" "cake4everybot/util" "fmt" "log" @@ -69,6 +70,7 @@ func Register(s *discordgo.Session, guildID string) error { commandsList = append(commandsList, &birthday.Chat{}) commandsList = append(commandsList, &info.Chat{}) commandsList = append(commandsList, &adventcalendar.Chat{}) + commandsList = append(commandsList, &secretsanta.MsgCmd{}) // messsage commands // user commands commandsList = append(commandsList, &birthday.UserShow{}) diff --git a/event/commands.go b/event/commands.go index 78f199b..ccf97d2 100644 --- a/event/commands.go +++ b/event/commands.go @@ -17,6 +17,7 @@ package event import ( "cake4everybot/event/command" "cake4everybot/event/component" + "cake4everybot/event/modal" "strings" "github.com/bwmarrin/discordgo" @@ -42,5 +43,11 @@ func handleInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreat } else { log.Printf("got component interaction from unknown module '%s' (full id '%s')", strings.Split(data.CustomID, ".")[0], data.CustomID) } + + case discordgo.InteractionModalSubmit: + data := i.ModalSubmitData() + if m, ok := modal.ModalMap[strings.Split(data.CustomID, ".")[0]]; ok { + m.HandleModal(s, i) + } } } diff --git a/event/component/componentBase.go b/event/component/componentBase.go index dd54eb2..f4d03ec 100644 --- a/event/component/componentBase.go +++ b/event/component/componentBase.go @@ -2,6 +2,7 @@ package component import ( "cake4everybot/modules/adventcalendar" + "cake4everybot/modules/secretsanta" "log" "github.com/bwmarrin/discordgo" @@ -31,6 +32,7 @@ func Register() { var componentList []Component componentList = append(componentList, adventcalendar.Component{}) + componentList = append(componentList, secretsanta.Component{}) if len(componentList) == 0 { return diff --git a/event/event.go b/event/event.go index 18a55c1..f12f2e9 100644 --- a/event/event.go +++ b/event/event.go @@ -17,6 +17,7 @@ package event import ( "cake4everybot/event/command" "cake4everybot/event/component" + "cake4everybot/event/modal" "cake4everybot/event/twitch" logger "log" @@ -33,6 +34,7 @@ func PostRegister(dc *discordgo.Session, t *twitchgo.Twitch, guildID string) err return err } component.Register() + modal.Register() twitch.Register(t) diff --git a/event/modal/componentBase.go b/event/modal/componentBase.go new file mode 100644 index 0000000..24ebf37 --- /dev/null +++ b/event/modal/componentBase.go @@ -0,0 +1,42 @@ +package modal + +import ( + "cake4everybot/modules/secretsanta" + "log" + + "github.com/bwmarrin/discordgo" +) + +// Modal is an interface wrapper for all message components. +type Modal interface { + // Function of a component. + // All things that should happen after submitting a modal. + HandleModal(*discordgo.Session, *discordgo.InteractionCreate) + + // Custom ID of the modal to identify the module + ID() string +} + +// ModalMap holds all active modals. It maps them from a unique string identifier to the +// corresponding [Modal]. +var ModalMap = make(map[string]Modal) + +// Register registers modals +func Register() { + // This is the list of modals to use. Add a modal via + // simply appending the struct (which must implement the + // [Modal] interface) to the list, e.g.: + // + // ModalList = append(ModalList, mymodule.MyComponent{}) + var ModalList []Modal + + ModalList = append(ModalList, secretsanta.Component{}) + + if len(ModalList) == 0 { + return + } + for _, c := range ModalList { + ModalMap[c.ID()] = c + } + log.Printf("Added %d modal handler(s)!", len(ModalMap)) +} diff --git a/go.mod b/go.mod index 47c08fe..e29cd61 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/kesuaheli/twitchgo v0.2.7 github.com/spf13/viper v1.19.0 + golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f ) require ( @@ -27,7 +28,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.29.0 // indirect - golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/modules/adventcalendar/post.go b/modules/adventcalendar/post.go index 96e011d..f5aa75b 100644 --- a/modules/adventcalendar/post.go +++ b/modules/adventcalendar/post.go @@ -68,11 +68,7 @@ func postData(t time.Time) *discordgo.MessageSend { fmt.Sprintf("%s.post.%s", Component.ID(Component{}), t.Format("2006.01.02")), lang.GetDefault("module.adventcalendar.post.button"), discordgo.PrimaryButton, - &discordgo.ComponentEmoji{ - Name: viper.GetString("event.adventcalendar.emoji.name"), - ID: viper.GetString("event.adventcalendar.emoji.id"), - Animated: viper.GetBool("event.adventcalendar.emoji.animated"), - }, + util.GetConfigComponentEmoji("adventcalendar"), ), }}, } diff --git a/modules/secretsanta/component.go b/modules/secretsanta/component.go new file mode 100644 index 0000000..8bfc761 --- /dev/null +++ b/modules/secretsanta/component.go @@ -0,0 +1,74 @@ +package secretsanta + +import ( + "cake4everybot/util" + "strings" + + "github.com/bwmarrin/discordgo" +) + +// The Component of the secret santa package. +type Component struct { + secretSantaBase + data discordgo.MessageComponentInteractionData + modal discordgo.ModalSubmitInteractionData +} + +// Handle handles the functionality of a component. +func (c Component) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + c.InteractionUtil = util.InteractionUtil{Session: s, Interaction: i} + c.member = i.Member + c.user = i.User + if i.Member != nil { + c.user = i.Member.User + } else if i.User != nil { + c.member = &discordgo.Member{User: i.User} + } + c.data = i.MessageComponentData() + + ids := strings.Split(c.data.CustomID, ".") + // pop the first level identifier + util.ShiftL(ids) + + switch util.ShiftL(ids) { + case "setup": + c.handleSetup(ids) + return + case "invite": + c.handleInvite(ids) + return + default: + log.Printf("Unknown component interaction ID: %s", c.data.CustomID) + } + +} + +// HandleModal handles the functionality of a modal. +func (c Component) HandleModal(s *discordgo.Session, i *discordgo.InteractionCreate) { + c.InteractionUtil = util.InteractionUtil{Session: s, Interaction: i} + c.member = i.Member + c.user = i.User + if i.Member != nil { + c.user = i.Member.User + } else if i.User != nil { + c.member = &discordgo.Member{User: i.User} + } + c.modal = i.ModalSubmitData() + + ids := strings.Split(c.modal.CustomID, ".") + // pop the first level identifier + util.ShiftL(ids) + + switch util.ShiftL(ids) { + case "set_address": + c.handleModalSetAddress(ids) + return + default: + log.Printf("Unknown modal submit ID: %s", c.modal.CustomID) + } +} + +// ID returns the custom ID of the modal to identify the module +func (c Component) ID() string { + return "secretsanta" +} diff --git a/modules/secretsanta/handleComponentInvite.go b/modules/secretsanta/handleComponentInvite.go new file mode 100644 index 0000000..a182105 --- /dev/null +++ b/modules/secretsanta/handleComponentInvite.go @@ -0,0 +1,190 @@ +package secretsanta + +import ( + "cake4everybot/data/lang" + "cake4everybot/util" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) + +func (c Component) handleInvite(ids []string) { + switch util.ShiftL(ids) { + case "show_match": + c.handleInviteShowMatch(ids) + return + case "set_address": + c.handleInviteSetAddress(ids) + return + case "nudge_match": + c.handleInviteNudgeMatch(ids) + case "confirm_nudge": + c.handleInviteConfirmNudge(ids) + return + case "delete": + err := c.Session.ChannelMessageDelete(c.Interaction.ChannelID, c.Interaction.Message.ID) + if err != nil { + log.Printf("ERROR: could not delete message %s/%s: %+v", c.Interaction.ChannelID, c.Interaction.Message.ID, err) + c.ReplyError() + } + return + default: + log.Printf("Unknown component interaction ID: %s", c.data.CustomID) + } +} + +func (c Component) handleInviteShowMatch(ids []string) { + c.Interaction.GuildID = util.ShiftL(ids) + players, err := c.getPlayers() + if err != nil { + log.Printf("ERROR: could not get players: %+v", err) + c.ReplyError() + return + } + if len(players) == 0 { + log.Printf("ERROR: no players in guild %s", c.Interaction.GuildID) + c.ReplyError() + return + } + player, ok := players[c.Interaction.User.ID] + if !ok { + log.Printf("ERROR: could not find player %s in guild %s: %+v", c.Interaction.User.ID, c.Interaction.GuildID, c.Interaction.User.ID) + c.ReplyError() + return + } + + e := util.AuthoredEmbed(c.Session, player.Match.Member, tp+"display") + e.Title = fmt.Sprintf(lang.GetDefault(tp+"msg.invite.show_match.title"), player.Match.Member.DisplayName()) + e.Description = lang.GetDefault(tp + "msg.invite.show_match.description") + e.Color = 0x690042 + e.Fields = append(e.Fields, &discordgo.MessageEmbedField{ + Name: lang.GetDefault(tp + "msg.invite.show_match.address"), + Value: fmt.Sprintf("```\n%s\n```\n%s", player.Match.Address, lang.GetDefault(tp+"msg.invite.show_match.nudge_description")), + }) + if player.Match.Address == "" { + e.Fields[0].Value = lang.GetDefault(tp + "msg.invite.show_match.address_not_set") + } + + util.SetEmbedFooter(c.Session, tp+"display", e) + c.ReplyComponentsHiddenEmbed( + []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + util.CreateButtonComponent( + fmt.Sprintf("secretsanta.invite.nudge_match.%s", c.Interaction.GuildID), + lang.GetDefault(tp+"msg.invite.button.nudge_match"), + discordgo.SecondaryButton, + util.GetConfigComponentEmoji("secretsanta.invite.nudge_match"), + ), + }}}, + e) +} + +func (c Component) handleInviteSetAddress(ids []string) { + c.Interaction.GuildID = util.ShiftL(ids) + players, err := c.getPlayers() + if err != nil { + log.Printf("ERROR: could not get players: %+v", err) + c.ReplyError() + return + } + if len(players) == 0 { + log.Printf("ERROR: no players in guild %s", c.Interaction.GuildID) + c.ReplyError() + return + } + + player, ok := players[c.Interaction.User.ID] + if !ok { + log.Printf("ERROR: could not find player %s in guild %s: %+v", c.Interaction.User.ID, c.Interaction.GuildID, c.Interaction.User.ID) + c.ReplyError() + return + } + + c.ReplyModal("secretsanta.set_address."+c.Interaction.GuildID, lang.GetDefault(tp+"msg.invite.modal.set_address.title"), discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "address", + Label: lang.GetDefault(tp + "msg.invite.modal.set_address.label"), + Style: discordgo.TextInputParagraph, + Placeholder: lang.GetDefault(tp + "msg.invite.modal.set_address.placeholder"), + Value: player.Address, + Required: true, + }, + }}) +} + +func (c Component) handleInviteNudgeMatch(ids []string) { + c.ReplyComponentsHiddenSimpleEmbedUpdate( + []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + util.CreateButtonComponent( + "secretsanta.invite.confirm_nudge."+strings.Join(ids, "."), + lang.GetDefault(tp+"msg.invite.button.nudge_match"), + discordgo.PrimaryButton, + util.GetConfigComponentEmoji("secretsanta.invite.nudge_match"), + ), + }}}, + 0x690042, + lang.GetDefault(tp+"msg.invite.nudge_match.confirm")) +} + +func (c Component) handleInviteConfirmNudge(ids []string) { + c.Interaction.GuildID = util.ShiftL(ids) + players, err := c.getPlayers() + if err != nil { + log.Printf("ERROR: could not get players: %+v", err) + c.ReplyError() + return + } + if len(players) == 0 { + log.Printf("ERROR: no players in guild %s", c.Interaction.GuildID) + c.ReplyError() + return + } + + player, ok := players[c.Interaction.User.ID] + if !ok { + log.Printf("ERROR: could not find player %s in guild %s: %+v", c.Interaction.User.ID, c.Interaction.GuildID, c.Interaction.User.ID) + c.ReplyError() + return + } + player.Match.PendingNudge = true + + matchChannel, err := c.Session.UserChannelCreate(player.Match.User.ID) + if err != nil { + log.Printf("ERROR: could not create DM channel with user %s: %+v", player.Match.User.ID, err) + c.ReplyError() + return + } + _, err = c.Session.ChannelMessageEditEmbed(matchChannel.ID, player.Match.MessageID, player.Match.InviteEmbed(c.Session)) + if err != nil { + log.Printf("ERROR: could not edit match message embed: %+v", err) + c.ReplyError() + return + } + + data := &discordgo.MessageSend{ + Content: lang.GetDefault(tp + "msg.invite.nudge_received"), + Reference: &discordgo.MessageReference{MessageID: player.Match.MessageID}, + Components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + util.CreateButtonComponent( + "secretsanta.invite.delete", + lang.GetDefault(tp+"msg.invite.button.delete"), + discordgo.DangerButton, + util.GetConfigComponentEmoji("secretsanta.invite.delete"), + ), + }}}, + } + _, err = c.Session.ChannelMessageSendComplex(matchChannel.ID, data) + if err != nil { + log.Printf("ERROR: could not send nudge message: %+v", err) + c.ReplyError() + return + } + + _, err = c.Session.ChannelMessageEditEmbed(c.Interaction.ChannelID, player.MessageID, player.InviteEmbed(c.Session)) + if err != nil { + log.Printf("ERROR: could not edit invite message embed: %+v", err) + c.ReplyError() + return + } + c.ReplyHiddenSimpleEmbedUpdate(0x690042, lang.GetDefault(tp+"msg.invite.nudge_match.success")) +} diff --git a/modules/secretsanta/handleComponentSetup.go b/modules/secretsanta/handleComponentSetup.go new file mode 100644 index 0000000..b037c81 --- /dev/null +++ b/modules/secretsanta/handleComponentSetup.go @@ -0,0 +1,90 @@ +package secretsanta + +import ( + "cake4everybot/data/lang" + "cake4everybot/util" + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func (c Component) handleSetup(ids []string) { + switch util.ShiftL(ids) { + case "invite": + c.handleSetupInvite() + return + default: + log.Printf("Unknown component interaction ID: %s", c.data.CustomID) + } +} + +func (c Component) handleSetupInvite() { + players, err := c.getPlayers() + if err != nil { + log.Printf("ERROR: could not get players: %+v", err) + c.ReplyError() + return + } + c.ReplyDeferedHidden() + players, err = derangementMatch(players) + if err != nil { + log.Printf("ERROR: could not match players: %+v", err) + c.ReplySimpleEmbed(0xFF0000, lang.GetDefault(tp+"msg.setup.match_error")) + return + } + + inviteMessage := &discordgo.MessageSend{ + Embeds: make([]*discordgo.MessageEmbed, 1), + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + util.CreateButtonComponent( + fmt.Sprintf("secretsanta.invite.show_match.%s", c.Interaction.GuildID), + lang.GetDefault(tp+"msg.invite.button.show_match"), + discordgo.PrimaryButton, + util.GetConfigComponentEmoji("secretsanta.invite.show_match"), + ), + util.CreateButtonComponent( + fmt.Sprintf("secretsanta.invite.set_address.%s", c.Interaction.GuildID), + lang.GetDefault(tp+"msg.invite.button.set_address"), + discordgo.SecondaryButton, + util.GetConfigComponentEmoji("secretsanta.invite.set_address"), + ), + }}, + }, + } + + var failedToSend string + for _, player := range players { + var DMChannel *discordgo.Channel + DMChannel, err = c.Session.UserChannelCreate(player.User.ID) + if err != nil { + log.Printf("ERROR: could not create DM channel for user %s: %+v", player.User.ID, err) + failedToSend += "\n- " + player.Mention() + continue + } + + inviteMessage.Embeds[0] = player.InviteEmbed(c.Session) + var msg *discordgo.Message + msg, err = c.Session.ChannelMessageSendComplex(DMChannel.ID, inviteMessage) + if err != nil { + log.Printf("ERROR: could not send invite: %+v", err) + failedToSend += "\n- " + player.Mention() + continue + } + player.MessageID = msg.ID + } + + if failedToSend != "" { + c.ReplyHiddenSimpleEmbedf(0xFF0000, lang.GetDefault(tp+"msg.setup.invite.error"), failedToSend) + return + } + + err = c.setPlayers(players) + if err != nil { + log.Printf("ERROR: could not save players to file: %+v", err) + c.ReplyError() + return + } + + c.ReplyHiddenSimpleEmbed(0x690042, lang.GetDefault(tp+"msg.setup.success")) +} diff --git a/modules/secretsanta/handleModal.go b/modules/secretsanta/handleModal.go new file mode 100644 index 0000000..be4c7dd --- /dev/null +++ b/modules/secretsanta/handleModal.go @@ -0,0 +1,94 @@ +package secretsanta + +import ( + "cake4everybot/data/lang" + "cake4everybot/util" + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func (c Component) handleModalSetAddress(ids []string) { + c.Interaction.GuildID = util.ShiftL(ids) + players, err := c.getPlayers() + if err != nil { + log.Printf("ERROR: could not get players: %+v", err) + c.ReplyError() + return + } + if len(players) == 0 { + log.Printf("ERROR: no players in guild %s", c.Interaction.GuildID) + c.ReplyError() + return + } + player, ok := players[c.Interaction.User.ID] + if !ok { + log.Printf("ERROR: could not find player %s in guild %s: %+v", c.Interaction.User.ID, c.Interaction.GuildID, c.Interaction.User.ID) + c.ReplyError() + return + } + + addressFiled := c.modal.Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput) + if addressFiled.Value == player.Address { + c.ReplyHidden(lang.GetDefault(tp + "msg.invite.set_address.not_changed")) + return + } + + player.Address = addressFiled.Value + player.PendingNudge = false + err = c.setPlayers(players) + if err != nil { + log.Printf("ERROR: could not set players: %+v", err) + c.ReplyError() + return + } + + _, err = c.Session.ChannelMessageEditEmbed(c.Interaction.ChannelID, player.MessageID, player.InviteEmbed(c.Session)) + if err != nil { + log.Printf("ERROR: could not update bot message for %s '%s/%s': %+v", player.DisplayName(), c.Interaction.ChannelID, player.MessageID, err) + c.ReplyError() + return + } + + santaPlayer := c.getSantaForPlayer(player.User.ID) + santaChannel, err := c.Session.UserChannelCreate(santaPlayer.User.ID) + if err != nil { + log.Printf("ERROR: could not get user channel for %s: %+v", santaPlayer.DisplayName(), err) + c.ReplyError() + return + } + _, err = c.Session.ChannelMessageEditEmbed(santaChannel.ID, santaPlayer.MessageID, santaPlayer.InviteEmbed(c.Session)) + if err != nil { + log.Printf("ERROR: could not update bot message for %s '%s/%s': %+v", santaPlayer.DisplayName(), santaChannel.ID, santaPlayer.MessageID, err) + c.ReplyError() + return + } + _, err = c.Session.ChannelMessageSendComplex(santaChannel.ID, &discordgo.MessageSend{ + Content: lang.GetDefault(tp + "msg.invite.set_address.match_updated"), + Reference: &discordgo.MessageReference{MessageID: santaPlayer.MessageID}, + Components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + util.CreateButtonComponent( + "secretsanta.invite.delete", + lang.GetDefault(tp+"msg.invite.button.delete"), + discordgo.DangerButton, + util.GetConfigComponentEmoji("secretsanta.invite.delete"), + ), + }}}, + }) + if err != nil { + log.Printf("ERROR: could not send address update message for %s '%s/%s': %+v", santaPlayer.DisplayName(), santaChannel.ID, santaPlayer.MessageID, err) + c.ReplyError() + return + } + + e := &discordgo.MessageEmbed{ + Color: 0x00FF00, + Fields: []*discordgo.MessageEmbedField{{ + Name: lang.GetDefault(tp + "msg.invite.set_address.changed"), + Value: fmt.Sprintf("```\n%s\n```", player.Address), + }}, + } + + util.SetEmbedFooter(c.Session, tp+"display", e) + c.ReplyHiddenEmbed(e) +} diff --git a/modules/secretsanta/handlerMessageSetup.go b/modules/secretsanta/handlerMessageSetup.go new file mode 100644 index 0000000..4778995 --- /dev/null +++ b/modules/secretsanta/handlerMessageSetup.go @@ -0,0 +1,88 @@ +package secretsanta + +import ( + "cake4everybot/data/lang" + "cake4everybot/util" + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func (cmd MsgCmd) handler() { + joinEmoji := util.GetConfigEmoji("secretsanta") + joinEmojiID := joinEmoji.ID + if joinEmojiID == "" { + joinEmojiID = joinEmoji.Name + } + + msg := cmd.data.Resolved.Messages[cmd.data.TargetID] + if len(msg.Reactions) == 0 { + cmd.ReplyHiddenf(lang.GetDefault(tp+"msg.setup.no_reactions"), joinEmojiID) + return + } + var hasReaction bool + for _, r := range msg.Reactions { + if !util.CompareEmoji(r.Emoji, joinEmoji) { + continue + } + hasReaction = true + break + } + + if !hasReaction { + cmd.ReplyHiddenf(lang.GetDefault(tp+"msg.setup.no_reactions"), joinEmojiID) + return + } + + users, err := cmd.Session.MessageReactions(msg.ChannelID, msg.ID, joinEmojiID, 100, "", "") + if err != nil { + log.Printf("Error on get users: %v\n", err) + cmd.ReplyError() + return + } + + if len(users) < 2 { + cmd.ReplyHiddenf(lang.GetDefault(tp+"msg.setup.not_enough_reactions"), 2) + return + } + + e := &discordgo.MessageEmbed{ + Title: lang.GetDefault(tp + "title"), + Color: 0x690042, + } + + var ( + names string + players = map[string]*player{} + ) + for _, u := range users { + member, err := cmd.Session.GuildMember(cmd.Interaction.GuildID, u.ID) + if member == nil { + log.Printf("WARN: Could not get member '%s' from guild '%s': %v", u.ID, cmd.Interaction.GuildID, err) + continue + } + players[u.ID] = &player{Member: member} + names += fmt.Sprintf("%s\n", member.Mention()) + } + if len(players) < 2 { + cmd.ReplyHiddenf(lang.GetDefault(tp+"msg.setup.not_enough_reactions"), 2) + return + } + util.AddEmbedField(e, lang.GetDefault(tp+"msg.setup.users"), names, false) + + err = cmd.setPlayers(players) + if err != nil { + log.Printf("Error on set players: %v\n", err) + cmd.ReplyError() + return + } + + components := []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + util.CreateButtonComponent("secretsanta.setup.invite", "Invite", discordgo.SuccessButton, nil), + }}, + } + + util.SetEmbedFooter(cmd.Session, tp+"display", e) + cmd.ReplyComponentsHiddenEmbed(components, e) +} diff --git a/modules/secretsanta/messageCommandSetup.go b/modules/secretsanta/messageCommandSetup.go new file mode 100644 index 0000000..c9f7afd --- /dev/null +++ b/modules/secretsanta/messageCommandSetup.go @@ -0,0 +1,49 @@ +package secretsanta + +import ( + "cake4everybot/data/lang" + "cake4everybot/util" + + "github.com/bwmarrin/discordgo" +) + +// MsgCmd represents the mesaage command of the secretsanta package. It adds the ability to start a +// new secret santa game. +type MsgCmd struct { + secretSantaBase + + data discordgo.ApplicationCommandInteractionData + ID string +} + +// AppCmd (ApplicationCommand) returns the definition of the chat command +func (cmd *MsgCmd) AppCmd() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Type: discordgo.MessageApplicationCommand, + Name: lang.GetDefault(tp + "base"), + NameLocalizations: util.TranslateLocalization(tp + "base"), + } +} + +// Handle handles the functionality of a command +func (cmd *MsgCmd) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + cmd.InteractionUtil = util.InteractionUtil{Session: s, Interaction: i} + cmd.member = i.Member + cmd.user = i.User + if i.Member != nil { + cmd.user = i.Member.User + } + + cmd.data = cmd.Interaction.ApplicationCommandData() + cmd.handler() +} + +// SetID sets the registered command ID for internal uses after uploading to discord +func (cmd *MsgCmd) SetID(id string) { + cmd.ID = id +} + +// GetID gets the registered command ID +func (cmd *MsgCmd) GetID() string { + return cmd.ID +} diff --git a/modules/secretsanta/secretsantabase.go b/modules/secretsanta/secretsantabase.go new file mode 100644 index 0000000..5f1a25c --- /dev/null +++ b/modules/secretsanta/secretsantabase.go @@ -0,0 +1,271 @@ +package secretsanta + +import ( + "cake4everybot/data/lang" + "cake4everybot/util" + "encoding/json" + "fmt" + logger "log" + "os" + + "github.com/bwmarrin/discordgo" + "github.com/spf13/viper" + "golang.org/x/exp/rand" +) + +const ( + // Prefix for translation key, i.e.: + // key := tp+"base" // => adventcalendar + tp = "discord.command.secretsanta." +) + +var log = logger.New(logger.Writer(), "[SecretSanta] ", logger.LstdFlags|logger.Lmsgprefix) + +type secretSantaBase struct { + util.InteractionUtil + member *discordgo.Member + user *discordgo.User +} + +// getPlayers returns the list of players for the current guild. If it is the first time, it loads +// the players from the file or creates an empty file. +func (ssb secretSantaBase) getPlayers() (map[string]*player, error) { + if allPlayers != nil { + return allPlayers[ssb.Interaction.GuildID], nil + } + + log.Println("First time getting players. Loading from file...") + playersPath := viper.GetString("event.secretsanta.players") + playersData, err := os.ReadFile(playersPath) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read players file: %v", err) + } + allPlayers = make(AllPlayers) + playersData, err = json.Marshal(allPlayers) + if err != nil { + return nil, fmt.Errorf("marshal players file: %v", err) + } + err = os.WriteFile(playersPath, playersData, 0644) + if err != nil { + return nil, fmt.Errorf("write players file: %v", err) + } + log.Printf("Created players file: %s\n", playersPath) + return map[string]*player{}, nil + } + allPlayersUnresolved := AllPlayersUnresolved{} + err = json.Unmarshal(playersData, &allPlayersUnresolved) + if err != nil { + return nil, fmt.Errorf("unmarshal players file: %v", err) + } + err = allPlayersUnresolved.Resolve(ssb.Session) + if err != nil { + return nil, fmt.Errorf("resolve players file: %v", err) + } + log.Printf("Got %d guilds from file", len(allPlayers)) + + return allPlayers[ssb.Interaction.GuildID], nil +} + +// getSantaForPlayer returns the santa player of the given player for the current guild i.e. the +// player whose match is the given player. +// +// It panics if the player doesn't exist. +func (ssb secretSantaBase) getSantaForPlayer(playerID string) *player { + for _, player := range allPlayers[ssb.Interaction.GuildID] { + if player.Match.User.ID == playerID { + return player + } + } + panic(fmt.Sprintf("tried to get santa for player that doesn't exist: '%s' in guild '%s'", playerID, ssb.Interaction.GuildID)) +} + +// setPlayers sets the players for the current guild. +func (ssb secretSantaBase) setPlayers(players map[string]*player) (err error) { + if _, err = ssb.getPlayers(); err != nil { + return err + } + + allPlayers[ssb.Interaction.GuildID] = players + playersData, err := json.Marshal(allPlayers) + if err != nil { + return fmt.Errorf("marshal players file: %v", err) + } + err = os.WriteFile(viper.GetString("event.secretsanta.players"), playersData, 0644) + if err != nil { + return fmt.Errorf("write players file: %v", err) + } + return nil +} + +// player is a player in the secret santa game +type player struct { + *discordgo.Member + + // Match is the matched player + Match *player + // Address is the address of the player + Address string + // MessageID is the message the bot sent to the player + MessageID string + // PendingNudge is true if the player has received a nugde from their santa and they haven't changed their + // address yet i.e. the nudge is still pending. + PendingNudge bool +} + +// InviteEmbed returns an embed for the player to be sent by the bot. +func (player *player) InviteEmbed(s *discordgo.Session) (e *discordgo.MessageEmbed) { + var matchValue, addressValue = "❌", "❌" + if player != nil && player.Match.Address != "" { + if player.Match.PendingNudge { + matchValue = fmt.Sprintf("%s %s", "⌛", lang.GetDefault(tp+"msg.invite.nudge_match.pending")) + } else { + matchValue = "✅" + } + } + if player != nil && player.Address != "" { + if player.PendingNudge { + addressValue = fmt.Sprintf("%s %s", "⚠️", lang.GetDefault(tp+"msg.invite.nudge_received")) + } else { + addressValue = "✅" + } + } + + e = &discordgo.MessageEmbed{ + Title: lang.GetDefault(tp + "msg.invite.title"), + Description: lang.GetDefault(tp + "msg.invite.description"), + Color: 0x690042, + Fields: []*discordgo.MessageEmbedField{ + { + Name: lang.GetDefault(tp + "msg.invite.set_address.match"), + Value: matchValue, + Inline: true, + }, + { + Name: lang.GetDefault(tp + "msg.invite.set_address"), + Value: addressValue, + Inline: true, + }, + }, + } + util.SetEmbedFooter(s, tp+"display", e) + return e +} + +type playerUnresolved struct { + MatchID string `json:"match"` + Address string `json:"address"` + MessageID string `json:"message"` +} + +// AllPlayers is a map from guild ID to a list of players +type AllPlayers map[string]map[string]*player + +// allPlayers is the current state of all players. +// See [AllPlayers] +var allPlayers AllPlayers + +// MarshalJSON implements json.Marshaler +func (allPlayers AllPlayers) MarshalJSON() ([]byte, error) { + m := make(AllPlayersUnresolved) + for guildID, players := range allPlayers { + m[guildID] = make(map[string]*playerUnresolved) + for userID, player := range players { + var matchID string + if player.Match != nil { + matchID = player.Match.User.ID + } + m[guildID][userID] = &playerUnresolved{ + MatchID: matchID, + Address: player.Address, + MessageID: player.MessageID, + } + } + } + return json.Marshal(m) +} + +// AllPlayersUnresolved is a map from guild ID to a list of unresolved players. +// Unresolved players have no member but only an ID +type AllPlayersUnresolved map[string]map[string]*playerUnresolved + +// Resolve resolves allPlayersUnresolved into allPlayers +func (allPlayersUnresolved AllPlayersUnresolved) Resolve(s *discordgo.Session) (err error) { + allPlayers = make(AllPlayers) + for guildID, unresolvedPlayers := range allPlayersUnresolved { + allPlayers[guildID] = make(map[string]*player) + for userID, up := range unresolvedPlayers { + member, err := s.GuildMember(guildID, userID) + if err != nil { + return fmt.Errorf("failed to get guild member %s/%s: %v", guildID, userID, err) + } + allPlayers[guildID][userID] = &player{ + Member: member, + Match: allPlayers[guildID][up.MatchID], + Address: up.Address, + MessageID: up.MessageID, + } + } + for userID, rp := range allPlayers[guildID] { + if rp.Match != nil { + continue + } + rp.Match = allPlayers[guildID][unresolvedPlayers[userID].MatchID] + } + } + return nil +} + +var blacklist map[string][]string + +// loadBlacklist loads the blacklist from the configured file path. +func loadBlacklist() (err error) { + blacklistPath := viper.GetString("event.secretsanta.blacklist") + blacklistData, err := os.ReadFile(blacklistPath) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("read blacklist file: %w", err) + } + if err = os.WriteFile(blacklistPath, []byte("{}"), 0644); err != nil { + return fmt.Errorf("write blacklist file: %w", err) + } + return nil + } + + blacklist = make(map[string][]string) + if err = json.Unmarshal(blacklistData, &blacklist); err != nil { + return fmt.Errorf("unmarshal blacklist file: %w", err) + } + return nil +} + +// derangementMatch matches the players in a way that no one gets matched to themselves. +func derangementMatch(players map[string]*player) (map[string]*player, error) { + loadBlacklist() + n := len(players) + playersSlice := make([]*player, 0, n) + for _, p := range players { + p.Match = p + playersSlice = append(playersSlice, p) + } + + for i := 0; i < n-1; i++ { + j := i + rand.Intn(n-i-1) + 1 + if !util.ContainsString(blacklist[playersSlice[i].User.ID], playersSlice[j].Match.User.ID) { + log.Printf("%s would match blacklisted %s. Attempt to generate new match.", playersSlice[i].DisplayName(), playersSlice[j].Match.DisplayName()) + // HACK: very simple attempt to avoid blacklisted matches + // just in case a match is blacklisted, generate a new match. If it is still + // blacklisted, then it will be caught by the blacklist check below. + j = i + rand.Intn(n-i-1) + 1 + } + playersSlice[i].Match, playersSlice[j].Match = playersSlice[j].Match, playersSlice[i].Match + } + + for id, blacklisted := range blacklist { + if player, ok := players[id]; ok && util.ContainsString(blacklisted, player.Match.User.ID) { + return nil, fmt.Errorf("'%s' has a blacklisted match: '%s'", player.DisplayName(), player.Match.DisplayName()) + } + } + + return players, nil +} diff --git a/util/discord.go b/util/discord.go index 4724f92..3fce1f3 100644 --- a/util/discord.go +++ b/util/discord.go @@ -48,7 +48,7 @@ func AuthoredEmbed[T *discordgo.User | *discordgo.Member](s *discordgo.Session, panic("Given generic type is not an discord user or member") } user = member.User - username = member.Nick + username = member.DisplayName() } if username == "" { @@ -151,3 +151,46 @@ func GetChannelsFromDatabase(s *discordgo.Session, channelName string) (map[stri return IDMap, nil } + +// GetConfigComponentEmoji returns a configured [discordgo.ComponentEmoji] for the given name. +func GetConfigComponentEmoji(name string) *discordgo.ComponentEmoji { + e := GetConfigEmoji(name) + return &discordgo.ComponentEmoji{ + Name: e.Name, + ID: e.ID, + Animated: e.Animated, + } +} + +// GetConfigEmoji returns a configured [discordgo.Emoji] for the given name. +func GetConfigEmoji(name string) *discordgo.Emoji { + override := viper.GetString("event.emoji." + name) + if override != "" && override != name { + return GetConfigEmoji(override) + } + return &discordgo.Emoji{ + Name: viper.GetString("event.emoji." + name + ".name"), + ID: viper.GetString("event.emoji." + name + ".id"), + Animated: viper.GetBool("event.emoji." + name + ".animated"), + } +} + +// CompareEmoji returns true if the two emoji are the same +func CompareEmoji[E1, E2 *discordgo.Emoji | *discordgo.ComponentEmoji](e1 E1, e2 E2) bool { + return *componentEmoji(e1) == *componentEmoji(e2) +} + +// componentEmoji returns a [discordgo.ComponentEmoji] for the given [discordgo.Emoji] or [discordgo.ComponentEmoji]. +func componentEmoji[E *discordgo.Emoji | *discordgo.ComponentEmoji](e E) *discordgo.ComponentEmoji { + if ee, ok := any(e).(*discordgo.Emoji); ok { + return &discordgo.ComponentEmoji{ + Name: ee.Name, + ID: ee.ID, + Animated: ee.Animated, + } + } + if ce, ok := any(e).(*discordgo.ComponentEmoji); ok { + return ce + } + panic("Given generic type is not an emoji or component emoji") +} diff --git a/util/interaction.go b/util/interaction.go index 3d5d3a8..d4e2897 100644 --- a/util/interaction.go +++ b/util/interaction.go @@ -42,9 +42,19 @@ func (i *InteractionUtil) respond() { } if i.acknowledged { - _, err := i.Session.FollowupMessageCreate(i.Interaction.Interaction, true, &discordgo.WebhookParams{ - Content: i.response.Data.Content, - }) + data := &discordgo.WebhookParams{ + AllowedMentions: i.response.Data.AllowedMentions, + Components: i.response.Data.Components, + Content: i.response.Data.Content, + Embeds: i.response.Data.Embeds, + Files: i.response.Data.Files, + Flags: i.response.Data.Flags, + TTS: i.response.Data.TTS, + } + if i.response.Data.Attachments != nil { + data.Attachments = *i.response.Data.Attachments + } + _, err := i.Session.FollowupMessageCreate(i.Interaction.Interaction, true, data) if err != nil { log.Printf("ERROR: could not send follow up message: %+v\n%s", err, debug.Stack()) } @@ -185,6 +195,17 @@ func (i *InteractionUtil) ReplyHiddenEmbed(embeds ...*discordgo.MessageEmbed) { i.respond() } +// ReplyHiddenEmbedUpdate is like [InteractionUtil.ReplyHiddenEmbed] but made for an update for +// components. +func (i *InteractionUtil) ReplyHiddenEmbedUpdate(embeds ...*discordgo.MessageEmbed) { + if !i.respondMessage(true, false) { + return + } + i.response.Data.Embeds = embeds + i.response.Data.Flags = discordgo.MessageFlagsEphemeral + i.respond() +} + // ReplyComponents sends a message along with the provied message components. func (i *InteractionUtil) ReplyComponents(components []discordgo.MessageComponent, message string) { i.respondMessage(false, false) @@ -249,6 +270,22 @@ func (i *InteractionUtil) ReplySimpleEmbedUpdatef(color int, format string, a .. i.ReplyEmbedUpdate(e) } +// ReplyHiddenSimpleEmbedUpdate is like [InteractionUtil.ReplyHiddenSimpleEmbed] but made for an +// update for components. +func (i *InteractionUtil) ReplyHiddenSimpleEmbedUpdate(color int, content string) { + e := &discordgo.MessageEmbed{ + Description: content, + Color: color, + } + i.ReplyHiddenEmbedUpdate(e) +} + +// ReplyHiddenSimpleEmbedUpdatef is like [InteractionUtil.ReplyHiddenSimpleEmbedf] but made for an +// update for components. +func (i *InteractionUtil) ReplyHiddenSimpleEmbedUpdatef(color int, format string, a ...any) { + i.ReplyHiddenSimpleEmbedUpdate(color, fmt.Sprintf(format, a...)) +} + // ReplyComponentsf formats according to a format specifier and sends the result along with the // provied message components. func (i *InteractionUtil) ReplyComponentsf(components []discordgo.MessageComponent, format string, a ...any) { @@ -292,6 +329,16 @@ func (i *InteractionUtil) ReplyComponentsEmbed(components []discordgo.MessageCom i.respond() } +// ReplyComponentsEmbedUpdate is like [InteractionUtil.ReplyComponentsEmbed] but made for an update for components. +func (i *InteractionUtil) ReplyComponentsEmbedUpdate(components []discordgo.MessageComponent, embeds ...*discordgo.MessageEmbed) { + if !i.respondMessage(true, false) { + return + } + i.response.Data.Embeds = embeds + i.response.Data.Components = components + i.respond() +} + // ReplyComponentsHiddenEmbed sends the given embeds as ephemeral reply along with the provided message // components. func (i *InteractionUtil) ReplyComponentsHiddenEmbed(components []discordgo.MessageComponent, embeds ...*discordgo.MessageEmbed) { @@ -302,6 +349,45 @@ func (i *InteractionUtil) ReplyComponentsHiddenEmbed(components []discordgo.Mess i.respond() } +// ReplyComponentsHiddenEmbedUpdate is like [InteractionUtil.ReplyComponentsHiddenEmbed] but made for an update for components. +func (i *InteractionUtil) ReplyComponentsHiddenEmbedUpdate(components []discordgo.MessageComponent, embeds ...*discordgo.MessageEmbed) { + if !i.respondMessage(true, false) { + return + } + i.response.Data.Embeds = embeds + i.response.Data.Components = components + i.response.Data.Flags = discordgo.MessageFlagsEphemeral + i.respond() +} + +// ReplyComponentsSimpleEmbedUpdate is like [InteractionUtil.ReplyComponentsSimpleEmbed] but made for an update for components. +func (i *InteractionUtil) ReplyComponentsSimpleEmbedUpdate(components []discordgo.MessageComponent, color int, content string) { + e := &discordgo.MessageEmbed{ + Description: content, + Color: color, + } + i.ReplyComponentsEmbedUpdate(components, e) +} + +// ReplyComponentsSimpleEmbedUpdatef is like [InteractionUtil.ReplyComponentsSimpleEmbedf] but made for an update for components. +func (i *InteractionUtil) ReplyComponentsSimpleEmbedUpdatef(components []discordgo.MessageComponent, color int, format string, a ...any) { + i.ReplyComponentsSimpleEmbedUpdate(components, color, fmt.Sprintf(format, a...)) +} + +// ReplyComponentsHiddenSimpleEmbedUpdate is like [InteractionUtil.ReplyComponentsHiddenSimpleEmbed] but made for an update for components. +func (i *InteractionUtil) ReplyComponentsHiddenSimpleEmbedUpdate(components []discordgo.MessageComponent, color int, content string) { + e := &discordgo.MessageEmbed{ + Description: content, + Color: color, + } + i.ReplyComponentsHiddenEmbedUpdate(components, e) +} + +// ReplyComponentsHiddenSimpleEmbedUpdatef is like [InteractionUtil.ReplyComponentsHiddenSimpleEmbedf] but made for an update for components. +func (i *InteractionUtil) ReplyComponentsHiddenSimpleEmbedUpdatef(components []discordgo.MessageComponent, color int, format string, a ...any) { + i.ReplyComponentsHiddenSimpleEmbedUpdate(components, color, fmt.Sprintf(format, a...)) +} + // ReplyAutocomplete returns the given choices to the user. When this is called on an interaction // type outside form an applicationCommandAutocomplete nothing will happen. func (i *InteractionUtil) ReplyAutocomplete(choices []*discordgo.ApplicationCommandOptionChoice) {