Initial sanitized code sync
This commit is contained in:
184
pkg/chatgpt/account.go
Normal file
184
pkg/chatgpt/account.go
Normal file
@@ -0,0 +1,184 @@
|
||||
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
|
||||
}
|
||||
119
pkg/chatgpt/invite.go
Normal file
119
pkg/chatgpt/invite.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gpt-plus/pkg/httpclient"
|
||||
)
|
||||
|
||||
// InviteResult holds the result of a team member invitation.
|
||||
type InviteResult struct {
|
||||
Email string
|
||||
Success bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// GetWorkspaceAccessToken gets a workspace-scoped access token by calling
|
||||
// GET /api/auth/session with chatgpt-account-id header.
|
||||
// This is simpler than the exchange_workspace_token approach and matches browser behavior.
|
||||
func GetWorkspaceAccessToken(client *httpclient.Client, teamAccountID string) (string, error) {
|
||||
sessionURL := "https://chatgpt.com/api/auth/session"
|
||||
|
||||
headers := map[string]string{
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "application/json",
|
||||
"Origin": chatGPTOrigin,
|
||||
"Referer": chatGPTOrigin + "/",
|
||||
"chatgpt-account-id": teamAccountID,
|
||||
}
|
||||
|
||||
resp, err := client.Get(sessionURL, headers)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace session request: %w", err)
|
||||
}
|
||||
|
||||
body, err := httpclient.ReadBody(resp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read workspace session body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("workspace session returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse workspace session response: %w", err)
|
||||
}
|
||||
|
||||
if result.AccessToken == "" {
|
||||
return "", fmt.Errorf("empty accessToken in workspace session response")
|
||||
}
|
||||
|
||||
log.Printf("[invite] workspace access token obtained for %s, len=%d", teamAccountID, len(result.AccessToken))
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
// InviteToTeam sends a team invitation to the specified email address.
|
||||
// POST /backend-api/accounts/{account_id}/invites
|
||||
func InviteToTeam(client *httpclient.Client, workspaceToken, teamAccountID, deviceID, email string) error {
|
||||
inviteURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites", teamAccountID)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"email_addresses": []string{email},
|
||||
"role": "standard-user",
|
||||
"resend_emails": true,
|
||||
}
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal invite body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", inviteURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create invite request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+workspaceToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Origin", chatGPTOrigin)
|
||||
req.Header.Set("Referer", chatGPTOrigin+"/")
|
||||
req.Header.Set("oai-device-id", deviceID)
|
||||
req.Header.Set("oai-language", defaultLanguage)
|
||||
req.Header.Set("chatgpt-account-id", teamAccountID)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send invite request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := httpclient.ReadBody(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read invite response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("invite returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Verify response contains account_invites
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("parse invite response: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := result["account_invites"]; !ok {
|
||||
return fmt.Errorf("no account_invites in response: %s", string(body))
|
||||
}
|
||||
|
||||
log.Printf("[invite] successfully invited %s to team %s", email, teamAccountID)
|
||||
return nil
|
||||
}
|
||||
472
pkg/chatgpt/plus.go
Normal file
472
pkg/chatgpt/plus.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gpt-plus/config"
|
||||
"gpt-plus/pkg/auth"
|
||||
"gpt-plus/pkg/captcha"
|
||||
"gpt-plus/pkg/httpclient"
|
||||
"gpt-plus/pkg/provider/card"
|
||||
"gpt-plus/pkg/stripe"
|
||||
)
|
||||
|
||||
// ErrPlusNotEligible indicates the account must pay full price ($20) for Plus — no free trial.
|
||||
var ErrPlusNotEligible = errors.New("plus not eligible: full price $20, no free trial")
|
||||
|
||||
// ErrCaptchaRequired indicates the payment triggered an hCaptcha challenge.
|
||||
var ErrCaptchaRequired = errors.New("payment requires hCaptcha challenge")
|
||||
|
||||
const (
|
||||
plusCheckoutStatusPolls = 20
|
||||
plusCheckoutStatusPollDelay = 2 * time.Second
|
||||
plusActivationPolls = 20
|
||||
plusActivationPollDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// ActivationResult holds the outcome of a Plus subscription activation.
|
||||
type ActivationResult struct {
|
||||
StripeSessionID string
|
||||
GUID string
|
||||
MUID string
|
||||
SID string
|
||||
PlanType string
|
||||
}
|
||||
|
||||
// ActivatePlus orchestrates the full Plus subscription activation flow.
|
||||
// Simplified: get card → fingerprint → sentinel → checkout → RunPaymentFlow → verify plan.
|
||||
func ActivatePlus(ctx context.Context, session *Session, sentinel *auth.SentinelGenerator,
|
||||
cardProv card.CardProvider, stripeCfg config.StripeConfig, emailAddr string,
|
||||
solver *captcha.Solver, statusFn StatusFunc, browserFP *stripe.BrowserFingerprint) (*ActivationResult, error) {
|
||||
|
||||
sf := func(format string, args ...interface{}) {
|
||||
if statusFn != nil {
|
||||
statusFn(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Get first card (for checkout session creation — country/currency)
|
||||
sf(" → 获取支付卡片...")
|
||||
cardInfo, err := cardProv.GetCard(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get card: %w", err)
|
||||
}
|
||||
sf(" → 卡片: ...%s (%s)", last4(cardInfo.Number), cardInfo.Country)
|
||||
|
||||
// Step 2: Get Stripe fingerprint (m.stripe.com/6)
|
||||
sf(" → 生成 Stripe 指纹...")
|
||||
sc := stripe.FetchStripeConstants()
|
||||
var fp *stripe.Fingerprint
|
||||
if browserFP != nil {
|
||||
log.Printf("[phase-4] using pooled browser fingerprint: lang=%s", browserFP.Language)
|
||||
fp, err = stripe.GetFingerprint(session.Client, defaultUserAgent, "chatgpt.com", sc.TagVersion, browserFP)
|
||||
} else {
|
||||
log.Printf("[phase-4] no browser fingerprint pool, using auto-generated")
|
||||
fp, err = stripe.GetFingerprintAuto(ctx, session.Client, defaultUserAgent, "chatgpt.com", sc.TagVersion)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get stripe fingerprint: %w", err)
|
||||
}
|
||||
log.Printf("[phase-4] fingerprint: guid=%s, muid=%s, sid=%s", fp.GUID, fp.MUID, fp.SID)
|
||||
|
||||
// Generate per-session stripe_js_id (UUID v4, like stripe.js does)
|
||||
stripeJsID := uuid.New().String()
|
||||
|
||||
// Step 3: Generate sentinel token
|
||||
sf(" → 生成 Sentinel Token...")
|
||||
sentinelToken, err := sentinel.GenerateToken(ctx, nil, "authorize_continue")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate sentinel token: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Create checkout session
|
||||
var checkoutResult *stripe.CheckoutResult
|
||||
if stripeCfg.Aimizy.Enabled {
|
||||
sf(" → 创建 Checkout 会话 (Aimizy)...")
|
||||
checkoutResult, err = stripe.CreateCheckoutViaAimizy(
|
||||
stripeCfg.Aimizy,
|
||||
session.AccessToken,
|
||||
cardInfo.Country,
|
||||
cardInfo.Currency,
|
||||
"chatgptplusplan",
|
||||
"plus-1-month-free",
|
||||
0, // no seats for plus
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aimizy create checkout: %w", err)
|
||||
}
|
||||
} else {
|
||||
sf(" → 创建 Checkout 会话...")
|
||||
checkoutBody := map[string]interface{}{
|
||||
"plan_name": "chatgptplusplan",
|
||||
"promo_campaign": map[string]interface{}{
|
||||
"promo_campaign_id": "plus-1-month-free",
|
||||
"is_coupon_from_query_param": false,
|
||||
},
|
||||
"billing_details": map[string]interface{}{
|
||||
"country": cardInfo.Country,
|
||||
"currency": cardInfo.Currency,
|
||||
},
|
||||
"checkout_ui_mode": "custom",
|
||||
"cancel_url": "https://chatgpt.com/#pricing",
|
||||
}
|
||||
|
||||
checkoutResult, err = stripe.CreateCheckoutSession(
|
||||
session.Client,
|
||||
session.AccessToken,
|
||||
session.DeviceID,
|
||||
sentinelToken,
|
||||
checkoutBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create checkout session: %w", err)
|
||||
}
|
||||
}
|
||||
sf(" → Checkout 会话已创建: %s (金额=%d)", checkoutResult.CheckoutSessionID, checkoutResult.ExpectedAmount)
|
||||
|
||||
// Step 5: Run common payment flow (card retry + captcha)
|
||||
flowResult, err := stripe.RunPaymentFlow(ctx, &stripe.PaymentFlowParams{
|
||||
Client: session.Client,
|
||||
CheckoutResult: checkoutResult,
|
||||
FirstCard: cardInfo,
|
||||
CardProv: cardProv,
|
||||
Solver: solver,
|
||||
StripeCfg: stripeCfg,
|
||||
Fingerprint: fp,
|
||||
EmailAddr: emailAddr,
|
||||
StripeJsID: stripeJsID,
|
||||
UserAgent: defaultUserAgent,
|
||||
StatusFn: sf,
|
||||
MaxRetries: 20,
|
||||
CheckAmountFn: func(amount int) error {
|
||||
if amount >= 2000 {
|
||||
sf(" → 金额 $%.2f (无免费试用),跳过", float64(amount)/100)
|
||||
return ErrPlusNotEligible
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, stripe.ErrNoCaptchaSolver) {
|
||||
return nil, ErrCaptchaRequired
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log successful card
|
||||
logSuccessCard(flowResult.CardInfo, emailAddr)
|
||||
verifyURL := buildPlusVerifyURL(checkoutResult.CheckoutSessionID, checkoutResult.ProcessorEntity)
|
||||
if _, err := runPlusPostPaymentActivation(ctx, session, checkoutResult, verifyURL, sf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 6: Verify account status
|
||||
sf(" → 验证账号状态...")
|
||||
var accountInfo *AccountInfo
|
||||
for attempt := 1; attempt <= 6; attempt++ {
|
||||
accountInfo, err = CheckAccount(session.Client, session.AccessToken, session.DeviceID)
|
||||
if err != nil {
|
||||
log.Printf("[phase-4] attempt %d: check account error: %v", attempt, err)
|
||||
} else if accountInfo.PlanType == "plus" {
|
||||
break
|
||||
} else {
|
||||
sf(" → 验证中 (%d/6): plan=%s,等待...", attempt, accountInfo.PlanType)
|
||||
}
|
||||
if attempt < 6 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
if accountInfo == nil || accountInfo.PlanType != "plus" {
|
||||
planType := "unknown"
|
||||
if accountInfo != nil {
|
||||
planType = accountInfo.PlanType
|
||||
}
|
||||
return nil, fmt.Errorf("plan type still %q after payment, expected plus", planType)
|
||||
}
|
||||
log.Printf("[phase-4] account verified: plan_type=%s", accountInfo.PlanType)
|
||||
|
||||
return &ActivationResult{
|
||||
StripeSessionID: checkoutResult.CheckoutSessionID,
|
||||
GUID: fp.GUID,
|
||||
MUID: fp.MUID,
|
||||
SID: fp.SID,
|
||||
PlanType: accountInfo.PlanType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// logSuccessCardMu protects concurrent writes to success_cards.txt.
|
||||
var logSuccessCardMu sync.Mutex
|
||||
|
||||
// logSuccessCard appends a successful card to output/success_cards.txt for record keeping.
|
||||
func logSuccessCard(cardInfo *card.CardInfo, email string) {
|
||||
logSuccessCardMu.Lock()
|
||||
defer logSuccessCardMu.Unlock()
|
||||
|
||||
os.MkdirAll("output", 0755)
|
||||
f, err := os.OpenFile("output/success_cards.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Printf("[card-log] failed to open success_cards.txt: %v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
line := fmt.Sprintf("%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n",
|
||||
cardInfo.Number, cardInfo.ExpMonth, cardInfo.ExpYear, cardInfo.CVC,
|
||||
cardInfo.Name, cardInfo.Country, cardInfo.Currency,
|
||||
cardInfo.Address, cardInfo.City, cardInfo.State, cardInfo.PostalCode,
|
||||
email)
|
||||
f.WriteString(line)
|
||||
log.Printf("[card-log] recorded success card ...%s for %s", last4(cardInfo.Number), email)
|
||||
}
|
||||
|
||||
// last4 returns the last 4 characters of a string, or the full string if shorter.
|
||||
func last4(s string) string {
|
||||
if len(s) <= 4 {
|
||||
return s
|
||||
}
|
||||
return s[len(s)-4:]
|
||||
}
|
||||
|
||||
type plusCheckoutStatus struct {
|
||||
Status string `json:"status"`
|
||||
PaymentStatus string `json:"payment_status"`
|
||||
RequiresManualApproval bool `json:"requires_manual_approval"`
|
||||
}
|
||||
|
||||
func buildPlusVerifyURL(stripeSessionID, processorEntity string) string {
|
||||
if processorEntity == "" {
|
||||
processorEntity = "openai_llc"
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("stripe_session_id", stripeSessionID)
|
||||
q.Set("processor_entity", processorEntity)
|
||||
q.Set("plan_type", "plus")
|
||||
return chatGPTOrigin + "/checkout/verify?" + q.Encode()
|
||||
}
|
||||
|
||||
func runPlusPostPaymentActivation(ctx context.Context, session *Session,
|
||||
checkoutResult *stripe.CheckoutResult, verifyURL string, statusFn StatusFunc) (*AccountInfo, error) {
|
||||
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Visiting checkout verify page...")
|
||||
}
|
||||
if err := visitPlusVerifyPage(session.Client, checkoutResult.CheckoutSessionID, verifyURL); err != nil {
|
||||
return nil, fmt.Errorf("visit plus verify page: %w", err)
|
||||
}
|
||||
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Waiting for OpenAI checkout status...")
|
||||
}
|
||||
if _, err := waitForPlusCheckoutCompletion(ctx, session.Client, session.AccessToken,
|
||||
session.DeviceID, checkoutResult.CheckoutSessionID, checkoutResult.ProcessorEntity, verifyURL, statusFn); err != nil {
|
||||
return nil, fmt.Errorf("wait for plus checkout completion: %w", err)
|
||||
}
|
||||
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Triggering Plus success data...")
|
||||
}
|
||||
if err := fetchPlusSuccessData(session.Client, checkoutResult.CheckoutSessionID,
|
||||
checkoutResult.ProcessorEntity, verifyURL); err != nil {
|
||||
return nil, fmt.Errorf("fetch plus success data: %w", err)
|
||||
}
|
||||
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Waiting for Plus activation...")
|
||||
}
|
||||
accountInfo, err := waitForPlusActivation(ctx, session.Client, session.AccessToken, session.DeviceID, statusFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return accountInfo, nil
|
||||
}
|
||||
|
||||
func visitPlusVerifyPage(client *httpclient.Client, stripeSessionID, verifyURL string) error {
|
||||
req, err := http.NewRequest("GET", verifyURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build verify request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Referer", fmt.Sprintf("%s/checkout/openai_llc/%s", chatGPTOrigin, stripeSessionID))
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify request: %w", err)
|
||||
}
|
||||
|
||||
body, err := httpclient.ReadBody(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read verify response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("verify page returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
|
||||
}
|
||||
|
||||
log.Printf("[plus] verify page visited (status=%d)", resp.StatusCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForPlusCheckoutCompletion(ctx context.Context, client *httpclient.Client,
|
||||
accessToken, deviceID, stripeSessionID, processorEntity, verifyURL string, statusFn StatusFunc) (*plusCheckoutStatus, error) {
|
||||
|
||||
if processorEntity == "" {
|
||||
processorEntity = "openai_llc"
|
||||
}
|
||||
|
||||
statusURL := fmt.Sprintf("%s/backend-api/payments/checkout/%s/%s", chatGPTOrigin, processorEntity, stripeSessionID)
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + accessToken,
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "application/json",
|
||||
"Origin": chatGPTOrigin,
|
||||
"Referer": verifyURL,
|
||||
"oai-device-id": deviceID,
|
||||
"oai-language": defaultLanguage,
|
||||
}
|
||||
|
||||
var lastResult *plusCheckoutStatus
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= plusCheckoutStatusPolls; attempt++ {
|
||||
resp, err := client.Get(statusURL, headers)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("checkout status request: %w", err)
|
||||
log.Printf("[plus] checkout status attempt %d/%d failed: %v", attempt, plusCheckoutStatusPolls, lastErr)
|
||||
} else {
|
||||
body, readErr := httpclient.ReadBody(resp)
|
||||
if readErr != nil {
|
||||
lastErr = fmt.Errorf("read checkout status body: %w", readErr)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
lastErr = fmt.Errorf("checkout status returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
|
||||
} else {
|
||||
var result plusCheckoutStatus
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
lastErr = fmt.Errorf("parse checkout status response: %w", err)
|
||||
} else {
|
||||
lastResult = &result
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Checkout status %d/%d: status=%s payment_status=%s rma=%v",
|
||||
attempt, plusCheckoutStatusPolls, result.Status, result.PaymentStatus, result.RequiresManualApproval)
|
||||
}
|
||||
if result.RequiresManualApproval {
|
||||
return nil, fmt.Errorf("checkout requires manual approval")
|
||||
}
|
||||
if result.Status == "complete" && result.PaymentStatus == "paid" {
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if attempt < plusCheckoutStatusPolls {
|
||||
if err := sleepContext(ctx, plusCheckoutStatusPollDelay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastResult != nil {
|
||||
return nil, fmt.Errorf("checkout status did not reach complete/paid (status=%q payment_status=%q)",
|
||||
lastResult.Status, lastResult.PaymentStatus)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("checkout status polling exhausted with no result")
|
||||
}
|
||||
|
||||
func fetchPlusSuccessData(client *httpclient.Client, stripeSessionID, processorEntity, verifyURL string) error {
|
||||
if processorEntity == "" {
|
||||
processorEntity = "openai_llc"
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("stripe_session_id", stripeSessionID)
|
||||
q.Set("plan_type", "plus")
|
||||
q.Set("processor_entity", processorEntity)
|
||||
q.Set("_routes", "routes/payments.success")
|
||||
|
||||
successURL := chatGPTOrigin + "/payments/success.data?" + q.Encode()
|
||||
req, err := http.NewRequest("GET", successURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build success.data request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Referer", verifyURL)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("success.data request: %w", err)
|
||||
}
|
||||
|
||||
body, err := httpclient.ReadBody(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read success.data response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("success.data returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("success.data returned empty body")
|
||||
}
|
||||
|
||||
log.Printf("[plus] success.data visited (status=%d, bytes=%d)", resp.StatusCode, len(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForPlusActivation(ctx context.Context, client *httpclient.Client,
|
||||
accessToken, deviceID string, statusFn StatusFunc) (*AccountInfo, error) {
|
||||
|
||||
var lastInfo *AccountInfo
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= plusActivationPolls; attempt++ {
|
||||
accountInfo, err := CheckAccount(client, accessToken, deviceID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
log.Printf("[plus] accounts/check attempt %d/%d failed: %v", attempt, plusActivationPolls, err)
|
||||
} else {
|
||||
lastInfo = accountInfo
|
||||
if plusAccountActivated(accountInfo) {
|
||||
return accountInfo, nil
|
||||
}
|
||||
if statusFn != nil {
|
||||
statusFn(" -> accounts/check %d/%d: plan=%s active=%v subscription_id=%t",
|
||||
attempt, plusActivationPolls, accountInfo.PlanType,
|
||||
accountInfo.HasActiveSubscription, accountInfo.SubscriptionID != "")
|
||||
}
|
||||
lastErr = fmt.Errorf("plus not activated yet")
|
||||
}
|
||||
|
||||
if attempt < plusActivationPolls {
|
||||
if err := sleepContext(ctx, plusActivationPollDelay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastInfo != nil {
|
||||
return nil, fmt.Errorf("plus not activated after %d polls: plan=%q active=%v subscription_id=%q",
|
||||
plusActivationPolls, lastInfo.PlanType, lastInfo.HasActiveSubscription, lastInfo.SubscriptionID)
|
||||
}
|
||||
return nil, fmt.Errorf("plus activation check failed after %d polls: %w", plusActivationPolls, lastErr)
|
||||
}
|
||||
|
||||
func plusAccountActivated(accountInfo *AccountInfo) bool {
|
||||
return accountInfo != nil &&
|
||||
accountInfo.PlanType == "plus" &&
|
||||
accountInfo.HasActiveSubscription &&
|
||||
accountInfo.SubscriptionID != ""
|
||||
}
|
||||
131
pkg/chatgpt/session.go
Normal file
131
pkg/chatgpt/session.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gpt-plus/pkg/httpclient"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||
chatGPTOrigin = "https://chatgpt.com"
|
||||
)
|
||||
|
||||
// defaultLanguage is the package-level language setting used by standalone functions
|
||||
// that don't have access to a Session. Set via SetDefaultLanguage().
|
||||
var defaultLanguage = "en-US"
|
||||
|
||||
// StatusFunc is a callback for reporting progress to the user.
|
||||
type StatusFunc func(format string, args ...interface{})
|
||||
|
||||
// GetDefaultUA returns the default User-Agent string.
|
||||
func GetDefaultUA() string {
|
||||
return defaultUserAgent
|
||||
}
|
||||
|
||||
// SetDefaultLanguage sets the default oai-language for standalone functions (CheckAccount, etc.).
|
||||
func SetDefaultLanguage(lang string) {
|
||||
if lang != "" {
|
||||
defaultLanguage = lang
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultLanguage returns the current default language setting.
|
||||
func GetDefaultLanguage() string {
|
||||
return defaultLanguage
|
||||
}
|
||||
|
||||
// Session holds authentication state for a ChatGPT session.
|
||||
type Session struct {
|
||||
Client *httpclient.Client
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDToken string
|
||||
DeviceID string
|
||||
AccountID string
|
||||
UserID string
|
||||
Language string // browser locale e.g. "en-US" — derived from card country
|
||||
}
|
||||
|
||||
// NewSession creates a new Session with all auth details.
|
||||
func NewSession(client *httpclient.Client, accessToken, refreshToken, idToken, deviceID, accountID, userID string) *Session {
|
||||
return &Session{
|
||||
Client: client,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
IDToken: idToken,
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
UserID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthHeaders returns common headers required for ChatGPT API requests.
|
||||
func (s *Session) AuthHeaders() map[string]string {
|
||||
lang := s.Language
|
||||
if lang == "" {
|
||||
lang = defaultLanguage
|
||||
}
|
||||
return map[string]string{
|
||||
"Authorization": "Bearer " + s.AccessToken,
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "*/*",
|
||||
"Content-Type": "application/json",
|
||||
"Origin": chatGPTOrigin,
|
||||
"Referer": chatGPTOrigin + "/",
|
||||
"oai-device-id": s.DeviceID,
|
||||
"oai-language": lang,
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshSession calls /api/auth/session to get a fresh access token
|
||||
// (reflecting current plan status after Plus/Team activation).
|
||||
func (s *Session) RefreshSession() error {
|
||||
sessionURL := "https://chatgpt.com/api/auth/session"
|
||||
|
||||
headers := map[string]string{
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "application/json",
|
||||
"Origin": chatGPTOrigin,
|
||||
"Referer": chatGPTOrigin + "/",
|
||||
}
|
||||
|
||||
resp, err := s.Client.Get(sessionURL, headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh session request: %w", err)
|
||||
}
|
||||
|
||||
body, err := httpclient.ReadBody(resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read refresh session body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("refresh session returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
IDToken string `json:"idToken"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("parse refresh session response: %w", err)
|
||||
}
|
||||
|
||||
if result.AccessToken != "" {
|
||||
s.AccessToken = result.AccessToken
|
||||
}
|
||||
if result.RefreshToken != "" {
|
||||
s.RefreshToken = result.RefreshToken
|
||||
}
|
||||
if result.IDToken != "" {
|
||||
s.IDToken = result.IDToken
|
||||
}
|
||||
|
||||
log.Printf("[session] tokens refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
711
pkg/chatgpt/team.go
Normal file
711
pkg/chatgpt/team.go
Normal file
@@ -0,0 +1,711 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gpt-plus/config"
|
||||
"gpt-plus/pkg/auth"
|
||||
"gpt-plus/pkg/captcha"
|
||||
"gpt-plus/pkg/httpclient"
|
||||
"gpt-plus/pkg/provider/card"
|
||||
"gpt-plus/pkg/provider/email"
|
||||
"gpt-plus/pkg/stripe"
|
||||
)
|
||||
|
||||
const (
|
||||
couponCheckURL = "https://chatgpt.com/backend-api/promo_campaign/check_coupon"
|
||||
teamStripePollAttempts = 20
|
||||
teamWorkspacePolls = 20
|
||||
teamWorkspacePollDelay = 2 * time.Second
|
||||
teamSuccessPageAttempts = 3
|
||||
)
|
||||
|
||||
// TeamResult holds the outcome of a Team subscription activation.
|
||||
type TeamResult struct {
|
||||
TeamAccountID string
|
||||
WorkspaceToken string
|
||||
StripeSessionID string
|
||||
}
|
||||
|
||||
// couponCheckResponse mirrors the JSON from check_coupon.
|
||||
type couponCheckResponse struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// CheckTeamEligibility checks whether the account is eligible for a team coupon.
|
||||
func CheckTeamEligibility(client *httpclient.Client, accessToken, deviceID, coupon string) (bool, error) {
|
||||
checkURL := fmt.Sprintf("%s?coupon=%s&is_coupon_from_query_param=false", couponCheckURL, coupon)
|
||||
|
||||
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(checkURL, headers)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("coupon check request: %w", err)
|
||||
}
|
||||
|
||||
body, err := httpclient.ReadBody(resp)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read coupon check body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("coupon check returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result couponCheckResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return false, fmt.Errorf("parse coupon check response: %w", err)
|
||||
}
|
||||
|
||||
return result.State == "eligible", nil
|
||||
}
|
||||
|
||||
// ActivateTeam orchestrates the full Team subscription activation flow.
|
||||
func ActivateTeam(ctx context.Context, session *Session, sentinel *auth.SentinelGenerator,
|
||||
cardProv card.CardProvider, stripeCfg config.StripeConfig, teamCfg config.TeamConfig,
|
||||
fingerprint *stripe.Fingerprint, emailAddr string,
|
||||
solver *captcha.Solver,
|
||||
emailProvider email.EmailProvider, mailboxID string,
|
||||
statusFn StatusFunc) (*TeamResult, error) {
|
||||
|
||||
sf := func(format string, args ...interface{}) {
|
||||
if statusFn != nil {
|
||||
statusFn(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Check coupon eligibility.
|
||||
sf(" -> Checking Team coupon eligibility...")
|
||||
eligible, err := CheckTeamEligibility(session.Client, session.AccessToken, session.DeviceID, teamCfg.Coupon)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check team eligibility: %w", err)
|
||||
}
|
||||
if !eligible {
|
||||
return nil, fmt.Errorf("account not eligible for team coupon %q", teamCfg.Coupon)
|
||||
}
|
||||
sf(" -> Team coupon is eligible")
|
||||
|
||||
// Step 2: Generate Sentinel token for checkout creation.
|
||||
sf(" -> Generating Sentinel token...")
|
||||
sentinelToken, err := sentinel.GenerateToken(ctx, nil, "authorize_continue")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate sentinel token: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Fetch the first card before checkout so country/currency match.
|
||||
sf(" -> Fetching payment card...")
|
||||
cardInfo, err := cardProv.GetCard(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get card for team: %w", err)
|
||||
}
|
||||
|
||||
workspaceName := fmt.Sprintf("%s-%s", teamCfg.WorkspacePrefix, randomString(8))
|
||||
sf(" -> Card: ...%s (%s), workspace=%s", last4(cardInfo.Number), cardInfo.Country, workspaceName)
|
||||
|
||||
checkoutBody := map[string]interface{}{
|
||||
"plan_name": "chatgptteamplan",
|
||||
"team_plan_data": map[string]interface{}{
|
||||
"workspace_name": workspaceName,
|
||||
"price_interval": "month",
|
||||
"seat_quantity": teamCfg.SeatQuantity,
|
||||
},
|
||||
"promo_campaign": map[string]interface{}{
|
||||
"promo_campaign_id": teamCfg.Coupon,
|
||||
"is_coupon_from_query_param": false,
|
||||
},
|
||||
"billing_details": map[string]interface{}{
|
||||
"country": cardInfo.Country,
|
||||
"currency": cardInfo.Currency,
|
||||
},
|
||||
"checkout_ui_mode": "custom",
|
||||
"cancel_url": "https://chatgpt.com/#pricing",
|
||||
}
|
||||
|
||||
// Step 4: Create the checkout session.
|
||||
var checkoutResult *stripe.CheckoutResult
|
||||
if stripeCfg.Aimizy.Enabled {
|
||||
sf(" -> Creating Team checkout via Aimizy...")
|
||||
checkoutResult, err = stripe.CreateCheckoutViaAimizy(
|
||||
stripeCfg.Aimizy,
|
||||
session.AccessToken,
|
||||
cardInfo.Country,
|
||||
cardInfo.Currency,
|
||||
"chatgptteamplan",
|
||||
teamCfg.Coupon,
|
||||
teamCfg.SeatQuantity,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aimizy create team checkout: %w", err)
|
||||
}
|
||||
} else {
|
||||
sf(" -> Creating Team checkout...")
|
||||
checkoutResult, err = stripe.CreateCheckoutSession(
|
||||
session.Client,
|
||||
session.AccessToken,
|
||||
session.DeviceID,
|
||||
sentinelToken,
|
||||
checkoutBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create team checkout session: %w", err)
|
||||
}
|
||||
}
|
||||
sf(" -> Checkout created: %s (amount=%d)", checkoutResult.CheckoutSessionID, checkoutResult.ExpectedAmount)
|
||||
|
||||
// Step 5: Complete Stripe payment (card retry + captcha).
|
||||
teamStripeJsID := uuid.New().String()
|
||||
flowResult, err := stripe.RunPaymentFlow(ctx, &stripe.PaymentFlowParams{
|
||||
Client: session.Client,
|
||||
CheckoutResult: checkoutResult,
|
||||
FirstCard: cardInfo,
|
||||
CardProv: cardProv,
|
||||
Solver: solver,
|
||||
StripeCfg: stripeCfg,
|
||||
Fingerprint: fingerprint,
|
||||
EmailAddr: emailAddr,
|
||||
StripeJsID: teamStripeJsID,
|
||||
UserAgent: defaultUserAgent,
|
||||
StatusFn: sf,
|
||||
MaxRetries: 20,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, stripe.ErrNoCaptchaSolver) {
|
||||
return nil, ErrCaptchaRequired
|
||||
}
|
||||
return nil, fmt.Errorf("confirm team payment: %w", err)
|
||||
}
|
||||
|
||||
logSuccessCard(flowResult.CardInfo, emailAddr)
|
||||
|
||||
// Step 6: Wait for Stripe to finish processing the subscription.
|
||||
returnURL := ""
|
||||
if flowResult.ConfirmResult != nil {
|
||||
returnURL = flowResult.ConfirmResult.ReturnURL
|
||||
}
|
||||
|
||||
sf(" -> Waiting for Stripe finalization...")
|
||||
pollResult, err := waitForStripePaymentPageSuccess(ctx, session.Client, checkoutResult, stripeCfg, sf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wait for stripe finalization: %w", err)
|
||||
}
|
||||
if pollResult.ReturnURL != "" {
|
||||
returnURL = pollResult.ReturnURL
|
||||
}
|
||||
|
||||
teamAccountID := accountIDFromReturnURL(returnURL)
|
||||
if teamAccountID != "" {
|
||||
sf(" -> Stripe return URL provided account_id=%s", teamAccountID)
|
||||
} else {
|
||||
log.Printf("[phase-6] no account_id found in Stripe return_url")
|
||||
}
|
||||
|
||||
// Flow C fallback: extract account_id from email only after Stripe is truly finalized.
|
||||
if teamAccountID == "" {
|
||||
if emailProvider != nil && mailboxID != "" {
|
||||
sf(" -> Flow C: waiting for workspace email account_id (up to 90s)...")
|
||||
emailAccountID, emailErr := emailProvider.WaitForTeamAccountID(ctx, mailboxID, 90*time.Second, time.Now().Add(-30*time.Second))
|
||||
if emailErr == nil && emailAccountID != "" {
|
||||
teamAccountID = emailAccountID
|
||||
sf(" -> Flow C: extracted account_id=%s from email", teamAccountID)
|
||||
} else if emailErr != nil {
|
||||
log.Printf("[phase-6] Flow C email extraction failed: %v", emailErr)
|
||||
} else {
|
||||
log.Printf("[phase-6] Flow C did not find account_id in email")
|
||||
}
|
||||
} else {
|
||||
log.Printf("[phase-6] Flow C skipped (no email provider/mailboxID)")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Visit success-team after Stripe itself is finalized.
|
||||
sf(" -> Visiting Team success page...")
|
||||
if err := visitSuccessTeamPageWithRetry(ctx, session.Client,
|
||||
checkoutResult.CheckoutSessionID, teamAccountID, checkoutResult.ProcessorEntity, false); err != nil {
|
||||
return nil, fmt.Errorf("visit success-team page: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Wait for the workspace to actually appear in accounts/check.
|
||||
sf(" -> Waiting for Team workspace to appear in accounts/check...")
|
||||
workspaceAccount, err := waitForTeamWorkspace(ctx, session.Client, session.AccessToken, session.DeviceID, teamAccountID, sf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teamAccountID = workspaceAccount.AccountID
|
||||
teamAccountUserID := workspaceAccount.AccountUserID
|
||||
sf(" -> Team workspace confirmed: %s", teamAccountID)
|
||||
|
||||
// Step 9: Exchange a workspace-scoped token only after the workspace exists.
|
||||
sf(" -> Exchanging workspace token...")
|
||||
workspaceToken, tokenErr := getWorkspaceTokenWithRetry(ctx, session.Client, teamAccountID, sf)
|
||||
if tokenErr != nil {
|
||||
log.Printf("[phase-6] workspace token exchange failed (non-fatal): %v", tokenErr)
|
||||
}
|
||||
|
||||
workspaceAuthToken := session.AccessToken
|
||||
if workspaceToken != "" {
|
||||
workspaceAuthToken = workspaceToken
|
||||
}
|
||||
|
||||
// Step 10: Final server-side subscription check.
|
||||
sf(" -> Verifying Team subscription status...")
|
||||
if err := checkTeamSubscription(session.Client, workspaceAuthToken, session.DeviceID, teamAccountID); err != nil {
|
||||
return nil, fmt.Errorf("verify team subscription: %w", err)
|
||||
}
|
||||
|
||||
// Step 11: Complete workspace onboarding.
|
||||
sf(" -> Finalizing workspace onboarding...")
|
||||
if userID := extractUserID(teamAccountUserID); userID != "" {
|
||||
log.Printf("[phase-6] patching workspace user: %s", userID)
|
||||
if err := patchWorkspaceUser(session.Client, workspaceAuthToken, session.DeviceID, teamAccountID, userID); err != nil {
|
||||
log.Printf("[phase-6] warning: patch workspace user failed: %v (non-fatal)", err)
|
||||
} else {
|
||||
log.Printf("[phase-6] workspace user patched successfully")
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[phase-6] marking onboarding as seen")
|
||||
if err := markOnboardingSeen(session.Client, workspaceAuthToken, session.DeviceID); err != nil {
|
||||
log.Printf("[phase-6] warning: mark onboarding failed: %v (non-fatal)", err)
|
||||
} else {
|
||||
log.Printf("[phase-6] onboarding marked as seen")
|
||||
}
|
||||
|
||||
log.Printf("[phase-6] team activation complete: account=%s", teamAccountID)
|
||||
|
||||
return &TeamResult{
|
||||
TeamAccountID: teamAccountID,
|
||||
WorkspaceToken: workspaceToken,
|
||||
StripeSessionID: checkoutResult.CheckoutSessionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func waitForStripePaymentPageSuccess(ctx context.Context, client *httpclient.Client,
|
||||
checkoutResult *stripe.CheckoutResult, stripeCfg config.StripeConfig, statusFn StatusFunc) (*stripe.PaymentPagePollResult, error) {
|
||||
|
||||
var lastResult *stripe.PaymentPagePollResult
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= teamStripePollAttempts; attempt++ {
|
||||
result, err := stripe.PollPaymentPage(client, &stripe.PaymentPagePollParams{
|
||||
CheckoutSessionID: checkoutResult.CheckoutSessionID,
|
||||
PublishableKey: checkoutResult.PublishableKey,
|
||||
StripeVersion: stripeCfg.StripeVersion,
|
||||
UserAgent: defaultUserAgent,
|
||||
})
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
log.Printf("[phase-6] stripe poll attempt %d/%d failed: %v", attempt, teamStripePollAttempts, err)
|
||||
} else {
|
||||
lastResult = result
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Stripe poll %d/%d: state=%s payment_object_status=%s",
|
||||
attempt, teamStripePollAttempts, result.State, result.PaymentObjectStatus)
|
||||
}
|
||||
if result.State == "succeeded" {
|
||||
return result, nil
|
||||
}
|
||||
if result.State == "failed" || result.PaymentObjectStatus == "failed" {
|
||||
return nil, fmt.Errorf("stripe payment page failed: state=%q payment_object_status=%q",
|
||||
result.State, result.PaymentObjectStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if attempt < teamStripePollAttempts {
|
||||
if err := sleepContext(ctx, time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastResult != nil {
|
||||
return nil, fmt.Errorf("stripe payment page did not reach succeeded (state=%q payment_object_status=%q)",
|
||||
lastResult.State, lastResult.PaymentObjectStatus)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("stripe payment page polling exhausted with no result")
|
||||
}
|
||||
|
||||
func accountIDFromReturnURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Query().Get("account_id")
|
||||
}
|
||||
|
||||
func visitSuccessTeamPageWithRetry(ctx context.Context, client *httpclient.Client,
|
||||
stripeSessionID, accountID, processorEntity string, refreshAccount bool) error {
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= teamSuccessPageAttempts; attempt++ {
|
||||
if err := doVisitSuccessTeamPage(client, stripeSessionID, accountID, processorEntity, refreshAccount); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
log.Printf("[phase-6] success-team attempt %d/%d failed: %v", attempt, teamSuccessPageAttempts, err)
|
||||
}
|
||||
if attempt < teamSuccessPageAttempts {
|
||||
if err := sleepContext(ctx, time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func waitForTeamWorkspace(ctx context.Context, client *httpclient.Client,
|
||||
accessToken, deviceID, preferredAccountID string, statusFn StatusFunc) (*AccountInfo, error) {
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= teamWorkspacePolls; attempt++ {
|
||||
accounts, err := CheckAccountFull(client, accessToken, deviceID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
log.Printf("[phase-6] accounts/check attempt %d/%d failed: %v", attempt, teamWorkspacePolls, err)
|
||||
} else {
|
||||
if acct := selectTeamWorkspace(accounts, preferredAccountID); acct != nil {
|
||||
log.Printf("[phase-6] workspace confirmed: plan=%s, user=%s", acct.PlanType, acct.AccountUserID)
|
||||
return acct, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("workspace not visible yet")
|
||||
if statusFn != nil {
|
||||
statusFn(" -> accounts/check %d/%d: workspace not visible yet", attempt, teamWorkspacePolls)
|
||||
}
|
||||
}
|
||||
|
||||
if attempt < teamWorkspacePolls {
|
||||
if err := sleepContext(ctx, teamWorkspacePollDelay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if preferredAccountID != "" {
|
||||
return nil, fmt.Errorf("team workspace %s not visible after %d polls: %w",
|
||||
preferredAccountID, teamWorkspacePolls, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("team workspace not visible after %d polls: %w", teamWorkspacePolls, lastErr)
|
||||
}
|
||||
|
||||
func selectTeamWorkspace(accounts []*AccountInfo, preferredAccountID string) *AccountInfo {
|
||||
if preferredAccountID != "" {
|
||||
for _, acct := range accounts {
|
||||
if acct.AccountID == preferredAccountID && acct.Structure == "workspace" && acct.PlanType == "team" {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, acct := range accounts {
|
||||
if acct.Structure == "workspace" && acct.PlanType == "team" {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getWorkspaceTokenWithRetry(ctx context.Context, client *httpclient.Client,
|
||||
teamAccountID string, statusFn StatusFunc) (string, error) {
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
token, err := exchangeWorkspaceToken(client, teamAccountID)
|
||||
if err == nil {
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Workspace token exchange succeeded (%d/3)", attempt)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
lastErr = err
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Workspace token exchange %d/3 failed: %v", attempt, err)
|
||||
}
|
||||
if attempt < 3 {
|
||||
if err := sleepContext(ctx, time.Second); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
token, err := GetWorkspaceAccessToken(client, teamAccountID)
|
||||
if err == nil {
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Workspace session token succeeded (%d/3)", attempt)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
lastErr = err
|
||||
if statusFn != nil {
|
||||
statusFn(" -> Workspace session token %d/3 failed: %v", attempt, err)
|
||||
}
|
||||
if attempt < 3 {
|
||||
if err := sleepContext(ctx, time.Second); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// doVisitSuccessTeamPage visits the success-team page after Stripe checkout finalizes.
|
||||
func doVisitSuccessTeamPage(client *httpclient.Client, stripeSessionID, accountID, processorEntity string, refreshAccount bool) error {
|
||||
successURL := fmt.Sprintf(
|
||||
"https://chatgpt.com/payments/success-team?stripe_session_id=%s&processor_entity=%s",
|
||||
stripeSessionID, processorEntity,
|
||||
)
|
||||
if accountID != "" {
|
||||
successURL += "&account_id=" + accountID
|
||||
}
|
||||
if refreshAccount {
|
||||
successURL += "&refresh_account=true"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", successURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build success-team request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Referer", chatGPTOrigin+"/")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("success-team request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read success-team response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("success-team returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
log.Printf("[team] success-team visited (status=%d, account_id=%s, refresh=%v)", resp.StatusCode, accountID, refreshAccount)
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkTeamSubscription(client *httpclient.Client, accessToken, deviceID, accountID string) error {
|
||||
subURL := fmt.Sprintf("https://chatgpt.com/backend-api/subscriptions?account_id=%s", url.QueryEscape(accountID))
|
||||
|
||||
req, err := http.NewRequest("GET", subURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build subscription request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Origin", chatGPTOrigin)
|
||||
req.Header.Set("Referer", chatGPTOrigin+"/")
|
||||
req.Header.Set("oai-device-id", deviceID)
|
||||
req.Header.Set("oai-language", defaultLanguage)
|
||||
req.Header.Set("chatgpt-account-id", accountID)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscription request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read subscription response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("subscription returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
PlanType string `json:"plan_type"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("parse subscription response: %w", err)
|
||||
}
|
||||
if result.PlanType != "team" {
|
||||
return fmt.Errorf("unexpected subscription plan_type=%q", result.PlanType)
|
||||
}
|
||||
|
||||
log.Printf("[team] subscription verified: account=%s plan=%s", accountID, result.PlanType)
|
||||
return nil
|
||||
}
|
||||
|
||||
// patchWorkspaceUser finalizes the owner's onboarding inside the workspace.
|
||||
func patchWorkspaceUser(client *httpclient.Client, accessToken, deviceID, accountID, userID string) error {
|
||||
patchURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/users/%s", accountID, userID)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"onboarding_information": map[string]interface{}{
|
||||
"role": "engineering",
|
||||
"departments": []string{},
|
||||
},
|
||||
}
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal patch payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PATCH", patchURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build patch request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Origin", chatGPTOrigin)
|
||||
req.Header.Set("Referer", chatGPTOrigin+"/")
|
||||
req.Header.Set("oai-device-id", deviceID)
|
||||
req.Header.Set("oai-language", defaultLanguage)
|
||||
req.Header.Set("chatgpt-account-id", accountID)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("patch request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read patch response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("patch returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
log.Printf("[team] patch workspace user response: %s", string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// markOnboardingSeen tells the backend that onboarding has been completed.
|
||||
func markOnboardingSeen(client *httpclient.Client, accessToken, deviceID string) error {
|
||||
onboardURL := "https://chatgpt.com/backend-api/settings/announcement_viewed?announcement_id=oai%2Fapps%2FhasSeenOnboarding"
|
||||
|
||||
req, err := http.NewRequest("POST", onboardURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build onboarding request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
req.Header.Set("Origin", chatGPTOrigin)
|
||||
req.Header.Set("Referer", chatGPTOrigin+"/")
|
||||
req.Header.Set("oai-device-id", deviceID)
|
||||
req.Header.Set("oai-language", defaultLanguage)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("onboarding request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read onboarding response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("onboarding returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
log.Printf("[team] onboarding marked as seen: %s", string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractUserID extracts the short user ID from "user-xxx__account-id".
|
||||
func extractUserID(accountUserID string) string {
|
||||
if idx := strings.Index(accountUserID, "__"); idx > 0 {
|
||||
return accountUserID[:idx]
|
||||
}
|
||||
return accountUserID
|
||||
}
|
||||
|
||||
// exchangeWorkspaceToken gets a workspace-scoped JWT via the NextAuth cookie-authenticated endpoint.
|
||||
func exchangeWorkspaceToken(client *httpclient.Client, teamAccountID string) (string, error) {
|
||||
exchangeURL := fmt.Sprintf(
|
||||
"https://chatgpt.com/api/auth/session?exchange_workspace_token=true&workspace_id=%s&reason=setCurrentAccountWithoutRedirect",
|
||||
teamAccountID,
|
||||
)
|
||||
|
||||
headers := map[string]string{
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "application/json",
|
||||
"Origin": chatGPTOrigin,
|
||||
"Referer": chatGPTOrigin + "/",
|
||||
}
|
||||
|
||||
resp, err := client.Get(exchangeURL, headers)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace token request: %w", err)
|
||||
}
|
||||
|
||||
body, err := httpclient.ReadBody(resp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read workspace token body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("workspace token exchange returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse workspace token response: %w", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
log.Printf("[team] workspace token exchange response body: %s", string(body))
|
||||
return "", fmt.Errorf("empty access token in workspace exchange response")
|
||||
}
|
||||
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
// randomString generates a random alphanumeric string of the given length.
|
||||
func randomString(n int) string {
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rng.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
30
pkg/chatgpt/types.go
Normal file
30
pkg/chatgpt/types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package chatgpt
|
||||
|
||||
// AccountResult is the output data for a fully provisioned account.
|
||||
type AccountResult struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
ChatGPTAccountID string `json:"chatgpt_account_id"`
|
||||
ChatGPTUserID string `json:"chatgpt_user_id"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
|
||||
// Plus info
|
||||
PlanType string `json:"plan_type"`
|
||||
StripeSessionID string `json:"stripe_session_id"`
|
||||
|
||||
// Team info (if Team was activated)
|
||||
TeamAccountID string `json:"team_account_id,omitempty"`
|
||||
WorkspaceToken string `json:"workspace_token,omitempty"`
|
||||
|
||||
// Stripe fingerprint (reusable)
|
||||
GUID string `json:"guid"`
|
||||
MUID string `json:"muid"`
|
||||
SID string `json:"sid"`
|
||||
|
||||
// Meta
|
||||
CreatedAt string `json:"created_at"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user