Files
t5de-proxy/internal/hub/hub.go
T
dhkatzandClaude Opus 5.5 93fa2f5d90
CI / build (push) Successful in 28s
Room channel: other T5DE users' community products on their avatars
The proxy follows the client's IMQ chat subscriptions and tells the hub which chats it's in (and the room, from
chat.getOrMakeChat). For the other T5DE users the hub says are in a chat, it gives the client the community
products it has packs for, as *use and *remove lines from them, and adds them to the whole outfits they send.
It also takes community products out of the outfit the client posts to IMVU's chat participant endpoint, which
had been sending them to IMVU.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:14:44 -07:00

399 lines
10 KiB
Go

// Package hub connects the proxy to T5DE's hub (git.t5de.dev/t5de/t5de-hub): signing in with a code the person
// enters on the hub's website (OAuth 2.0 device authorization, by the hub's Better Auth), and a live connection
// reporting which IMVU account the client is using, the community products it wears, and the IMVU chats it's in;
// the hub answers with the other T5DE users in those chats and what they wear (the room channel).
//
// Signing in gives a session token, kept encrypted for the Windows user and sent as a bearer token. The hub extends
// the session while it's used, and ends it when the person signs their computers out or is banned.
package hub
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/coder/websocket"
)
// Item is a community product worn.
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
Adult bool `json:"adult"`
}
// Avatar is another T5DE user in a chat, and what they wear.
type Avatar struct {
IMVUID int64 `json:"imvu_id"`
Items []Item `json:"items"`
}
// Saved is the proxy's sign-in, in <data dir>/hub.json. The session token is encrypted for the Windows user.
type Saved struct {
URL string `json:"url"`
Token string `json:"-"`
Protected []byte `json:"token"`
}
// ErrSignedOut means there's no sign-in, or the hub ended it.
var ErrSignedOut = errors.New("not signed in to the hub")
func Load(path string) (*Saved, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrSignedOut
}
if err != nil {
return nil, err
}
var s Saved
if err := json.Unmarshal(b, &s); err != nil {
return nil, err
}
tok, err := unprotect(s.Protected)
if err != nil {
return nil, fmt.Errorf("reading the hub sign-in: %w", err)
}
s.Token = string(tok)
return &s, nil
}
func (s *Saved) Save(path string) error {
p, err := protect([]byte(s.Token))
if err != nil {
return err
}
s.Protected = p
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ClientID is the proxy's client ID in device sign-in.
const ClientID = "t5de-proxy"
// UserAgent marks the proxy's requests; the hub counts "computers signed in" by it.
var UserAgent = "t5de-proxy"
func postJSON(ctx context.Context, endpoint string, body any, out any) (int, error) {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(string(b)))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", UserAgent)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err := json.Unmarshal(data, out); err != nil {
return resp.StatusCode, fmt.Errorf("the hub answered %d", resp.StatusCode)
}
return resp.StatusCode, nil
}
// SignIn signs the proxy in: it gets a code from the hub, shows it (show gets the code and the page to enter it
// on), and waits until the person has entered it.
func SignIn(ctx context.Context, hubURL string, show func(code, page string)) (*Saved, error) {
hubURL = strings.TrimSuffix(hubURL, "/")
var device struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
Complete string `json:"verification_uri_complete"`
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
status, err := postJSON(ctx, hubURL+"/api/auth/device/code", map[string]string{"client_id": ClientID}, &device)
if err != nil {
return nil, err
}
if device.DeviceCode == "" {
return nil, fmt.Errorf("the hub didn't give a sign-in code (%d)", status)
}
show(device.UserCode, device.Complete)
interval := time.Duration(max(device.Interval, 2)) * time.Second
deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
var t struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
}
_, err := postJSON(ctx, hubURL+"/api/auth/device/token", map[string]string{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device.DeviceCode,
"client_id": ClientID,
}, &t)
if err != nil {
continue
}
switch {
case t.AccessToken != "":
return &Saved{URL: hubURL, Token: t.AccessToken}, nil
case t.Error == "authorization_pending":
case t.Error == "slow_down":
interval += 5 * time.Second
default:
return nil, fmt.Errorf("sign-in failed: %s", t.Error)
}
}
return nil, errors.New("the code expired")
}
// Client is the live connection to the hub.
type Client struct {
path string
log *log.Logger
// OnChat, if set, hears the other T5DE users in a chat the client is in, whenever that changes (and with none
// when the connection to the hub is lost).
OnChat func(chat string, avatars []Avatar)
mu sync.Mutex
saved *Saved
imvuID int64
outfit []Item
chats map[string]string // chat ID -> room instance ("" for other chats)
wake chan struct{}
}
// New makes a client for the sign-in saved at path.
func New(path string, saved *Saved, logger *log.Logger) *Client {
return &Client{path: path, saved: saved, log: logger, chats: map[string]string{}, wake: make(chan struct{}, 1)}
}
// Join reports the client is in an IMVU chat (room: the room instance, for a room's chat).
func (c *Client) Join(chat, room string) {
c.mu.Lock()
old, had := c.chats[chat]
c.chats[chat] = room
c.mu.Unlock()
if !had || old != room {
c.poke()
}
}
// Leave reports the client left an IMVU chat.
func (c *Client) Leave(chat string) {
c.mu.Lock()
_, had := c.chats[chat]
delete(c.chats, chat)
c.mu.Unlock()
if had {
c.poke()
}
if c.OnChat != nil {
c.OnChat(chat, nil)
}
}
// Session reports the IMVU account the client signed in as.
func (c *Client) Session(imvuID int64) {
c.mu.Lock()
changed := c.imvuID != imvuID
c.imvuID = imvuID
if changed {
c.outfit = nil
}
c.mu.Unlock()
if changed {
c.poke()
}
}
// Outfit reports the community products the account wears.
func (c *Client) Outfit(imvuID int64, items []Item) {
c.mu.Lock()
if c.imvuID != imvuID {
c.mu.Unlock()
return
}
c.outfit = append([]Item(nil), items...)
c.mu.Unlock()
c.poke()
}
func (c *Client) poke() {
select {
case c.wake <- struct{}{}:
default:
}
}
// Run keeps the connection up until ctx ends or the hub signs the proxy out.
func (c *Client) Run(ctx context.Context) {
backoff := time.Second
for ctx.Err() == nil {
start := time.Now()
err := c.connect(ctx)
if errors.Is(err, ErrSignedOut) {
c.log.Printf("hub: signed out (%v); run t5de-proxy signin again", err)
return
}
if ctx.Err() != nil {
return
}
if time.Since(start) > time.Minute {
backoff = time.Second
}
c.log.Printf("hub: disconnected (%v); retrying in %s", err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, 2*time.Minute)
}
}
func (c *Client) connect(ctx context.Context) error {
wsURL := strings.Replace(strings.Replace(c.saved.URL, "https://", "wss://", 1), "http://", "ws://", 1) + "/api/v1/realtime"
conn, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{
"Authorization": {"Bearer " + c.saved.Token}, "User-Agent": {UserAgent}}})
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
return fmt.Errorf("%w: the hub answered %d", ErrSignedOut, resp.StatusCode)
}
if err != nil {
return err
}
defer conn.CloseNow()
c.log.Printf("hub: connected to %s", c.saved.URL)
errs := make(chan error, 1)
go func() {
for {
_, data, err := conn.Read(ctx)
if err != nil {
// The hub closes with 4001 when the person signs their computers out or is banned.
if websocket.CloseStatus(err) == 4001 {
err = fmt.Errorf("%w: %v", ErrSignedOut, err)
}
errs <- err
return
}
var m struct {
Type, Message, Chat string
Avatars []Avatar
}
if json.Unmarshal(data, &m) != nil {
continue
}
switch m.Type {
case "error":
c.log.Printf("hub: %s", m.Message)
case "chat":
c.mu.Lock()
_, in := c.chats[m.Chat]
c.mu.Unlock()
if in && c.OnChat != nil {
c.OnChat(m.Chat, m.Avatars)
}
}
}
}()
send := func(v any) error {
b, _ := json.Marshal(v)
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return conn.Write(wctx, websocket.MessageText, b)
}
// Without the hub, nobody else's community products are known.
defer func() {
if c.OnChat == nil {
return
}
c.mu.Lock()
var chats []string
for chat := range c.chats {
chats = append(chats, chat)
}
c.mu.Unlock()
for _, chat := range chats {
c.OnChat(chat, nil)
}
}()
sentID, sentOutfit := int64(-1), ""
sentChats := map[string]string{}
for {
c.mu.Lock()
id, outfit := c.imvuID, append([]Item{}, c.outfit...) // a list, never null
chats := make(map[string]string, len(c.chats))
for k, v := range c.chats {
chats[k] = v
}
c.mu.Unlock()
if id != sentID && id != 0 {
if err := send(map[string]any{"type": "session", "imvu_id": id}); err != nil {
return err
}
sentID, sentOutfit = id, ""
}
if b, _ := json.Marshal(outfit); id != 0 && string(b) != sentOutfit {
if err := send(map[string]any{"type": "outfit", "items": outfit}); err != nil {
return err
}
sentOutfit = string(b)
}
for chat, room := range chats {
if r, ok := sentChats[chat]; ok && r == room {
continue
}
m := map[string]any{"type": "join", "chat": chat}
if room != "" {
m["room"] = room
}
if err := send(m); err != nil {
return err
}
if room != "" {
c.log.Printf("hub: in chat %s (room %s)", chat, room)
} else {
c.log.Printf("hub: in chat %s", chat)
}
sentChats[chat] = room
}
for chat := range sentChats {
if _, ok := chats[chat]; !ok {
if err := send(map[string]any{"type": "leave", "chat": chat}); err != nil {
return err
}
c.log.Printf("hub: left chat %s", chat)
delete(sentChats, chat)
}
}
select {
case <-ctx.Done():
conn.Close(websocket.StatusNormalClosure, "")
return ctx.Err()
case err := <-errs:
return err
case <-c.wake:
}
}
}