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 " 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 " 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 " 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/ 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)) } }