diff --git a/internal/hosted/hosted.go b/internal/hosted/hosted.go index 2cbca97..57a497b 100644 --- a/internal/hosted/hosted.go +++ b/internal/hosted/hosted.go @@ -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) + } +} diff --git a/internal/hosted/hosted_test.go b/internal/hosted/hosted_test.go index d42637e..e872c2b 100644 --- a/internal/hosted/hosted_test.go +++ b/internal/hosted/hosted_test.go @@ -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) + } +}