Hosted rooms phase 2: boots, invitations, favorites, ratings, live occupancy
CI / build (push) Successful in 36s

The client's own room actions work in hosted rooms, through the hub (t5de-hub's docs/hosted-rooms.md,
Phase 2): a "*boot" line goes to the room, then to the hub as room.boot; refused joins are declined as
the client's own are (booted ones as booted). Invitations: chat.attemptInvite polls the hub's answer,
and the invitee's client gets chatInvite on its user queue, accepting with the invite. Favorites
(favorite_rooms.php, add/removeFavoriteRoom, the favorites list), ratings (room_rating.php), the room
card's moderators, image and score, and room_occupancy queues fed from room.watch.

Web API parameters are read from JSON bodies too (imvu.http.securePost), and hosted rows added to an
empty IMVU list keep their flags numeric ("0" counted as set).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 06:30:42 -07:00
1 parent fc59367ba6
commit 210bc064e9
5 files changed
+629 -32

No files matched your search

+101 -27
View File
@@ -35,17 +35,24 @@ type Hub interface {
// 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"`
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.
@@ -65,6 +72,8 @@ type Rooms struct {
// 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)
// 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)
@@ -75,6 +84,11 @@ type Rooms struct {
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
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 {
@@ -223,6 +237,8 @@ func (r *Rooms) FromHub(data []byte) {
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)
@@ -257,10 +273,11 @@ func (r *Rooms) Reconnected() {
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) (joinAnswer, error) {
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 {
@@ -268,7 +285,11 @@ func (r *Rooms) join(ctx context.Context, n int64) (joinAnswer, error) {
}
r.waiting[n] = append(r.waiting[n], w)
r.mu.Unlock()
if !r.Hub.Send(map[string]any{"type": "room.join", "room": n}) {
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 {
@@ -317,6 +338,9 @@ func (r *Rooms) routeQueue(c *proxy.IMQConn, unsubscribe bool, queue string, opI
}
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 {
@@ -344,8 +368,9 @@ func (r *Rooms) routeQueue(c *proxy.IMQConn, unsubscribe bool, queue string, opI
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, nil, 0))
c.Send(imq.G2cCreateMount, imq.CreateMount(queue, "occupants", imq.StateMount, props, 0))
}
if withResult {
c.Send(imq.G2cResult, imq.Result(opID, 0))
@@ -381,6 +406,12 @@ func (r *Rooms) Send(queue, mount string, message, original []byte, keep bool) b
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})
}
@@ -451,9 +482,16 @@ func (r *Rooms) Intercept(x *proxy.Exchange) error {
}
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":
if _, ok := RoomNumber(x.URL.Query().Get("room_instance_id")); ok {
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"score":0,"result":"success"}`))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if r.rating(ctx, x) {
return nil
}
}
@@ -520,6 +558,9 @@ func (r *Rooms) interceptXMLRPC(x *proxy.Exchange) error {
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"))
@@ -542,7 +583,7 @@ func (r *Rooms) interceptXMLRPC(x *proxy.Exchange) error {
if !ok {
return nil
}
x.Response = r.joinChat(ctx, n)
x.Response = r.joinChat(ctx, n, "")
case "chat.getParticipants", "chat.terminateChat":
chat, err := strconv.ParseInt(member(args, "chatId"), 10, 64)
if err != nil {
@@ -603,15 +644,15 @@ func (r *Rooms) roomCard(ctx context.Context, n int64) *http.Response {
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: []xmlrpc.Value{}}, {Name: "roomOwners", Value: []xmlrpc.Value{}},
{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: ""}, {Name: "language", Value: ""},
{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: false}, {Name: "showFavorite", Value: false}, {Name: "showRatings", 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: float64(0)},
{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)
@@ -715,8 +756,8 @@ func asInt(v xmlrpc.Value) (int64, bool) {
return 0, false
}
func (r *Rooms) joinChat(ctx context.Context, n int64) *http.Response {
a, err := r.join(ctx, n)
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.")
@@ -726,7 +767,12 @@ func (r *Rooms) joinChat(ctx context.Context, n int64) *http.Response {
if msg == "" {
msg = "You can't join this T5DE room."
}
return xmlrpcFault(msg)
// Refused as the client's own refusals are (it shows the explanation; booted ones as booted).
reason := int64(0)
if a.Reason == "banned" {
reason = errorPublicRoomBooted
}
return xmlrpcAnswer(xmlrpc.Struct{{Name: "response", Value: "declined"}, {Name: "reason", Value: reason}, {Name: "explanation", Value: msg}})
}
me := r.user()
taken := map[int]bool{}
@@ -787,6 +833,7 @@ func (r *Rooms) addToRoomList(x *proxy.Exchange) {
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 {
@@ -795,7 +842,7 @@ func (r *Rooms) addToRoomList(x *proxy.Exchange) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if addHosted {
for _, rm := range r.listedRooms(ctx, recent) {
for _, rm := range r.listedRooms(ctx, recent, favorites) {
appendRoom(list, rm)
}
}
@@ -806,10 +853,16 @@ func (r *Rooms) addToRoomList(x *proxy.Exchange) {
// 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 bool) []Room {
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
@@ -896,9 +949,11 @@ func appendRoom(list map[string]json.RawMessage, rm Room) {
"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": "", "rating": "0", "language": "", "is_ap": adult,
"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 {
@@ -907,6 +962,9 @@ func appendRoom(list map[string]json.RawMessage, rm Room) {
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 {
@@ -1097,3 +1155,19 @@ func (r *Rooms) markRoomInfo(x *proxy.Exchange, instance string) {
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
}