Files
gpt-plus-gpt/pkg/chatgpt/account.go
2026-03-15 20:48:19 +08:00

185 lines
5.7 KiB
Go

package chatgpt
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"gpt-plus/pkg/httpclient"
)
const accountCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
// AccountInfo holds parsed account data from the accounts/check endpoint.
type AccountInfo struct {
AccountID string
AccountUserID string // e.g. "user-xxx__account-id"
PlanType string // "free", "plus", "team"
OrganizationID string
IsTeam bool
Structure string // "personal" or "workspace"
// Entitlement reflects whether OpenAI has actually activated the subscription.
HasActiveSubscription bool
SubscriptionID string
// EligiblePromos contains promo campaign IDs keyed by plan (e.g. "plus", "team").
EligiblePromos map[string]string
}
// accountCheckResponse mirrors the JSON response from accounts/check.
type accountCheckResponse struct {
Accounts map[string]accountEntry `json:"accounts"`
}
type promoEntry struct {
ID string `json:"id"`
}
type accountEntry struct {
Account struct {
AccountID string `json:"account_id"`
AccountUserID string `json:"account_user_id"`
PlanType string `json:"plan_type"`
IsDeactivated bool `json:"is_deactivated"`
Structure string `json:"structure"`
} `json:"account"`
Entitlement struct {
SubscriptionID string `json:"subscription_id"`
HasActiveSubscription bool `json:"has_active_subscription"`
} `json:"entitlement"`
Features []string `json:"features"`
EligiblePromoCampaigns map[string]promoEntry `json:"eligible_promo_campaigns"`
}
// CheckAccount queries the ChatGPT account status and returns parsed account info.
func CheckAccount(client *httpclient.Client, accessToken, deviceID string) (*AccountInfo, error) {
headers := map[string]string{
"Authorization": "Bearer " + accessToken,
"User-Agent": defaultUserAgent,
"Accept": "*/*",
"Origin": chatGPTOrigin,
"Referer": chatGPTOrigin + "/",
"oai-device-id": deviceID,
"oai-language": defaultLanguage,
}
resp, err := client.Get(accountCheckURL, headers)
if err != nil {
return nil, fmt.Errorf("account check request: %w", err)
}
body, err := httpclient.ReadBody(resp)
if err != nil {
return nil, fmt.Errorf("read account check body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("account check returned %d: %s", resp.StatusCode, string(body))
}
log.Printf("[account] check response: %s", string(body)[:min(len(body), 500)])
var result accountCheckResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse account check response: %w", err)
}
// Flatten all accounts, then pick the best one (personal > workspace)
var allAccounts []*AccountInfo
for _, entry := range result.Accounts {
promos := make(map[string]string)
for key, promo := range entry.EligiblePromoCampaigns {
promos[key] = promo.ID
}
allAccounts = append(allAccounts, &AccountInfo{
AccountID: entry.Account.AccountID,
AccountUserID: entry.Account.AccountUserID,
PlanType: entry.Account.PlanType,
Structure: entry.Account.Structure,
IsTeam: entry.Account.Structure == "workspace",
HasActiveSubscription: entry.Entitlement.HasActiveSubscription,
SubscriptionID: entry.Entitlement.SubscriptionID,
EligiblePromos: promos,
})
}
if len(allAccounts) == 0 {
return nil, fmt.Errorf("no accounts found in response")
}
// Sort by AccountID for deterministic ordering (eliminates map iteration randomness)
sort.Slice(allAccounts, func(i, j int) bool {
return allAccounts[i].AccountID < allAccounts[j].AccountID
})
// Prefer personal account over workspace
for _, acct := range allAccounts {
if acct.Structure == "personal" {
return acct, nil
}
}
return allAccounts[0], nil
}
// CheckAccountFull returns all account entries (useful for finding team accounts).
func CheckAccountFull(client *httpclient.Client, accessToken, deviceID string) ([]*AccountInfo, error) {
headers := map[string]string{
"Authorization": "Bearer " + accessToken,
"User-Agent": defaultUserAgent,
"Accept": "*/*",
"Origin": chatGPTOrigin,
"Referer": chatGPTOrigin + "/",
"oai-device-id": deviceID,
"oai-language": defaultLanguage,
}
resp, err := client.Get(accountCheckURL, headers)
if err != nil {
return nil, fmt.Errorf("account check request: %w", err)
}
body, err := httpclient.ReadBody(resp)
if err != nil {
return nil, fmt.Errorf("read account check body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("account check returned %d: %s", resp.StatusCode, string(body))
}
var result accountCheckResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse account check response: %w", err)
}
var accounts []*AccountInfo
for _, entry := range result.Accounts {
promos := make(map[string]string)
for key, promo := range entry.EligiblePromoCampaigns {
promos[key] = promo.ID
}
info := &AccountInfo{
AccountID: entry.Account.AccountID,
AccountUserID: entry.Account.AccountUserID,
PlanType: entry.Account.PlanType,
Structure: entry.Account.Structure,
IsTeam: entry.Account.Structure == "workspace",
HasActiveSubscription: entry.Entitlement.HasActiveSubscription,
SubscriptionID: entry.Entitlement.SubscriptionID,
EligiblePromos: promos,
}
accounts = append(accounts, info)
}
if len(accounts) == 0 {
return nil, fmt.Errorf("no accounts found in response")
}
sort.Slice(accounts, func(i, j int) bool {
return accounts[i].AccountID < accounts[j].AccountID
})
return accounts, nil
}