CI / build (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
471 lines
14 KiB
Go
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))
|
|
}
|
|
}
|