185 lines
5.7 KiB
Go
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
|
|
}
|