From 455dae10135338402369929021e982c8a3efb7d7 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Fri, 30 Oct 2026 02:52:55 -0700 Subject: [PATCH] Answer the client's T5 badge lookups POST http://t5de-proxy.local/badges {imvuIds} -> {badges}: which IMVU accounts show the T5 badge, asked of the hub (POST /api/v1/badges, in batches of 200) and remembered for 10 minutes. Co-Authored-By: Claude Opus 5.5 --- internal/control/control.go | 86 +++++++++++++++++++++++++++++++- internal/control/control_test.go | 45 +++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/internal/control/control.go b/internal/control/control.go index a947311..6d652e7 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -5,6 +5,8 @@ // GET /hub {signedIn, hub, username, signingIn, code, page, error} // POST /hub/signin starts signing in (hub= optional): opens the hub's page for the code in the browser // POST /hub/signout signs this computer out +// POST /badges {"imvuIds": [...]} -> {"badges": [...]}: which of those IMVU accounts show the T5 badge (their +// T5DE users opted in), from the hub, remembered for a while package control import ( @@ -16,6 +18,7 @@ import ( "os" "strings" "sync" + "time" "git.t5de.dev/t5de/t5de-proxy/internal/hub" "git.t5de.dev/t5de/t5de-proxy/internal/proxy" @@ -41,6 +44,70 @@ type Manager struct { username string lastHub string // the hub last signed in to, for signing in again after signing out signin *signin + badges map[int64]badgeAnswer +} + +// badgeAnswer is whether an IMVU account shows the T5 badge, as the hub said at a time. +type badgeAnswer struct { + shows bool + at time.Time +} + +// badgeFor is how long an answer about a badge is kept. +var badgeFor = 10 * time.Minute + +// Badges is which of the IMVU accounts show the T5 badge: remembered answers, and the hub asked about the rest. +// Signed out, none do. +func (m *Manager) Badges(ctx context.Context, ids []int64) []int64 { + m.mu.Lock() + c := m.client + if m.badges == nil { + m.badges = map[int64]badgeAnswer{} + } + var out, ask []int64 + seen := map[int64]bool{} + for _, id := range ids { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + if a, ok := m.badges[id]; ok && time.Since(a.at) < badgeFor { + if a.shows { + out = append(out, id) + } + } else { + ask = append(ask, id) + } + } + m.mu.Unlock() + if c == nil || len(ask) == 0 { + return out + } + for len(ask) > 0 { + batch := ask[:min(len(ask), 200)] + ask = ask[len(batch):] + var r struct { + Badges []int64 `json:"badges"` + } + if err := c.Call(ctx, "POST", "/api/v1/badges", map[string]any{"imvuIds": batch}, &r); err != nil { + m.Log.Printf("hub: badges: %v", err) + return out + } + shows := map[int64]bool{} + for _, id := range r.Badges { + shows[id] = true + } + now := time.Now() + m.mu.Lock() + for _, id := range batch { + m.badges[id] = badgeAnswer{shows[id], now} + if shows[id] { + out = append(out, id) + } + } + m.mu.Unlock() + } + return out } type signin struct { @@ -166,7 +233,7 @@ func (m *Manager) SignOut() { if m.saved != nil { m.lastHub = m.saved.URL } - m.client, m.saved, m.stop, m.username, m.signin = nil, nil, nil, "", nil + m.client, m.saved, m.stop, m.username, m.signin, m.badges = nil, nil, nil, "", nil, nil m.mu.Unlock() if c != nil { c.Call(context.Background(), "POST", "/api/auth/sign-out", map[string]any{}, nil) @@ -201,6 +268,23 @@ func (m *Manager) Intercept(x *proxy.Exchange) error { m.SignIn(hubURL) case x.URL.Path == "/hub/signout" && method == "POST": m.SignOut() + case x.URL.Path == "/badges" && method == "POST": + var req struct { + IDs []int64 `json:"imvuIds"` + } + if json.Unmarshal(x.Body, &req) != nil || len(req.IDs) > 1000 { + x.Response = proxy.LocalResponse(400, "application/json", []byte(`{"error":"imvuIds: up to 1000 IMVU account IDs"}`)) + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + badges := m.Badges(ctx, req.IDs) + if badges == nil { + badges = []int64{} + } + b, _ := json.Marshal(map[string]any{"badges": badges}) + x.Response = proxy.LocalResponse(200, "application/json", b) + return nil default: x.Response = proxy.LocalResponse(404, "application/json", []byte(`{"error":"not found"}`)) return nil diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 60bccfa..9c0ec2e 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -18,6 +18,8 @@ import ( "git.t5de.dev/t5de/t5de-proxy/internal/proxy" ) +var badgeAsks atomic.Int32 + // fakeHub does device sign-in (approved on the second poll), /me and sign-out. func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server { var polls atomic.Int32 @@ -38,6 +40,17 @@ func fakeHub(t *testing.T, signedOut *atomic.Bool) *httptest.Server { return } io.WriteString(w, `{"person": {"username": "erin"}}`) + case "/api/v1/badges": + var req struct{ ImvuIds []int64 } + json.NewDecoder(r.Body).Decode(&req) + badgeAsks.Add(1) + var even []int64 + for _, id := range req.ImvuIds { + if id%2 == 0 { + even = append(even, id) + } + } + json.NewEncoder(w).Encode(map[string]any{"badges": even}) case "/api/auth/sign-out": signedOut.Store(r.Header.Get("Authorization") == "Bearer session-token") io.WriteString(w, `{"success": true}`) @@ -137,3 +150,35 @@ func TestSignInAndOut(t *testing.T) { t.Fatal("answered another host") } } + +func TestBadges(t *testing.T) { + var signedOut atomic.Bool + srv := fakeHub(t, &signedOut) + m := &Manager{Path: filepath.Join(t.TempDir(), "hub.json"), DefaultHub: srv.URL, Log: log.New(io.Discard, "", 0)} + badges := func(body string) string { + t.Helper() + u, _ := url.Parse("http://" + Host + "/badges") + req, _ := http.NewRequest("POST", u.String(), strings.NewReader(body)) + x := &proxy.Exchange{URL: u, Request: req, Body: []byte(body)} + m.Intercept(x) + b, _ := io.ReadAll(x.Response.Body) + return strings.TrimSpace(string(b)) + } + // Signed out: nobody. + if got := badges(`{"imvuIds": [2, 3]}`); got != `{"badges":[]}` { + t.Fatalf("signed out: %s", got) + } + saved := &hub.Saved{URL: srv.URL, Token: "session-token"} + m.run(saved) + badgeAsks.Store(0) + if got := badges(`{"imvuIds": [2, 3, 4, 3]}`); got != `{"badges":[2,4]}` { + t.Fatalf("badges %s", got) + } + // Answers are remembered: only new IDs are asked about. + if got := badges(`{"imvuIds": [4, 3, 6]}`); got != `{"badges":[4,6]}` || badgeAsks.Load() != 2 { + t.Fatalf("badges %s after %d asks", got, badgeAsks.Load()) + } + if got := badges(`{"imvuIds": "x"}`); !strings.Contains(got, "error") { + t.Fatalf("bad request: %s", got) + } +}