Files
t5de-proxy/internal/hosted/hosted.go
T
dhkatzandClaude Opus 5.5 8daf91ede3
CI / build (push) Successful in 28s
Manage T5DE rooms from the client; the AP+ badge on avatar cards
Chat Rooms > Manage (the t5de-room-manage patch) lists the person's T5DE rooms
(room_management_info.php?t5de=1) and saves them from IMVU's room dialog (room_management.php: create
with t5de=1, edit and delete naming a hosted room) as hub calls: the room, its moderators by name, and a
picture from the person's IMVU gallery, uploaded to the hub. The hub's refusals show in the dialog
(hub.Error keeps their message).

Avatar cards get show_ap_plus again, which IMVU stopped sending, from the person's public IMVU profile
(is_ap_plus).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:19:33 -07:00

1200 lines
37 KiB
Go

// 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 "<owner's IMVU ID>-<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 "<owner's IMVU ID>-<Base + n>": 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-<instance>) or "occupancy" (room_occupancy/<instance>).
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 <n>", or "*msg SeatAssignment <kind> <user> <seat> 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":
if x.URL.Query().Get("t5de") == "1" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
x.Response = r.manageInfo(ctx)
return nil
}
r.markManagedRooms(x)
return nil
case "/api/room_management.php":
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if r.manage(ctx, 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
}