Room channel: other T5DE users' community products on their avatars
CI / build (push) Successful in 28s

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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 18:14:44 -07:00
1 parent e394f1dc24
commit 93fa2f5d90
11 files changed
+858 -12

No files matched your search

+105 -6
View File
@@ -1,6 +1,7 @@
// 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 and the community products it wears.
// 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.
@@ -25,8 +26,15 @@ import (
// Item is a community product worn.
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
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.
@@ -162,16 +170,46 @@ 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, wake: make(chan struct{}, 1)}
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.
@@ -258,9 +296,23 @@ func (c *Client) connect(ctx context.Context) error {
errs <- err
return
}
var m struct{ Type, Message string }
if json.Unmarshal(data, &m) == nil && m.Type == "error" {
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)
}
}
}
}()
@@ -270,10 +322,30 @@ func (c *Client) connect(ctx context.Context) error {
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 {
@@ -287,6 +359,33 @@ func (c *Client) connect(ctx context.Context) error {
}
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, "")