Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate handling of interactions via outgoing webhooks #1181

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions events.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ type WebhooksUpdate struct {
// InteractionCreate is the data for a InteractionCreate event
type InteractionCreate struct {
*Interaction
Respond func(response *InteractionResponse) error `json:"-"`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a good idea to place a (potentially) REST method into a gateway event structure.
And while we don't have any other place for storing that, we certainly wouldn't like to mix HTTP and gateway functional.

Copy link
Contributor Author

@topi314 topi314 Jul 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discordgo could provide a separate interaction create event for http interactions

}

// UnmarshalJSON is a helper function to unmarshal Interaction object.
Expand Down
68 changes: 68 additions & 0 deletions examples/http_interactions/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"encoding/hex"
"flag"
"log"
"net/http"

"github.com/bwmarrin/discordgo"
)

var (
GuildID = flag.String("guild", "", "Test guild ID. If not passed - bot registers commands globally")
AppID = flag.String("app", "", "Discord app ID")
BotToken = flag.String("token", "", "Bot access token")
PublicKey = flag.String("publickey", "", "Public key for verifying requests")
)

func init() { flag.Parse() }

func main() {
s, err := discordgo.New("Bot " + *BotToken)
if err != nil {
log.Fatalf("Invalid bot parameters: %v", err)
}

hexDecodedKey, err := hex.DecodeString(*PublicKey)
if err != nil {
log.Fatal("Invalid public key: ", err)
}
s.PublicKey = hexDecodedKey

if _, err = s.ApplicationCommandBulkOverwrite(*AppID, *GuildID, []*discordgo.ApplicationCommand{
{
Name: "ping",
Description: "Ping command",
},
}); err != nil {
log.Fatalf("Failed to register commands: %v", err)
}

s.AddHandler(handleInteraction)

http.Handle("/", s)

if err = http.ListenAndServe(":5678", nil); err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

func handleInteraction(s *discordgo.Session, e *discordgo.InteractionCreate) {
if e.Type != discordgo.InteractionApplicationCommand {
return
}
data := e.ApplicationCommandData()

switch data.Name {
case "ping":
if err := e.Respond(&discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "pong",
},
}); err != nil {
log.Print("Failed to respond to interaction: ", err)
}
}
}
117 changes: 117 additions & 0 deletions http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package discordgo

import (
"encoding/json"
"errors"
"net/http"
"sync"
"time"
)

var (
// ErrInteractionExpired is returned when you try to reply to an interaction after 3s
ErrInteractionExpired = errors.New("interaction expired")

// ErrInteractionAlreadyRepliedTo is returned when you try to reply to an interaction multiple times
ErrInteractionAlreadyRepliedTo = errors.New("interaction was already replied to")
)

type replyStatus int

const (
replyStatusReplied replyStatus = iota + 1
replyStatusTimedOut
)

// ServeHTTP handles the heavy lifting of parsing the interaction request and sending the response
func (s *Session) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !VerifyInteraction(r, s.PublicKey) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

var i *InteractionCreate

if err := json.NewDecoder(r.Body).Decode(&i); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
}

// we can always respond to ping with pong
if i.Type == InteractionPing {
s.log(LogDebug, "received http ping")
if err := json.NewEncoder(w).Encode(InteractionResponse{
Type: InteractionResponsePong,
}); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}

responseChannel := make(chan *InteractionResponse)
defer close(responseChannel)
errorChannel := make(chan error)
defer close(errorChannel)

var (
status replyStatus
mu sync.Mutex
)

i.Respond = func(response *InteractionResponse) error {
mu.Lock()
defer mu.Unlock()

if status == replyStatusTimedOut {
return ErrInteractionExpired
}

if status == replyStatusReplied {
return ErrInteractionAlreadyRepliedTo
}

status = replyStatusReplied
responseChannel <- response
return <-errorChannel
}

go s.handleEvent(interactionCreateEventType, i)

var (
body []byte
contentType string
err error
)

// interactions can be replied to within 3 seconds, wait 4 to be safe
timer := time.NewTimer(time.Second * 4)
defer timer.Stop()
select {
case resp := <-responseChannel:
if resp.Data != nil && len(resp.Data.Files) > 0 {
contentType, body, err = MultipartBodyWithJSON(resp, resp.Data.Files)
} else {
contentType = "application/json"
body, err = json.Marshal(*resp)
}
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
errorChannel <- err
return
}

case <-timer.C:
mu.Lock()
defer mu.Unlock()
status = replyStatusTimedOut

s.log(LogWarning, "interaction timed out")

http.Error(w, "interaction timed out", http.StatusRequestTimeout)
return
}

w.Header().Set("Content-Type", contentType)
if _, err = w.Write(body); err != nil {
errorChannel <- err
}
}
3 changes: 3 additions & 0 deletions structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
package discordgo

import (
"crypto/ed25519"
"encoding/json"
"fmt"
"math"
Expand Down Expand Up @@ -129,6 +130,8 @@ type Session struct {

// used to make sure gateway websocket writes do not happen concurrently
wsMutex sync.Mutex

PublicKey ed25519.PublicKey
}

// Application stores values for a Discord Application
Expand Down