| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- package service
- import (
- "context"
- "fmt"
- "log/slog"
- "strings"
- "time"
- "github.com/imroc/req/v3"
- )
- // PrivacyClientFactory creates an HTTP client for privacy API calls.
- // Injected from repository layer to avoid import cycles.
- type PrivacyClientFactory func(proxyURL string) (*req.Client, error)
- const (
- openAISettingsURL = "https://chatgpt.com/backend-api/settings/account_user_setting"
- PrivacyModeTrainingOff = "training_off"
- PrivacyModeFailed = "training_set_failed"
- PrivacyModeCFBlocked = "training_set_cf_blocked"
- )
- func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool {
- if extra == nil {
- return false
- }
- raw, ok := extra["privacy_mode"]
- if !ok {
- return false
- }
- mode, _ := raw.(string)
- mode = strings.TrimSpace(mode)
- return mode != PrivacyModeFailed && mode != PrivacyModeCFBlocked
- }
- // disableOpenAITraining calls ChatGPT settings API to turn off "Improve the model for everyone".
- // Returns privacy_mode value: "training_off" on success, "cf_blocked" / "failed" on failure.
- func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string {
- if accessToken == "" || clientFactory == nil {
- return ""
- }
- ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
- defer cancel()
- client, err := clientFactory(proxyURL)
- if err != nil {
- slog.Warn("openai_privacy_client_error", "error", err.Error())
- return PrivacyModeFailed
- }
- resp, err := client.R().
- SetContext(ctx).
- SetHeader("Authorization", "Bearer "+accessToken).
- SetHeader("Origin", "https://chatgpt.com").
- SetHeader("Referer", "https://chatgpt.com/").
- SetHeader("Accept", "application/json").
- SetHeader("sec-fetch-mode", "cors").
- SetHeader("sec-fetch-site", "same-origin").
- SetHeader("sec-fetch-dest", "empty").
- SetQueryParam("feature", "training_allowed").
- SetQueryParam("value", "false").
- Patch(openAISettingsURL)
- if err != nil {
- slog.Warn("openai_privacy_request_error", "error", err.Error())
- return PrivacyModeFailed
- }
- if resp.StatusCode == 403 || resp.StatusCode == 503 {
- body := resp.String()
- if strings.Contains(body, "cloudflare") || strings.Contains(body, "cf-") || strings.Contains(body, "Just a moment") {
- slog.Warn("openai_privacy_cf_blocked", "status", resp.StatusCode)
- return PrivacyModeCFBlocked
- }
- }
- if !resp.IsSuccessState() {
- slog.Warn("openai_privacy_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
- return PrivacyModeFailed
- }
- slog.Info("openai_privacy_training_disabled")
- return PrivacyModeTrainingOff
- }
- // ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息
- type ChatGPTAccountInfo struct {
- PlanType string
- Email string
- // AccountID 是本条信息所属账号的标识(优先取 account.account_id,否则取 accounts
- // 的 map key)。accounts/check 是多账号/工作区端点,调用方需要据此判断拿到的
- // plan_type / expires_at 到底属于个人账号还是某个 workspace。
- AccountID string
- SubscriptionExpiresAt string // entitlement.expires_at (RFC3339)
- }
- var (
- chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
- chatGPTSubscriptionsURL = "https://chatgpt.com/backend-api/subscriptions"
- )
- // fetchChatGPTAccountInfo calls ChatGPT backend-api to get account info (plan_type, etc.).
- // Used as fallback when id_token doesn't contain these fields (e.g., Mobile RT).
- // orgID is used to match the correct account when multiple accounts exist (e.g., personal + team).
- // Returns nil on any failure (best-effort, non-blocking).
- func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, orgID string) *ChatGPTAccountInfo {
- if accessToken == "" || clientFactory == nil {
- return nil
- }
- ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
- defer cancel()
- client, err := clientFactory(proxyURL)
- if err != nil {
- slog.Debug("chatgpt_account_check_client_error", "error", err.Error())
- return nil
- }
- var result map[string]any
- resp, err := client.R().
- SetContext(ctx).
- SetHeader("Authorization", "Bearer "+accessToken).
- SetHeader("Origin", "https://chatgpt.com").
- SetHeader("Referer", "https://chatgpt.com/").
- SetHeader("Accept", "application/json").
- SetSuccessResult(&result).
- Get(chatGPTAccountsCheckURL)
- if err != nil {
- slog.Debug("chatgpt_account_check_request_error", "error", err.Error())
- return nil
- }
- if !resp.IsSuccessState() {
- slog.Debug("chatgpt_account_check_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
- return nil
- }
- info := &ChatGPTAccountInfo{}
- accounts, ok := result["accounts"].(map[string]any)
- if !ok {
- slog.Debug("chatgpt_account_check_no_accounts", "body", truncate(resp.String(), 300))
- return nil
- }
- // 优先匹配 orgID 对应的账号(access_token JWT 中的 poid)
- if orgID != "" {
- if acctRaw, ok := accounts[orgID]; ok {
- if acct, ok := acctRaw.(map[string]any); ok {
- if isUsableChatGPTAccountCandidate(acct, time.Now()) {
- fillAccountInfo(info, acct, orgID)
- }
- }
- }
- }
- // 未匹配到时,遍历所有账号:优先 is_default,次选非 free
- if info.PlanType == "" {
- type candidate struct {
- planType string
- expiresAt string
- accountID string
- }
- var defaultC, paidC, anyC candidate
- for key, acctRaw := range accounts {
- acct, ok := acctRaw.(map[string]any)
- if !ok {
- continue
- }
- if !isUsableChatGPTAccountCandidate(acct, time.Now()) {
- continue
- }
- planType := extractPlanType(acct)
- if planType == "" {
- continue
- }
- ea := extractEntitlementExpiresAt(acct)
- id := chatGPTAccountObjectID(acct, key)
- if anyC.planType == "" {
- anyC = candidate{planType, ea, id}
- }
- if account, ok := acct["account"].(map[string]any); ok {
- if isDefault, _ := account["is_default"].(bool); isDefault {
- defaultC = candidate{planType, ea, id}
- }
- }
- if !strings.EqualFold(planType, "free") && paidC.planType == "" {
- paidC = candidate{planType, ea, id}
- }
- }
- // 优先级:default > 非 free > 任意
- switch {
- case defaultC.planType != "":
- info.PlanType, info.SubscriptionExpiresAt, info.AccountID = defaultC.planType, defaultC.expiresAt, defaultC.accountID
- case paidC.planType != "":
- info.PlanType, info.SubscriptionExpiresAt, info.AccountID = paidC.planType, paidC.expiresAt, paidC.accountID
- default:
- info.PlanType, info.SubscriptionExpiresAt, info.AccountID = anyC.planType, anyC.expiresAt, anyC.accountID
- }
- }
- if info.PlanType == "" {
- slog.Debug("chatgpt_account_check_no_plan_type", "body", truncate(resp.String(), 300))
- return nil
- }
- slog.Info("chatgpt_account_check_success", "plan_type", info.PlanType, "subscription_expires_at", info.SubscriptionExpiresAt, "org_id", orgID)
- return info
- }
- // fetchChatGPTSubscriptionExpiresAt reads the lightweight subscription endpoint used by
- // ChatGPT/Codex clients. Some Plus accounts no longer expose entitlement.expires_at in
- // accounts/check, but this endpoint still returns active_until.
- func fetchChatGPTSubscriptionExpiresAt(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, accountID string) string {
- accountID = strings.TrimSpace(accountID)
- if accessToken == "" || accountID == "" || clientFactory == nil {
- return ""
- }
- ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
- defer cancel()
- client, err := clientFactory(proxyURL)
- if err != nil {
- slog.Debug("chatgpt_subscription_client_error", "error", err.Error())
- return ""
- }
- var result struct {
- PlanType string `json:"plan_type"`
- ActiveUntil string `json:"active_until"`
- WillRenew bool `json:"will_renew"`
- ID string `json:"id"`
- }
- resp, err := client.R().
- SetContext(ctx).
- SetHeader("Authorization", "Bearer "+accessToken).
- SetHeader("Origin", "https://chatgpt.com").
- SetHeader("Referer", "https://chatgpt.com/").
- SetHeader("Accept", "application/json").
- SetSuccessResult(&result).
- SetQueryParam("account_id", accountID).
- Get(chatGPTSubscriptionsURL)
- if err != nil {
- slog.Debug("chatgpt_subscription_request_error", "error", err.Error())
- return ""
- }
- if !resp.IsSuccessState() {
- slog.Debug("chatgpt_subscription_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
- return ""
- }
- activeUntil := strings.TrimSpace(result.ActiveUntil)
- if activeUntil == "" {
- slog.Debug("chatgpt_subscription_no_active_until", "plan_type", result.PlanType, "has_subscription_id", strings.TrimSpace(result.ID) != "", "will_renew", result.WillRenew)
- return ""
- }
- if _, err := time.Parse(time.RFC3339, activeUntil); err != nil {
- slog.Debug("chatgpt_subscription_bad_active_until", "active_until", activeUntil, "error", err.Error())
- return ""
- }
- slog.Info("chatgpt_subscription_success", "plan_type", result.PlanType, "subscription_expires_at", activeUntil, "account_id", accountID)
- return activeUntil
- }
- // fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at。
- // fallbackID 是该对象在 accounts 里的 map key,用于 account.account_id 缺失时兜底。
- func fillAccountInfo(info *ChatGPTAccountInfo, acct map[string]any, fallbackID string) {
- info.PlanType = extractPlanType(acct)
- info.SubscriptionExpiresAt = extractEntitlementExpiresAt(acct)
- info.AccountID = chatGPTAccountObjectID(acct, fallbackID)
- }
- // chatGPTAccountObjectID 取单个 account 对象的账号标识。
- // accounts 的 map key 有时是 "default" 这类别名,所以优先读 account.account_id。
- func chatGPTAccountObjectID(acct map[string]any, fallbackID string) string {
- if account, ok := acct["account"].(map[string]any); ok {
- if id, ok := account["account_id"].(string); ok && strings.TrimSpace(id) != "" {
- return strings.TrimSpace(id)
- }
- }
- return strings.TrimSpace(fallbackID)
- }
- // extractPlanType 从单个 account 对象中提取 plan_type
- func extractPlanType(acct map[string]any) string {
- if account, ok := acct["account"].(map[string]any); ok {
- if planType, ok := account["plan_type"].(string); ok && planType != "" {
- return planType
- }
- }
- if entitlement, ok := acct["entitlement"].(map[string]any); ok {
- if subPlan, ok := entitlement["subscription_plan"].(string); ok && subPlan != "" {
- return subPlan
- }
- }
- return ""
- }
- func isUsableChatGPTAccountCandidate(acct map[string]any, now time.Time) bool {
- if acct == nil || hasChatGPTAccountDeactivatedMarker(acct) {
- return false
- }
- if account, ok := acct["account"].(map[string]any); ok && hasChatGPTAccountDeactivatedMarker(account) {
- return false
- }
- expiresAt := extractEntitlementExpiresAt(acct)
- if expiresAt == "" {
- return true
- }
- expiry, err := time.Parse(time.RFC3339, expiresAt)
- if err != nil {
- return true
- }
- return expiry.After(now)
- }
- func hasChatGPTAccountDeactivatedMarker(obj map[string]any) bool {
- for _, key := range []string{"deactivated", "is_deactivated", "disabled", "is_disabled"} {
- if value, ok := obj[key].(bool); ok && value {
- return true
- }
- }
- for _, key := range []string{"deactivated_at", "disabled_at", "deleted_at"} {
- if value, ok := obj[key].(string); ok && strings.TrimSpace(value) != "" {
- return true
- }
- }
- for _, key := range []string{"status", "state"} {
- value, _ := obj[key].(string)
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "deactivated", "disabled", "deleted", "inactive", "suspended":
- return true
- }
- }
- return false
- }
- // extractEntitlementExpiresAt 从 entitlement 中提取 expires_at。
- // 预期为 RFC3339 字符串格式,如 "2026-05-02T20:32:12+00:00"。
- func extractEntitlementExpiresAt(acct map[string]any) string {
- entitlement, ok := acct["entitlement"].(map[string]any)
- if !ok {
- return ""
- }
- ea, _ := entitlement["expires_at"].(string)
- return ea
- }
- func truncate(s string, n int) string {
- if len(s) <= n {
- return s
- }
- return s[:n] + fmt.Sprintf("...(%d more)", len(s)-n)
- }
|