Hosted rooms: seats, owner-first room IDs, T5 items in their scenes
CI / build (push) Successful in 31s

- The seat chosen on joining, and "*msg SeatAssignment" room seats, go to the hub, so people joining get
  free seats (both stood on seat 1).
- Instance IDs are "<owner>-<2100000000+n>" (the hub's change): the client takes the owner from them, so
  owners can decorate their rooms.
- A hosted room's scene carries community products as the hub's IDs (placements too), turned into local
  IDs by each proxy, instead of being stripped; saved and loaded scenes translate placements too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 05:26:02 -07:00
1 parent 13ee023836
commit c483099558
6 files changed
+192 -31

No files matched your search

+68 -15
View File
@@ -3,7 +3,7 @@
// (XML-RPC), serves their IMQ queues (subscriptions answered here, messages to and from the hub), and adds them to
// the room list. IMVU never hears of them.
//
// A hosted room has a number n; its instance ID is "2100000000-<n>" and its chat ID 2100000000+n.
// A hosted room has a number n; its instance ID is "<owner's IMVU ID>-<2100000000+n>" and its chat ID 2100000000+n.
package hosted
import (
@@ -94,7 +94,8 @@ type joinAnswer struct {
Message string `json:"message"`
}
var instancePattern = regexp.MustCompile(`^` + strconv.Itoa(Base) + `-([1-9][0-9]{0,8})$`)
// A hosted room's instance ID is "<owner's IMVU ID>-<Base + n>": the client takes the owner from the first part.
var instancePattern = regexp.MustCompile(`^[1-9][0-9]{0,11}-([0-9]{10})$`)
// RoomNumber is the hosted room an instance ID names.
func RoomNumber(instance string) (int64, bool) {
@@ -102,8 +103,11 @@ func RoomNumber(instance string) (int64, bool) {
if m == nil {
return 0, false
}
n, err := strconv.ParseInt(m[1], 10, 64)
return n, err == nil && n < 2147483647-Base
number, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return 0, false
}
return ChatRoom(number) // the same range as chat IDs
}
// ChatRoom is the hosted room a chat ID belongs to.
@@ -114,7 +118,10 @@ func ChatRoom(chat int64) (int64, bool) {
return chat - Base, true
}
func instanceOf(n int64) string { return fmt.Sprintf("%d-%d", Base, n) }
func instanceID(owner, n int64) string { return fmt.Sprintf("%d-%d", owner, Base+n) }
// instanceOf is a room's instance ID, with its owner as last looked up (under mu).
func (r *Rooms) instanceOfLocked(n int64) string { return instanceID(r.owners[n], n) }
// queueRoom is the hosted room a queue belongs to, and which of its queues it is: "chat", "node" (the room's
// inv:/room/room-<instance>) or "occupancy" (room_occupancy/<instance>).
@@ -232,7 +239,7 @@ func (r *Rooms) roomLocked(n int64) *room {
}
rm, ok := r.rooms[n]
if !ok {
rm = &room{chat: Base + n, instance: instanceOf(n), conns: map[*proxy.IMQConn]bool{}}
rm = &room{chat: Base + n, instance: r.instanceOfLocked(n), conns: map[*proxy.IMQConn]bool{}}
r.rooms[n] = rm
}
return rm
@@ -356,7 +363,7 @@ func (r *Rooms) Closed(c *proxy.IMQConn) {
// Send sends what the client said or did in a hosted room to the hub (the client's message has already had its
// community products taken out, as in IMVU's rooms). It reports whether the queue was a hosted room's.
func (r *Rooms) Send(queue, mount string, message []byte) bool {
func (r *Rooms) Send(queue, mount string, message, original []byte, keep bool) bool {
n, kind, ok := queueRoom(queue)
if !ok {
return false
@@ -364,13 +371,16 @@ func (r *Rooms) Send(queue, mount string, message []byte) bool {
if kind != "chat" {
return true // nothing to say on the others
}
// (No echo of the client's own messages: the client handles them as it sends them, and ignores IMVU's.)
if keep {
return true
}
if mount == "messages" {
// Sitting down: "*seat <n>" in the chat text.
var m struct {
Message string `json:"message"`
}
if json.Unmarshal(message, &m) == nil && strings.HasPrefix(m.Message, "*seat ") {
if seat, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(m.Message, "*seat "))); err == nil && seat >= 0 && seat < 100 {
if json.Unmarshal(message, &m) == nil {
if seat, ok := roomSeat(m.Message, r.user()); ok {
r.Hub.Send(map[string]any{"type": "room.seat", "room": n, "seat": seat})
}
}
@@ -379,8 +389,28 @@ func (r *Rooms) Send(queue, mount string, message []byte) bool {
return true
}
// roomSeat is the room seat a chat line takes: "*seat <n>", or "*msg SeatAssignment <kind> <user> <seat> 0" from
// the client's own user (a seat of the room itself; seats on furniture, with a furniture instance ID, aren't the
// room's). The hub keeps them so people joining get free seats.
func roomSeat(text string, me int64) (int, bool) {
f := strings.Fields(text)
var s string
switch {
case len(f) == 2 && f[0] == "*seat":
s = f[1]
case len(f) == 6 && f[0] == "*msg" && f[1] == "SeatAssignment" && f[3] == strconv.FormatInt(me, 10) && f[5] == "0":
s = f[4]
default:
return 0, false
}
seat, err := strconv.Atoi(s)
return seat, err == nil && seat >= 0 && seat < 100
}
// --- HTTP: XML-RPC, the participant post and the room list ---
var namedRoomPattern = regexp.MustCompile(`([1-9][0-9]{0,11}-21[0-9]{8})(?:[^0-9]|$)`)
var participantPath = regexp.MustCompile(`^/chat/chat-([0-9]+)/participants/`)
// Intercept answers the client's calls about hosted rooms (proxy.Interceptor). It goes before the products'.
@@ -424,7 +454,12 @@ func (r *Rooms) Intercept(x *proxy.Exchange) error {
// chat ID of a room the client is in): IMVU doesn't know them, and needn't hear of them.
func (r *Rooms) keepFromIMVU(x *proxy.Exchange) {
text := x.URL.Path + "?" + x.URL.RawQuery + " " + string(x.Body)
named := strings.Contains(text, strconv.Itoa(Base)+"-")
named := false
for _, m := range namedRoomPattern.FindAllStringSubmatch(text, -1) {
if _, ok := RoomNumber(m[1]); ok {
named = true
}
}
if !named {
r.mu.Lock()
for _, rm := range r.rooms {
@@ -594,6 +629,7 @@ func (r *Rooms) roomState(ctx context.Context, n int64) *http.Response {
continue // a community product that isn't here
}
product = local
placement = swapToken(placement, p, strconv.FormatInt(local, 10))
default:
continue
}
@@ -606,7 +642,7 @@ func (r *Rooms) roomState(ctx context.Context, n int64) *http.Response {
{Name: "room_state", Value: xmlrpc.Struct{
{Name: "room_info", Value: xmlrpc.Struct{
{Name: "customers_id", Value: strconv.FormatInt(rm.OwnerImvuID, 10)},
{Name: "room_instance_id", Value: instanceOf(n)},
{Name: "room_instance_id", Value: instanceID(rm.OwnerImvuID, n)},
{Name: "room_pid", Value: strconv.FormatInt(rm.RoomPid, 10)},
}},
{Name: "room_contents", Value: items},
@@ -640,6 +676,7 @@ func (r *Rooms) saveRoomState(ctx context.Context, n int64, args xmlrpc.Struct)
continue
}
p = id
placement = swapToken(placement, strconv.FormatInt(product, 10), id)
}
contents = append(contents, [3]any{furni, p, placement})
}
@@ -687,6 +724,8 @@ func (r *Rooms) joinChat(ctx context.Context, n int64) *http.Response {
for taken[seat] {
seat++
}
// Taken now, so whoever joins next gets another.
r.Hub.Send(map[string]any{"type": "room.seat", "room": n, "seat": seat})
r.mu.Lock()
r.last = n
_, known := r.owners[n]
@@ -695,7 +734,10 @@ func (r *Rooms) joinChat(ctx context.Context, n int64) *http.Response {
r.getRoom(ctx, n) // its owner, for the room's products
}
if r.OnJoin != nil {
r.OnJoin(strconv.FormatInt(a.Chat, 10), instanceOf(n))
r.mu.Lock()
instance := r.instanceOfLocked(n)
r.mu.Unlock()
r.OnJoin(strconv.FormatInt(a.Chat, 10), instance)
}
r.logf("hosted: in room %d (chat %d)", n, a.Chat)
return xmlrpcAnswer(xmlrpc.Struct{
@@ -769,8 +811,8 @@ func appendRoom(list map[string]json.RawMessage, rm Room) {
adult = "1"
}
known := map[string]any{
"customers_room_id": strconv.FormatInt(rm.N, 10), "customers_id": strconv.Itoa(Base),
"room_instance_id": instanceOf(rm.N), "name": plain(rm.Name), "customers_name": plain(rm.OwnerName),
"customers_room_id": strconv.FormatInt(Base+rm.N, 10), "customers_id": strconv.FormatInt(rm.OwnerImvuID, 10),
"room_instance_id": instanceID(rm.OwnerImvuID, rm.N), "name": plain(rm.Name), "customers_name": plain(rm.OwnerName),
"num_participants": strconv.Itoa(rm.Occupancy), "max_users": strconv.Itoa(rm.MaxUsers),
"description": plain(rm.Description), "image_url": "", "rating": "0", "language": "", "is_ap": adult,
"is_av": "0", "is_vip": "0", "participants": []any{},
@@ -825,3 +867,14 @@ func plain(s string) string {
return c
}, s)
}
// swapToken replaces a whole space-separated token of a placement (which names its own product).
func swapToken(placement, from, to string) string {
fields := strings.Split(placement, " ")
for i, f := range fields {
if f == from {
fields[i] = to
}
}
return strings.Join(fields, " ")
}
+14 -11
View File
@@ -51,7 +51,7 @@ func (h *fakeHub) Call(ctx context.Context, method, path string, body, out any)
"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)
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":
h.mu.Lock()
h.scene = append(h.scene, body)
@@ -132,15 +132,15 @@ func TestHostedRoom(t *testing.T) {
}
// 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"}})
resp, _ := xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
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" {
if len(*events) != 1 || (*events)[0] != "join 2100000003 88-2100000003" {
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." {
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." {
t.Fatalf("full room: %+v", resp)
}
@@ -168,10 +168,13 @@ func TestHostedRoom(t *testing.T) {
}
// 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(`{}`)) {
if !r.Send("/chat/2100000003", "messages", []byte(`{"message": "*seat 2", "userId": 7}`), []byte(`{"message": "*seat 2", "userId": 7}`), false) || r.Send("/chat/1385863547", "messages", []byte(`{}`), []byte(`{}`), false) {
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 {
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*msg SeatAssignment 0 7 3 0", "userId": 7}`), nil, false)
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*msg SeatAssignment 0 7 1 5521", "userId": 7}`), nil, false) // on furniture
// The seat chosen on joining (1), "*seat 2", and the room seat 3.
if seats, sends := h.sentOf("room.seat"), h.sentOf("room.send"); len(seats) != 3 || seats[0]["seat"] != 1 || seats[1]["seat"] != 2 || seats[2]["seat"] != 3 || len(sends) != 3 {
t.Fatalf("seats %v, sends %v", seats, sends)
}
@@ -188,17 +191,17 @@ func TestHostedRoom(t *testing.T) {
}
// 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"}})
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomCardInfo", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
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"}})
resp, _ = xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.getRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"}})
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"},
xmlrpcCall(t, r, "client-dynamic.imvu.com", "test.setRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "88-2100000003"},
{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)
@@ -214,7 +217,7 @@ func TestHostedRoom(t *testing.T) {
}
// 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",
for _, raw := range []string{"https://api.imvu.com/room/room-88-2100000003", "https://client-dynamic.imvu.com/api/rooms/room_rating.php?room_instance_id=88-2100000003",
"https://client-dynamic.imvu.com/api/whatever.php?chat=2100000003"} {
u, _ := url.Parse(raw)
req, _ := http.NewRequest("GET", u.String(), nil)
@@ -245,7 +248,7 @@ func TestRoomList(t *testing.T) {
}
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]" ||
if fmt.Sprint(l["customers_room_id"]) != "[644 2100000003]" || fmt.Sprint(l["customers_id"]) != "[1 88]" ||
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)
}