更新缩略图

This commit is contained in:
2026-07-03 00:15:41 +08:00
parent 7bb0531c4b
commit 273892f98f
15 changed files with 416 additions and 61 deletions
+94 -14
View File
@@ -153,6 +153,14 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
used = fullCredits
}
remaining := fullCredits - used
// 恢复时间: prefer the subscription's billing-period end (when the plan renews
// and credits reset) over the credits-config timestamp. Free accounts have no
// subscription, so this falls back to the credits-config reset above.
sub, _ := c.FetchSubscription(ctx, token)
if sub != nil && strings.TrimSpace(sub.BillingPeriodEnd) != "" {
reset = strings.TrimSpace(sub.BillingPeriodEnd)
}
return map[string]any{
"remaining": remaining,
"used": used,
@@ -163,6 +171,78 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
}, nil
}
// Subscription is the membership view parsed from GET /rest/subscriptions.
type Subscription struct {
Member bool // an active subscription exists
Tier string // e.g. SUBSCRIPTION_TIER_GROK_PRO ("" for free)
Status string // e.g. SUBSCRIPTION_STATUS_ACTIVE
BillingPeriodEnd string // RFC3339; when the plan renews / credits reset
FreeTrial bool // currently in a free-trial offer
}
// FetchSubscription reads GET /rest/subscriptions and reports the account's
// membership. An empty subscriptions array means a free account (Member=false).
// A 401/403 maps to ErrAuth; other transport/HTTP errors are returned so callers
// can treat them as best-effort (they already have the credit balance).
func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscription, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, ErrAuth
}
client, err := c.newTLSClient()
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, nil)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrAuth
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%w: subscriptions http %d", ErrTemporaryUpstream, resp.StatusCode)
}
var body struct {
Subscriptions []struct {
Tier string `json:"tier"`
Status string `json:"status"`
BillingPeriodEnd string `json:"billingPeriodEnd"`
ActiveOffer struct {
FreeTrial *struct {
TrialDays int `json:"trialDays"`
} `json:"freeTrial"`
} `json:"activeOffer"`
} `json:"subscriptions"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil, fmt.Errorf("%w: subscriptions non-json", ErrTemporaryUpstream)
}
out := &Subscription{}
// Pick the active subscription (fall back to the first entry) as the membership.
for i, s := range body.Subscriptions {
if i == 0 || strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE") {
out.Member = true
out.Tier = strings.TrimSpace(s.Tier)
out.Status = strings.TrimSpace(s.Status)
out.BillingPeriodEnd = strings.TrimSpace(s.BillingPeriodEnd)
out.FreeTrial = s.ActiveOffer.FreeTrial != nil
if strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE") {
break
}
}
}
return out, nil
}
// FetchSession reads the account profile via GET /api/auth/session and returns
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
@@ -271,21 +351,21 @@ func statsigID(path, method string) string {
// extra overrides/adds per-request headers (e.g. content-type).
func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]string) {
h := http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/json"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {userAgent},
"x-statsig-id": {statsigID(req.URL.Path, req.Method)},
"x-xai-request-id": {uuid.NewString()},
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
"sec-ch-ua-mobile": {"?0"},
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/json"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {userAgent},
"x-statsig-id": {statsigID(req.URL.Path, req.Method)},
"x-xai-request-id": {uuid.NewString()},
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
"cookie": {"sso=" + token + "; sso-rw=" + token},
}
for k, v := range extra {
h[k] = []string{v}
+16 -3
View File
@@ -331,9 +331,10 @@ func mapStatus(path string, status int, raw []byte) error {
switch {
case status == 200:
return nil
case status == 403 && strings.Contains(strings.ToLower(string(raw)), "anti-bot"):
// grok bot-detection (proxy/TLS fingerprint), NOT a dead token — transient,
// so a good account isn't killed by an IP/anti-bot hiccup.
case status == 403 && isBotChallenge(string(raw)):
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
// NOT a dead token — transient, so a good account isn't killed by an
// IP/anti-bot hiccup.
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
case status == 401 || status == 403:
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
@@ -355,6 +356,18 @@ func mapStatus(path string, status int, raw []byte) error {
}
}
// isBotChallenge reports whether a 403 body is an anti-bot interstitial rather
// than a real auth rejection: grok's own "anti-bot" marker or a Cloudflare
// challenge page ("Just a moment…" / cf-chl / challenge-platform).
func isBotChallenge(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "anti-bot") ||
strings.Contains(s, "just a moment") ||
strings.Contains(s, "cf-chl") ||
strings.Contains(s, "challenge-platform") ||
strings.Contains(s, "cf_chl")
}
func isCreditError(s string) bool {
s = strings.ToLower(s)
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")