Files
t5de-proxy/internal/products/room.go
T
dhkatzandClaude Opus 5.5 ca7a86ce89
CI / build (push) Successful in 25s
T5 products in the client's inventory
The inventory's product list (test.showInventory) gets the community products the person has: local packs, and
hub products in their library. Hiding one is answered by the proxy (and removes a hub product from the library).
When the library changes, the client is told to load its inventory again, with the server event IMVU sends
after a purchase. A product whose revision or info changes gets a new local ID, since the client keeps product
info for good; names lose characters the client's pages could take as HTML.

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

384 lines
11 KiB
Go

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)
last map[string][]hub.Avatar // chat -> what the hub last said about the others in it
users map[*proxy.IMQConn]string // each connection's user queue ("/user/<id>")
}
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{}
r.last = map[string][]hub.Avatar{}
r.users = map[*proxy.IMQConn]string{}
}
}
// 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 {
if strings.HasPrefix(q, "/user/") {
p.room.users[c] = q
}
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)
delete(p.room.users, 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
}
if p.Want != nil {
p.Want(it.ID) // given to the client when it arrives (Ready)
}
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 here yet", 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)
delete(p.room.last, chat)
p.roomMu.Unlock()
return
}
p.room.last[chat] = avatars
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
}
// RefreshInventory has the client load its inventory again (after the library changed), with the server event
// IMVU sends after a purchase: "refreshInventory {}" on the user queue's web_msg mount.
func (p *Products) RefreshInventory() {
p.roomMu.Lock()
p.room.init()
users := make(map[*proxy.IMQConn]string, len(p.room.users))
for c, q := range p.room.users {
users[c] = q
}
p.roomMu.Unlock()
for c, q := range users {
if err := c.Deliver(imq.Delivered{UserID: "0", Queue: q, Mount: "web_msg", Message: []byte("refreshInventory {}")}); err != nil {
p.logf("products: refreshing the inventory: %v", err)
}
}
}