712 lines
22 KiB
Go
712 lines
22 KiB
Go
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)
|
|
}
|