From 7074a1a950e801fb8be7a4ae61ceee899fb2f5a6 Mon Sep 17 00:00:00 2001 From: Ophestra Umiker Date: Wed, 19 Jun 2024 23:16:43 +0900 Subject: [PATCH] library: rpc: many RPC data types and client handshake activation Add a few internal validation functions to make validation cleaner, activation function is called as needed so explicit client activation is not required. Signed-off-by: Ophestra Umiker --- io.go | 3 + rpc.go | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 rpc.go diff --git a/io.go b/io.go index 106ae10..5fcb6d3 100644 --- a/io.go +++ b/io.go @@ -11,6 +11,9 @@ import ( type Client struct { id string + config *Config + user *User + dialed bool active bool net.Conn diff --git a/rpc.go b/rpc.go new file mode 100644 index 0000000..31a1136 --- /dev/null +++ b/rpc.go @@ -0,0 +1,171 @@ +package rpcfetch + +import ( + "encoding/json" + "errors" + "fmt" +) + +var ( + ErrNonce = errors.New("nonce mismatch") +) + +const ( + // Dispatch indicates an event was dispatched. + Dispatch = iota + // Heartbeat is fired periodically by the client to keep the connection alive. + Heartbeat + // Identify starts a new session during the initial handshake. + Identify + // PresenceUpdate update the client's presence. + PresenceUpdate + // VoiceStateUpdate is used to join/leave or move between voice channels. + VoiceStateUpdate + // Resume a previous session that was disconnected. + Resume + // Reconnect indicates you should attempt to reconnect and resume immediately. + Reconnect + // RequestGuildMembers requests information about offline guild members in a large guild. + RequestGuildMembers + // InvalidSession indicates that the session has been invalidated. You should reconnect and identify/resume accordingly. + InvalidSession + // Hello is sent immediately after connecting, contains the heartbeat_interval to use. + Hello + // HeartbeatACK is sent in response to receiving a heartbeat to acknowledge that it has been received. + HeartbeatACK +) + +// nonceEventCommandTracer exposes tracing of the Command, Event and nonce field to the generic validateRaw function +type nonceEventCommandTracer interface { + traceEvent(event string) *string + traceCommand(command string) *string + nonce(n string) bool +} + +type payload struct { + // Command is the payload command. + // https://discord.com/developers/docs/topics/rpc#commands-and-events-rpc-commands + Command string `json:"cmd"` + // Nonce is a unique string used once for replies from the server, present in responses to commands (not subscribed) + Nonce string `json:"nonce,omitempty"` + // Event is the subscription event, present in subscribed events, errors and (un)subscribing events + // https://discord.com/developers/docs/topics/rpc#commands-and-events-rpc-events + Event string `json:"evt,omitempty"` +} + +func (p payload) traceEvent(event string) *string { + if event == p.Event { + return nil + } + return &p.Event +} + +func (p payload) traceCommand(command string) *string { + if command == p.Command { + return nil + } + return &p.Command +} + +func (p payload) nonce(n string) bool { + return p.Nonce == n +} + +type Command[A any] struct { + // Arguments are command arguments, present in commands sent to the server + Arguments A `json:"args,omitempty"` + + payload +} + +type Response[D any] struct { + // Data is the event data, present in responses from the server + Data D `json:"data,omitempty"` + + payload +} + +type Config struct { + APIEndpoint string `json:"api_endpoint"` + CDNHost string `json:"cdn_host"` + Environment string `json:"environment"` +} + +type User struct { + Avatar string `json:"avatar"` + Discriminator string `json:"discriminator"` + Flags int `json:"flags"` + ID string `json:"id"` + PremiumType int `json:"premium_type"` + Username string `json:"username"` +} + +type ReadyData struct { + Config Config `json:"config"` + User User `json:"user"` + Version int `json:"v"` +} + +func (d *Client) activate() error { + // this function will be used in every method requiring an active client + // therefore it silently succeeds on an already active client + if d.active { + return nil + } + + if !d.dialed { + if err := d.dial(); err != nil { + return err + } + } + + // do Handshake + if _, resp, err := validateRaw[Response[ReadyData]](Heartbeat, "READY", "DISPATCH", "initial-ready")( + d, Dispatch, struct { + Version int `json:"v"` + ID string `json:"client_id"` + }{1, d.id}); err != nil { + return err + } else { + d.config = &resp.Data.Config + d.user = &resp.Data.User + d.active = true + } + + return nil +} + +func validateRaw[T nonceEventCommandTracer](opcode uint32, event, command, nonce string) func( + d *Client, opcode uint32, payload any) (uint32, T, error) { + return func(d *Client, opcodeP uint32, p any) (uint32, T, error) { + opcodeR, resp, err := Raw[T](d, opcodeP, p) + + eventR := resp.traceEvent(event) + commandR := resp.traceCommand(command) + + if err == nil { + switch { + case opcodeR != opcode: + debugResponse(resp) + err = fmt.Errorf("received unexpected opcode %d", opcodeR) + case !resp.nonce(nonce): + err = ErrNonce + case eventR != nil: + debugResponse(resp) + err = fmt.Errorf("received unexpected event %s", *eventR) + case commandR != nil: + debugResponse(resp) + err = fmt.Errorf("received unexpected command %s", *commandR) + } + } + return opcodeR, resp, err + } +} + +func debugResponse(resp any) { + if d, err := json.Marshal(resp); err != nil { + fmt.Printf("error dumping response: %s\n", err) + } else { + fmt.Println(string(d)) + } +}