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]>
1174 lines
36 KiB
Go
1174 lines
36 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)
|
|
// 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
|
|
|
|
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":
|
|
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
|
|
}
|
|
x.Response = r.joinChat(ctx, n, "")
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// Owner is the IMVU account owning the hosted room the client joined last (0 if none, or not known): the client
|
|
// checks the room's products against it (products.HostedOwner).
|
|
func (r *Rooms) Owner() int64 {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if _, in := r.rooms[r.last]; !in {
|
|
return 0
|
|
}
|
|
return r.owners[r.last]
|
|
}
|
|
|
|
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)
|
|
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{}
|
|
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
|
|
}
|