Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
1286 lines
40 KiB
Go
1286 lines
40 KiB
Go
package admin
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
const codexImportClockSkewSeconds int64 = 120
|
||
|
||
type CodexSessionImportRequest struct {
|
||
Content string `json:"content"`
|
||
Contents []string `json:"contents"`
|
||
Name string `json:"name"`
|
||
Notes *string `json:"notes"`
|
||
GroupIDs []int64 `json:"group_ids"`
|
||
ProxyID *int64 `json:"proxy_id"`
|
||
Concurrency *int `json:"concurrency"`
|
||
Priority *int `json:"priority"`
|
||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||
LoadFactor *int `json:"load_factor"`
|
||
ExpiresAt *int64 `json:"expires_at"`
|
||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||
CredentialExtras map[string]any `json:"credential_extras"`
|
||
Extra map[string]any `json:"extra"`
|
||
UpdateExisting *bool `json:"update_existing"`
|
||
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
|
||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
|
||
}
|
||
|
||
type CodexSessionImportResult struct {
|
||
Total int `json:"total"`
|
||
Created int `json:"created"`
|
||
Updated int `json:"updated"`
|
||
Skipped int `json:"skipped"`
|
||
Failed int `json:"failed"`
|
||
Items []CodexSessionImportItem `json:"items,omitempty"`
|
||
Warnings []CodexSessionImportMessage `json:"warnings,omitempty"`
|
||
Errors []CodexSessionImportMessage `json:"errors,omitempty"`
|
||
}
|
||
|
||
type CodexSessionImportItem struct {
|
||
Index int `json:"index"`
|
||
Name string `json:"name,omitempty"`
|
||
Action string `json:"action"`
|
||
AccountID int64 `json:"account_id,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
type CodexSessionImportMessage struct {
|
||
Index int `json:"index"`
|
||
Name string `json:"name,omitempty"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
type codexImportEntry struct {
|
||
Index int
|
||
Value any
|
||
}
|
||
|
||
type codexImportAccount struct {
|
||
Name string
|
||
AccessToken string
|
||
RefreshToken string
|
||
IDToken string
|
||
Email string
|
||
AccountID string
|
||
UserID string
|
||
PlanType string
|
||
Organization string
|
||
AgentRuntimeID string
|
||
AgentPrivateKey string
|
||
AgentTaskID string
|
||
AgentFedRAMP bool
|
||
IsAgentIdentity bool
|
||
Credentials map[string]any
|
||
Extra map[string]any
|
||
TokenExpiresAt *time.Time
|
||
IdentityKeys []string
|
||
WarningTexts []string
|
||
}
|
||
|
||
type codexJWTClaims struct {
|
||
Sub string `json:"sub"`
|
||
Email string `json:"email"`
|
||
Exp int64 `json:"exp"`
|
||
Iat int64 `json:"iat"`
|
||
OpenAIAuth *codexJWTOpenAIClaims `json:"https://api.openai.com/auth,omitempty"`
|
||
}
|
||
|
||
type codexJWTOpenAIClaims struct {
|
||
ChatGPTAccountID string `json:"chatgpt_account_id"`
|
||
ChatGPTUserID string `json:"chatgpt_user_id"`
|
||
ChatGPTPlanType string `json:"chatgpt_plan_type"`
|
||
UserID string `json:"user_id"`
|
||
POID string `json:"poid"`
|
||
Organizations []openai.OrganizationClaim `json:"organizations"`
|
||
}
|
||
|
||
type codexAccountIndex struct {
|
||
accountsByKey map[string][]service.Account
|
||
keysByAccountID map[int64]map[string]struct{}
|
||
}
|
||
|
||
func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
|
||
var req CodexSessionImportRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if req.Concurrency != nil && *req.Concurrency < 0 {
|
||
response.BadRequest(c, "concurrency must be >= 0")
|
||
return
|
||
}
|
||
if req.Priority != nil && *req.Priority < 0 {
|
||
response.BadRequest(c, "priority must be >= 0")
|
||
return
|
||
}
|
||
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
|
||
response.BadRequest(c, "rate_multiplier must be >= 0")
|
||
return
|
||
}
|
||
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
|
||
response.BadRequest(c, "load_factor must be <= 10000")
|
||
return
|
||
}
|
||
|
||
entries, err := parseCodexSessionImportEntries(req)
|
||
if err != nil {
|
||
response.BadRequest(c, err.Error())
|
||
return
|
||
}
|
||
if len(entries) == 0 {
|
||
response.BadRequest(c, "请输入 accessToken 或 Codex session JSON")
|
||
return
|
||
}
|
||
|
||
executeAdminIdempotentJSON(c, "admin.accounts.import_codex_session", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
|
||
return h.importCodexSessions(ctx, req, entries)
|
||
})
|
||
}
|
||
|
||
func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessionImportRequest, entries []codexImportEntry) (CodexSessionImportResult, error) {
|
||
result := CodexSessionImportResult{
|
||
Total: len(entries),
|
||
Items: make([]CodexSessionImportItem, 0, len(entries)),
|
||
}
|
||
|
||
existingAccounts, err := h.listAccountsFiltered(ctx, service.PlatformOpenAI, service.AccountTypeOAuth, "", "", 0, "", "created_at", "desc")
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
index := buildCodexAccountIndex(existingAccounts)
|
||
|
||
updateExisting := true
|
||
if req.UpdateExisting != nil {
|
||
updateExisting = *req.UpdateExisting
|
||
}
|
||
concurrency := 3
|
||
if req.Concurrency != nil {
|
||
concurrency = *req.Concurrency
|
||
}
|
||
priority := 50
|
||
if req.Priority != nil {
|
||
priority = *req.Priority
|
||
}
|
||
credentialExtras := sanitizeCodexImportCredentialExtras(req.CredentialExtras)
|
||
skipDefaultGroupBind := false
|
||
if req.SkipDefaultGroupBind != nil {
|
||
skipDefaultGroupBind = *req.SkipDefaultGroupBind
|
||
}
|
||
skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||
|
||
seenIdentity := map[string]codexSeenIdentity{}
|
||
for _, entry := range entries {
|
||
item, err := normalizeCodexImportEntry(entry)
|
||
if err != nil {
|
||
result.Failed++
|
||
result.Items = append(result.Items, CodexSessionImportItem{
|
||
Index: entry.Index,
|
||
Action: "failed",
|
||
Message: err.Error(),
|
||
})
|
||
result.Errors = append(result.Errors, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Message: err.Error(),
|
||
})
|
||
continue
|
||
}
|
||
accountName := buildCodexCreateAccountName(req.Name, item, entry.Index, len(entries))
|
||
effectiveExpiresAt, credentialExpiresAt, autoPauseOnExpired, expiryWarnings, expiryErr := resolveCodexImportExpiry(req, item)
|
||
if expiryErr != nil {
|
||
result.Failed++
|
||
result.Items = append(result.Items, CodexSessionImportItem{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Action: "failed",
|
||
Message: expiryErr.Error(),
|
||
})
|
||
result.Errors = append(result.Errors, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Message: expiryErr.Error(),
|
||
})
|
||
continue
|
||
}
|
||
item.WarningTexts = append(item.WarningTexts, expiryWarnings...)
|
||
if credentialExpiresAt != nil {
|
||
item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339)
|
||
}
|
||
credentials := mergeCodexImportMap(item.Credentials, credentialExtras)
|
||
extra := mergeCodexImportMap(req.Extra, item.Extra)
|
||
for _, warning := range item.WarningTexts {
|
||
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Message: warning,
|
||
})
|
||
}
|
||
|
||
if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys, item.UserID); ok {
|
||
message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex)
|
||
result.Skipped++
|
||
result.Items = append(result.Items, CodexSessionImportItem{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Action: "skipped",
|
||
Message: message,
|
||
})
|
||
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Message: message,
|
||
})
|
||
continue
|
||
}
|
||
markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index, item.UserID)
|
||
|
||
existing, matchedKey := index.Find(item.IdentityKeys, item.UserID)
|
||
if existing != nil && updateExisting {
|
||
if strings.HasPrefix(matchedKey, "account:") && item.UserID != "" &&
|
||
codexCredentialString(existing.Credentials, "chatgpt_user_id") == "" {
|
||
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户",
|
||
})
|
||
}
|
||
preserveExistingRefresh := item.RefreshToken == "" &&
|
||
codexCredentialString(existing.Credentials, "refresh_token") != ""
|
||
if preserveExistingRefresh {
|
||
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据",
|
||
})
|
||
effectiveExpiresAt = nil
|
||
autoPauseOnExpired = nil
|
||
}
|
||
mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item)
|
||
mergedExtra := mergeCodexImportMap(existing.Extra, extra)
|
||
updateInput := &service.UpdateAccountInput{
|
||
Credentials: mergedCredentials,
|
||
Extra: mergedExtra,
|
||
Concurrency: req.Concurrency,
|
||
Priority: req.Priority,
|
||
RateMultiplier: req.RateMultiplier,
|
||
LoadFactor: req.LoadFactor,
|
||
ExpiresAt: effectiveExpiresAt,
|
||
AutoPauseOnExpired: autoPauseOnExpired,
|
||
}
|
||
if req.ProxyID != nil {
|
||
updateInput.ProxyID = req.ProxyID
|
||
}
|
||
if len(req.GroupIDs) > 0 {
|
||
groupIDs := append([]int64(nil), req.GroupIDs...)
|
||
updateInput.GroupIDs = &groupIDs
|
||
updateInput.SkipMixedChannelCheck = skipMixedChannelCheck
|
||
}
|
||
updated, updateErr := h.adminService.UpdateAccount(ctx, existing.ID, updateInput)
|
||
if updateErr != nil {
|
||
result.Failed++
|
||
result.Items = append(result.Items, CodexSessionImportItem{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Action: "failed",
|
||
Message: updateErr.Error(),
|
||
})
|
||
result.Errors = append(result.Errors, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Message: updateErr.Error(),
|
||
})
|
||
continue
|
||
}
|
||
if h.tokenCacheInvalidator != nil && updated != nil {
|
||
_ = h.tokenCacheInvalidator.InvalidateToken(ctx, updated)
|
||
}
|
||
result.Updated++
|
||
accountID := existing.ID
|
||
if updated != nil {
|
||
accountID = updated.ID
|
||
index.Add(*updated)
|
||
}
|
||
result.Items = append(result.Items, CodexSessionImportItem{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Action: "updated",
|
||
AccountID: accountID,
|
||
})
|
||
continue
|
||
}
|
||
|
||
account, createErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
|
||
Name: accountName,
|
||
Notes: req.Notes,
|
||
Platform: service.PlatformOpenAI,
|
||
Type: service.AccountTypeOAuth,
|
||
Credentials: credentials,
|
||
Extra: extra,
|
||
ProxyID: req.ProxyID,
|
||
Concurrency: concurrency,
|
||
Priority: priority,
|
||
RateMultiplier: req.RateMultiplier,
|
||
LoadFactor: req.LoadFactor,
|
||
GroupIDs: req.GroupIDs,
|
||
ExpiresAt: effectiveExpiresAt,
|
||
AutoPauseOnExpired: autoPauseOnExpired,
|
||
SkipDefaultGroupBind: skipDefaultGroupBind,
|
||
SkipMixedChannelCheck: skipMixedChannelCheck,
|
||
})
|
||
if createErr != nil {
|
||
result.Failed++
|
||
result.Items = append(result.Items, CodexSessionImportItem{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Action: "failed",
|
||
Message: createErr.Error(),
|
||
})
|
||
result.Errors = append(result.Errors, CodexSessionImportMessage{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Message: createErr.Error(),
|
||
})
|
||
continue
|
||
}
|
||
if account != nil {
|
||
index.Add(*account)
|
||
}
|
||
result.Created++
|
||
accountID := int64(0)
|
||
if account != nil {
|
||
accountID = account.ID
|
||
}
|
||
result.Items = append(result.Items, CodexSessionImportItem{
|
||
Index: entry.Index,
|
||
Name: accountName,
|
||
Action: "created",
|
||
AccountID: accountID,
|
||
})
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func parseCodexSessionImportEntries(req CodexSessionImportRequest) ([]codexImportEntry, error) {
|
||
contents := make([]string, 0, 1+len(req.Contents))
|
||
if strings.TrimSpace(req.Content) != "" {
|
||
contents = append(contents, req.Content)
|
||
}
|
||
for _, content := range req.Contents {
|
||
if strings.TrimSpace(content) != "" {
|
||
contents = append(contents, content)
|
||
}
|
||
}
|
||
|
||
var entries []codexImportEntry
|
||
for _, content := range contents {
|
||
values, err := parseCodexSessionImportContent(content)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, value := range values {
|
||
entries = append(entries, codexImportEntry{
|
||
Index: len(entries) + 1,
|
||
Value: value,
|
||
})
|
||
}
|
||
}
|
||
return entries, nil
|
||
}
|
||
|
||
func parseCodexSessionImportContent(content string) ([]any, error) {
|
||
trimmed := strings.TrimSpace(content)
|
||
if trimmed == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
if looksLikeJSON(trimmed) {
|
||
values, err := decodeCodexJSONStream(trimmed)
|
||
if err != nil {
|
||
if strings.Contains(trimmed, "\n") {
|
||
if lineValues, lineErr := parseCodexSessionImportLines(trimmed); lineErr == nil {
|
||
return lineValues, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("JSON 解析失败: %w", err)
|
||
}
|
||
return flattenCodexImportValues(values), nil
|
||
}
|
||
|
||
return parseCodexSessionImportLines(trimmed)
|
||
}
|
||
|
||
func parseCodexSessionImportLines(content string) ([]any, error) {
|
||
values := make([]any, 0)
|
||
for _, line := range strings.Split(content, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
if looksLikeJSON(line) {
|
||
lineValues, err := decodeCodexJSONStream(line)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("第 %d 行 JSON 解析失败: %w", len(values)+1, err)
|
||
}
|
||
values = append(values, flattenCodexImportValues(lineValues)...)
|
||
continue
|
||
}
|
||
values = append(values, line)
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func decodeCodexJSONStream(content string) ([]any, error) {
|
||
decoder := json.NewDecoder(strings.NewReader(content))
|
||
decoder.UseNumber()
|
||
values := make([]any, 0, 1)
|
||
for {
|
||
var value any
|
||
err := decoder.Decode(&value)
|
||
if errors.Is(err, io.EOF) {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
values = append(values, value)
|
||
}
|
||
if len(values) == 0 {
|
||
return nil, errors.New("空 JSON 内容")
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func flattenCodexImportValues(values []any) []any {
|
||
out := make([]any, 0, len(values))
|
||
var appendValue func(any)
|
||
appendValue = func(value any) {
|
||
if arr, ok := value.([]any); ok {
|
||
for _, item := range arr {
|
||
appendValue(item)
|
||
}
|
||
return
|
||
}
|
||
out = append(out, value)
|
||
}
|
||
for _, value := range values {
|
||
appendValue(value)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, error) {
|
||
now := time.Now().UTC()
|
||
item := &codexImportAccount{
|
||
Credentials: map[string]any{},
|
||
Extra: map[string]any{
|
||
"import_source": "codex_session",
|
||
"imported_at": now.Format(time.RFC3339),
|
||
},
|
||
}
|
||
|
||
switch raw := entry.Value.(type) {
|
||
case string:
|
||
item.AccessToken = strings.TrimSpace(raw)
|
||
case map[string]any:
|
||
if agentIdentity, ok := firstCodexMap(raw, []string{"agent_identity"}, []string{"agentIdentity"}); ok || strings.EqualFold(firstCodexString(raw, []string{"auth_mode"}, []string{"authMode"}), service.OpenAIAuthModeAgentIdentity) {
|
||
if !ok {
|
||
agentIdentity = raw
|
||
}
|
||
item.IsAgentIdentity = true
|
||
item.AgentRuntimeID = firstCodexString(agentIdentity, []string{"agent_runtime_id"}, []string{"agentRuntimeId"})
|
||
item.AgentPrivateKey = firstCodexString(agentIdentity, []string{"agent_private_key"}, []string{"agentPrivateKey"})
|
||
item.AgentTaskID = firstCodexString(agentIdentity, []string{"task_id"}, []string{"taskId"})
|
||
item.AccountID = firstCodexString(agentIdentity, []string{"account_id"}, []string{"accountId"})
|
||
item.UserID = firstCodexString(agentIdentity, []string{"chatgpt_user_id"}, []string{"chatgptUserId"})
|
||
item.Email = firstCodexString(agentIdentity, []string{"email"})
|
||
item.PlanType = firstCodexString(agentIdentity, []string{"plan_type"}, []string{"planType"})
|
||
item.AgentFedRAMP = firstCodexBool(agentIdentity, []string{"chatgpt_account_is_fedramp"}, []string{"chatgptAccountIsFedramp"})
|
||
if item.AgentRuntimeID == "" || item.AgentPrivateKey == "" || item.AccountID == "" || item.UserID == "" {
|
||
return nil, errors.New("agent identity 缺少必要字段")
|
||
}
|
||
if err := service.ValidateOpenAIAgentIdentityPrivateKey(item.AgentPrivateKey); err != nil {
|
||
return nil, errors.New("agent identity private key 格式无效")
|
||
}
|
||
item.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity
|
||
item.Credentials["agent_runtime_id"] = item.AgentRuntimeID
|
||
item.Credentials["agent_private_key"] = item.AgentPrivateKey
|
||
item.Credentials["chatgpt_account_id"] = item.AccountID
|
||
item.Credentials["chatgpt_user_id"] = item.UserID
|
||
item.Credentials["chatgpt_account_is_fedramp"] = item.AgentFedRAMP
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "task_id", item.AgentTaskID)
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
|
||
if item.AgentTaskID == "" {
|
||
item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task")
|
||
}
|
||
item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID)
|
||
item.Name = buildCodexImportAccountName(item, entry.Index)
|
||
return item, nil
|
||
}
|
||
item.AccessToken = firstCodexString(raw,
|
||
[]string{"tokens", "access_token"},
|
||
[]string{"tokens", "accessToken"},
|
||
[]string{"access_token"},
|
||
[]string{"accessToken"},
|
||
[]string{"token"},
|
||
)
|
||
item.RefreshToken = firstCodexString(raw,
|
||
[]string{"tokens", "refresh_token"},
|
||
[]string{"tokens", "refreshToken"},
|
||
[]string{"refresh_token"},
|
||
[]string{"refreshToken"},
|
||
)
|
||
item.IDToken = firstCodexString(raw,
|
||
[]string{"tokens", "id_token"},
|
||
[]string{"tokens", "idToken"},
|
||
[]string{"id_token"},
|
||
[]string{"idToken"},
|
||
)
|
||
item.Email = firstCodexString(raw, []string{"email"}, []string{"user", "email"})
|
||
item.AccountID = firstCodexString(raw,
|
||
[]string{"chatgpt_account_id"},
|
||
[]string{"chatgptAccountId"},
|
||
[]string{"account_id"},
|
||
[]string{"accountId"},
|
||
[]string{"account", "id"},
|
||
[]string{"account", "account_id"},
|
||
[]string{"account", "chatgpt_account_id"},
|
||
)
|
||
item.UserID = firstCodexString(raw,
|
||
[]string{"chatgpt_user_id"},
|
||
[]string{"chatgptUserId"},
|
||
[]string{"user_id"},
|
||
[]string{"userId"},
|
||
[]string{"user", "id"},
|
||
)
|
||
item.PlanType = firstCodexString(raw,
|
||
[]string{"plan_type"},
|
||
[]string{"planType"},
|
||
[]string{"account", "plan_type"},
|
||
[]string{"account", "planType"},
|
||
)
|
||
item.Organization = firstCodexString(raw,
|
||
[]string{"organization_id"},
|
||
[]string{"organizationId"},
|
||
[]string{"org_id"},
|
||
[]string{"orgId"},
|
||
)
|
||
item.Name = firstCodexString(raw, []string{"name"}, []string{"user", "name"})
|
||
authProvider := firstCodexString(raw, []string{"auth_provider"}, []string{"authProvider"})
|
||
if authProvider != "" {
|
||
item.Extra["auth_provider"] = authProvider
|
||
}
|
||
if sessionToken := firstCodexString(raw, []string{"session_token"}, []string{"sessionToken"}); sessionToken != "" {
|
||
item.Extra["session_token_present"] = true
|
||
item.WarningTexts = append(item.WarningTexts, "sessionToken 已忽略,不会作为 OAuth refresh_token 存储")
|
||
}
|
||
if sessionExpiresAt, ok := codexTimeAt(raw, []string{"expires"}); ok {
|
||
item.Extra["session_expires_at"] = sessionExpiresAt.Format(time.RFC3339)
|
||
}
|
||
if tokenExpiresAt, ok := firstCodexTime(raw,
|
||
[]string{"tokens", "expires_at"},
|
||
[]string{"tokens", "expiresAt"},
|
||
[]string{"expires_at"},
|
||
[]string{"expiresAt"},
|
||
); ok {
|
||
if tokenExpiresAt.Unix() <= now.Unix()-codexImportClockSkewSeconds {
|
||
return nil, fmt.Errorf("access_token 已过期: %s", tokenExpiresAt.Format(time.RFC3339))
|
||
}
|
||
item.TokenExpiresAt = &tokenExpiresAt
|
||
item.Credentials["expires_at"] = tokenExpiresAt.Format(time.RFC3339)
|
||
}
|
||
copyCodexExtraString(raw, item.Extra, "user_image", []string{"user", "image"})
|
||
copyCodexExtraString(raw, item.Extra, "user_picture", []string{"user", "picture"})
|
||
copyCodexExtraString(raw, item.Extra, "account_structure", []string{"account", "structure"})
|
||
copyCodexExtraString(raw, item.Extra, "account_residency_region", []string{"account", "residencyRegion"})
|
||
copyCodexExtraString(raw, item.Extra, "compute_residency", []string{"account", "computeResidency"})
|
||
default:
|
||
return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index)
|
||
}
|
||
|
||
if item.IsAgentIdentity {
|
||
return item, nil
|
||
}
|
||
if item.AccessToken == "" {
|
||
return nil, errors.New("缺少 accessToken/access_token")
|
||
}
|
||
item.Credentials["access_token"] = item.AccessToken
|
||
if item.RefreshToken != "" {
|
||
item.Credentials["refresh_token"] = item.RefreshToken
|
||
item.Credentials["client_id"] = openai.ClientID
|
||
}
|
||
if item.IDToken != "" {
|
||
item.Credentials["id_token"] = item.IDToken
|
||
_ = enrichCodexImportAccountFromJWT(item, item.IDToken, false, now)
|
||
}
|
||
if err := enrichCodexImportAccountFromJWT(item, item.AccessToken, true, now); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, ok := item.Credentials["expires_at"]; !ok {
|
||
item.WarningTexts = append(item.WarningTexts, "无法从 accessToken 解析过期时间,导入后需自行确认令牌有效性")
|
||
}
|
||
if item.RefreshToken == "" {
|
||
item.WarningTexts = append(item.WarningTexts, "未包含 refresh_token,accessToken 过期后无法自动续期")
|
||
}
|
||
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_account_id", item.AccountID)
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_user_id", item.UserID)
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "organization_id", item.Organization)
|
||
setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
|
||
|
||
fingerprint := codexTokenFingerprint(item.AccessToken)
|
||
item.Extra["access_token_sha256"] = fingerprint
|
||
item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken)
|
||
item.Name = buildCodexImportAccountName(item, entry.Index)
|
||
|
||
return item, nil
|
||
}
|
||
|
||
func enrichCodexImportAccountFromJWT(item *codexImportAccount, token string, validateExpiry bool, now time.Time) error {
|
||
claims, err := decodeCodexJWTClaims(token)
|
||
if err != nil {
|
||
if validateExpiry {
|
||
item.WarningTexts = append(item.WarningTexts, "accessToken 不是可解析 JWT,无法校验过期时间和账号身份")
|
||
}
|
||
return nil
|
||
}
|
||
if validateExpiry && claims.Exp > 0 {
|
||
if now.Unix() > claims.Exp+codexImportClockSkewSeconds {
|
||
return fmt.Errorf("access_token 已过期: %s", time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339))
|
||
}
|
||
expiresAt := time.Unix(claims.Exp, 0).UTC()
|
||
item.TokenExpiresAt = &expiresAt
|
||
item.Credentials["expires_at"] = expiresAt.Format(time.RFC3339)
|
||
}
|
||
if item.Email == "" {
|
||
item.Email = strings.TrimSpace(claims.Email)
|
||
}
|
||
if claims.OpenAIAuth == nil {
|
||
if item.UserID == "" {
|
||
item.UserID = strings.TrimSpace(claims.Sub)
|
||
}
|
||
return nil
|
||
}
|
||
if item.AccountID == "" {
|
||
item.AccountID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTAccountID)
|
||
}
|
||
if item.UserID == "" {
|
||
item.UserID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTUserID)
|
||
}
|
||
if item.UserID == "" {
|
||
item.UserID = strings.TrimSpace(claims.OpenAIAuth.UserID)
|
||
}
|
||
if item.PlanType == "" {
|
||
item.PlanType = strings.TrimSpace(claims.OpenAIAuth.ChatGPTPlanType)
|
||
}
|
||
if item.Organization == "" {
|
||
item.Organization = strings.TrimSpace(claims.OpenAIAuth.POID)
|
||
}
|
||
if item.Organization == "" {
|
||
for _, org := range claims.OpenAIAuth.Organizations {
|
||
if org.IsDefault {
|
||
item.Organization = org.ID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if item.Organization == "" && len(claims.OpenAIAuth.Organizations) > 0 {
|
||
item.Organization = claims.OpenAIAuth.Organizations[0].ID
|
||
}
|
||
if item.UserID == "" {
|
||
item.UserID = strings.TrimSpace(claims.Sub)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func decodeCodexJWTClaims(token string) (*codexJWTClaims, error) {
|
||
parts := strings.Split(token, ".")
|
||
if len(parts) != 3 {
|
||
return nil, fmt.Errorf("invalid JWT format")
|
||
}
|
||
payload, err := decodeCodexJWTSegment(parts[1])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var claims codexJWTClaims
|
||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||
return nil, err
|
||
}
|
||
return &claims, nil
|
||
}
|
||
|
||
func decodeCodexJWTSegment(segment string) ([]byte, error) {
|
||
if decoded, err := base64.RawURLEncoding.DecodeString(segment); err == nil {
|
||
return decoded, nil
|
||
}
|
||
if decoded, err := base64.RawStdEncoding.DecodeString(segment); err == nil {
|
||
return decoded, nil
|
||
}
|
||
padded := segment
|
||
if rem := len(padded) % 4; rem > 0 {
|
||
padded += strings.Repeat("=", 4-rem)
|
||
}
|
||
if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil {
|
||
return decoded, nil
|
||
}
|
||
return base64.StdEncoding.DecodeString(padded)
|
||
}
|
||
|
||
func buildCodexImportAccountName(item *codexImportAccount, index int) string {
|
||
for _, candidate := range []string{item.Name, item.Email, item.AccountID, item.UserID} {
|
||
candidate = strings.TrimSpace(candidate)
|
||
if candidate != "" {
|
||
return candidate
|
||
}
|
||
}
|
||
return fmt.Sprintf("Codex 导入账号 %d", index)
|
||
}
|
||
|
||
func buildCodexCreateAccountName(base string, item *codexImportAccount, index, total int) string {
|
||
base = strings.TrimSpace(base)
|
||
if base == "" {
|
||
if item == nil {
|
||
return fmt.Sprintf("Codex 导入账号 %d", index)
|
||
}
|
||
return item.Name
|
||
}
|
||
if total > 1 {
|
||
return fmt.Sprintf("%s #%d", base, index)
|
||
}
|
||
return base
|
||
}
|
||
|
||
func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAccount) (*int64, *time.Time, *bool, []string, error) {
|
||
if item == nil {
|
||
return nil, nil, nil, nil, errors.New("导入项为空")
|
||
}
|
||
// Agent Identity has no OAuth access-token lifetime. Its runtime/task
|
||
// lifecycle is handled by the upstream task recovery path, so it must not
|
||
// be rejected or auto-paused by the OAuth import expiry policy.
|
||
if item.IsAgentIdentity {
|
||
return nil, nil, nil, nil, nil
|
||
}
|
||
|
||
var requestExpiresAt *time.Time
|
||
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
|
||
t := time.Unix(*req.ExpiresAt, 0).UTC()
|
||
requestExpiresAt = &t
|
||
}
|
||
|
||
var accountExpiresAt *time.Time
|
||
var credentialExpiresAt *time.Time
|
||
warnings := make([]string, 0, 2)
|
||
if item.RefreshToken == "" {
|
||
if item.TokenExpiresAt != nil {
|
||
tokenExpiresAt := item.TokenExpiresAt.UTC()
|
||
accountExpiresAt = &tokenExpiresAt
|
||
credentialExpiresAt = &tokenExpiresAt
|
||
}
|
||
if requestExpiresAt != nil {
|
||
accountExpiresAt = earlierCodexTime(accountExpiresAt, requestExpiresAt)
|
||
credentialExpiresAt = earlierCodexTime(credentialExpiresAt, requestExpiresAt)
|
||
}
|
||
if accountExpiresAt == nil {
|
||
return nil, nil, nil, nil, errors.New("未包含 refresh_token,且无法解析 accessToken 过期时间;请在第一步设置过期时间后再导入")
|
||
}
|
||
if accountExpiresAt.Unix() <= time.Now().UTC().Unix()-codexImportClockSkewSeconds {
|
||
return nil, nil, nil, nil, fmt.Errorf("过期时间已过期: %s", accountExpiresAt.Format(time.RFC3339))
|
||
}
|
||
warnings = append(warnings, "未包含 refresh_token,已按 accessToken/账号过期时间设置自动停止调度")
|
||
if req.AutoPauseOnExpired != nil && !*req.AutoPauseOnExpired {
|
||
warnings = append(warnings, "未包含 refresh_token,已强制开启过期自动暂停")
|
||
}
|
||
autoPause := true
|
||
expiresAtUnix := accountExpiresAt.Unix()
|
||
return &expiresAtUnix, credentialExpiresAt, &autoPause, warnings, nil
|
||
}
|
||
|
||
if requestExpiresAt != nil {
|
||
accountExpiresAt = requestExpiresAt
|
||
}
|
||
if item.TokenExpiresAt != nil {
|
||
tokenExpiresAt := item.TokenExpiresAt.UTC()
|
||
credentialExpiresAt = &tokenExpiresAt
|
||
}
|
||
var expiresAtUnix *int64
|
||
if accountExpiresAt != nil {
|
||
v := accountExpiresAt.Unix()
|
||
expiresAtUnix = &v
|
||
}
|
||
return expiresAtUnix, credentialExpiresAt, req.AutoPauseOnExpired, warnings, nil
|
||
}
|
||
|
||
func earlierCodexTime(current, candidate *time.Time) *time.Time {
|
||
if candidate == nil {
|
||
return current
|
||
}
|
||
if current == nil || candidate.Before(*current) {
|
||
t := candidate.UTC()
|
||
return &t
|
||
}
|
||
t := current.UTC()
|
||
return &t
|
||
}
|
||
|
||
func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
|
||
if len(input) == 0 {
|
||
return nil
|
||
}
|
||
protected := map[string]struct{}{
|
||
"access_token": {},
|
||
"refresh_token": {},
|
||
"id_token": {},
|
||
"expires_at": {},
|
||
"email": {},
|
||
"chatgpt_account_id": {},
|
||
"chatgpt_user_id": {},
|
||
"organization_id": {},
|
||
"plan_type": {},
|
||
"client_id": {},
|
||
"auth_mode": {},
|
||
"openai_auth_mode": {},
|
||
"token_type": {},
|
||
"chatgpt_account_is_fedramp": {},
|
||
"agent_runtime_id": {},
|
||
"agent_private_key": {},
|
||
"task_id": {},
|
||
}
|
||
out := make(map[string]any, len(input))
|
||
for key, value := range input {
|
||
normalizedKey := strings.TrimSpace(key)
|
||
if normalizedKey == "" {
|
||
continue
|
||
}
|
||
if _, ok := protected[strings.ToLower(normalizedKey)]; ok {
|
||
continue
|
||
}
|
||
out[normalizedKey] = value
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时
|
||
// Codex session 只能作为 accessToken-only 凭据使用,此时以 access token
|
||
// 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。
|
||
func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string {
|
||
accessToken = strings.TrimSpace(accessToken)
|
||
refreshToken = strings.TrimSpace(refreshToken)
|
||
if refreshToken == "" && accessToken != "" {
|
||
return []string{"access:" + codexTokenFingerprint(accessToken)}
|
||
}
|
||
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
|
||
}
|
||
|
||
func buildCodexAgentIdentityKeys(accountID string) []string {
|
||
// Agent Identity credentials belonging to the same ChatGPT account are
|
||
// intentionally merged, while the same user may own multiple accounts.
|
||
// Do not use user/email/runtime as fallback keys here: user_id is shared
|
||
// across Team workspaces and runtime_id changes when a new runtime is
|
||
// registered for the same account.
|
||
accountID = strings.TrimSpace(accountID)
|
||
if accountID == "" {
|
||
return nil
|
||
}
|
||
return []string{"account:" + accountID}
|
||
}
|
||
|
||
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
|
||
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
|
||
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
|
||
keys := make([]string, 0, 3)
|
||
accountID = strings.TrimSpace(accountID)
|
||
userID = strings.TrimSpace(userID)
|
||
accessToken = strings.TrimSpace(accessToken)
|
||
if userID != "" {
|
||
keys = append(keys, "user:"+userID)
|
||
}
|
||
if accountID == "" && userID == "" {
|
||
if email = strings.ToLower(strings.TrimSpace(email)); email != "" {
|
||
keys = append(keys, "email:"+email)
|
||
}
|
||
}
|
||
if accessToken != "" {
|
||
keys = append(keys, "access:"+codexTokenFingerprint(accessToken))
|
||
}
|
||
if accountID != "" {
|
||
keys = append(keys, "account:"+accountID)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex {
|
||
index := &codexAccountIndex{
|
||
accountsByKey: map[string][]service.Account{},
|
||
keysByAccountID: map[int64]map[string]struct{}{},
|
||
}
|
||
for _, account := range accounts {
|
||
index.Add(account)
|
||
}
|
||
return index
|
||
}
|
||
|
||
func (i *codexAccountIndex) Add(account service.Account) {
|
||
if i == nil {
|
||
return
|
||
}
|
||
if i.accountsByKey == nil {
|
||
i.accountsByKey = map[string][]service.Account{}
|
||
}
|
||
if i.keysByAccountID == nil {
|
||
i.keysByAccountID = map[int64]map[string]struct{}{}
|
||
}
|
||
keys := buildCodexStoredIdentityKeys(
|
||
codexCredentialString(account.Credentials, "chatgpt_account_id"),
|
||
codexCredentialString(account.Credentials, "chatgpt_user_id"),
|
||
codexCredentialString(account.Credentials, "email"),
|
||
codexCredentialString(account.Credentials, "access_token"),
|
||
)
|
||
orderedKeys := make([]string, 0, len(keys)+1)
|
||
accountKeys := make(map[string]struct{}, len(keys)+1)
|
||
for _, key := range keys {
|
||
if _, exists := accountKeys[key]; exists {
|
||
continue
|
||
}
|
||
accountKeys[key] = struct{}{}
|
||
orderedKeys = append(orderedKeys, key)
|
||
}
|
||
if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" {
|
||
key := "agent:" + runtimeID
|
||
if _, exists := accountKeys[key]; !exists {
|
||
accountKeys[key] = struct{}{}
|
||
orderedKeys = append(orderedKeys, key)
|
||
}
|
||
}
|
||
|
||
previousKeys := i.keysByAccountID[account.ID]
|
||
for key := range previousKeys {
|
||
if _, retained := accountKeys[key]; retained {
|
||
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
|
||
continue
|
||
}
|
||
i.removeFromKey(key, account.ID)
|
||
}
|
||
for _, key := range orderedKeys {
|
||
if _, retained := previousKeys[key]; retained {
|
||
continue
|
||
}
|
||
i.accountsByKey[key] = append(i.accountsByKey[key], account)
|
||
}
|
||
|
||
if len(accountKeys) > 0 {
|
||
i.keysByAccountID[account.ID] = accountKeys
|
||
return
|
||
}
|
||
delete(i.keysByAccountID, account.ID)
|
||
}
|
||
|
||
func (i *codexAccountIndex) removeFromKey(key string, accountID int64) {
|
||
accounts := i.accountsByKey[key]
|
||
kept := accounts[:0]
|
||
for _, account := range accounts {
|
||
if account.ID != accountID {
|
||
kept = append(kept, account)
|
||
}
|
||
}
|
||
if len(kept) == 0 {
|
||
delete(i.accountsByKey, key)
|
||
return
|
||
}
|
||
i.accountsByKey[key] = kept
|
||
}
|
||
|
||
// upsertCodexAccount keeps all candidates for shared keys while replacing an
|
||
// existing account in place so ambiguous legacy matches retain their order.
|
||
func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account {
|
||
for idx := range accounts {
|
||
if accounts[idx].ID == account.ID {
|
||
accounts[idx] = account
|
||
return accounts
|
||
}
|
||
}
|
||
return append(accounts, account)
|
||
}
|
||
|
||
// Find 返回第一个通过跨用户校验的候选账号及其命中的匹配键。
|
||
func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account, string) {
|
||
if i == nil {
|
||
return nil, ""
|
||
}
|
||
for _, key := range keys {
|
||
for _, account := range i.accountsByKey[key] {
|
||
if codexIdentityConflicts(key, userID, codexCredentialString(account.Credentials, "chatgpt_user_id")) {
|
||
continue
|
||
}
|
||
return &account, key
|
||
}
|
||
}
|
||
return nil, ""
|
||
}
|
||
|
||
// codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个
|
||
// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧
|
||
// 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token
|
||
// 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。
|
||
func codexIdentityConflicts(key, userID, storedUserID string) bool {
|
||
if !strings.HasPrefix(key, "account:") {
|
||
return false
|
||
}
|
||
userID = strings.TrimSpace(userID)
|
||
storedUserID = strings.TrimSpace(storedUserID)
|
||
return userID != "" && storedUserID != "" && userID != storedUserID
|
||
}
|
||
|
||
type codexSeenIdentity struct {
|
||
index int
|
||
userID string
|
||
}
|
||
|
||
func firstSeenCodexIdentity(seen map[string]codexSeenIdentity, keys []string, userID string) (int, bool) {
|
||
for _, key := range keys {
|
||
entry, ok := seen[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if codexIdentityConflicts(key, userID, entry.userID) {
|
||
continue
|
||
}
|
||
return entry.index, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func markCodexIdentitySeen(seen map[string]codexSeenIdentity, keys []string, index int, userID string) {
|
||
for _, key := range keys {
|
||
seen[key] = codexSeenIdentity{index: index, userID: userID}
|
||
}
|
||
}
|
||
|
||
func mergeCodexImportMap(existing, incoming map[string]any) map[string]any {
|
||
out := make(map[string]any, len(existing)+len(incoming))
|
||
for k, v := range existing {
|
||
out[k] = v
|
||
}
|
||
for k, v := range incoming {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexImportAccount) map[string]any {
|
||
out := mergeCodexImportMap(existing, incoming)
|
||
if item == nil {
|
||
return out
|
||
}
|
||
if strings.TrimSpace(item.RefreshToken) == "" {
|
||
if codexCredentialString(existing, "refresh_token") == "" {
|
||
delete(out, "refresh_token")
|
||
delete(out, "client_id")
|
||
} else {
|
||
out["refresh_token"] = existing["refresh_token"]
|
||
if clientID, ok := existing["client_id"]; ok {
|
||
out["client_id"] = clientID
|
||
}
|
||
}
|
||
}
|
||
if strings.TrimSpace(item.IDToken) == "" {
|
||
delete(out, "id_token")
|
||
}
|
||
return out
|
||
}
|
||
|
||
func codexCredentialString(credentials map[string]any, key string) string {
|
||
if credentials == nil {
|
||
return ""
|
||
}
|
||
return codexStringValue(credentials[key])
|
||
}
|
||
|
||
func codexTokenFingerprint(token string) string {
|
||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func looksLikeJSON(content string) bool {
|
||
if content == "" {
|
||
return false
|
||
}
|
||
switch content[0] {
|
||
case '{', '[':
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func firstCodexString(obj map[string]any, paths ...[]string) string {
|
||
for _, path := range paths {
|
||
if value, ok := codexPathValue(obj, path); ok {
|
||
if str := codexStringValue(value); str != "" {
|
||
return str
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func firstCodexMap(obj map[string]any, paths ...[]string) (map[string]any, bool) {
|
||
for _, path := range paths {
|
||
value, ok := codexPathValue(obj, path)
|
||
if !ok || value == nil {
|
||
continue
|
||
}
|
||
if mapped, ok := value.(map[string]any); ok {
|
||
return mapped, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func firstCodexBool(obj map[string]any, paths ...[]string) bool {
|
||
for _, path := range paths {
|
||
value, ok := codexPathValue(obj, path)
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch value := value.(type) {
|
||
case bool:
|
||
return value
|
||
case string:
|
||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) {
|
||
value := firstCodexString(obj, path)
|
||
if value != "" {
|
||
extra[key] = value
|
||
}
|
||
}
|
||
|
||
func firstCodexTime(obj map[string]any, paths ...[]string) (time.Time, bool) {
|
||
for _, path := range paths {
|
||
if value, ok := codexTimeAt(obj, path); ok {
|
||
return value, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
func codexTimeAt(obj map[string]any, path []string) (time.Time, bool) {
|
||
value, ok := codexPathValue(obj, path)
|
||
if !ok {
|
||
return time.Time{}, false
|
||
}
|
||
return parseCodexTimeValue(value)
|
||
}
|
||
|
||
func codexPathValue(obj map[string]any, path []string) (any, bool) {
|
||
var current any = obj
|
||
for _, key := range path {
|
||
currentObj, ok := current.(map[string]any)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
value, ok := currentObj[key]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
current = value
|
||
}
|
||
return current, true
|
||
}
|
||
|
||
func codexStringValue(value any) string {
|
||
switch v := value.(type) {
|
||
case string:
|
||
return strings.TrimSpace(v)
|
||
case json.Number:
|
||
return strings.TrimSpace(v.String())
|
||
case float64:
|
||
return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
|
||
case float32:
|
||
return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
|
||
case int:
|
||
return strconv.Itoa(v)
|
||
case int64:
|
||
return strconv.FormatInt(v, 10)
|
||
case int32:
|
||
return strconv.FormatInt(int64(v), 10)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func setCodexCredentialIfNotEmpty(credentials map[string]any, key, value string) {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
credentials[key] = value
|
||
}
|
||
}
|
||
|
||
func parseCodexTimeValue(value any) (time.Time, bool) {
|
||
switch v := value.(type) {
|
||
case string:
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return time.Time{}, false
|
||
}
|
||
if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil {
|
||
return parsed.UTC(), true
|
||
}
|
||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||
return codexUnixTime(n), true
|
||
}
|
||
case json.Number:
|
||
if n, err := v.Int64(); err == nil {
|
||
return codexUnixTime(n), true
|
||
}
|
||
if f, err := v.Float64(); err == nil {
|
||
return codexUnixTime(int64(f)), true
|
||
}
|
||
case float64:
|
||
return codexUnixTime(int64(v)), true
|
||
case int:
|
||
return codexUnixTime(int64(v)), true
|
||
case int64:
|
||
return codexUnixTime(v), true
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
func codexUnixTime(value int64) time.Time {
|
||
if value > 1_000_000_000_000 {
|
||
return time.UnixMilli(value).UTC()
|
||
}
|
||
return time.Unix(value, 0).UTC()
|
||
}
|