473 lines
15 KiB
Go
473 lines
15 KiB
Go
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 != ""
|
||
}
|