T5 furniture and poses in IMVU rooms (decorations)
CI / build (push) Successful in 44s

Community products are taken out of rooms their owners save and broadcast, kept on the hub per room,
and added back when anyone with T5DE loads the room or gets its owner's broadcast; the hub's decor
notice resends the room with the new ones. Placements are stored with the hub's product ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 04:03:21 -07:00
1 parent d8332b21a8
commit 0be2cd8f4e
9 files changed
+685 -2

No files matched your search

+7
View File
@@ -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 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. 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/<instance>`; 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=<name>` (set by the client's `t5de-instances` patch for a second client run beside the first) keeps `T5DE_INSTANCE=<name>` (set by the client's `t5de-instances` patch for a second client run beside the first) keeps
the proxy's data in `%APPDATA%\T5DE-<name>\proxy` and its log in `t5de-proxy-<name>.log`. the proxy's data in `%APPDATA%\T5DE-<name>\proxy` and its log in `t5de-proxy-<name>.log`.
+15
View File
@@ -24,6 +24,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
@@ -180,6 +181,20 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
prods.Want = fetcher.Want prods.Want = fetcher.Want
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
prods.Shop, prods.Fetch, prods.Library = client.Products, fetcher.Ensure, fetcher.Library 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 { prods.AddToLibrary = func(ctx context.Context, id string) error {
return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil) return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil)
} }
+8 -2
View File
@@ -180,6 +180,8 @@ type Client struct {
OnConnect func() OnConnect func()
// OnRoom, if set, gets the hub's messages about hosted rooms ("room.*"), as sent. // OnRoom, if set, gets the hub's messages about hosted rooms ("room.*"), as sent.
OnRoom func(data []byte) 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 mu sync.Mutex
saved *Saved saved *Saved
@@ -343,8 +345,8 @@ func (c *Client) connect(ctx context.Context) error {
continue continue
} }
var m struct { var m struct {
Type, Message, Chat string Type, Message, Chat, Room string
Avatars []Avatar Avatars []Avatar
} }
if json.Unmarshal(data, &m) != nil { if json.Unmarshal(data, &m) != nil {
continue continue
@@ -352,6 +354,10 @@ func (c *Client) connect(ctx context.Context) error {
switch m.Type { switch m.Type {
case "error": case "error":
c.log.Printf("hub: %s", m.Message) c.log.Printf("hub: %s", m.Message)
case "decor":
if c.OnDecor != nil && m.Room != "" {
go c.OnDecor(m.Room)
}
case "library": case "library":
if c.OnLibrary != nil { if c.OnLibrary != nil {
go c.OnLibrary() go c.OnLibrary()
+527
View File
@@ -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 <scene>" 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 ("<owner>-<n>").
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)
}
+94
View File
@@ -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)
}
}
+14
View File
@@ -118,6 +118,14 @@ func (p *Products) interceptXMLRPC(x *proxy.Exchange) error {
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
return p.addToInventory(body) 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 return nil
} }
@@ -577,6 +585,12 @@ func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool)
if !ok { if !ok {
return message, false 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) r := stripCommand(m.text, IsCommunity)
if r.outfit && m.userID != 0 { if r.outfit && m.userID != 0 {
p.recordWorn(strconv.FormatInt(m.userID, 10), r.kept, r.products) p.recordWorn(strconv.FormatInt(m.userID, 10), r.kept, r.products)
+4
View File
@@ -68,6 +68,10 @@ type Products struct {
AddToLibrary func(ctx context.Context, id string) error 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, if set, is the person's hub library as last read (for the inventory's Recently Added).
Library func() []hub.LibraryEntry 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 // 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. // the hosted room the client is in (0 if none), whose products the room's are.
+8
View File
@@ -125,6 +125,14 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{ return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{
{Name: "userId", Value: int64(7)}, {Name: "securityKey", Value: key}, {Name: "userId", Value: int64(7)}, {Name: "securityKey", Value: key},
{Name: "look", Value: []xmlrpc.Value{int64(80), int64(1)}}}}}).Marshal())) {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": case "test.showInventory":
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(2)}}}).Marshal())) return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{[]xmlrpc.Value{int64(1), int64(2)}}}).Marshal()))
case "test.inventoryProductBatch": case "test.inventoryProductBatch":
+8
View File
@@ -153,6 +153,14 @@ func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte {
if user == 0 { if user == 0 {
return m.Message 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.roomMu.Lock()
p.room.init() p.room.init()
p.room.userIDs[user] = m.UserID p.room.userIDs[user] = m.UserID