CI / build (push) Successful in 44s
Community products are taken out of rooms their owners save and broadcast, kept on the hub per room, and added back when anyone with T5DE loads the room or gets its owner's broadcast; the hub's decor notice resends the room with the new ones. Placements are stored with the hub's product ID. Co-Authored-By: Claude Opus 5.5 <[email protected]>
407 lines
11 KiB
Go
407 lines
11 KiB
Go
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)
|
|
last map[string][]hub.Avatar // chat -> what the hub last said about the others in it
|
|
conns map[*proxy.IMQConn]bool // the client's IMQ connections
|
|
}
|
|
|
|
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{}
|
|
r.last = map[string][]hub.Avatar{}
|
|
r.conns = map[*proxy.IMQConn]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) {
|
|
if p.Hosted != nil {
|
|
p.Hosted.Closed(c)
|
|
}
|
|
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)
|
|
delete(p.room.conns, 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
|
|
}
|
|
if bytes.Contains(m.Message, []byte(setRoomStateCommand)) {
|
|
if msg, ok := parseChatMessage(m.Message); ok && strings.HasPrefix(msg.text, setRoomStateCommand) {
|
|
if out, changed := p.incomingScene(msg); changed {
|
|
return out
|
|
}
|
|
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
|
|
}
|
|
if p.Want != nil {
|
|
p.Want(it.ID) // given to the client when it arrives (Ready)
|
|
}
|
|
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 here yet", 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)
|
|
delete(p.room.last, chat)
|
|
p.roomMu.Unlock()
|
|
return
|
|
}
|
|
p.room.last[chat] = avatars
|
|
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
|
|
}
|
|
|
|
// Opened implements proxy.IMQWatcher.
|
|
func (p *Products) Opened(c *proxy.IMQConn) {
|
|
p.roomMu.Lock()
|
|
defer p.roomMu.Unlock()
|
|
p.room.init()
|
|
p.room.conns[c] = true
|
|
}
|
|
|
|
// RefreshInventory has the client load its inventory again (after the library changed), with the server event
|
|
// IMVU sends after a purchase: "refreshInventory {}" on the user queue's ("/user/<id>") web_msg mount.
|
|
func (p *Products) RefreshInventory() {
|
|
p.mu.Lock()
|
|
user := p.user
|
|
p.mu.Unlock()
|
|
if user == "" {
|
|
return
|
|
}
|
|
p.roomMu.Lock()
|
|
p.room.init()
|
|
var conns []*proxy.IMQConn
|
|
for c := range p.room.conns {
|
|
conns = append(conns, c)
|
|
}
|
|
p.roomMu.Unlock()
|
|
q := "/user/" + user
|
|
for _, c := range conns {
|
|
if err := c.Deliver(imq.Delivered{UserID: "0", Queue: q, Mount: "web_msg", Message: []byte("refreshInventory {}")}); err != nil {
|
|
p.logf("products: refreshing the inventory: %v", err)
|
|
}
|
|
}
|
|
}
|