Invitations into hosted rooms: the dialog's details, and joining as a public room
CI / build (push) Successful in 34s
CI / build (push) Successful in 34s
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]>
This commit is contained in:
1 parent
210bc064e9
commit
2df46da3ad
3 files changed
+116
-9
No files matched your search
@@ -72,6 +72,8 @@ type Rooms struct {
|
|||||||
// OnJoin and OnLeave report being in a room's chat (the room channel, for others' community products).
|
// OnJoin and OnLeave report being in a room's chat (the room channel, for others' community products).
|
||||||
OnJoin func(chat, room string)
|
OnJoin func(chat, room string)
|
||||||
OnLeave func(chat string)
|
OnLeave func(chat string)
|
||||||
|
// HTTPClient fetches IMVU's public profiles (nil: http.DefaultClient).
|
||||||
|
HTTPClient *http.Client
|
||||||
// UserDeliver gives the client a message on its own user queue (invitations).
|
// UserDeliver gives the client a message on its own user queue (invitations).
|
||||||
UserDeliver func(mount string, message []byte) error
|
UserDeliver func(mount string, message []byte) error
|
||||||
// LocalProduct and GlobalProduct translate scenes' community products between the hub's IDs and the client's
|
// LocalProduct and GlobalProduct translate scenes' community products between the hub's IDs and the client's
|
||||||
@@ -85,6 +87,7 @@ type Rooms struct {
|
|||||||
owners map[int64]int64 // room -> its owner's IMVU account, as last looked up
|
owners map[int64]int64 // room -> its owner's IMVU account, as last looked up
|
||||||
last int64 // the room the client joined last
|
last int64 // the room the client joined last
|
||||||
|
|
||||||
|
accepted map[int64]string // room -> the invite the client accepted, for its next join
|
||||||
invites map[int64]*invite // by the local ID the client knows
|
invites map[int64]*invite // by the local ID the client knows
|
||||||
nextInvite int64 //
|
nextInvite int64 //
|
||||||
watched map[int64]map[*proxy.IMQConn]string // room -> connections watching its occupancy (their queue)
|
watched map[int64]map[*proxy.IMQConn]string // room -> connections watching its occupancy (their queue)
|
||||||
@@ -583,7 +586,11 @@ func (r *Rooms) interceptXMLRPC(x *proxy.Exchange) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
x.Response = r.joinChat(ctx, n, "")
|
r.mu.Lock()
|
||||||
|
inv := r.accepted[n]
|
||||||
|
delete(r.accepted, n)
|
||||||
|
r.mu.Unlock()
|
||||||
|
x.Response = r.joinChat(ctx, n, inv)
|
||||||
case "chat.getParticipants", "chat.terminateChat":
|
case "chat.getParticipants", "chat.terminateChat":
|
||||||
chat, err := strconv.ParseInt(member(args, "chatId"), 10, 64)
|
chat, err := strconv.ParseInt(member(args, "chatId"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -598,6 +605,21 @@ func (r *Rooms) interceptXMLRPC(x *proxy.Exchange) error {
|
|||||||
x.Response = xmlrpcAnswer(true)
|
x.Response = xmlrpcAnswer(true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
_, in := r.rooms[n]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !in {
|
||||||
|
// Not in it (yet): who is, from the hub (the client checks its inviter is there before joining).
|
||||||
|
var people []xmlrpc.Value
|
||||||
|
for _, p := range r.people(ctx, n) {
|
||||||
|
people = append(people, xmlrpc.Struct{{Name: "userId", Value: p.ImvuID}, {Name: "seat", Value: int64(p.Seat)}})
|
||||||
|
}
|
||||||
|
if people == nil {
|
||||||
|
people = []xmlrpc.Value{}
|
||||||
|
}
|
||||||
|
x.Response = xmlrpcAnswer(people)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
x.Response = xmlrpcAnswer(r.others(n))
|
x.Response = xmlrpcAnswer(r.others(n))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -318,6 +319,11 @@ func TestBadgesAndRoomInfo(t *testing.T) {
|
|||||||
|
|
||||||
func TestPhase2(t *testing.T) {
|
func TestPhase2(t *testing.T) {
|
||||||
r, h, _ := setup()
|
r, h, _ := setup()
|
||||||
|
imvu := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
fmt.Fprintf(w, `{"denormalized": {"https://api.imvu.com/user/user-88": {"data": {"username": "Erin", "age": 30, "gender": "f", "thumbnail_url": "https://x/p.png"}}}}`)
|
||||||
|
}))
|
||||||
|
defer imvu.Close()
|
||||||
|
r.HTTPClient = &http.Client{Transport: rewrite{imvu.URL}}
|
||||||
var userMsgs []string
|
var userMsgs []string
|
||||||
r.UserDeliver = func(mount string, b []byte) error { userMsgs = append(userMsgs, mount+" "+string(b)); return nil }
|
r.UserDeliver = func(mount string, b []byte) error { userMsgs = append(userMsgs, mount+" "+string(b)); return nil }
|
||||||
call := func(method string, args xmlrpc.Struct) xmlrpc.Value {
|
call := func(method string, args xmlrpc.Struct) xmlrpc.Value {
|
||||||
@@ -350,15 +356,25 @@ func TestPhase2(t *testing.T) {
|
|||||||
for i := 0; i < 100 && len(userMsgs) == 0; i++ {
|
for i := 0; i < 100 && len(userMsgs) == 0; i++ {
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
if len(userMsgs) != 1 || !strings.HasPrefix(userMsgs[0], "web_msg chatInvite ") || !strings.Contains(userMsgs[0], `"location":"88-2100000003"`) || !strings.Contains(userMsgs[0], `"name":"Erin"`) {
|
if len(userMsgs) != 1 || !strings.HasPrefix(userMsgs[0], "web_msg chatInvite ") || !strings.Contains(userMsgs[0], `"room_instance_id":"88-2100000003"`) || !strings.Contains(userMsgs[0], `"maxOccupancy":10`) || !strings.Contains(userMsgs[0], `"gender":"f"`) || !strings.Contains(userMsgs[0], `"name":"Erin"`) {
|
||||||
t.Fatalf("invitation %v", userMsgs)
|
t.Fatalf("invitation %v", userMsgs)
|
||||||
}
|
}
|
||||||
var inv map[string]any
|
var inv map[string]any
|
||||||
json.Unmarshal([]byte(strings.TrimPrefix(userMsgs[0], "web_msg chatInvite ")), &inv)
|
json.Unmarshal([]byte(strings.TrimPrefix(userMsgs[0], "web_msg chatInvite ")), &inv)
|
||||||
|
// Before accepting (not in the room), getParticipants says who's in it, so the client sees its inviter there.
|
||||||
|
r.leave(3)
|
||||||
|
if ps := call("chat.getParticipants", xmlrpc.Struct{{Name: "chatId", Value: int64(2100000003)}}); !strings.Contains(fmt.Sprint(ps), "88") {
|
||||||
|
t.Fatalf("participants before joining %v", ps)
|
||||||
|
}
|
||||||
accepted := call("chat.acceptInvite", xmlrpc.Struct{{Name: "inviteId", Value: int64(inv["inviteId"].(float64))}, {Name: "chatId", Value: int64(2100000003)}})
|
accepted := call("chat.acceptInvite", xmlrpc.Struct{{Name: "inviteId", Value: int64(inv["inviteId"].(float64))}, {Name: "chatId", Value: int64(2100000003)}})
|
||||||
if get(accepted, "chatId") != int64(2100000003) || h.sentOf("room.invite_reply")[0]["accept"] != true || h.sentOf("room.join")[len(h.sentOf("room.join"))-1]["invite"] != "xyz" {
|
if get(accepted, "room_instance_id") != "88-2100000003" || h.sentOf("room.invite_reply")[0]["accept"] != true {
|
||||||
t.Fatalf("accepting: %v, sent %v", accepted, h.sent)
|
t.Fatalf("accepting: %v, sent %v", accepted, h.sent)
|
||||||
}
|
}
|
||||||
|
// The client then joins the room, with the invite.
|
||||||
|
xmlrpcCall(t, r, "chat.imvu.com", "chat.getOrMakeChat", xmlrpc.Struct{{Name: "activity", Value: "publicroom-88-2100000003"}})
|
||||||
|
if joins := h.sentOf("room.join"); joins[len(joins)-1]["invite"] != "xyz" {
|
||||||
|
t.Fatalf("join after accepting %v", joins)
|
||||||
|
}
|
||||||
|
|
||||||
// Booting: the *boot line also tells the hub; bootOutOfChat is answered here.
|
// Booting: the *boot line also tells the hub; bootOutOfChat is answered here.
|
||||||
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*boot 7 88", "userId": 7}`), nil, false)
|
r.Send("/chat/2100000003", "messages", []byte(`{"message": "*boot 7 88", "userId": 7}`), nil, false)
|
||||||
@@ -415,3 +431,13 @@ func TestAppendRoomToEmptyList(t *testing.T) {
|
|||||||
t.Fatalf("list %s %s %s", list["is_ap"], list["is_vip"], list["name"])
|
t.Fatalf("list %s %s %s", list["is_ap"], list["is_vip"], list["name"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rewrite sends every request to a test server.
|
||||||
|
type rewrite struct{ to string }
|
||||||
|
|
||||||
|
func (t rewrite) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
u, _ := url.Parse(t.to + req.URL.Path)
|
||||||
|
req = req.Clone(req.Context())
|
||||||
|
req.URL, req.Host = u, u.Host
|
||||||
|
return http.DefaultTransport.RoundTrip(req)
|
||||||
|
}
|
||||||
@@ -112,23 +112,72 @@ func (r *Rooms) deliverInvite(n int64, hubID string, from int64) {
|
|||||||
if err != nil || r.UserDeliver == nil {
|
if err != nil || r.UserDeliver == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := ""
|
inviter := r.profile(ctx, from)
|
||||||
for _, p := range r.people(ctx, n) {
|
for _, p := range r.people(ctx, n) {
|
||||||
if p.ImvuID == from {
|
if p.ImvuID == from && p.Name != "" {
|
||||||
name = p.Name
|
inviter["name"] = plain(p.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
local := r.newInviteLocked(&invite{room: n, hub: hubID, from: from})
|
local := r.newInviteLocked(&invite{room: n, hub: hubID, from: from})
|
||||||
r.mu.Unlock()
|
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,
|
b, _ := json.Marshal(map[string]any{"chatId": Base + n, "inviteId": local, "partnerId": from,
|
||||||
"location": instanceID(rm.OwnerImvuID, n),
|
"location": location, "inviter": inviter})
|
||||||
"inviter": map[string]any{"name": plain(name), "age": "", "gender": "", "imageUrl": "", "relationship": ""}})
|
|
||||||
if err := r.UserDeliver("web_msg", append([]byte("chatInvite "), b...)); err != nil {
|
if err := r.UserDeliver("web_msg", append([]byte("chatInvite "), b...)); err != nil {
|
||||||
r.logf("hosted: giving the client an invitation: %v", err)
|
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 {
|
type person struct {
|
||||||
ImvuID int64 `json:"imvu_id"`
|
ImvuID int64 `json:"imvu_id"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
@@ -184,7 +233,17 @@ func (r *Rooms) interceptPhase2(ctx context.Context, x *proxy.Exchange, method s
|
|||||||
x.Response = xmlrpcAnswer(true)
|
x.Response = xmlrpcAnswer(true)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
x.Response = r.joinChat(ctx, in.room, in.hub)
|
// 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
|
return true
|
||||||
case "chat.bootOutOfChat":
|
case "chat.bootOutOfChat":
|
||||||
// The "*boot" chat line already told the hub (Send); the client only wants an answer.
|
// The "*boot" chat line already told the hub (Send); the client only wants an answer.
|
||||||
|
|||||||
Reference in new issue
Block a user