rpcfetch/rpc.go

185 lines
4.9 KiB
Go
Raw Permalink Normal View History

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 {
if n == "initial-ready" {
return true
}
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:
// the socket is still open however
// as far as I'm aware this state is not recoverable
// therefore we close the connection and advise a retry
if opcodeR == Identify {
// clean up as much as possible
_ = d.Close()
// advise retry
err = ErrAgain
} else {
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))
}
}