From 761c97665ef7d2a58be69d30d673d9b07941ec82 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 5 Jan 2025 02:40:10 +0100 Subject: [PATCH] feat(FAQ): added faq functionality --- config.yaml | 17 ++++-- data/lang/de.yaml | 13 +++++ data/lang/en.yaml | 13 +++++ event/component/componentBase.go | 3 + event/component/handleGenericComponents.go | 64 ++++++++++++++++++++++ modules/faq/chatCommand.go | 24 ++++++++ modules/faq/component.go | 12 +++- modules/faq/faqBase.go | 40 ++++++++++++++ modules/faq/handleAllQuestions.go | 34 ++++++++++++ modules/faq/handleAutocomplete.go | 27 +++++++++ modules/faq/handleCommand.go | 52 ++++++++++++++++++ modules/faq/handleShowQuestion.go | 44 +++++++++++++++ util/discord.go | 16 ++++++ util/interaction.go | 17 ++++++ util/modal.go | 15 ++++- 15 files changed, 385 insertions(+), 6 deletions(-) create mode 100644 event/component/handleGenericComponents.go create mode 100644 modules/faq/handleAllQuestions.go create mode 100644 modules/faq/handleAutocomplete.go create mode 100644 modules/faq/handleCommand.go create mode 100644 modules/faq/handleShowQuestion.go diff --git a/config.yaml b/config.yaml index 855aebe..7a0146b 100644 --- a/config.yaml +++ b/config.yaml @@ -69,10 +69,22 @@ event: name: 🔁 #id: #animated: true + generic.back: + name: ↩️ + #id: + #animated: true + generic.delete: + name: 🗑️ + #id: + #animated: true adventcalendar: # Emoji for entering the advent calendar giveaway enter: vote.check + + faq: + all_questions: generic.back + random.coin: heads: name: 👤 @@ -129,10 +141,7 @@ event: name: 🏠 #id: #animated: true - invite.delete: - name: 🗑️ - #id: - #animated: true + invite.delete: generic.delete invite.nudge_match: name: 👉 #id: diff --git a/data/lang/de.yaml b/data/lang/de.yaml index 3093515..c11e68e 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -7,6 +7,8 @@ discord.command: msg.self_hidden: Warum ist das unsichtbar? msg.self_hidden.desc: Da du deinen Geburtstag als nicht sichtbar eingetragen hast, kannst diese Nachricht nur du sehen. Du kannst diese Nachricht nun schließen. msg.page: Seite %d/%d + msg.button.delete: Schließen + msg.error.not_author: Das ist die Nachricht von %s. Du kannst das nicht verwenden! birthday: base: geburtstag @@ -108,6 +110,17 @@ discord.command: base.description: Häufig gestellte Fragen display: FAQ + option.question: frage + option.question.description: Die Frage, nach der gesucht werden soll + + msg.button.all_questions: Alle Fragen + msg.no_questions: Es hier noch keine häufig gestellten Fragen. + #TODO: Link slash command + msg.question_not_found: |- + Die Frage, nach der du gesucht hast, konnte nicht gefunden werden. + > %s + Versuche es erneut oder schau dir alle Fragen an indem du keine Frage in der suche angibst. + info: base: info base.description: Zeigt ein paar Infos über den Bot diff --git a/data/lang/en.yaml b/data/lang/en.yaml index 33311ae..6af0d5e 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -7,6 +7,8 @@ discord.command: msg.self_hidden: Why is this invisible? msg.self_hidden.desc: Since you've set your birthday to not be visible, this message is also only visible to you. You can close this message now. msg.page: Page %d/%d + msg.button.delete: Close + msg.error.not_author: This is the message of %s. You can't use this here! birthday: base: birthday @@ -108,6 +110,17 @@ discord.command: base.description: Frequently Asked Questions display: FAQ + option.question: question + option.question.description: The question you want to ask + + msg.button.all_questions: All questions + msg.no_questions: There are no frequently asked questions yet + #TODO: Link slash command + msg.question_not_found: |- + The question you searched for was not found. + > %s + Check again or list all questions by not searching anything. + info: base: info base.description: Displays some infos about the bot diff --git a/event/component/componentBase.go b/event/component/componentBase.go index 4740b7c..91a0f72 100644 --- a/event/component/componentBase.go +++ b/event/component/componentBase.go @@ -4,6 +4,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/cake4everyone/cake4everybot/logger" "github.com/cake4everyone/cake4everybot/modules/adventcalendar" + "github.com/cake4everyone/cake4everybot/modules/faq" "github.com/cake4everyone/cake4everybot/modules/random" "github.com/cake4everyone/cake4everybot/modules/secretsanta" ) @@ -34,6 +35,8 @@ func Register() { var componentList []Component componentList = append(componentList, adventcalendar.Component{}) + componentList = append(componentList, faq.Component{}) + componentList = append(componentList, GenericComponents{}) componentList = append(componentList, random.Component{}) componentList = append(componentList, secretsanta.Component{}) diff --git a/event/component/handleGenericComponents.go b/event/component/handleGenericComponents.go new file mode 100644 index 0000000..9cefdec --- /dev/null +++ b/event/component/handleGenericComponents.go @@ -0,0 +1,64 @@ +package component + +import ( + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/cake4everyone/cake4everybot/util" +) + +// GenericComponents is the Component handler for generic components +type GenericComponents struct { + util.InteractionUtil + member *discordgo.Member + user *discordgo.User + originalAuthor *discordgo.User + data discordgo.MessageComponentInteractionData +} + +// Handle handles the functionality of a component interaction +func (gc GenericComponents) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + gc.InteractionUtil = util.InteractionUtil{Session: s, Interaction: i} + gc.member = i.Member + gc.user = i.User + if i.Member != nil { + gc.user = i.Member.User + } else if i.User != nil { + gc.member = &discordgo.Member{User: i.User} + } + gc.data = i.MessageComponentData() + + if gc.Interaction.Message.Type == discordgo.MessageTypeChatInputCommand { + gc.originalAuthor = gc.Interaction.Message.Interaction.User + } else { + gc.originalAuthor = gc.Interaction.Message.Author + } + + ids := strings.Split(gc.data.CustomID, ".") + // pop the first level identifier + util.ShiftL(ids) + + switch util.ShiftL(ids) { + case "delete": + if gc.RequireOriginalAuthor() { + gc.handleDelete() + } + return + default: + log.Printf("Unknown component interaction ID: %s", gc.data.CustomID) + gc.ReplyError() + } +} + +// ID returns the component ID to identify the module +func (gc GenericComponents) ID() string { + return "generic" +} + +func (gc GenericComponents) handleDelete() { + err := gc.Session.ChannelMessageDelete(gc.Interaction.ChannelID, gc.Interaction.Message.ID) + if err != nil { + log.Printf("ERROR: could not delete message %s/%s: %+v", gc.Interaction.ChannelID, gc.Interaction.Message.ID, err) + gc.ReplyError() + } +} diff --git a/modules/faq/chatCommand.go b/modules/faq/chatCommand.go index 731a7a9..b2d8dd7 100644 --- a/modules/faq/chatCommand.go +++ b/modules/faq/chatCommand.go @@ -25,6 +25,15 @@ func (cmd Chat) AppCmd() *discordgo.ApplicationCommand { NameLocalizations: util.TranslateLocalization(tp + "base"), Description: lang.GetDefault(tp + "base.description"), DescriptionLocalizations: util.TranslateLocalization(tp + "base.description"), + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: lang.GetDefault(tp + "option.question"), + Description: lang.GetDefault(tp + "option.question.description"), + Required: false, + Autocomplete: true, + }, + }, } } @@ -38,6 +47,21 @@ func (cmd Chat) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { } else if i.User != nil { cmd.member = &discordgo.Member{User: i.User} } + + data := i.ApplicationCommandData() + var question string + for _, option := range data.Options { + switch option.Name { + case lang.GetDefault(tp + "option.question"): + question = option.StringValue() + } + } + + if i.Type == discordgo.InteractionApplicationCommandAutocomplete { + cmd.handleAutocomplete(question) + } else { + cmd.handleCommand(question) + } } // SetID sets the registered command ID for internal uses after uploading to discord diff --git a/modules/faq/component.go b/modules/faq/component.go index 69eb69c..3350a72 100644 --- a/modules/faq/component.go +++ b/modules/faq/component.go @@ -23,7 +23,6 @@ func (c Component) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) } else if i.User != nil { c.member = &discordgo.Member{User: i.User} } - //lint:ignore SA4005 currently not used but will be when implementing the component c.data = i.MessageComponentData() ids := strings.Split(c.data.CustomID, ".") @@ -31,6 +30,17 @@ func (c Component) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) util.ShiftL(ids) switch util.ShiftL(ids) { + case "show_question": + if c.RequireOriginalAuthor() { + question := strings.Join(ids[:len(ids)-2], ".") + c.handleShowQuestion(question) + } + return + case "all_questions": + if c.RequireOriginalAuthor() { + c.handleAllQuestions() + } + return default: log.Printf("Unknown component interaction ID: %s", c.data.CustomID) } diff --git a/modules/faq/faqBase.go b/modules/faq/faqBase.go index 6072f6c..41f014d 100644 --- a/modules/faq/faqBase.go +++ b/modules/faq/faqBase.go @@ -1,15 +1,55 @@ package faq import ( + "database/sql" + "errors" + "fmt" + "time" + "github.com/bwmarrin/discordgo" + "github.com/cake4everyone/cake4everybot/database" "github.com/cake4everyone/cake4everybot/logger" "github.com/cake4everyone/cake4everybot/util" ) var log = logger.New("FAQ") +var ( + // lastFAQs is a cache for all the FAQs + lastFAQs = make(map[string]map[string]string) + // lastFAQTime is the time when the lastFAQs map was updated + lastFAQTime time.Time +) + type faqBase struct { util.InteractionUtil member *discordgo.Member user *discordgo.User } + +func (faq faqBase) getAllFAQs() (map[string]string, error) { + if time.Since(lastFAQTime) < 2*time.Minute { + return lastFAQs[faq.Interaction.GuildID], nil + } + delete(lastFAQs, faq.Interaction.GuildID) + lastFAQs[faq.Interaction.GuildID] = make(map[string]string) + + row, err := database.Query("SELECT question, answer FROM faq WHERE guild_id=?", faq.Interaction.GuildID) + if errors.Is(err, sql.ErrNoRows) { + return lastFAQs[faq.Interaction.GuildID], nil + } else if err != nil { + return lastFAQs[faq.Interaction.GuildID], fmt.Errorf("getting all FAQs from guild %s: %w", faq.Interaction.GuildID, err) + } + defer row.Close() + + for row.Next() { + var question, answer string + if err := row.Scan(&question, &answer); err != nil { + return lastFAQs[faq.Interaction.GuildID], fmt.Errorf("scanning row: %w", err) + } + lastFAQs[faq.Interaction.GuildID][question] = answer + } + + lastFAQTime = time.Now() + return lastFAQs[faq.Interaction.GuildID], nil +} diff --git a/modules/faq/handleAllQuestions.go b/modules/faq/handleAllQuestions.go new file mode 100644 index 0000000..79f57d9 --- /dev/null +++ b/modules/faq/handleAllQuestions.go @@ -0,0 +1,34 @@ +package faq + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/cake4everyone/cake4everybot/util" +) + +func (c Component) handleAllQuestions() { + faqs, err := c.getAllFAQs() + if err != nil { + log.Printf("ERROR: getting all FAQs: %v", err) + c.ReplyError() + return + } + + e := &discordgo.MessageEmbed{ + Color: 0xFAB1FD, + Title: "FAQs", + } + util.SetEmbedFooter(c.Session, tp+"display", e) + + var components []discordgo.MessageComponent + var i int + for question := range faqs { + i++ + util.AddEmbedField(e, fmt.Sprintf("%d", i), question, true) + components = append(components, util.CreateButtonComponent(fmt.Sprintf("faq.show_question.%s", question), fmt.Sprint(i), discordgo.PrimaryButton, nil)) + } + components = []discordgo.MessageComponent{discordgo.ActionsRow{Components: components}} + + c.ReplyComponentsEmbedUpdate(components, e) +} diff --git a/modules/faq/handleAutocomplete.go b/modules/faq/handleAutocomplete.go new file mode 100644 index 0000000..9f3b5a1 --- /dev/null +++ b/modules/faq/handleAutocomplete.go @@ -0,0 +1,27 @@ +package faq + +import ( + "strings" + + "github.com/bwmarrin/discordgo" +) + +func (cmd Chat) handleAutocomplete(input string) { + faqs, err := cmd.getAllFAQs() + if err != nil { + log.Printf("ERROR: getting all FAQs: %v", err) + cmd.ReplyError() + return + } + + options := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(faqs)) + for question := range faqs { + if input == "" || strings.Contains(strings.ToLower(question), strings.ToLower(input)) { + options = append(options, &discordgo.ApplicationCommandOptionChoice{ + Name: question, + Value: question, + }) + } + } + cmd.ReplyAutocomplete(options) +} diff --git a/modules/faq/handleCommand.go b/modules/faq/handleCommand.go new file mode 100644 index 0000000..81735a9 --- /dev/null +++ b/modules/faq/handleCommand.go @@ -0,0 +1,52 @@ +package faq + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/cake4everyone/cake4everybot/data/lang" + "github.com/cake4everyone/cake4everybot/util" +) + +func (cmd Chat) handleCommand(question string) { + faqs, err := cmd.getAllFAQs() + if err != nil { + log.Printf("ERROR: getting all FAQs: %v", err) + cmd.ReplyError() + return + } + + if len(faqs) == 0 { + cmd.ReplyHiddenSimpleEmbed(0xFAB1FD, lang.GetDefault(tp+"msg.no_questions")) + return + } + + e := &discordgo.MessageEmbed{ + Color: 0xFAB1FD, + Title: question, + } + util.SetEmbedFooter(cmd.Session, tp+"display", e) + + if question != "" { + var ok bool + e.Description, ok = faqs[question] + if !ok { + cmd.ReplyHiddenSimpleEmbedf(0xFAB1FD, lang.GetDefault(tp+"msg.question_not_found"), question) + return + } + cmd.ReplyEmbed(e) + return + } + + e.Title = "FAQs" + var components []discordgo.MessageComponent + var i int + for question := range faqs { + i++ + util.AddEmbedField(e, fmt.Sprintf("%d", i), question, true) + components = append(components, util.CreateButtonComponent(fmt.Sprintf("faq.show_question.%s", question), fmt.Sprint(i), discordgo.PrimaryButton, nil)) + } + components = []discordgo.MessageComponent{discordgo.ActionsRow{Components: components}} + + cmd.ReplyComponentsEmbed(components, e) +} diff --git a/modules/faq/handleShowQuestion.go b/modules/faq/handleShowQuestion.go new file mode 100644 index 0000000..ac4c6b4 --- /dev/null +++ b/modules/faq/handleShowQuestion.go @@ -0,0 +1,44 @@ +package faq + +import ( + "github.com/bwmarrin/discordgo" + "github.com/cake4everyone/cake4everybot/data/lang" + "github.com/cake4everyone/cake4everybot/util" +) + +func (c Component) handleShowQuestion(question string) { + faqs, err := c.getAllFAQs() + if err != nil { + log.Printf("ERROR: getting all FAQs: %v", err) + c.ReplyError() + return + } + + e := &discordgo.MessageEmbed{ + Color: 0xFAB1FD, + Title: question, + } + util.SetEmbedFooter(c.Session, tp+"display", e) + + var ok bool + e.Description, ok = faqs[question] + if !ok { + log.Printf("ERROR: could not find question: '%s'", question) + log.Printf("Available questions: %v", faqs) + c.ReplyError() + return + } + + var components []discordgo.MessageComponent + components = append(components, util.CreateButtonComponent( + "faq.all_questions", + lang.GetDefault(tp+"msg.button.all_questions"), + discordgo.SecondaryButton, + util.GetConfigComponentEmoji("faq.all_questions"), + )) + components = append(components, util.CloseButtonComponent()) + + components = []discordgo.MessageComponent{discordgo.ActionsRow{Components: components}} + + c.ReplyComponentsEmbedUpdate(components, e) +} diff --git a/util/discord.go b/util/discord.go index 7b89314..3ac14ac 100644 --- a/util/discord.go +++ b/util/discord.go @@ -458,3 +458,19 @@ func IsGuildMember(s *discordgo.Session, guildID, userID string) (member *discor } return nil } + +// OriginalAuthor returns the original author of the given message. +// +// If the message is a reply, the original author of the reply is returned. If +// the message is an interaction response, the author of the interaction is +// returned. +func OriginalAuthor(m *discordgo.Message) *discordgo.User { + switch m.Type { + case discordgo.MessageTypeChatInputCommand, discordgo.MessageTypeContextMenuCommand: + return m.Interaction.User + case discordgo.MessageTypeReply: + return OriginalAuthor(m.ReferencedMessage) + default: + return m.Author + } +} diff --git a/util/interaction.go b/util/interaction.go index fe67b69..f375b96 100644 --- a/util/interaction.go +++ b/util/interaction.go @@ -19,6 +19,7 @@ import ( "runtime/debug" "github.com/bwmarrin/discordgo" + "github.com/cake4everyone/cake4everybot/data/lang" ) // InteractionUtil is a helper for discords application interactions. It add useful methods for @@ -464,3 +465,19 @@ func (i *InteractionUtil) ReplyModal(id, title string, components ...discordgo.M } i.respond() } + +// RequireOriginalAuthor checks if the user who executed the current interaction +// is the same as the original author of the interaction. +func (i *InteractionUtil) RequireOriginalAuthor() bool { + originalAuthor := OriginalAuthor(i.Interaction.Message) + // the user who executed the current interaction + var user *discordgo.User = i.Interaction.User + if user == nil { + user = i.Interaction.Member.User + } + if originalAuthor.ID != user.ID { + i.ReplyHiddenf(lang.GetDefault("discord.command.generic.msg.error.not_author"), originalAuthor.Mention()) + return false + } + return true +} diff --git a/util/modal.go b/util/modal.go index f7844f2..84473b3 100644 --- a/util/modal.go +++ b/util/modal.go @@ -14,7 +14,10 @@ package util -import "github.com/bwmarrin/discordgo" +import ( + "github.com/bwmarrin/discordgo" + "github.com/cake4everyone/cake4everybot/data/lang" +) // CreateButtonComponent returns a simple button component with the specified configurations. // Params: @@ -50,6 +53,16 @@ func CreateURLButtonComponent(id, label, url string, emoji *discordgo.ComponentE } } +// CloseButtonComponent returns a button component that deletes the message when clicked. +func CloseButtonComponent() *discordgo.Button { + return CreateButtonComponent( + "generic.delete", + lang.GetDefault("discord.command.generic.msg.button.delete"), + discordgo.DangerButton, + GetConfigComponentEmoji("generic.delete"), + ) +} + // CreateTextInputComponent returns a text input form for modals with the specified configurations. // Params: //