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 }