feat: add CPA plus session import strategy
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
(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,
|
||||
};
|
||||
});
|
||||
@@ -593,9 +593,14 @@
|
||||
}
|
||||
|
||||
function normalizePlusAccountAccessStrategyForDisplay(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === 'sub2api_codex_session'
|
||||
? 'sub2api_codex_session'
|
||||
: 'oauth';
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'sub2api_codex_session') {
|
||||
return 'sub2api_codex_session';
|
||||
}
|
||||
if (normalized === 'cpa_codex_session') {
|
||||
return 'cpa_codex_session';
|
||||
}
|
||||
return 'oauth';
|
||||
}
|
||||
|
||||
function getPlusAccountAccessStrategyLabel(value = '') {
|
||||
@@ -604,6 +609,27 @@
|
||||
: 'OAuth';
|
||||
}
|
||||
|
||||
function getPlusAccountAccessStrategyLabel(value = '', targetId = '') {
|
||||
const strategy = normalizePlusAccountAccessStrategyForDisplay(value);
|
||||
const normalizedTargetId = String(targetId || '').trim().toLowerCase();
|
||||
if (strategy === 'sub2api_codex_session') {
|
||||
return '导入当前 ChatGPT 会话到 SUB2API';
|
||||
}
|
||||
if (strategy === 'cpa_codex_session') {
|
||||
return '导入当前 ChatGPT 会话到 CPA';
|
||||
}
|
||||
if (normalizedTargetId === 'cpa') {
|
||||
return '通过 OAuth 回调创建 CPA 账号';
|
||||
}
|
||||
if (normalizedTargetId === 'sub2api') {
|
||||
return '通过 OAuth 回调创建 SUB2API 账号';
|
||||
}
|
||||
if (normalizedTargetId === 'codex2api') {
|
||||
return '通过 OAuth 回调创建 Codex2API 账号';
|
||||
}
|
||||
return 'OAuth';
|
||||
}
|
||||
|
||||
async function handlePlatformVerifyStepData(payload) {
|
||||
if (payload.localhostUrl) {
|
||||
await closeLocalhostCallbackTabs(payload.localhostUrl);
|
||||
@@ -1561,7 +1587,12 @@
|
||||
stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal'
|
||||
);
|
||||
const selectedPlusAccountAccessStrategy = getPlusAccountAccessStrategyLabel(
|
||||
stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth'
|
||||
stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth',
|
||||
stateUpdates.panelMode
|
||||
?? currentState?.panelMode
|
||||
?? stateUpdates.openaiIntegrationTargetId
|
||||
?? currentState?.openaiIntegrationTargetId
|
||||
?? 'cpa'
|
||||
);
|
||||
await addLog(
|
||||
Boolean(updates.plusModeEnabled)
|
||||
@@ -1576,7 +1607,12 @@
|
||||
await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info');
|
||||
} else if (plusAccountAccessStrategyChanged && nextPlusModeEnabled) {
|
||||
const selectedPlusAccountAccessStrategy = getPlusAccountAccessStrategyLabel(
|
||||
stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth'
|
||||
stateUpdates.plusAccountAccessStrategy ?? currentState?.plusAccountAccessStrategy ?? 'oauth',
|
||||
stateUpdates.panelMode
|
||||
?? currentState?.panelMode
|
||||
?? stateUpdates.openaiIntegrationTargetId
|
||||
?? currentState?.openaiIntegrationTargetId
|
||||
?? 'cpa'
|
||||
);
|
||||
await addLog(`Plus 账号接入策略已切换为 ${selectedPlusAccountAccessStrategy},已更新对应的 Plus 尾链。`, 'info');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
(function attachBackgroundCpaSessionImport(root, factory) {
|
||||
root.MultiPageBackgroundCpaSessionImport = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundCpaSessionImportModule() {
|
||||
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
|
||||
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/operation-delay.js', 'content/plus-checkout.js'];
|
||||
|
||||
function createCpaSessionImportExecutor(deps = {}) {
|
||||
const {
|
||||
addLog: rawAddLog = async () => {},
|
||||
chrome,
|
||||
completeNodeFromBackground,
|
||||
ensureContentScriptReadyOnTabUntilStopped,
|
||||
getTabId,
|
||||
isTabAlive,
|
||||
registerTab,
|
||||
sendTabMessageUntilStopped,
|
||||
sleepWithStop = async () => {},
|
||||
throwIfStopped = () => {},
|
||||
waitForTabCompleteUntilStopped = async () => {},
|
||||
} = deps;
|
||||
|
||||
let cpaApi = null;
|
||||
|
||||
function addStepLog(step, message, level = 'info') {
|
||||
return rawAddLog(message, level, {
|
||||
step,
|
||||
stepKey: 'cpa-session-import',
|
||||
});
|
||||
}
|
||||
|
||||
function getCpaApi() {
|
||||
if (cpaApi) {
|
||||
return cpaApi;
|
||||
}
|
||||
const factory = deps.createCpaApi
|
||||
|| self.MultiPageBackgroundCpaApi?.createCpaApi;
|
||||
if (typeof factory !== 'function') {
|
||||
throw new Error('CPA 接口模块未加载,无法导入当前 ChatGPT 会话。');
|
||||
}
|
||||
cpaApi = factory({
|
||||
addLog: rawAddLog,
|
||||
});
|
||||
return cpaApi;
|
||||
}
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function resolveVisibleStep(state = {}) {
|
||||
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
|
||||
return visibleStep > 0 ? visibleStep : 10;
|
||||
}
|
||||
|
||||
function isSupportedChatGptSessionUrl(url = '') {
|
||||
try {
|
||||
const parsed = new URL(String(url || ''));
|
||||
if (!/^https?:$/i.test(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
const hostname = String(parsed.hostname || '').trim().toLowerCase();
|
||||
return /(^|\.)chatgpt\.com$/.test(hostname)
|
||||
|| hostname === 'chat.openai.com'
|
||||
|| /(^|\.)openai\.com$/.test(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionTabHostPriority(url = '') {
|
||||
try {
|
||||
const hostname = String(new URL(String(url || '')).hostname || '').trim().toLowerCase();
|
||||
if (/(^|\.)chatgpt\.com$/.test(hostname)) {
|
||||
return 0;
|
||||
}
|
||||
if (hostname === 'chat.openai.com') {
|
||||
return 1;
|
||||
}
|
||||
if (/(^|\.)openai\.com$/.test(hostname)) {
|
||||
return 2;
|
||||
}
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function getSessionTabActivityPriority(tab = {}) {
|
||||
if (tab?.active && tab?.currentWindow) {
|
||||
return 0;
|
||||
}
|
||||
if (tab?.active) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
function pickPreferredSessionTab(tabs = []) {
|
||||
const candidates = (Array.isArray(tabs) ? tabs : [])
|
||||
.filter((tab) => Number.isInteger(tab?.id) && isSupportedChatGptSessionUrl(tab.url));
|
||||
if (!candidates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidates.reduce((best, candidate) => {
|
||||
if (!best) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const candidateHostPriority = getSessionTabHostPriority(candidate.url);
|
||||
const bestHostPriority = getSessionTabHostPriority(best.url);
|
||||
if (candidateHostPriority !== bestHostPriority) {
|
||||
return candidateHostPriority < bestHostPriority ? candidate : best;
|
||||
}
|
||||
|
||||
const candidateActivityPriority = getSessionTabActivityPriority(candidate);
|
||||
const bestActivityPriority = getSessionTabActivityPriority(best);
|
||||
if (candidateActivityPriority !== bestActivityPriority) {
|
||||
return candidateActivityPriority < bestActivityPriority ? candidate : best;
|
||||
}
|
||||
|
||||
const candidateLastAccessed = Number(candidate?.lastAccessed) || 0;
|
||||
const bestLastAccessed = Number(best?.lastAccessed) || 0;
|
||||
if (candidateLastAccessed !== bestLastAccessed) {
|
||||
return candidateLastAccessed > bestLastAccessed ? candidate : best;
|
||||
}
|
||||
|
||||
return Number(candidate.id) < Number(best.id) ? candidate : best;
|
||||
}, null);
|
||||
}
|
||||
|
||||
async function readSupportedSessionTab(tabId) {
|
||||
const numericTabId = Number(tabId) || 0;
|
||||
if (!numericTabId || !chrome?.tabs?.get) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tab = await chrome.tabs.get(numericTabId).catch(() => null);
|
||||
return tab?.id && isSupportedChatGptSessionUrl(tab.url)
|
||||
? tab
|
||||
: null;
|
||||
}
|
||||
|
||||
async function findFallbackSessionTab() {
|
||||
if (!chrome?.tabs?.query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }).catch(() => []);
|
||||
const activeMatch = pickPreferredSessionTab(activeTabs);
|
||||
const allTabs = await chrome.tabs.query({}).catch(() => []);
|
||||
const globalMatch = pickPreferredSessionTab(allTabs);
|
||||
return pickPreferredSessionTab([activeMatch, globalMatch]);
|
||||
}
|
||||
|
||||
async function resolveSessionTabId(state = {}) {
|
||||
const registeredTabId = typeof getTabId === 'function'
|
||||
? await getTabId(PLUS_CHECKOUT_SOURCE)
|
||||
: null;
|
||||
if (registeredTabId && typeof isTabAlive === 'function' && await isTabAlive(PLUS_CHECKOUT_SOURCE)) {
|
||||
const registeredTab = await readSupportedSessionTab(registeredTabId);
|
||||
if (registeredTab?.id) {
|
||||
return registeredTab.id;
|
||||
}
|
||||
}
|
||||
|
||||
const storedTabId = Number(state?.plusCheckoutTabId) || 0;
|
||||
const storedTab = await readSupportedSessionTab(storedTabId);
|
||||
if (storedTab?.id) {
|
||||
if (typeof registerTab === 'function') {
|
||||
await registerTab(PLUS_CHECKOUT_SOURCE, storedTab.id);
|
||||
}
|
||||
return storedTab.id;
|
||||
}
|
||||
|
||||
const fallbackTab = await findFallbackSessionTab();
|
||||
if (fallbackTab?.id) {
|
||||
if (typeof registerTab === 'function') {
|
||||
await registerTab(PLUS_CHECKOUT_SOURCE, fallbackTab.id);
|
||||
}
|
||||
return fallbackTab.id;
|
||||
}
|
||||
|
||||
throw new Error('未找到可读取 ChatGPT 会话的标签页,请先打开一个已登录的 ChatGPT / OpenAI 页面,或完成当前 Plus 支付链路。');
|
||||
}
|
||||
|
||||
async function getResolvedSessionTab(tabId, visibleStep) {
|
||||
const tab = await chrome?.tabs?.get?.(tabId).catch(() => null);
|
||||
if (!tab?.id) {
|
||||
throw new Error(`步骤 ${visibleStep}:ChatGPT 会话标签页不存在或已关闭,无法继续导入 CPA。`);
|
||||
}
|
||||
if (!isSupportedChatGptSessionUrl(tab.url)) {
|
||||
throw new Error(`步骤 ${visibleStep}:当前标签页不在 ChatGPT / OpenAI 页面,无法读取当前登录会话。`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function readCurrentChatGptSession(tabId, visibleStep) {
|
||||
await waitForTabCompleteUntilStopped(tabId);
|
||||
await sleepWithStop(1000);
|
||||
await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, {
|
||||
inject: PLUS_CHECKOUT_INJECT_FILES,
|
||||
injectSource: PLUS_CHECKOUT_SOURCE,
|
||||
logMessage: `步骤 ${visibleStep}:正在等待 ChatGPT 会话页完成加载,再继续读取当前登录会话...`,
|
||||
});
|
||||
|
||||
const sessionResult = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, {
|
||||
type: 'PLUS_CHECKOUT_GET_STATE',
|
||||
source: 'background',
|
||||
payload: {
|
||||
includeSession: true,
|
||||
includeAccessToken: true,
|
||||
},
|
||||
});
|
||||
if (sessionResult?.error) {
|
||||
throw new Error(sessionResult.error);
|
||||
}
|
||||
|
||||
const session = sessionResult?.session && typeof sessionResult.session === 'object' && !Array.isArray(sessionResult.session)
|
||||
? sessionResult.session
|
||||
: null;
|
||||
const accessToken = normalizeString(
|
||||
sessionResult?.accessToken
|
||||
|| session?.accessToken
|
||||
);
|
||||
if (!session && !accessToken) {
|
||||
throw new Error(`步骤 ${visibleStep}:未读取到有效的 ChatGPT 会话或 accessToken,请确认当前标签页仍处于已登录状态。`);
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCpaSessionImport(state = {}) {
|
||||
throwIfStopped();
|
||||
const visibleStep = resolveVisibleStep(state);
|
||||
const api = getCpaApi();
|
||||
|
||||
await addStepLog(visibleStep, '正在定位当前 ChatGPT 会话页并准备导入 CPA...', 'info');
|
||||
const tabId = await resolveSessionTabId(state);
|
||||
const tab = await getResolvedSessionTab(tabId, visibleStep);
|
||||
if (chrome?.tabs?.update) {
|
||||
await chrome.tabs.update(tab.id, { active: true }).catch(() => {});
|
||||
}
|
||||
|
||||
await addStepLog(visibleStep, '正在读取当前 ChatGPT 登录会话...', 'info');
|
||||
const sessionState = await readCurrentChatGptSession(tab.id, visibleStep);
|
||||
throwIfStopped();
|
||||
|
||||
const result = await api.importCurrentChatGptSession({
|
||||
...state,
|
||||
session: sessionState.session,
|
||||
accessToken: sessionState.accessToken,
|
||||
}, {
|
||||
visibleStep,
|
||||
logLabel: `步骤 ${visibleStep}`,
|
||||
logOptions: { step: visibleStep, stepKey: 'cpa-session-import' },
|
||||
timeoutMs: 120000,
|
||||
importTimeoutMs: 120000,
|
||||
});
|
||||
|
||||
await completeNodeFromBackground(state?.nodeId || 'cpa-session-import', result);
|
||||
}
|
||||
|
||||
return {
|
||||
executeCpaSessionImport,
|
||||
isSupportedChatGptSessionUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createCpaSessionImportExecutor,
|
||||
};
|
||||
});
|
||||
@@ -4,14 +4,28 @@
|
||||
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
|
||||
const GOPAY_CONFIRM_NODE_ID = 'gopay-subscription-confirm';
|
||||
const SUB2API_SESSION_IMPORT_NODE_ID = 'sub2api-session-import';
|
||||
const CPA_SESSION_IMPORT_NODE_ID = 'cpa-session-import';
|
||||
const DEFAULT_CONFIRM_TITLE = 'GoPay 订阅确认';
|
||||
const OAUTH_CONTINUATION_LABEL = 'OAuth 登录';
|
||||
const SUB2API_SESSION_CONTINUATION_LABEL = '导入当前 ChatGPT 会话到 SUB2API';
|
||||
|
||||
const CPA_SESSION_CONTINUATION_LABEL = '导入当前 ChatGPT 会话到 CPA';
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function getContinuationActionLabelForNodeId(nodeId = '') {
|
||||
const normalizedNodeId = normalizeString(nodeId);
|
||||
if (normalizedNodeId === SUB2API_SESSION_IMPORT_NODE_ID) {
|
||||
return SUB2API_SESSION_CONTINUATION_LABEL;
|
||||
}
|
||||
if (normalizedNodeId === CPA_SESSION_IMPORT_NODE_ID) {
|
||||
return CPA_SESSION_CONTINUATION_LABEL;
|
||||
}
|
||||
return OAUTH_CONTINUATION_LABEL;
|
||||
}
|
||||
|
||||
function getContinuationActionLabel(state = {}, options = {}) {
|
||||
const { getNodeIdsForState = null } = options;
|
||||
if (typeof getNodeIdsForState === 'function') {
|
||||
@@ -24,11 +38,16 @@
|
||||
? normalizeString(nodeIds[currentNodeIndex + 1])
|
||||
: '';
|
||||
|
||||
if (
|
||||
nextNodeId === SUB2API_SESSION_IMPORT_NODE_ID
|
||||
|| (currentNodeIndex < 0 && nodeIds.includes(SUB2API_SESSION_IMPORT_NODE_ID))
|
||||
) {
|
||||
return SUB2API_SESSION_CONTINUATION_LABEL;
|
||||
if (nextNodeId) {
|
||||
return getContinuationActionLabelForNodeId(nextNodeId);
|
||||
}
|
||||
if (currentNodeIndex < 0) {
|
||||
if (nodeIds.includes(SUB2API_SESSION_IMPORT_NODE_ID)) {
|
||||
return SUB2API_SESSION_CONTINUATION_LABEL;
|
||||
}
|
||||
if (nodeIds.includes(CPA_SESSION_IMPORT_NODE_ID)) {
|
||||
return CPA_SESSION_CONTINUATION_LABEL;
|
||||
}
|
||||
}
|
||||
}
|
||||
return OAUTH_CONTINUATION_LABEL;
|
||||
|
||||
Reference in New Issue
Block a user