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

@ -1,6 +1,6 @@
{
pkgs ? import <nixpkgs> {},
vendorHash ? "sha256-dnYDdGjQHLcHD78EFoUqMcACXUuKBSK3FWHalWjCd9c=",
vendorHash ? "sha256-OxPbXF7ouilQUCp8I76gqoq3t9PPYrUQukgxliACqlI=",
}:
rec {
default = roleypoly;

8
discord/api.go Normal file
View file

@ -0,0 +1,8 @@
package discord
type IDiscordClient interface {
}
func Get(url string) {
}

11
discord/guild.go Normal file
View file

@ -0,0 +1,11 @@
package discord
import "context"
type IGuild interface {
GetMember(ctx context.Context, memberID string) (IMember, error)
}
type Guild struct {
ID string
}

21
discord/guild_mock.go Normal file
View file

@ -0,0 +1,21 @@
package discord
import (
"context"
"github.com/stretchr/testify/mock"
)
type GuildMock struct {
mock.Mock
}
func (g *GuildMock) GetMember(ctx context.Context, memberID string) (IMember, error) {
args := g.Called(ctx, memberID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(IMember), args.Error(1)
}

14
discord/guildservice.go Normal file
View file

@ -0,0 +1,14 @@
package discord
import "context"
type IGuildService interface {
GetGuild(ctx context.Context, guildID string) (IGuild, error)
}
type GuildService struct {
}
func (gs *GuildService) GetGuild(ctx context.Context, guildID string) (IGuild, error) {
return nil, nil
}

View file

@ -0,0 +1,21 @@
package discord
import (
"context"
"github.com/stretchr/testify/mock"
)
type GuildServiceMock struct {
mock.Mock
}
func (gs *GuildServiceMock) GetGuild(ctx context.Context, guildID string) (IGuild, error) {
args := gs.Called(ctx, guildID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(IGuild), args.Error(1)
}

20
discord/member.go Normal file
View file

@ -0,0 +1,20 @@
package discord
import "git.sapphic.engineer/roleypoly/v4/types"
type IMember interface {
GetRoles() error
PatchRoles() error
}
type Member struct {
types.DiscordMember
}
func (m *Member) GetRoles() error {
return nil
}
func (m *Member) PatchRoles() error {
return nil
}

17
discord/member_mock.go Normal file
View file

@ -0,0 +1,17 @@
package discord
import "github.com/stretchr/testify/mock"
type MemberMock struct {
mock.Mock
}
func (m *MemberMock) GetRoles() error {
args := m.Called()
return args.Error(0)
}
func (m *MemberMock) PatchRoles() error {
args := m.Called()
return args.Error(0)
}

1
go.mod
View file

@ -25,6 +25,7 @@ require (
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.58.0 // indirect

2
go.sum
View file

@ -43,6 +43,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=

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

View file

@ -15,6 +15,7 @@ fmt:
go fmt ./...
tidy:
go clean -modcache
go mod tidy
update-vendor-hash:

46
main.go
View file

@ -1,36 +1,15 @@
package main // import "git.sapphic.engineer/roleypoly/v4"
import (
"embed"
"log"
"net/http"
"os"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/template/html/v2"
"git.sapphic.engineer/roleypoly/v4/roleypoly"
)
//go:embed templates/*
var templatesfs embed.FS
func main() {
// storageDir := getStorageDirectory()
viewEngine := html.NewFileSystem(http.FS(templatesfs), ".html")
app := fiber.New(fiber.Config{
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
Views: viewEngine,
ViewsLayout: "main",
})
// app.Use(session.New(session.Config{
// Storage: pebble.New(pebble.Config{
// Path: storageDir + "/sessions",
// }),
// }))
app := roleypoly.CreateFiberApp()
roleypoly.SetupRoutes(app)
listenAddr := os.Getenv("LISTEN_ADDR")
if listenAddr == "" {
@ -39,22 +18,3 @@ func main() {
log.Fatal(app.Listen(listenAddr))
}
func getStorageDirectory() string {
path := os.Getenv("STORAGE_DIR")
if path == "" {
path = "./.storage"
}
_, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
err := os.MkdirAll(path, 0744)
if err != nil {
log.Fatalln("failed to create storage directory: ", err)
}
}
}
return path
}

51
roleypoly/fiber.go Normal file
View file

@ -0,0 +1,51 @@
package roleypoly
import (
"net/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
"github.com/gofiber/template/html/v2"
"git.sapphic.engineer/roleypoly/v4/templates"
"git.sapphic.engineer/roleypoly/v4/testing"
)
func CreateFiberApp() *fiber.App {
viewEngine := html.NewFileSystem(http.FS(templates.FS), ".html")
app := fiber.New(fiber.Config{
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
Views: viewEngine,
ViewsLayout: "layouts/main",
})
app.Use(session.New(session.Config{}))
return app
}
func SetupRoutes(app *fiber.App) {
(&testing.TestingController{}).Routes(app.Group("/testing"))
}
// func getStorageDirectory() string {
// path := os.Getenv("STORAGE_DIR")
// if path == "" {
// path = "./.storage"
// }
// _, err := os.Stat(path)
// if err != nil {
// if os.IsNotExist(err) {
// err := os.MkdirAll(path, 0744)
// if err != nil {
// log.Fatalln("failed to create storage directory: ", err)
// }
// }
// }
// return path
// }

6
templates/embed.go Normal file
View file

@ -0,0 +1,6 @@
package templates
import "embed"
//go:embed *.html */*.html
var FS embed.FS

View file

@ -0,0 +1 @@
<h1>picker!</h1>

14
testing/testing.go Normal file
View file

@ -0,0 +1,14 @@
package testing
import "github.com/gofiber/fiber/v3"
type TestingController struct{}
func (t *TestingController) Routes(r fiber.Router) {
r.Get("/picker/:version?", t.Picker)
}
func (t *TestingController) Picker(c fiber.Ctx) error {
version := c.Params("version", "main")
return c.Render("picker/"+version, fiber.Map{})
}

22
types/api_error.go Normal file
View file

@ -0,0 +1,22 @@
package types
import "github.com/gofiber/fiber/v3"
type APIError struct {
StatusCode int `json:"-"`
Message string `json:"message"`
Success bool `json:"success"`
}
func NewAPIError(code int, message string) APIError {
return APIError{
StatusCode: code,
Message: message,
Success: false,
}
}
func (a APIError) Send(c fiber.Ctx) error {
c.JSON(a)
return c.SendStatus(a.StatusCode)
}

8
types/member.go Normal file
View file

@ -0,0 +1,8 @@
package types
type DiscordMember struct {
User DiscordUser `json:"user"`
Roles []string `json:"roles"`
Permissions uint64 `json:"permissions"`
Nick string `json:"nick"`
}

7
types/user.go Normal file
View file

@ -0,0 +1,7 @@
package types
type DiscordUser struct {
ID string `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}