diff --git a/internal/products/decor.go b/internal/products/decor.go index 713623e..9174882 100644 --- a/internal/products/decor.go +++ b/internal/products/decor.go @@ -75,6 +75,14 @@ func (d *decorations) get(instance string) ([]DecorItem, bool) { return e.items, true } +// last is the room's decorations as last known, however old (a broadcast can't wait for the hub). +func (d *decorations) last(instance string) ([]DecorItem, bool) { + d.mu.Lock() + defer d.mu.Unlock() + e, ok := d.rooms[instance] + return e.items, ok +} + func (d *decorations) set(instance string, items []DecorItem) { d.mu.Lock() defer d.mu.Unlock() @@ -135,13 +143,19 @@ func (p *Products) decorFetch(ctx context.Context, instance string) []DecorItem if items, ok := p.decor.get(instance); ok { return items } + return p.decorRefresh(ctx, instance) +} + +// decorRefresh asks the hub for a room's decorations; what was known stays until the answer replaces it. +func (p *Products) decorRefresh(ctx context.Context, instance string) []DecorItem { if p.Decorations == nil { return nil } items, err := p.Decorations(ctx, instance) if err != nil { p.logf("decor: %s: %v", instance, err) - return nil + old, _ := p.decor.last(instance) + return old } p.decor.set(instance, items) return items @@ -457,36 +471,107 @@ func (p *Products) outgoingScene(m *chatMessage) ([]byte, bool) { return m.withText(setRoomStateCommand + encodeScene(scene)), true } +// incomingControlScene adds the room's decorations to IMVU's server's {"command": "SetRoomState", "state": scene} +// on a chat's control mount. +func (p *Products) incomingControlScene(message []byte) ([]byte, bool) { + if !bytes.Contains(message, []byte(`"SetRoomState"`)) { + return nil, false + } + var fields map[string]json.RawMessage + if json.Unmarshal(message, &fields) != nil { + return nil, false + } + var command, state string + if json.Unmarshal(fields["command"], &command) != nil || command != "SetRoomState" || json.Unmarshal(fields["state"], &state) != nil { + return nil, false + } + scene, ok := decodeScene(state) + if !ok { + return nil, false + } + encoded, changed := p.decorateScene(scene) + if !changed { + return nil, false + } + fields["state"], _ = json.Marshal(encoded) + out, err := json.Marshal(fields) + return out, err == nil +} + +// outgoingControlScene takes community products out of a SetRoomState the client sends on a control mount (it +// isn't known to, but nothing naming them goes to IMVU). +func (p *Products) outgoingControlScene(message []byte) ([]byte, bool) { + if !bytes.Contains(message, []byte(`"SetRoomState"`)) { + return nil, false + } + var fields map[string]json.RawMessage + var state string + if json.Unmarshal(message, &fields) != nil || json.Unmarshal(fields["state"], &state) != nil { + return nil, false + } + scene, ok := decodeScene(state) + if !ok { + return nil, false + } + holder, rows := sceneContents(scene) + rest, _, stripped := p.splitCommunity(rows) + if stripped == 0 { + return nil, false + } + if rest == nil { + rest = []any{} + } + holder["room_contents"] = rest + fields["state"], _ = json.Marshal(encodeScene(scene)) + out, err := json.Marshal(fields) + p.logf("decor: kept %d community products out of a room state sent to IMVU", stripped) + return out, err == nil +} + // incomingScene adds the room's decorations to its owner's broadcast. func (p *Products) incomingScene(m *chatMessage) ([]byte, bool) { scene, ok := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) if !ok { return nil, false } + encoded, changed := p.decorateScene(scene) + if !changed { + return nil, false + } + return m.withText(setRoomStateCommand + encoded), true +} + +// 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) { - return nil, false + return "", false } holder, rows := sceneContents(scene) p.decor.setBase(instance, rows, holder["room_info"]) - items, _ := p.decor.get(instance) + items, fresh := p.decor.get(instance) + if !fresh { + items, _ = p.decor.last(instance) // stale or none: used now, asked for again for next time + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + p.decorRefresh(ctx, instance) + }() + } extra := p.decorLocals(items) if len(extra) == 0 { - return nil, false + return "", false } holder["room_contents"] = append(append([]any{}, rows...), extra...) - return m.withText(setRoomStateCommand + encodeScene(scene)), true + return encodeScene(scene), true } // DecorChanged is told by the hub that a room's decorations changed: they're asked for again, and if the client is // in that room, it gets the room again with them (as its owner's broadcast). func (p *Products) DecorChanged(instance string) { - p.decor.mu.Lock() - delete(p.decor.rooms, instance) - p.decor.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - items := p.decorFetch(ctx, instance) + items := p.decorRefresh(ctx, instance) // the old ones stay in use until then if roomOwner(instance) == p.User() { return // the owner's client has the room as it is } diff --git a/internal/products/decor_test.go b/internal/products/decor_test.go index 058d5d8..909f620 100644 --- a/internal/products/decor_test.go +++ b/internal/products/decor_test.go @@ -92,3 +92,55 @@ func TestDecorations(t *testing.T) { t.Fatalf("delivered: %v", rows) } } + +// A broadcast arriving while the proxy asks the hub for changed decorations still gets the ones known so far. +func TestDecorationsWhileRefreshing(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 + release := make(chan struct{}) + asked := make(chan struct{}, 1) + h.p.Decorations = func(ctx context.Context, instance string) ([]DecorItem, error) { + asked <- struct{}{} + <-release + return []DecorItem{{11, "sofasofa22", "x"}, {12, "sofasofa22", "y"}}, nil + } + h.p.decor.set("7-1", []DecorItem{{11, "sofasofa22", "x"}}) + go h.p.DecorChanged("7-1") + <-asked // the hub is being asked + scene := map[string]any{"room_state": map[string]any{ + "room_info": map[string]any{"room_instance_id": "7-1"}, "room_contents": []any{[]any{10, 301, "2-1"}}}} + msg, _ := json.Marshal(map[string]any{"userId": 7, "chatId": 555, "message": setRoomStateCommand + encodeScene(scene)}) + in := h.p.Incoming(nil, imq.Delivered{UserID: "7", Queue: "/chat/555", Mount: "messages", Message: msg}) + close(release) + m, _ := parseChatMessage(in) + got, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) + if _, rows := sceneContents(got); len(rows) != 2 || !strings.Contains(fmt.Sprint(rows), fmt.Sprint(sofa)) { + t.Fatalf("delivered %v", rows) + } +} + +// IMVU's server sends a saved room on the chat's control mount; decorations are added to it too. +func TestDecorationsInServerRoomState(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) + h.p.decor.set("7-1", []DecorItem{{11, "sofasofa22", "x"}}) + scene := map[string]any{"room_state": map[string]any{ + "room_info": map[string]any{"room_instance_id": "7-1"}, "room_contents": []any{[]any{10, 301, "2-1"}}}} + msg, _ := json.Marshal(map[string]any{"command": "SetRoomState", "state": encodeScene(scene)}) + in := h.p.Incoming(nil, imq.Delivered{UserID: "admin", Queue: "/chat/555", Mount: "control", Message: msg}) + var fields map[string]string + json.Unmarshal(in, &fields) + got, _ := decodeScene(fields["state"]) + if _, rows := sceneContents(got); len(rows) != 2 || fields["command"] != "SetRoomState" { + t.Fatalf("delivered %s", in) + } +} diff --git a/internal/products/intercept.go b/internal/products/intercept.go index c3afd4c..1db2ed2 100644 --- a/internal/products/intercept.go +++ b/internal/products/intercept.go @@ -578,6 +578,12 @@ func (p *Products) Outgoing(queue, mount string, message []byte) ([]byte, bool) } func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool) { + if strings.HasPrefix(queue, "/chat/") && mount == "control" { + if out, changed := p.outgoingControlScene(message); changed { + return out, false + } + return message, false + } if !strings.HasPrefix(queue, "/chat/") || mount != "messages" { return message, false } diff --git a/internal/products/room.go b/internal/products/room.go index 39d00ad..48d1294 100644 --- a/internal/products/room.go +++ b/internal/products/room.go @@ -146,6 +146,13 @@ func userOf(userID string) int64 { // Incoming implements proxy.IMQWatcher: a T5DE user's whole outfit gets their community products added. func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte { chat := chatOf(m.Queue) + if chat != "" && m.Mount == "control" { + // IMVU's server sends everyone a room its owner saved (as IMVU has it: without decorations). + if out, changed := p.incomingControlScene(m.Message); changed { + return out + } + return m.Message + } if chat == "" || m.Mount != "messages" { return m.Message }