Files
t5de-proxy/internal/hosted/phase2.go
T
dhkatzandClaude Opus 5.5 6aac769246
CI / build (push) Successful in 29s
Full hosted rooms are refused with IMVU's own full-room code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:07:31 -07:00

471 lines
14 KiB
Go

package hosted
// Hosted rooms phase 2 (t5de-hub's docs/hosted-rooms.md, "Phase 2"): the client's own room actions, for hosted
// rooms. Boots are the room's "*boot <booter> <booted>" chat line (every client acts on it) and a room.boot to the
// hub; invitations go through the hub (the inviter's client polls chat.attemptInvite; the invitee's gets a
// "chatInvite" on its user queue); favorites, ratings and the room list's occupancy come from the hub.
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"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"
)
// The client's reason codes for refused joins: booted (it shows "booted"), and full (IMVU's own wording).
const (
errorPublicRoomFull = 5
errorPublicRoomBooted = 4
)
// invite is an invitation the client sent (outgoing, by the local ID the client knows) or got (incoming).
type invite struct {
room int64
hub string // the hub's invite ID ("" until the hub answers)
to int64 // outgoing: who
from int64 // incoming: who
state string // outgoing: "waiting", "accepted", "declined"
note string // why it was declined
}
func (r *Rooms) newInviteLocked(in *invite) int64 {
if r.invites == nil {
r.invites = map[int64]*invite{}
}
r.nextInvite++
r.invites[r.nextInvite] = in
return r.nextInvite
}
// --- from the hub ---
func (r *Rooms) phase2FromHub(kind string, data []byte) {
var m struct {
Room int64 `json:"room"`
ImvuID int64 `json:"imvu_id"`
OK bool `json:"ok"`
Invite string `json:"invite"`
To int64 `json:"to"`
From int64 `json:"from"`
Accept bool `json:"accept"`
Offline bool `json:"offline"`
Note string `json:"note"`
Reason string `json:"reason"`
Message string `json:"message"`
RoomName string `json:"room_name"`
Participants []Participant `json:"participants"`
}
if json.Unmarshal(data, &m) != nil {
return
}
switch kind {
case "room.invite_result":
r.mu.Lock()
for _, in := range r.invites {
if in.room == m.Room && in.to == m.ImvuID && in.hub == "" && in.state == "waiting" {
if m.OK {
in.hub = m.Invite
} else {
in.state, in.note = "declined", m.Message
}
break
}
}
r.mu.Unlock()
case "room.invite_answer":
r.mu.Lock()
for _, in := range r.invites {
if in.hub == m.Invite && in.state == "waiting" {
switch {
case m.Offline:
in.state, in.note = "declined", "isn't online"
case m.Accept:
in.state = "accepted"
default:
in.state, in.note = "declined", m.Note
}
}
}
r.mu.Unlock()
case "room.invited":
go r.deliverInvite(m.Room, m.Invite, m.From)
case "room.occupancy":
r.occupancy(m.Room, m.Participants)
case "room.boot_result":
if !m.OK {
r.logf("hosted: booting %d from room %d: %s", m.ImvuID, m.Room, m.Message)
}
}
}
// deliverInvite gives the client an invitation into a hosted room, as IMVU's server does ("chatInvite <json>" on its
// user queue, mount web_msg).
func (r *Rooms) deliverInvite(n int64, hubID string, from int64) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
rm, _, _, err := r.getRoom(ctx, n)
if err != nil || r.UserDeliver == nil {
return
}
inviter := r.profile(ctx, from)
for _, p := range r.people(ctx, n) {
if p.ImvuID == from && p.Name != "" {
inviter["name"] = plain(p.Name)
}
}
r.mu.Lock()
local := r.newInviteLocked(&invite{room: n, hub: hubID, from: from})
r.mu.Unlock()
rating := 0
if rm.Rating == "adult" {
rating = 1
}
instance := instanceID(rm.OwnerImvuID, n)
// location is what the invitation dialog shows of the room.
location := map[string]any{"name": plain(rm.Name), "private": rm.Privacy == "private", "imageUrl": rm.Image,
"ownerName": plain(rm.OwnerName), "language": "", "currentOccupancy": rm.Occupancy, "maxOccupancy": rm.MaxUsers,
"description": plain(rm.Description), "rating": rating, "room_instance_id": instance}
b, _ := json.Marshal(map[string]any{"chatId": Base + n, "inviteId": local, "partnerId": from,
"location": location, "inviter": inviter})
if err := r.UserDeliver("web_msg", append([]byte("chatInvite "), b...)); err != nil {
r.logf("hosted: giving the client an invitation: %v", err)
}
}
// profile is what the invitation dialog shows of the inviter, from IMVU's public profile.
func (r *Rooms) profile(ctx context.Context, id int64) map[string]any {
out := map[string]any{"name": "", "age": "", "gender": "", "imageUrl": "", "relationship": ""}
u := fmt.Sprintf("https://api.imvu.com/user/user-%d", id)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return out
}
resp, err := r.client().Do(req)
if err != nil {
return out
}
defer resp.Body.Close()
var body struct {
Denormalized map[string]struct {
Data struct {
Username string `json:"username"`
Age any `json:"age"`
Gender string `json:"gender"`
ThumbnailURL string `json:"thumbnail_url"`
} `json:"data"`
} `json:"denormalized"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&body) != nil {
return out
}
d := body.Denormalized[u].Data
out["name"], out["gender"], out["imageUrl"] = plain(d.Username), d.Gender, d.ThumbnailURL
if d.Age != nil {
out["age"] = fmt.Sprint(d.Age)
}
return out
}
func (r *Rooms) client() *http.Client {
if r.HTTPClient != nil {
return r.HTTPClient
}
return http.DefaultClient
}
type person struct {
ImvuID int64 `json:"imvu_id"`
Seat int `json:"seat"`
Name string `json:"name"`
}
// people is who's in a room, with names.
func (r *Rooms) people(ctx context.Context, n int64) []person {
var out struct {
Participants []person `json:"participants"`
}
r.Hub.Call(ctx, "GET", fmt.Sprintf("/api/v1/hosted-rooms/%d/participants", n), nil, &out)
return out.Participants
}
// --- the client's calls ---
// interceptPhase2 answers the client's room actions for hosted rooms; false if the call isn't one.
func (r *Rooms) interceptPhase2(ctx context.Context, x *proxy.Exchange, method string, args xmlrpc.Struct) bool {
switch method {
case "chat.attemptInvite":
n, ok := RoomNumber(member(args, "location"))
if !ok {
if chat, err := strconv.ParseInt(member(args, "chatId"), 10, 64); err == nil {
n, ok = ChatRoom(chat)
}
}
if !ok {
return false
}
x.Response = r.attemptInvite(n, args)
return true
case "chat.leaveInviteAwayNote":
if r.knownInvite(member(args, "inviteId")) {
x.Response = xmlrpcAnswer(true)
return true
}
case "chat.acceptInvite", "chat.declineInvite":
local, _ := strconv.ParseInt(member(args, "inviteId"), 10, 64)
r.mu.Lock()
in, ok := r.invites[local]
r.mu.Unlock()
if !ok || in.from == 0 {
return false
}
accept := method == "chat.acceptInvite"
reply := map[string]any{"type": "room.invite_reply", "invite": in.hub, "accept": accept}
if note := member(args, "reason"); !accept && note != "" {
reply["note"] = note
}
r.Hub.Send(reply)
if !accept {
x.Response = xmlrpcAnswer(true)
return true
}
// The client joins the room itself when the answer names it (as a public room, not a private chat); that
// join carries the invite.
r.mu.Lock()
if r.accepted == nil {
r.accepted = map[int64]string{}
}
r.accepted[in.room] = in.hub
instance := r.instanceOfLocked(in.room) // known: the room was looked up for the invitation
r.mu.Unlock()
x.Response = xmlrpcAnswer(xmlrpc.Struct{{Name: "chatId", Value: Base + in.room}, {Name: "partnerId", Value: in.from},
{Name: "room_instance_id", Value: instance}})
return true
case "chat.bootOutOfChat":
// The "*boot" chat line already told the hub (Send); the client only wants an answer.
if _, ok := RoomNumber(member(args, "roomInstanceId")); ok {
x.Response = xmlrpcAnswer(true)
return true
}
case "test.addFavoriteRoom", "test.removeFavoriteRoom":
n, ok := RoomNumber(member(args, "room_instance_id"))
if !ok {
return false
}
verb := "PUT"
if method == "test.removeFavoriteRoom" {
verb = "DELETE"
}
if err := r.Hub.Call(ctx, verb, fmt.Sprintf("/api/v1/hosted-rooms/%d/favorite", n), map[string]any{}, nil); err != nil {
x.Response = xmlrpcAnswer(xmlrpc.Struct{{Name: "error", Value: "T5DE's hub didn't take it: " + err.Error()}})
return true
}
x.Response = xmlrpcAnswer(xmlrpc.Struct{})
return true
}
return false
}
func (r *Rooms) knownInvite(id string) bool {
local, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.invites[local]
return ok
}
// attemptInvite is the inviter's poll: the first call sends the invitation; later ones (with its inviteId) say
// whether it's been answered.
func (r *Rooms) attemptInvite(n int64, args xmlrpc.Struct) *http.Response {
local, _ := strconv.ParseInt(member(args, "inviteId"), 10, 64)
r.mu.Lock()
in, ok := r.invites[local]
if !ok {
to, _ := strconv.ParseInt(member(args, "partnerId"), 10, 64)
in = &invite{room: n, to: to, state: "waiting"}
local = r.newInviteLocked(in)
}
state, note := in.state, in.note
r.mu.Unlock()
if !ok {
r.Hub.Send(map[string]any{"type": "room.invite", "room": n, "imvu_id": in.to})
}
switch state {
case "accepted":
return xmlrpcAnswer(xmlrpc.Struct{{Name: "inviteId", Value: local}, {Name: "chatId", Value: Base + n}})
case "declined":
if note == "" {
note = "declined"
}
return xmlrpcAnswer(xmlrpc.Struct{{Name: "inviteId", Value: local}, {Name: "response", Value: "decline"}, {Name: "reason", Value: note}})
}
return xmlrpcAnswer(xmlrpc.Struct{{Name: "inviteId", Value: local}, {Name: "waiting", Value: int64(1)}})
}
// boot tells the hub about a "*boot <booter> <booted>" line the client sent in a hosted room.
func (r *Rooms) boot(n int64, text string) {
f := strings.Fields(text)
if len(f) != 3 || f[0] != "*boot" {
return
}
booted, err := strconv.ParseInt(f[2], 10, 64)
if err != nil {
return
}
r.Hub.Send(map[string]any{"type": "room.boot", "room": n, "imvu_id": booted})
}
// favoriteForm answers the room list's favorite_rooms.php (action=add|remove, room_instance_id) for hosted rooms.
func (r *Rooms) favoriteForm(ctx context.Context, x *proxy.Exchange) bool {
form := params(x)
n, ok := RoomNumber(form.Get("room_instance_id"))
if !ok {
return false
}
verb := "PUT"
if form.Get("action") == "remove" {
verb = "DELETE"
}
if err := r.Hub.Call(ctx, verb, fmt.Sprintf("/api/v1/hosted-rooms/%d/favorite", n), map[string]any{}, nil); err != nil {
b, _ := json.Marshal(map[string]any{"error": "T5DE's hub didn't take it: " + err.Error()})
x.Response = proxy.LocalResponse(200, "application/json", b)
return true
}
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"result":"success"}`))
return true
}
// params is a web API call's parameters: its query, then its body (JSON, as imvu.http.securePost sends, or a form).
func params(x *proxy.Exchange) url.Values {
out := x.URL.Query()
var body map[string]any
if json.Unmarshal(x.Body, &body) == nil {
for k, v := range body {
switch v := v.(type) {
case string:
out.Set(k, v)
case float64:
out.Set(k, strconv.FormatFloat(v, 'f', -1, 64))
case bool:
out.Set(k, strconv.FormatBool(v))
}
}
return out
}
if form, err := url.ParseQuery(string(x.Body)); err == nil {
for k, v := range form {
out[k] = v
}
}
return out
}
// --- ratings (room_rating.php) ---
func (r *Rooms) rating(ctx context.Context, x *proxy.Exchange) bool {
form := params(x)
n, ok := RoomNumber(form.Get("room_instance_id"))
if !ok {
return false
}
if score, err := strconv.Atoi(form.Get("score")); err == nil && x.Request.Method == "POST" {
if err := r.Hub.Call(ctx, "PUT", fmt.Sprintf("/api/v1/hosted-rooms/%d/rating", n), map[string]any{"score": score}, nil); err != nil {
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"result":"failure"}`))
return true
}
x.Response = proxy.LocalResponse(200, "application/json", []byte(`{"result":"success"}`))
return true
}
mine := 0
if rm, _, _, err := r.getRoom(ctx, n); err == nil {
mine = rm.MyRating
}
b, _ := json.Marshal(map[string]any{"score": mine, "result": "success"})
x.Response = proxy.LocalResponse(200, "application/json", b)
return true
}
// --- occupancy (room_occupancy/<instance> queues in the room list) ---
// watch subscribes (or unsubscribes) a connection to a room's occupancy, and tells the hub which rooms to report.
func (r *Rooms) watch(c *proxy.IMQConn, n int64, queue string, on bool) []imq.Property {
r.mu.Lock()
if r.watched == nil {
r.watched = map[int64]map[*proxy.IMQConn]string{}
}
if on {
if r.watched[n] == nil {
r.watched[n] = map[*proxy.IMQConn]string{}
}
r.watched[n][c] = queue
} else if r.watched[n] != nil {
delete(r.watched[n], c)
if len(r.watched[n]) == 0 {
delete(r.watched, n)
}
}
var props []imq.Property
for key := range r.occupants[n] {
props = append(props, imq.Property{Key: key, Value: "1"})
}
r.mu.Unlock()
r.sendWatch()
return props
}
func (r *Rooms) sendWatch() {
r.mu.Lock()
rooms := []int64{}
for n := range r.watched {
rooms = append(rooms, n)
}
r.mu.Unlock()
r.Hub.Send(map[string]any{"type": "room.watch", "rooms": rooms})
}
// occupancy gives watchers a room's occupants as state changes: a key per person (the client counts them).
func (r *Rooms) occupancy(n int64, participants []Participant) {
now := map[string]bool{}
for _, p := range participants {
now[strconv.FormatInt(p.ImvuID, 10)] = true
}
r.mu.Lock()
if r.occupants == nil {
r.occupants = map[int64]map[string]bool{}
}
before := r.occupants[n]
r.occupants[n] = now
watchers := map[*proxy.IMQConn]string{}
for c, q := range r.watched[n] {
watchers[c] = q
}
r.mu.Unlock()
var props []imq.Property
for key := range now {
if !before[key] {
props = append(props, imq.Property{Key: key, Value: "1"})
}
}
for key := range before {
if !now[key] {
props = append(props, imq.Property{Key: key}) // empty: gone
}
}
if len(props) == 0 {
return
}
for c, queue := range watchers {
c.Send(imq.G2cStateChange, imq.StateChange(0, "admin", queue, "occupants", props, 0))
}
}