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 != "" }