Files
t5de-hub/internal/realtime/realtime.go
T
dhkatzandClaude Opus 5.5 b9dc3f0ec1
CI / test (push) Failing after 42s
Hub: accounts, IMVU linking by tagline, proxy sign-in, presence, console
People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the
hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification
or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open,
reporting the IMVU account in use and the community products it wears; /online shows those who opted in.
Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the
website and API against an embedded Postgres and a fake IMVU API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:45:52 -07:00

230 lines
5.7 KiB
Go

// Package realtime is the hub's live connection with signed-in proxies: which IMVU account each client is using
// (presence), and the community products it's wearing. Later, room channels.
//
// Protocol (JSON text messages over a WebSocket at /api/v1/realtime, authorized with the proxy's access token):
//
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
package realtime
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"sort"
"sync"
"time"
"github.com/coder/websocket"
)
// Item is a community product someone wears.
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Online is an IMVU account in use in a T5DE client now.
type Online struct {
PersonID int64
IMVUID int64
Since time.Time
Outfit []Item
}
// Owners tells the hub which person an IMVU account is linked to.
type Owners interface {
LinkOwner(ctx context.Context, imvuID int64) (personID int64, ok bool, err error)
}
// Hub holds the connected proxies.
type Hub struct {
owners Owners
log *log.Logger
mu sync.Mutex
sessions map[*session]bool
changed chan struct{} // closed and replaced on every change, for live views
}
type session struct {
personID int64
conn *websocket.Conn
imvuID int64
since time.Time
outfit []Item
}
func New(owners Owners, logger *log.Logger) *Hub {
return &Hub{owners: owners, log: logger, sessions: map[*session]bool{}, changed: make(chan struct{})}
}
// Changed is closed the next time anyone's presence or outfit changes.
func (h *Hub) Changed() <-chan struct{} {
h.mu.Lock()
defer h.mu.Unlock()
return h.changed
}
func (h *Hub) notifyLocked() {
close(h.changed)
h.changed = make(chan struct{})
}
// Online lists the IMVU accounts in use now, one entry each (the most recent session), by IMVU ID.
func (h *Hub) Online() []Online {
h.mu.Lock()
defer h.mu.Unlock()
byID := map[int64]Online{}
for s := range h.sessions {
if s.imvuID == 0 {
continue
}
if prev, ok := byID[s.imvuID]; ok && prev.Since.After(s.since) {
continue
}
byID[s.imvuID] = Online{PersonID: s.personID, IMVUID: s.imvuID, Since: s.since, Outfit: append([]Item(nil), s.outfit...)}
}
out := make([]Online, 0, len(byID))
for _, o := range byID {
out = append(out, o)
}
sort.Slice(out, func(i, j int) bool { return out[i].Since.Before(out[j].Since) })
return out
}
// Connections counts connected proxies (signed in or not to an IMVU account yet).
func (h *Hub) Connections() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.sessions)
}
// Disconnect closes a person's connections (when they're banned or sign their proxies out).
func (h *Hub) Disconnect(personID int64) {
h.mu.Lock()
var conns []*websocket.Conn
for s := range h.sessions {
if s.personID == personID {
conns = append(conns, s.conn)
}
}
h.mu.Unlock()
for _, c := range conns {
c.Close(websocket.StatusPolicyViolation, "signed out")
}
}
type message struct {
Type string `json:"type"`
IMVUID int64 `json:"imvu_id,omitempty"`
Items []Item `json:"items,omitempty"`
Message string `json:"message,omitempty"`
}
// Serve runs one proxy's connection, already authorized as personID, until it closes.
func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, personID int64) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
conn.SetReadLimit(64 << 10)
s := &session{personID: personID, conn: conn, since: time.Now()}
h.mu.Lock()
h.sessions[s] = true
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.sessions, s)
if s.imvuID != 0 {
h.notifyLocked()
}
h.mu.Unlock()
conn.CloseNow()
}()
ctx := r.Context()
send := func(m message) error {
b, _ := json.Marshal(m)
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return conn.Write(wctx, websocket.MessageText, b)
}
if send(message{Type: "welcome"}) != nil {
return
}
// Keep the connection alive through proxies and NATs.
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
cancel()
if err != nil {
conn.CloseNow()
return
}
}
}
}()
for {
_, data, err := conn.Read(ctx)
if err != nil {
return
}
var m message
if json.Unmarshal(data, &m) != nil {
send(message{Type: "error", Message: "not a message"})
continue
}
switch m.Type {
case "session":
owner, ok, err := h.owners.LinkOwner(ctx, m.IMVUID)
if err != nil {
send(message{Type: "error", Message: "try again later"})
continue
}
if !ok || owner != personID {
// Not this person's account: no presence, no outfit for it.
h.set(s, func() { s.imvuID, s.outfit = 0, nil })
send(message{Type: "error", Message: "not_linked"})
continue
}
h.set(s, func() { s.imvuID, s.since, s.outfit = m.IMVUID, time.Now(), nil })
case "outfit":
if len(m.Items) > 64 {
send(message{Type: "error", Message: "too many items"})
continue
}
items := make([]Item, 0, len(m.Items))
for _, it := range m.Items {
if it.ID == "" || len(it.ID) > 128 || len(it.Name) > 128 {
continue
}
items = append(items, it)
}
h.set(s, func() { s.outfit = items })
default:
send(message{Type: "error", Message: "unknown message type"})
}
}
}
func (h *Hub) set(s *session, change func()) {
h.mu.Lock()
defer h.mu.Unlock()
change()
h.notifyLocked()
}
// ErrUnauthorized is returned by Authorize funcs for a missing or bad token.
var ErrUnauthorized = errors.New("unauthorized")