interactions yay!!
This commit is contained in:
parent
b9a05bedf9
commit
f60033a3e4
30 changed files with 716 additions and 44 deletions
5
interactions/cmd_helloworld.go
Normal file
5
interactions/cmd_helloworld.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package interactions
|
||||
|
||||
func (i *Interactions) CmdHelloWorld(ix Interaction) (InteractionResponse, error) {
|
||||
return String("Hello world!", true)
|
||||
}
|
16
interactions/cmd_helloworld_test.go
Normal file
16
interactions/cmd_helloworld_test.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
package interactions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCmdHelloWorld(t *testing.T) {
|
||||
i, _, _ := makeInteractions(t)
|
||||
ix := mkInteraction("hello-world")
|
||||
|
||||
ir, err := i.CmdHelloWorld(ix)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "Hello world!", ir.Data.Content)
|
||||
}
|
74
interactions/interactions.go
Normal file
74
interactions/interactions.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package interactions
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
||||
"git.sapphic.engineer/roleypoly/v4/discord"
|
||||
"git.sapphic.engineer/roleypoly/v4/types"
|
||||
)
|
||||
|
||||
type Interactions struct {
|
||||
PublicKey ed25519.PublicKey
|
||||
Guilds discord.IGuildService
|
||||
Router *InteractionRouter
|
||||
|
||||
// if interactions are able to be used
|
||||
OK bool
|
||||
}
|
||||
|
||||
func NewInteractions(publicKey string, guildsService discord.IGuildService) *Interactions {
|
||||
if publicKey == "" {
|
||||
publicKey = os.Getenv("DISCORD_PUBLIC_KEY")
|
||||
}
|
||||
|
||||
return &Interactions{
|
||||
PublicKey: ed25519.PublicKey(publicKey),
|
||||
Guilds: guildsService,
|
||||
Router: NewInteractionRouter(),
|
||||
OK: len(publicKey) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interactions) Routes(r fiber.Router) {
|
||||
r.Post("/", i.PostHandler)
|
||||
|
||||
i.Router.Add("hello-world", i.CmdHelloWorld)
|
||||
}
|
||||
|
||||
func (i *Interactions) PostHandler(c fiber.Ctx) error {
|
||||
if !i.OK {
|
||||
return types.NewAPIError(http.StatusUnauthorized, "interactions not enabled").Send(c)
|
||||
}
|
||||
|
||||
signature := c.Get("X-Signature-Ed25519")
|
||||
timestamp := c.Get("X-Signature-Timestamp")
|
||||
err := i.Verify(signature, timestamp, c.BodyRaw())
|
||||
if err != nil {
|
||||
log.Println("interactions: verification failed, ", err)
|
||||
return types.NewAPIError(http.StatusUnauthorized, "verification failed").Send(c)
|
||||
}
|
||||
|
||||
ix := Interaction{}
|
||||
err = c.Bind().JSON(&ix)
|
||||
if err != nil {
|
||||
log.Println("interactions: json body parse failed, ", err)
|
||||
return types.NewAPIError(http.StatusBadRequest, "bad request").Send(c)
|
||||
}
|
||||
|
||||
if ix.Type == TypePing {
|
||||
return c.JSON(InteractionResponse{
|
||||
Type: ResponsePong,
|
||||
})
|
||||
}
|
||||
|
||||
if ix.Type == TypeApplicationCommand {
|
||||
return i.Router.Handle(c, ix)
|
||||
}
|
||||
|
||||
return types.NewAPIError(http.StatusNotImplemented, "not implemented").Send(c)
|
||||
}
|
85
interactions/interactions_test.go
Normal file
85
interactions/interactions_test.go
Normal file
|
@ -0,0 +1,85 @@
|
|||
package interactions_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.sapphic.engineer/roleypoly/v4/discord"
|
||||
"git.sapphic.engineer/roleypoly/v4/interactions"
|
||||
"git.sapphic.engineer/roleypoly/v4/roleypoly"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewInteractions(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(nil)
|
||||
key := hex.EncodeToString(pub)
|
||||
t.Setenv("DISCORD_PUBLIC_KEY", key)
|
||||
|
||||
gsm := &discord.GuildServiceMock{}
|
||||
|
||||
i := interactions.NewInteractions("", gsm)
|
||||
assert.True(t, i.OK, "interactions OK")
|
||||
assert.Equal(t, string(i.PublicKey), key, "public key fetched from environment")
|
||||
|
||||
i = interactions.NewInteractions(key, gsm)
|
||||
assert.True(t, i.OK, "interactions OK")
|
||||
assert.Equal(t, string(i.PublicKey), key, "public key accepted from args")
|
||||
}
|
||||
|
||||
func TestPostHandler(t *testing.T) {
|
||||
i, _, key := makeInteractions(t)
|
||||
app := fiber.New()
|
||||
i.Routes(app)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"type": 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
sig, ts := sign(t, key, body)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/", bytes.NewBuffer(body))
|
||||
req.Header.Add("X-Signature-Ed25519", sig)
|
||||
req.Header.Add("X-Signature-Timestamp", ts)
|
||||
|
||||
res, err := app.Test(req, fiber.TestConfig{})
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestPostHandlerSigFail(t *testing.T) {
|
||||
i, _, _ := makeInteractions(t)
|
||||
app := roleypoly.CreateFiberApp()
|
||||
i.Routes(app)
|
||||
|
||||
// TODO: make real interaction
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"temp": "temp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
badPub, _, _ := ed25519.GenerateKey(nil)
|
||||
|
||||
sig, ts := sign(t, ed25519.PrivateKey(hex.EncodeToString(badPub)), body)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Signature-Ed25519", sig)
|
||||
req.Header.Set("X-Signature-Timestamp", ts)
|
||||
|
||||
res, err := app.Test(req, fiber.TestConfig{})
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
}
|
24
interactions/responses.go
Normal file
24
interactions/responses.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
package interactions
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
func Deferred(c fiber.Ctx) (InteractionResponse, error) {
|
||||
return InteractionResponse{
|
||||
Type: ResponseDeferredChannelMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func String(msg string, ephemeral bool) (InteractionResponse, error) {
|
||||
flags := 0
|
||||
if ephemeral {
|
||||
flags = FlagEphemeral
|
||||
}
|
||||
|
||||
return InteractionResponse{
|
||||
Type: ResponseChannelMessage,
|
||||
Data: InteractionResponseData{
|
||||
Content: msg,
|
||||
Flags: flags,
|
||||
},
|
||||
}, nil
|
||||
}
|
38
interactions/router.go
Normal file
38
interactions/router.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package interactions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sapphic.engineer/roleypoly/v4/types"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type InteractionHandler func(interaction Interaction) (InteractionResponse, error)
|
||||
|
||||
type InteractionRouter struct {
|
||||
Routes map[string]InteractionHandler
|
||||
}
|
||||
|
||||
func NewInteractionRouter() *InteractionRouter {
|
||||
return &InteractionRouter{
|
||||
Routes: map[string]InteractionHandler{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *InteractionRouter) Add(name string, h InteractionHandler) {
|
||||
r.Routes[name] = h
|
||||
}
|
||||
|
||||
func (r *InteractionRouter) Handle(c fiber.Ctx, interaction Interaction) error {
|
||||
route, ok := r.Routes[interaction.Data.Name]
|
||||
if !ok {
|
||||
return types.NewAPIError(http.StatusNotFound, "command not found").Send(c)
|
||||
}
|
||||
|
||||
resp, err := route(interaction)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(resp)
|
||||
}
|
16
interactions/router_test.go
Normal file
16
interactions/router_test.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
package interactions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRouter(t *testing.T) {
|
||||
i, _, key := makeInteractions(t)
|
||||
i.Router.Add("hello-world", cmdHelloWorld)
|
||||
|
||||
r := sendInteraction(t, i, key, mkInteraction("hello-world"))
|
||||
|
||||
assert.Equal(t, "Hello world!", r.Data.Content)
|
||||
}
|
50
interactions/types.go
Normal file
50
interactions/types.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
package interactions
|
||||
|
||||
import "git.sapphic.engineer/roleypoly/v4/types"
|
||||
|
||||
type Interaction struct {
|
||||
ID string `json:"id"`
|
||||
Member types.DiscordMember `json:"member"`
|
||||
Data InteractionData `json:"data"`
|
||||
GuildID string `json:"guild_id"`
|
||||
AppPermissions uint64 `json:"app_permissions"`
|
||||
Type uint64 `json:"type"`
|
||||
}
|
||||
|
||||
const (
|
||||
TypePing uint64 = 1
|
||||
TypeApplicationCommand uint64 = 2
|
||||
)
|
||||
|
||||
type InteractionData struct {
|
||||
Name string `json:"name"`
|
||||
Options []InteractionOption `json:"options"`
|
||||
}
|
||||
|
||||
type InteractionOption struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
const (
|
||||
ResponsePong = 1
|
||||
ResponseChannelMessage = 4
|
||||
ResponseDeferredChannelMessage = 5
|
||||
)
|
||||
|
||||
type InteractionResponse struct {
|
||||
Type int `json:"type"`
|
||||
Data InteractionResponseData `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
FlagEphemeral = 1 << 6
|
||||
)
|
||||
|
||||
type InteractionResponseData struct {
|
||||
Content string
|
||||
Embeds []Embed
|
||||
Flags int
|
||||
}
|
||||
|
||||
type Embed struct{}
|
93
interactions/utils_test.go
Normal file
93
interactions/utils_test.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
package interactions_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sapphic.engineer/roleypoly/v4/discord"
|
||||
"git.sapphic.engineer/roleypoly/v4/interactions"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func makeInteractions(t *testing.T) (*interactions.Interactions, *discord.GuildServiceMock, ed25519.PrivateKey) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Errorf("makeInteractions: failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
gs := &discord.GuildServiceMock{}
|
||||
|
||||
i := &interactions.Interactions{
|
||||
PublicKey: pub,
|
||||
Guilds: gs,
|
||||
Router: interactions.NewInteractionRouter(),
|
||||
OK: true,
|
||||
}
|
||||
|
||||
return i, gs, priv
|
||||
}
|
||||
|
||||
func cmdHelloWorld(ix interactions.Interaction) (interactions.InteractionResponse, error) {
|
||||
return interactions.String("Hello world!", true)
|
||||
}
|
||||
|
||||
func sign(t *testing.T, key ed25519.PrivateKey, body []byte) (string, string) {
|
||||
timestamp := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
payloadBuf := bytes.Buffer{}
|
||||
payloadBuf.WriteString(timestamp)
|
||||
payloadBuf.Write(body)
|
||||
|
||||
signature, err := key.Sign(nil, payloadBuf.Bytes(), crypto.Hash(0))
|
||||
if err != nil {
|
||||
t.Errorf("signing failed: %v", err)
|
||||
}
|
||||
|
||||
sigHex := hex.EncodeToString(signature)
|
||||
|
||||
return sigHex, timestamp
|
||||
}
|
||||
|
||||
func sendInteraction(t *testing.T, i *interactions.Interactions, key ed25519.PrivateKey, ix interactions.Interaction) (ir interactions.InteractionResponse) {
|
||||
app := fiber.New()
|
||||
i.Routes(app)
|
||||
|
||||
body, err := json.Marshal(ix)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
sig, ts := sign(t, key, body)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/", bytes.NewBuffer(body))
|
||||
req.Header.Add("X-Signature-Ed25519", sig)
|
||||
req.Header.Add("X-Signature-Timestamp", ts)
|
||||
|
||||
resp, err := app.Test(req)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&ir)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func mkInteraction(name string) interactions.Interaction {
|
||||
return interactions.Interaction{
|
||||
Type: interactions.TypeApplicationCommand,
|
||||
Data: interactions.InteractionData{
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
}
|
40
interactions/verify.go
Normal file
40
interactions/verify.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
package interactions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingHeader = errors.New("interactions: missing verification headers")
|
||||
ErrSignatureSize = errors.New("interactions: signature length invalid")
|
||||
ErrNotVerifed = errors.New("interactions: not verified")
|
||||
)
|
||||
|
||||
func (i *Interactions) Verify(signature, timestamp string, body []byte) error {
|
||||
if signature == "" || timestamp == "" {
|
||||
return ErrMissingHeader
|
||||
}
|
||||
|
||||
sig, err := hex.DecodeString(signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactions: signature failed to parse: %w", err)
|
||||
}
|
||||
|
||||
if len(sig) != ed25519.SignatureSize || sig[63]&224 != 0 {
|
||||
return ErrSignatureSize
|
||||
}
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
buf.WriteString(timestamp)
|
||||
buf.Write(body)
|
||||
|
||||
if !ed25519.Verify(i.PublicKey, buf.Bytes(), sig) {
|
||||
return ErrNotVerifed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
46
interactions/verify_test.go
Normal file
46
interactions/verify_test.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package interactions_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
i, _, key := makeInteractions(t)
|
||||
|
||||
// TODO: make this into a real interaction
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"test": "test",
|
||||
})
|
||||
|
||||
sig, ts := sign(t, key, body)
|
||||
|
||||
assert.NoError(t, i.Verify(sig, ts, body), "verification errored")
|
||||
}
|
||||
|
||||
func TestVerifyFailures(t *testing.T) {
|
||||
i, _, key := makeInteractions(t)
|
||||
badPub, _, _ := ed25519.GenerateKey(nil)
|
||||
|
||||
// TODO: make this into a real interaction
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"test": "test",
|
||||
})
|
||||
|
||||
sig, ts := sign(t, key, body)
|
||||
|
||||
assert.Error(t, i.Verify(sig, "", body), "erroneously allowed no timestamp")
|
||||
assert.Error(t, i.Verify("", ts, body), "erroneously allowed no signature")
|
||||
assert.Error(t, i.Verify(hex.EncodeToString(badPub), ts, body), "signature passed erroneously")
|
||||
assert.Error(t, i.Verify(string(badPub), ts, body), "accepted an incorrectly encoded key")
|
||||
assert.Error(t, i.Verify(sig+"aaaa", ts, body), "length of signature was not checked")
|
||||
|
||||
body, _ = json.Marshal(map[string]string{
|
||||
"test": "test2",
|
||||
})
|
||||
assert.Error(t, i.Verify(sig, ts, body), "verified against different data")
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue