diff --git a/README.md b/README.md index 03e585a..d965749 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,13 @@ T5DE-hosted rooms (`internal/hosted`, t5de-hub's `docs/hosted-rooms.md`): rooms chat to and from the hub over its WebSocket), adds them to the room list, checks the room's products against its real owner (`authorizeProducts2`), and answers any other request to IMVU naming one ("not found") rather than send it. +Decorations (`internal/products/decor.go`): community furniture and poses in IMVU rooms. They're taken out of the +room its owner saves (`test.setRoomState`) and broadcasts (`*imvu:setRoomState`, a zlib-compressed scene) so IMVU +keeps the base room, and stored on the hub for that room (`PUT /api/v1/decorations/`; placements name their +product, stored as the hub's ID). Loading the room (`test.getRoomState`, the hub asked at the same time) and the owner's +incoming broadcasts get them added back as local IDs; the hub's `decor` notice makes the proxy give the client the room +again with the new ones. + `T5DE_INSTANCE=` (set by the client's `t5de-instances` patch for a second client run beside the first) keeps the proxy's data in `%APPDATA%\T5DE-\proxy` and its log in `t5de-proxy-.log`. diff --git a/cmd/t5de-proxy/main.go b/cmd/t5de-proxy/main.go index ad9e2f3..68da798 100644 --- a/cmd/t5de-proxy/main.go +++ b/cmd/t5de-proxy/main.go @@ -24,6 +24,7 @@ import ( "log" "net" "net/http" + "net/url" "os" "os/exec" "os/signal" @@ -180,6 +181,20 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa prods.Want = fetcher.Want prods.HubDir, prods.Owned = hubProducts, fetcher.Owned prods.Shop, prods.Fetch, prods.Library = client.Products, fetcher.Ensure, fetcher.Library + prods.Decorations = func(ctx context.Context, instance string) ([]products.DecorItem, error) { + var out struct { + Contents []products.DecorItem `json:"contents"` + } + err := client.Call(ctx, "GET", "/api/v1/decorations/"+url.PathEscape(instance), nil, &out) + return out.Contents, err + } + prods.SaveDecorations = func(ctx context.Context, instance string, items []products.DecorItem) error { + if items == nil { + items = []products.DecorItem{} + } + return client.Call(ctx, "PUT", "/api/v1/decorations/"+url.PathEscape(instance), map[string]any{"contents": items}, nil) + } + client.OnDecor = prods.DecorChanged prods.AddToLibrary = func(ctx context.Context, id string) error { return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil) } diff --git a/internal/hub/hub.go b/internal/hub/hub.go index d208c00..255825c 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -180,6 +180,8 @@ type Client struct { OnConnect func() // OnRoom, if set, gets the hub's messages about hosted rooms ("room.*"), as sent. OnRoom func(data []byte) + // OnDecor, if set, hears that an IMVU room's decorations changed (its instance ID). + OnDecor func(room string) mu sync.Mutex saved *Saved @@ -343,8 +345,8 @@ func (c *Client) connect(ctx context.Context) error { continue } var m struct { - Type, Message, Chat string - Avatars []Avatar + Type, Message, Chat, Room string + Avatars []Avatar } if json.Unmarshal(data, &m) != nil { continue @@ -352,6 +354,10 @@ func (c *Client) connect(ctx context.Context) error { switch m.Type { case "error": c.log.Printf("hub: %s", m.Message) + case "decor": + if c.OnDecor != nil && m.Room != "" { + go c.OnDecor(m.Room) + } case "library": if c.OnLibrary != nil { go c.OnLibrary() diff --git a/internal/products/decor.go b/internal/products/decor.go new file mode 100644 index 0000000..713623e --- /dev/null +++ b/internal/products/decor.go @@ -0,0 +1,527 @@ +package products + +// Decorations: community furniture (and poses, which are furniture) placed in IMVU rooms. +// +// IMVU keeps the room without them: the proxy takes community products out of the room its owner saves +// (test.setRoomState) and broadcasts ("*imvu:setRoomState " in the room's chat), and stores them on the hub +// for that room (hub products only; a local pack's can't be shared). When anyone with T5DE loads the room +// (test.getRoomState) or gets its owner's broadcast, the proxy adds the room's decorations back, as local IDs. The +// hub tells everyone in the room when its owner changes them ("decor"), and the proxy gives the client the room +// again with the new ones. + +import ( + "bytes" + "compress/zlib" + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "git.t5de.dev/t5de/t5de-proxy/internal/imq" + "git.t5de.dev/t5de/t5de-proxy/internal/proxy" + "git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc" +) + +// DecorItem is a decoration: a furniture instance ID, a hub product ID, and the placement, as IMVU's +// room_contents has them (as a JSON array). +type DecorItem struct { + Furni int64 + Product string + Placement string +} + +func (d DecorItem) MarshalJSON() ([]byte, error) { + return json.Marshal([]any{d.Furni, d.Product, d.Placement}) +} + +func (d *DecorItem) UnmarshalJSON(b []byte) error { + var a []json.RawMessage + if err := json.Unmarshal(b, &a); err != nil || len(a) != 3 { + return err + } + json.Unmarshal(a[0], &d.Furni) + json.Unmarshal(a[1], &d.Product) + return json.Unmarshal(a[2], &d.Placement) +} + +// decorations is the proxy's view of rooms' decorations, under its own lock. +type decorations struct { + mu sync.Mutex + rooms map[string]decorEntry // room instance -> decorations + base map[string][]any // room instance -> its contents as IMVU last had them (for giving the room again) + info map[string]any // room instance -> its room_info, as last seen +} + +type decorEntry struct { + items []DecorItem + at time.Time +} + +// decorFor is how long decorations are used before being asked for again (the hub also says when they change). +const decorFor = 5 * time.Minute + +func (d *decorations) get(instance string) ([]DecorItem, bool) { + d.mu.Lock() + defer d.mu.Unlock() + e, ok := d.rooms[instance] + if !ok || time.Since(e.at) > decorFor { + return nil, false + } + return e.items, true +} + +func (d *decorations) set(instance string, items []DecorItem) { + d.mu.Lock() + defer d.mu.Unlock() + if d.rooms == nil { + d.rooms = map[string]decorEntry{} + } + d.rooms[instance] = decorEntry{items, time.Now()} +} + +func (d *decorations) setBase(instance string, contents []any, info any) { + d.mu.Lock() + defer d.mu.Unlock() + if d.base == nil { + d.base, d.info = map[string][]any{}, map[string]any{} + } + d.base[instance] = contents + if info != nil { + d.info[instance] = info + } +} + +func (d *decorations) getBase(instance string) ([]any, any) { + d.mu.Lock() + defer d.mu.Unlock() + return d.base[instance], d.info[instance] +} + +// infoMap is a room_info as JSON for a scene (XML-RPC structs become objects). +func infoMap(v any) any { + s, ok := v.(xmlrpc.Struct) + if !ok { + return v + } + m := map[string]any{} + for _, mem := range s { + m[mem.Name] = mem.Value + } + return m +} + +// roomOwner is the owner of an IMVU room instance ("-"). +func roomOwner(instance string) int64 { + owner, _, ok := strings.Cut(instance, "-") + if !ok { + return 0 + } + id, _ := strconv.ParseInt(owner, 10, 64) + return id +} + +// 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)+"-") +} + +// decorFetch gets a room's decorations, from memory or the hub. +func (p *Products) decorFetch(ctx context.Context, instance string) []DecorItem { + if items, ok := p.decor.get(instance); ok { + return items + } + if p.Decorations == nil { + return nil + } + items, err := p.Decorations(ctx, instance) + if err != nil { + p.logf("decor: %s: %v", instance, err) + return nil + } + p.decor.set(instance, items) + return items +} + +// decorLocals is decorations as room_contents rows with local IDs (asking for products that aren't here yet). +func (p *Products) decorLocals(items []DecorItem) []any { + var rows []any + for _, it := range items { + local, ok := p.LocalID(it.Product) + if !ok { + if p.Want != nil { + p.Want(it.Product) + } + continue + } + rows = append(rows, []any{it.Furni, local, swapToken(it.Placement, it.Product, strconv.FormatInt(local, 10))}) + } + return rows +} + +// swapToken replaces a whole space-separated token of a placement: the placement names its own product, which is +// stored as the hub's product ID and given to the client as its local ID. +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, " ") +} + +// splitCommunity takes community products out of room_contents rows: the rest, and the community ones as +// decorations (hub products; others are dropped). +func (p *Products) splitCommunity(rows []any) (rest []any, items []DecorItem, stripped int) { + for _, row := range rows { + r, ok := row.([]any) + if !ok || len(r) < 3 { + rest = append(rest, row) + continue + } + product, ok := numberOf(r[1]) + if !ok || !IsCommunity(product) { + rest = append(rest, row) + continue + } + stripped++ + id, ok := p.state.productID(product) + if !ok || !p.inHubDir(id) { + p.logf("decor: a room has %d, which isn't a hub product; it can't be shared", product) + continue + } + furni, _ := numberOf(r[0]) + placement, _ := r[2].(string) + items = append(items, DecorItem{furni, id, swapToken(placement, strconv.FormatInt(product, 10), id)}) + } + return rest, items, stripped +} + +func numberOf(v any) (int64, bool) { + switch v := v.(type) { + case int64: + return v, true + case float64: + return int64(v), v == float64(int64(v)) + case json.Number: + n, err := v.Int64() + return n, err == nil + case string: + return parseID(v) + } + return 0, false +} + +// saveDecor stores a room's decorations on the hub, if the client is its owner and they changed. +func (p *Products) saveDecor(instance string, items []DecorItem) { + if roomOwner(instance) != p.User() || p.SaveDecorations == nil { + return + } + old, known := p.decor.get(instance) + if known && sameDecor(old, items) { + return + } + p.decor.set(instance, items) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := p.SaveDecorations(ctx, instance, items); err != nil { + p.logf("decor: saving %s: %v", instance, err) + return + } + p.logf("decor: %d community products in %s, kept on T5DE's hub", len(items), instance) + }() +} + +func sameDecor(a, b []DecorItem) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// --- XML-RPC: saving and loading rooms --- + +func (p *Products) interceptSetRoomState(x *proxy.Exchange, call *xmlrpc.Call) error { + args, ok := call.Params[0].(xmlrpc.Struct) + if !ok { + return nil + } + instance := member(args, "room_instance_id") + if instance == "" || isHostedInstance(instance) { + return nil + } + v, _ := args.Get("room_contents") + rows, ok := v.([]xmlrpc.Value) + if !ok { + return nil + } + rest, items, stripped := p.splitCommunity(rows) + _, hadDecor := p.decor.get(instance) + if stripped > 0 || hadDecor { + p.saveDecor(instance, items) + } + if stripped == 0 { + return nil + } + if rest == nil { + rest = []xmlrpc.Value{} + } + changed := make(xmlrpc.Struct, len(args)) + copy(changed, args) + for i := range changed { + if changed[i].Name == "room_contents" { + changed[i].Value = rest + } + } + p.logf("decor: kept %d community products out of %s saved to IMVU", stripped, instance) + return p.replaceBody(x, &xmlrpc.Call{Method: call.Method, Params: []xmlrpc.Value{changed}}) +} + +func (p *Products) interceptGetRoomState(x *proxy.Exchange, call *xmlrpc.Call) error { + args, ok := call.Params[0].(xmlrpc.Struct) + if !ok { + return nil + } + instance := member(args, "room_instance_id") + if instance == "" || isHostedInstance(instance) { + return nil + } + // Asked of the hub while IMVU is asked about the room: no added wait. + got := make(chan []DecorItem, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + got <- p.decorFetch(ctx, instance) + }() + x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { + r, err := xmlrpc.ParseResponse(body) + if err != nil || r.Fault != nil || len(r.Params) != 1 { + return body, nil + } + state, _ := r.Params[0].(xmlrpc.Struct) + roomState, _ := state.Get("room_state") + rs, ok := roomState.(xmlrpc.Struct) + if !ok { + return body, nil + } + v, _ := rs.Get("room_contents") + rows, _ := v.([]xmlrpc.Value) + info, _ := rs.Get("room_info") + p.decor.setBase(instance, rows, infoMap(info)) + var items []DecorItem + select { + case items = <-got: + case <-time.After(5 * time.Second): + } + extra := p.decorLocals(items) + if len(extra) == 0 { + return body, nil + } + for i := range rs { + if rs[i].Name == "room_contents" { + rs[i].Value = append(append([]xmlrpc.Value{}, rows...), extra...) + } + } + return r.Marshal(), nil + } + return nil +} + +// member is a struct member as a string (strings and numbers). +func member(s xmlrpc.Struct, name string) string { + v, ok := s.Get(name) + if !ok { + return "" + } + switch v := v.(type) { + case string: + return v + case int64: + return strconv.FormatInt(v, 10) + } + return "" +} + +// --- the owner's broadcast in the room's chat --- + +const setRoomStateCommand = "*imvu:setRoomState " + +// decodeScene reads a broadcast scene: "c" and base64 of zlib-compressed JSON (the client's +// RoomState.encodeRoomState). +func decodeScene(encoded string) (map[string]any, bool) { + encoded = strings.TrimLeft(encoded, " \t\r\n") + if !strings.HasPrefix(encoded, "c") { + return nil, false + } + raw, err := base64.StdEncoding.DecodeString(strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' { + return -1 + } + return r + }, encoded[1:])) + if err != nil { + return nil, false + } + zr, err := zlib.NewReader(bytes.NewReader(raw)) + if err != nil { + return nil, false + } + b, err := io.ReadAll(io.LimitReader(zr, 4<<20)) + if err != nil { + return nil, false + } + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + var scene map[string]any + if d.Decode(&scene) != nil { + return nil, false + } + return scene, true +} + +// encodeScene writes a scene as the client does (base64 in lines of 76, each ending in a newline). +func encodeScene(scene map[string]any) string { + b, _ := json.Marshal(scene) + var z bytes.Buffer + w := zlib.NewWriter(&z) + w.Write(b) + w.Close() + s := base64.StdEncoding.EncodeToString(z.Bytes()) + var out strings.Builder + out.WriteString("c") + for len(s) > 76 { + out.WriteString(s[:76]) + out.WriteString("\n") + s = s[76:] + } + out.WriteString(s) + out.WriteString("\n") + return out.String() +} + +// sceneContents is the scene's room_contents and where it is (at the top, or under room_state). +func sceneContents(scene map[string]any) (map[string]any, []any) { + holder := scene + if rs, ok := scene["room_state"].(map[string]any); ok { + holder = rs + } + rows, _ := holder["room_contents"].([]any) + return holder, rows +} + +func sceneInstance(scene map[string]any) string { + holder, _ := sceneContents(scene) + info, _ := holder["room_info"].(map[string]any) + switch v := info["room_instance_id"].(type) { + case string: + return v + } + return "" +} + +// outgoingScene takes community products out of the owner's broadcast (keeping them as the room's decorations). +func (p *Products) outgoingScene(m *chatMessage) ([]byte, bool) { + scene, ok := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) + if !ok { + return nil, false + } + holder, rows := sceneContents(scene) + instance := sceneInstance(scene) + rest, items, stripped := p.splitCommunity(rows) + _, hadDecor := p.decor.get(instance) + if instance != "" && !isHostedInstance(instance) && (stripped > 0 || hadDecor) { + p.saveDecor(instance, items) + } + if instance != "" { + p.decor.setBase(instance, rest, holder["room_info"]) + } + if stripped == 0 { + return nil, false + } + if rest == nil { + rest = []any{} + } + holder["room_contents"] = rest + p.logf("decor: kept %d community products out of a room broadcast", stripped) + return m.withText(setRoomStateCommand + encodeScene(scene)), true +} + +// 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 + } + instance := sceneInstance(scene) + if instance == "" || isHostedInstance(instance) { + return nil, false + } + holder, rows := sceneContents(scene) + p.decor.setBase(instance, rows, holder["room_info"]) + items, _ := p.decor.get(instance) + extra := p.decorLocals(items) + if len(extra) == 0 { + return nil, false + } + holder["room_contents"] = append(append([]any{}, rows...), extra...) + return m.withText(setRoomStateCommand + 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) + if roomOwner(instance) == p.User() { + return // the owner's client has the room as it is + } + base, info := p.decor.getBase(instance) + if base == nil || info == nil { + return + } + p.roomMu.Lock() + p.room.init() + var chat string + for c, inst := range p.room.rooms { + if inst == instance && p.room.inChat(c) { + chat = c + } + } + var conns []*proxy.IMQConn + for c, chats := range p.room.subs { + if chats[chat] { + conns = append(conns, c) + } + } + p.roomMu.Unlock() + if chat == "" { + return + } + owner := roomOwner(instance) + scene := map[string]any{"room_state": map[string]any{ + "room_info": info, + "room_contents": append(append([]any{}, base...), p.decorLocals(items)...), + }} + chatID, _ := strconv.ParseInt(chat, 10, 64) + msg, _ := json.Marshal(map[string]any{"userId": owner, "chatId": chatID, + "message": setRoomStateCommand + encodeScene(scene), "to": 0}) + for _, c := range conns { + c.Deliver(imq.Delivered{UserID: "/user/" + strconv.FormatInt(owner, 10), Queue: "/chat/" + chat, Mount: "messages", Message: msg}) + } + p.logf("decor: gave the client %s again with its decorations", instance) +} diff --git a/internal/products/decor_test.go b/internal/products/decor_test.go new file mode 100644 index 0000000..058d5d8 --- /dev/null +++ b/internal/products/decor_test.go @@ -0,0 +1,94 @@ +package products + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + + "git.t5de.dev/t5de/t5de-proxy/internal/imq" + "git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc" +) + +func TestSceneEncoding(t *testing.T) { + scene := map[string]any{"room_state": map[string]any{"room_contents": []any{[]any{1, 2, "x"}}}} + back, ok := decodeScene(encodeScene(scene)) + if !ok { + t.Fatal("didn't decode") + } + if _, rows := sceneContents(back); len(rows) != 1 { + t.Fatalf("rows %v", rows) + } + if enc := encodeScene(scene); !strings.HasPrefix(enc, "c") || !strings.HasSuffix(enc, "\n") { + t.Fatalf("encoded %q", enc) + } +} + +func TestDecorations(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 + h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false) + + var mu sync.Mutex + stored := map[string][]DecorItem{} + saved := make(chan string, 4) + h.p.Decorations = func(ctx context.Context, instance string) ([]DecorItem, error) { + mu.Lock() + defer mu.Unlock() + return stored[instance], nil + } + h.p.SaveDecorations = func(ctx context.Context, instance string, items []DecorItem) error { + mu.Lock() + stored[instance] = items + mu.Unlock() + saved <- instance + return nil + } + + // The owner saves the room: IMVU gets it without the community sofa, the hub gets the sofa. + save := xmlrpc.Struct{{Name: "room_instance_id", Value: "7-1"}, {Name: "room_contents", Value: []xmlrpc.Value{ + []xmlrpc.Value{int64(10), int64(301), "2-1"}, []xmlrpc.Value{int64(11), sofa, fmt.Sprintf("3 0 11 %d 632", sofa)}}}} + h.do("POST", "http://client-dynamic.imvu.com/api/xmlrpc/client.php", call("test.setRoomState", save), true, false) + if asked := h.imvu.asked["test.setRoomState"]; len(asked) != 1 || strings.Contains(asked[0], fmt.Sprint(sofa)) || !strings.Contains(asked[0], "301") { + t.Fatalf("IMVU got %v", asked) + } + if <-saved != "7-1" || fmt.Sprint(stored["7-1"]) != "[{11 sofasofa22 3 0 11 sofasofa22 632}]" { + t.Fatalf("stored %v", stored) + } + + // Loading the room (anyone): the sofa is added back. + h.p.decor = decorations{} // as if another computer + _, body := h.do("POST", "http://client-dynamic.imvu.com/api/xmlrpc/client.php", call("test.getRoomState", xmlrpc.Struct{{Name: "room_instance_id", Value: "7-1"}}), true, false) + if !strings.Contains(string(body), fmt.Sprintf("3 0 11 %d 632", sofa)) || !strings.Contains(string(body), "301") { + t.Fatalf("loaded %s", body) + } + + // The owner's broadcast: the sofa is taken out for IMVU... + scene := map[string]any{"room_state": map[string]any{ + "room_info": map[string]any{"customers_id": "7", "room_instance_id": "7-1", "room_pid": "300"}, + "room_contents": []any{[]any{10, 301, "2-1"}, []any{11, sofa, "2-2"}}}} + msg, _ := json.Marshal(map[string]any{"userId": 7, "chatId": 555, "message": setRoomStateCommand + encodeScene(scene), "to": 0}) + out, drop := h.p.Outgoing("/chat/555", "messages", msg) + if drop { + t.Fatal("dropped") + } + m, _ := parseChatMessage(out) + sent, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) + if _, rows := sceneContents(sent); len(rows) != 1 { + t.Fatalf("sent to IMVU: %v", rows) + } + // ...and added back for whoever gets it with T5DE. + in := h.p.Incoming(nil, imq.Delivered{UserID: "/user/7", Queue: "/chat/555", Mount: "messages", Message: out}) + m, _ = parseChatMessage(in) + got, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) + if _, rows := sceneContents(got); len(rows) != 2 { + t.Fatalf("delivered: %v", rows) + } +} diff --git a/internal/products/intercept.go b/internal/products/intercept.go index e6a0ed3..c3afd4c 100644 --- a/internal/products/intercept.go +++ b/internal/products/intercept.go @@ -118,6 +118,14 @@ func (p *Products) interceptXMLRPC(x *proxy.Exchange) error { x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { return p.addToInventory(body) } + case "test.setRoomState": + if len(call.Params) == 1 { + return p.interceptSetRoomState(x, call) + } + case "test.getRoomState": + if len(call.Params) == 1 { + return p.interceptGetRoomState(x, call) + } } return nil } @@ -577,6 +585,12 @@ func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool) if !ok { return message, false } + if strings.HasPrefix(m.text, setRoomStateCommand) { + if out, changed := p.outgoingScene(m); changed { + return out, false + } + return message, false + } r := stripCommand(m.text, IsCommunity) if r.outfit && m.userID != 0 { p.recordWorn(strconv.FormatInt(m.userID, 10), r.kept, r.products) diff --git a/internal/products/products.go b/internal/products/products.go index fee2250..a985cb5 100644 --- a/internal/products/products.go +++ b/internal/products/products.go @@ -68,6 +68,10 @@ type Products struct { AddToLibrary func(ctx context.Context, id string) error // Library, if set, is the person's hub library as last read (for the inventory's Recently Added). Library func() []hub.LibraryEntry + // Decorations and SaveDecorations, if set, get and store an IMVU room's decorations on the hub (decor.go). + Decorations func(ctx context.Context, instance string) ([]DecorItem, error) + SaveDecorations func(ctx context.Context, instance string, items []DecorItem) error + decor decorations // Hosted, if set, serves T5DE-hosted rooms' queues (the hosted package); HostedOwner is the IMVU account owning // the hosted room the client is in (0 if none), whose products the room's are. diff --git a/internal/products/products_test.go b/internal/products/products_test.go index a9316b3..5a8f279 100644 --- a/internal/products/products_test.go +++ b/internal/products/products_test.go @@ -125,6 +125,14 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) { return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{ {Name: "userId", Value: int64(7)}, {Name: "securityKey", Value: key}, {Name: "look", Value: []xmlrpc.Value{int64(80), int64(1)}}}}}).Marshal())) + case "test.setRoomState": + checkSigned(false) + return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "state_changed", Value: true}, {Name: "room_hash", Value: "h"}}}}).Marshal())) + case "test.getRoomState": + return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{{Name: "room_state", Value: xmlrpc.Struct{ + {Name: "room_info", Value: xmlrpc.Struct{{Name: "customers_id", Value: "7"}, {Name: "room_instance_id", Value: "7-1"}, {Name: "room_pid", Value: "300"}}}, + {Name: "room_contents", Value: []xmlrpc.Value{[]xmlrpc.Value{int64(10), int64(301), "2-1"}}}}}, + {Name: "room_hash", Value: "h"}}}}).Marshal())) case "test.showInventory": return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(2)}}}).Marshal())) case "test.inventoryProductBatch": diff --git a/internal/products/room.go b/internal/products/room.go index a42e1e1..39d00ad 100644 --- a/internal/products/room.go +++ b/internal/products/room.go @@ -153,6 +153,14 @@ func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte { if user == 0 { return m.Message } + if bytes.Contains(m.Message, []byte(setRoomStateCommand)) { + if msg, ok := parseChatMessage(m.Message); ok && strings.HasPrefix(msg.text, setRoomStateCommand) { + if out, changed := p.incomingScene(msg); changed { + return out + } + return m.Message + } + } p.roomMu.Lock() p.room.init() p.room.userIDs[user] = m.UserID