interactions yay!!

This commit is contained in:
41666 2025-03-25 21:26:24 -07:00
parent b9a05bedf9
commit f60033a3e4
30 changed files with 716 additions and 44 deletions

View file

@ -0,0 +1,5 @@
package interactions
func (i *Interactions) CmdHelloWorld(ix Interaction) (InteractionResponse, error) {
return String("Hello world!", true)
}

View 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)
}

View 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)
}

View 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
View 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
View 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)
}

View 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
View 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{}

View 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
View 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
}

View 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")
}