From 85a7a3716235d35ccb680f63b1fe9604e1a431f9 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sun, 26 Sep 2021 17:34:38 -0500 Subject: [PATCH] Add /profile slash command --- src/discord/commands.go | 142 ++++++++++++++ src/discord/gateway.go | 15 +- src/discord/payloads.go | 419 +++++++++++++++++++++++++++++++++++++--- src/discord/rest.go | 68 +++++++ 4 files changed, 613 insertions(+), 31 deletions(-) create mode 100644 src/discord/commands.go diff --git a/src/discord/commands.go b/src/discord/commands.go new file mode 100644 index 00000000..d565a60e --- /dev/null +++ b/src/discord/commands.go @@ -0,0 +1,142 @@ +package discord + +import ( + "context" + "errors" + "fmt" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" +) + +const CommandNameProfile = "profile" + +const ProfileCommandOptionUser = "user" + +func (bot *botInstance) createApplicationCommands(ctx context.Context) { + doOrWarn := func(err error) { + if err == nil { + logging.ExtractLogger(ctx).Info().Msg("Created Discord application command") + } else { + logging.ExtractLogger(ctx).Warn().Err(err).Msg("Failed to create Discord application command") + } + } + + doOrWarn(CreateGuildApplicationCommand(ctx, CreateGuildApplicationCommandRequest{ + Name: CommandNameProfile, + Description: "Get a link to a user's Handmade Network profile", + Options: []ApplicationCommandOption{ + { + Type: ApplicationCommandOptionTypeUser, + Name: ProfileCommandOptionUser, + Description: "The Discord user to look up on Handmade Network", + Required: true, + }, + }, + Type: ApplicationCommandTypeChatInput, + })) +} + +func (bot *botInstance) doInteraction(ctx context.Context, i *Interaction) { + defer func() { + if recovered := recover(); recovered != nil { + logger := logging.ExtractLogger(ctx).Error() + if err, ok := recovered.(error); ok { + logger = logger.Err(err) + } else { + logger = logger.Interface("recovered", recovered) + } + logger.Msg("panic when handling Discord interaction") + } + }() + + switch i.Data.Name { + case CommandNameProfile: + bot.handleProfileCommand(ctx, i) + default: + logging.ExtractLogger(ctx).Warn().Str("name", i.Data.Name).Msg("didn't recognize Discord interaction name") + } +} + +func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction) { + userOpt := mustGetInteractionOption(i.Data.Options, ProfileCommandOptionUser) + userID := userOpt.Value.(string) + member := i.Data.Resolved.Members[userID] + + if userID == config.Config.Discord.BotUserID { + err := CreateInteractionResponse(ctx, i.ID, i.Token, InteractionResponse{ + Type: InteractionCallbackTypeChannelMessageWithSource, + Data: &InteractionCallbackData{ + Content: "", + }, + }) + if err != nil { + logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to send profile response") + } + return + } + + type profileResult struct { + HMNUser models.User `db:"auth_user"` + } + ires, err := db.QueryOne(ctx, bot.dbConn, profileResult{}, + ` + SELECT $columns + FROM + handmade_discorduser AS duser + JOIN auth_user ON duser.hmn_user_id = auth_user.id + WHERE + duser.userid = $1 + `, + userID, + ) + if err != nil { + if errors.Is(err, db.NotFound) { + err = CreateInteractionResponse(ctx, i.ID, i.Token, InteractionResponse{ + Type: InteractionCallbackTypeChannelMessageWithSource, + Data: &InteractionCallbackData{ + Content: fmt.Sprintf("%s hasn't linked a Handmade Network profile.", member.DisplayName()), + }, + }) + if err != nil { + logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to send profile response") + } + } else { + logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to look up user profile") + } + return + } + res := ires.(*profileResult) + + url := hmnurl.BuildUserProfile(res.HMNUser.Username) + err = CreateInteractionResponse(ctx, i.ID, i.Token, InteractionResponse{ + Type: InteractionCallbackTypeChannelMessageWithSource, + Data: &InteractionCallbackData{ + Content: fmt.Sprintf("%s's profile can be viewed at %s.", member.DisplayName(), url), + }, + }) + if err != nil { + logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to send profile response") + } +} + +func getInteractionOption(opts []ApplicationCommandInteractionDataOption, name string) (ApplicationCommandInteractionDataOption, bool) { + for _, opt := range opts { + if opt.Name == name { + return opt, true + } + } + + return ApplicationCommandInteractionDataOption{}, false +} + +func mustGetInteractionOption(opts []ApplicationCommandInteractionDataOption, name string) ApplicationCommandInteractionDataOption { + opt, ok := getInteractionOption(opts, name) + if !ok { + panic(fmt.Errorf("failed to get interaction option with name '%s'", name)) + } + return opt +} diff --git a/src/discord/gateway.go b/src/discord/gateway.go index 45d7351b..f2ef8178 100644 --- a/src/discord/gateway.go +++ b/src/discord/gateway.go @@ -544,15 +544,17 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage case "RESUMED": // Nothing to do, but at least we can log something logging.ExtractLogger(ctx).Info().Msg("Finished resuming gateway session") + + bot.createApplicationCommands(ctx) case "MESSAGE_CREATE": - newMessage := MessageFromMap(msg.Data) + newMessage := *MessageFromMap(msg.Data, "") err := bot.messageCreateOrUpdate(ctx, &newMessage) if err != nil { return oops.New(err, "error on new message") } case "MESSAGE_UPDATE": - newMessage := MessageFromMap(msg.Data) + newMessage := *MessageFromMap(msg.Data, "") err := bot.messageCreateOrUpdate(ctx, &newMessage) if err != nil { @@ -569,6 +571,15 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage GuildID: bulkDelete.GuildID, }) } + case "GUILD_CREATE": + guild := *GuildFromMap(msg.Data, "") + if guild.ID != config.Config.Discord.GuildID { + break + } + + bot.createApplicationCommands(ctx) + case "INTERACTION_CREATE": + go bot.doInteraction(ctx, InteractionFromMap(msg.Data, "")) } return nil diff --git a/src/discord/payloads.go b/src/discord/payloads.go index 33362f42..db472086 100644 --- a/src/discord/payloads.go +++ b/src/discord/payloads.go @@ -95,7 +95,7 @@ func ReadyFromMap(m interface{}) Ready { return Ready{ GatewayVersion: int(mmap["v"].(float64)), - User: UserFromMap(mmap["user"]), + User: *UserFromMap(mmap["user"], ""), SessionID: mmap["session_id"].(string), } } @@ -170,15 +170,43 @@ type Role struct { // more fields not yet present } +func RoleFromMap(m interface{}, k string) *Role { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + r := &Role{ + ID: mmap["id"].(string), + Name: mmap["name"].(string), + } + + return r +} + // https://discord.com/developers/docs/resources/channel#channel-object type Channel struct { - ID string `json:"id"` - Type ChannelType `json:"type"` - GuildID string `json:"guild_id"` - Name string `json:"name"` - Receipients []User `json:"recipients"` - OwnerID User `json:"owner_id"` - ParentID *string `json:"parent_id"` + ID string `json:"id"` + Type ChannelType `json:"type"` + GuildID string `json:"guild_id"` + Name string `json:"name"` + // More fields not yet present +} + +func ChannelFromMap(m interface{}, k string) *Channel { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + c := &Channel{ + ID: mmap["id"].(string), + Type: ChannelType(mmap["type"].(float64)), + GuildID: maybeString(mmap, "guild_id"), + Name: maybeString(mmap, "name"), + } + + return c } type MessageType int @@ -222,7 +250,7 @@ type Message struct { ChannelID string `json:"channel_id"` GuildID *string `json:"guild_id"` Content string `json:"content"` - Author User `json:"author"` // note that this may not be an actual valid user (see the docs) + Author *User `json:"author"` // note that this may not be an actual valid user (see the docs) Timestamp string `json:"timestamp"` Type MessageType `json:"type"` @@ -269,44 +297,45 @@ func (m *Message) OriginalHasFields(fields ...string) bool { return true } -func MessageFromMap(m interface{}) Message { +func MessageFromMap(m interface{}, k string) *Message { /* Some gateway events, like MESSAGE_UPDATE, do not contain the entire message body. So we need to be defensive on all fields here, except the most basic identifying information. */ - mmap := m.(map[string]interface{}) + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + msg := Message{ ID: mmap["id"].(string), ChannelID: mmap["channel_id"].(string), GuildID: maybeStringP(mmap, "guild_id"), Content: maybeString(mmap, "content"), + Author: UserFromMap(m, "author"), Timestamp: maybeString(mmap, "timestamp"), Type: MessageType(maybeInt(mmap, "type")), originalMap: mmap, } - if author, ok := mmap["author"]; ok { - msg.Author = UserFromMap(author) - } - if iattachments, ok := mmap["attachments"]; ok { attachments := iattachments.([]interface{}) for _, iattachment := range attachments { - msg.Attachments = append(msg.Attachments, AttachmentFromMap(iattachment)) + msg.Attachments = append(msg.Attachments, *AttachmentFromMap(iattachment, "")) } } if iembeds, ok := mmap["embeds"]; ok { embeds := iembeds.([]interface{}) for _, iembed := range embeds { - msg.Embeds = append(msg.Embeds, EmbedFromMap(iembed)) + msg.Embeds = append(msg.Embeds, *EmbedFromMap(iembed)) } } - return msg + return &msg } // https://discord.com/developers/docs/resources/user#user-object @@ -319,8 +348,11 @@ type User struct { Locale string `json:"locale"` } -func UserFromMap(m interface{}) User { - mmap := m.(map[string]interface{}) +func UserFromMap(m interface{}, k string) *User { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } u := User{ ID: mmap["id"].(string), @@ -332,7 +364,25 @@ func UserFromMap(m interface{}) User { u.IsBot = isBot.(bool) } - return u + return &u +} + +type Guild struct { + ID string `json:"id"` + // Who cares about the rest tbh +} + +func GuildFromMap(m interface{}, k string) *Guild { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + g := Guild{ + ID: mmap["id"].(string), + } + + return &g } // https://discord.com/developers/docs/resources/guild#guild-member-object @@ -342,6 +392,30 @@ type GuildMember struct { // more fields not yet handled here } +func (gm *GuildMember) DisplayName() string { + if gm.Nick != nil { + return *gm.Nick + } + if gm.User != nil { + return gm.User.Username + } + return "" +} + +func GuildMemberFromMap(m interface{}, k string) *GuildMember { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + gm := &GuildMember{ + User: UserFromMap(m, "user"), + Nick: maybeStringP(mmap, "nick"), + } + + return gm +} + // https://discord.com/developers/docs/resources/channel#attachment-object type Attachment struct { ID string `json:"id"` @@ -354,8 +428,12 @@ type Attachment struct { Width *int `json:"width"` } -func AttachmentFromMap(m interface{}) Attachment { - mmap := m.(map[string]interface{}) +func AttachmentFromMap(m interface{}, k string) *Attachment { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + a := Attachment{ ID: mmap["id"].(string), Filename: mmap["filename"].(string), @@ -367,7 +445,7 @@ func AttachmentFromMap(m interface{}) Attachment { Width: maybeIntP(mmap, "width"), } - return a + return &a } // https://discord.com/developers/docs/resources/channel#embed-object @@ -430,8 +508,11 @@ type EmbedField struct { Inline *bool `json:"inline"` } -func EmbedFromMap(m interface{}) Embed { +func EmbedFromMap(m interface{}) *Embed { mmap := m.(map[string]interface{}) + if mmap == nil { + return nil + } e := Embed{ Title: maybeStringP(mmap, "title"), @@ -449,7 +530,7 @@ func EmbedFromMap(m interface{}) Embed { Fields: EmbedFieldsFromMap(mmap, "fields"), } - return e + return &e } func EmbedFooterFromMap(m map[string]interface{}, k string) *EmbedFooter { @@ -588,6 +669,286 @@ func EmbedFieldsFromMap(m map[string]interface{}, k string) []EmbedField { return result } +// Data is always present on application command and message component interaction types. It is optional for future-proofing against new interaction types. +// +// Member is sent when the interaction is invoked in a guild, and User is sent when invoked in a DM. +// +// See https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-structure +type Interaction struct { + ID string `json:"id"` // id of the interaction + ApplicationID string `json:"application_id"` // id of the application this interaction is for + Type InteractionType `json:"type"` // the type of interaction + Data *InteractionData `json:"data"` // the command data payload + GuildID string `json:"guild_id"` // the guild it was sent from + ChannelID string `json:"channel_id"` // the channel it was sent from + Member *GuildMember `json:"member"` // guild member data for the invoking user, including permissions + User *User `json:"user"` // user object for the invoking user, if invoked in a DM + Token string `json:"token"` // a continuation token for responding to the interaction + Version int `json:"version"` // read-only property, always 1 + Message *Message `json:"message"` // for components, the message they were attached to +} + +// https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-type +type InteractionType int + +const ( + InteractionTypePing InteractionType = 1 + InteractionTypeApplicationCommand InteractionType = 2 + InteractionTypeMessageComponent InteractionType = 3 +) + +// See https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-data-structure +type InteractionData struct { + // Fields for Application Commands + // See https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure + + ID string `json:"id"` // the ID of the invoked command + Name string `json:"name"` // the name of the invoked command + Type ApplicationCommandType `json:"type"` // the type of the invoked command + Resolved *ResolvedData `json:"resolved"` // converted users + roles + channels + Options []ApplicationCommandInteractionDataOption `json:"options"` // the params + values from the user + + // Fields for Components + // TODO + + // Fields for User Command and Message Command + // TODO +} + +// See https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-resolved-data-structure +type ResolvedData struct { + Users map[string]User `json:"users"` + Members map[string]GuildMember `json:"members"` // Partial Member objects are missing user, deaf and mute fields. If data for a Member is included, data for its corresponding User will also be included. + Roles map[string]Role `json:"roles"` + Channels map[string]Channel `json:"channels"` // Partial Channel objects only have id, name, type and permissions fields. Threads will also have thread_metadata and parent_id fields. + Messages map[string]Message `json:"messages"` +} + +// See https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-response-structure +type InteractionResponse struct { + Type InteractionCallbackType `json:"type"` // the type of response + Data *InteractionCallbackData `json:"data"` // an optional response message +} + +// See https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type +type InteractionCallbackType int + +const ( + InteractionCallbackTypePong InteractionCallbackType = 1 // ACK a Ping + InteractionCallbackTypeChannelMessageWithSource InteractionCallbackType = 4 // respond to an interaction with a message + InteractionCallbackTypeDeferredChannelMessageWithSource InteractionCallbackType = 5 // ACK an interaction and edit a response later, the user sees a loading state + InteractionCallbackTypeDeferredUpdateMessage InteractionCallbackType = 6 // for components, ACK an interaction and edit the original message later; the user does not see a loading state + InteractionCallbackTypeUpdateMessage InteractionCallbackType = 7 // for components, edit the message the component was attached to +) + +type InteractionCallbackData struct { + TTS bool `json:"bool,omitempty"` + Content string `json:"content,omitempty"` + Embeds []Embed `json:"embeds,omitempty"` + // TODO: Allowed mentions + Flags InteractionCallbackDataFlags `json:"flags,omitempty"` + // TODO: Components +} + +type InteractionCallbackDataFlags int + +const ( + FlagEphemeral InteractionCallbackDataFlags = 1 << 6 +) + +type ApplicationCommandType int + +const ( + ApplicationCommandTypeChatInput ApplicationCommandType = 1 // Slash commands; a text-based command that shows up when a user types `/` + ApplicationCommandTypeUser ApplicationCommandType = 2 // A UI-based command that shows up when you right click or tap on a user + ApplicationCommandTypeMessage ApplicationCommandOptionType = 3 // A UI-based command that shows up when you right click or tap on a message +) + +// Required `options` must be listed before optional options +type ApplicationCommandOption struct { + Type ApplicationCommandOptionType `json:"type"` // the type of option + Name string `json:"name"` // 1-32 character name + Description string `json:"description"` // 1-100 character description + Required bool `json:"required"` // if the parameter is required or optional--default false + Choices []ApplicationCommandOptionChoice `json:"choices"` // choices for STRING, INTEGER, and NUMBER types for the user to pick from, max 25 + Options []ApplicationCommandOption `json:"options"` // if the option is a subcommand or subcommand group type, this nested options will be the parameters +} + +type ApplicationCommandOptionType int + +const ( + ApplicationCommandOptionTypeSubCommand ApplicationCommandOptionType = 1 + ApplicationCommandOptionTypeSubCommandGroup ApplicationCommandOptionType = 2 + ApplicationCommandOptionTypeString ApplicationCommandOptionType = 3 + ApplicationCommandOptionTypeInteger ApplicationCommandOptionType = 4 // Any integer between -2^53 and 2^53 + ApplicationCommandOptionTypeBoolean ApplicationCommandOptionType = 5 + ApplicationCommandOptionTypeUser ApplicationCommandOptionType = 6 + ApplicationCommandOptionTypeChannel ApplicationCommandOptionType = 7 // Includes all channel types + categories + ApplicationCommandOptionTypeRole ApplicationCommandOptionType = 8 + ApplicationCommandOptionTypeMentionable ApplicationCommandOptionType = 9 // Includes users and roles + ApplicationCommandOptionTypeNumber ApplicationCommandOptionType = 10 // Any double between -2^53 and 2^53 +) + +// If you specify `choices` for an option, they are the only valid values for a user to pick +type ApplicationCommandOptionChoice struct { + Name string `json:"name"` // 1-100 character choice name + Value interface{} `json:"value"` // value of the choice, up to 100 characters if string +} + +// All options have names, and an option can either be a parameter and input +// value--in which case Value will be set--or it can denote a subcommand or +// group--in which case it will contain a top-level key and another array +// of Options. +// +// Value and Options are mutually exclusive. +type ApplicationCommandInteractionDataOption struct { + Name string `json:"name"` + Type ApplicationCommandOptionType `json:"type"` + Value interface{} `json:"value"` // the value of the pair + Options []ApplicationCommandInteractionDataOption `json:"options"` // present if this option is a group or subcommand +} + +func InteractionFromMap(m interface{}, k string) *Interaction { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + i := &Interaction{ + ID: mmap["id"].(string), + ApplicationID: mmap["application_id"].(string), + Type: InteractionType(mmap["type"].(float64)), + Data: InteractionDataFromMap(m, "data"), + GuildID: maybeString(mmap, "guild_id"), + ChannelID: maybeString(mmap, "channel_id"), + Member: GuildMemberFromMap(mmap, "member"), + User: UserFromMap(mmap, "user"), + Token: mmap["token"].(string), + Version: int(mmap["version"].(float64)), + Message: MessageFromMap(mmap, "message"), + } + + return i +} + +func InteractionDataFromMap(m interface{}, k string) *InteractionData { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + d := &InteractionData{ + ID: mmap["id"].(string), + Name: mmap["name"].(string), + Type: ApplicationCommandType(mmap["type"].(float64)), + Resolved: ResolvedDataFromMap(mmap, "resolved"), + } + + if ioptions, ok := mmap["options"]; ok { + options := ioptions.([]interface{}) + for _, ioption := range options { + d.Options = append(d.Options, *ApplicationCommandInteractionDataOptionFromMap(ioption, "")) + } + } + + return d +} + +func ResolvedDataFromMap(m interface{}, k string) *ResolvedData { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + d := &ResolvedData{} + + if iusers, ok := mmap["users"]; ok { + users := iusers.(map[string]interface{}) + d.Users = make(map[string]User) + for id, iuser := range users { + d.Users[id] = *UserFromMap(iuser, "") + } + } + + if imembers, ok := mmap["members"]; ok { + members := imembers.(map[string]interface{}) + d.Members = make(map[string]GuildMember) + for id, imember := range members { + member := *GuildMemberFromMap(imember, "") + user := d.Users[id] + member.User = &user + d.Members[id] = member + } + } + + if iroles, ok := mmap["roles"]; ok { + roles := iroles.(map[string]interface{}) + d.Roles = make(map[string]Role) + for id, irole := range roles { + d.Roles[id] = *RoleFromMap(irole, "") + } + } + + if ichannels, ok := mmap["channels"]; ok { + channels := ichannels.(map[string]interface{}) + d.Channels = make(map[string]Channel) + for id, ichannel := range channels { + d.Channels[id] = *ChannelFromMap(ichannel, "") + } + } + + if imessages, ok := mmap["messages"]; ok { + messages := imessages.(map[string]interface{}) + d.Messages = make(map[string]Message) + for id, imessage := range messages { + d.Messages[id] = *MessageFromMap(imessage, "") + } + } + + return d +} + +func ApplicationCommandInteractionDataOptionFromMap(m interface{}, k string) *ApplicationCommandInteractionDataOption { + mmap := maybeGetKey(m, k) + if mmap == nil { + return nil + } + + o := &ApplicationCommandInteractionDataOption{ + Name: mmap["name"].(string), + Type: ApplicationCommandOptionType(mmap["type"].(float64)), + Value: mmap["value"], + } + + if ioptions, ok := mmap["options"]; ok { + options := ioptions.([]interface{}) + for _, ioption := range options { + o.Options = append(o.Options, *ApplicationCommandInteractionDataOptionFromMap(ioption, "")) + } + } + + return o +} + +// If called without a key, returns m. Otherwise, returns m[k]. +// If m[k] does not exist, returns nil. +// +// The intent is to allow the ThingFromMap functions to be flexibly called, +// either with the data in question as the root (no key) or as a child of +// another object (with a key). +func maybeGetKey(m interface{}, k string) map[string]interface{} { + if k == "" { + return m.(map[string]interface{}) + } else { + mmap := m.(map[string]interface{}) + if mk, ok := mmap[k]; ok { + return mk.(map[string]interface{}) + } else { + return nil + } + } +} + func maybeString(m map[string]interface{}, k string) string { val, ok := m[k] if !ok { @@ -598,7 +959,7 @@ func maybeString(m map[string]interface{}, k string) string { func maybeStringP(m map[string]interface{}, k string) *string { val, ok := m[k] - if !ok { + if !ok || val == nil { return nil } strval := val.(string) @@ -615,7 +976,7 @@ func maybeInt(m map[string]interface{}, k string) int { func maybeIntP(m map[string]interface{}, k string) *int { val, ok := m[k] - if !ok { + if !ok || val == nil { return nil } intval := int(val.(float64)) @@ -632,7 +993,7 @@ func maybeBool(m map[string]interface{}, k string) bool { func maybeBoolP(m map[string]interface{}, k string) *bool { val, ok := m[k] - if !ok { + if !ok || val == nil { return nil } boolval := val.(bool) diff --git a/src/discord/rest.go b/src/discord/rest.go index 925383d8..b8020634 100644 --- a/src/discord/rest.go +++ b/src/discord/rest.go @@ -500,6 +500,74 @@ func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMess return msgs, nil } +// See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command-json-params +type CreateGuildApplicationCommandRequest struct { + Name string `json:"name"` // 1-32 character name + Description string `json:"description"` // 1-100 character description + Options []ApplicationCommandOption `json:"options"` // the parameters for the command + DefaultPermission *bool `json:"default_permission"` // whether the command is enabled by default when the app is added to a guild + Type ApplicationCommandType `json:"type"` // the type of command, defaults 1 if not set +} + +// See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command +func CreateGuildApplicationCommand(ctx context.Context, in CreateGuildApplicationCommandRequest) error { + const name = "Create Guild Application Command" + + if in.Type == 0 { + in.Type = ApplicationCommandTypeChatInput + } + + payloadJSON, err := json.Marshal(in) + if err != nil { + return oops.New(nil, "failed to marshal request body") + } + + path := fmt.Sprintf("/applications/%s/guilds/%s/commands", config.Config.Discord.BotUserID, config.Config.Discord.GuildID) + res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { + req := makeRequest(ctx, http.MethodPost, path, []byte(payloadJSON)) + req.Header.Add("Content-Type", "application/json") + return req + }) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode >= 400 { + logErrorResponse(ctx, name, res, "") + return oops.New(nil, "received error from Discord") + } + + return nil +} + +func CreateInteractionResponse(ctx context.Context, interactionID, interactionToken string, in InteractionResponse) error { + const name = "Create Interaction Response" + + payloadJSON, err := json.Marshal(in) + if err != nil { + return oops.New(nil, "failed to marshal request body") + } + + path := fmt.Sprintf("/interactions/%s/%s/callback", interactionID, interactionToken) + res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request { + req := makeRequest(ctx, http.MethodPost, path, []byte(payloadJSON)) + req.Header.Add("Content-Type", "application/json") + return req + }) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode >= 400 { + logErrorResponse(ctx, name, res, "") + return oops.New(nil, "received error from Discord") + } + + return nil +} + func GetAuthorizeUrl(state string) string { params := make(url.Values) params.Set("response_type", "code")