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

+353
View File
@@ -0,0 +1,353 @@
package products
// The room channel: other T5DE users' community products, on their avatars in this client.
//
// IMVU never sees community products, so other people's clients get each avatar's outfit without them. The hub
// tells the proxy which T5DE users are in the same chat and what they wear (by product ID); for products the proxy
// has, it delivers "*use" and "*remove" lines from those users to the client, as if they had sent them, and adds the
// products to the whole outfits those users send (which would otherwise put IMVU's products back in their place).
// The client then asks for the products like any other, and the proxy answers for them.
import (
"bytes"
"encoding/json"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
)
// room is the room channel's state, under Products.roomMu.
type room struct {
subs map[*proxy.IMQConn]map[string]bool // chat IDs each IMQ connection is subscribed to
rooms map[string]string // chat ID -> room instance, from getOrMakeChat
applied map[string]map[int64][]int64 // chat -> user -> local IDs the client was given for them
userIDs map[int64]string // user -> the gateway's user_id for them, as last seen
missing map[string]bool // products others wear that aren't in the packs (logged once)
}
func (r *room) init() {
if r.subs == nil {
r.subs = map[*proxy.IMQConn]map[string]bool{}
r.rooms = map[string]string{}
r.applied = map[string]map[int64][]int64{}
r.userIDs = map[int64]string{}
r.missing = map[string]bool{}
}
}
// chatOf is the chat ID of an IMQ chat queue ("/chat/<id>"), or "".
func chatOf(queue string) string {
id, ok := strings.CutPrefix(queue, "/chat/")
if !ok || id == "" || strings.Trim(id, "0123456789") != "" {
return ""
}
return id
}
// inChat reports whether any connection is subscribed to chat (under roomMu).
func (r *room) inChat(chat string) bool {
for _, chats := range r.subs {
if chats[chat] {
return true
}
}
return false
}
// Subscribed implements proxy.IMQWatcher: chats the client joins are reported to the hub.
func (p *Products) Subscribed(c *proxy.IMQConn, queues []string) {
p.roomMu.Lock()
p.room.init()
var joined []string
var rooms []string
for _, q := range queues {
chat := chatOf(q)
if chat == "" {
continue
}
if !p.room.inChat(chat) {
joined, rooms = append(joined, chat), append(rooms, p.room.rooms[chat])
}
if p.room.subs[c] == nil {
p.room.subs[c] = map[string]bool{}
}
p.room.subs[c][chat] = true
}
p.roomMu.Unlock()
if p.Observer != nil {
for i, chat := range joined {
p.Observer.Join(chat, rooms[i])
}
}
}
// Unsubscribed implements proxy.IMQWatcher.
func (p *Products) Unsubscribed(c *proxy.IMQConn, queues []string) {
p.roomMu.Lock()
p.room.init()
var left []string
for _, q := range queues {
if chat := chatOf(q); chat != "" && p.room.subs[c][chat] {
delete(p.room.subs[c], chat)
if !p.room.inChat(chat) {
left = append(left, chat)
delete(p.room.applied, chat)
}
}
}
p.roomMu.Unlock()
if p.Observer != nil {
for _, chat := range left {
p.Observer.Leave(chat)
}
}
}
// Closed implements proxy.IMQWatcher: a connection's subscriptions end with it.
func (p *Products) Closed(c *proxy.IMQConn) {
p.roomMu.Lock()
p.room.init()
var queues []string
for chat := range p.room.subs[c] {
queues = append(queues, "/chat/"+chat)
}
p.roomMu.Unlock()
p.Unsubscribed(c, queues)
p.roomMu.Lock()
delete(p.room.subs, c)
p.roomMu.Unlock()
}
// userOf is the user ID in the gateway's user_id ("123" or ".../123").
func userOf(userID string) int64 {
i := strings.LastIndexByte(userID, '/')
id, err := strconv.ParseInt(userID[i+1:], 10, 64)
if err != nil {
return 0
}
return id
}
// Incoming implements proxy.IMQWatcher: a T5DE user's whole outfit gets their community products added.
func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte {
chat := chatOf(m.Queue)
if chat == "" || m.Mount != "messages" {
return m.Message
}
user := userOf(m.UserID)
if user == 0 {
return m.Message
}
p.roomMu.Lock()
p.room.init()
p.room.userIDs[user] = m.UserID
locals := slices.Clone(p.room.applied[chat][user])
p.roomMu.Unlock()
if len(locals) == 0 {
return m.Message
}
msg, ok := parseChatMessage(m.Message)
if !ok {
return m.Message
}
fields := strings.Fields(msg.text)
if len(fields) < 2 || fields[0] != "*use" || !slices.Contains(fields[1:], strconv.Itoa(bodyProduct)) {
return m.Message
}
for _, id := range locals {
if s := strconv.FormatInt(id, 10); !slices.Contains(fields, s) {
fields = append(fields, s)
}
}
return msg.withText(strings.Join(fields, " "))
}
// Others is the hub's word on the other T5DE users in a chat and what they wear (none: forget them). The client is
// given the community products it has packs for, and told to take off the ones no longer worn.
func (p *Products) Others(chat string, avatars []hub.Avatar) {
type line struct {
user int64
text string
}
want := map[int64][]int64{}
for _, a := range avatars {
var locals []int64
for _, it := range a.Items {
if s := p.byProductID(it.ID); s != nil {
locals = append(locals, s.local)
continue
}
p.roomMu.Lock()
p.room.init()
first := !p.room.missing[it.ID]
p.room.missing[it.ID] = true
p.roomMu.Unlock()
if first {
p.logf("products: someone in the room wears %q (%s), which isn't in your packs", it.Name, it.ID)
}
}
slices.Sort(locals)
want[a.IMVUID] = locals
}
p.roomMu.Lock()
p.room.init()
if avatars == nil {
delete(p.room.applied, chat)
p.roomMu.Unlock()
return
}
had := p.room.applied[chat]
var lines []line
for user, old := range had {
if _, still := want[user]; !still && len(old) > 0 {
lines = append(lines, line{user, "*remove " + joinIDs(old)})
}
}
for user, locals := range want {
old := had[user]
var gone, added []int64
for _, id := range old {
if !slices.Contains(locals, id) {
gone = append(gone, id)
}
}
for _, id := range locals {
if !slices.Contains(old, id) {
added = append(added, id)
}
}
if len(gone) > 0 {
lines = append(lines, line{user, "*remove " + joinIDs(gone)})
}
if len(added) > 0 {
lines = append(lines, line{user, "*use " + joinIDs(added)})
}
}
p.room.applied[chat] = want
var conns []*proxy.IMQConn
for c, chats := range p.room.subs {
if chats[chat] {
conns = append(conns, c)
}
}
userIDs := map[int64]string{}
for _, l := range lines {
if id, ok := p.room.userIDs[l.user]; ok {
userIDs[l.user] = id
} else {
userIDs[l.user] = strconv.FormatInt(l.user, 10)
}
}
p.roomMu.Unlock()
chatID, _ := strconv.ParseInt(chat, 10, 64)
for _, l := range lines {
message, _ := json.Marshal(map[string]any{"userId": l.user, "chatId": chatID, "message": l.text, "to": 0})
for _, c := range conns {
if err := c.Deliver(imq.Delivered{UserID: userIDs[l.user], Queue: "/chat/" + chat, Mount: "messages", Message: message}); err != nil {
p.logf("products: giving the client %d's community products: %v", l.user, err)
}
}
}
if len(lines) > 0 {
p.logf("products: %d community product changes for others in chat %s", len(lines), chat)
}
}
func joinIDs(ids []int64) string {
s := make([]string, len(ids))
for i, id := range ids {
s[i] = strconv.FormatInt(id, 10)
}
return strings.Join(s, " ")
}
// interceptChat learns which room a chat is (chat.getOrMakeChat: asked with activity "publicroom-<room>",
// answered with the chat ID).
func (p *Products) interceptChat(x *proxy.Exchange) error {
call, err := xmlrpc.ParseCall(x.Body)
if err != nil || call.Method != "chat.getOrMakeChat" || len(call.Params) != 1 {
return nil
}
args, ok := call.Params[0].(xmlrpc.Struct)
if !ok {
return nil
}
activity, _ := args.Get("activity")
a, _ := activity.(string)
inst, ok := strings.CutPrefix(a, "publicroom-")
if !ok || !roomInstance.MatchString(inst) {
return nil
}
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
r, err := xmlrpc.ParseResponse(body)
if err != nil || r.Fault != nil || len(r.Params) != 1 {
return body, nil
}
res, ok := r.Params[0].(xmlrpc.Struct)
if !ok {
return body, nil
}
v, _ := res.Get("chatId")
var chat string
switch v := v.(type) {
case int64:
chat = strconv.FormatInt(v, 10)
case string:
chat = v
}
if chat != "" && strings.Trim(chat, "0123456789") == "" {
p.roomMu.Lock()
p.room.init()
p.room.rooms[chat] = inst
p.roomMu.Unlock()
}
return body, nil
}
return nil
}
var roomInstance = regexp.MustCompile(`^[0-9]{1,12}-[0-9]{1,12}$`)
// participantPath is where the client posts its outfit and seat to IMVU while in a chat.
var participantPath = regexp.MustCompile(`^/chat/chat-[0-9]+/participants/user-[0-9]+$`)
// interceptParticipant keeps community products out of the outfit the client posts to IMVU's chat participant
// endpoint ({"legacy_outfit_message": "*use ...", ...}).
func (p *Products) interceptParticipant(x *proxy.Exchange) error {
if len(x.Body) == 0 || !bytes.Contains(x.Body, []byte("legacy_outfit_message")) {
return nil
}
var post map[string]json.RawMessage
if json.Unmarshal(x.Body, &post) != nil {
return nil
}
var text string
if json.Unmarshal(post["legacy_outfit_message"], &text) != nil {
return nil
}
r := stripCommand(text, IsCommunity)
if !r.changed {
return nil
}
if r.empty {
delete(post, "legacy_outfit_message")
} else {
post["legacy_outfit_message"], _ = json.Marshal(r.text)
}
body, _ := json.Marshal(post)
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
return errNoKey
}
p.logf("products: kept %d community products out of the outfit posted to IMVU's chat", len(r.products))
x.Body = body
return nil
}