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

473 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 != ""
}