init: video-create project with skills and accounts
This commit is contained in:
680
.claude/skills/klingai-1.1.0/scripts/shared/client.mjs
Normal file
680
.claude/skills/klingai-1.1.0/scripts/shared/client.mjs
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Kling AI HTTP client (zero external deps, Node.js 18+ fetch)
|
||||
*
|
||||
* - **klingGet / klingPost**:Bearer 鉴权 + resolveApiBase(业务 API)
|
||||
* - **runAccountBindHttpSequence**:无 Bearer,固定 bind 端点(与鉴权流量区分在实现上,不混用 token)
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
getBearerToken,
|
||||
makeKlingHeaders,
|
||||
getConfiguredApiBase,
|
||||
getConfiguredBindBase,
|
||||
persistProbedApiBase,
|
||||
getSkillVersion,
|
||||
ensureIdentityForBind,
|
||||
patchKlingIdentity,
|
||||
persistBoundApiKeys,
|
||||
} from './auth.mjs';
|
||||
|
||||
const KLING_API_ENDPOINTS = Object.freeze([
|
||||
{
|
||||
key: 'cn',
|
||||
apiBase: 'https://api-beijing.klingai.com',
|
||||
bindBase: 'https://klingai.com',
|
||||
consoleUrl: 'https://klingai.com/dev/api-key',
|
||||
},
|
||||
{
|
||||
key: 'global',
|
||||
apiBase: 'https://api-singapore.klingai.com',
|
||||
bindBase: 'https://kling.ai',
|
||||
consoleUrl: 'https://kling.ai/dev/api-key',
|
||||
},
|
||||
]);
|
||||
|
||||
const ALL_KLING_CONSOLE_URLS = Object.freeze(
|
||||
Object.fromEntries(KLING_API_ENDPOINTS.map((item) => [item.key, item.consoleUrl])),
|
||||
);
|
||||
|
||||
const API_BASE = KLING_API_ENDPOINTS[0].apiBase;
|
||||
const CANDIDATE_BASES = KLING_API_ENDPOINTS.map((item) => item.apiBase);
|
||||
export let KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
|
||||
|
||||
function normalizeApiBase(base) {
|
||||
return String(base || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function findEndpointByBase(base) {
|
||||
const normalized = normalizeApiBase(base);
|
||||
if (!normalized) return null;
|
||||
const direct = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.apiBase) === normalized);
|
||||
if (direct) return direct;
|
||||
const bindDirect = KLING_API_ENDPOINTS.find((item) => normalizeApiBase(item.bindBase) === normalized);
|
||||
if (bindDirect) return bindDirect;
|
||||
if (normalized.includes('api-beijing.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
if (normalized.includes('api-singapore.klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
|
||||
if (normalized.includes('klingai.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
if (normalized.includes('kling.ai')) return KLING_API_ENDPOINTS.find((item) => item.key === 'global') || null;
|
||||
if (normalized.includes('kuaishou.com')) return KLING_API_ENDPOINTS.find((item) => item.key === 'cn') || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function setConsoleUrlsForBase(base) {
|
||||
const endpoint = findEndpointByBase(base);
|
||||
if (!endpoint) {
|
||||
KLING_CONSOLE_URLS = ALL_KLING_CONSOLE_URLS;
|
||||
return;
|
||||
}
|
||||
KLING_CONSOLE_URLS = Object.freeze({ [endpoint.key]: endpoint.consoleUrl });
|
||||
}
|
||||
|
||||
const initialConfiguredApiBase = getConfiguredApiBase();
|
||||
if (initialConfiguredApiBase) {
|
||||
setConsoleUrlsForBase(initialConfiguredApiBase);
|
||||
}
|
||||
|
||||
function printConsoleUrlsHint(prefix = ' ') {
|
||||
for (const [region, url] of Object.entries(KLING_CONSOLE_URLS)) {
|
||||
const label = region === 'cn' ? 'China / 国内' : (region === 'global' ? 'Global / 国际' : region);
|
||||
console.error(`${prefix}${label}: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeBase(base, token) {
|
||||
try {
|
||||
const res = await fetch(`${base}/v1/videos/text2video?pageNum=1&pageSize=1`, {
|
||||
method: 'GET',
|
||||
headers: makeKlingHeaders(token, null),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json().catch(() => null);
|
||||
return json != null && (json.code === 0 || json.code === 200);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let _resolvedBase = null;
|
||||
|
||||
async function resolveApiBase(token) {
|
||||
if (_resolvedBase) return _resolvedBase;
|
||||
const configuredApiBase = getConfiguredApiBase();
|
||||
if (configuredApiBase) {
|
||||
_resolvedBase = normalizeApiBase(configuredApiBase);
|
||||
setConsoleUrlsForBase(_resolvedBase);
|
||||
return _resolvedBase;
|
||||
}
|
||||
|
||||
console.error('\n🔍 Probing API endpoints... / 正在检测 API 节点...');
|
||||
for (const endpoint of KLING_API_ENDPOINTS) {
|
||||
process.stderr.write(` [${endpoint.key}] ${endpoint.apiBase} ... `);
|
||||
if (await probeBase(endpoint.apiBase, token)) {
|
||||
process.stderr.write('✓ OK\n\n');
|
||||
_resolvedBase = endpoint.apiBase;
|
||||
setConsoleUrlsForBase(_resolvedBase);
|
||||
try {
|
||||
persistProbedApiBase(_resolvedBase);
|
||||
} catch {}
|
||||
return _resolvedBase;
|
||||
}
|
||||
process.stderr.write('✗\n');
|
||||
}
|
||||
|
||||
console.error('\n❌ Cannot connect to any Kling API endpoint / 无法连接任何可灵 API 节点');
|
||||
for (const base of CANDIDATE_BASES) console.error(` • ${base}`);
|
||||
console.error('\nPossible causes / 可能原因:');
|
||||
console.error(' 1. Token invalid or expired / Token 无效或已过期:');
|
||||
printConsoleUrlsHint();
|
||||
console.error(' 2. Network issue / 网络问题');
|
||||
console.error('\nCheck credentials file, KLING_TOKEN, or run account configure / 检查 credentials、KLING_TOKEN 或 account configure:\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保护 JSON 中的大整数字段(防止 Number 精度丢失)
|
||||
* 将 element_id, task_id 等大整数字段转为字符串
|
||||
*/
|
||||
function protectBigInts(text) {
|
||||
return text.replace(
|
||||
/"(element_id|task_id|elementId|taskId)":\s*(\d{15,})/g,
|
||||
'"$1":"$2"'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析可灵 API 响应,code 为 0 或 200 为成功
|
||||
*/
|
||||
function parseResponse(json) {
|
||||
if (json.code !== 0 && json.code !== 200) {
|
||||
throw new Error(`API error / API 错误 (code=${json.code}): ${json.message || 'Unknown error'}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
function parseJsonSafely(text) {
|
||||
try {
|
||||
return JSON.parse(protectBigInts(String(text || '')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHttpErrorMessage(status, text) {
|
||||
const body = parseJsonSafely(text);
|
||||
if (status === 401 && body && typeof body === 'object') {
|
||||
const code = Number(body.code);
|
||||
const requestId = body.request_id ? `, request_id=${body.request_id}` : '';
|
||||
if (code === 1000) {
|
||||
return `HTTP 401: code=1000,signature is invalid / 秘钥无效,请重新绑定${requestId}`;
|
||||
}
|
||||
if (code === 1002) {
|
||||
return `HTTP 401: code=1002,access key not exist / 账户不存在,请重新绑定${requestId}`;
|
||||
}
|
||||
}
|
||||
return `HTTP ${status}: ${text}`;
|
||||
}
|
||||
|
||||
function parseApiJsonOrThrow(text) {
|
||||
const parsed = parseJsonSafely(text);
|
||||
if (parsed != null) return parsed;
|
||||
const preview = String(text || '').trim().slice(0, 60);
|
||||
if (preview.startsWith('<')) {
|
||||
throw new Error(`API Service Error: Non-JSON content. check KLING_API_BASE and network/DNS/proxy: ${preview}`);
|
||||
}
|
||||
throw new Error(`API Service Error: Cannot parse JSON: ${preview}`);
|
||||
}
|
||||
|
||||
async function safeFetch(url, init, context) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (e) {
|
||||
const baseHint = getConfiguredApiBase() || '<auto>';
|
||||
const msg = e?.message || String(e);
|
||||
throw new Error(
|
||||
`Network error / 网络错误: ${msg}\n`
|
||||
+ `Request / 请求: ${context.method} ${url}\n`
|
||||
+ `KLING_API_BASE: ${baseHint}\n`
|
||||
+ 'Hint / 提示: check KLING_API_BASE and network/DNS/proxy, or remove KLING_API_BASE to auto-probe official endpoints / '
|
||||
+ '请检查 KLING_API_BASE 与网络(DNS/代理),或移除 KLING_API_BASE 让脚本自动探测官方节点。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求可灵 API
|
||||
* @param {string} path API 路径,如 /v1/videos/image2video
|
||||
* @param {object} body 请求体
|
||||
* @param {string} [token] 可选 token,不传则自动获取
|
||||
* @returns {Promise<object>} data 字段
|
||||
*/
|
||||
export async function klingPost(path, body, token) {
|
||||
if (!token) token = getBearerToken();
|
||||
const base = await resolveApiBase(token);
|
||||
const url = `${base}${path}`;
|
||||
const res = await safeFetch(url, {
|
||||
method: 'POST',
|
||||
headers: makeKlingHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
}, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(buildHttpErrorMessage(res.status, text));
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseResponse(parseApiJsonOrThrow(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求可灵 API
|
||||
* @param {string} path API 路径,如 /v1/videos/image2video/{task_id}
|
||||
* @param {string} [token] 可选 token,不传则自动获取
|
||||
* @param {{ contentType?: string|null }} [options] 如部分接口要求 `Content-Type: application/json`(传 `'application/json'`);默认不传 Content-Type
|
||||
* @returns {Promise<object>} data 字段
|
||||
*/
|
||||
export async function klingGet(path, token, options = {}) {
|
||||
if (!token) token = getBearerToken();
|
||||
const base = await resolveApiBase(token);
|
||||
const ct = options.contentType !== undefined ? options.contentType : null;
|
||||
const url = `${base}${path}`;
|
||||
const res = await safeFetch(url, {
|
||||
method: 'GET',
|
||||
headers: makeKlingHeaders(token, ct),
|
||||
}, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(buildHttpErrorMessage(res.status, text));
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseResponse(parseApiJsonOrThrow(text));
|
||||
}
|
||||
|
||||
// —— 设备绑定 HTTP(无 Authorization;不经过 resolveApiBase) ——
|
||||
|
||||
const DEFAULT_BIND_INIT = '/console/api/auth/skill/init-sessions';
|
||||
const DEFAULT_BIND_EXCHANGE = '/console/api/auth/skill/exchange';
|
||||
const DEFAULT_BIND_SKILL_ID = 'Kling-Provider-Skill';
|
||||
const DEFAULT_BIND_SCOPE = 'kling.openapi.invoke';
|
||||
const DEFAULT_BIND_FETCH_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_BIND_TIMEOUT_MS = 180000;
|
||||
|
||||
function sleepBind(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function base64url(input) {
|
||||
return Buffer.from(input).toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function createPkcePair() {
|
||||
const codeVerifier = base64url(randomBytes(48));
|
||||
const codeChallenge = base64url(createHash('sha256').update(codeVerifier, "utf8").digest());
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
function bindExtractData(json) {
|
||||
if (json == null || typeof json !== 'object') return json;
|
||||
const c = json.code;
|
||||
if (c !== undefined && c !== 0 && c !== 200) {
|
||||
const msg = json.message || json.msg || 'Unknown error';
|
||||
throw new Error(`Bind API error / 绑定接口错误 (code=${c}): ${msg}`);
|
||||
}
|
||||
return json.data !== undefined ? json.data : json;
|
||||
}
|
||||
|
||||
function normalizeBindBase(base) {
|
||||
const raw = String(base || '').trim();
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function resolveBindBase(bindBaseOverride) {
|
||||
const override = normalizeBindBase(bindBaseOverride);
|
||||
if (override) {
|
||||
const overrideEndpoint = findEndpointByBase(override);
|
||||
if (overrideEndpoint?.bindBase) return normalizeBindBase(overrideEndpoint.bindBase);
|
||||
return override;
|
||||
}
|
||||
const configuredBindBase = getConfiguredBindBase();
|
||||
if (configuredBindBase) return normalizeBindBase(configuredBindBase);
|
||||
const candidate = getConfiguredApiBase() || _resolvedBase || API_BASE;
|
||||
const endpoint = findEndpointByBase(candidate);
|
||||
if (endpoint?.bindBase) return normalizeBindBase(endpoint.bindBase);
|
||||
return normalizeBindBase(candidate);
|
||||
}
|
||||
|
||||
async function skillBindHttpJson(userAgent, base, path, body, method = 'POST') {
|
||||
const b = String(base || '').replace(/\/$/, '');
|
||||
const p = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = method === 'GET' && body && typeof body === 'object'
|
||||
? `${b}${p}${p.includes('?') ? '&' : '?'}${new URLSearchParams(
|
||||
Object.entries(body).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]),
|
||||
).toString()}`
|
||||
: `${b}${p}`;
|
||||
const headers = { 'User-Agent': userAgent };
|
||||
if (method !== 'GET') headers['Content-Type'] = 'application/json';
|
||||
const init = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(DEFAULT_BIND_FETCH_TIMEOUT_MS),
|
||||
};
|
||||
if (method !== 'GET' && body != null) init.body = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Network error / 网络错误: ${e?.message || e}\n`
|
||||
+ 'Hint / 提示: check network/DNS/proxy and endpoint reachability / 请检查网络、DNS、代理与目标地址可达性。',
|
||||
);
|
||||
}
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${res.status}: ${text}\n`
|
||||
+ 'Hint / 提示: verify API base and network reachability / 请确认 API 基址与网络可达性。',
|
||||
);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON / 非 JSON 响应: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return bindExtractData(json);
|
||||
}
|
||||
|
||||
function pickBindSessionId(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return data.session_id || data.sessionId || data.bind_session_id || data.id || null;
|
||||
}
|
||||
|
||||
function pickBindAuthorizeHint(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return (
|
||||
data.verificationUriComplete
|
||||
|| data.verification_uri_complete
|
||||
|| data.verificationUri
|
||||
|| data.verification_uri
|
||||
|| data.authorize_url
|
||||
|| data.authorization_url
|
||||
|| data.qr_url
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
function pickBindAccessSecretKeys(data) {
|
||||
const src = data?.credential && typeof data.credential === 'object' ? data.credential : data;
|
||||
if (!src || typeof src !== 'object') {
|
||||
return {
|
||||
ak: null, sk: null, credentialId: null, accountId: null,
|
||||
};
|
||||
}
|
||||
const ak = src.accessKey || src.access_key || src.access_key_id || src.accessKeyId || src.ak;
|
||||
const sk = src.secretKey || src.secret_key || src.secret_access_key || src.secretAccessKey || src.sk;
|
||||
const credentialId = src.credentialId || src.credential_id || src.credentialID || src.credentialid;
|
||||
const accountId = src.accountId || src.account_id || src.accountID || src.accountid;
|
||||
return {
|
||||
ak: ak != null ? String(ak).trim() : null,
|
||||
sk: sk != null ? String(sk).trim() : null,
|
||||
credentialId: credentialId != null ? String(credentialId).trim() : null,
|
||||
accountId: accountId != null ? String(accountId).trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBindStatus(data) {
|
||||
if (!data || typeof data !== 'object') return 'pending';
|
||||
const s = data.status || data.state || data.bind_status || data.phase;
|
||||
if (s == null) return 'pending';
|
||||
return String(s).toUpperCase();
|
||||
}
|
||||
|
||||
function makeBindFlowError(message, meta = {}) {
|
||||
const err = new Error(message);
|
||||
err.name = 'BindFlowError';
|
||||
if (meta.code) err.bindCode = meta.code;
|
||||
if (meta.authorizeUrl) err.bindAuthorizeUrl = meta.authorizeUrl;
|
||||
if (meta.sessionId) err.bindSessionId = meta.sessionId;
|
||||
if (meta.status) err.bindStatus = meta.status;
|
||||
if (meta.responseData !== undefined) err.bindResponseData = meta.responseData;
|
||||
return err;
|
||||
}
|
||||
|
||||
function resolveAuthorizationUrl(bindBase, authorizePathOrUrl) {
|
||||
const raw = String(authorizePathOrUrl || '').trim();
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
||||
const baseUrl = new URL(`${normalizeBindBase(bindBase)}/`);
|
||||
if (raw.startsWith('/')) return `${baseUrl.origin}${raw}`;
|
||||
return new URL(raw, baseUrl).toString();
|
||||
}
|
||||
|
||||
function defaultBindOnLog(ev) {
|
||||
if (ev.url) {
|
||||
console.error(`${ev.message}\n ${ev.url}`);
|
||||
} else {
|
||||
console.error(ev.message);
|
||||
}
|
||||
}
|
||||
|
||||
function maskSecret(secret) {
|
||||
const s = String(secret || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 6) return '***';
|
||||
return `${s.slice(0, 3)}***${s.slice(-2)}`;
|
||||
}
|
||||
|
||||
function maskAccessKey(accessKey) {
|
||||
const s = String(accessKey || '');
|
||||
if (!s) return '';
|
||||
if (s.length <= 8) return `${s.slice(0, 2)}***`;
|
||||
return `${s.slice(0, 4)}***${s.slice(-3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整设备绑定并写入 credentials(供 account 与 getTokenOrExit 自动调用)
|
||||
* @param {{ onLog?: function }} [options]
|
||||
*/
|
||||
export async function runDeviceBindFlow(options = {}) {
|
||||
const onLog = options.onLog || defaultBindOnLog;
|
||||
const identity = ensureIdentityForBind();
|
||||
const {
|
||||
client_instance_id, device_name, platform, hostname,
|
||||
} = identity;
|
||||
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
|
||||
|
||||
const result = await runAccountBindHttpSequence({
|
||||
userAgent,
|
||||
skillVersion: getSkillVersion(),
|
||||
identity: {
|
||||
clientInstanceId: client_instance_id,
|
||||
deviceName: device_name,
|
||||
platform,
|
||||
hostname,
|
||||
},
|
||||
onInitSession: (sessionId) => {
|
||||
patchKlingIdentity({ session_id: sessionId });
|
||||
},
|
||||
onLog,
|
||||
});
|
||||
const persisted = persistBoundApiKeys(
|
||||
result.accessKey,
|
||||
result.secretKey,
|
||||
{ session_id: result.sessionId },
|
||||
{
|
||||
credentialId: result.credentialId || null,
|
||||
accountId: result.accountId || null,
|
||||
},
|
||||
);
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
authorizeUrl: result.authorizeHint || null,
|
||||
savePath: persisted.savePath,
|
||||
accessKeyMasked: maskAccessKey(result.accessKey),
|
||||
secretKeyMasked: maskSecret(result.secretKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅执行绑定前置:init → verify,返回可手动打开的授权 URL。
|
||||
* @param {{ onLog?: function }} [options]
|
||||
*/
|
||||
export async function prepareDeviceBindUrl(options = {}) {
|
||||
const onLog = options.onLog || defaultBindOnLog;
|
||||
const identity = ensureIdentityForBind();
|
||||
const {
|
||||
client_instance_id, device_name, platform, hostname,
|
||||
} = identity;
|
||||
const userAgent = `Kling-Provider-Skill/${getSkillVersion()}`;
|
||||
const result = await runAccountBindInitVerify({
|
||||
userAgent,
|
||||
skillVersion: getSkillVersion(),
|
||||
identity: {
|
||||
clientInstanceId: client_instance_id,
|
||||
deviceName: device_name,
|
||||
platform,
|
||||
hostname,
|
||||
},
|
||||
onInitSession: (sessionId) => {
|
||||
patchKlingIdentity({ session_id: sessionId });
|
||||
},
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
authorizeUrl: result.authorizeHint || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号绑定前半段:init → verify,拿到可给用户手动打开的授权 URL。
|
||||
* @returns {Promise<{sessionId: string, authorizeHint: string|null}>}
|
||||
*/
|
||||
export async function runAccountBindInitVerify(options) {
|
||||
const bindBase = options.bindBase ? normalizeBindBase(options.bindBase) : resolveBindBase();
|
||||
const initPath = options.initPath || DEFAULT_BIND_INIT;
|
||||
const {
|
||||
clientInstanceId,
|
||||
deviceName,
|
||||
platform,
|
||||
hostname,
|
||||
} = options.identity || {};
|
||||
if (!clientInstanceId) {
|
||||
throw makeBindFlowError('identity.clientInstanceId is required / 缺少 identity.clientInstanceId', { code: 'MISSING_CLIENT_INSTANCE_ID' });
|
||||
}
|
||||
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
|
||||
const skillVersion = String(options.skillVersion || getSkillVersion());
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||||
const onInitSession = options.onInitSession;
|
||||
const { codeVerifier, codeChallenge } = createPkcePair();
|
||||
onLog({ step: 'base', message: 'Using bind base / 当前 bind 基址:', url: bindBase });
|
||||
|
||||
onLog({ step: 'init', message: 'Calling init-sessions / 调用 init-sessions …' });
|
||||
const initData = await skillBindHttpJson(userAgent, bindBase, initPath, {
|
||||
skillId: DEFAULT_BIND_SKILL_ID,
|
||||
skillVersion,
|
||||
clientInstanceId,
|
||||
deviceName: String(deviceName || '').trim() || 'unknown',
|
||||
platform: String(platform || '').trim() || 'unknown',
|
||||
hostname: String(hostname || '').trim() || 'unknown',
|
||||
requestedScopes: [DEFAULT_BIND_SCOPE],
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
const sessionId = pickBindSessionId(initData);
|
||||
if (!sessionId) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing sessionId / init-sessions 响应缺少 sessionId',
|
||||
{ code: 'MISSING_SESSION_ID' },
|
||||
);
|
||||
}
|
||||
if (onInitSession) await onInitSession(sessionId);
|
||||
const deviceCode = String(initData.deviceCode || initData.device_code || '').trim();
|
||||
if (!deviceCode) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing deviceCode / init-sessions 响应缺少 deviceCode',
|
||||
{ code: 'MISSING_DEVICE_CODE', sessionId },
|
||||
);
|
||||
}
|
||||
const authorizeHint = resolveAuthorizationUrl(bindBase, pickBindAuthorizeHint(initData));
|
||||
if (!authorizeHint) {
|
||||
throw makeBindFlowError(
|
||||
'init-sessions response missing authorize url / init-sessions 响应缺少授权链接',
|
||||
{ code: 'MISSING_AUTHORIZE_URL', sessionId },
|
||||
);
|
||||
}
|
||||
|
||||
onLog({ step: 'authorize', message: 'Open in browser / 请在浏览器完成授权:', url: authorizeHint });
|
||||
return {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
authorizeHint,
|
||||
interval: Number(initData.interval),
|
||||
expiresIn: Number(initData.expiresIn),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号设备绑定:init → verify → 轮询 check。无 Bearer;凭证落盘由调用方配合 auth 负责。
|
||||
*/
|
||||
export async function runAccountBindHttpSequence(options) {
|
||||
const bindBase = resolveBindBase(options.bindBase);
|
||||
const exchangePath = options.exchangePath || DEFAULT_BIND_EXCHANGE;
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs ?? DEFAULT_BIND_TIMEOUT_MS));
|
||||
const userAgent = String(options.userAgent || 'Kling-Provider-Skill/unknown');
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||||
const {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
authorizeHint,
|
||||
expiresIn,
|
||||
} = await runAccountBindInitVerify({
|
||||
...options,
|
||||
bindBase,
|
||||
userAgent,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
// 服务端已返回 ttl,优先取较小值避免本地等待过长。
|
||||
let remainingTtlSec = Number.isFinite(Number(expiresIn))
|
||||
? Number(expiresIn)
|
||||
: null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (remainingTtlSec != null && remainingTtlSec <= 0) {
|
||||
throw makeBindFlowError('Bind expired / 绑定已过期', {
|
||||
code: 'BIND_EXPIRED',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status: 'EXPIRED',
|
||||
});
|
||||
}
|
||||
onLog({ step: 'exchange', message: 'Polling exchange / 轮询 exchange …' });
|
||||
const exchangeData = await skillBindHttpJson(userAgent, bindBase, exchangePath, {
|
||||
sessionId,
|
||||
deviceCode,
|
||||
codeVerifier,
|
||||
}, 'POST');
|
||||
const status = normalizeBindStatus(exchangeData);
|
||||
if (status === 'ISSUED' || status === 'ALREADY_EXCHANGED') {
|
||||
const {
|
||||
ak, sk, credentialId, accountId,
|
||||
} = pickBindAccessSecretKeys(exchangeData);
|
||||
if (!ak || !sk) {
|
||||
throw makeBindFlowError(`${status} without credential / ${status} 但缺少 credential`, {
|
||||
code: 'MISSING_CREDENTIAL',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
responseData: exchangeData,
|
||||
});
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
authorizeHint,
|
||||
accessKey: ak,
|
||||
secretKey: sk,
|
||||
credentialId,
|
||||
accountId,
|
||||
status,
|
||||
};
|
||||
}
|
||||
if (status !== 'PENDING') {
|
||||
throw makeBindFlowError(`Bind status: ${status}`, {
|
||||
code: 'BIND_STATUS',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
responseData: exchangeData,
|
||||
});
|
||||
}
|
||||
const waitSec = Number(exchangeData?.pollAfterSeconds);
|
||||
const nextExpiresSec = Number(exchangeData?.expiresIn);
|
||||
if (Number.isFinite(nextExpiresSec)) remainingTtlSec = nextExpiresSec;
|
||||
if (!Number.isFinite(waitSec) || waitSec <= 0) {
|
||||
throw makeBindFlowError('Missing pollAfterSeconds, treat as timeout / 缺少 pollAfterSeconds,按超时处理', {
|
||||
code: 'BIND_TIMEOUT',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
await sleepBind(waitSec * 1000);
|
||||
}
|
||||
|
||||
throw makeBindFlowError(`Bind timeout / 绑定超时(>${timeoutMs}ms)`, {
|
||||
code: 'BIND_TIMEOUT',
|
||||
authorizeUrl: authorizeHint,
|
||||
sessionId,
|
||||
status: 'TIMEOUT',
|
||||
});
|
||||
}
|
||||
|
||||
export { getBearerToken, makeKlingHeaders, setSkillVersion, getSkillVersion } from './auth.mjs';
|
||||
export { API_BASE, CANDIDATE_BASES, resolveApiBase };
|
||||
Reference in New Issue
Block a user