// Package imvu reads IMVU's public user API: who an account is, its tagline (for linking) and whether it's age // verified or has Access Pass. The hub calls it rarely (linking, and age checks at most daily) and never depends on // it for anything else. package imvu import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // User is an IMVU account, as the public API shows it. type User struct { ID int64 Username string Tagline string AgeVerified bool AccessPass bool } // ErrNoSuchUser means IMVU has no account by that name or ID. var ErrNoSuchUser = errors.New("no such IMVU account") // Client reads IMVU's API. type Client struct { // Base is the API's address; tests point it elsewhere. Base string HTTP *http.Client mu sync.Mutex last time.Time } // New is a client for api.imvu.com. func New() *Client { return &Client{Base: "https://api.imvu.com", HTTP: &http.Client{Timeout: 15 * time.Second}} } // minInterval spaces out calls to IMVU. const minInterval = 200 * time.Millisecond func (c *Client) wait(ctx context.Context) error { c.mu.Lock() next := c.last.Add(minInterval) now := time.Now() if next.Before(now) { next = now } c.last = next c.mu.Unlock() select { case <-time.After(time.Until(next)): return nil case <-ctx.Done(): return ctx.Err() } } // ByUsername looks an account up by name. func (c *Client) ByUsername(ctx context.Context, username string) (*User, error) { username = strings.TrimSpace(username) if username == "" || len(username) > 64 { return nil, ErrNoSuchUser } return c.get(ctx, "/user?username="+url.QueryEscape(username)) } // ByID reads an account (fresh, for checks). func (c *Client) ByID(ctx context.Context, id int64) (*User, error) { return c.get(ctx, "/user/user-"+strconv.FormatInt(id, 10)) } func (c *Client) get(ctx context.Context, path string) (*User, error) { if err := c.wait(ctx); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "GET", c.Base+path, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)") resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode == http.StatusNotFound { return nil, ErrNoSuchUser } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("IMVU answered %d", resp.StatusCode) } return parse(body) } // parse reads IMVU's "denormalized" answer: the user is the entry the answer's id points to, or the only user in it. func parse(body []byte) (*User, error) { var answer struct { Status string `json:"status"` ID string `json:"id"` Denormalized map[string]struct { Data json.RawMessage `json:"data"` } `json:"denormalized"` } if err := json.Unmarshal(body, &answer); err != nil { return nil, fmt.Errorf("IMVU's answer: %w", err) } var found []json.RawMessage for key, entry := range answer.Denormalized { if strings.Contains(key, "/user/user-") { found = append(found, entry.Data) } } if len(found) != 1 { return nil, ErrNoSuchUser } var data struct { LegacyCID json.Number `json:"legacy_cid"` Username string `json:"username"` Tagline string `json:"tagline"` AgeVerified bool `json:"is_ageverified"` AP bool `json:"is_ap"` } if err := json.Unmarshal(found[0], &data); err != nil { return nil, fmt.Errorf("IMVU's user: %w", err) } id, err := data.LegacyCID.Int64() if err != nil || id <= 0 || data.Username == "" { return nil, ErrNoSuchUser } return &User{ID: id, Username: data.Username, Tagline: data.Tagline, AgeVerified: data.AgeVerified, AccessPass: data.AP}, nil }