From 9d9602318a2963d9d4c2dbf2f4bfea3182d2b05b Mon Sep 17 00:00:00 2001 From: Fedor Lapshin Date: Wed, 18 Aug 2021 23:16:46 +0300 Subject: [PATCH] Context menus (#978) * Interactions: context menus * Example for message context menus * Added flags to followups * Example for user context menus * Godoc fix * Rebase fix * Update message types to reflect new separations Co-authored-by: Carson Hoffman --- examples/context_menus/main.go | 222 +++++++++++++++++++++++++++++++++ interactions.go | 38 ++++-- message.go | 3 +- 3 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 examples/context_menus/main.go diff --git a/examples/context_menus/main.go b/examples/context_menus/main.go new file mode 100644 index 000000000..1d82a22d0 --- /dev/null +++ b/examples/context_menus/main.go @@ -0,0 +1,222 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + + "github.com/bwmarrin/discordgo" +) + +// Bot parameters +var ( + GuildID = flag.String("guild", "", "Test guild ID") + BotToken = flag.String("token", "", "Bot access token") + AppID = flag.String("app", "", "Application ID") + Cleanup = flag.Bool("cleanup", true, "Cleanup of commands") +) + +var s *discordgo.Session + +func init() { flag.Parse() } + +func init() { + var err error + s, err = discordgo.New("Bot " + *BotToken) + if err != nil { + log.Fatalf("Invalid bot parameters: %v", err) + } +} + +func searchLink(message, format, sep string) string { + return fmt.Sprintf(format, strings.Join( + strings.Split( + message, + " ", + ), + sep, + )) +} + +var ( + commands = []discordgo.ApplicationCommand{ + { + Name: "rickroll-em", + Type: discordgo.UserApplicationCommand, + }, + { + Name: "google-it", + Type: discordgo.MessageApplicationCommand, + }, + { + Name: "stackoverflow-it", + Type: discordgo.MessageApplicationCommand, + }, + { + Name: "godoc-it", + Type: discordgo.MessageApplicationCommand, + }, + { + Name: "discordjs-it", + Type: discordgo.MessageApplicationCommand, + }, + { + Name: "discordpy-it", + Type: discordgo.MessageApplicationCommand, + }, + } + commandsHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "rickroll-em": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Operation rickroll has begun", + Flags: 1 << 6, + }, + }) + if err != nil { + panic(err) + } + + ch, err := s.UserChannelCreate( + i.ApplicationCommandData().TargetID, + ) + if err != nil { + _, err = s.FollowupMessageCreate(*AppID, i.Interaction, true, &discordgo.WebhookParams{ + Content: fmt.Sprintf("Mission failed. Cannot send a message to this user: %q", err.Error()), + Flags: 1 << 6, + }) + if err != nil { + panic(err) + } + } + _, err = s.ChannelMessageSend( + ch.ID, + fmt.Sprintf("%s sent you this: https://youtu.be/dQw4w9WgXcQ", i.Member.Mention()), + ) + if err != nil { + panic(err) + } + }, + "google-it": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: searchLink( + i.ApplicationCommandData().Resolved.Messages[i.ApplicationCommandData().TargetID].Content, + "https://google.com/search?q=%s", "+"), + Flags: 1 << 6, + }, + }) + if err != nil { + panic(err) + } + }, + "stackoverflow-it": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: searchLink( + i.ApplicationCommandData().Resolved.Messages[i.ApplicationCommandData().TargetID].Content, + "https://stackoverflow.com/search?q=%s", "+"), + Flags: 1 << 6, + }, + }) + if err != nil { + panic(err) + } + }, + "godoc-it": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: searchLink( + i.ApplicationCommandData().Resolved.Messages[i.ApplicationCommandData().TargetID].Content, + "https://pkg.go.dev/search?q=%s", "+"), + Flags: 1 << 6, + }, + }) + if err != nil { + panic(err) + } + }, + "discordjs-it": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: searchLink( + i.ApplicationCommandData().Resolved.Messages[i.ApplicationCommandData().TargetID].Content, + "https://discord.js.org/#/docs/main/stable/search?query=%s", "+"), + Flags: 1 << 6, + }, + }) + if err != nil { + panic(err) + } + }, + "discordpy-it": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: searchLink( + i.ApplicationCommandData().Resolved.Messages[i.ApplicationCommandData().TargetID].Content, + "https://discordpy.readthedocs.io/en/stable/search.html?q=%s", "+"), + Flags: 1 << 6, + }, + }) + if err != nil { + panic(err) + } + }, + } +) + +func main() { + s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + log.Println("Bot is up!") + }) + + s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if h, ok := commandsHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + }) + + cmdIDs := make(map[string]string, len(commands)) + + for _, cmd := range commands { + rcmd, err := s.ApplicationCommandCreate(*AppID, *GuildID, &cmd) + if err != nil { + log.Fatalf("Cannot create slash command %q: %v", cmd.Name, err) + } + + cmdIDs[rcmd.ID] = rcmd.Name + + } + + err := s.Open() + if err != nil { + log.Fatalf("Cannot open the session: %v", err) + } + defer s.Close() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt) + <-stop + log.Println("Graceful shutdown") + + if !*Cleanup { + return + } + + for id, name := range cmdIDs { + err := s.ApplicationCommandDelete(*AppID, *GuildID, id) + if err != nil { + log.Fatalf("Cannot delete slash command %q: %v", name, err) + } + } + +} diff --git a/interactions.go b/interactions.go index 281dd7960..1bb244ec3 100644 --- a/interactions.go +++ b/interactions.go @@ -15,14 +15,30 @@ import ( // InteractionDeadline is the time allowed to respond to an interaction. const InteractionDeadline = time.Second * 3 +// ApplicationCommandType represents the type of application command. +type ApplicationCommandType uint8 + +// Application command types +const ( + // ChatApplicationCommand is default command type. They are slash commands (i.e. called directly from the chat). + ChatApplicationCommand ApplicationCommandType = 1 + // UserApplicationCommand adds command to user context menu. + UserApplicationCommand ApplicationCommandType = 2 + // MessageApplicationCommand adds command to message context menu. + MessageApplicationCommand ApplicationCommandType = 3 +) + // ApplicationCommand represents an application's slash command. type ApplicationCommand struct { - ID string `json:"id,omitempty"` - ApplicationID string `json:"application_id,omitempty"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version,omitempty"` - Options []*ApplicationCommandOption `json:"options"` + ID string `json:"id,omitempty"` + ApplicationID string `json:"application_id,omitempty"` + Type ApplicationCommandType `json:"type,omitempty"` + Name string `json:"name"` + // NOTE: Chat commands only. Otherwise it mustn't be set. + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + // NOTE: Chat commands only. Otherwise it mustn't be set. + Options []*ApplicationCommandOption `json:"options"` } // ApplicationCommandOptionType indicates the type of a slash command's option. @@ -197,10 +213,15 @@ type ApplicationCommandInteractionData struct { ID string `json:"id"` Name string `json:"name"` Resolved *ApplicationCommandInteractionDataResolved `json:"resolved"` - Options []*ApplicationCommandInteractionDataOption `json:"options"` + + // Slash command options + Options []*ApplicationCommandInteractionDataOption `json:"options"` + // Target (user/message) id on which context menu command was called. + // The details are stored in Resolved according to command type. + TargetID string `json:"target_id"` } -// ApplicationCommandInteractionDataResolved contains resolved data for command arguments. +// ApplicationCommandInteractionDataResolved contains resolved data of command execution. // Partial Member objects are missing user, deaf and mute fields. // Partial Channel objects only have id, name, type and permissions fields. type ApplicationCommandInteractionDataResolved struct { @@ -208,6 +229,7 @@ type ApplicationCommandInteractionDataResolved struct { Members map[string]*Member `json:"members"` Roles map[string]*Role `json:"roles"` Channels map[string]*Channel `json:"channels"` + Messages map[string]*Message `json:"messages"` } // Type returns the type of interaction data. diff --git a/message.go b/message.go index 63ba96c4a..fdcdd87d2 100644 --- a/message.go +++ b/message.go @@ -38,7 +38,8 @@ const ( MessageTypeGuildDiscoveryDisqualified MessageType = 14 MessageTypeGuildDiscoveryRequalified MessageType = 15 MessageTypeReply MessageType = 19 - MessageTypeApplicationCommand MessageType = 20 + MessageTypeChatInputCommand MessageType = 20 + MessageTypeContextMenuCommand MessageType = 23 ) // A Message stores all data related to a specific Discord message.