508 lines
16 KiB
JavaScript
508 lines
16 KiB
JavaScript
(function attachBackgroundCpaApi(root, factory) {
|
||
root.MultiPageBackgroundCpaApi = factory();
|
||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundCpaApiModule() {
|
||
function createCpaApi(deps = {}) {
|
||
const {
|
||
addLog = async () => {},
|
||
fetchImpl = (...args) => fetch(...args),
|
||
} = deps;
|
||
|
||
function normalizeString(value = '') {
|
||
return String(value || '').trim();
|
||
}
|
||
|
||
function isPlainObject(value) {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function firstNonEmpty(...values) {
|
||
for (const value of values) {
|
||
const normalized = normalizeString(value);
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function normalizeEmailValue(value = '') {
|
||
const email = normalizeString(value);
|
||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
|
||
}
|
||
|
||
function extractStateFromAuthUrl(authUrl = '') {
|
||
try {
|
||
return new URL(authUrl).searchParams.get('state') || '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function deriveCpaManagementOrigin(vpsUrl) {
|
||
const normalizedUrl = normalizeString(vpsUrl);
|
||
if (!normalizedUrl) {
|
||
throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。');
|
||
}
|
||
let parsed;
|
||
try {
|
||
parsed = new URL(normalizedUrl);
|
||
} catch {
|
||
throw new Error('CPA 地址格式无效,请先在侧边栏检查。');
|
||
}
|
||
return parsed.origin;
|
||
}
|
||
|
||
function getCpaApiErrorMessage(payload, responseStatus = 500) {
|
||
const candidates = [
|
||
payload?.error,
|
||
payload?.message,
|
||
payload?.detail,
|
||
payload?.reason,
|
||
];
|
||
const message = candidates.map(normalizeString).find(Boolean);
|
||
return message || `CPA 管理接口请求失败(HTTP ${responseStatus})。`;
|
||
}
|
||
|
||
async function fetchCpaManagementJson(origin, path, options = {}) {
|
||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000));
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
|
||
try {
|
||
const managementKey = normalizeString(options.managementKey);
|
||
const headers = {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
};
|
||
if (managementKey) {
|
||
headers.Authorization = `Bearer ${managementKey}`;
|
||
headers['X-Management-Key'] = managementKey;
|
||
}
|
||
|
||
const response = await fetchImpl(`${origin}${path}`, {
|
||
method: options.method || 'POST',
|
||
headers,
|
||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
let payload = {};
|
||
try {
|
||
payload = await response.json();
|
||
} catch {
|
||
payload = {};
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(getCpaApiErrorMessage(payload, response.status));
|
||
}
|
||
|
||
return payload;
|
||
} catch (error) {
|
||
if (error?.name === 'AbortError') {
|
||
throw new Error('CPA 管理接口请求超时,请稍后重试。');
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function decodeBase64UrlSegment(segment = '') {
|
||
const normalized = normalizeString(segment)
|
||
.replace(/-/g, '+')
|
||
.replace(/_/g, '/');
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||
try {
|
||
if (typeof Buffer !== 'undefined') {
|
||
return Buffer.from(padded, 'base64').toString('utf8');
|
||
}
|
||
if (typeof atob === 'function') {
|
||
const binary = atob(padded);
|
||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||
if (typeof TextDecoder !== 'undefined') {
|
||
return new TextDecoder().decode(bytes);
|
||
}
|
||
return binary;
|
||
}
|
||
} catch {
|
||
return '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function encodeBase64UrlJson(value) {
|
||
const json = JSON.stringify(value);
|
||
if (typeof Buffer !== 'undefined') {
|
||
return Buffer.from(json, 'utf8')
|
||
.toString('base64')
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_')
|
||
.replace(/=+$/g, '');
|
||
}
|
||
const bytes = new TextEncoder().encode(json);
|
||
let binary = '';
|
||
bytes.forEach((byte) => {
|
||
binary += String.fromCharCode(byte);
|
||
});
|
||
return btoa(binary)
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_')
|
||
.replace(/=+$/g, '');
|
||
}
|
||
|
||
function parseJwtPayload(token = '') {
|
||
const normalized = normalizeString(token);
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
const parts = normalized.split('.');
|
||
if (parts.length < 2) {
|
||
return null;
|
||
}
|
||
try {
|
||
return JSON.parse(decodeBase64UrlSegment(parts[1]));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getOpenAiAuthSection(payload) {
|
||
if (!isPlainObject(payload)) {
|
||
return {};
|
||
}
|
||
const auth = payload['https://api.openai.com/auth'];
|
||
return isPlainObject(auth) ? auth : {};
|
||
}
|
||
|
||
function getOpenAiProfileSection(payload) {
|
||
if (!isPlainObject(payload)) {
|
||
return {};
|
||
}
|
||
const profile = payload['https://api.openai.com/profile'];
|
||
return isPlainObject(profile) ? profile : {};
|
||
}
|
||
|
||
function normalizeTimestamp(value) {
|
||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||
return value.toISOString();
|
||
}
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
const milliseconds = value > 1e11 ? value : value * 1000;
|
||
const date = new Date(milliseconds);
|
||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||
}
|
||
if (typeof value !== 'string' || !value.trim()) {
|
||
return '';
|
||
}
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||
}
|
||
|
||
function timestampFromUnixSeconds(value) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) {
|
||
return '';
|
||
}
|
||
const date = new Date(numeric * 1000);
|
||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||
}
|
||
|
||
function epochSecondsFromValue(value) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return 0;
|
||
}
|
||
const numeric = Number(value);
|
||
if (Number.isFinite(numeric)) {
|
||
return Math.trunc(numeric > 1e11 ? numeric / 1000 : numeric);
|
||
}
|
||
const parsed = Date.parse(String(value));
|
||
return Number.isFinite(parsed) ? Math.trunc(parsed / 1000) : 0;
|
||
}
|
||
|
||
function buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) {
|
||
const normalizedAccountId = normalizeString(accountId);
|
||
if (!normalizedAccountId) {
|
||
return '';
|
||
}
|
||
const now = Math.trunc(Date.now() / 1000);
|
||
const expires = epochSecondsFromValue(expiresAt) || now + 90 * 24 * 60 * 60;
|
||
const authInfo = { chatgpt_account_id: normalizedAccountId };
|
||
|
||
if (planType) {
|
||
authInfo.chatgpt_plan_type = normalizeString(planType);
|
||
}
|
||
if (userId) {
|
||
authInfo.chatgpt_user_id = normalizeString(userId);
|
||
authInfo.user_id = normalizeString(userId);
|
||
}
|
||
|
||
const payload = {
|
||
iat: now,
|
||
exp: expires,
|
||
'https://api.openai.com/auth': authInfo,
|
||
};
|
||
if (email) {
|
||
payload.email = normalizeString(email);
|
||
}
|
||
|
||
return `${encodeBase64UrlJson({ alg: 'none', typ: 'JWT', cpa_synthetic: true })}.${encodeBase64UrlJson(payload)}.synthetic`;
|
||
}
|
||
|
||
function normalizePlanTypeForFileName(planType = '') {
|
||
return normalizeString(planType)
|
||
.split(/[^a-zA-Z0-9]+/)
|
||
.map((part) => part.trim().toLowerCase())
|
||
.filter(Boolean)
|
||
.join('-');
|
||
}
|
||
|
||
function sanitizeFileSegment(value = '', fallback = 'chatgpt-session') {
|
||
const normalized = normalizeString(value)
|
||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||
.replace(/\s+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
return normalized || fallback;
|
||
}
|
||
|
||
function buildCpaAuthFileName(metadata = {}) {
|
||
const email = sanitizeFileSegment(metadata.email || '');
|
||
const planType = normalizePlanTypeForFileName(metadata.planType || '');
|
||
const accountId = sanitizeFileSegment(metadata.accountId || '');
|
||
if (email && planType) {
|
||
return `codex-${email}-${planType}.json`;
|
||
}
|
||
if (email) {
|
||
return `codex-${email}.json`;
|
||
}
|
||
if (accountId && planType) {
|
||
return `codex-${accountId}-${planType}.json`;
|
||
}
|
||
if (accountId) {
|
||
return `codex-${accountId}.json`;
|
||
}
|
||
return `codex-${Date.now()}.json`;
|
||
}
|
||
|
||
function buildCpaSessionAuthJson(state = {}, options = {}) {
|
||
const session = isPlainObject(state?.session) ? state.session : {};
|
||
const accessToken = normalizeString(state?.accessToken || session?.accessToken);
|
||
if (!accessToken) {
|
||
throw new Error('未读取到可导入的 ChatGPT accessToken。');
|
||
}
|
||
|
||
const inputIdToken = firstNonEmpty(
|
||
state?.idToken,
|
||
state?.id_token,
|
||
session?.idToken,
|
||
session?.id_token
|
||
);
|
||
const refreshToken = firstNonEmpty(
|
||
state?.refreshToken,
|
||
state?.refresh_token,
|
||
session?.refreshToken,
|
||
session?.refresh_token
|
||
);
|
||
const sessionToken = firstNonEmpty(
|
||
state?.sessionToken,
|
||
state?.session_token,
|
||
session?.sessionToken,
|
||
session?.session_token
|
||
);
|
||
const accessPayload = parseJwtPayload(accessToken);
|
||
const idPayload = parseJwtPayload(inputIdToken);
|
||
const accessAuth = getOpenAiAuthSection(accessPayload);
|
||
const idAuth = getOpenAiAuthSection(idPayload);
|
||
const profile = getOpenAiProfileSection(accessPayload);
|
||
const expiresAt = firstNonEmpty(
|
||
timestampFromUnixSeconds(accessPayload?.exp),
|
||
normalizeTimestamp(session?.expires),
|
||
normalizeTimestamp(session?.expiresAt),
|
||
normalizeTimestamp(session?.expired),
|
||
normalizeTimestamp(session?.expires_at)
|
||
);
|
||
const accountIdentifierEmail = normalizeString(state?.accountIdentifierType).toLowerCase() === 'email'
|
||
? normalizeEmailValue(state?.accountIdentifier)
|
||
: '';
|
||
const email = firstNonEmpty(
|
||
normalizeEmailValue(session?.user?.email),
|
||
normalizeEmailValue(session?.email),
|
||
normalizeEmailValue(state?.email),
|
||
accountIdentifierEmail,
|
||
normalizeEmailValue(profile?.email),
|
||
normalizeEmailValue(idPayload?.email),
|
||
normalizeEmailValue(accessPayload?.email)
|
||
);
|
||
const accountId = firstNonEmpty(
|
||
session?.account?.id,
|
||
session?.account_id,
|
||
accessAuth?.chatgpt_account_id,
|
||
idAuth?.chatgpt_account_id
|
||
);
|
||
const userId = firstNonEmpty(
|
||
session?.user?.id,
|
||
session?.user_id,
|
||
accessAuth?.chatgpt_user_id,
|
||
accessAuth?.user_id,
|
||
idAuth?.chatgpt_user_id,
|
||
idAuth?.user_id
|
||
);
|
||
const planType = firstNonEmpty(
|
||
session?.account?.planType,
|
||
session?.account?.plan_type,
|
||
session?.planType,
|
||
session?.plan_type,
|
||
accessAuth?.chatgpt_plan_type,
|
||
idAuth?.chatgpt_plan_type
|
||
);
|
||
const exportedAt = normalizeTimestamp(options.now || new Date()) || new Date().toISOString();
|
||
const syntheticIdToken = inputIdToken
|
||
? ''
|
||
: buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt);
|
||
const idToken = inputIdToken || syntheticIdToken;
|
||
const authJson = Object.fromEntries(
|
||
Object.entries({
|
||
type: 'codex',
|
||
account_id: accountId,
|
||
chatgpt_account_id: accountId,
|
||
email,
|
||
name: firstNonEmpty(email, state?.email, 'ChatGPT Account'),
|
||
plan_type: planType,
|
||
chatgpt_plan_type: planType,
|
||
id_token: idToken,
|
||
id_token_synthetic: syntheticIdToken ? true : undefined,
|
||
access_token: accessToken,
|
||
refresh_token: refreshToken || '',
|
||
session_token: sessionToken,
|
||
last_refresh: exportedAt,
|
||
expired: expiresAt,
|
||
disabled: session?.disabled === true ? true : undefined,
|
||
}).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||
);
|
||
|
||
return {
|
||
authJson,
|
||
accountId,
|
||
email,
|
||
expiresAt,
|
||
fileName: buildCpaAuthFileName({ email, planType, accountId }),
|
||
hasRefreshToken: Boolean(refreshToken),
|
||
};
|
||
}
|
||
|
||
async function logWithOptions(message, level = 'info', options = {}) {
|
||
await addLog(message, level, options.logOptions || {});
|
||
}
|
||
|
||
async function requestOAuthUrl(state, options = {}) {
|
||
const managementKey = normalizeString(state?.vpsPassword);
|
||
if (!managementKey) {
|
||
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
|
||
}
|
||
const origin = deriveCpaManagementOrigin(state?.vpsUrl);
|
||
const result = await fetchCpaManagementJson(origin, '/v0/management/codex-auth-url', {
|
||
method: 'GET',
|
||
managementKey,
|
||
timeoutMs: options.timeoutMs,
|
||
});
|
||
const oauthUrl = firstNonEmpty(
|
||
result?.url,
|
||
result?.auth_url,
|
||
result?.authUrl,
|
||
result?.data?.url,
|
||
result?.data?.auth_url,
|
||
result?.data?.authUrl
|
||
);
|
||
const oauthState = firstNonEmpty(
|
||
result?.state,
|
||
result?.auth_state,
|
||
result?.authState,
|
||
result?.data?.state,
|
||
result?.data?.auth_state,
|
||
result?.data?.authState,
|
||
extractStateFromAuthUrl(oauthUrl)
|
||
);
|
||
|
||
if (!oauthUrl || !oauthUrl.startsWith('http')) {
|
||
throw new Error('CPA 管理接口未返回有效的 auth_url。');
|
||
}
|
||
|
||
return {
|
||
oauthUrl,
|
||
cpaOAuthState: oauthState || null,
|
||
cpaManagementOrigin: origin,
|
||
};
|
||
}
|
||
|
||
async function submitOAuthCallback(state, callbackUrl, options = {}) {
|
||
const managementKey = normalizeString(state?.vpsPassword);
|
||
if (!managementKey) {
|
||
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
|
||
}
|
||
const origin = normalizeString(state?.cpaManagementOrigin) || deriveCpaManagementOrigin(state?.vpsUrl);
|
||
const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', {
|
||
method: 'POST',
|
||
managementKey,
|
||
timeoutMs: options.timeoutMs,
|
||
body: {
|
||
provider: 'codex',
|
||
redirect_url: normalizeString(callbackUrl),
|
||
},
|
||
});
|
||
return {
|
||
localhostUrl: normalizeString(callbackUrl),
|
||
verifiedStatus: firstNonEmpty(result?.message, result?.status_message, 'CPA 已通过接口提交回调'),
|
||
};
|
||
}
|
||
|
||
async function importCurrentChatGptSession(state = {}, options = {}) {
|
||
const logLabel = normalizeString(options.logLabel) || 'CPA 会话导入';
|
||
const managementKey = normalizeString(state?.vpsPassword);
|
||
if (!managementKey) {
|
||
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
|
||
}
|
||
const origin = deriveCpaManagementOrigin(state?.vpsUrl);
|
||
const sessionAuth = buildCpaSessionAuthJson(state, options);
|
||
|
||
await logWithOptions(`${logLabel}:正在通过 CPA 管理接口导入当前 ChatGPT 会话...`, 'info', options);
|
||
if (!sessionAuth.hasRefreshToken) {
|
||
await logWithOptions(`${logLabel}:未包含 refresh_token,access_token 过期后无法自动续期。`, 'warn', options);
|
||
}
|
||
|
||
await fetchCpaManagementJson(origin, `/v0/management/auth-files?name=${encodeURIComponent(sessionAuth.fileName)}`, {
|
||
method: 'POST',
|
||
managementKey,
|
||
timeoutMs: options.importTimeoutMs || options.timeoutMs,
|
||
body: sessionAuth.authJson,
|
||
});
|
||
|
||
const verifiedStatus = sessionAuth.email
|
||
? `CPA 会话导入完成:${sessionAuth.email}`
|
||
: `CPA 会话导入完成:${sessionAuth.fileName}`;
|
||
await logWithOptions(verifiedStatus, 'ok', options);
|
||
return {
|
||
verifiedStatus,
|
||
cpaImportedFileName: sessionAuth.fileName,
|
||
cpaImportedEmail: sessionAuth.email || null,
|
||
};
|
||
}
|
||
|
||
return {
|
||
buildCpaSessionAuthJson,
|
||
deriveCpaManagementOrigin,
|
||
fetchCpaManagementJson,
|
||
importCurrentChatGptSession,
|
||
requestOAuthUrl,
|
||
submitOAuthCallback,
|
||
};
|
||
}
|
||
|
||
return {
|
||
createCpaApi,
|
||
};
|
||
});
|