Answer the client's T5 badge lookups
CI / build (push) Successful in 29s

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 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 02:52:55 -07:00
1 parent 171e8173af
commit 455dae1013
2 files changed
+130 -1

No files matched your search

+85 -1
View File
@@ -5,6 +5,8 @@
// GET /hub {signedIn, hub, username, signingIn, code, page, error}
// POST /hub/signin starts signing in (hub=<URL> 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