Add /profile slash command

This commit is contained in:
Ben Visness 2021-09-26 17:34:38 -05:00
parent 0cebe90268
commit 85a7a37162
4 changed files with 613 additions and 31 deletions

142
src/discord/commands.go Normal file
View File

@ -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: "<a:confusedparrot:891814826484064336>",
},
})
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
}

View File

@ -544,15 +544,17 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage
case "RESUMED": case "RESUMED":
// Nothing to do, but at least we can log something // Nothing to do, but at least we can log something
logging.ExtractLogger(ctx).Info().Msg("Finished resuming gateway session") logging.ExtractLogger(ctx).Info().Msg("Finished resuming gateway session")
bot.createApplicationCommands(ctx)
case "MESSAGE_CREATE": case "MESSAGE_CREATE":
newMessage := MessageFromMap(msg.Data) newMessage := *MessageFromMap(msg.Data, "")
err := bot.messageCreateOrUpdate(ctx, &newMessage) err := bot.messageCreateOrUpdate(ctx, &newMessage)
if err != nil { if err != nil {
return oops.New(err, "error on new message") return oops.New(err, "error on new message")
} }
case "MESSAGE_UPDATE": case "MESSAGE_UPDATE":
newMessage := MessageFromMap(msg.Data) newMessage := *MessageFromMap(msg.Data, "")
err := bot.messageCreateOrUpdate(ctx, &newMessage) err := bot.messageCreateOrUpdate(ctx, &newMessage)
if err != nil { if err != nil {
@ -569,6 +571,15 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage
GuildID: bulkDelete.GuildID, 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 return nil

View File

@ -95,7 +95,7 @@ func ReadyFromMap(m interface{}) Ready {
return Ready{ return Ready{
GatewayVersion: int(mmap["v"].(float64)), GatewayVersion: int(mmap["v"].(float64)),
User: UserFromMap(mmap["user"]), User: *UserFromMap(mmap["user"], ""),
SessionID: mmap["session_id"].(string), SessionID: mmap["session_id"].(string),
} }
} }
@ -170,15 +170,43 @@ type Role struct {
// more fields not yet present // 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 // https://discord.com/developers/docs/resources/channel#channel-object
type Channel struct { type Channel struct {
ID string `json:"id"` ID string `json:"id"`
Type ChannelType `json:"type"` Type ChannelType `json:"type"`
GuildID string `json:"guild_id"` GuildID string `json:"guild_id"`
Name string `json:"name"` Name string `json:"name"`
Receipients []User `json:"recipients"` // More fields not yet present
OwnerID User `json:"owner_id"` }
ParentID *string `json:"parent_id"`
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 type MessageType int
@ -222,7 +250,7 @@ type Message struct {
ChannelID string `json:"channel_id"` ChannelID string `json:"channel_id"`
GuildID *string `json:"guild_id"` GuildID *string `json:"guild_id"`
Content string `json:"content"` 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"` Timestamp string `json:"timestamp"`
Type MessageType `json:"type"` Type MessageType `json:"type"`
@ -269,44 +297,45 @@ func (m *Message) OriginalHasFields(fields ...string) bool {
return true return true
} }
func MessageFromMap(m interface{}) Message { func MessageFromMap(m interface{}, k string) *Message {
/* /*
Some gateway events, like MESSAGE_UPDATE, do not contain the Some gateway events, like MESSAGE_UPDATE, do not contain the
entire message body. So we need to be defensive on all fields here, entire message body. So we need to be defensive on all fields here,
except the most basic identifying information. except the most basic identifying information.
*/ */
mmap := m.(map[string]interface{}) mmap := maybeGetKey(m, k)
if mmap == nil {
return nil
}
msg := Message{ msg := Message{
ID: mmap["id"].(string), ID: mmap["id"].(string),
ChannelID: mmap["channel_id"].(string), ChannelID: mmap["channel_id"].(string),
GuildID: maybeStringP(mmap, "guild_id"), GuildID: maybeStringP(mmap, "guild_id"),
Content: maybeString(mmap, "content"), Content: maybeString(mmap, "content"),
Author: UserFromMap(m, "author"),
Timestamp: maybeString(mmap, "timestamp"), Timestamp: maybeString(mmap, "timestamp"),
Type: MessageType(maybeInt(mmap, "type")), Type: MessageType(maybeInt(mmap, "type")),
originalMap: mmap, originalMap: mmap,
} }
if author, ok := mmap["author"]; ok {
msg.Author = UserFromMap(author)
}
if iattachments, ok := mmap["attachments"]; ok { if iattachments, ok := mmap["attachments"]; ok {
attachments := iattachments.([]interface{}) attachments := iattachments.([]interface{})
for _, iattachment := range attachments { for _, iattachment := range attachments {
msg.Attachments = append(msg.Attachments, AttachmentFromMap(iattachment)) msg.Attachments = append(msg.Attachments, *AttachmentFromMap(iattachment, ""))
} }
} }
if iembeds, ok := mmap["embeds"]; ok { if iembeds, ok := mmap["embeds"]; ok {
embeds := iembeds.([]interface{}) embeds := iembeds.([]interface{})
for _, iembed := range embeds { 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 // https://discord.com/developers/docs/resources/user#user-object
@ -319,8 +348,11 @@ type User struct {
Locale string `json:"locale"` Locale string `json:"locale"`
} }
func UserFromMap(m interface{}) User { func UserFromMap(m interface{}, k string) *User {
mmap := m.(map[string]interface{}) mmap := maybeGetKey(m, k)
if mmap == nil {
return nil
}
u := User{ u := User{
ID: mmap["id"].(string), ID: mmap["id"].(string),
@ -332,7 +364,25 @@ func UserFromMap(m interface{}) User {
u.IsBot = isBot.(bool) 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 // https://discord.com/developers/docs/resources/guild#guild-member-object
@ -342,6 +392,30 @@ type GuildMember struct {
// more fields not yet handled here // 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 "<UNKNOWN USER>"
}
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 // https://discord.com/developers/docs/resources/channel#attachment-object
type Attachment struct { type Attachment struct {
ID string `json:"id"` ID string `json:"id"`
@ -354,8 +428,12 @@ type Attachment struct {
Width *int `json:"width"` Width *int `json:"width"`
} }
func AttachmentFromMap(m interface{}) Attachment { func AttachmentFromMap(m interface{}, k string) *Attachment {
mmap := m.(map[string]interface{}) mmap := maybeGetKey(m, k)
if mmap == nil {
return nil
}
a := Attachment{ a := Attachment{
ID: mmap["id"].(string), ID: mmap["id"].(string),
Filename: mmap["filename"].(string), Filename: mmap["filename"].(string),
@ -367,7 +445,7 @@ func AttachmentFromMap(m interface{}) Attachment {
Width: maybeIntP(mmap, "width"), Width: maybeIntP(mmap, "width"),
} }
return a return &a
} }
// https://discord.com/developers/docs/resources/channel#embed-object // https://discord.com/developers/docs/resources/channel#embed-object
@ -430,8 +508,11 @@ type EmbedField struct {
Inline *bool `json:"inline"` Inline *bool `json:"inline"`
} }
func EmbedFromMap(m interface{}) Embed { func EmbedFromMap(m interface{}) *Embed {
mmap := m.(map[string]interface{}) mmap := m.(map[string]interface{})
if mmap == nil {
return nil
}
e := Embed{ e := Embed{
Title: maybeStringP(mmap, "title"), Title: maybeStringP(mmap, "title"),
@ -449,7 +530,7 @@ func EmbedFromMap(m interface{}) Embed {
Fields: EmbedFieldsFromMap(mmap, "fields"), Fields: EmbedFieldsFromMap(mmap, "fields"),
} }
return e return &e
} }
func EmbedFooterFromMap(m map[string]interface{}, k string) *EmbedFooter { func EmbedFooterFromMap(m map[string]interface{}, k string) *EmbedFooter {
@ -588,6 +669,286 @@ func EmbedFieldsFromMap(m map[string]interface{}, k string) []EmbedField {
return result 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 { func maybeString(m map[string]interface{}, k string) string {
val, ok := m[k] val, ok := m[k]
if !ok { if !ok {
@ -598,7 +959,7 @@ func maybeString(m map[string]interface{}, k string) string {
func maybeStringP(m map[string]interface{}, k string) *string { func maybeStringP(m map[string]interface{}, k string) *string {
val, ok := m[k] val, ok := m[k]
if !ok { if !ok || val == nil {
return nil return nil
} }
strval := val.(string) 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 { func maybeIntP(m map[string]interface{}, k string) *int {
val, ok := m[k] val, ok := m[k]
if !ok { if !ok || val == nil {
return nil return nil
} }
intval := int(val.(float64)) 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 { func maybeBoolP(m map[string]interface{}, k string) *bool {
val, ok := m[k] val, ok := m[k]
if !ok { if !ok || val == nil {
return nil return nil
} }
boolval := val.(bool) boolval := val.(bool)

View File

@ -500,6 +500,74 @@ func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMess
return msgs, nil 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 { func GetAuthorizeUrl(state string) string {
params := make(url.Values) params := make(url.Values)
params.Set("response_type", "code") params.Set("response_type", "code")