Room channel: other T5DE users' community products on their avatars
CI / build (push) Successful in 28s
CI / build (push) Successful in 28s
The proxy follows the client's IMQ chat subscriptions and tells the hub which chats it's in (and the room, from chat.getOrMakeChat). For the other T5DE users the hub says are in a chat, it gives the client the community products it has packs for, as *use and *remove lines from them, and adds them to the whole outfits they send. It also takes community products out of the outfit the client posts to IMVU's chat participant endpoint, which had been sending them to IMVU. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e394f1dc24
commit
93fa2f5d90
11 files changed
+858
-12
No files matched your search
@@ -27,7 +27,12 @@ func (p *Products) Intercept(x *proxy.Exchange) error {
|
||||
if !strings.HasSuffix(host, ".imvu.com") {
|
||||
return nil
|
||||
}
|
||||
if host == "api.imvu.com" && participantPath.MatchString(path) {
|
||||
return p.interceptParticipant(x)
|
||||
}
|
||||
switch path {
|
||||
case "/api/xmlrpc/chat.php":
|
||||
return p.interceptChat(x)
|
||||
case "/api/xmlrpc/client.php":
|
||||
return p.interceptXMLRPC(x)
|
||||
case "/api/shop/product.php":
|
||||
|
||||
@@ -44,15 +44,20 @@ type Products struct {
|
||||
byID map[string]*served
|
||||
problems map[string]string // pack directory -> last error logged
|
||||
|
||||
// Observer, if set, hears which IMVU account the client signs in as and the community products it wears (the
|
||||
// hub client).
|
||||
// Observer, if set, hears which IMVU account the client signs in as, the community products it wears, and the
|
||||
// chats it's in (the hub client).
|
||||
Observer Observer
|
||||
|
||||
roomMu sync.Mutex
|
||||
room room
|
||||
}
|
||||
|
||||
// Observer hears about the client's IMVU account and its community outfit.
|
||||
// Observer hears about the client's IMVU account, its community outfit, and the chats it's in.
|
||||
type Observer interface {
|
||||
Session(imvuID int64)
|
||||
Outfit(imvuID int64, items []hub.Item)
|
||||
Join(chat, room string)
|
||||
Leave(chat string)
|
||||
}
|
||||
|
||||
// served is a pack ready to serve.
|
||||
@@ -305,7 +310,7 @@ func (p *Products) reportOutfit(user string, locals []int64) {
|
||||
items := []hub.Item{}
|
||||
for _, local := range locals {
|
||||
if s := p.lookup(local); s != nil {
|
||||
items = append(items, hub.Item{ID: s.pack.Manifest.ID, Name: s.pack.Manifest.Name})
|
||||
items = append(items, hub.Item{ID: s.pack.Manifest.ID, Name: s.pack.Manifest.Name, Adult: s.pack.Manifest.Rating == "adult"})
|
||||
}
|
||||
}
|
||||
p.Observer.Outfit(id, items)
|
||||
|
||||
@@ -67,7 +67,16 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
f.t.Errorf("%s: bad signature", r.URL)
|
||||
}
|
||||
}
|
||||
if participantPath.MatchString(r.URL.Path) {
|
||||
checkSigned(false)
|
||||
f.record("participant", body)
|
||||
return reply(`{"status": "success"}`)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/xmlrpc/chat.php":
|
||||
f.record("chat", body)
|
||||
return reply(string((&xmlrpc.Response{Params: []xmlrpc.Value{xmlrpc.Struct{
|
||||
{Name: "chatId", Value: int64(555)}, {Name: "participantUrl", Value: "https://api.imvu.com/chat/chat-555/participants/user-7"}}}}).Marshal()))
|
||||
case "/api/base_look.php":
|
||||
if len(body) > 0 {
|
||||
checkSigned(true)
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package products
|
||||
|
||||
// The room channel: other T5DE users' community products, on their avatars in this client.
|
||||
//
|
||||
// IMVU never sees community products, so other people's clients get each avatar's outfit without them. The hub
|
||||
// tells the proxy which T5DE users are in the same chat and what they wear (by product ID); for products the proxy
|
||||
// has, it delivers "*use" and "*remove" lines from those users to the client, as if they had sent them, and adds the
|
||||
// products to the whole outfits those users send (which would otherwise put IMVU's products back in their place).
|
||||
// The client then asks for the products like any other, and the proxy answers for them.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||
)
|
||||
|
||||
// room is the room channel's state, under Products.roomMu.
|
||||
type room struct {
|
||||
subs map[*proxy.IMQConn]map[string]bool // chat IDs each IMQ connection is subscribed to
|
||||
rooms map[string]string // chat ID -> room instance, from getOrMakeChat
|
||||
applied map[string]map[int64][]int64 // chat -> user -> local IDs the client was given for them
|
||||
userIDs map[int64]string // user -> the gateway's user_id for them, as last seen
|
||||
missing map[string]bool // products others wear that aren't in the packs (logged once)
|
||||
}
|
||||
|
||||
func (r *room) init() {
|
||||
if r.subs == nil {
|
||||
r.subs = map[*proxy.IMQConn]map[string]bool{}
|
||||
r.rooms = map[string]string{}
|
||||
r.applied = map[string]map[int64][]int64{}
|
||||
r.userIDs = map[int64]string{}
|
||||
r.missing = map[string]bool{}
|
||||
}
|
||||
}
|
||||
|
||||
// chatOf is the chat ID of an IMQ chat queue ("/chat/<id>"), or "".
|
||||
func chatOf(queue string) string {
|
||||
id, ok := strings.CutPrefix(queue, "/chat/")
|
||||
if !ok || id == "" || strings.Trim(id, "0123456789") != "" {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// inChat reports whether any connection is subscribed to chat (under roomMu).
|
||||
func (r *room) inChat(chat string) bool {
|
||||
for _, chats := range r.subs {
|
||||
if chats[chat] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Subscribed implements proxy.IMQWatcher: chats the client joins are reported to the hub.
|
||||
func (p *Products) Subscribed(c *proxy.IMQConn, queues []string) {
|
||||
p.roomMu.Lock()
|
||||
p.room.init()
|
||||
var joined []string
|
||||
var rooms []string
|
||||
for _, q := range queues {
|
||||
chat := chatOf(q)
|
||||
if chat == "" {
|
||||
continue
|
||||
}
|
||||
if !p.room.inChat(chat) {
|
||||
joined, rooms = append(joined, chat), append(rooms, p.room.rooms[chat])
|
||||
}
|
||||
if p.room.subs[c] == nil {
|
||||
p.room.subs[c] = map[string]bool{}
|
||||
}
|
||||
p.room.subs[c][chat] = true
|
||||
}
|
||||
p.roomMu.Unlock()
|
||||
if p.Observer != nil {
|
||||
for i, chat := range joined {
|
||||
p.Observer.Join(chat, rooms[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribed implements proxy.IMQWatcher.
|
||||
func (p *Products) Unsubscribed(c *proxy.IMQConn, queues []string) {
|
||||
p.roomMu.Lock()
|
||||
p.room.init()
|
||||
var left []string
|
||||
for _, q := range queues {
|
||||
if chat := chatOf(q); chat != "" && p.room.subs[c][chat] {
|
||||
delete(p.room.subs[c], chat)
|
||||
if !p.room.inChat(chat) {
|
||||
left = append(left, chat)
|
||||
delete(p.room.applied, chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.roomMu.Unlock()
|
||||
if p.Observer != nil {
|
||||
for _, chat := range left {
|
||||
p.Observer.Leave(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Closed implements proxy.IMQWatcher: a connection's subscriptions end with it.
|
||||
func (p *Products) Closed(c *proxy.IMQConn) {
|
||||
p.roomMu.Lock()
|
||||
p.room.init()
|
||||
var queues []string
|
||||
for chat := range p.room.subs[c] {
|
||||
queues = append(queues, "/chat/"+chat)
|
||||
}
|
||||
p.roomMu.Unlock()
|
||||
p.Unsubscribed(c, queues)
|
||||
p.roomMu.Lock()
|
||||
delete(p.room.subs, c)
|
||||
p.roomMu.Unlock()
|
||||
}
|
||||
|
||||
// userOf is the user ID in the gateway's user_id ("123" or ".../123").
|
||||
func userOf(userID string) int64 {
|
||||
i := strings.LastIndexByte(userID, '/')
|
||||
id, err := strconv.ParseInt(userID[i+1:], 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Incoming implements proxy.IMQWatcher: a T5DE user's whole outfit gets their community products added.
|
||||
func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte {
|
||||
chat := chatOf(m.Queue)
|
||||
if chat == "" || m.Mount != "messages" {
|
||||
return m.Message
|
||||
}
|
||||
user := userOf(m.UserID)
|
||||
if user == 0 {
|
||||
return m.Message
|
||||
}
|
||||
p.roomMu.Lock()
|
||||
p.room.init()
|
||||
p.room.userIDs[user] = m.UserID
|
||||
locals := slices.Clone(p.room.applied[chat][user])
|
||||
p.roomMu.Unlock()
|
||||
if len(locals) == 0 {
|
||||
return m.Message
|
||||
}
|
||||
msg, ok := parseChatMessage(m.Message)
|
||||
if !ok {
|
||||
return m.Message
|
||||
}
|
||||
fields := strings.Fields(msg.text)
|
||||
if len(fields) < 2 || fields[0] != "*use" || !slices.Contains(fields[1:], strconv.Itoa(bodyProduct)) {
|
||||
return m.Message
|
||||
}
|
||||
for _, id := range locals {
|
||||
if s := strconv.FormatInt(id, 10); !slices.Contains(fields, s) {
|
||||
fields = append(fields, s)
|
||||
}
|
||||
}
|
||||
return msg.withText(strings.Join(fields, " "))
|
||||
}
|
||||
|
||||
// Others is the hub's word on the other T5DE users in a chat and what they wear (none: forget them). The client is
|
||||
// given the community products it has packs for, and told to take off the ones no longer worn.
|
||||
func (p *Products) Others(chat string, avatars []hub.Avatar) {
|
||||
type line struct {
|
||||
user int64
|
||||
text string
|
||||
}
|
||||
want := map[int64][]int64{}
|
||||
for _, a := range avatars {
|
||||
var locals []int64
|
||||
for _, it := range a.Items {
|
||||
if s := p.byProductID(it.ID); s != nil {
|
||||
locals = append(locals, s.local)
|
||||
continue
|
||||
}
|
||||
p.roomMu.Lock()
|
||||
p.room.init()
|
||||
first := !p.room.missing[it.ID]
|
||||
p.room.missing[it.ID] = true
|
||||
p.roomMu.Unlock()
|
||||
if first {
|
||||
p.logf("products: someone in the room wears %q (%s), which isn't in your packs", it.Name, it.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(locals)
|
||||
want[a.IMVUID] = locals
|
||||
}
|
||||
|
||||
p.roomMu.Lock()
|
||||
p.room.init()
|
||||
if avatars == nil {
|
||||
delete(p.room.applied, chat)
|
||||
p.roomMu.Unlock()
|
||||
return
|
||||
}
|
||||
had := p.room.applied[chat]
|
||||
var lines []line
|
||||
for user, old := range had {
|
||||
if _, still := want[user]; !still && len(old) > 0 {
|
||||
lines = append(lines, line{user, "*remove " + joinIDs(old)})
|
||||
}
|
||||
}
|
||||
for user, locals := range want {
|
||||
old := had[user]
|
||||
var gone, added []int64
|
||||
for _, id := range old {
|
||||
if !slices.Contains(locals, id) {
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
for _, id := range locals {
|
||||
if !slices.Contains(old, id) {
|
||||
added = append(added, id)
|
||||
}
|
||||
}
|
||||
if len(gone) > 0 {
|
||||
lines = append(lines, line{user, "*remove " + joinIDs(gone)})
|
||||
}
|
||||
if len(added) > 0 {
|
||||
lines = append(lines, line{user, "*use " + joinIDs(added)})
|
||||
}
|
||||
}
|
||||
p.room.applied[chat] = want
|
||||
var conns []*proxy.IMQConn
|
||||
for c, chats := range p.room.subs {
|
||||
if chats[chat] {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
}
|
||||
userIDs := map[int64]string{}
|
||||
for _, l := range lines {
|
||||
if id, ok := p.room.userIDs[l.user]; ok {
|
||||
userIDs[l.user] = id
|
||||
} else {
|
||||
userIDs[l.user] = strconv.FormatInt(l.user, 10)
|
||||
}
|
||||
}
|
||||
p.roomMu.Unlock()
|
||||
|
||||
chatID, _ := strconv.ParseInt(chat, 10, 64)
|
||||
for _, l := range lines {
|
||||
message, _ := json.Marshal(map[string]any{"userId": l.user, "chatId": chatID, "message": l.text, "to": 0})
|
||||
for _, c := range conns {
|
||||
if err := c.Deliver(imq.Delivered{UserID: userIDs[l.user], Queue: "/chat/" + chat, Mount: "messages", Message: message}); err != nil {
|
||||
p.logf("products: giving the client %d's community products: %v", l.user, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
p.logf("products: %d community product changes for others in chat %s", len(lines), chat)
|
||||
}
|
||||
}
|
||||
|
||||
func joinIDs(ids []int64) string {
|
||||
s := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
s[i] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
return strings.Join(s, " ")
|
||||
}
|
||||
|
||||
// interceptChat learns which room a chat is (chat.getOrMakeChat: asked with activity "publicroom-<room>",
|
||||
// answered with the chat ID).
|
||||
func (p *Products) interceptChat(x *proxy.Exchange) error {
|
||||
call, err := xmlrpc.ParseCall(x.Body)
|
||||
if err != nil || call.Method != "chat.getOrMakeChat" || len(call.Params) != 1 {
|
||||
return nil
|
||||
}
|
||||
args, ok := call.Params[0].(xmlrpc.Struct)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
activity, _ := args.Get("activity")
|
||||
a, _ := activity.(string)
|
||||
inst, ok := strings.CutPrefix(a, "publicroom-")
|
||||
if !ok || !roomInstance.MatchString(inst) {
|
||||
return nil
|
||||
}
|
||||
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||
r, err := xmlrpc.ParseResponse(body)
|
||||
if err != nil || r.Fault != nil || len(r.Params) != 1 {
|
||||
return body, nil
|
||||
}
|
||||
res, ok := r.Params[0].(xmlrpc.Struct)
|
||||
if !ok {
|
||||
return body, nil
|
||||
}
|
||||
v, _ := res.Get("chatId")
|
||||
var chat string
|
||||
switch v := v.(type) {
|
||||
case int64:
|
||||
chat = strconv.FormatInt(v, 10)
|
||||
case string:
|
||||
chat = v
|
||||
}
|
||||
if chat != "" && strings.Trim(chat, "0123456789") == "" {
|
||||
p.roomMu.Lock()
|
||||
p.room.init()
|
||||
p.room.rooms[chat] = inst
|
||||
p.roomMu.Unlock()
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var roomInstance = regexp.MustCompile(`^[0-9]{1,12}-[0-9]{1,12}$`)
|
||||
|
||||
// participantPath is where the client posts its outfit and seat to IMVU while in a chat.
|
||||
var participantPath = regexp.MustCompile(`^/chat/chat-[0-9]+/participants/user-[0-9]+$`)
|
||||
|
||||
// interceptParticipant keeps community products out of the outfit the client posts to IMVU's chat participant
|
||||
// endpoint ({"legacy_outfit_message": "*use ...", ...}).
|
||||
func (p *Products) interceptParticipant(x *proxy.Exchange) error {
|
||||
if len(x.Body) == 0 || !bytes.Contains(x.Body, []byte("legacy_outfit_message")) {
|
||||
return nil
|
||||
}
|
||||
var post map[string]json.RawMessage
|
||||
if json.Unmarshal(x.Body, &post) != nil {
|
||||
return nil
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(post["legacy_outfit_message"], &text) != nil {
|
||||
return nil
|
||||
}
|
||||
r := stripCommand(text, IsCommunity)
|
||||
if !r.changed {
|
||||
return nil
|
||||
}
|
||||
if r.empty {
|
||||
delete(post, "legacy_outfit_message")
|
||||
} else {
|
||||
post["legacy_outfit_message"], _ = json.Marshal(r.text)
|
||||
}
|
||||
body, _ := json.Marshal(post)
|
||||
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
||||
return errNoKey
|
||||
}
|
||||
p.logf("products: kept %d community products out of the outfit posted to IMVU's chat", len(r.products))
|
||||
x.Body = body
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/imq"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/xmlrpc"
|
||||
)
|
||||
|
||||
// fakeObserver records what the hub client would be told.
|
||||
type fakeObserver struct {
|
||||
mu sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func (f *fakeObserver) add(e string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.events = append(f.events, e)
|
||||
}
|
||||
func (f *fakeObserver) Session(int64) {}
|
||||
func (f *fakeObserver) Outfit(int64, []hub.Item) {}
|
||||
func (f *fakeObserver) Join(chat, room string) { f.add("join " + chat + " " + room) }
|
||||
func (f *fakeObserver) Leave(chat string) { f.add("leave " + chat) }
|
||||
|
||||
// delivered reads the chat lines delivered to a client: "from: text".
|
||||
func delivered(t *testing.T, b *bytes.Buffer) []string {
|
||||
t.Helper()
|
||||
var lines []string
|
||||
for b.Len() > 0 {
|
||||
f, n, err := imq.Next(b.Bytes())
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("bad frames: %v", err)
|
||||
}
|
||||
b.Next(n)
|
||||
m, err := imq.ParseDelivered(f.Data)
|
||||
if err != nil || m.Queue != "/chat/555" || m.Mount != "messages" {
|
||||
t.Fatalf("delivered %+v %v", m, err)
|
||||
}
|
||||
var msg struct {
|
||||
Message string
|
||||
UserID int64 `json:"userId"`
|
||||
}
|
||||
json.Unmarshal(m.Message, &msg)
|
||||
lines = append(lines, fmt.Sprintf("%s(%d): %s", m.UserID, msg.UserID, msg.Message))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestRoomChannel(t *testing.T) {
|
||||
h, _ := newHarness(t)
|
||||
obs := &fakeObserver{}
|
||||
h.p.Observer = obs
|
||||
local := h.p.byProductID("blue-coat").local
|
||||
|
||||
// Joining a room: getOrMakeChat says which chat it is, then the client subscribes to its queue.
|
||||
h.do("POST", "http://chat.imvu.com/api/xmlrpc/chat.php", call("chat.getOrMakeChat",
|
||||
xmlrpc.Struct{{Name: "userId", Value: int64(7)}, {Name: "activity", Value: "publicroom-12-3"}}), false, false)
|
||||
var toClient bytes.Buffer
|
||||
c := proxy.NewIMQConn(1, &toClient)
|
||||
h.p.Subscribed(c, []string{"/user/7", "/chat/555"})
|
||||
if got := strings.Join(obs.events, "; "); got != "join 555 12-3" {
|
||||
t.Fatalf("observer heard %q", got)
|
||||
}
|
||||
|
||||
// Someone else wears the coat (and something not in the packs): the client is given it from them.
|
||||
h.p.Incoming(c, imq.Delivered{UserID: "/user/88", Queue: "/chat/555", Mount: "messages", Message: []byte(`{"message":"hi","userId":88}`)})
|
||||
h.p.Others("555", []hub.Avatar{{IMVUID: 88, Items: []hub.Item{{ID: "blue-coat", Name: "Coat"}, {ID: "elsewhere", Name: "Hat"}}}})
|
||||
if got := delivered(t, &toClient); len(got) != 1 || got[0] != fmt.Sprintf("/user/88(88): *use %d", local) {
|
||||
t.Fatalf("delivered %q", got)
|
||||
}
|
||||
// Told again with no change: nothing new.
|
||||
h.p.Others("555", []hub.Avatar{{IMVUID: 88, Items: []hub.Item{{ID: "blue-coat", Name: "Coat"}}}})
|
||||
if toClient.Len() != 0 {
|
||||
t.Fatal("delivered again")
|
||||
}
|
||||
|
||||
// Their whole outfit (which IMVU sends without it) gets it added; other lines don't.
|
||||
whole := []byte(`{"message":"*use 80 1 2","userId":88,"chatId":555,"to":0}`)
|
||||
out := h.p.Incoming(c, imq.Delivered{UserID: "/user/88", Queue: "/chat/555", Mount: "messages", Message: whole})
|
||||
if !bytes.Contains(out, []byte(fmt.Sprintf(`"message":"*use 80 1 2 %d"`, local))) || !bytes.Contains(out, []byte(`"chatId":555`)) {
|
||||
t.Fatalf("whole outfit %s", out)
|
||||
}
|
||||
part := []byte(`{"message":"*use 3","userId":88}`)
|
||||
if out := h.p.Incoming(c, imq.Delivered{UserID: "/user/88", Queue: "/chat/555", Mount: "messages", Message: part}); !bytes.Equal(out, part) {
|
||||
t.Fatalf("a single product changed: %s", out)
|
||||
}
|
||||
other := []byte(`{"message":"*use 80 1","userId":99}`)
|
||||
if out := h.p.Incoming(c, imq.Delivered{UserID: "/user/99", Queue: "/chat/555", Mount: "messages", Message: other}); !bytes.Equal(out, other) {
|
||||
t.Fatalf("someone else's outfit changed: %s", out)
|
||||
}
|
||||
|
||||
// They take it off, then leave: the client is told to take it off once.
|
||||
h.p.Others("555", []hub.Avatar{{IMVUID: 88}})
|
||||
h.p.Others("555", []hub.Avatar{})
|
||||
if got := delivered(t, &toClient); len(got) != 1 || got[0] != fmt.Sprintf("/user/88(88): *remove %d", local) {
|
||||
t.Fatalf("delivered %q", got)
|
||||
}
|
||||
|
||||
// The connection closing leaves the chat.
|
||||
h.p.Closed(c)
|
||||
if got := obs.events[len(obs.events)-1]; got != "leave 555" {
|
||||
t.Fatalf("last event %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantPostKeepsCommunityProducts(t *testing.T) {
|
||||
h, _ := newHarness(t)
|
||||
local := h.p.byProductID("blue-coat").local
|
||||
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||
body := []byte(fmt.Sprintf(`{"legacy_outfit_message": "*use 80 1 %d", "seat_number": 2}`, local))
|
||||
if status, _ := h.do("POST", "https://api.imvu.com/chat/chat-555/participants/user-7", body, true, false); status != 200 {
|
||||
t.Fatalf("status %d", status)
|
||||
}
|
||||
got := h.imvu.asked["participant"]
|
||||
if len(got) != 1 || strings.Contains(got[0], fmt.Sprint(local)) || !strings.Contains(got[0], `"legacy_outfit_message":"*use 80 1"`) || !strings.Contains(got[0], `"seat_number":2`) {
|
||||
t.Fatalf("IMVU was sent %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user