diff --git a/internal/hosted/hosted.go b/internal/hosted/hosted.go index 1a0595b..99a13b5 100644 --- a/internal/hosted/hosted.go +++ b/internal/hosted/hosted.go @@ -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 func(chat, room 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 func(mount string, message []byte) error // 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 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 nextInvite int64 // 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 { 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": chat, err := strconv.ParseInt(member(args, "chatId"), 10, 64) if err != nil { @@ -598,6 +605,21 @@ func (r *Rooms) interceptXMLRPC(x *proxy.Exchange) error { x.Response = xmlrpcAnswer(true) 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)) } return nil diff --git a/internal/hosted/hosted_test.go b/internal/hosted/hosted_test.go index 84ab6c8..b0138cc 100644 --- a/internal/hosted/hosted_test.go +++ b/internal/hosted/hosted_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "net/url" "strings" "sync" @@ -318,6 +319,11 @@ func TestBadgesAndRoomInfo(t *testing.T) { func TestPhase2(t *testing.T) { 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 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 { @@ -350,15 +356,25 @@ func TestPhase2(t *testing.T) { for i := 0; i < 100 && len(userMsgs) == 0; i++ { 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) } var inv map[string]any 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)}}) - 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) } + // 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. 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"]) } } + +// 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) +} diff --git a/internal/hosted/phase2.go b/internal/hosted/phase2.go index cc1c9e6..813cf08 100644 --- a/internal/hosted/phase2.go +++ b/internal/hosted/phase2.go @@ -112,23 +112,72 @@ func (r *Rooms) deliverInvite(n int64, hubID string, from int64) { if err != nil || r.UserDeliver == nil { return } - name := "" + inviter := r.profile(ctx, from) for _, p := range r.people(ctx, n) { - if p.ImvuID == from { - name = p.Name + 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": instanceID(rm.OwnerImvuID, n), - "inviter": map[string]any{"name": plain(name), "age": "", "gender": "", "imageUrl": "", "relationship": ""}}) + "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"` @@ -184,7 +233,17 @@ func (r *Rooms) interceptPhase2(ctx context.Context, x *proxy.Exchange, method s x.Response = xmlrpcAnswer(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 case "chat.bootOutOfChat": // The "*boot" chat line already told the hub (Send); the client only wants an answer.