Hosted rooms phase 2: boots, invitations, favorites, ratings, live occupancy
CI / build (push) Successful in 36s
CI / build (push) Successful in 36s
The client's own room actions work in hosted rooms, through the hub (t5de-hub's docs/hosted-rooms.md,
Phase 2): a "*boot" line goes to the room, then to the hub as room.boot; refused joins are declined as
the client's own are (booted ones as booted). Invitations: chat.attemptInvite polls the hub's answer,
and the invitee's client gets chatInvite on its user queue, accepting with the invite. Favorites
(favorite_rooms.php, add/removeFavoriteRoom, the favorites list), ratings (room_rating.php), the room
card's moderators, image and score, and room_occupancy queues fed from room.watch.
Web API parameters are read from JSON bodies too (imvu.http.securePost), and hosted rows added to an
empty IMVU list keep their flags numeric ("0" counted as set).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fc59367ba6
commit
210bc064e9
5 files changed
+629
-32
No files matched your search
@@ -0,0 +1,408 @@
|
||||
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
|
||||
}
|
||||
name := ""
|
||||
for _, p := range r.people(ctx, n) {
|
||||
if p.ImvuID == from {
|
||||
name = p.Name
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
local := r.newInviteLocked(&invite{room: n, hub: hubID, from: from})
|
||||
r.mu.Unlock()
|
||||
b, _ := json.Marshal(map[string]any{"chatId": Base + n, "inviteId": local, "partnerId": from,
|
||||
"location": instanceID(rm.OwnerImvuID, n),
|
||||
"inviter": map[string]any{"name": plain(name), "age": "", "gender": "", "imageUrl": "", "relationship": ""}})
|
||||
if err := r.UserDeliver("web_msg", append([]byte("chatInvite "), b...)); err != nil {
|
||||
r.logf("hosted: giving the client an invitation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
x.Response = r.joinChat(ctx, in.room, in.hub)
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user