Manage T5DE rooms from the client; the AP+ badge on avatar cards
CI / build (push) Successful in 28s

Chat Rooms > Manage (the t5de-room-manage patch) lists the person's T5DE rooms
(room_management_info.php?t5de=1) and saves them from IMVU's room dialog (room_management.php: create
with t5de=1, edit and delete naming a hosted room) as hub calls: the room, its moderators by name, and a
picture from the person's IMVU gallery, uploaded to the hub. The hub's refusals show in the dialog
(hub.Error keeps their message).

Avatar cards get show_ap_plus again, which IMVU stopped sending, from the person's public IMVU profile
(is_ap_plus).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 08:19:33 -07:00
1 parent 6aac769246
commit 8daf91ede3
7 files changed
+608 -2

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
// Package cards fixes what IMVU's servers no longer send the client's avatar cards: the AP+ badge. The card shows it
// when avatarcard.php's answer has show_ap_plus, which IMVU stopped sending; whether someone has AP+ is public on
// their IMVU profile (api.imvu.com/user/user-<id>, is_ap_plus), as the friends list and Find People still show.
package cards
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
// keep is how long an account's AP+ is remembered.
const keep = 10 * time.Minute
// Cards adds show_ap_plus to avatar cards of people with AP+.
type Cards struct {
// Client fetches IMVU's public profiles (nil: http.DefaultClient).
Client *http.Client
// Base is IMVU's API ("" for https://api.imvu.com).
Base string
mu sync.Mutex
known map[int64]apPlus
}
type apPlus struct {
has bool
at time.Time
}
func (c *Cards) Intercept(x *proxy.Exchange) error {
if !strings.EqualFold(x.URL.Hostname(), "client-dynamic.imvu.com") || x.URL.Path != "/api/avatarcard.php" {
return nil
}
cid, err := strconv.ParseInt(x.URL.Query().Get("cid"), 10, 64)
if err != nil || cid <= 0 {
return nil
}
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
var card map[string]json.RawMessage
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &card) != nil || set(card["show_ap_plus"]) {
return body, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !c.has(ctx, cid) {
return body, nil
}
card["show_ap_plus"] = json.RawMessage("1")
return json.Marshal(card)
}
return nil
}
// set reports whether a JSON value counts as set in the card's JavaScript (truthy).
func set(v json.RawMessage) bool {
s := strings.TrimSpace(string(v))
return s != "" && s != "null" && s != "false" && s != "0" && s != `""` && s != `"0"`
}
// has reports whether an account has AP+ (false if IMVU can't say).
func (c *Cards) has(ctx context.Context, id int64) bool {
c.mu.Lock()
k, ok := c.known[id]
c.mu.Unlock()
if ok && time.Since(k.at) < keep {
return k.has
}
base := c.Base
if base == "" {
base = "https://api.imvu.com"
}
u := fmt.Sprintf("%s/user/user-%d", base, id)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return false
}
client := c.Client
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return k.has
}
defer resp.Body.Close()
var profile struct {
Denormalized map[string]struct {
Data struct {
IsAPPlus bool `json:"is_ap_plus"`
} `json:"data"`
} `json:"denormalized"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&profile) != nil {
return k.has
}
has := profile.Denormalized[fmt.Sprintf("https://api.imvu.com/user/user-%d", id)].Data.IsAPPlus
c.mu.Lock()
if c.known == nil {
c.known = map[int64]apPlus{}
}
c.known[id] = apPlus{has: has, at: time.Now()}
c.mu.Unlock()
return has
}
+50
View File
@@ -0,0 +1,50 @@
package cards
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
)
func TestAPPlusBadge(t *testing.T) {
asked := 0
imvu := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked++
id := strings.TrimPrefix(r.URL.Path, "/user/user-")
fmt.Fprintf(w, `{"denormalized": {"https://api.imvu.com/user/user-%s": {"data": {"is_ap_plus": %v}}}}`, id, id == "5")
}))
defer imvu.Close()
c := &Cards{Base: imvu.URL}
card := func(cid, body string) string {
t.Helper()
u, _ := url.Parse("https://client-dynamic.imvu.com/api/avatarcard.php?cid=" + cid + "&viewer_cid=1")
x := &proxy.Exchange{URL: u}
if err := c.Intercept(x); err != nil || x.ModifyResponse == nil {
t.Fatalf("not intercepted: %v", err)
}
out, err := x.ModifyResponse(&http.Response{StatusCode: 200}, []byte(body))
if err != nil {
t.Fatal(err)
}
return string(out)
}
if out := card("5", `{"avatarname": "Ann", "show_ap": 1}`); !strings.Contains(out, `"show_ap_plus":1`) || !strings.Contains(out, `"avatarname":"Ann"`) {
t.Fatalf("AP+ card %s", out)
}
if out := card("6", `{"avatarname": "Bo"}`); strings.Contains(out, "show_ap_plus") {
t.Fatalf("no AP+ %s", out)
}
card("5", `{}`)
if asked != 2 {
t.Fatalf("IMVU asked %d times (kept for a while)", asked)
}
// Already set by IMVU: left alone, IMVU not asked.
if out := card("7", `{"show_ap_plus": true}`); out != `{"show_ap_plus": true}` || asked != 2 {
t.Fatalf("set already %s (%d)", out, asked)
}
}