Files
t5de-proxy/internal/hosted/phase2.go
T
dhkatzandClaude Opus 5.5 2df46da3ad
CI / build (push) Successful in 34s
Invitations into hosted rooms: the dialog's details, and joining as a public room
The invitation carries the room as the dialog shows it (name, owner, image, occupancy, description)
and the inviter's picture, age and gender from IMVU's public profile. Accepting names the room
(room_instance_id), so the client joins it as a public room with the invite rather than opening a
private chat, and getParticipants before joining asks the hub, so the client sees its inviter there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:44:33 -07:00

468 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"
)
// errorPublicRoomBooted is the client's reason code for being refused a room it was booted from.
const 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))
}
}