Serve T5DE-hosted rooms; a second client instance's own data
CI / build (push) Successful in 36s

Hosted rooms (internal/hosted): room calls for them answered here, their IMQ queues served (a new
proxy.IMQRouter hook takes them out of subscriptions to IMVU), chat relayed through the hub's
WebSocket (hub.Client gains Send, OnRoom and OnConnect), the room list extended, room products
checked against the real owner, and anything else naming a hosted room kept from IMVU.

T5DE_INSTANCE gives a second client's proxy its own data folder and log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 03:36:17 -07:00
1 parent 455dae1013
commit d8332b21a8
12 files changed
+1492 -12

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
package hosted
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"testing"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
)
// fakeHub answers joins at once (through rooms.FromHub) and records what it's sent.
type fakeHub struct {
mu sync.Mutex
rooms *Rooms
sent []map[string]any
scene []any
}
func (h *fakeHub) Send(v any) bool {
m := v.(map[string]any)
h.mu.Lock()
h.sent = append(h.sent, m)
h.mu.Unlock()
if m["type"] == "room.join" {
n := m["room"].(int64)
var answer string
if n == 9 {
answer = `{"type": "room.error", "room": 9, "reason": "full", "message": "This room is full."}`
} else {
answer = fmt.Sprintf(`{"type": "room.joined", "room": %d, "chat": %d, "sequence": 4,
"participants": [{"imvu_id": 7, "seat": 1}, {"imvu_id": 88, "seat": 2}]}`, n, Base+n)
}
go h.rooms.FromHub([]byte(answer))
}
return true
}
func (h *fakeHub) Call(ctx context.Context, method, path string, body, out any) error {
switch {
case method == "GET" && path == "/api/v1/hosted-rooms/3":
return json.Unmarshal([]byte(`{"room": {"n": 3, "name": "Lounge <b>", "ownerImvuId": 88, "ownerName": "Erin",
"roomPid": 50168116, "rating": "general", "maxUsers": 10, "occupancy": 2},
"scene": {"contents": [[1, 47477426, "37207-46"], [2, "coatcoat22", "37-11"], [3, "notherenn2", "37-12"]], "hash": "h1"}}`), out)
case method == "GET" && path == "/api/v1/hosted-rooms":
return json.Unmarshal([]byte(`{"rooms": [{"n": 3, "name": "Lounge", "ownerName": "Erin", "maxUsers": 10, "occupancy": 2}]}`), out)
case method == "PUT" && path == "/api/v1/hosted-rooms/3/scene":
h.mu.Lock()
h.scene = append(h.scene, body)
h.mu.Unlock()
return nil
}
return fmt.Errorf("unexpected %s %s", method, path)
}
func (h *fakeHub) sentOf(kind string) []map[string]any {
h.mu.Lock()
defer h.mu.Unlock()
var out []map[string]any
for _, m := range h.sent {
if m["type"] == kind {
out = append(out, m)
}
}
return out
}
func setup() (*Rooms, *fakeHub, *[]string) {
h := &fakeHub{}
var events []string
r := &Rooms{Hub: h, User: func() int64 { return 7 },
OnJoin: func(chat, room string) { events = append(events, "join "+chat+" "+room) },
OnLeave: func(chat string) { events = append(events, "leave "+chat) },
LocalProduct: func(id string) (int64, bool) { return 2000000005, id == "coatcoat22" },
GlobalProduct: func(local int64) (string, bool) { return "coatcoat22", local == 2000000005 }}
h.rooms = r
return r, h, &events
}
func xmlrpcCall(t *testing.T, r *Rooms, host, method string, args xmlrpc.Struct) (*xmlrpc.Response, bool) {
t.Helper()
body := (&xmlrpc.Call{Method: method, Params: []xmlrpc.Value{args}}).Marshal()
u, _ := url.Parse("https://" + host + "/api/xmlrpc/" + map[bool]string{true: "chat.php", false: "client.php"}[strings.HasPrefix(method, "chat.")])
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 {
return nil, false
}
b, _ := io.ReadAll(x.Response.Body)
resp, err := xmlrpc.ParseResponse(b)
if err != nil {
t.Fatalf("%s: %v\n%s", method, err, b)
}
return resp, true
}
func get(v xmlrpc.Value, name string) xmlrpc.Value {
s, _ := v.(xmlrpc.Struct)
out, _ := s.Get(name)
return out
}
// frames reads the frames the proxy sent the client.
func frames(t *testing.T, b []byte) []imq.Frame {
var out []imq.Frame
for len(b) > 0 {
f, n, err := imq.Next(b)
if err != nil || n == 0 {
t.Fatalf("frames: %v", err)
}
out = append(out, f)
b = b[n:]
}
return out
}
func TestHostedRoom(t *testing.T) {
r, h, events := setup()
// IMVU's rooms are left alone.
if _, answered := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-170879926-5533"}}); answered {
t.Fatal("answered an IMVU room")
}
// Joining: the hub lets the client in; it sits in the first free seat.
resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-2100000000-3"}})
if get(resp.Params[0], "chatId") != int64(2100000003) || get(resp.Params[0], "seat") != int64(1) || get(resp.Params[0], "lastMessageId") != int64(4) {
t.Fatalf("join answer %v", resp.Params[0])
}
if len(*events) != 1 || (*events)[0] != "join 2100000003 2100000000-3" {
t.Fatalf("events %v", *events)
}
// A full room: the client is told why.
if resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-2100000000-9"}}); resp.Fault == nil || resp.Fault.String != "This room is full." {
t.Fatalf("full room: %+v", resp)
}
// Others in the room, without the client.
resp, _ = xmlrpcCall(t, r, "chat.imvu.com", "chat.getParticipants", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
others := resp.Params[0].([]xmlrpc.Value)
if len(others) != 1 || get(others[0], "userId") != int64(88) {
t.Fatalf("participants %v", others)
}
// IMQ: the room's queues are answered here; IMVU gets the rest.
var toClient bytes.Buffer
c := proxy.NewIMQConn(1, &toClient)
sub := imq.Subscription{Queues: []string{"/user/7"}, WithResults: []imq.QueueOp{{Queue: "/chat/2100000003", OpID: 5}, {Queue: "/chat/1385863547", OpID: 6}}}
rest := r.Route(c, false, sub)
if fmt.Sprint(rest.All()) != "[/user/7 /chat/1385863547]" {
t.Fatalf("for IMVU: %v", rest.All())
}
var types []string
for _, f := range frames(t, toClient.Bytes()) {
types = append(types, imq.TypeName(f.Type))
}
if got := strings.Join(types, " "); got != "G2cJoinedQueue G2cCreateMount G2cCreateMount G2cCreateMount G2cResult" {
t.Fatalf("frames %s", got)
}
// Chat goes to the hub; sitting down too.
if !r.Send("/chat/2100000003", "messages", []byte(`{"message": "*seat 2", "userId": 7}`)) || r.Send("/chat/1385863547", "messages", []byte(`{}`)) {
t.Fatal("Send's answer")
}
if seats, sends := h.sentOf("room.seat"), h.sentOf("room.send"); len(seats) != 1 || seats[0]["seat"] != 2 || len(sends) != 1 {
t.Fatalf("seats %v, sends %v", seats, sends)
}
// Others' chat comes back as the gateway would deliver it.
toClient.Reset()
r.FromHub([]byte(`{"type": "room.message", "room": 3, "from": 88, "mount": "messages", "message": "{\"message\":\"hi\",\"userId\":88}", "sequence": 5}`))
fs := frames(t, toClient.Bytes())
if len(fs) != 1 {
t.Fatalf("%d frames", len(fs))
}
d, _ := imq.ParseDelivered(fs[0].Data)
if d.UserID != "/user/88" || d.Queue != "/chat/2100000003" || d.Sequence != 5 || !strings.Contains(string(d.Message), "hi") {
t.Fatalf("delivered %+v", d)
}
// The room's card and scene come from the hub; the scene's community products as local IDs (missing ones left out).
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomCardInfo", xmlrpc.Struct{{Name: "room_instance_id", Value: "2100000000-3"}})
if get(resp.Params[0], "name") != "Lounge b" || get(resp.Params[0], "owner_cid") != int64(88) {
t.Fatalf("card %v", resp.Params[0])
}
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "2100000000-3"}})
contents := get(get(resp.Params[0], "room_state"), "room_contents").([]xmlrpc.Value)
if len(contents) != 2 || fmt.Sprint(contents[1]) != "[2 2000000005 37-11]" || get(get(get(resp.Params[0], "room_state"), "room_info"), "room_pid") != "50168116" {
t.Fatalf("scene %v", resp.Params[0])
}
// Saving the scene: community products as the hub's IDs.
xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.setRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "2100000000-3"},
{Name: "room_contents", Value: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(47477426), "37-1"}, []xmlrpc.Value{int64(2), int64(2000000005), "37-2"}}}})
if b, _ := json.Marshal(h.scene); string(b) != `[{"contents":[[1,47477426,"37-1"],[2,"coatcoat22","37-2"]]}]` {
t.Fatalf("saved %s", b)
}
// The participant post for a hosted chat is answered here.
u, _ := url.Parse("https://api.imvu.com/chat/chat-2100000003/participants/user-7")
req, _ := http.NewRequest("POST", u.String(), nil)
x := &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
if x.Response == nil || x.Response.StatusCode != 200 {
t.Fatal("participant post not answered")
}
// Anything else naming a hosted room is answered here, not sent to IMVU.
for _, raw := range []string{"https://api.imvu.com/room/room-2100000000-3", "https://client-dynamic.imvu.com/api/rooms/room_rating.php?room_instance_id=2100000000-3",
"https://client-dynamic.imvu.com/api/whatever.php?chat=2100000003"} {
u, _ := url.Parse(raw)
req, _ := http.NewRequest("GET", u.String(), nil)
x := &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
if x.Response == nil {
t.Errorf("%s went to IMVU", raw)
}
}
// Leaving.
xmlrpcCall(t, r, "chat.imvu.com", "chat.terminateChat", xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "chatId", Value: int64(2100000003)}})
if len(h.sentOf("room.leave")) != 1 || (*events)[len(*events)-1] != "leave 2100000003" {
t.Fatalf("leave: %v %v", h.sentOf("room.leave"), *events)
}
}
func TestRoomList(t *testing.T) {
r, _, _ := setup()
u, _ := url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=1")
req, _ := http.NewRequest("GET", u.String(), nil)
x := &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
body := []byte(`{"customers_room_id": ["644"], "customers_id": ["1"], "num_participants": [4], "name": ["A"], "participants": [[]], "is_ap": ["0"], "odd": [{"x": 1}], "number_of_rooms": 1}`)
out, err := x.ModifyResponse(&http.Response{StatusCode: 200}, body)
if err != nil {
t.Fatal(err)
}
var l map[string]any
json.Unmarshal(out, &l)
if fmt.Sprint(l["customers_room_id"]) != "[644 3]" || fmt.Sprint(l["customers_id"]) != "[1 2100000000]" ||
fmt.Sprint(l["num_participants"]) != "[4 2]" || fmt.Sprint(l["odd"]) != "[map[x:1] map[]]" || l["number_of_rooms"] != float64(1) {
t.Fatalf("list %s", out)
}
// Other pages and searches are left alone.
u, _ = url.Parse("https://client-dynamic.imvu.com/api/rooms/rooms_list_paginated.php?page=2")
x = &proxy.Exchange{URL: u, Request: req}
r.Intercept(x)
if x.ModifyResponse != nil {
t.Fatal("changed page 2")
}
}