Hosted rooms phase 2: boots, invitations, favorites, ratings, live occupancy
CI / build (push) Successful in 36s

The client's own room actions work in hosted rooms, through the hub (t5de-hub's docs/hosted-rooms.md,
Phase 2): a "*boot" line goes to the room, then to the hub as room.boot; refused joins are declined as
the client's own are (booted ones as booted). Invitations: chat.attemptInvite polls the hub's answer,
and the invitee's client gets chatInvite on its user queue, accepting with the invite. Favorites
(favorite_rooms.php, add/removeFavoriteRoom, the favorites list), ratings (room_rating.php), the room
card's moderators, image and score, and room_occupancy queues fed from room.watch.

Web API parameters are read from JSON bodies too (imvu.http.securePost), and hosted rows added to an
empty IMVU list keep their flags numeric ("0" counted as set).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 06:30:42 -07:00
1 parent fc59367ba6
commit 210bc064e9
5 files changed
+629 -32

No files matched your search

+107 -1
View File
@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"testing"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
@@ -56,6 +57,11 @@ func (h *fakeHub) Call(ctx context.Context, method, path string, body, out any)
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" || method == "DELETE") && (path == "/api/v1/hosted-rooms/3/favorite" || path == "/api/v1/hosted-rooms/3/rating"):
h.mu.Lock()
h.sent = append(h.sent, map[string]any{"type": method + " " + path, "body": body})
h.mu.Unlock()
return nil
case method == "PUT" && path == "/api/v1/hosted-rooms/3/scene":
h.mu.Lock()
h.scene = append(h.scene, body)
@@ -144,7 +150,7 @@ func TestHostedRoom(t *testing.T) {
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-88-2100000009"}}); resp.Fault == nil || resp.Fault.String != "This room is full." {
if resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000009"}}); get(resp.Params[0], "response") != "declined" || get(resp.Params[0], "explanation") != "This room is full." {
t.Fatalf("full room: %+v", resp)
}
@@ -309,3 +315,103 @@ func TestBadgesAndRoomInfo(t *testing.T) {
t.Fatalf("room info %s", out)
}
}
func TestPhase2(t *testing.T) {
r, h, _ := setup()
var userMsgs []string
r.UserDeliver = func(mount string, b []byte) error { userMsgs = append(userMsgs, mount+" "+string(b)); return nil }
call := func(method string, args xmlrpc.Struct) xmlrpc.Value {
t.Helper()
resp, ok := xmlrpcCall(t, r, "chat.imvu.com", method, args)
if !ok {
t.Fatalf("%s left to IMVU", method)
}
return resp.Params[0]
}
xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
// Inviting: waiting until the hub says it was accepted.
first := call("chat.attemptInvite", xmlrpc.Struct{{Name: "partnerId", Value: int64(55)}, {Name: "location", Value: "88-2100000003"}})
id := get(first, "inviteId")
if get(first, "waiting") == nil || len(h.sentOf("room.invite")) != 1 || h.sentOf("room.invite")[0]["imvu_id"] != int64(55) {
t.Fatalf("first attempt %v, sent %v", first, h.sentOf("room.invite"))
}
r.FromHub([]byte(`{"type": "room.invite_result", "room": 3, "imvu_id": 55, "ok": true, "invite": "abc"}`))
if again := call("chat.attemptInvite", xmlrpc.Struct{{Name: "inviteId", Value: id}, {Name: "location", Value: "88-2100000003"}}); get(again, "waiting") == nil {
t.Fatalf("still waiting expected: %v", again)
}
r.FromHub([]byte(`{"type": "room.invite_answer", "invite": "abc", "to": 55, "accept": true}`))
if done := call("chat.attemptInvite", xmlrpc.Struct{{Name: "inviteId", Value: id}, {Name: "location", Value: "88-2100000003"}}); get(done, "waiting") != nil || get(done, "response") != nil {
t.Fatalf("accepted expected: %v", done)
}
// Invited: the client gets chatInvite on its user queue; accepting joins with the invite.
r.FromHub([]byte(`{"type": "room.invited", "room": 3, "invite": "xyz", "from": 88, "room_name": "Lounge"}`))
for i := 0; i < 100 && len(userMsgs) == 0; i++ {
time.Sleep(10 * time.Millisecond)
}
if len(userMsgs) != 1 || !strings.HasPrefix(userMsgs[0], "web_msg chatInvite ") || !strings.Contains(userMsgs[0], `"location":"88-2100000003"`) || !strings.Contains(userMsgs[0], `"name":"Erin"`) {
t.Fatalf("invitation %v", userMsgs)
}
var inv map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(userMsgs[0], "web_msg chatInvite ")), &inv)
accepted := call("chat.acceptInvite", xmlrpc.Struct{{Name: "inviteId", Value: int64(inv["inviteId"].(float64))}, {Name: "chatId", Value: int64(2100000003)}})
if get(accepted, "chatId") != int64(2100000003) || h.sentOf("room.invite_reply")[0]["accept"] != true || h.sentOf("room.join")[len(h.sentOf("room.join"))-1]["invite"] != "xyz" {
t.Fatalf("accepting: %v, sent %v", accepted, h.sent)
}
// Booting: the *boot line also tells the hub; bootOutOfChat is answered here.
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*boot 7 88", "userId": 7}`), nil, false)
if boots := h.sentOf("room.boot"); len(boots) != 1 || boots[0]["imvu_id"] != int64(88) {
t.Fatalf("boot %v", boots)
}
call("chat.bootOutOfChat", xmlrpc.Struct{{Name: "userId", Value: int64(88)}, {Name: "roomInstanceId", Value: "88-2100000003"}})
// Favorites.
resp, _ := xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.addFavoriteRoom", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
if resp == nil || len(h.sentOf("PUT /api/v1/hosted-rooms/3/favorite")) != 1 {
t.Fatal("favorite not added")
}
// Favorites from the room list.
fu, _ := url.Parse("https://client-dynamic.imvu.com/api/favorite_rooms.php")
fbody := []byte(`{"action": "remove", "room_instance_id": "88-2100000003"}`)
freq, _ := http.NewRequest("POST", fu.String(), bytes.NewReader(fbody))
fx := &proxy.Exchange{URL: fu, Request: freq, Body: fbody}
r.Intercept(fx)
if fx.Response == nil || len(h.sentOf("DELETE /api/v1/hosted-rooms/3/favorite")) != 1 {
t.Fatal("favorite not removed")
}
// Rating.
u, _ := url.Parse("https://client-dynamic.imvu.com/api/rooms/room_rating.php")
body := []byte("room_instance_id=88-2100000003&score=4")
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 || len(h.sentOf("PUT /api/v1/hosted-rooms/3/rating")) != 1 {
t.Fatal("rating not sent")
}
// Occupancy: watching tells the hub; changes reach the watcher as state changes.
var toClient bytes.Buffer
c := proxy.NewIMQConn(2, &toClient)
r.Route(c, false, imq.Subscription{Queues: []string{"room_occupancy/88-2100000003"}})
if w := h.sentOf("room.watch"); len(w) == 0 || fmt.Sprint(w[len(w)-1]["rooms"]) != "[3]" {
t.Fatalf("watch %v", w)
}
toClient.Reset()
r.FromHub([]byte(`{"type": "room.occupancy", "room": 3, "participants": [{"imvu_id": 88, "seat": 1}, {"imvu_id": 7, "seat": 2}]}`))
fs := frames(t, toClient.Bytes())
if len(fs) != 1 || fs[0].Type != imq.G2cStateChange || !strings.Contains(string(fs[0].Data), "room_occupancy/88-2100000003") {
t.Fatalf("occupancy frames %v", fs)
}
}
func TestAppendRoomToEmptyList(t *testing.T) {
list := map[string]json.RawMessage{"customers_id": json.RawMessage(`[]`), "is_ap": json.RawMessage(`[]`), "is_vip": json.RawMessage(`[]`), "name": json.RawMessage(`[]`)}
appendRoom(list, Room{N: 3, OwnerImvuID: 88, Name: "Lounge"})
if string(list["is_ap"]) != "[0]" || string(list["is_vip"]) != "[0]" || string(list["name"]) != `["Lounge"]` {
t.Fatalf("list %s %s %s", list["is_ap"], list["is_vip"], list["name"])
}
}