diff --git a/internal/hosted/hosted.go b/internal/hosted/hosted.go index 8cca9b8..1f36bbd 100644 --- a/internal/hosted/hosted.go +++ b/internal/hosted/hosted.go @@ -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-" and its chat ID 2100000000+n. +// A hosted room has a number n; its instance ID is "-<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 "-": 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-) or "occupancy" (room_occupancy/). @@ -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 " 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 ", or "*msg SeatAssignment 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, " ") +} diff --git a/internal/hosted/hosted_test.go b/internal/hosted/hosted_test.go index 4a7765c..cb521c8 100644 --- a/internal/hosted/hosted_test.go +++ b/internal/hosted/hosted_test.go @@ -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) } diff --git a/internal/products/decor.go b/internal/products/decor.go index 9174882..705616d 100644 --- a/internal/products/decor.go +++ b/internal/products/decor.go @@ -17,6 +17,7 @@ import ( "encoding/json" "io" "net/http" + "regexp" "strconv" "strings" "sync" @@ -135,7 +136,9 @@ func roomOwner(instance string) int64 { // isHostedInstance reports whether a room is hosted by the hub (its scene is the hosted package's). func isHostedInstance(instance string) bool { - return strings.HasPrefix(instance, strconv.Itoa(HostedOwnerID)+"-") + _, number, ok := strings.Cut(instance, "-") + n, err := strconv.ParseInt(number, 10, 64) + return ok && err == nil && n > HostedOwnerID // hosted rooms' numbers start above 2100000000 } // decorFetch gets a room's decorations, from memory or the hub. @@ -452,6 +455,16 @@ func (p *Products) outgoingScene(m *chatMessage) ([]byte, bool) { } holder, rows := sceneContents(scene) instance := sceneInstance(scene) + if isHostedInstance(instance) { + // A hosted room's scene goes only to T5DE users (through the hub): its community products travel as the + // hub's product IDs, which each proxy turns into its own local IDs. + out, changed := p.globalScene(rows) + if !changed { + return nil, false + } + holder["room_contents"] = out + return m.withText(setRoomStateCommand + encodeScene(scene)), true + } rest, items, stripped := p.splitCommunity(rows) _, hadDecor := p.decor.get(instance) if instance != "" && !isHostedInstance(instance) && (stripped > 0 || hadDecor) { @@ -544,7 +557,16 @@ func (p *Products) incomingScene(m *chatMessage) ([]byte, bool) { // decorateScene adds the room's decorations to a scene IMVU sent: the scene encoded again, and whether it changed. func (p *Products) decorateScene(scene map[string]any) (string, bool) { instance := sceneInstance(scene) - if instance == "" || isHostedInstance(instance) { + if isHostedInstance(instance) { + holder, rows := sceneContents(scene) + out, changed := p.localScene(rows) + if !changed { + return "", false + } + holder["room_contents"] = out + return encodeScene(scene), true + } + if instance == "" { return "", false } holder, rows := sceneContents(scene) @@ -610,3 +632,58 @@ func (p *Products) DecorChanged(instance string) { } p.logf("decor: gave the client %s again with its decorations", instance) } + +// globalScene gives a hosted room's community products as the hub's product IDs (in the row and its placement); a +// local pack's can't be shared and is left out. +func (p *Products) globalScene(rows []any) ([]any, bool) { + out, changed := make([]any, 0, len(rows)), false + for _, row := range rows { + r, ok := row.([]any) + product, isNumber := int64(0), false + if ok && len(r) >= 3 { + product, isNumber = numberOf(r[1]) + } + if !isNumber || !IsCommunity(product) { + out = append(out, row) + continue + } + changed = true + id, ok := p.state.productID(product) + if !ok || !p.inHubDir(id) { + continue + } + placement, _ := r[2].(string) + out = append(out, []any{r[0], id, swapToken(placement, strconv.FormatInt(product, 10), id)}) + } + return out, changed +} + +// localScene turns hub product IDs in a hosted room's scene into local IDs (asking for products that aren't here). +func (p *Products) localScene(rows []any) ([]any, bool) { + out, changed := make([]any, 0, len(rows)), false + for _, row := range rows { + r, ok := row.([]any) + if !ok || len(r) < 3 { + out = append(out, row) + continue + } + id, isString := r[1].(string) + if !isString || !hubIDPattern.MatchString(id) { + out = append(out, row) + continue + } + changed = true + local, ok := p.LocalID(id) + if !ok { + if p.Want != nil { + p.Want(id) + } + continue + } + placement, _ := r[2].(string) + out = append(out, []any{r[0], local, swapToken(placement, id, strconv.FormatInt(local, 10))}) + } + return out, changed +} + +var hubIDPattern = regexp.MustCompile(`^[a-z2-7]{10}$`) diff --git a/internal/products/decor_test.go b/internal/products/decor_test.go index 909f620..615b596 100644 --- a/internal/products/decor_test.go +++ b/internal/products/decor_test.go @@ -144,3 +144,30 @@ func TestDecorationsInServerRoomState(t *testing.T) { t.Fatalf("delivered %s", in) } } + +// In a hosted room, community products travel as the hub's IDs and come back as local IDs. +func TestHostedRoomScene(t *testing.T) { + h, _ := newHarness(t) + hubDir := t.TempDir() + writePack(t, hubDir, "sofasofa22", 1) + h.p.dirs = append(h.p.dirs, hubDir) + h.p.HubDir = hubDir + h.p.refresh(true) + sofa := h.p.byProductID("sofasofa22").local + scene := map[string]any{"room_state": map[string]any{ + "room_info": map[string]any{"room_instance_id": "7-2100000003"}, + "room_contents": []any{[]any{10, 301, "2-1"}, []any{11, sofa, fmt.Sprintf("3 0 11 %d 6", sofa)}}}} + msg, _ := json.Marshal(map[string]any{"userId": 7, "message": setRoomStateCommand + encodeScene(scene)}) + out, _ := h.p.outgoing("/chat/2100000003", "messages", msg) + m, _ := parseChatMessage(out) + sent, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) + if _, rows := sceneContents(sent); fmt.Sprint(rows) != "[[10 301 2-1] [11 sofasofa22 3 0 11 sofasofa22 6]]" { + t.Fatalf("sent %v", rows) + } + in := h.p.Incoming(nil, imq.Delivered{UserID: "7", Queue: "/chat/2100000003", Mount: "messages", Message: out}) + m, _ = parseChatMessage(in) + got, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) + if _, rows := sceneContents(got); fmt.Sprint(rows) != fmt.Sprintf("[[10 301 2-1] [11 %d 3 0 11 %d 6]]", sofa, sofa) { + t.Fatalf("delivered %v", rows) + } +} diff --git a/internal/products/intercept.go b/internal/products/intercept.go index 1db2ed2..1aa272f 100644 --- a/internal/products/intercept.go +++ b/internal/products/intercept.go @@ -571,7 +571,7 @@ func (p *Products) interceptBaseLook(x *proxy.Exchange) error { func (p *Products) Outgoing(queue, mount string, message []byte) ([]byte, bool) { out, drop := p.outgoing(queue, mount, message) // A hosted room's message goes to the hub (without community products, as to IMVU), never to IMVU. - if p.Hosted != nil && !drop && p.Hosted.Send(queue, mount, out) { + if p.Hosted != nil && p.Hosted.Send(queue, mount, out, message, drop) { return nil, true } return out, drop diff --git a/internal/products/products.go b/internal/products/products.go index a985cb5..f998f1b 100644 --- a/internal/products/products.go +++ b/internal/products/products.go @@ -115,10 +115,11 @@ func (p *Products) Ready(id string) { const HostedOwnerID = 2100000000 // HostedRooms serves T5DE-hosted rooms' IMQ queues: Route answers subscriptions to them, Send takes the client's -// messages to them (reporting whether the queue was one), Closed forgets a connection. +// messages to them (message as for others, original as the client sent it, for the echo; keep: nothing to send +// others), reporting whether the queue was one; Closed forgets a connection. type HostedRooms interface { Route(c *proxy.IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription - Send(queue, mount string, message []byte) bool + Send(queue, mount string, message, original []byte, keep bool) bool Closed(c *proxy.IMQConn) }