Hosted rooms (internal/hosted): room calls for them answered here, their IMQ queues served (a new proxy.IMQRouter hook takes them out of subscriptions to IMVU), chat relayed through the hub's WebSocket (hub.Client gains Send, OnRoom and OnConnect), the room list extended, room products checked against the real owner, and anything else naming a hosted room kept from IMVU. T5DE_INSTANCE gives a second client's proxy its own data folder and log. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
455dae1013
commit
d8332b21a8
12 files changed
+1492
-12
No files matched your search
@@ -0,0 +1,827 @@
|
||||
// Package hosted runs T5DE-hosted rooms for the client (t5de-hub's docs/hosted-rooms.md): rooms that live on T5DE's
|
||||
// hub, which the client joins and uses like IMVU's rooms. The proxy answers the client's room calls for them
|
||||
// (XML-RPC), serves their IMQ queues (subscriptions answered here, messages to and from the hub), and adds them to
|
||||
// the room list. IMVU never hears of them.
|
||||
//
|
||||
// A hosted room has a number n; its instance ID is "2100000000-<n>" and its chat ID 2100000000+n.
|
||||
package hosted
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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"`
|
||||
}
|
||||
|
||||
var instancePattern = regexp.MustCompile(`^` + strconv.Itoa(Base) + `-([1-9][0-9]{0,8})$`)
|
||||
|
||||
// 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
|
||||
}
|
||||
n, err := strconv.ParseInt(m[1], 10, 64)
|
||||
return n, err == nil && n < 2147483647-Base
|
||||
}
|
||||
|
||||
// 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 instanceOf(n int64) string { return fmt.Sprintf("%d-%d", Base, 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: instanceOf(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 []byte) bool {
|
||||
n, kind, ok := queueRoom(queue)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if kind != "chat" {
|
||||
return true // nothing to say on the others
|
||||
}
|
||||
if mount == "messages" {
|
||||
// Sitting down: "*seat <n>" in the chat text.
|
||||
var m struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(message, &m) == nil && strings.HasPrefix(m.Message, "*seat ") {
|
||||
if seat, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(m.Message, "*seat "))); err == nil && seat >= 0 && seat < 100 {
|
||||
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
|
||||
}
|
||||
|
||||
// --- HTTP: XML-RPC, the participant post and the room list ---
|
||||
|
||||
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 := strings.Contains(text, strconv.Itoa(Base)+"-")
|
||||
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
|
||||
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: instanceOf(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
|
||||
}
|
||||
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++
|
||||
}
|
||||
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.OnJoin(strconv.FormatInt(a.Chat, 10), instanceOf(n))
|
||||
}
|
||||
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(rm.N, 10), "customers_id": strconv.Itoa(Base),
|
||||
"room_instance_id": instanceOf(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)
|
||||
}
|
||||
Reference in new issue
Block a user