// Package hosted runs T5DE-hosted rooms for the client (t5de-hub's docs/hosted-rooms.md): rooms that live on T5DE's // hub, which the client joins and uses like IMVU's rooms. The proxy answers the client's room calls for them // (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. package hosted import ( "context" "encoding/json" "fmt" "net/http" "net/url" "regexp" "sort" "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" ) // Base is the first part of hosted rooms' instance IDs, and the start of their chat IDs. const Base = 2100000000 // Hub is what the rooms need of the hub: its realtime connection and its API. type Hub interface { Send(v any) bool Call(ctx context.Context, method, path string, body, out any) error } // Room is a hosted room as the hub lists it. type Room struct { N int64 `json:"n"` InstanceID string `json:"instanceId"` ChatID int64 `json:"chatId"` Name string `json:"name"` Description string `json:"description"` RoomPid int64 `json:"roomPid"` Rating string `json:"rating"` MaxUsers int `json:"maxUsers"` OwnerImvuID int64 `json:"ownerImvuId"` OwnerName string `json:"ownerName"` Occupancy int `json:"occupancy"` Privacy string `json:"privacy"` Moderators []int64 `json:"moderators"` Image string `json:"image"` Favorite bool `json:"favorite"` Score float64 `json:"score"` Ratings int `json:"ratings"` MyRating int `json:"myRating"` } // Participant is someone in a hosted room. type Participant struct { ImvuID int64 `json:"imvu_id"` Seat int `json:"seat"` } // Rooms serves hosted rooms to the client. type Rooms struct { Hub Hub Logf func(format string, args ...any) // User is the IMVU account the client is signed in as (0 if not yet known). User func() int64 // Deliver gives the client a message from someone in a room (through the products' Incoming). Deliver func(c *proxy.IMQConn, m imq.Delivered) // OnJoin and OnLeave report being in a room's chat (the room channel, for others' community products). OnJoin func(chat, room string) OnLeave func(chat string) // HTTPClient fetches IMVU's public profiles (nil: http.DefaultClient). HTTPClient *http.Client // UserDeliver gives the client a message on its own user queue (invitations). UserDeliver func(mount string, message []byte) error // LocalProduct and GlobalProduct translate scenes' community products between the hub's IDs and the client's // local IDs (false: not a community product, or not here). LocalProduct func(hubID string) (int64, bool) GlobalProduct func(local int64) (string, bool) mu sync.Mutex rooms map[int64]*room waiting map[int64][]chan joinAnswer owners map[int64]int64 // room -> its owner's IMVU account, as last looked up last int64 // the room the client joined last accepted map[int64]string // room -> the invite the client accepted, for its next join invites map[int64]*invite // by the local ID the client knows nextInvite int64 // watched map[int64]map[*proxy.IMQConn]string // room -> connections watching its occupancy (their queue) occupants map[int64]map[string]bool // room -> who the watchers were last told is in it } type room struct { chat int64 instance string participants []Participant sequence uint64 conns map[*proxy.IMQConn]bool // subscribed to the room's chat queue } type joinAnswer struct { Type string `json:"type"` Room int64 `json:"room"` Chat int64 `json:"chat"` Participants []Participant `json:"participants"` Sequence uint64 `json:"sequence"` Reason string `json:"reason"` Message string `json:"message"` } // 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) { m := instancePattern.FindStringSubmatch(instance) if m == nil { return 0, false } 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. func ChatRoom(chat int64) (int64, bool) { if chat <= Base || chat > 2147483647 { return 0, false } return chat - Base, true } 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/). func queueRoom(queue string) (int64, string, bool) { switch { case strings.HasPrefix(queue, "/chat/"): chat, err := strconv.ParseInt(strings.TrimPrefix(queue, "/chat/"), 10, 64) if err != nil { return 0, "", false } n, ok := ChatRoom(chat) return n, "chat", ok case strings.HasPrefix(queue, "inv:/room/room-"): n, ok := RoomNumber(strings.TrimPrefix(queue, "inv:/room/room-")) return n, "node", ok case strings.HasPrefix(queue, "room_occupancy/"): n, ok := RoomNumber(strings.TrimPrefix(queue, "room_occupancy/")) return n, "occupancy", ok } return 0, "", false } // IsHostedQueue reports whether a queue is a hosted room's. func IsHostedQueue(queue string) bool { _, _, ok := queueRoom(queue) return ok } func (r *Rooms) logf(format string, args ...any) { if r.Logf != nil { r.Logf(format, args...) } } func (r *Rooms) user() int64 { if r.User == nil { return 0 } return r.User() } // --- the hub's messages --- // FromHub handles a "room.*" message from the hub. func (r *Rooms) FromHub(data []byte) { var m struct { joinAnswer From int64 `json:"from"` Mount string `json:"mount"` Message string `json:"message"` } if json.Unmarshal(data, &m) != nil { return } switch m.Type { case "room.joined", "room.error": m.joinAnswer.Message = m.Message // the outer field takes "message" r.mu.Lock() if m.Type == "room.joined" { rm := r.roomLocked(m.Room) rm.chat, rm.participants, rm.sequence = m.Chat, m.Participants, m.Sequence } waiting := r.waiting[m.Room] delete(r.waiting, m.Room) r.mu.Unlock() for _, w := range waiting { w <- m.joinAnswer } if m.Type == "room.error" { r.logf("hosted: room %d: %s (%s)", m.Room, m.Reason, m.Message) } case "room.participants": r.mu.Lock() if rm, ok := r.rooms[m.Room]; ok { rm.participants = m.Participants } r.mu.Unlock() case "room.message": r.mu.Lock() rm, ok := r.rooms[m.Room] var conns []*proxy.IMQConn chat := Base + m.Room if ok { if m.Sequence > rm.sequence { rm.sequence = m.Sequence } for c := range rm.conns { conns = append(conns, c) } } r.mu.Unlock() for _, c := range conns { d := imq.Delivered{UserID: fmt.Sprintf("/user/%d", m.From), Queue: fmt.Sprintf("/chat/%d", chat), Mount: m.Mount, Message: []byte(m.Message), Sequence: m.Sequence} if r.Deliver != nil { r.Deliver(c, d) } else { c.Deliver(d) } } case "room.invite_result", "room.invite_answer", "room.invited", "room.occupancy", "room.boot_result": r.phase2FromHub(m.Type, data) case "room.closed": r.mu.Lock() delete(r.rooms, m.Room) r.mu.Unlock() if r.OnLeave != nil { r.OnLeave(strconv.FormatInt(Base+m.Room, 10)) } r.logf("hosted: room %d was closed", m.Room) } } func (r *Rooms) roomLocked(n int64) *room { if r.rooms == nil { r.rooms = map[int64]*room{} } rm, ok := r.rooms[n] if !ok { rm = &room{chat: Base + n, instance: r.instanceOfLocked(n), conns: map[*proxy.IMQConn]bool{}} r.rooms[n] = rm } return rm } // Reconnected joins the rooms the client is in again (the hub forgets them when the connection drops). func (r *Rooms) Reconnected() { r.mu.Lock() var ns []int64 for n := range r.rooms { ns = append(ns, n) } r.mu.Unlock() for _, n := range ns { r.Hub.Send(map[string]any{"type": "room.join", "room": n}) } r.sendWatch() } // join asks the hub to let the client into a room, and waits for the answer. func (r *Rooms) join(ctx context.Context, n int64, inviteID string) (joinAnswer, error) { w := make(chan joinAnswer, 1) r.mu.Lock() if r.waiting == nil { r.waiting = map[int64][]chan joinAnswer{} } r.waiting[n] = append(r.waiting[n], w) r.mu.Unlock() join := map[string]any{"type": "room.join", "room": n} if inviteID != "" { join["invite"] = inviteID } if !r.Hub.Send(join) { return joinAnswer{}, fmt.Errorf("the hub isn't connected") } select { case a := <-w: return a, nil case <-ctx.Done(): return joinAnswer{}, fmt.Errorf("the hub didn't answer") } } func (r *Rooms) leave(n int64) { r.mu.Lock() _, in := r.rooms[n] delete(r.rooms, n) r.mu.Unlock() if in { r.Hub.Send(map[string]any{"type": "room.leave", "room": n}) if r.OnLeave != nil { r.OnLeave(strconv.FormatInt(Base+n, 10)) } } } // --- IMQ --- // Route takes hosted rooms' queues out of a subscription and answers the client for them (proxy.IMQRouter). func (r *Rooms) Route(c *proxy.IMQConn, unsubscribe bool, s imq.Subscription) imq.Subscription { var rest imq.Subscription for _, q := range s.Queues { if !r.routeQueue(c, unsubscribe, q, 0, false) { rest.Queues = append(rest.Queues, q) } } for _, q := range s.WithResults { if !r.routeQueue(c, unsubscribe, q.Queue, q.OpID, true) { rest.WithResults = append(rest.WithResults, q) } } return rest } func (r *Rooms) routeQueue(c *proxy.IMQConn, unsubscribe bool, queue string, opID uint64, withResult bool) bool { n, kind, ok := queueRoom(queue) if !ok { return false } me := fmt.Sprintf("/user/%d", r.user()) if unsubscribe { if kind == "occupancy" { r.watch(c, n, queue, false) } if kind == "chat" { r.mu.Lock() if rm, ok := r.rooms[n]; ok { delete(rm.conns, c) } r.mu.Unlock() } if withResult { c.Send(imq.G2cResult, imq.Result(opID, 0)) } return true } switch kind { case "chat": r.mu.Lock() rm := r.roomLocked(n) rm.conns[c] = true seq := rm.sequence r.mu.Unlock() c.Send(imq.G2cJoinedQueue, imq.JoinedQueue(me, queue, seq)) c.Send(imq.G2cCreateMount, imq.CreateMount(queue, "control", imq.MessageMount, nil, seq)) c.Send(imq.G2cCreateMount, imq.CreateMount(queue, "messages", imq.MessageMount, nil, seq)) c.Send(imq.G2cCreateMount, imq.CreateMount(queue, "participants", imq.StateMount, nil, seq)) case "node": c.Send(imq.G2cJoinedQueue, imq.JoinedQueue(me, queue, 0)) c.Send(imq.G2cCreateMount, imq.CreateMount(queue, "node", imq.MessageMount, nil, 0)) case "occupancy": props := r.watch(c, n, queue, true) c.Send(imq.G2cJoinedQueue, imq.JoinedQueue(me, queue, 0)) c.Send(imq.G2cCreateMount, imq.CreateMount(queue, "occupants", imq.StateMount, props, 0)) } if withResult { c.Send(imq.G2cResult, imq.Result(opID, 0)) } return true } // Closed forgets a connection's subscriptions. func (r *Rooms) Closed(c *proxy.IMQConn) { r.mu.Lock() defer r.mu.Unlock() for _, rm := range r.rooms { delete(rm.conns, c) } } // 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, original []byte, keep bool) bool { n, kind, ok := queueRoom(queue) if !ok { return false } 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" { var m struct { Message string `json:"message"` } if json.Unmarshal(message, &m) == nil { if strings.HasPrefix(m.Message, "*boot ") { // The line first, so the booted client gets it (it leaves on it) before the hub takes it out. r.Hub.Send(map[string]any{"type": "room.send", "room": n, "mount": mount, "message": string(message)}) r.boot(n, m.Message) return true } if seat, ok := roomSeat(m.Message, r.user()); ok { r.Hub.Send(map[string]any{"type": "room.seat", "room": n, "seat": seat}) } } } r.Hub.Send(map[string]any{"type": "room.send", "room": n, "mount": mount, "message": string(message)}) 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'. func (r *Rooms) Intercept(x *proxy.Exchange) error { host, path := strings.ToLower(x.URL.Hostname()), x.URL.Path if !strings.HasSuffix(host, ".imvu.com") { return nil } if host == "api.imvu.com" { if m := participantPath.FindStringSubmatch(path); m != nil { if chat, err := strconv.ParseInt(m[1], 10, 64); err == nil { if _, ok := ChatRoom(chat); ok { x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"status":"success"}`)) } } } if x.Response == nil { r.keepFromIMVU(x) } return nil } switch path { case "/api/xmlrpc/chat.php", "/api/xmlrpc/client.php": if err := r.interceptXMLRPC(x); err != nil || x.Response != nil { return err } case "/api/rooms/rooms_list_paginated.php": r.addToRoomList(x) return nil case "/api/room_management_info.php": r.markManagedRooms(x) return nil case "/api/rooms/room_info.php": if n, ok := RoomNumber(x.URL.Query().Get("room_id")); ok { x.Response = r.roomInfo(n) return nil } r.markRoomInfo(x, x.URL.Query().Get("room_id")) return nil case "/api/favorite_rooms.php": ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if r.favoriteForm(ctx, x) { return nil } case "/api/rooms/room_rating.php": ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if r.rating(ctx, x) { return nil } } r.keepFromIMVU(x) return nil } // keepFromIMVU answers, rather than sends, any other request to IMVU naming a hosted room (its instance ID, or the // 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 := 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 { if strings.Contains(text, strconv.FormatInt(rm.chat, 10)) { named = true break } } r.mu.Unlock() } if !named { return } r.logf("hosted: answered a request to %s%s naming a hosted room (not found)", x.URL.Host, x.URL.Path) x.Response = proxy.LocalResponse(404, "application/json", []byte(`{"status":"failure","error":"NOT_FOUND"}`)) } func xmlrpcAnswer(v xmlrpc.Value) *http.Response { return proxy.LocalResponse(200, "text/xml", (&xmlrpc.Response{Params: []xmlrpc.Value{v}}).Marshal()) } func xmlrpcFault(message string) *http.Response { return proxy.LocalResponse(200, "text/xml", (&xmlrpc.Response{Fault: &xmlrpc.Fault{Code: 1, String: message}}).Marshal()) } 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) case float64: return strconv.FormatFloat(v, 'f', -1, 64) } return "" } func (r *Rooms) interceptXMLRPC(x *proxy.Exchange) error { call, err := xmlrpc.ParseCall(x.Body) if err != nil || len(call.Params) == 0 { return nil } args, _ := call.Params[0].(xmlrpc.Struct) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if r.interceptPhase2(ctx, x, call.Method, args) { return nil } switch call.Method { case "test.getRoomCardInfo", "test.getRoomState", "test.setRoomState": n, ok := RoomNumber(member(args, "room_instance_id")) if !ok { if call.Method == "test.getRoomCardInfo" { r.markCard(x, member(args, "room_instance_id")) } return nil } switch call.Method { case "test.getRoomCardInfo": x.Response = r.roomCard(ctx, n) case "test.getRoomState": x.Response = r.roomState(ctx, n) default: x.Response = r.saveRoomState(ctx, n, args) } case "chat.getOrMakeChat": n, ok := RoomNumber(strings.TrimPrefix(member(args, "activity"), "publicroom-")) if !ok { return nil } r.mu.Lock() inv := r.accepted[n] delete(r.accepted, n) r.mu.Unlock() x.Response = r.joinChat(ctx, n, inv) case "chat.getParticipants", "chat.terminateChat": chat, err := strconv.ParseInt(member(args, "chatId"), 10, 64) if err != nil { return nil } n, ok := ChatRoom(chat) if !ok { return nil } if call.Method == "chat.terminateChat" { r.leave(n) x.Response = xmlrpcAnswer(true) return nil } r.mu.Lock() _, in := r.rooms[n] r.mu.Unlock() if !in { // Not in it (yet): who is, from the hub (the client checks its inviter is there before joining). var people []xmlrpc.Value for _, p := range r.people(ctx, n) { people = append(people, xmlrpc.Struct{{Name: "userId", Value: p.ImvuID}, {Name: "seat", Value: int64(p.Seat)}}) } if people == nil { people = []xmlrpc.Value{} } x.Response = xmlrpcAnswer(people) return nil } x.Response = xmlrpcAnswer(r.others(n)) } return nil } func (r *Rooms) getRoom(ctx context.Context, n int64) (*Room, [][3]any, string, error) { var out struct { Room Room `json:"room"` Scene struct { Contents [][3]any `json:"contents"` Hash string `json:"hash"` } `json:"scene"` } if err := r.Hub.Call(ctx, "GET", fmt.Sprintf("/api/v1/hosted-rooms/%d", n), nil, &out); err != nil { return nil, nil, "", err } r.mu.Lock() if r.owners == nil { r.owners = map[int64]int64{} } r.owners[n] = out.Room.OwnerImvuID r.mu.Unlock() return &out.Room, out.Scene.Contents, out.Scene.Hash, nil } func (r *Rooms) roomCard(ctx context.Context, n int64) *http.Response { rm, _, _, err := r.getRoom(ctx, n) if err != nil { r.logf("hosted: room %d: %v", n, err) return xmlrpcFault("This T5DE room isn't available.") } adult := rm.Rating == "adult" return xmlrpcAnswer(xmlrpc.Struct{ {Name: "owner", Value: plain(rm.OwnerName)}, {Name: "owner_cid", Value: rm.OwnerImvuID}, {Name: "size", Value: int64(rm.Occupancy)}, {Name: "name", Value: plain(rm.Name)}, {Name: "lieutenants", Value: lieutenants(rm.Moderators)}, {Name: "roomOwners", Value: []xmlrpc.Value{}}, {Name: "allowApProducts", Value: adult}, {Name: "allowRoomShellChange", Value: false}, {Name: "allowRoomShellOverwrite", Value: false}, {Name: "allowJoinNewRoom", Value: false}, {Name: "allowLoadNewRoom", Value: false}, {Name: "maxOccupancy", Value: int64(rm.MaxUsers)}, {Name: "description", Value: plain(rm.Description)}, {Name: "imageUrl", Value: rm.Image}, {Name: "language", Value: ""}, {Name: "isAP", Value: adult}, {Name: "isVIP", Value: false}, {Name: "allowGames", Value: false}, {Name: "isFavorite", Value: rm.Favorite}, {Name: "showFavorite", Value: true}, {Name: "showRatings", Value: true}, {Name: "enableFlagging", Value: false}, {Name: "private", Value: false}, {Name: "autoBootWhenOwnerLeaves", Value: false}, {Name: "rating", Value: rm.Score}, {Name: "permission", Value: ""}, {Name: "themedStatus", Value: ""}, {Name: "themeId", Value: ""}, {Name: "eligibleForThemedRoomSubmission", Value: ""}, {Name: "occupants", Value: []xmlrpc.Value{}}, {Name: "t5de_t5", Value: true}, // the client's T5 badge (its t5de-rooms patch) }) } func (r *Rooms) roomState(ctx context.Context, n int64) *http.Response { rm, contents, hash, err := r.getRoom(ctx, n) if err != nil { r.logf("hosted: room %d: %v", n, err) return xmlrpcFault("This T5DE room isn't available.") } var items []xmlrpc.Value for _, it := range contents { furni, ok1 := it[0].(float64) placement, ok2 := it[2].(string) if !ok1 || !ok2 { continue } var product int64 switch p := it[1].(type) { case float64: product = int64(p) case string: local, ok := int64(0), false if r.LocalProduct != nil { local, ok = r.LocalProduct(p) } if !ok { continue // a community product that isn't here } product = local placement = swapToken(placement, p, strconv.FormatInt(local, 10)) default: continue } items = append(items, []xmlrpc.Value{int64(furni), product, placement}) } if items == nil { items = []xmlrpc.Value{} } return xmlrpcAnswer(xmlrpc.Struct{ {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: instanceID(rm.OwnerImvuID, n)}, {Name: "room_pid", Value: strconv.FormatInt(rm.RoomPid, 10)}, }}, {Name: "room_contents", Value: items}, }}, {Name: "room_hash", Value: hash}, }) } func (r *Rooms) saveRoomState(ctx context.Context, n int64, args xmlrpc.Struct) *http.Response { v, _ := args.Get("room_contents") list, _ := v.([]xmlrpc.Value) contents := [][3]any{} for _, item := range list { it, ok := item.([]xmlrpc.Value) if !ok || len(it) < 3 { continue } furni, ok1 := asInt(it[0]) product, ok2 := asInt(it[1]) placement, ok3 := it[2].(string) if !ok1 || !ok2 || !ok3 { continue } var p any = product if product >= 2000000000 { id, ok := "", false if r.GlobalProduct != nil { id, ok = r.GlobalProduct(product) } if !ok { continue } p = id placement = swapToken(placement, strconv.FormatInt(product, 10), id) } contents = append(contents, [3]any{furni, p, placement}) } if err := r.Hub.Call(ctx, "PUT", fmt.Sprintf("/api/v1/hosted-rooms/%d/scene", n), map[string]any{"contents": contents}, nil); err != nil { r.logf("hosted: saving room %d: %v", n, err) return xmlrpcFault("Couldn't save the room on T5DE's hub.") } return xmlrpcAnswer(true) } func asInt(v xmlrpc.Value) (int64, bool) { switch v := v.(type) { case int64: return v, true case float64: return int64(v), v == float64(int64(v)) case string: n, err := strconv.ParseInt(v, 10, 64) return n, err == nil } return 0, false } func (r *Rooms) joinChat(ctx context.Context, n int64, inviteID string) *http.Response { a, err := r.join(ctx, n, inviteID) if err != nil { r.logf("hosted: joining room %d: %v", n, err) return xmlrpcFault("T5DE's hub isn't reachable right now.") } if a.Type != "room.joined" { msg := a.Message if msg == "" { msg = "You can't join this T5DE room." } // Refused as the client's own refusals are (it shows the explanation; booted ones as booted). reason := int64(0) switch a.Reason { case "banned": reason = errorPublicRoomBooted case "full": reason = errorPublicRoomFull } return xmlrpcAnswer(xmlrpc.Struct{{Name: "response", Value: "declined"}, {Name: "reason", Value: reason}, {Name: "explanation", Value: msg}}) } me := r.user() taken := map[int]bool{} for _, p := range a.Participants { if p.ImvuID != me { taken[p.Seat] = true } } seat := 1 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] r.mu.Unlock() if !known { r.getRoom(ctx, n) // its owner, for the room's products } if r.OnJoin != nil { 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{ {Name: "waiting", Value: int64(0)}, {Name: "lastMessageId", Value: int64(a.Sequence)}, {Name: "chatId", Value: a.Chat}, {Name: "partnerId", Value: int64(0)}, {Name: "seat", Value: int64(seat)}, {Name: "participantUrl", Value: fmt.Sprintf("https://api.imvu.com/chat/chat-%d/participants/user-%d", a.Chat, me)}, }) } // others is the room's other people, as chat.getParticipants lists them. func (r *Rooms) others(n int64) []xmlrpc.Value { me := r.user() r.mu.Lock() defer r.mu.Unlock() out := []xmlrpc.Value{} if rm, ok := r.rooms[n]; ok { for _, p := range rm.participants { if p.ImvuID != me { out = append(out, xmlrpc.Struct{{Name: "userId", Value: p.ImvuID}, {Name: "seat", Value: int64(p.Seat)}}) } } } return out } // addToRoomList changes the client's room list (rooms_list_paginated.php: one array per field): hosted rooms are // added to the first page without a search (all of them; to recently visited rooms, the person's recent ones), and // every room gets t5de_t5: 1 for rooms needing T5DE to be seen as meant (hosted, or with T5 decorations), for the // client's T5 badge (its t5de-rooms patch). func (r *Rooms) addToRoomList(x *proxy.Exchange) { q := x.URL.Query() firstPage := q.Get("page") == "" || q.Get("page") == "1" || q.Get("page") == "0" addHosted := firstPage && q.Get("keywords") == "" && q.Get("search") == "" recent := q.Get("recent") != "" favorites := q.Get("favorite") != "" && q.Get("favorite") != "0" x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { var list map[string]json.RawMessage if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &list) != nil { return body, nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if addHosted { for _, rm := range r.listedRooms(ctx, recent, favorites) { appendRoom(list, rm) } } r.markT5(ctx, list) return json.Marshal(list) } } // listedRooms is the hosted rooms for the list: the person's recent ones (or, if the hub can't say, those visited // this run), or all of them, busiest first. func (r *Rooms) listedRooms(ctx context.Context, recent, favorites bool) []Room { var out struct { Rooms []Room `json:"rooms"` } if favorites { if err := r.Hub.Call(ctx, "GET", "/api/v1/hosted-rooms?favorites=1", nil, &out); err != nil { return nil } return out.Rooms } if recent { if err := r.Hub.Call(ctx, "GET", "/api/v1/hosted-rooms?recent=1", nil, &out); err == nil { return out.Rooms } } if err := r.Hub.Call(ctx, "GET", "/api/v1/hosted-rooms", nil, &out); err != nil { return nil } sort.SliceStable(out.Rooms, func(i, j int) bool { return out.Rooms[i].Occupancy > out.Rooms[j].Occupancy }) if !recent { return out.Rooms } r.mu.Lock() defer r.mu.Unlock() var visited []Room for _, rm := range out.Rooms { if _, ok := r.owners[rm.N]; ok { visited = append(visited, rm) } } return visited } // markT5 sets the list's t5de_t5 column: hosted rooms, and IMVU rooms the hub has decorations for. func (r *Rooms) markT5(ctx context.Context, list map[string]json.RawMessage) { var owners, numbers []json.RawMessage if json.Unmarshal(list["customers_id"], &owners) != nil || json.Unmarshal(list["customers_room_id"], &numbers) != nil || len(owners) != len(numbers) { return } marks := make([]int, len(owners)) var ask []string index := map[string][]int{} for i := range owners { instance := rawText(owners[i]) + "-" + rawText(numbers[i]) if _, ok := RoomNumber(instance); ok { marks[i] = 1 continue } if index[instance] == nil { ask = append(ask, instance) } index[instance] = append(index[instance], i) } for len(ask) > 0 { batch := ask[:min(len(ask), 20)] ask = ask[len(batch):] var out struct { Rooms []struct { Instance string `json:"instance"` Contents []json.RawMessage `json:"contents"` } `json:"rooms"` } if err := r.Hub.Call(ctx, "GET", "/api/v1/decorations?instances="+url.QueryEscape(strings.Join(batch, ",")), nil, &out); err != nil { break } for _, d := range out.Rooms { if len(d.Contents) > 0 { for _, i := range index[d.Instance] { marks[i] = 1 } } } } list["t5de_t5"], _ = json.Marshal(marks) } // rawText is a JSON string's or number's text. func rawText(raw json.RawMessage) string { var s string if json.Unmarshal(raw, &s) == nil { return s } return strings.TrimSpace(string(raw)) } // appendRoom adds a room to each of the list's arrays (fields it doesn't know get an empty value of the array's // kind). func appendRoom(list map[string]json.RawMessage, rm Room) { adult := "0" if rm.Rating == "adult" { adult = "1" } known := map[string]any{ "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": rm.Image, "rating": strconv.FormatFloat(rm.Score, 'f', -1, 64), "is_favorite": boolNumber(rm.Favorite), "language": "", "is_ap": adult, "is_av": "0", "is_vip": "0", "participants": []any{}, } flag := map[string]bool{"is_ap": true, "is_av": true, "is_vip": true} for name, raw := range list { var arr []json.RawMessage if json.Unmarshal(raw, &arr) != nil { continue } v, ok := known[name] if !ok { v = emptyLike(arr) } else if flag[name] && len(arr) == 0 { // Nothing to copy the type from (IMVU had no rooms): a number, as "0" would count as set. v, _ = strconv.Atoi(v.(string)) } else if str, isString := v.(string); isString && len(arr) > 0 && !strings.HasPrefix(strings.TrimSpace(string(arr[0])), "\"") { // The list has numbers here, not strings. if n, err := strconv.ParseInt(str, 10, 64); err == nil { v = n } } b, _ := json.Marshal(v) arr = append(arr, b) list[name], _ = json.Marshal(arr) } } // emptyLike is an empty value of the kind the array holds. func emptyLike(arr []json.RawMessage) any { if len(arr) == 0 { return "" } switch b := strings.TrimSpace(string(arr[0])); { case strings.HasPrefix(b, "["): return []any{} case strings.HasPrefix(b, "{"): return map[string]any{} case strings.HasPrefix(b, "\""): return "" case b == "true" || b == "false": return false case b == "null": return nil } return 0 } // plain keeps text to what the client's pages can show however they insert it (as in the products package). func plain(s string) string { return strings.Map(func(c rune) rune { switch c { case '<', '>', '&', '"', '\'', '`': return -1 } 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, " ") } // decorated is which of the IMVU rooms the hub has decorations for (20 asked at a time). func (r *Rooms) decorated(ctx context.Context, instances []string) map[string]bool { out := map[string]bool{} for len(instances) > 0 { batch := instances[:min(len(instances), 20)] instances = instances[len(batch):] var answer struct { Rooms []struct { Instance string `json:"instance"` Contents []json.RawMessage `json:"contents"` } `json:"rooms"` } if err := r.Hub.Call(ctx, "GET", "/api/v1/decorations?instances="+url.QueryEscape(strings.Join(batch, ",")), nil, &answer); err != nil { break } for _, d := range answer.Rooms { if len(d.Contents) > 0 { out[d.Instance] = true } } } return out } // markCard adds t5de_t5 to an IMVU room's card if it has T5 decorations (the client's T5 badge). func (r *Rooms) markCard(x *proxy.Exchange, instance string) { if instance == "" { return } x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if !r.decorated(ctx, []string{instance})[instance] { return body, nil } answer, err := xmlrpc.ParseResponse(body) if err != nil || answer.Fault != nil || len(answer.Params) != 1 { return body, nil } card, ok := answer.Params[0].(xmlrpc.Struct) if !ok { return body, nil } answer.Params[0] = append(card, xmlrpc.Member{Name: "t5de_t5", Value: true}) return answer.Marshal(), nil } } // markManagedRooms adds t5de_t5 to the person's own rooms with T5 decorations (room_management_info.php's rooms, // which the chat rooms page's Manage tab shows). func (r *Rooms) markManagedRooms(x *proxy.Exchange) { x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { var top map[string]json.RawMessage if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &top) != nil { return body, nil } holder, wrapped := top, false if inner, ok := top["result"]; ok { var m map[string]json.RawMessage if json.Unmarshal(inner, &m) == nil && m["rooms"] != nil { holder, wrapped = m, true } } var rooms []map[string]json.RawMessage if json.Unmarshal(holder["rooms"], &rooms) != nil || len(rooms) == 0 { return body, nil } var instances []string for _, room := range rooms { instances = append(instances, rawText(room["room_instance_id"])) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() marked := r.decorated(ctx, instances) if len(marked) == 0 { return body, nil } for i, room := range rooms { if marked[instances[i]] { room["t5de_t5"] = json.RawMessage("1") } } holder["rooms"], _ = json.Marshal(rooms) if wrapped { top["result"], _ = json.Marshal(holder) } return json.Marshal(top) } } // roomInfo answers room_info.php for a hosted room: the people in it, for the room card. func (r *Rooms) roomInfo(n int64) *http.Response { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var out struct { Participants []struct { ImvuID int64 `json:"imvu_id"` Name string `json:"name"` } `json:"participants"` } people := []map[string]any{} if err := r.Hub.Call(ctx, "GET", fmt.Sprintf("/api/v1/hosted-rooms/%d/participants", n), nil, &out); err == nil { for _, p := range out.Participants { people = append(people, map[string]any{"customers_id": strconv.FormatInt(p.ImvuID, 10), "avatar_name": plain(p.Name), "is_friend": false, "age": "", "gender": "", "country": "", "avatar_download_size": 0}) } } maxUsers := 0 if rm, _, _, err := r.getRoom(ctx, n); err == nil { maxUsers = rm.MaxUsers } b, _ := json.Marshal(map[string]any{"visitors_count": nil, "boot_count": nil, "participants": people, "max_users": maxUsers, "t5de_t5": 1}) return proxy.LocalResponse(200, "application/json", b) } // markRoomInfo adds t5de_t5 to an IMVU room's room_info.php answer if it has T5 decorations (the room card's T5 // badge; the card always asks it, however it was opened). func (r *Rooms) markRoomInfo(x *proxy.Exchange, instance string) { if instance == "" { return } x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) { var info map[string]json.RawMessage if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &info) != nil { return body, nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if !r.decorated(ctx, []string{instance})[instance] { return body, nil } info["t5de_t5"] = json.RawMessage("1") return json.Marshal(info) } } // lieutenants is a room's moderators as the room card lists them (strings: the client compares str(cid)). func lieutenants(ids []int64) []xmlrpc.Value { out := []xmlrpc.Value{} for _, id := range ids { out = append(out, strconv.FormatInt(id, 10)) } return out } func boolNumber(b bool) int { if b { return 1 } return 0 }