feat(leonardo): 接入视频通道并新增 seedance-2.0/minimax-h3 模型
This commit is contained in:
@@ -28,7 +28,7 @@ import (
|
||||
const (
|
||||
appBase = "https://app.leonardo.ai"
|
||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
||||
schemaVersion = "1.187.0"
|
||||
schemaVersion = "1.255.2"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
@@ -45,10 +45,15 @@ type Client struct {
|
||||
// (429) hard, so re-using the ~1h JWT is essential.
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
// rotated maps a stored cookie to the freshest value Leonardo handed back via
|
||||
// Set-Cookie (better-auth rotates its session_data cookie cache). The service
|
||||
// persists it; keeping it here means an unpersisted rotation still works for
|
||||
// the rest of the process's life.
|
||||
rotated map[string]string
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}}
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
@@ -63,6 +68,75 @@ func IsLeonardoCookie(value string) bool {
|
||||
strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// HasSessionData reports whether the cookie carries better-auth's session_data
|
||||
// cache. Leonardo authenticates get-session off THAT cookie: session_token alone
|
||||
// answers 200 null (no bearer), which looks exactly like a dead account — so a
|
||||
// cookie without it must be rejected at import instead of dying later.
|
||||
func HasSessionData(value string) bool {
|
||||
return strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// RotatedCookie returns the freshest value for a stored cookie when Leonardo
|
||||
// rotated its session_data cache, so the caller can persist it.
|
||||
func (c *Client) RotatedCookie(cookie string) (string, bool) {
|
||||
key := strings.TrimSpace(cookie)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fresh, ok := c.rotated[key]
|
||||
return fresh, ok && fresh != key
|
||||
}
|
||||
|
||||
// mergeCookies applies a response's Set-Cookie pairs onto a request cookie
|
||||
// string, keeping the original order and appending new names.
|
||||
func mergeCookies(cookie string, setCookies []string) string {
|
||||
if len(setCookies) == 0 {
|
||||
return cookie
|
||||
}
|
||||
updates := map[string]string{}
|
||||
order := []string{}
|
||||
for _, sc := range setCookies {
|
||||
pair := strings.TrimSpace(strings.Split(sc, ";")[0])
|
||||
name, value, ok := strings.Cut(pair, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := updates[name]; !seen {
|
||||
order = append(order, name)
|
||||
}
|
||||
updates[name] = value
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return cookie
|
||||
}
|
||||
var out []string
|
||||
used := map[string]bool{}
|
||||
for _, part := range strings.Split(cookie, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(part, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if v, ok := updates[name]; ok {
|
||||
used[name] = true
|
||||
if v == "" { // a cleared cookie drops out
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+v)
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
for _, name := range order {
|
||||
if used[name] || updates[name] == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+updates[name])
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// Session is the result of /api/auth/get-session: the short-lived bearer plus the
|
||||
// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed
|
||||
// and the CDN image path) and the human-facing account fields.
|
||||
@@ -73,10 +147,14 @@ type Session struct {
|
||||
Email string
|
||||
Name string
|
||||
ExpiresAt int64
|
||||
// Cookie is the cookie that produced this session, with any Set-Cookie
|
||||
// rotation applied — persist it so the account keeps authenticating.
|
||||
Cookie string
|
||||
}
|
||||
|
||||
// GetSession exchanges the cookie for a fresh access token + account ids. A 401/403
|
||||
// (or a response with no access token) means the cookie/session is dead → ErrAuth.
|
||||
// GetSession exchanges the cookie for a fresh access token + account ids. Only a
|
||||
// 401 or a 200 carrying no access token means the session is dead → ErrAuth;
|
||||
// everything else (notably the 403/429 人机校验 page) is a temporary error.
|
||||
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
cookie = strings.TrimSpace(cookie)
|
||||
if cookie == "" {
|
||||
@@ -91,6 +169,15 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Use the freshest known value (an earlier response may have rotated the
|
||||
// better-auth cookie cache) rather than the possibly stale stored cookie.
|
||||
send := cookie
|
||||
c.mu.Lock()
|
||||
if fresh, ok := c.rotated[cookie]; ok && fresh != "" {
|
||||
send = fresh
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -103,7 +190,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cookie": {cookie},
|
||||
"cookie": {send},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
@@ -121,10 +208,18 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send {
|
||||
c.mu.Lock()
|
||||
c.rotated[cookie] = merged
|
||||
c.mu.Unlock()
|
||||
send = merged
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
||||
// 当成临时错误,否则健康的号会被误判死。
|
||||
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 160))
|
||||
}
|
||||
var raw struct {
|
||||
@@ -162,6 +257,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
Email: strings.TrimSpace(raw.User.Email),
|
||||
Name: strings.TrimSpace(raw.User.Name),
|
||||
ExpiresAt: raw.Session.TokenExpiry,
|
||||
Cookie: send,
|
||||
}
|
||||
if sess.ExpiresAt > time.Now().Unix() {
|
||||
c.mu.Lock()
|
||||
@@ -171,6 +267,59 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// session returns the cookie's access token, optionally forcing a fresh mint
|
||||
// (dropping the cache) — used when the upstream rejected the current bearer.
|
||||
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
|
||||
if force {
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, strings.TrimSpace(cookie))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return c.GetSession(ctx, cookie)
|
||||
}
|
||||
|
||||
// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives
|
||||
// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted
|
||||
// from the cookie and the call retried once. Only a cookie that itself stops
|
||||
// authenticating yields ErrAuth — an upstream bearer rejection stays temporary so
|
||||
// the account is never killed for it.
|
||||
func (c *Client) callGraphQL(ctx context.Context, cookie string, payload []byte, useProxy bool, label string) ([]byte, error) {
|
||||
var lastStatus int
|
||||
var lastBody []byte
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
sess, err := c.session(ctx, cookie, attempt > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, useProxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %s", ErrTemporaryUpstream, label, err.Error())
|
||||
}
|
||||
lastStatus, lastBody = status, body
|
||||
stale := status == 401 || status == 403
|
||||
var gqlErr error
|
||||
if !stale && status == 200 {
|
||||
gqlErr = graphqlError(body)
|
||||
stale = errors.Is(gqlErr, ErrAuth)
|
||||
}
|
||||
if stale {
|
||||
if attempt == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if status != 200 {
|
||||
return nil, fmt.Errorf("%w: %s http %d: %s", ErrTemporaryUpstream, label, status, clip(body, 160))
|
||||
}
|
||||
if gqlErr != nil {
|
||||
return nil, gqlErr
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s rejected a freshly minted bearer (http %d): %s",
|
||||
ErrTemporaryUpstream, label, lastStatus, clip(lastBody, 160))
|
||||
}
|
||||
|
||||
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
|
||||
user_details(where: {cognitoId: {_eq: $sub}}) {
|
||||
id
|
||||
@@ -205,15 +354,12 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st
|
||||
"variables": map[string]any{"sub": sess.CognitoSub},
|
||||
"query": qGetTokens,
|
||||
})
|
||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, false)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "credits")
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
return unknownBalance(err.Error()), nil
|
||||
}
|
||||
var result struct {
|
||||
Data struct {
|
||||
@@ -248,13 +394,8 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st
|
||||
}, nil
|
||||
}
|
||||
|
||||
// graphql runs a GraphQL call through the proxy. graphqlP lets callers pick the
|
||||
// egress: only the generate submit uses the proxy; reference-image upload and
|
||||
// polling run direct (local IP).
|
||||
func (c *Client) graphql(ctx context.Context, accessToken string, payload []byte) ([]byte, int, error) {
|
||||
return c.graphqlP(ctx, accessToken, payload, true)
|
||||
}
|
||||
|
||||
// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit
|
||||
// uses the proxy; reference-image upload and polling run direct (local IP).
|
||||
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user