2026-02-01 21:37:10 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-02-19 15:09:58 +08:00
|
|
|
|
"hash/fnv"
|
2026-02-01 21:37:10 +08:00
|
|
|
|
"io"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"math/rand"
|
|
|
|
|
|
"mime"
|
|
|
|
|
|
"mime/multipart"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/textproto"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"path"
|
2026-02-19 08:02:56 +08:00
|
|
|
|
"sort"
|
2026-02-01 21:37:10 +08:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
2026-02-19 08:02:56 +08:00
|
|
|
|
openaioauth "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
2026-02-19 21:18:35 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
|
2026-02-01 21:37:10 +08:00
|
|
|
|
"github.com/google/uuid"
|
2026-02-10 08:59:30 +08:00
|
|
|
|
"github.com/tidwall/gjson"
|
2026-02-01 21:37:10 +08:00
|
|
|
|
"golang.org/x/crypto/sha3"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
soraChatGPTBaseURL = "https://chatgpt.com"
|
|
|
|
|
|
soraSentinelFlow = "sora_2_create_task"
|
|
|
|
|
|
soraDefaultUserAgent = "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-19 08:02:56 +08:00
|
|
|
|
var (
|
|
|
|
|
|
soraSessionAuthURL = "https://sora.chatgpt.com/api/auth/session"
|
|
|
|
|
|
soraOAuthTokenURL = "https://auth.openai.com/oauth/token"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
const (
|
|
|
|
|
|
soraPowMaxIteration = 500000
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var soraPowCores = []int{8, 16, 24, 32}
|
|
|
|
|
|
|
|
|
|
|
|
var soraPowScripts = []string{
|
|
|
|
|
|
"https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var soraPowDPL = []string{
|
|
|
|
|
|
"prod-f501fe933b3edf57aea882da888e1a544df99840",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var soraPowNavigatorKeys = []string{
|
|
|
|
|
|
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
|
|
|
|
|
"storage−[object StorageManager]",
|
|
|
|
|
|
"locks−[object LockManager]",
|
|
|
|
|
|
"appCodeName−Mozilla",
|
|
|
|
|
|
"permissions−[object Permissions]",
|
|
|
|
|
|
"webdriver−false",
|
|
|
|
|
|
"vendor−Google Inc.",
|
|
|
|
|
|
"mediaDevices−[object MediaDevices]",
|
|
|
|
|
|
"cookieEnabled−true",
|
|
|
|
|
|
"product−Gecko",
|
|
|
|
|
|
"productSub−20030107",
|
|
|
|
|
|
"hardwareConcurrency−32",
|
|
|
|
|
|
"onLine−true",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var soraPowDocumentKeys = []string{
|
|
|
|
|
|
"_reactListeningo743lnnpvdg",
|
|
|
|
|
|
"location",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var soraPowWindowKeys = []string{
|
|
|
|
|
|
"0", "window", "self", "document", "name", "location",
|
|
|
|
|
|
"navigator", "screen", "innerWidth", "innerHeight",
|
|
|
|
|
|
"localStorage", "sessionStorage", "crypto", "performance",
|
|
|
|
|
|
"fetch", "setTimeout", "setInterval", "console",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var soraDesktopUserAgents = []string{
|
|
|
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 20:04:10 +08:00
|
|
|
|
var soraMobileUserAgents = []string{
|
|
|
|
|
|
"Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)",
|
|
|
|
|
|
"Sora/1.2026.007 (Android 14; SM-G998B; build 2600700)",
|
|
|
|
|
|
"Sora/1.2026.007 (Android 15; Pixel 8 Pro; build 2600700)",
|
|
|
|
|
|
"Sora/1.2026.007 (Android 14; Pixel 7; build 2600700)",
|
|
|
|
|
|
"Sora/1.2026.007 (Android 15; 2211133C; build 2600700)",
|
|
|
|
|
|
"Sora/1.2026.007 (Android 14; SM-S918B; build 2600700)",
|
|
|
|
|
|
"Sora/1.2026.007 (Android 15; OnePlus 12; build 2600700)",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
var soraRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
|
|
var soraRandMu sync.Mutex
|
|
|
|
|
|
var soraPerfStart = time.Now()
|
2026-02-19 15:09:58 +08:00
|
|
|
|
var soraPowTokenGenerator = soraGetPowToken
|
2026-02-01 21:37:10 +08:00
|
|
|
|
|
|
|
|
|
|
// SoraClient 定义直连 Sora 的任务操作接口。
|
|
|
|
|
|
type SoraClient interface {
|
|
|
|
|
|
Enabled() bool
|
|
|
|
|
|
UploadImage(ctx context.Context, account *Account, data []byte, filename string) (string, error)
|
|
|
|
|
|
CreateImageTask(ctx context.Context, account *Account, req SoraImageRequest) (string, error)
|
|
|
|
|
|
CreateVideoTask(ctx context.Context, account *Account, req SoraVideoRequest) (string, error)
|
2026-02-19 20:04:10 +08:00
|
|
|
|
CreateStoryboardTask(ctx context.Context, account *Account, req SoraStoryboardRequest) (string, error)
|
|
|
|
|
|
UploadCharacterVideo(ctx context.Context, account *Account, data []byte) (string, error)
|
|
|
|
|
|
GetCameoStatus(ctx context.Context, account *Account, cameoID string) (*SoraCameoStatus, error)
|
|
|
|
|
|
DownloadCharacterImage(ctx context.Context, account *Account, imageURL string) ([]byte, error)
|
|
|
|
|
|
UploadCharacterImage(ctx context.Context, account *Account, data []byte) (string, error)
|
|
|
|
|
|
FinalizeCharacter(ctx context.Context, account *Account, req SoraCharacterFinalizeRequest) (string, error)
|
|
|
|
|
|
SetCharacterPublic(ctx context.Context, account *Account, cameoID string) error
|
|
|
|
|
|
DeleteCharacter(ctx context.Context, account *Account, characterID string) error
|
|
|
|
|
|
PostVideoForWatermarkFree(ctx context.Context, account *Account, generationID string) (string, error)
|
|
|
|
|
|
DeletePost(ctx context.Context, account *Account, postID string) error
|
|
|
|
|
|
GetWatermarkFreeURLCustom(ctx context.Context, account *Account, parseURL, parseToken, postID string) (string, error)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
EnhancePrompt(ctx context.Context, account *Account, prompt, expansionLevel string, durationS int) (string, error)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
GetImageTask(ctx context.Context, account *Account, taskID string) (*SoraImageTaskStatus, error)
|
|
|
|
|
|
GetVideoTask(ctx context.Context, account *Account, taskID string) (*SoraVideoTaskStatus, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraImageRequest 图片生成请求参数
|
|
|
|
|
|
type SoraImageRequest struct {
|
|
|
|
|
|
Prompt string
|
|
|
|
|
|
Width int
|
|
|
|
|
|
Height int
|
|
|
|
|
|
MediaID string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraVideoRequest 视频生成请求参数
|
|
|
|
|
|
type SoraVideoRequest struct {
|
|
|
|
|
|
Prompt string
|
|
|
|
|
|
Orientation string
|
|
|
|
|
|
Frames int
|
|
|
|
|
|
Model string
|
|
|
|
|
|
Size string
|
|
|
|
|
|
MediaID string
|
|
|
|
|
|
RemixTargetID string
|
2026-02-19 20:04:10 +08:00
|
|
|
|
CameoIDs []string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraStoryboardRequest 分镜视频生成请求参数
|
|
|
|
|
|
type SoraStoryboardRequest struct {
|
|
|
|
|
|
Prompt string
|
|
|
|
|
|
Orientation string
|
|
|
|
|
|
Frames int
|
|
|
|
|
|
Model string
|
|
|
|
|
|
Size string
|
|
|
|
|
|
MediaID string
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraImageTaskStatus 图片任务状态
|
|
|
|
|
|
type SoraImageTaskStatus struct {
|
|
|
|
|
|
ID string
|
|
|
|
|
|
Status string
|
|
|
|
|
|
ProgressPct float64
|
|
|
|
|
|
URLs []string
|
|
|
|
|
|
ErrorMsg string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraVideoTaskStatus 视频任务状态
|
|
|
|
|
|
type SoraVideoTaskStatus struct {
|
2026-02-19 20:04:10 +08:00
|
|
|
|
ID string
|
|
|
|
|
|
Status string
|
|
|
|
|
|
ProgressPct int
|
|
|
|
|
|
URLs []string
|
|
|
|
|
|
GenerationID string
|
|
|
|
|
|
ErrorMsg string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraCameoStatus 角色处理中间态
|
|
|
|
|
|
type SoraCameoStatus struct {
|
|
|
|
|
|
Status string
|
|
|
|
|
|
StatusMessage string
|
|
|
|
|
|
DisplayNameHint string
|
|
|
|
|
|
UsernameHint string
|
|
|
|
|
|
ProfileAssetURL string
|
|
|
|
|
|
InstructionSetHint any
|
|
|
|
|
|
InstructionSet any
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraCharacterFinalizeRequest 角色定稿请求参数
|
|
|
|
|
|
type SoraCharacterFinalizeRequest struct {
|
|
|
|
|
|
CameoID string
|
|
|
|
|
|
Username string
|
|
|
|
|
|
DisplayName string
|
|
|
|
|
|
ProfileAssetPointer string
|
|
|
|
|
|
InstructionSet any
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraUpstreamError 上游错误
|
|
|
|
|
|
type SoraUpstreamError struct {
|
|
|
|
|
|
StatusCode int
|
|
|
|
|
|
Message string
|
|
|
|
|
|
Headers http.Header
|
|
|
|
|
|
Body []byte
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (e *SoraUpstreamError) Error() string {
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
return "sora upstream error"
|
|
|
|
|
|
}
|
|
|
|
|
|
if e.Message != "" {
|
|
|
|
|
|
return fmt.Sprintf("sora upstream error: %d %s", e.StatusCode, e.Message)
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("sora upstream error: %d", e.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SoraDirectClient 直连 Sora 实现
|
|
|
|
|
|
type SoraDirectClient struct {
|
2026-02-19 21:18:35 +08:00
|
|
|
|
cfg *config.Config
|
|
|
|
|
|
httpUpstream HTTPUpstream
|
|
|
|
|
|
tokenProvider *OpenAITokenProvider
|
|
|
|
|
|
accountRepo AccountRepository
|
|
|
|
|
|
soraAccountRepo SoraAccountRepository
|
|
|
|
|
|
baseURL string
|
|
|
|
|
|
challengeCooldownMu sync.RWMutex
|
|
|
|
|
|
challengeCooldowns map[string]soraChallengeCooldownEntry
|
|
|
|
|
|
sidecarSessionMu sync.RWMutex
|
|
|
|
|
|
sidecarSessions map[string]soraSidecarSessionEntry
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 21:38:04 +08:00
|
|
|
|
type soraRequestTraceContextKey struct{}
|
|
|
|
|
|
|
|
|
|
|
|
type soraRequestTrace struct {
|
|
|
|
|
|
ID string
|
|
|
|
|
|
ProxyKey string
|
|
|
|
|
|
UAHash string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
// NewSoraDirectClient 创建 Sora 直连客户端
|
|
|
|
|
|
func NewSoraDirectClient(cfg *config.Config, httpUpstream HTTPUpstream, tokenProvider *OpenAITokenProvider) *SoraDirectClient {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
baseURL := ""
|
|
|
|
|
|
if cfg != nil {
|
|
|
|
|
|
rawBaseURL := strings.TrimRight(strings.TrimSpace(cfg.Sora.Client.BaseURL), "/")
|
|
|
|
|
|
baseURL = normalizeSoraBaseURL(rawBaseURL)
|
|
|
|
|
|
if rawBaseURL != "" && baseURL != rawBaseURL {
|
|
|
|
|
|
log.Printf("[SoraClient] normalized base_url from %s to %s", sanitizeSoraLogURL(rawBaseURL), sanitizeSoraLogURL(baseURL))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return &SoraDirectClient{
|
2026-02-19 21:18:35 +08:00
|
|
|
|
cfg: cfg,
|
|
|
|
|
|
httpUpstream: httpUpstream,
|
|
|
|
|
|
tokenProvider: tokenProvider,
|
|
|
|
|
|
baseURL: baseURL,
|
|
|
|
|
|
challengeCooldowns: make(map[string]soraChallengeCooldownEntry),
|
|
|
|
|
|
sidecarSessions: make(map[string]soraSidecarSessionEntry),
|
2026-02-19 08:02:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) SetAccountRepositories(accountRepo AccountRepository, soraAccountRepo SoraAccountRepository) {
|
|
|
|
|
|
if c == nil {
|
|
|
|
|
|
return
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-19 08:02:56 +08:00
|
|
|
|
c.accountRepo = accountRepo
|
|
|
|
|
|
c.soraAccountRepo = soraAccountRepo
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Enabled 判断是否启用 Sora 直连
|
|
|
|
|
|
func (c *SoraDirectClient) Enabled() bool {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if c == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(c.baseURL) != "" {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.cfg == nil {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-02-19 08:02:56 +08:00
|
|
|
|
return strings.TrimSpace(normalizeSoraBaseURL(c.cfg.Sora.Client.BaseURL)) != ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PreflightCheck 在创建任务前执行账号能力预检。
|
|
|
|
|
|
// 当前仅对视频模型执行 /nf/check 预检,用于提前识别额度耗尽或能力缺失。
|
|
|
|
|
|
func (c *SoraDirectClient) PreflightCheck(ctx context.Context, account *Account, requestedModel string, modelCfg SoraModelConfig) error {
|
|
|
|
|
|
if modelCfg.Type != "video" {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
headers.Set("Accept", "application/json")
|
2026-02-19 15:09:58 +08:00
|
|
|
|
body, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodGet, c.buildURL("/nf/check"), headers, nil, false)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
var upstreamErr *SoraUpstreamError
|
|
|
|
|
|
if errors.As(err, &upstreamErr) && upstreamErr.StatusCode == http.StatusNotFound {
|
|
|
|
|
|
return &SoraUpstreamError{
|
|
|
|
|
|
StatusCode: http.StatusForbidden,
|
|
|
|
|
|
Message: "当前账号未开通 Sora2 能力或无可用配额",
|
|
|
|
|
|
Headers: upstreamErr.Headers,
|
|
|
|
|
|
Body: upstreamErr.Body,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
rateLimitReached := gjson.GetBytes(body, "rate_limit_and_credit_balance.rate_limit_reached").Bool()
|
|
|
|
|
|
remaining := gjson.GetBytes(body, "rate_limit_and_credit_balance.estimated_num_videos_remaining")
|
|
|
|
|
|
if rateLimitReached || (remaining.Exists() && remaining.Int() <= 0) {
|
|
|
|
|
|
msg := "当前账号 Sora2 可用配额不足"
|
|
|
|
|
|
if requestedModel != "" {
|
|
|
|
|
|
msg = fmt.Sprintf("当前账号 %s 可用配额不足", requestedModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
return &SoraUpstreamError{
|
|
|
|
|
|
StatusCode: http.StatusTooManyRequests,
|
|
|
|
|
|
Message: msg,
|
|
|
|
|
|
Headers: http.Header{},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) UploadImage(ctx context.Context, account *Account, data []byte, filename string) (string, error) {
|
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
|
return "", errors.New("empty image data")
|
|
|
|
|
|
}
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if filename == "" {
|
|
|
|
|
|
filename = "image.png"
|
|
|
|
|
|
}
|
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
|
|
contentType := mime.TypeByExtension(path.Ext(filename))
|
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
|
contentType = "application/octet-stream"
|
|
|
|
|
|
}
|
|
|
|
|
|
partHeader := make(textproto.MIMEHeader)
|
|
|
|
|
|
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
|
|
|
|
|
partHeader.Set("Content-Type", contentType)
|
|
|
|
|
|
part, err := writer.CreatePart(partHeader)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := part.Write(data); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.WriteField("file_name", filename); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.Close(); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
headers.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/uploads"), headers, &body, false)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
id := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
|
|
|
|
|
|
if id == "" {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return "", errors.New("upload response missing id")
|
|
|
|
|
|
}
|
|
|
|
|
|
return id, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) CreateImageTask(ctx context.Context, account *Account, req SoraImageRequest) (string, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
2026-02-19 21:38:04 +08:00
|
|
|
|
ctx = c.withRequestTrace(ctx, account, proxyURL, userAgent)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
operation := "simple_compose"
|
|
|
|
|
|
inpaintItems := []map[string]any{}
|
|
|
|
|
|
if strings.TrimSpace(req.MediaID) != "" {
|
|
|
|
|
|
operation = "remix"
|
|
|
|
|
|
inpaintItems = append(inpaintItems, map[string]any{
|
|
|
|
|
|
"type": "image",
|
|
|
|
|
|
"frame_index": 0,
|
|
|
|
|
|
"upload_media_id": req.MediaID,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"type": "image_gen",
|
|
|
|
|
|
"operation": operation,
|
|
|
|
|
|
"prompt": req.Prompt,
|
|
|
|
|
|
"width": req.Width,
|
|
|
|
|
|
"height": req.Height,
|
|
|
|
|
|
"n_variants": 1,
|
|
|
|
|
|
"n_frames": 1,
|
|
|
|
|
|
"inpaint_items": inpaintItems,
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
sentinel, err := c.generateSentinelToken(ctx, account, token, userAgent, proxyURL)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers.Set("openai-sentinel-token", sentinel)
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/video_gen"), headers, bytes.NewReader(body), true)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
taskID := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
|
|
|
|
|
|
if taskID == "" {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return "", errors.New("image task response missing id")
|
|
|
|
|
|
}
|
|
|
|
|
|
return taskID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) CreateVideoTask(ctx context.Context, account *Account, req SoraVideoRequest) (string, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
2026-02-19 21:38:04 +08:00
|
|
|
|
ctx = c.withRequestTrace(ctx, account, proxyURL, userAgent)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
orientation := req.Orientation
|
|
|
|
|
|
if orientation == "" {
|
|
|
|
|
|
orientation = "landscape"
|
|
|
|
|
|
}
|
|
|
|
|
|
nFrames := req.Frames
|
|
|
|
|
|
if nFrames <= 0 {
|
|
|
|
|
|
nFrames = 450
|
|
|
|
|
|
}
|
|
|
|
|
|
model := req.Model
|
|
|
|
|
|
if model == "" {
|
|
|
|
|
|
model = "sy_8"
|
|
|
|
|
|
}
|
|
|
|
|
|
size := req.Size
|
|
|
|
|
|
if size == "" {
|
|
|
|
|
|
size = "small"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inpaintItems := []map[string]any{}
|
|
|
|
|
|
if strings.TrimSpace(req.MediaID) != "" {
|
|
|
|
|
|
inpaintItems = append(inpaintItems, map[string]any{
|
|
|
|
|
|
"kind": "upload",
|
|
|
|
|
|
"upload_id": req.MediaID,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"kind": "video",
|
|
|
|
|
|
"prompt": req.Prompt,
|
|
|
|
|
|
"orientation": orientation,
|
|
|
|
|
|
"size": size,
|
|
|
|
|
|
"n_frames": nFrames,
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"inpaint_items": inpaintItems,
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(req.RemixTargetID) != "" {
|
|
|
|
|
|
payload["remix_target_id"] = req.RemixTargetID
|
|
|
|
|
|
payload["cameo_ids"] = []string{}
|
|
|
|
|
|
payload["cameo_replacements"] = map[string]any{}
|
2026-02-19 20:04:10 +08:00
|
|
|
|
} else if len(req.CameoIDs) > 0 {
|
|
|
|
|
|
payload["cameo_ids"] = req.CameoIDs
|
|
|
|
|
|
payload["cameo_replacements"] = map[string]any{}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
sentinel, err := c.generateSentinelToken(ctx, account, token, userAgent, proxyURL)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers.Set("openai-sentinel-token", sentinel)
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/nf/create"), headers, bytes.NewReader(body), true)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
taskID := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
|
|
|
|
|
|
if taskID == "" {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return "", errors.New("video task response missing id")
|
|
|
|
|
|
}
|
|
|
|
|
|
return taskID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 20:04:10 +08:00
|
|
|
|
func (c *SoraDirectClient) CreateStoryboardTask(ctx context.Context, account *Account, req SoraStoryboardRequest) (string, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
2026-02-19 21:38:04 +08:00
|
|
|
|
ctx = c.withRequestTrace(ctx, account, proxyURL, userAgent)
|
2026-02-19 20:04:10 +08:00
|
|
|
|
orientation := req.Orientation
|
|
|
|
|
|
if orientation == "" {
|
|
|
|
|
|
orientation = "landscape"
|
|
|
|
|
|
}
|
|
|
|
|
|
nFrames := req.Frames
|
|
|
|
|
|
if nFrames <= 0 {
|
|
|
|
|
|
nFrames = 450
|
|
|
|
|
|
}
|
|
|
|
|
|
model := req.Model
|
|
|
|
|
|
if model == "" {
|
|
|
|
|
|
model = "sy_8"
|
|
|
|
|
|
}
|
|
|
|
|
|
size := req.Size
|
|
|
|
|
|
if size == "" {
|
|
|
|
|
|
size = "small"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inpaintItems := []map[string]any{}
|
|
|
|
|
|
if strings.TrimSpace(req.MediaID) != "" {
|
|
|
|
|
|
inpaintItems = append(inpaintItems, map[string]any{
|
|
|
|
|
|
"kind": "upload",
|
|
|
|
|
|
"upload_id": req.MediaID,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"kind": "video",
|
|
|
|
|
|
"prompt": req.Prompt,
|
|
|
|
|
|
"title": "Draft your video",
|
|
|
|
|
|
"orientation": orientation,
|
|
|
|
|
|
"size": size,
|
|
|
|
|
|
"n_frames": nFrames,
|
|
|
|
|
|
"storyboard_id": nil,
|
|
|
|
|
|
"inpaint_items": inpaintItems,
|
|
|
|
|
|
"remix_target_id": nil,
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"metadata": nil,
|
|
|
|
|
|
"style_id": nil,
|
|
|
|
|
|
"cameo_ids": nil,
|
|
|
|
|
|
"cameo_replacements": nil,
|
|
|
|
|
|
"audio_caption": nil,
|
|
|
|
|
|
"audio_transcript": nil,
|
|
|
|
|
|
"video_caption": nil,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
sentinel, err := c.generateSentinelToken(ctx, account, token, userAgent, proxyURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers.Set("openai-sentinel-token", sentinel)
|
|
|
|
|
|
|
|
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/nf/create/storyboard"), headers, bytes.NewReader(body), true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
taskID := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
|
|
|
|
|
|
if taskID == "" {
|
|
|
|
|
|
return "", errors.New("storyboard task response missing id")
|
|
|
|
|
|
}
|
|
|
|
|
|
return taskID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) UploadCharacterVideo(ctx context.Context, account *Account, data []byte) (string, error) {
|
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
|
return "", errors.New("empty video data")
|
|
|
|
|
|
}
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
|
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
|
|
partHeader := make(textproto.MIMEHeader)
|
|
|
|
|
|
partHeader.Set("Content-Disposition", `form-data; name="file"; filename="video.mp4"`)
|
|
|
|
|
|
partHeader.Set("Content-Type", "video/mp4")
|
|
|
|
|
|
part, err := writer.CreatePart(partHeader)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := part.Write(data); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.WriteField("timestamps", "0,3"); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.Close(); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
headers.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/characters/upload"), headers, &body, false)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
cameoID := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
|
|
|
|
|
|
if cameoID == "" {
|
|
|
|
|
|
return "", errors.New("character upload response missing id")
|
|
|
|
|
|
}
|
|
|
|
|
|
return cameoID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) GetCameoStatus(ctx context.Context, account *Account, cameoID string) (*SoraCameoStatus, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
respBody, _, err := c.doRequestWithProxy(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
account,
|
|
|
|
|
|
proxyURL,
|
|
|
|
|
|
http.MethodGet,
|
|
|
|
|
|
c.buildURL("/project_y/cameos/in_progress/"+strings.TrimSpace(cameoID)),
|
|
|
|
|
|
headers,
|
|
|
|
|
|
nil,
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return &SoraCameoStatus{
|
|
|
|
|
|
Status: strings.TrimSpace(gjson.GetBytes(respBody, "status").String()),
|
|
|
|
|
|
StatusMessage: strings.TrimSpace(gjson.GetBytes(respBody, "status_message").String()),
|
|
|
|
|
|
DisplayNameHint: strings.TrimSpace(gjson.GetBytes(respBody, "display_name_hint").String()),
|
|
|
|
|
|
UsernameHint: strings.TrimSpace(gjson.GetBytes(respBody, "username_hint").String()),
|
|
|
|
|
|
ProfileAssetURL: strings.TrimSpace(gjson.GetBytes(respBody, "profile_asset_url").String()),
|
|
|
|
|
|
InstructionSetHint: gjson.GetBytes(respBody, "instruction_set_hint").Value(),
|
|
|
|
|
|
InstructionSet: gjson.GetBytes(respBody, "instruction_set").Value(),
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) DownloadCharacterImage(ctx context.Context, account *Account, imageURL string) ([]byte, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
headers.Set("Accept", "image/*,*/*;q=0.8")
|
|
|
|
|
|
|
|
|
|
|
|
respBody, _, err := c.doRequestWithProxy(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
account,
|
|
|
|
|
|
proxyURL,
|
|
|
|
|
|
http.MethodGet,
|
|
|
|
|
|
strings.TrimSpace(imageURL),
|
|
|
|
|
|
headers,
|
|
|
|
|
|
nil,
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return respBody, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) UploadCharacterImage(ctx context.Context, account *Account, data []byte) (string, error) {
|
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
|
return "", errors.New("empty character image")
|
|
|
|
|
|
}
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
|
|
|
|
|
|
var body bytes.Buffer
|
|
|
|
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
|
|
partHeader := make(textproto.MIMEHeader)
|
|
|
|
|
|
partHeader.Set("Content-Disposition", `form-data; name="file"; filename="profile.webp"`)
|
|
|
|
|
|
partHeader.Set("Content-Type", "image/webp")
|
|
|
|
|
|
part, err := writer.CreatePart(partHeader)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := part.Write(data); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.WriteField("use_case", "profile"); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.Close(); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
headers.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/project_y/file/upload"), headers, &body, false)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
assetPointer := strings.TrimSpace(gjson.GetBytes(respBody, "asset_pointer").String())
|
|
|
|
|
|
if assetPointer == "" {
|
|
|
|
|
|
return "", errors.New("character image upload response missing asset_pointer")
|
|
|
|
|
|
}
|
|
|
|
|
|
return assetPointer, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) FinalizeCharacter(ctx context.Context, account *Account, req SoraCharacterFinalizeRequest) (string, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
2026-02-19 21:38:04 +08:00
|
|
|
|
ctx = c.withRequestTrace(ctx, account, proxyURL, userAgent)
|
2026-02-19 20:04:10 +08:00
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"cameo_id": req.CameoID,
|
|
|
|
|
|
"username": req.Username,
|
|
|
|
|
|
"display_name": req.DisplayName,
|
|
|
|
|
|
"profile_asset_pointer": req.ProfileAssetPointer,
|
|
|
|
|
|
"instruction_set": nil,
|
|
|
|
|
|
"safety_instruction_set": nil,
|
|
|
|
|
|
}
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/characters/finalize"), headers, bytes.NewReader(body), false)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
characterID := strings.TrimSpace(gjson.GetBytes(respBody, "character.character_id").String())
|
|
|
|
|
|
if characterID == "" {
|
|
|
|
|
|
return "", errors.New("character finalize response missing character_id")
|
|
|
|
|
|
}
|
|
|
|
|
|
return characterID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) SetCharacterPublic(ctx context.Context, account *Account, cameoID string) error {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
payload := map[string]any{"visibility": "public"}
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
_, _, err = c.doRequestWithProxy(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
account,
|
|
|
|
|
|
proxyURL,
|
|
|
|
|
|
http.MethodPost,
|
|
|
|
|
|
c.buildURL("/project_y/cameos/by_id/"+strings.TrimSpace(cameoID)+"/update_v2"),
|
|
|
|
|
|
headers,
|
|
|
|
|
|
bytes.NewReader(body),
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) DeleteCharacter(ctx context.Context, account *Account, characterID string) error {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
_, _, err = c.doRequestWithProxy(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
account,
|
|
|
|
|
|
proxyURL,
|
|
|
|
|
|
http.MethodDelete,
|
|
|
|
|
|
c.buildURL("/project_y/characters/"+strings.TrimSpace(characterID)),
|
|
|
|
|
|
headers,
|
|
|
|
|
|
nil,
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) PostVideoForWatermarkFree(ctx context.Context, account *Account, generationID string) (string, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
2026-02-19 21:38:04 +08:00
|
|
|
|
ctx = c.withRequestTrace(ctx, account, proxyURL, userAgent)
|
2026-02-19 20:04:10 +08:00
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"attachments_to_create": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"generation_id": generationID,
|
|
|
|
|
|
"kind": "sora",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"post_text": "",
|
|
|
|
|
|
}
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
sentinel, err := c.generateSentinelToken(ctx, account, token, userAgent, proxyURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers.Set("openai-sentinel-token", sentinel)
|
|
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/project_y/post"), headers, bytes.NewReader(body), true)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
postID := strings.TrimSpace(gjson.GetBytes(respBody, "post.id").String())
|
|
|
|
|
|
if postID == "" {
|
|
|
|
|
|
return "", errors.New("watermark-free publish response missing post.id")
|
|
|
|
|
|
}
|
|
|
|
|
|
return postID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) DeletePost(ctx context.Context, account *Account, postID string) error {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
|
|
|
|
|
_, _, err = c.doRequestWithProxy(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
account,
|
|
|
|
|
|
proxyURL,
|
|
|
|
|
|
http.MethodDelete,
|
|
|
|
|
|
c.buildURL("/project_y/post/"+strings.TrimSpace(postID)),
|
|
|
|
|
|
headers,
|
|
|
|
|
|
nil,
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) GetWatermarkFreeURLCustom(ctx context.Context, account *Account, parseURL, parseToken, postID string) (string, error) {
|
|
|
|
|
|
parseURL = strings.TrimRight(strings.TrimSpace(parseURL), "/")
|
|
|
|
|
|
if parseURL == "" {
|
|
|
|
|
|
return "", errors.New("custom parse url is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(parseToken) == "" {
|
|
|
|
|
|
return "", errors.New("custom parse token is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
shareURL := "https://sora.chatgpt.com/p/" + strings.TrimSpace(postID)
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"url": shareURL,
|
|
|
|
|
|
"token": strings.TrimSpace(parseToken),
|
|
|
|
|
|
}
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parseURL+"/get-sora-link", bytes.NewReader(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
accountID := int64(0)
|
|
|
|
|
|
accountConcurrency := 0
|
|
|
|
|
|
if account != nil {
|
|
|
|
|
|
accountID = account.ID
|
|
|
|
|
|
accountConcurrency = account.Concurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
var resp *http.Response
|
|
|
|
|
|
if c.httpUpstream != nil {
|
|
|
|
|
|
resp, err = c.httpUpstream.Do(req, proxyURL, accountID, accountConcurrency)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resp, err = http.DefaultClient.Do(req)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
return "", fmt.Errorf("custom parse failed: %d %s", resp.StatusCode, truncateForLog(raw, 256))
|
|
|
|
|
|
}
|
|
|
|
|
|
downloadLink := strings.TrimSpace(gjson.GetBytes(raw, "download_link").String())
|
|
|
|
|
|
if downloadLink == "" {
|
|
|
|
|
|
return "", errors.New("custom parse response missing download_link")
|
|
|
|
|
|
}
|
|
|
|
|
|
return downloadLink, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 08:02:56 +08:00
|
|
|
|
func (c *SoraDirectClient) EnhancePrompt(ctx context.Context, account *Account, prompt, expansionLevel string, durationS int) (string, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if strings.TrimSpace(expansionLevel) == "" {
|
|
|
|
|
|
expansionLevel = "medium"
|
|
|
|
|
|
}
|
|
|
|
|
|
if durationS <= 0 {
|
|
|
|
|
|
durationS = 10
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"prompt": prompt,
|
|
|
|
|
|
"expansion_level": expansionLevel,
|
|
|
|
|
|
"duration_s": durationS,
|
|
|
|
|
|
}
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Accept", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, c.buildURL("/editor/enhance_prompt"), headers, bytes.NewReader(body), false)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
enhancedPrompt := strings.TrimSpace(gjson.GetBytes(respBody, "enhanced_prompt").String())
|
|
|
|
|
|
if enhancedPrompt == "" {
|
|
|
|
|
|
return "", errors.New("enhance_prompt response missing enhanced_prompt")
|
|
|
|
|
|
}
|
|
|
|
|
|
return enhancedPrompt, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
func (c *SoraDirectClient) GetImageTask(ctx context.Context, account *Account, taskID string) (*SoraImageTaskStatus, error) {
|
2026-02-01 22:10:15 +08:00
|
|
|
|
status, found, err := c.fetchRecentImageTask(ctx, account, taskID, c.recentTaskLimit())
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-02-01 22:10:15 +08:00
|
|
|
|
if found {
|
|
|
|
|
|
return status, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
maxLimit := c.recentTaskLimitMax()
|
|
|
|
|
|
if maxLimit > 0 && maxLimit != c.recentTaskLimit() {
|
|
|
|
|
|
status, found, err = c.fetchRecentImageTask(ctx, account, taskID, maxLimit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if found {
|
|
|
|
|
|
return status, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return &SoraImageTaskStatus{ID: taskID, Status: "processing"}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) fetchRecentImageTask(ctx context.Context, account *Account, taskID string, limit int) (*SoraImageTaskStatus, bool, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, false, err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
2026-02-01 22:10:15 +08:00
|
|
|
|
if limit <= 0 {
|
|
|
|
|
|
limit = 20
|
|
|
|
|
|
}
|
|
|
|
|
|
endpoint := fmt.Sprintf("/v2/recent_tasks?limit=%d", limit)
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodGet, c.buildURL(endpoint), headers, nil, false)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
2026-02-01 22:10:15 +08:00
|
|
|
|
return nil, false, err
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
var found *SoraImageTaskStatus
|
|
|
|
|
|
gjson.GetBytes(respBody, "task_responses").ForEach(func(_, item gjson.Result) bool {
|
|
|
|
|
|
if item.Get("id").String() != taskID {
|
|
|
|
|
|
return true // continue
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
status := strings.TrimSpace(item.Get("status").String())
|
|
|
|
|
|
progress := item.Get("progress_pct").Float()
|
|
|
|
|
|
var urls []string
|
|
|
|
|
|
item.Get("generations").ForEach(func(_, gen gjson.Result) bool {
|
|
|
|
|
|
if u := strings.TrimSpace(gen.Get("url").String()); u != "" {
|
|
|
|
|
|
urls = append(urls, u)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
found = &SoraImageTaskStatus{
|
|
|
|
|
|
ID: taskID,
|
|
|
|
|
|
Status: status,
|
|
|
|
|
|
ProgressPct: progress,
|
|
|
|
|
|
URLs: urls,
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
return false // break
|
|
|
|
|
|
})
|
|
|
|
|
|
if found != nil {
|
|
|
|
|
|
return found, true, nil
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-01 22:10:15 +08:00
|
|
|
|
return &SoraImageTaskStatus{ID: taskID, Status: "processing"}, false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) recentTaskLimit() int {
|
|
|
|
|
|
if c == nil || c.cfg == nil {
|
|
|
|
|
|
return 20
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.cfg.Sora.Client.RecentTaskLimit > 0 {
|
|
|
|
|
|
return c.cfg.Sora.Client.RecentTaskLimit
|
|
|
|
|
|
}
|
|
|
|
|
|
return 20
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) recentTaskLimitMax() int {
|
|
|
|
|
|
if c == nil || c.cfg == nil {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.cfg.Sora.Client.RecentTaskLimitMax > 0 {
|
|
|
|
|
|
return c.cfg.Sora.Client.RecentTaskLimitMax
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) GetVideoTask(ctx context.Context, account *Account, taskID string) (*SoraVideoTaskStatus, error) {
|
|
|
|
|
|
token, err := c.getAccessToken(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent := c.taskUserAgent()
|
|
|
|
|
|
proxyURL := c.resolveProxyURL(account)
|
|
|
|
|
|
headers := c.buildBaseHeaders(token, userAgent)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodGet, c.buildURL("/nf/pending/v2"), headers, nil, false)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
// 搜索 pending 列表(JSON 数组)
|
|
|
|
|
|
pendingResult := gjson.ParseBytes(respBody)
|
|
|
|
|
|
if pendingResult.IsArray() {
|
|
|
|
|
|
var pendingFound *SoraVideoTaskStatus
|
|
|
|
|
|
pendingResult.ForEach(func(_, task gjson.Result) bool {
|
|
|
|
|
|
if task.Get("id").String() != taskID {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
progress := 0
|
|
|
|
|
|
if v := task.Get("progress_pct"); v.Exists() {
|
|
|
|
|
|
progress = int(v.Float() * 100)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
status := strings.TrimSpace(task.Get("status").String())
|
|
|
|
|
|
pendingFound = &SoraVideoTaskStatus{
|
|
|
|
|
|
ID: taskID,
|
|
|
|
|
|
Status: status,
|
|
|
|
|
|
ProgressPct: progress,
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
})
|
|
|
|
|
|
if pendingFound != nil {
|
|
|
|
|
|
return pendingFound, nil
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err = c.doRequestWithProxy(ctx, account, proxyURL, http.MethodGet, c.buildURL("/project_y/profile/drafts?limit=15"), headers, nil, false)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
var draftFound *SoraVideoTaskStatus
|
|
|
|
|
|
gjson.GetBytes(respBody, "items").ForEach(func(_, draft gjson.Result) bool {
|
|
|
|
|
|
if draft.Get("task_id").String() != taskID {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-02-19 20:04:10 +08:00
|
|
|
|
generationID := strings.TrimSpace(draft.Get("id").String())
|
2026-02-10 08:59:30 +08:00
|
|
|
|
kind := strings.TrimSpace(draft.Get("kind").String())
|
|
|
|
|
|
reason := strings.TrimSpace(draft.Get("reason_str").String())
|
|
|
|
|
|
if reason == "" {
|
|
|
|
|
|
reason = strings.TrimSpace(draft.Get("markdown_reason_str").String())
|
|
|
|
|
|
}
|
|
|
|
|
|
urlStr := strings.TrimSpace(draft.Get("downloadable_url").String())
|
|
|
|
|
|
if urlStr == "" {
|
|
|
|
|
|
urlStr = strings.TrimSpace(draft.Get("url").String())
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 08:59:30 +08:00
|
|
|
|
if kind == "sora_content_violation" || reason != "" || urlStr == "" {
|
|
|
|
|
|
msg := reason
|
|
|
|
|
|
if msg == "" {
|
|
|
|
|
|
msg = "Content violates guardrails"
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
draftFound = &SoraVideoTaskStatus{
|
2026-02-19 20:04:10 +08:00
|
|
|
|
ID: taskID,
|
|
|
|
|
|
Status: "failed",
|
|
|
|
|
|
GenerationID: generationID,
|
|
|
|
|
|
ErrorMsg: msg,
|
2026-02-10 08:59:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
draftFound = &SoraVideoTaskStatus{
|
2026-02-19 20:04:10 +08:00
|
|
|
|
ID: taskID,
|
|
|
|
|
|
Status: "completed",
|
|
|
|
|
|
GenerationID: generationID,
|
|
|
|
|
|
URLs: []string{urlStr},
|
2026-02-10 08:59:30 +08:00
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-10 08:59:30 +08:00
|
|
|
|
return false
|
|
|
|
|
|
})
|
|
|
|
|
|
if draftFound != nil {
|
|
|
|
|
|
return draftFound, nil
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &SoraVideoTaskStatus{ID: taskID, Status: "processing"}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) buildURL(endpoint string) string {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
base := strings.TrimRight(strings.TrimSpace(c.baseURL), "/")
|
|
|
|
|
|
if base == "" && c != nil && c.cfg != nil {
|
|
|
|
|
|
base = normalizeSoraBaseURL(c.cfg.Sora.Client.BaseURL)
|
|
|
|
|
|
c.baseURL = base
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
if base == "" {
|
|
|
|
|
|
return endpoint
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.HasPrefix(endpoint, "/") {
|
|
|
|
|
|
return base + endpoint
|
|
|
|
|
|
}
|
|
|
|
|
|
return base + "/" + endpoint
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) defaultUserAgent() string {
|
|
|
|
|
|
if c == nil || c.cfg == nil {
|
|
|
|
|
|
return soraDefaultUserAgent
|
|
|
|
|
|
}
|
|
|
|
|
|
ua := strings.TrimSpace(c.cfg.Sora.Client.UserAgent)
|
|
|
|
|
|
if ua == "" {
|
|
|
|
|
|
return soraDefaultUserAgent
|
|
|
|
|
|
}
|
|
|
|
|
|
return ua
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
func (c *SoraDirectClient) taskUserAgent() string {
|
|
|
|
|
|
if c != nil && c.cfg != nil {
|
|
|
|
|
|
if ua := strings.TrimSpace(c.cfg.Sora.Client.UserAgent); ua != "" {
|
|
|
|
|
|
return ua
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-19 20:04:10 +08:00
|
|
|
|
if len(soraMobileUserAgents) > 0 {
|
|
|
|
|
|
return soraMobileUserAgents[soraRandInt(len(soraMobileUserAgents))]
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
if len(soraDesktopUserAgents) > 0 {
|
2026-02-19 20:04:10 +08:00
|
|
|
|
return soraDesktopUserAgents[soraRandInt(len(soraDesktopUserAgents))]
|
2026-02-19 15:09:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
return soraDefaultUserAgent
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) resolveProxyURL(account *Account) string {
|
|
|
|
|
|
if account == nil || account.ProxyID == nil || account.Proxy == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.TrimSpace(account.Proxy.URL())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
func (c *SoraDirectClient) getAccessToken(ctx context.Context, account *Account) (string, error) {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return "", errors.New("account is nil")
|
|
|
|
|
|
}
|
2026-02-19 08:02:56 +08:00
|
|
|
|
|
|
|
|
|
|
allowProvider := c.allowOpenAITokenProvider(account)
|
|
|
|
|
|
var providerErr error
|
|
|
|
|
|
if allowProvider && c.tokenProvider != nil {
|
|
|
|
|
|
token, err := c.tokenProvider.GetAccessToken(ctx, account)
|
|
|
|
|
|
if err == nil && strings.TrimSpace(token) != "" {
|
|
|
|
|
|
c.logTokenSource(account, "openai_token_provider")
|
|
|
|
|
|
return token, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
providerErr = err
|
|
|
|
|
|
if err != nil && c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf(
|
|
|
|
|
|
"token_provider_failed account_id=%d platform=%s err=%s",
|
|
|
|
|
|
account.ID,
|
|
|
|
|
|
account.Platform,
|
|
|
|
|
|
logredact.RedactText(err.Error()),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
token := strings.TrimSpace(account.GetCredential("access_token"))
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if token != "" {
|
|
|
|
|
|
expiresAt := account.GetCredentialAsTime("expires_at")
|
|
|
|
|
|
if expiresAt != nil && time.Until(*expiresAt) <= 2*time.Minute {
|
|
|
|
|
|
refreshed, refreshErr := c.recoverAccessToken(ctx, account, "access_token_expiring")
|
|
|
|
|
|
if refreshErr == nil && strings.TrimSpace(refreshed) != "" {
|
|
|
|
|
|
c.logTokenSource(account, "refresh_token_recovered")
|
|
|
|
|
|
return refreshed, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if refreshErr != nil && c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf("token_refresh_before_use_failed account_id=%d err=%s", account.ID, logredact.RedactText(refreshErr.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
c.logTokenSource(account, "account_credentials")
|
|
|
|
|
|
return token, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
recovered, recoverErr := c.recoverAccessToken(ctx, account, "access_token_missing")
|
|
|
|
|
|
if recoverErr == nil && strings.TrimSpace(recovered) != "" {
|
|
|
|
|
|
c.logTokenSource(account, "session_or_refresh_recovered")
|
|
|
|
|
|
return recovered, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if recoverErr != nil && c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf("token_recover_failed account_id=%d platform=%s err=%s", account.ID, account.Platform, logredact.RedactText(recoverErr.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
if providerErr != nil {
|
|
|
|
|
|
return "", providerErr
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.tokenProvider != nil && !allowProvider {
|
|
|
|
|
|
c.logTokenSource(account, "account_credentials(provider_disabled)")
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", errors.New("access_token not found")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) recoverAccessToken(ctx context.Context, account *Account, reason string) (string, error) {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return "", errors.New("account is nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if sessionToken := strings.TrimSpace(account.GetCredential("session_token")); sessionToken != "" {
|
|
|
|
|
|
accessToken, expiresAt, err := c.exchangeSessionToken(ctx, account, sessionToken)
|
|
|
|
|
|
if err == nil && strings.TrimSpace(accessToken) != "" {
|
|
|
|
|
|
c.applyRecoveredToken(ctx, account, accessToken, "", expiresAt, sessionToken)
|
|
|
|
|
|
c.logTokenRecover(account, "session_token", reason, true, nil)
|
|
|
|
|
|
return accessToken, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
c.logTokenRecover(account, "session_token", reason, false, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
refreshToken := strings.TrimSpace(account.GetCredential("refresh_token"))
|
|
|
|
|
|
if refreshToken == "" {
|
|
|
|
|
|
return "", errors.New("session_token/refresh_token not found")
|
|
|
|
|
|
}
|
|
|
|
|
|
accessToken, newRefreshToken, expiresAt, err := c.exchangeRefreshToken(ctx, account, refreshToken)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.logTokenRecover(account, "refresh_token", reason, false, err)
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(accessToken) == "" {
|
|
|
|
|
|
return "", errors.New("refreshed access_token is empty")
|
|
|
|
|
|
}
|
|
|
|
|
|
c.applyRecoveredToken(ctx, account, accessToken, newRefreshToken, expiresAt, "")
|
|
|
|
|
|
c.logTokenRecover(account, "refresh_token", reason, true, nil)
|
|
|
|
|
|
return accessToken, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) exchangeSessionToken(ctx context.Context, account *Account, sessionToken string) (string, string, error) {
|
|
|
|
|
|
headers := http.Header{}
|
|
|
|
|
|
headers.Set("Cookie", "__Secure-next-auth.session-token="+sessionToken)
|
|
|
|
|
|
headers.Set("Accept", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
headers.Set("User-Agent", c.defaultUserAgent())
|
|
|
|
|
|
body, _, err := c.doRequest(ctx, account, http.MethodGet, soraSessionAuthURL, headers, nil, false)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
accessToken := strings.TrimSpace(gjson.GetBytes(body, "accessToken").String())
|
|
|
|
|
|
if accessToken == "" {
|
|
|
|
|
|
return "", "", errors.New("session exchange missing accessToken")
|
|
|
|
|
|
}
|
|
|
|
|
|
expiresAt := strings.TrimSpace(gjson.GetBytes(body, "expires").String())
|
|
|
|
|
|
return accessToken, expiresAt, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) exchangeRefreshToken(ctx context.Context, account *Account, refreshToken string) (string, string, string, error) {
|
|
|
|
|
|
clientIDs := []string{
|
|
|
|
|
|
strings.TrimSpace(account.GetCredential("client_id")),
|
|
|
|
|
|
openaioauth.SoraClientID,
|
|
|
|
|
|
openaioauth.ClientID,
|
|
|
|
|
|
}
|
|
|
|
|
|
tried := make(map[string]struct{}, len(clientIDs))
|
|
|
|
|
|
var lastErr error
|
|
|
|
|
|
|
|
|
|
|
|
for _, clientID := range clientIDs {
|
|
|
|
|
|
if clientID == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := tried[clientID]; ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
tried[clientID] = struct{}{}
|
|
|
|
|
|
|
2026-02-19 08:23:00 +08:00
|
|
|
|
formData := url.Values{}
|
|
|
|
|
|
formData.Set("client_id", clientID)
|
|
|
|
|
|
formData.Set("grant_type", "refresh_token")
|
|
|
|
|
|
formData.Set("refresh_token", refreshToken)
|
|
|
|
|
|
formData.Set("redirect_uri", "com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback")
|
2026-02-19 08:02:56 +08:00
|
|
|
|
headers := http.Header{}
|
|
|
|
|
|
headers.Set("Accept", "application/json")
|
2026-02-19 08:23:00 +08:00
|
|
|
|
headers.Set("Content-Type", "application/x-www-form-urlencoded")
|
2026-02-19 08:02:56 +08:00
|
|
|
|
headers.Set("User-Agent", c.defaultUserAgent())
|
|
|
|
|
|
|
2026-02-19 08:23:00 +08:00
|
|
|
|
respBody, _, err := c.doRequest(ctx, account, http.MethodPost, soraOAuthTokenURL, headers, strings.NewReader(formData.Encode()), false)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
lastErr = err
|
|
|
|
|
|
if c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf("refresh_token_exchange_failed account_id=%d client_id=%s err=%s", account.ID, clientID, logredact.RedactText(err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
accessToken := strings.TrimSpace(gjson.GetBytes(respBody, "access_token").String())
|
|
|
|
|
|
if accessToken == "" {
|
|
|
|
|
|
lastErr = errors.New("oauth refresh response missing access_token")
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
newRefreshToken := strings.TrimSpace(gjson.GetBytes(respBody, "refresh_token").String())
|
|
|
|
|
|
expiresIn := gjson.GetBytes(respBody, "expires_in").Int()
|
|
|
|
|
|
expiresAt := ""
|
|
|
|
|
|
if expiresIn > 0 {
|
|
|
|
|
|
expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second).Format(time.RFC3339)
|
|
|
|
|
|
}
|
|
|
|
|
|
return accessToken, newRefreshToken, expiresAt, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if lastErr != nil {
|
|
|
|
|
|
return "", "", "", lastErr
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", "", "", errors.New("no available client_id for refresh_token exchange")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) applyRecoveredToken(ctx context.Context, account *Account, accessToken, refreshToken, expiresAt, sessionToken string) {
|
|
|
|
|
|
if account == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if account.Credentials == nil {
|
|
|
|
|
|
account.Credentials = make(map[string]any)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(accessToken) != "" {
|
|
|
|
|
|
account.Credentials["access_token"] = accessToken
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(refreshToken) != "" {
|
|
|
|
|
|
account.Credentials["refresh_token"] = refreshToken
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(expiresAt) != "" {
|
|
|
|
|
|
account.Credentials["expires_at"] = expiresAt
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(sessionToken) != "" {
|
|
|
|
|
|
account.Credentials["session_token"] = sessionToken
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if c.accountRepo != nil {
|
|
|
|
|
|
if err := c.accountRepo.Update(ctx, account); err != nil {
|
|
|
|
|
|
if c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf("persist_recovered_token_failed account_id=%d err=%s", account.ID, logredact.RedactText(err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
c.updateSoraAccountExtension(ctx, account, accessToken, refreshToken, sessionToken)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) updateSoraAccountExtension(ctx context.Context, account *Account, accessToken, refreshToken, sessionToken string) {
|
|
|
|
|
|
if c == nil || c.soraAccountRepo == nil || account == nil || account.ID <= 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
updates := make(map[string]any)
|
|
|
|
|
|
if strings.TrimSpace(accessToken) != "" && strings.TrimSpace(refreshToken) != "" {
|
|
|
|
|
|
updates["access_token"] = accessToken
|
|
|
|
|
|
updates["refresh_token"] = refreshToken
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(sessionToken) != "" {
|
|
|
|
|
|
updates["session_token"] = sessionToken
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(updates) == 0 {
|
|
|
|
|
|
return
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if err := c.soraAccountRepo.Upsert(ctx, account.ID, updates); err != nil && c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf("persist_sora_extension_failed account_id=%d err=%s", account.ID, logredact.RedactText(err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) logTokenRecover(account *Account, source, reason string, success bool, err error) {
|
|
|
|
|
|
if !c.debugEnabled() || account == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if success {
|
|
|
|
|
|
c.debugLogf("token_recover_success account_id=%d platform=%s source=%s reason=%s", account.ID, account.Platform, source, reason)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
c.debugLogf("token_recover_failed account_id=%d platform=%s source=%s reason=%s", account.ID, account.Platform, source, reason)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
c.debugLogf("token_recover_failed account_id=%d platform=%s source=%s reason=%s err=%s", account.ID, account.Platform, source, reason, logredact.RedactText(err.Error()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) allowOpenAITokenProvider(account *Account) bool {
|
|
|
|
|
|
if c == nil || c.tokenProvider == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if account != nil && account.Platform == PlatformSora {
|
|
|
|
|
|
return c.cfg != nil && c.cfg.Sora.Client.UseOpenAITokenProvider
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) logTokenSource(account *Account, source string) {
|
|
|
|
|
|
if !c.debugEnabled() || account == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
c.debugLogf(
|
|
|
|
|
|
"token_selected account_id=%d platform=%s account_type=%s source=%s",
|
|
|
|
|
|
account.ID,
|
|
|
|
|
|
account.Platform,
|
|
|
|
|
|
account.Type,
|
|
|
|
|
|
source,
|
|
|
|
|
|
)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) buildBaseHeaders(token, userAgent string) http.Header {
|
|
|
|
|
|
headers := http.Header{}
|
|
|
|
|
|
if token != "" {
|
|
|
|
|
|
headers.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
}
|
|
|
|
|
|
if userAgent != "" {
|
|
|
|
|
|
headers.Set("User-Agent", userAgent)
|
|
|
|
|
|
}
|
|
|
|
|
|
if c != nil && c.cfg != nil {
|
|
|
|
|
|
for key, value := range c.cfg.Sora.Client.Headers {
|
|
|
|
|
|
if strings.EqualFold(key, "authorization") || strings.EqualFold(key, "openai-sentinel-token") {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
headers.Set(key, value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return headers
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) doRequest(ctx context.Context, account *Account, method, urlStr string, headers http.Header, body io.Reader, allowRetry bool) ([]byte, http.Header, error) {
|
2026-02-19 15:09:58 +08:00
|
|
|
|
return c.doRequestWithProxy(ctx, account, c.resolveProxyURL(account), method, urlStr, headers, body, allowRetry)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) doRequestWithProxy(
|
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
|
account *Account,
|
|
|
|
|
|
proxyURL string,
|
|
|
|
|
|
method,
|
|
|
|
|
|
urlStr string,
|
|
|
|
|
|
headers http.Header,
|
|
|
|
|
|
body io.Reader,
|
|
|
|
|
|
allowRetry bool,
|
|
|
|
|
|
) ([]byte, http.Header, error) {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if strings.TrimSpace(urlStr) == "" {
|
|
|
|
|
|
return nil, nil, errors.New("empty upstream url")
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
proxyURL = strings.TrimSpace(proxyURL)
|
|
|
|
|
|
if proxyURL == "" {
|
|
|
|
|
|
proxyURL = c.resolveProxyURL(account)
|
|
|
|
|
|
}
|
2026-02-19 21:18:35 +08:00
|
|
|
|
if cooldownErr := c.checkCloudflareChallengeCooldown(account, proxyURL); cooldownErr != nil {
|
|
|
|
|
|
return nil, nil, cooldownErr
|
|
|
|
|
|
}
|
2026-02-19 21:38:04 +08:00
|
|
|
|
traceID, traceProxyKey, traceUAHash := c.requestTraceFields(ctx, proxyURL, headers.Get("User-Agent"))
|
2026-02-01 21:37:10 +08:00
|
|
|
|
timeout := 0
|
|
|
|
|
|
if c != nil && c.cfg != nil {
|
|
|
|
|
|
timeout = c.cfg.Sora.Client.TimeoutSeconds
|
|
|
|
|
|
}
|
|
|
|
|
|
if timeout > 0 {
|
|
|
|
|
|
var cancel context.CancelFunc
|
|
|
|
|
|
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
}
|
|
|
|
|
|
maxRetries := 0
|
|
|
|
|
|
if allowRetry && c != nil && c.cfg != nil {
|
|
|
|
|
|
maxRetries = c.cfg.Sora.Client.MaxRetries
|
|
|
|
|
|
}
|
|
|
|
|
|
if maxRetries < 0 {
|
|
|
|
|
|
maxRetries = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var bodyBytes []byte
|
|
|
|
|
|
if body != nil {
|
|
|
|
|
|
b, err := io.ReadAll(body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
bodyBytes = b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
attempts := maxRetries + 1
|
2026-02-19 08:02:56 +08:00
|
|
|
|
authRecovered := false
|
|
|
|
|
|
authRecoverExtraAttemptGranted := false
|
2026-02-19 21:38:04 +08:00
|
|
|
|
challengeRetried := false
|
|
|
|
|
|
sawCFChallenge := false
|
2026-02-19 08:02:56 +08:00
|
|
|
|
var lastErr error
|
2026-02-01 21:37:10 +08:00
|
|
|
|
for attempt := 1; attempt <= attempts; attempt++ {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf(
|
2026-02-19 21:38:04 +08:00
|
|
|
|
"request_start trace_id=%s method=%s url=%s attempt=%d/%d timeout_s=%d body_bytes=%d proxy_bound=%t proxy_key=%s ua_hash=%s headers=%s",
|
|
|
|
|
|
traceID,
|
2026-02-19 08:02:56 +08:00
|
|
|
|
method,
|
|
|
|
|
|
sanitizeSoraLogURL(urlStr),
|
|
|
|
|
|
attempt,
|
|
|
|
|
|
attempts,
|
|
|
|
|
|
timeout,
|
|
|
|
|
|
len(bodyBytes),
|
2026-02-19 15:09:58 +08:00
|
|
|
|
proxyURL != "",
|
2026-02-19 21:38:04 +08:00
|
|
|
|
traceProxyKey,
|
|
|
|
|
|
traceUAHash,
|
2026-02-19 08:02:56 +08:00
|
|
|
|
formatSoraHeaders(headers),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
var reader io.Reader
|
|
|
|
|
|
if bodyBytes != nil {
|
|
|
|
|
|
reader = bytes.NewReader(bodyBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, urlStr, reader)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header = headers.Clone()
|
|
|
|
|
|
start := time.Now()
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := c.doHTTP(req, proxyURL, account)
|
|
|
|
|
|
if err != nil {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
lastErr = err
|
|
|
|
|
|
if c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf(
|
2026-02-19 21:38:04 +08:00
|
|
|
|
"request_transport_error trace_id=%s method=%s url=%s attempt=%d/%d err=%s",
|
|
|
|
|
|
traceID,
|
2026-02-19 08:02:56 +08:00
|
|
|
|
method,
|
|
|
|
|
|
sanitizeSoraLogURL(urlStr),
|
|
|
|
|
|
attempt,
|
|
|
|
|
|
attempts,
|
|
|
|
|
|
logredact.RedactText(err.Error()),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if attempt < attempts && allowRetry {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if c.debugEnabled() {
|
2026-02-19 21:38:04 +08:00
|
|
|
|
c.debugLogf("request_retry_scheduled trace_id=%s method=%s url=%s reason=transport_error next_attempt=%d/%d", traceID, method, sanitizeSoraLogURL(urlStr), attempt+1, attempts)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
c.sleepRetry(attempt)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
|
return nil, resp.Header, readErr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if c.cfg != nil && c.cfg.Sora.Client.Debug {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
c.debugLogf(
|
2026-02-19 21:38:04 +08:00
|
|
|
|
"response_received trace_id=%s method=%s url=%s attempt=%d/%d status=%d cost=%s resp_bytes=%d resp_headers=%s",
|
|
|
|
|
|
traceID,
|
2026-02-19 08:02:56 +08:00
|
|
|
|
method,
|
|
|
|
|
|
sanitizeSoraLogURL(urlStr),
|
|
|
|
|
|
attempt,
|
|
|
|
|
|
attempts,
|
|
|
|
|
|
resp.StatusCode,
|
|
|
|
|
|
time.Since(start),
|
|
|
|
|
|
len(respBody),
|
|
|
|
|
|
formatSoraHeaders(resp.Header),
|
|
|
|
|
|
)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
2026-02-19 21:18:35 +08:00
|
|
|
|
isCFChallenge := soraerror.IsCloudflareChallengeResponse(resp.StatusCode, resp.Header, respBody)
|
|
|
|
|
|
if isCFChallenge {
|
2026-02-19 21:38:04 +08:00
|
|
|
|
sawCFChallenge = true
|
2026-02-19 21:18:35 +08:00
|
|
|
|
c.recordCloudflareChallengeCooldown(account, proxyURL, resp.StatusCode, resp.Header, respBody)
|
2026-02-19 21:38:04 +08:00
|
|
|
|
if allowRetry && attempt < attempts && !challengeRetried {
|
|
|
|
|
|
challengeRetried = true
|
|
|
|
|
|
if c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf("request_retry_scheduled trace_id=%s method=%s url=%s reason=cloudflare_challenge status=%d next_attempt=%d/%d", traceID, method, sanitizeSoraLogURL(urlStr), resp.StatusCode, attempt+1, attempts)
|
|
|
|
|
|
}
|
|
|
|
|
|
c.sleepRetry(attempt)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-02-19 21:18:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !isCFChallenge && !authRecovered && shouldAttemptSoraTokenRecover(resp.StatusCode, urlStr) && account != nil {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if recovered, recoverErr := c.recoverAccessToken(ctx, account, fmt.Sprintf("upstream_status_%d", resp.StatusCode)); recoverErr == nil && strings.TrimSpace(recovered) != "" {
|
|
|
|
|
|
headers.Set("Authorization", "Bearer "+recovered)
|
|
|
|
|
|
authRecovered = true
|
|
|
|
|
|
if attempt == attempts && !authRecoverExtraAttemptGranted {
|
|
|
|
|
|
attempts++
|
|
|
|
|
|
authRecoverExtraAttemptGranted = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.debugEnabled() {
|
2026-02-19 21:38:04 +08:00
|
|
|
|
c.debugLogf("request_retry_with_recovered_token trace_id=%s method=%s url=%s status=%d", traceID, method, sanitizeSoraLogURL(urlStr), resp.StatusCode)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
} else if recoverErr != nil && c.debugEnabled() {
|
2026-02-19 21:38:04 +08:00
|
|
|
|
c.debugLogf("request_recover_token_failed trace_id=%s method=%s url=%s status=%d err=%s", traceID, method, sanitizeSoraLogURL(urlStr), resp.StatusCode, logredact.RedactText(recoverErr.Error()))
|
2026-02-19 08:02:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.debugEnabled() {
|
|
|
|
|
|
c.debugLogf(
|
2026-02-19 21:38:04 +08:00
|
|
|
|
"response_non_success trace_id=%s method=%s url=%s attempt=%d/%d status=%d body=%s",
|
|
|
|
|
|
traceID,
|
2026-02-19 08:02:56 +08:00
|
|
|
|
method,
|
|
|
|
|
|
sanitizeSoraLogURL(urlStr),
|
|
|
|
|
|
attempt,
|
|
|
|
|
|
attempts,
|
|
|
|
|
|
resp.StatusCode,
|
|
|
|
|
|
summarizeSoraResponseBody(respBody, 512),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
upstreamErr := c.buildUpstreamError(resp.StatusCode, resp.Header, respBody, urlStr)
|
|
|
|
|
|
lastErr = upstreamErr
|
2026-02-19 21:18:35 +08:00
|
|
|
|
if isCFChallenge {
|
|
|
|
|
|
return nil, resp.Header, upstreamErr
|
|
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if allowRetry && attempt < attempts && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500) {
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if c.debugEnabled() {
|
2026-02-19 21:38:04 +08:00
|
|
|
|
c.debugLogf("request_retry_scheduled trace_id=%s method=%s url=%s reason=status_%d next_attempt=%d/%d", traceID, method, sanitizeSoraLogURL(urlStr), resp.StatusCode, attempt+1, attempts)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
c.sleepRetry(attempt)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, resp.Header, upstreamErr
|
|
|
|
|
|
}
|
2026-02-19 21:38:04 +08:00
|
|
|
|
if sawCFChallenge {
|
|
|
|
|
|
c.clearCloudflareChallengeCooldown(account, proxyURL)
|
|
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return respBody, resp.Header, nil
|
|
|
|
|
|
}
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if lastErr != nil {
|
|
|
|
|
|
return nil, nil, lastErr
|
|
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return nil, nil, errors.New("upstream retries exhausted")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 08:02:56 +08:00
|
|
|
|
func shouldAttemptSoraTokenRecover(statusCode int, rawURL string) bool {
|
|
|
|
|
|
switch statusCode {
|
|
|
|
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
|
|
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
host := strings.ToLower(parsed.Hostname())
|
|
|
|
|
|
if host != "sora.chatgpt.com" && host != "chatgpt.com" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
// 避免在 ST->AT 转换接口上递归触发 token 恢复导致死循环。
|
|
|
|
|
|
path := strings.ToLower(strings.TrimSpace(parsed.Path))
|
|
|
|
|
|
if path == "/api/auth/session" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
func (c *SoraDirectClient) doHTTP(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
2026-02-19 20:29:31 +08:00
|
|
|
|
if c != nil && c.cfg != nil && c.cfg.Sora.Client.CurlCFFISidecar.Enabled {
|
2026-02-19 21:18:35 +08:00
|
|
|
|
resp, err := c.doHTTPViaCurlCFFISidecar(req, proxyURL, account)
|
2026-02-19 20:29:31 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return resp, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 20:04:10 +08:00
|
|
|
|
enableTLS := c == nil || c.cfg == nil || !c.cfg.Sora.Client.DisableTLSFingerprint
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if c.httpUpstream != nil {
|
|
|
|
|
|
accountID := int64(0)
|
|
|
|
|
|
accountConcurrency := 0
|
|
|
|
|
|
if account != nil {
|
|
|
|
|
|
accountID = account.ID
|
|
|
|
|
|
accountConcurrency = account.Concurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
return c.httpUpstream.DoWithTLS(req, proxyURL, accountID, accountConcurrency, enableTLS)
|
|
|
|
|
|
}
|
|
|
|
|
|
return http.DefaultClient.Do(req)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) sleepRetry(attempt int) {
|
|
|
|
|
|
backoff := time.Duration(attempt*attempt) * time.Second
|
|
|
|
|
|
if backoff > 10*time.Second {
|
|
|
|
|
|
backoff = 10 * time.Second
|
|
|
|
|
|
}
|
|
|
|
|
|
time.Sleep(backoff)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 08:02:56 +08:00
|
|
|
|
func (c *SoraDirectClient) buildUpstreamError(status int, headers http.Header, body []byte, requestURL string) error {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
msg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
|
|
|
|
|
msg = sanitizeUpstreamErrorMessage(msg)
|
2026-02-19 08:02:56 +08:00
|
|
|
|
if status == http.StatusNotFound && strings.Contains(strings.ToLower(msg), "not found") {
|
|
|
|
|
|
if hint := soraBaseURLNotFoundHint(requestURL); hint != "" {
|
|
|
|
|
|
msg = strings.TrimSpace(msg + " " + hint)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if msg == "" {
|
|
|
|
|
|
msg = truncateForLog(body, 256)
|
|
|
|
|
|
}
|
|
|
|
|
|
return &SoraUpstreamError{
|
|
|
|
|
|
StatusCode: status,
|
|
|
|
|
|
Message: msg,
|
|
|
|
|
|
Headers: headers,
|
|
|
|
|
|
Body: body,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 08:02:56 +08:00
|
|
|
|
func normalizeSoraBaseURL(raw string) string {
|
|
|
|
|
|
trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
|
|
|
|
|
|
if trimmed == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
parsed, err := url.Parse(trimmed)
|
|
|
|
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
|
|
|
|
return trimmed
|
|
|
|
|
|
}
|
|
|
|
|
|
host := strings.ToLower(parsed.Hostname())
|
|
|
|
|
|
if host != "sora.chatgpt.com" && host != "chatgpt.com" {
|
|
|
|
|
|
return trimmed
|
|
|
|
|
|
}
|
|
|
|
|
|
pathVal := strings.TrimRight(strings.TrimSpace(parsed.Path), "/")
|
|
|
|
|
|
switch pathVal {
|
|
|
|
|
|
case "", "/":
|
|
|
|
|
|
parsed.Path = "/backend"
|
|
|
|
|
|
case "/backend-api":
|
|
|
|
|
|
parsed.Path = "/backend"
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.TrimRight(parsed.String(), "/")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func soraBaseURLNotFoundHint(requestURL string) string {
|
|
|
|
|
|
parsed, err := url.Parse(strings.TrimSpace(requestURL))
|
|
|
|
|
|
if err != nil || parsed.Host == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
host := strings.ToLower(parsed.Hostname())
|
|
|
|
|
|
if host != "sora.chatgpt.com" && host != "chatgpt.com" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
pathVal := strings.TrimSpace(parsed.Path)
|
|
|
|
|
|
if strings.HasPrefix(pathVal, "/backend/") || pathVal == "/backend" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return "(请检查 sora.client.base_url,建议配置为 https://sora.chatgpt.com/backend)"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
func (c *SoraDirectClient) generateSentinelToken(ctx context.Context, account *Account, accessToken, userAgent, proxyURL string) (string, error) {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
reqID := uuid.NewString()
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent = strings.TrimSpace(userAgent)
|
|
|
|
|
|
if userAgent == "" {
|
|
|
|
|
|
userAgent = c.taskUserAgent()
|
|
|
|
|
|
}
|
|
|
|
|
|
powToken := soraPowTokenGenerator(userAgent)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"p": powToken,
|
|
|
|
|
|
"flow": soraSentinelFlow,
|
|
|
|
|
|
"id": reqID,
|
|
|
|
|
|
}
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
headers := http.Header{}
|
|
|
|
|
|
headers.Set("Accept", "application/json, text/plain, */*")
|
|
|
|
|
|
headers.Set("Content-Type", "application/json")
|
|
|
|
|
|
headers.Set("Origin", "https://sora.chatgpt.com")
|
|
|
|
|
|
headers.Set("Referer", "https://sora.chatgpt.com/")
|
|
|
|
|
|
headers.Set("User-Agent", userAgent)
|
|
|
|
|
|
if accessToken != "" {
|
|
|
|
|
|
headers.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
urlStr := soraChatGPTBaseURL + "/backend-api/sentinel/req"
|
2026-02-19 15:09:58 +08:00
|
|
|
|
respBody, _, err := c.doRequestWithProxy(ctx, account, proxyURL, http.MethodPost, urlStr, headers, bytes.NewReader(body), true)
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
var resp map[string]any
|
|
|
|
|
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sentinel := soraBuildSentinelToken(soraSentinelFlow, reqID, powToken, resp, userAgent)
|
|
|
|
|
|
if sentinel == "" {
|
|
|
|
|
|
return "", errors.New("failed to build sentinel token")
|
|
|
|
|
|
}
|
|
|
|
|
|
return sentinel, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func soraGetPowToken(userAgent string) string {
|
|
|
|
|
|
configList := soraBuildPowConfig(userAgent)
|
|
|
|
|
|
seed := strconv.FormatFloat(soraRandFloat(), 'f', -1, 64)
|
|
|
|
|
|
difficulty := "0fffff"
|
|
|
|
|
|
solution, _ := soraSolvePow(seed, difficulty, configList)
|
|
|
|
|
|
return "gAAAAAC" + solution
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func soraRandFloat() float64 {
|
|
|
|
|
|
soraRandMu.Lock()
|
|
|
|
|
|
defer soraRandMu.Unlock()
|
|
|
|
|
|
return soraRand.Float64()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 20:04:10 +08:00
|
|
|
|
func soraRandInt(max int) int {
|
|
|
|
|
|
if max <= 1 {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
soraRandMu.Lock()
|
|
|
|
|
|
defer soraRandMu.Unlock()
|
|
|
|
|
|
return soraRand.Intn(max)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
func soraBuildPowConfig(userAgent string) []any {
|
2026-02-19 15:09:58 +08:00
|
|
|
|
userAgent = strings.TrimSpace(userAgent)
|
|
|
|
|
|
if userAgent == "" && len(soraDesktopUserAgents) > 0 {
|
|
|
|
|
|
userAgent = soraDesktopUserAgents[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
screenVal := soraStableChoiceInt([]int{
|
|
|
|
|
|
1920 + 1080,
|
|
|
|
|
|
2560 + 1440,
|
|
|
|
|
|
1920 + 1200,
|
|
|
|
|
|
2560 + 1600,
|
|
|
|
|
|
}, userAgent+"|screen")
|
2026-02-01 21:37:10 +08:00
|
|
|
|
perfMs := float64(time.Since(soraPerfStart).Milliseconds())
|
|
|
|
|
|
wallMs := float64(time.Now().UnixNano()) / 1e6
|
|
|
|
|
|
diff := wallMs - perfMs
|
|
|
|
|
|
return []any{
|
|
|
|
|
|
screenVal,
|
|
|
|
|
|
soraPowParseTime(),
|
|
|
|
|
|
4294705152,
|
|
|
|
|
|
0,
|
|
|
|
|
|
userAgent,
|
2026-02-19 15:09:58 +08:00
|
|
|
|
soraStableChoice(soraPowScripts, userAgent+"|script"),
|
|
|
|
|
|
soraStableChoice(soraPowDPL, userAgent+"|dpl"),
|
2026-02-01 21:37:10 +08:00
|
|
|
|
"en-US",
|
|
|
|
|
|
"en-US,es-US,en,es",
|
|
|
|
|
|
0,
|
2026-02-19 15:09:58 +08:00
|
|
|
|
soraStableChoice(soraPowNavigatorKeys, userAgent+"|navigator"),
|
|
|
|
|
|
soraStableChoice(soraPowDocumentKeys, userAgent+"|document"),
|
|
|
|
|
|
soraStableChoice(soraPowWindowKeys, userAgent+"|window"),
|
2026-02-01 21:37:10 +08:00
|
|
|
|
perfMs,
|
|
|
|
|
|
uuid.NewString(),
|
|
|
|
|
|
"",
|
2026-02-19 15:09:58 +08:00
|
|
|
|
soraStableChoiceInt(soraPowCores, userAgent+"|cores"),
|
2026-02-01 21:37:10 +08:00
|
|
|
|
diff,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
func soraStableChoice(items []string, seed string) string {
|
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
idx := soraStableIndex(seed, len(items))
|
|
|
|
|
|
return items[idx]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func soraStableChoiceInt(items []int, seed string) int {
|
2026-02-01 21:37:10 +08:00
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
2026-02-19 15:09:58 +08:00
|
|
|
|
idx := soraStableIndex(seed, len(items))
|
2026-02-01 21:37:10 +08:00
|
|
|
|
return items[idx]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 15:09:58 +08:00
|
|
|
|
func soraStableIndex(seed string, size int) int {
|
|
|
|
|
|
if size <= 0 {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
h := fnv.New32a()
|
|
|
|
|
|
_, _ = h.Write([]byte(seed))
|
|
|
|
|
|
return int(h.Sum32() % uint32(size))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
func soraPowParseTime() string {
|
|
|
|
|
|
loc := time.FixedZone("EST", -5*3600)
|
|
|
|
|
|
return time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05 GMT-0700 (Eastern Standard Time)")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func soraSolvePow(seed, difficulty string, configList []any) (string, bool) {
|
|
|
|
|
|
diffLen := len(difficulty) / 2
|
|
|
|
|
|
target, err := hexDecodeString(difficulty)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
seedBytes := []byte(seed)
|
|
|
|
|
|
|
|
|
|
|
|
part1 := mustMarshalJSON(configList[:3])
|
|
|
|
|
|
part2 := mustMarshalJSON(configList[4:9])
|
|
|
|
|
|
part3 := mustMarshalJSON(configList[10:])
|
|
|
|
|
|
|
|
|
|
|
|
staticPart1 := append(part1[:len(part1)-1], ',')
|
|
|
|
|
|
staticPart2 := append([]byte(","), append(part2[1:len(part2)-1], ',')...)
|
|
|
|
|
|
staticPart3 := append([]byte(","), part3[1:]...)
|
|
|
|
|
|
|
|
|
|
|
|
for i := 0; i < soraPowMaxIteration; i++ {
|
|
|
|
|
|
dynamicI := []byte(strconv.Itoa(i))
|
|
|
|
|
|
dynamicJ := []byte(strconv.Itoa(i >> 1))
|
|
|
|
|
|
finalJSON := make([]byte, 0, len(staticPart1)+len(dynamicI)+len(staticPart2)+len(dynamicJ)+len(staticPart3))
|
|
|
|
|
|
finalJSON = append(finalJSON, staticPart1...)
|
|
|
|
|
|
finalJSON = append(finalJSON, dynamicI...)
|
|
|
|
|
|
finalJSON = append(finalJSON, staticPart2...)
|
|
|
|
|
|
finalJSON = append(finalJSON, dynamicJ...)
|
|
|
|
|
|
finalJSON = append(finalJSON, staticPart3...)
|
|
|
|
|
|
|
|
|
|
|
|
b64 := base64.StdEncoding.EncodeToString(finalJSON)
|
|
|
|
|
|
hash := sha3.Sum512(append(seedBytes, []byte(b64)...))
|
|
|
|
|
|
if bytes.Compare(hash[:diffLen], target[:diffLen]) <= 0 {
|
|
|
|
|
|
return b64, true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
errorToken := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\"%s\"", seed)))
|
|
|
|
|
|
return errorToken, false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func soraBuildSentinelToken(flow, reqID, powToken string, resp map[string]any, userAgent string) string {
|
|
|
|
|
|
finalPow := powToken
|
|
|
|
|
|
proof, _ := resp["proofofwork"].(map[string]any)
|
|
|
|
|
|
if required, _ := proof["required"].(bool); required {
|
|
|
|
|
|
seed, _ := proof["seed"].(string)
|
|
|
|
|
|
difficulty, _ := proof["difficulty"].(string)
|
|
|
|
|
|
if seed != "" && difficulty != "" {
|
|
|
|
|
|
configList := soraBuildPowConfig(userAgent)
|
|
|
|
|
|
solution, _ := soraSolvePow(seed, difficulty, configList)
|
|
|
|
|
|
finalPow = "gAAAAAB" + solution
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.HasSuffix(finalPow, "~S") {
|
|
|
|
|
|
finalPow += "~S"
|
|
|
|
|
|
}
|
|
|
|
|
|
turnstile, _ := resp["turnstile"].(map[string]any)
|
|
|
|
|
|
tokenPayload := map[string]any{
|
|
|
|
|
|
"p": finalPow,
|
|
|
|
|
|
"t": safeMapString(turnstile, "dx"),
|
|
|
|
|
|
"c": safeString(resp["token"]),
|
|
|
|
|
|
"id": reqID,
|
|
|
|
|
|
"flow": flow,
|
|
|
|
|
|
}
|
|
|
|
|
|
encoded, _ := json.Marshal(tokenPayload)
|
|
|
|
|
|
return string(encoded)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func safeMapString(m map[string]any, key string) string {
|
|
|
|
|
|
if m == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if v, ok := m[key]; ok {
|
|
|
|
|
|
return safeString(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func safeString(v any) string {
|
|
|
|
|
|
switch val := v.(type) {
|
|
|
|
|
|
case string:
|
|
|
|
|
|
return val
|
|
|
|
|
|
default:
|
|
|
|
|
|
return fmt.Sprintf("%v", val)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func mustMarshalJSON(v any) []byte {
|
|
|
|
|
|
b, _ := json.Marshal(v)
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func hexDecodeString(s string) ([]byte, error) {
|
|
|
|
|
|
dst := make([]byte, len(s)/2)
|
|
|
|
|
|
_, err := hex.Decode(dst, []byte(s))
|
|
|
|
|
|
return dst, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 21:38:04 +08:00
|
|
|
|
func (c *SoraDirectClient) withRequestTrace(ctx context.Context, account *Account, proxyURL, userAgent string) context.Context {
|
|
|
|
|
|
if ctx == nil {
|
|
|
|
|
|
ctx = context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
if existing, ok := ctx.Value(soraRequestTraceContextKey{}).(*soraRequestTrace); ok && existing != nil && existing.ID != "" {
|
|
|
|
|
|
return ctx
|
|
|
|
|
|
}
|
|
|
|
|
|
accountID := int64(0)
|
|
|
|
|
|
if account != nil {
|
|
|
|
|
|
accountID = account.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
seed := fmt.Sprintf("%d|%s|%s|%d", accountID, normalizeSoraProxyKey(proxyURL), strings.TrimSpace(userAgent), time.Now().UnixNano())
|
|
|
|
|
|
trace := &soraRequestTrace{
|
|
|
|
|
|
ID: "sora-" + soraHashForLog(seed),
|
|
|
|
|
|
ProxyKey: normalizeSoraProxyKey(proxyURL),
|
|
|
|
|
|
UAHash: soraHashForLog(strings.TrimSpace(userAgent)),
|
|
|
|
|
|
}
|
|
|
|
|
|
return context.WithValue(ctx, soraRequestTraceContextKey{}, trace)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) requestTraceFields(ctx context.Context, proxyURL, userAgent string) (string, string, string) {
|
|
|
|
|
|
proxyKey := normalizeSoraProxyKey(proxyURL)
|
|
|
|
|
|
uaHash := soraHashForLog(strings.TrimSpace(userAgent))
|
|
|
|
|
|
traceID := ""
|
|
|
|
|
|
if ctx != nil {
|
|
|
|
|
|
if trace, ok := ctx.Value(soraRequestTraceContextKey{}).(*soraRequestTrace); ok && trace != nil {
|
|
|
|
|
|
if strings.TrimSpace(trace.ID) != "" {
|
|
|
|
|
|
traceID = strings.TrimSpace(trace.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(trace.ProxyKey) != "" {
|
|
|
|
|
|
proxyKey = strings.TrimSpace(trace.ProxyKey)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(trace.UAHash) != "" {
|
|
|
|
|
|
uaHash = strings.TrimSpace(trace.UAHash)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if traceID == "" {
|
|
|
|
|
|
traceID = "sora-" + soraHashForLog(fmt.Sprintf("%s|%d", proxyKey, time.Now().UnixNano()))
|
|
|
|
|
|
}
|
|
|
|
|
|
return traceID, proxyKey, uaHash
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func soraHashForLog(raw string) string {
|
|
|
|
|
|
h := fnv.New32a()
|
|
|
|
|
|
_, _ = h.Write([]byte(raw))
|
|
|
|
|
|
return fmt.Sprintf("%08x", h.Sum32())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 21:37:10 +08:00
|
|
|
|
func sanitizeSoraLogURL(raw string) string {
|
|
|
|
|
|
parsed, err := url.Parse(raw)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return raw
|
|
|
|
|
|
}
|
|
|
|
|
|
q := parsed.Query()
|
|
|
|
|
|
q.Del("sig")
|
|
|
|
|
|
q.Del("expires")
|
|
|
|
|
|
parsed.RawQuery = q.Encode()
|
|
|
|
|
|
return parsed.String()
|
|
|
|
|
|
}
|
2026-02-19 08:02:56 +08:00
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) debugEnabled() bool {
|
|
|
|
|
|
return c != nil && c.cfg != nil && c.cfg.Sora.Client.Debug
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *SoraDirectClient) debugLogf(format string, args ...any) {
|
|
|
|
|
|
if !c.debugEnabled() {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("[SoraClient] "+format, args...)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatSoraHeaders(headers http.Header) string {
|
|
|
|
|
|
if len(headers) == 0 {
|
|
|
|
|
|
return "{}"
|
|
|
|
|
|
}
|
|
|
|
|
|
keys := make([]string, 0, len(headers))
|
|
|
|
|
|
for key := range headers {
|
|
|
|
|
|
keys = append(keys, key)
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Strings(keys)
|
|
|
|
|
|
out := make(map[string]string, len(keys))
|
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
|
values := headers.Values(key)
|
|
|
|
|
|
if len(values) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
val := strings.Join(values, ",")
|
|
|
|
|
|
if isSensitiveHeader(key) {
|
|
|
|
|
|
out[key] = "***"
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out[key] = truncateForLog([]byte(logredact.RedactText(val)), 160)
|
|
|
|
|
|
}
|
|
|
|
|
|
encoded, err := json.Marshal(out)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "{}"
|
|
|
|
|
|
}
|
|
|
|
|
|
return string(encoded)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isSensitiveHeader(key string) bool {
|
|
|
|
|
|
k := strings.ToLower(strings.TrimSpace(key))
|
|
|
|
|
|
switch k {
|
|
|
|
|
|
case "authorization", "openai-sentinel-token", "cookie", "set-cookie", "x-api-key":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func summarizeSoraResponseBody(body []byte, maxLen int) string {
|
|
|
|
|
|
if len(body) == 0 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
var text string
|
|
|
|
|
|
if json.Valid(body) {
|
|
|
|
|
|
text = logredact.RedactJSON(body)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
text = logredact.RedactText(string(body))
|
|
|
|
|
|
}
|
|
|
|
|
|
text = strings.TrimSpace(text)
|
|
|
|
|
|
if maxLen <= 0 || len(text) <= maxLen {
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
return text[:maxLen] + "...(truncated)"
|
|
|
|
|
|
}
|