From 74f086ba8a318d064d02a1efa844e8959b065987 Mon Sep 17 00:00:00 2001 From: trofchik Date: Mon, 11 Apr 2022 18:34:07 +0400 Subject: [PATCH 1/2] Integrated changes from #1129. Commands will be deleted even if panic occurs inside one of the handlers. --- examples/slash_commands/commands.go | 195 +++++++++++ examples/slash_commands/handlers.go | 288 ++++++++++++++++ examples/slash_commands/main.go | 504 +++------------------------- 3 files changed, 524 insertions(+), 463 deletions(-) create mode 100644 examples/slash_commands/commands.go create mode 100644 examples/slash_commands/handlers.go diff --git a/examples/slash_commands/commands.go b/examples/slash_commands/commands.go new file mode 100644 index 000000000..cb5e3be65 --- /dev/null +++ b/examples/slash_commands/commands.go @@ -0,0 +1,195 @@ +package main + +import "github.com/bwmarrin/discordgo" + +var integerOptionMinValue = 1.0 + +var commands = []*discordgo.ApplicationCommand{ + { + Name: "basic-command", + // All commands and options must have a description + // Commands/options without description will fail the registration + // of the command. + Description: "Basic command", + }, + + { + Name: "basic-command-with-files", + Description: "Basic command with files", + }, + + { + Name: "localized-command", + Description: "Localized command. Description and name may vary depending on the Language setting", + NameLocalizations: &map[discordgo.Locale]string{ + discordgo.ChineseCN: "本地化的命令", + }, + DescriptionLocalizations: &map[discordgo.Locale]string{ + discordgo.ChineseCN: "这是一个本地化的命令", + }, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "localized-option", + Description: "Localized option. Description and name may vary depending on the Language setting", + NameLocalizations: map[discordgo.Locale]string{ + discordgo.ChineseCN: "一个本地化的选项", + }, + DescriptionLocalizations: map[discordgo.Locale]string{ + discordgo.ChineseCN: "这是一个本地化的选项", + }, + Type: discordgo.ApplicationCommandOptionInteger, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "First", + NameLocalizations: map[discordgo.Locale]string{ + discordgo.ChineseCN: "一的", + }, + Value: 1, + }, + { + Name: "Second", + NameLocalizations: map[discordgo.Locale]string{ + discordgo.ChineseCN: "二的", + }, + Value: 2, + }, + }, + }, + }, + }, + + { + Name: "options", + Description: "Command for demonstrating options", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "string-option", + Description: "String option", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "integer-option", + Description: "Integer option", + MinValue: &integerOptionMinValue, + MaxValue: 10, + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionNumber, + Name: "number-option", + Description: "Float option", + MaxValue: 10.1, + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionBoolean, + Name: "bool-option", + Description: "Boolean option", + Required: true, + }, + // Required options must be listed first since optional parameters + // always come after when they're used. + // The same concept applies to Discord's Slash-commands API + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel-option", + Description: "Channel option", + // Channel type mask + ChannelTypes: []discordgo.ChannelType{ + discordgo.ChannelTypeGuildText, + discordgo.ChannelTypeGuildVoice, + }, + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user-option", + Description: "User option", + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionRole, + Name: "role-option", + Description: "Role option", + Required: false, + }, + }, + }, + + { + Name: "subcommands", + Description: "Subcommands and command groups example", + Options: []*discordgo.ApplicationCommandOption{ + // When a command has subcommands/subcommand groups + // It must not have top-level options, they aren't accesible in the UI + // in this case (at least not yet), so if a command has + // subcommands/subcommand any groups registering top-level options + // will cause the registration of the command to fail + { + Name: "subcommand-group", + Description: "Subcommands group", + Options: []*discordgo.ApplicationCommandOption{ + // Also, subcommand groups aren't capable of + // containing options, by the name of them, you can see + // they can only contain subcommands + { + Name: "nested-subcommand", + Description: "Nested subcommand", + Type: discordgo.ApplicationCommandOptionSubCommand, + }, + }, + Type: discordgo.ApplicationCommandOptionSubCommandGroup, + }, + // Also, you can create both subcommand groups and subcommands + // in the command at the same time. But, there's some limits to + // nesting, count of subcommands (top level and nested) and options. + // Read the intro of slash-commands docs on Discord dev portal + // to get more information + { + Name: "subcommand", + Description: "Top-level subcommand", + Type: discordgo.ApplicationCommandOptionSubCommand, + }, + }, + }, + + { + Name: "responses", + Description: "Interaction responses testing initiative", + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "resp-type", + Description: "Response type", + Type: discordgo.ApplicationCommandOptionInteger, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "Channel message with source", + Value: 4, + }, + { + Name: "Deferred response With Source", + Value: 5, + }, + }, + Required: true, + }, + }, + }, + + { + Name: "followups", + Description: "Followup messages", + }, +} + +var commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "basic-command": Basic, + "basic-command-with-files": BasicWithFile, + "localized-command": Localized, + "options": Options, + "subcommands": Subcommands, + "responses": Responses, + "followups": Followups, +} diff --git a/examples/slash_commands/handlers.go b/examples/slash_commands/handlers.go new file mode 100644 index 000000000..7ae2787ea --- /dev/null +++ b/examples/slash_commands/handlers.go @@ -0,0 +1,288 @@ +package main + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +func Login(ds *discordgo.Session, r *discordgo.Ready) { + log.Printf("Logged in as: %v#%v", ds.State.User.Username, ds.State.User.Discriminator) +} + +func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + cmdName := i.ApplicationCommandData().Name + + h, ok := commandHandlers[cmdName] + if !ok { + log.Panicf("No handler found for command: %s", cmdName) + } + + defer removeCommandsOnFailure(s, i.GuildID) + // This way commands will be deleted even if panic occurs inside one of the handlers + h(s, i) +} + +func removeCommandsOnFailure(ds *discordgo.Session, guildID string) { + r := recover() + if r != nil { + deleteCommands(ds, guildID) + log.Fatal("Error occured inside command handler") + } +} + +func Basic(s *discordgo.Session, i *discordgo.InteractionCreate) { + panic("test") + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Hey there! Congratulations, you've just executed your first slash command!", + }, + }) +} + +func BasicWithFile(s *discordgo.Session, i *discordgo.InteractionCreate) { + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Hey there! Congratulations, you just executed your first slash command with a file in the response", + Files: []*discordgo.File{ + { + ContentType: "text/plain", + Name: "test.txt", + Reader: strings.NewReader("Hello Discord!!"), + }, + }, + }, + }) +} + +func Localized(s *discordgo.Session, i *discordgo.InteractionCreate) { + responses := map[discordgo.Locale]string{ + discordgo.ChineseCN: "你好! 这是一个本地化的命令", + } + response := "Hi! This is a localized message" + if r, ok := responses[i.Locale]; ok { + response = r + } + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: response, + }, + }) + if err != nil { + panic(err) + } +} + +func Options(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Access options in the order provided by the user. + options := i.ApplicationCommandData().Options + + // Or convert the slice into a map + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + // This example stores the provided arguments in an []interface{} + // which will be used to format the bot's response + margs := make([]interface{}, 0, len(options)) + msgformat := "You learned how to use command options! " + + "Take a look at the value(s) you entered:\n" + + // Get the value from the option map. + // When the option exists, ok = true + if option, ok := optionMap["string-option"]; ok { + // Option values must be type asserted from interface{}. + // Discordgo provides utility functions to make this simple. + margs = append(margs, option.StringValue()) + msgformat += "> string-option: %s\n" + } + + if opt, ok := optionMap["integer-option"]; ok { + margs = append(margs, opt.IntValue()) + msgformat += "> integer-option: %d\n" + } + + if opt, ok := optionMap["number-option"]; ok { + margs = append(margs, opt.FloatValue()) + msgformat += "> number-option: %f\n" + } + + if opt, ok := optionMap["bool-option"]; ok { + margs = append(margs, opt.BoolValue()) + msgformat += "> bool-option: %v\n" + } + + if opt, ok := optionMap["channel-option"]; ok { + margs = append(margs, opt.ChannelValue(nil).ID) + msgformat += "> channel-option: <#%s>\n" + } + + if opt, ok := optionMap["user-option"]; ok { + margs = append(margs, opt.UserValue(nil).ID) + msgformat += "> user-option: <@%s>\n" + } + + if opt, ok := optionMap["role-option"]; ok { + margs = append(margs, opt.RoleValue(nil, "").ID) + msgformat += "> role-option: <@&%s>\n" + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + // Ignore type for now, they will be discussed in "responses" + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf( + msgformat, + margs..., + ), + }, + }) +} + +func Subcommands(s *discordgo.Session, i *discordgo.InteractionCreate) { + + options := i.ApplicationCommandData().Options + content := "" + + // As you can see, names of subcommands (nested, top-level) + // and subcommand groups are provided through the arguments. + switch options[0].Name { + case "subcommand": + content = "The top-level subcommand is executed. Now try to execute the nested one." + case "subcommand-group": + options = options[0].Options + switch options[0].Name { + case "nested-subcommand": + content = "Nice, now you know how to execute nested commands too" + default: + content = "Oops, something went wrong.\n" + + "Hol' up, you aren't supposed to see this message." + } + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + }, + }) +} + +func Responses(s *discordgo.Session, i *discordgo.InteractionCreate) { + + // Responses to command are very important. + // First of all, because you need to react to the interaction + // by sending the response in 3 seconds after receiving, otherwise + // interaction will be considered invalid and you can no longer + // use the interaction token and ID for responding to the user's request + + // As you can see, the response type names used here are pretty self-explanatory, + // but for those who want more information see the official documentation + + content := "" + + switch i.ApplicationCommandData().Options[0].IntValue() { + case int64(discordgo.InteractionResponseChannelMessageWithSource): + content = + "You've just responded to user inte interaction. User input is supposed to be shown as well but non were given." + case int64(discordgo.InteractionResponseDeferredChannelMessageWithSource): + content = + "You've just deferedly responded to user interaction. Congratulations!" + default: + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseType(i.ApplicationCommandData().Options[0].IntValue()), + }) + + if err != nil { + s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + Content: "Something went wrong.", + }) + } + return + } + content += + "\nAlso... you can edit response after it was sent. Wait for 5 seconds and this message will be changed." + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseType(i.ApplicationCommandData().Options[0].IntValue()), + Data: &discordgo.InteractionResponseData{ + Content: content, + }, + }) + + if err != nil { + s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + Content: "Something went wrong.", + }) + return + } + + time.AfterFunc(time.Second*5, func() { + _, err = s.InteractionResponseEdit(s.State.User.ID, i.Interaction, &discordgo.WebhookEdit{ + Content: content + "\n\nWell, now you know how to create and edit responses. " + + "But you still don't know how to delete them... so... wait 10 seconds and this " + + "message will be deleted.", + }) + + if err != nil { + s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + Content: "Something went wrong.", + }) + return + } + + time.Sleep(time.Second * 10) + s.InteractionResponseDelete(s.State.User.ID, i.Interaction) + }) +} + +func Followups(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Followup messages are basically regular messages (you can create as many of them as you wish) + // but work as they are created by webhooks and their functionality + // is for handling additional messages after sending a response. + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + // Note: this isn't documented, but you can use that if you want to. + // This flag allows you to create messages visible only for the caller of the command + // (user who triggered the command) + Flags: 1 << 6, + Content: "Surprise!", + }, + }) + + msg, err := s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + Content: "Followup message has been created, after 5 seconds it will be edited.", + }) + + if err != nil { + s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + Content: "Something went wrong.", + }) + return + } + time.Sleep(time.Second * 5) + + s.FollowupMessageEdit(s.State.User.ID, i.Interaction, msg.ID, &discordgo.WebhookEdit{ + Content: "Now the original message is gone and after 10 seconds this message will ~~self-destruct~~ be deleted.", + }) + + time.Sleep(time.Second * 10) + + s.FollowupMessageDelete(s.State.User.ID, i.Interaction, msg.ID) + + s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + Content: "For those, who didn't skip anything and followed tutorial along fairly, " + + "take a unicorn :unicorn: as reward!\n" + + "Also, as bonus... look at the original interaction response :D", + }) +} diff --git a/examples/slash_commands/main.go b/examples/slash_commands/main.go index 704150c86..552da0263 100644 --- a/examples/slash_commands/main.go +++ b/examples/slash_commands/main.go @@ -2,503 +2,81 @@ package main import ( "flag" - "fmt" "log" "os" "os/signal" - "strings" - "time" "github.com/bwmarrin/discordgo" ) -// Bot parameters var ( GuildID = flag.String("guild", "", "Test guild ID. If not passed - bot registers commands globally") BotToken = flag.String("token", "", "Bot access token") - RemoveCommands = flag.Bool("rmcmd", true, "Remove all commands after shutdowning or not") + RemoveCommands = flag.Bool("rmcmd", true, "Weather to remove all commands after shutdowning or not") ) -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 deleteCommands(ds *discordgo.Session, guildId string) { + for _, cmd := range commands { + err := ds.ApplicationCommandDelete( + ds.State.User.ID, + guildId, + cmd.ID, + ) + if err != nil { + log.Fatalf("Could not delete command %s: %v\n", cmd.Name, err) + } } } -var ( - integerOptionMinValue = 1.0 - - commands = []*discordgo.ApplicationCommand{ - { - Name: "basic-command", - // All commands and options must have a description - // Commands/options without description will fail the registration - // of the command. - Description: "Basic command", - }, - { - Name: "basic-command-with-files", - Description: "Basic command with files", - }, - { - Name: "localized-command", - Description: "Localized command. Description and name may vary depending on the Language setting", - NameLocalizations: &map[discordgo.Locale]string{ - discordgo.ChineseCN: "本地化的命令", - }, - DescriptionLocalizations: &map[discordgo.Locale]string{ - discordgo.ChineseCN: "这是一个本地化的命令", - }, - Options: []*discordgo.ApplicationCommandOption{ - { - Name: "localized-option", - Description: "Localized option. Description and name may vary depending on the Language setting", - NameLocalizations: map[discordgo.Locale]string{ - discordgo.ChineseCN: "一个本地化的选项", - }, - DescriptionLocalizations: map[discordgo.Locale]string{ - discordgo.ChineseCN: "这是一个本地化的选项", - }, - Type: discordgo.ApplicationCommandOptionInteger, - Choices: []*discordgo.ApplicationCommandOptionChoice{ - { - Name: "First", - NameLocalizations: map[discordgo.Locale]string{ - discordgo.ChineseCN: "一的", - }, - Value: 1, - }, - { - Name: "Second", - NameLocalizations: map[discordgo.Locale]string{ - discordgo.ChineseCN: "二的", - }, - Value: 2, - }, - }, - }, - }, - }, - { - Name: "options", - Description: "Command for demonstrating options", - Options: []*discordgo.ApplicationCommandOption{ - - { - Type: discordgo.ApplicationCommandOptionString, - Name: "string-option", - Description: "String option", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "integer-option", - Description: "Integer option", - MinValue: &integerOptionMinValue, - MaxValue: 10, - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionNumber, - Name: "number-option", - Description: "Float option", - MaxValue: 10.1, - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionBoolean, - Name: "bool-option", - Description: "Boolean option", - Required: true, - }, - - // Required options must be listed first since optional parameters - // always come after when they're used. - // The same concept applies to Discord's Slash-commands API - - { - Type: discordgo.ApplicationCommandOptionChannel, - Name: "channel-option", - Description: "Channel option", - // Channel type mask - ChannelTypes: []discordgo.ChannelType{ - discordgo.ChannelTypeGuildText, - discordgo.ChannelTypeGuildVoice, - }, - Required: false, - }, - { - Type: discordgo.ApplicationCommandOptionUser, - Name: "user-option", - Description: "User option", - Required: false, - }, - { - Type: discordgo.ApplicationCommandOptionRole, - Name: "role-option", - Description: "Role option", - Required: false, - }, - }, - }, - { - Name: "subcommands", - Description: "Subcommands and command groups example", - Options: []*discordgo.ApplicationCommandOption{ - // When a command has subcommands/subcommand groups - // It must not have top-level options, they aren't accesible in the UI - // in this case (at least not yet), so if a command has - // subcommands/subcommand any groups registering top-level options - // will cause the registration of the command to fail - - { - Name: "subcommand-group", - Description: "Subcommands group", - Options: []*discordgo.ApplicationCommandOption{ - // Also, subcommand groups aren't capable of - // containing options, by the name of them, you can see - // they can only contain subcommands - { - Name: "nested-subcommand", - Description: "Nested subcommand", - Type: discordgo.ApplicationCommandOptionSubCommand, - }, - }, - Type: discordgo.ApplicationCommandOptionSubCommandGroup, - }, - // Also, you can create both subcommand groups and subcommands - // in the command at the same time. But, there's some limits to - // nesting, count of subcommands (top level and nested) and options. - // Read the intro of slash-commands docs on Discord dev portal - // to get more information - { - Name: "subcommand", - Description: "Top-level subcommand", - Type: discordgo.ApplicationCommandOptionSubCommand, - }, - }, - }, - { - Name: "responses", - Description: "Interaction responses testing initiative", - Options: []*discordgo.ApplicationCommandOption{ - { - Name: "resp-type", - Description: "Response type", - Type: discordgo.ApplicationCommandOptionInteger, - Choices: []*discordgo.ApplicationCommandOptionChoice{ - { - Name: "Channel message with source", - Value: 4, - }, - { - Name: "Deferred response With Source", - Value: 5, - }, - }, - Required: true, - }, - }, - }, - { - Name: "followups", - Description: "Followup messages", - }, +func createCommands(ds *discordgo.Session, guildId string) { + var err error + for i, cmd := range commands { + commands[i], err = ds.ApplicationCommandCreate( + ds.State.User.ID, + guildId, + cmd, + ) + if err != nil { + log.Panicf("Could not create command %s: %s\n", cmd.Name, err) + } } +} - commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "basic-command": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Hey there! Congratulations, you just executed your first slash command", - }, - }) - }, - "basic-command-with-files": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Hey there! Congratulations, you just executed your first slash command with a file in the response", - Files: []*discordgo.File{ - { - ContentType: "text/plain", - Name: "test.txt", - Reader: strings.NewReader("Hello Discord!!"), - }, - }, - }, - }) - }, - "localized-command": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - responses := map[discordgo.Locale]string{ - discordgo.ChineseCN: "你好! 这是一个本地化的命令", - } - response := "Hi! This is a localized message" - if r, ok := responses[i.Locale]; ok { - response = r - } - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: response, - }, - }) - if err != nil { - panic(err) - } - }, - "options": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - // Access options in the order provided by the user. - options := i.ApplicationCommandData().Options - - // Or convert the slice into a map - optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) - for _, opt := range options { - optionMap[opt.Name] = opt - } - - // This example stores the provided arguments in an []interface{} - // which will be used to format the bot's response - margs := make([]interface{}, 0, len(options)) - msgformat := "You learned how to use command options! " + - "Take a look at the value(s) you entered:\n" - - // Get the value from the option map. - // When the option exists, ok = true - if option, ok := optionMap["string-option"]; ok { - // Option values must be type asserted from interface{}. - // Discordgo provides utility functions to make this simple. - margs = append(margs, option.StringValue()) - msgformat += "> string-option: %s\n" - } - - if opt, ok := optionMap["integer-option"]; ok { - margs = append(margs, opt.IntValue()) - msgformat += "> integer-option: %d\n" - } - - if opt, ok := optionMap["number-option"]; ok { - margs = append(margs, opt.FloatValue()) - msgformat += "> number-option: %f\n" - } - - if opt, ok := optionMap["bool-option"]; ok { - margs = append(margs, opt.BoolValue()) - msgformat += "> bool-option: %v\n" - } - - if opt, ok := optionMap["channel-option"]; ok { - margs = append(margs, opt.ChannelValue(nil).ID) - msgformat += "> channel-option: <#%s>\n" - } - - if opt, ok := optionMap["user-option"]; ok { - margs = append(margs, opt.UserValue(nil).ID) - msgformat += "> user-option: <@%s>\n" - } - - if opt, ok := optionMap["role-option"]; ok { - margs = append(margs, opt.RoleValue(nil, "").ID) - msgformat += "> role-option: <@&%s>\n" - } - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - // Ignore type for now, they will be discussed in "responses" - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf( - msgformat, - margs..., - ), - }, - }) - }, - "subcommands": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - options := i.ApplicationCommandData().Options - content := "" - - // As you can see, names of subcommands (nested, top-level) - // and subcommand groups are provided through the arguments. - switch options[0].Name { - case "subcommand": - content = "The top-level subcommand is executed. Now try to execute the nested one." - case "subcommand-group": - options = options[0].Options - switch options[0].Name { - case "nested-subcommand": - content = "Nice, now you know how to execute nested commands too" - default: - content = "Oops, something went wrong.\n" + - "Hol' up, you aren't supposed to see this message." - } - } - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: content, - }, - }) - }, - "responses": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - // Responses to a command are very important. - // First of all, because you need to react to the interaction - // by sending the response in 3 seconds after receiving, otherwise - // interaction will be considered invalid and you can no longer - // use the interaction token and ID for responding to the user's request - - content := "" - // As you can see, the response type names used here are pretty self-explanatory, - // but for those who want more information see the official documentation - switch i.ApplicationCommandData().Options[0].IntValue() { - case int64(discordgo.InteractionResponseChannelMessageWithSource): - content = - "You just responded to an interaction, sent a message and showed the original one. " + - "Congratulations!" - content += - "\nAlso... you can edit your response, wait 5 seconds and this message will be changed" - default: - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseType(i.ApplicationCommandData().Options[0].IntValue()), - }) - if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ - Content: "Something went wrong", - }) - } - return - } - - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseType(i.ApplicationCommandData().Options[0].IntValue()), - Data: &discordgo.InteractionResponseData{ - Content: content, - }, - }) - if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ - Content: "Something went wrong", - }) - return - } - time.AfterFunc(time.Second*5, func() { - _, err = s.InteractionResponseEdit(s.State.User.ID, i.Interaction, &discordgo.WebhookEdit{ - Content: content + "\n\nWell, now you know how to create and edit responses. " + - "But you still don't know how to delete them... so... wait 10 seconds and this " + - "message will be deleted.", - }) - if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ - Content: "Something went wrong", - }) - return - } - time.Sleep(time.Second * 10) - s.InteractionResponseDelete(s.State.User.ID, i.Interaction) - }) - }, - "followups": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - // Followup messages are basically regular messages (you can create as many of them as you wish) - // but work as they are created by webhooks and their functionality - // is for handling additional messages after sending a response. - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - // Note: this isn't documented, but you can use that if you want to. - // This flag just allows you to create messages visible only for the caller of the command - // (user who triggered the command) - Flags: 1 << 6, - Content: "Surprise!", - }, - }) - msg, err := s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ - Content: "Followup message has been created, after 5 seconds it will be edited", - }) - if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ - Content: "Something went wrong", - }) - return - } - time.Sleep(time.Second * 5) - - s.FollowupMessageEdit(s.State.User.ID, i.Interaction, msg.ID, &discordgo.WebhookEdit{ - Content: "Now the original message is gone and after 10 seconds this message will ~~self-destruct~~ be deleted.", - }) +func main() { - time.Sleep(time.Second * 10) + flag.Parse() - s.FollowupMessageDelete(s.State.User.ID, i.Interaction, msg.ID) + *BotToken = "OTE1NjE2NDcyMTU4NTg0ODYz.YaeMSg.vr_uULSBq7PL1iqwAe8qwOm_EgM" + *GuildID = "891737479412060191" + *RemoveCommands = true - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ - Content: "For those, who didn't skip anything and followed tutorial along fairly, " + - "take a unicorn :unicorn: as reward!\n" + - "Also, as bonus... look at the original interaction response :D", - }) - }, + ds, err := discordgo.New("Bot " + *BotToken) + if err != nil { + log.Fatalf("Invalid bot parameters: %v", err) } -) -func init() { - s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { - if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { - h(s, i) - } - }) -} + ds.AddHandler(HandleInteraction) + ds.AddHandler(Login) -func main() { - s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { - log.Printf("Logged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator) - }) - err := s.Open() + err = ds.Open() if err != nil { - log.Fatalf("Cannot open the session: %v", err) + log.Fatalf("Could not open the session: %v", err) } + defer ds.Close() log.Println("Adding commands...") - registeredCommands := make([]*discordgo.ApplicationCommand, len(commands)) - for i, v := range commands { - cmd, err := s.ApplicationCommandCreate(s.State.User.ID, *GuildID, v) - if err != nil { - log.Panicf("Cannot create '%v' command: %v", v.Name, err) - } - registeredCommands[i] = cmd + createCommands(ds, *GuildID) + if *RemoveCommands { + defer deleteCommands(ds, *GuildID) + defer log.Println("Removing commands...") } - defer s.Close() + log.Println("Bot is up and running") stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt) log.Println("Press Ctrl+C to exit") <-stop - if *RemoveCommands { - log.Println("Removing commands...") - // // We need to fetch the commands, since deleting requires the command ID. - // // We are doing this from the returned commands on line 375, because using - // // this will delete all the commands, which might not be desirable, so we - // // are deleting only the commands that we added. - // registeredCommands, err := s.ApplicationCommands(s.State.User.ID, *GuildID) - // if err != nil { - // log.Fatalf("Could not fetch registered commands: %v", err) - // } - - for _, v := range registeredCommands { - err := s.ApplicationCommandDelete(s.State.User.ID, *GuildID, v.ID) - if err != nil { - log.Panicf("Cannot delete '%v' command: %v", v.Name, err) - } - } - } - log.Println("Gracefully shutting down.") } From a3a7e25bed2973e0f2603a49961f09fd75700c02 Mon Sep 17 00:00:00 2001 From: trofchik Date: Sat, 16 Apr 2022 15:19:43 +0400 Subject: [PATCH 2/2] Integrated #1125. Made use of ApplicationCommandBulkOverwrite. Improved panic handling. --- examples/slash_commands/handlers.go | 32 +++++------- examples/slash_commands/main.go | 75 +++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 36 deletions(-) diff --git a/examples/slash_commands/handlers.go b/examples/slash_commands/handlers.go index a83ac8029..42f56308a 100644 --- a/examples/slash_commands/handlers.go +++ b/examples/slash_commands/handlers.go @@ -21,19 +21,11 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { log.Panicf("No handler found for command: %s", cmdName) } - defer removeCommandsOnFailure(s, i.GuildID) + defer deleteCommandsOnFailure(s, i.GuildID) // This way commands will be deleted even if panic occurs inside one of the handlers h(s, i) } -func removeCommandsOnFailure(s *discordgo.Session, guildID string) { - err := recover() - if err != nil { - deleteCommands(s, guildID) - log.Fatalf("Error occured inside command handler: %s\n", err) - } -} - func Basic(s *discordgo.Session, i *discordgo.InteractionCreate) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, @@ -188,7 +180,7 @@ func Responses(s *discordgo.Session, i *discordgo.InteractionCreate) { switch i.ApplicationCommandData().Options[0].IntValue() { case int64(discordgo.InteractionResponseChannelMessageWithSource): content = - "You've just responded to user inte interaction. User input is supposed to be shown as well but non were given." + "You've just responded to the user interaction. User input is supposed to be shown as well but non were given." case int64(discordgo.InteractionResponseDeferredChannelMessageWithSource): content = "You've just deferedly responded to user interaction. Congratulations!" @@ -198,7 +190,7 @@ func Responses(s *discordgo.Session, i *discordgo.InteractionCreate) { }) if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: "Something went wrong.", }) } @@ -215,28 +207,28 @@ func Responses(s *discordgo.Session, i *discordgo.InteractionCreate) { }) if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: "Something went wrong.", }) return } time.AfterFunc(time.Second*5, func() { - _, err = s.InteractionResponseEdit(s.State.User.ID, i.Interaction, &discordgo.WebhookEdit{ + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Content: content + "\n\nWell, now you know how to create and edit responses. " + "But you still don't know how to delete them... so... wait 10 seconds and this " + "message will be deleted.", }) if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: "Something went wrong.", }) return } time.Sleep(time.Second * 10) - s.InteractionResponseDelete(s.State.User.ID, i.Interaction) + s.InteractionResponseDelete(i.Interaction) }) } @@ -256,27 +248,27 @@ func Followups(s *discordgo.Session, i *discordgo.InteractionCreate) { }, }) - msg, err := s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + msg, err := s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: "Followup message has been created, after 5 seconds it will be edited.", }) if err != nil { - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: "Something went wrong.", }) return } time.Sleep(time.Second * 5) - s.FollowupMessageEdit(s.State.User.ID, i.Interaction, msg.ID, &discordgo.WebhookEdit{ + s.FollowupMessageEdit(i.Interaction, msg.ID, &discordgo.WebhookEdit{ Content: "Now the original message is gone and after 10 seconds this message will ~~self-destruct~~ be deleted.", }) time.Sleep(time.Second * 10) - s.FollowupMessageDelete(s.State.User.ID, i.Interaction, msg.ID) + s.FollowupMessageDelete(i.Interaction, msg.ID) - s.FollowupMessageCreate(s.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: "For those, who didn't skip anything and followed tutorial along fairly, " + "take a unicorn :unicorn: as reward!\n" + "Also, as bonus... look at the original interaction response :D", diff --git a/examples/slash_commands/main.go b/examples/slash_commands/main.go index a5556d283..2f843eccb 100644 --- a/examples/slash_commands/main.go +++ b/examples/slash_commands/main.go @@ -5,20 +5,24 @@ import ( "log" "os" "os/signal" + "syscall" "github.com/bwmarrin/discordgo" ) var ( - GuildID = flag.String("guild", "", "Test guild ID. If not passed - bot registers commands globally") + GuildID = flag.String("guild", "", "Test guild ID. If not passed - bot registers commands globally.") BotToken = flag.String("token", "", "Bot access token") - RemoveCommands = flag.Bool("rmcmd", false, "Weather to remove all commands after shutdowning or not") + RemoveCommands = flag.Bool("rmcmd", false, "Weather to remove all commands after shutdowning or not.") + DoInBulk = flag.Bool("bulk", true, `Weather to create and delete commands in bulk or not. + Setting to "true" avoids rate limits of the API but may complicate debugging.`) + // Note that deleteCommandsOnFailure() will always delete commands in bulk ) -func deleteCommands(ds *discordgo.Session, guildId string) { +func deleteCommands(s *discordgo.Session, guildId string) { for _, cmd := range commands { - err := ds.ApplicationCommandDelete( - ds.State.User.ID, + err := s.ApplicationCommandDelete( + s.State.User.ID, guildId, cmd.ID, ) @@ -28,11 +32,13 @@ func deleteCommands(ds *discordgo.Session, guildId string) { } } -func createCommands(ds *discordgo.Session, guildId string) { +func createCommands(s *discordgo.Session, guildId string) { + defer deleteCommandsOnFailure(s, guildId) + var err error for i, cmd := range commands { - commands[i], err = ds.ApplicationCommandCreate( - ds.State.User.ID, + commands[i], err = s.ApplicationCommandCreate( + s.State.User.ID, guildId, cmd, ) @@ -42,35 +48,72 @@ func createCommands(ds *discordgo.Session, guildId string) { } } +// Discord API has rate limit which limits the amount of request bot can make per time period +// Thus if there are to many commands creation or deletion of them on by one may take a lot of time +// due to said limits. +func createCommandsBulk(s *discordgo.Session, guildId string) { + defer deleteCommandsOnFailure(s, guildId) + + _, err := s.ApplicationCommandBulkOverwrite(s.State.User.ID, guildId, commands) + if err != nil { + log.Panicf("Could not create commands in bulk") + } +} + +func deleteCommandsBulk(s *discordgo.Session, guildId string) { + empty := make([]*discordgo.ApplicationCommand, 0) + _, err := s.ApplicationCommandBulkOverwrite(s.State.User.ID, guildId, empty) + if err != nil { + log.Fatalf("Could not delete bulk of commands %s\n", err) + } +} + +// Commands are almost gauranteed to be deleted on failure so that +// they are not left hanging with nobody to handle them +func deleteCommandsOnFailure(s *discordgo.Session, guildID string) { + err := recover() + if err != nil && *RemoveCommands { + deleteCommandsBulk(s, guildID) + log.Fatalf("Error occured, commands were deleted: %s\n", err) + } +} + func main() { flag.Parse() - ds, err := discordgo.New("Bot " + *BotToken) + s, err := discordgo.New("Bot " + *BotToken) if err != nil { log.Fatalf("Invalid bot parameters: %v", err) } - ds.AddHandler(HandleInteraction) - ds.AddHandler(Login) + s.AddHandler(HandleInteraction) + s.AddHandler(Login) - err = ds.Open() + err = s.Open() if err != nil { log.Fatalf("Could not open the session: %v", err) } - defer ds.Close() + defer s.Close() + + createCmd := createCommandsBulk + deleteCmd := deleteCommandsBulk + if !*DoInBulk { + createCmd = createCommands + deleteCmd = deleteCommands + } log.Println("Adding commands...") - createCommands(ds, *GuildID) + createCmd(s, *GuildID) if *RemoveCommands { - defer deleteCommands(ds, *GuildID) + defer deleteCmd(s, *GuildID) defer log.Println("Removing commands...") } log.Println("Bot is up and running") stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) log.Println("Press Ctrl+C to exit") <-stop