Add /profile slash command
This commit is contained in:
parent
0cebe90268
commit
85a7a37162
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in New Issue