CI / build (push) Successful in 31s
- The seat chosen on joining, and "*msg SeatAssignment" room seats, go to the hub, so people joining get free seats (both stood on seat 1). - Instance IDs are "<owner>-<2100000000+n>" (the hub's change): the client takes the owner from them, so owners can decorate their rooms. - A hosted room's scene carries community products as the hub's IDs (placements too), turned into local IDs by each proxy, instead of being stripped; saved and loaded scenes translate placements too. Co-Authored-By: Claude Opus 5.5 <[email protected]>
881 lines
26 KiB
Go
881 lines
26 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"
|
|
"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"`
|
|
}
|
|
|
|
// 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)
|
|
// 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
|
|
}
|
|
|
|
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.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})
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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()
|
|
if !r.Hub.Send(map[string]any{"type": "room.join", "room": n}) {
|
|
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 == "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":
|
|
c.Send(imq.G2cJoinedQueue, imq.JoinedQueue(me, queue, 0))
|
|
c.Send(imq.G2cCreateMount, imq.CreateMount(queue, "occupants", imq.StateMount, nil, 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 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/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"}`))
|
|
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()
|
|
switch call.Method {
|
|
case "test.getRoomCardInfo", "test.getRoomState", "test.setRoomState":
|
|
n, ok := RoomNumber(member(args, "room_instance_id"))
|
|
if !ok {
|
|
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: []xmlrpc.Value{}}, {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: "isAP", Value: adult}, {Name: "isVIP", Value: false}, {Name: "allowGames", Value: false},
|
|
{Name: "isFavorite", Value: false}, {Name: "showFavorite", Value: false}, {Name: "showRatings", Value: false},
|
|
{Name: "enableFlagging", Value: false}, {Name: "private", Value: false},
|
|
{Name: "autoBootWhenOwnerLeaves", Value: false}, {Name: "rating", Value: float64(0)},
|
|
{Name: "permission", Value: ""}, {Name: "themedStatus", Value: ""}, {Name: "themeId", Value: ""},
|
|
{Name: "eligibleForThemedRoomSubmission", Value: ""}, {Name: "occupants", Value: []xmlrpc.Value{}},
|
|
})
|
|
}
|
|
|
|
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) *http.Response {
|
|
a, err := r.join(ctx, n)
|
|
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."
|
|
}
|
|
return xmlrpcFault(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 adds hosted rooms to the client's room list (rooms_list_paginated.php: one array per field): all
|
|
// of them to the first page without a search, and those the client was in this run to recently visited rooms.
|
|
func (r *Rooms) addToRoomList(x *proxy.Exchange) {
|
|
q := x.URL.Query()
|
|
if p := q.Get("page"); p != "" && p != "1" && p != "0" {
|
|
return
|
|
}
|
|
if q.Get("keywords") != "" || q.Get("search") != "" {
|
|
return
|
|
}
|
|
recent := q.Get("recent") != ""
|
|
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()
|
|
var out struct {
|
|
Rooms []Room `json:"rooms"`
|
|
}
|
|
if err := r.Hub.Call(ctx, "GET", "/api/v1/hosted-rooms", nil, &out); err != nil || len(out.Rooms) == 0 {
|
|
return body, nil
|
|
}
|
|
sort.SliceStable(out.Rooms, func(i, j int) bool { return out.Rooms[i].Occupancy > out.Rooms[j].Occupancy })
|
|
r.mu.Lock()
|
|
visited := map[int64]bool{}
|
|
for n := range r.owners {
|
|
visited[n] = true
|
|
}
|
|
r.mu.Unlock()
|
|
for _, rm := range out.Rooms {
|
|
if !recent || visited[rm.N] {
|
|
appendRoom(list, rm)
|
|
}
|
|
}
|
|
return json.Marshal(list)
|
|
}
|
|
}
|
|
|
|
// 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": "", "rating": "0", "language": "", "is_ap": adult,
|
|
"is_av": "0", "is_vip": "0", "participants": []any{},
|
|
}
|
|
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 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, " ")
|
|
}
|