diff --git a/_example/buttons/main.go b/_example/buttons/main.go new file mode 100644 index 0000000..57c5692 --- /dev/null +++ b/_example/buttons/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "log" + "os" + + "github.com/diamondburned/arikawa/v2/api" + "github.com/diamondburned/arikawa/v2/discord" + "github.com/diamondburned/arikawa/v2/gateway" + "github.com/diamondburned/arikawa/v2/session" +) + +// To run, do `APP_ID="APP ID" GUILD_ID="GUILD ID" BOT_TOKEN="TOKEN HERE" go run .` + +func main() { + appID := discord.AppID(mustSnowflakeEnv("APP_ID")) + guildID := discord.GuildID(mustSnowflakeEnv("GUILD_ID")) + + token := os.Getenv("BOT_TOKEN") + if token == "" { + log.Fatalln("No $BOT_TOKEN given.") + } + + s, err := session.New("Bot " + token) + if err != nil { + log.Fatalln("Session failed:", err) + return + } + + s.AddHandler(func(e *gateway.InteractionCreateEvent) { + if e.Type == gateway.CommandInteraction { + // Send a message with a button back on slash commands. + data := api.InteractionResponse{ + Type: api.MessageInteractionWithSource, + Data: &api.InteractionResponseData{ + Content: "This is a message with a button!", + Components: []discord.Component{ + discord.ActionRow{ + Components: []discord.Component{ + discord.Button{ + Label: "Hello World!", + CustomID: "first_button", + Emoji: &discord.ButtonEmoji{ + Name: "👋", + }, + Style: discord.PrimaryButton, + }, + discord.Button{ + Label: "Secondary", + CustomID: "second_button", + Style: discord.SecondaryButton, + }, + discord.Button{ + Label: "Success", + CustomID: "success_button", + Style: discord.SuccessButton, + }, + discord.Button{ + Label: "Danger", + CustomID: "danger_button", + Style: discord.DangerButton, + }, + discord.Button{ + Label: "Link", + URL: "https://google.com", + Style: discord.LinkButton, + }, + }, + }, + }, + }, + } + + if err := s.RespondInteraction(e.ID, e.Token, data); err != nil { + log.Println("failed to send interaction callback:", err) + } + } + + if e.Type != gateway.ButtonInteraction { + return + } + + data := api.InteractionResponse{ + Type: api.UpdateMessage, + Data: &api.InteractionResponseData{ + Content: "Custom ID: " + e.Data.CustomID, + }, + } + + if err := s.RespondInteraction(e.ID, e.Token, data); err != nil { + log.Println("failed to send interaction callback:", err) + } + }) + + s.Gateway.AddIntents(gateway.IntentGuilds) + s.Gateway.AddIntents(gateway.IntentGuildMessages) + + if err := s.Open(); err != nil { + log.Fatalln("failed to open:", err) + } + defer s.Close() + + log.Println("Gateway connected. Getting all guild commands.") + + commands, err := s.GuildCommands(appID, guildID) + if err != nil { + log.Fatalln("failed to get guild commands:", err) + } + + for _, command := range commands { + log.Println("Existing command", command.Name, "found.") + } + + newCommands := []api.CreateCommandData{ + { + Name: "buttons", + Description: "Send an interactable message.", + }, + } + + for _, command := range newCommands { + _, err := s.CreateGuildCommand(appID, guildID, command) + if err != nil { + log.Fatalln("failed to create guild command:", err) + } + } + + // Block forever. + select {} +} + +func mustSnowflakeEnv(env string) discord.Snowflake { + s, err := discord.ParseSnowflake(os.Getenv(env)) + if err != nil { + log.Fatalf("Invalid snowflake for $%s: %v", env, err) + } + return s +} diff --git a/api/interaction.go b/api/interaction.go index 23bbc24..21b3d00 100644 --- a/api/interaction.go +++ b/api/interaction.go @@ -15,6 +15,8 @@ const ( MessageInteraction MessageInteractionWithSource AcknowledgeInteractionWithSource + DeferredMessageUpdate + UpdateMessage ) type InteractionResponse struct { @@ -25,17 +27,17 @@ type InteractionResponse struct { // InteractionResponseData is InteractionApplicationCommandCallbackData in the // official documentation. type InteractionResponseData struct { - TTS bool `json:"tts"` - Content string `json:"content"` - Embeds []discord.Embed `json:"embeds,omitempty"` - AllowedMentions AllowedMentions `json:"allowed_mentions,omitempty"` + TTS bool `json:"tts"` + Content string `json:"content"` + Components []discord.Component `json:"components,omitempty"` + Embeds []discord.Embed `json:"embeds,omitempty"` + AllowedMentions AllowedMentions `json:"allowed_mentions,omitempty"` } // RespondInteraction responds to an incoming interaction. It is also known as // an "interaction callback". func (c *Client) RespondInteraction( id discord.InteractionID, token string, data InteractionResponse) error { - return c.FastRequest( "POST", EndpointInteractions+id.String()+"/"+token+"/callback", diff --git a/discord/component.go b/discord/component.go new file mode 100644 index 0000000..946394f --- /dev/null +++ b/discord/component.go @@ -0,0 +1,93 @@ +package discord + +import "encoding/json" + +// ComponentType is the type of a component. +type ComponentType uint + +const ( + ActionRowComponent ComponentType = iota + 1 + ButtonComponent +) + +// Component is a component that can be attached to an interaction response. +type Component interface { + json.Marshaler + Type() ComponentType +} + +// ActionRow is a row of components at the bottom of a message. +type ActionRow struct { + Components []Component `json:"components"` +} + +// Type implements the InteractionComponent interface. +func (ActionRow) Type() ComponentType { + return ActionRowComponent +} + +// MarshalJSON marshals the action row in the format Discord expects. +func (a ActionRow) MarshalJSON() ([]byte, error) { + type actionRow ActionRow + + return json.Marshal(struct { + actionRow + Type ComponentType `json:"type"` + }{ + actionRow: actionRow(a), + Type: ActionRowComponent, + }) +} + +// Button is a clickable button that may be added to an interaction response. +type Button struct { + Label string `json:"label"` + // CustomID attached to InteractionCreate event when clicked. + CustomID string `json:"custom_id"` + Style ButtonStyle `json:"style"` + Emoji *ButtonEmoji `json:"emoji,omitempty"` + // Present on link-style buttons. + URL string `json:"url,omitempty"` + Disabled bool `json:"disabled,omitempty"` +} + +// ButtonStyle is the style to display a button in. +type ButtonStyle uint + +// All types of ButtonStyle documented. +const ( + PrimaryButton ButtonStyle = iota + 1 // Blurple button. + SecondaryButton // Grey button. + SuccessButton // Green button. + DangerButton // Red button. + LinkButton // Button that navigates to a URL. +) + +// ButtonEmoji is the emoji displayed on the button before the text. +type ButtonEmoji struct { + Name string `json:"name,omitempty"` + ID EmojiID `json:"id,omitempty"` + Animated bool `json:"animated,omitempty"` +} + +// Type implements the InteractionComponent interface. +func (Button) Type() ComponentType { + return ButtonComponent +} + +// MarshalJSON marshals the button in the format Discord expects. +func (b Button) MarshalJSON() ([]byte, error) { + type button Button + + if b.Style == 0 { + b.Style = PrimaryButton // Sane default for button. + } + + return json.Marshal(struct { + button + Type ComponentType `json:"type"` + }{ + button: button(b), + Type: ButtonComponent, + }) +} diff --git a/gateway/events.go b/gateway/events.go index 40858cb..91f087e 100644 --- a/gateway/events.go +++ b/gateway/events.go @@ -376,6 +376,7 @@ type ( Data InteractionData `json:"data"` GuildID discord.GuildID `json:"guild_id"` ChannelID discord.ChannelID `json:"channel_id"` + AppID discord.AppID `json:"application_id"` Member discord.Member `json:"member"` Token string `json:"token"` Version int `json:"version"` @@ -387,12 +388,21 @@ type InteractionType uint const ( PingInteraction InteractionType = iota + 1 CommandInteraction + ButtonInteraction ) +// TODO: InteractionData is being overloaded by Slash Command and Button at the moment. +// Separate them when v3 rolls out. + type InteractionData struct { + // Slash commands ID discord.CommandID `json:"id"` Name string `json:"name"` Options []InteractionOption `json:"options"` + + // Button + CustomID string `json:"custom_id"` + ComponentType discord.ComponentType `json:"component_type"` } type InteractionOption struct {