T5 badge on room cards and the Manage tab; room_info.php for hosted rooms
CI / build (push) Successful in 44s

room_info.php (the room card's participants, asked for every room) is answered for hosted rooms from
the hub (it was refused as naming a hosted room, which the client showed as an error), with max_users;
it and getRoomCardInfo get t5de_t5 for hosted and decorated rooms, as do the person's own rooms in
room_management_info.php.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 06:09:41 -07:00
1 parent d379d848c1
commit fc59367ba6
2 files changed
+195

No files matched your search

+151
View File
@@ -441,6 +441,16 @@ func (r *Rooms) Intercept(x *proxy.Exchange) error {
case "/api/rooms/rooms_list_paginated.php":
r.addToRoomList(x)
return nil
case "/api/room_management_info.php":
r.markManagedRooms(x)
return nil
case "/api/rooms/room_info.php":
if n, ok := RoomNumber(x.URL.Query().Get("room_id")); ok {
x.Response = r.roomInfo(n)
return nil
}
r.markRoomInfo(x, x.URL.Query().Get("room_id"))
return nil
case "/api/rooms/room_rating.php":
if _, ok := RoomNumber(x.URL.Query().Get("room_instance_id")); ok {
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"score":0,"result":"success"}`))
@@ -514,6 +524,9 @@ func (r *Rooms) interceptXMLRPC(x *proxy.Exchange) error {
case "test.getRoomCardInfo", "test.getRoomState", "test.setRoomState":
n, ok := RoomNumber(member(args, "room_instance_id"))
if !ok {
if call.Method == "test.getRoomCardInfo" {
r.markCard(x, member(args, "room_instance_id"))
}
return nil
}
switch call.Method {
@@ -601,6 +614,7 @@ func (r *Rooms) roomCard(ctx context.Context, n int64) *http.Response {
{Name: "autoBootWhenOwnerLeaves", Value: false}, {Name: "rating", Value: float64(0)},
{Name: "permission", Value: ""}, {Name: "themedStatus", Value: ""}, {Name: "themeId", Value: ""},
{Name: "eligibleForThemedRoomSubmission", Value: ""}, {Name: "occupants", Value: []xmlrpc.Value{}},
{Name: "t5de_t5", Value: true}, // the client's T5 badge (its t5de-rooms patch)
})
}
@@ -946,3 +960,140 @@ func swapToken(placement, from, to string) string {
}
return strings.Join(fields, " ")
}
// decorated is which of the IMVU rooms the hub has decorations for (20 asked at a time).
func (r *Rooms) decorated(ctx context.Context, instances []string) map[string]bool {
out := map[string]bool{}
for len(instances) > 0 {
batch := instances[:min(len(instances), 20)]
instances = instances[len(batch):]
var answer struct {
Rooms []struct {
Instance string `json:"instance"`
Contents []json.RawMessage `json:"contents"`
} `json:"rooms"`
}
if err := r.Hub.Call(ctx, "GET", "/api/v1/decorations?instances="+url.QueryEscape(strings.Join(batch, ",")), nil, &answer); err != nil {
break
}
for _, d := range answer.Rooms {
if len(d.Contents) > 0 {
out[d.Instance] = true
}
}
}
return out
}
// markCard adds t5de_t5 to an IMVU room's card if it has T5 decorations (the client's T5 badge).
func (r *Rooms) markCard(x *proxy.Exchange, instance string) {
if instance == "" {
return
}
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !r.decorated(ctx, []string{instance})[instance] {
return body, nil
}
answer, err := xmlrpc.ParseResponse(body)
if err != nil || answer.Fault != nil || len(answer.Params) != 1 {
return body, nil
}
card, ok := answer.Params[0].(xmlrpc.Struct)
if !ok {
return body, nil
}
answer.Params[0] = append(card, xmlrpc.Member{Name: "t5de_t5", Value: true})
return answer.Marshal(), nil
}
}
// markManagedRooms adds t5de_t5 to the person's own rooms with T5 decorations (room_management_info.php's rooms,
// which the chat rooms page's Manage tab shows).
func (r *Rooms) markManagedRooms(x *proxy.Exchange) {
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
var top map[string]json.RawMessage
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &top) != nil {
return body, nil
}
holder, wrapped := top, false
if inner, ok := top["result"]; ok {
var m map[string]json.RawMessage
if json.Unmarshal(inner, &m) == nil && m["rooms"] != nil {
holder, wrapped = m, true
}
}
var rooms []map[string]json.RawMessage
if json.Unmarshal(holder["rooms"], &rooms) != nil || len(rooms) == 0 {
return body, nil
}
var instances []string
for _, room := range rooms {
instances = append(instances, rawText(room["room_instance_id"]))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
marked := r.decorated(ctx, instances)
if len(marked) == 0 {
return body, nil
}
for i, room := range rooms {
if marked[instances[i]] {
room["t5de_t5"] = json.RawMessage("1")
}
}
holder["rooms"], _ = json.Marshal(rooms)
if wrapped {
top["result"], _ = json.Marshal(holder)
}
return json.Marshal(top)
}
}
// roomInfo answers room_info.php for a hosted room: the people in it, for the room card.
func (r *Rooms) roomInfo(n int64) *http.Response {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var out struct {
Participants []struct {
ImvuID int64 `json:"imvu_id"`
Name string `json:"name"`
} `json:"participants"`
}
people := []map[string]any{}
if err := r.Hub.Call(ctx, "GET", fmt.Sprintf("/api/v1/hosted-rooms/%d/participants", n), nil, &out); err == nil {
for _, p := range out.Participants {
people = append(people, map[string]any{"customers_id": strconv.FormatInt(p.ImvuID, 10), "avatar_name": plain(p.Name),
"is_friend": false, "age": "", "gender": "", "country": "", "avatar_download_size": 0})
}
}
maxUsers := 0
if rm, _, _, err := r.getRoom(ctx, n); err == nil {
maxUsers = rm.MaxUsers
}
b, _ := json.Marshal(map[string]any{"visitors_count": nil, "boot_count": nil, "participants": people,
"max_users": maxUsers, "t5de_t5": 1})
return proxy.LocalResponse(200, "application/json", b)
}
// markRoomInfo adds t5de_t5 to an IMVU room's room_info.php answer if it has T5 decorations (the room card's T5
// badge; the card always asks it, however it was opened).
func (r *Rooms) markRoomInfo(x *proxy.Exchange, instance string) {
if instance == "" {
return
}
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
var info map[string]json.RawMessage
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &info) != nil {
return body, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !r.decorated(ctx, []string{instance})[instance] {
return body, nil
}
info["t5de_t5"] = json.RawMessage("1")
return json.Marshal(info)
}
}
+44
View File
@@ -52,6 +52,8 @@ func (h *fakeHub) Call(ctx context.Context, method, path string, body, out any)
"scene": {"contents": [[1, 47477426, "37207-46"], [2, "coatcoat22", "37-11"], [3, "notherenn2", "37-12"]], "hash": "h1"}}`), out)
case method == "GET" && strings.HasPrefix(path, "/api/v1/decorations?instances="):
return json.Unmarshal([]byte(`{"rooms": [{"instance": "1-644", "contents": [[1, "coatcoat22", "x"]]}, {"instance": "2-17", "contents": []}]}`), out)
case method == "GET" && path == "/api/v1/hosted-rooms/3/participants":
return json.Unmarshal([]byte(`{"participants": [{"imvu_id": 88, "seat": 1, "name": "Erin<"}]}`), out)
case method == "GET" && path == "/api/v1/hosted-rooms":
return json.Unmarshal([]byte(`{"rooms": [{"n": 3, "name": "Lounge", "ownerImvuId": 88, "ownerName": "Erin", "maxUsers": 10, "occupancy": 2}]}`), out)
case method == "PUT" && path == "/api/v1/hosted-rooms/3/scene":
@@ -265,3 +267,45 @@ func TestRoomList(t *testing.T) {
t.Fatalf("page 2: %s", out)
}
}
func TestBadgesAndRoomInfo(t *testing.T) {
r, _, _ := setup()
do := func(raw string, body []byte, modified []byte) []byte {
t.Helper()
u, _ := url.Parse(raw)
req, _ := http.NewRequest("POST", u.String(), bytes.NewReader(body))
x := &proxy.Exchange{URL: u, Request: req, Body: body}
r.Intercept(x)
if x.Response != nil {
b, _ := io.ReadAll(x.Response.Body)
return b
}
if x.ModifyResponse == nil {
t.Fatalf("%s: left alone", raw)
}
out, _ := x.ModifyResponse(&http.Response{StatusCode: 200}, modified)
return out
}
// A decorated IMVU room's card gets t5de_t5.
card := (&xmlrpc.Call{Method: "test.getRoomCardInfo", Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "room_instance_id", Value: "1-644"}}}}).Marshal()
answer := (&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "name", Value: "A"}}}}).Marshal()
if out := do("https://client-dynamic.imvu.com/api/xmlrpc/client.php", card, answer); !strings.Contains(string(out), "t5de_t5") {
t.Fatalf("card %s", out)
}
// The Manage tab's own decorated rooms get it too.
managed := []byte(`{"result": {"limit": 1, "rooms": [{"room_instance_id": "1-644"}, {"room_instance_id": "2-17"}]}}`)
out := do("https://client-dynamic.imvu.com/api/room_management_info.php", nil, managed)
if !strings.Contains(string(out), `{"room_instance_id":"1-644","t5de_t5":1}`) || strings.Count(string(out), "t5de_t5") != 1 {
t.Fatalf("managed %s", out)
}
// A decorated IMVU room's room_info.php gets t5de_t5.
if out := do("https://client-dynamic.imvu.com/api/rooms/room_info.php?room_id=1-644", nil, []byte(`{"participants": []}`)); !strings.Contains(string(out), `"t5de_t5":1`) {
t.Fatalf("room info %s", out)
}
// room_info.php for a hosted room: its people, from the hub.
out = do("https://client-dynamic.imvu.com/api/rooms/room_info.php?room_id=88-2100000003", nil, nil)
if !strings.Contains(string(out), `"avatar_name":"Erin"`) || !strings.Contains(string(out), `"customers_id":"88"`) ||
!strings.Contains(string(out), `"max_users":10`) || !strings.Contains(string(out), `"t5de_t5":1`) {
t.Fatalf("room info %s", out)
}
}