diff --git a/background/steps/sub2api-session-import.js b/background/steps/sub2api-session-import.js index 80640c0..7d51654 100644 --- a/background/steps/sub2api-session-import.js +++ b/background/steps/sub2api-session-import.js @@ -71,32 +71,127 @@ } } + 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)) { - return Number(registeredTabId) || 0; - } - - const storedTabId = Number(state?.plusCheckoutTabId) || 0; - if (storedTabId && chrome?.tabs?.get) { - const tab = await chrome.tabs.get(storedTabId).catch(() => null); - if (tab?.id) { - if (typeof registerTab === 'function') { - await registerTab(PLUS_CHECKOUT_SOURCE, tab.id); - } - return tab.id; + const registeredTab = await readSupportedSessionTab(registeredTabId); + if (registeredTab?.id) { + return registeredTab.id; } } - throw new Error('未找到可读取 ChatGPT 会话的 Plus 标签页,请先完成当前 Plus 支付链路。'); + 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}:Plus 会话标签页不存在或已关闭,无法继续导入 SUB2API。`); + throw new Error(`步骤 ${visibleStep}:ChatGPT 会话标签页不存在或已关闭,无法继续导入 SUB2API。`); } if (!isSupportedChatGptSessionUrl(tab.url)) { throw new Error(`步骤 ${visibleStep}:当前标签页不在 ChatGPT / OpenAI 页面,无法读取当前登录会话。`); @@ -147,7 +242,7 @@ const visibleStep = resolveVisibleStep(state); const api = getSub2ApiApi(); - await addStepLog(visibleStep, '正在定位当前 Plus 会话页并准备导入 SUB2API...', 'info'); + await addStepLog(visibleStep, '正在定位当前 ChatGPT 会话页并准备导入 SUB2API...', 'info'); const tabId = await resolveSessionTabId(state); const tab = await getResolvedSessionTab(tabId, visibleStep); if (chrome?.tabs?.update) { diff --git a/tests/background-sub2api-session-import.test.js b/tests/background-sub2api-session-import.test.js index 82ef552..3790344 100644 --- a/tests/background-sub2api-session-import.test.js +++ b/tests/background-sub2api-session-import.test.js @@ -238,7 +238,196 @@ test('session import step reads current ChatGPT session and completes node', asy ); }); -test('session import step rejects unsupported non-chatgpt tabs before reading session', async () => { +test('session import step falls back to an active ChatGPT tab when no checkout tab is tracked', async () => { + const moduleApi = loadSub2ApiSessionImportModule(); + const completed = []; + const importedPayloads = []; + const queryCalls = []; + const registerCalls = []; + const sentMessages = []; + + const sessionTab = { + id: 77, + url: 'https://chatgpt.com/?model=gpt-4o', + active: true, + currentWindow: true, + lastAccessed: 1234, + }; + + const executor = moduleApi.createSub2ApiSessionImportExecutor({ + addLog: async () => {}, + chrome: { + tabs: { + get: async (tabId) => ({ + ...sessionTab, + id: tabId, + }), + query: async (queryInfo = {}) => { + queryCalls.push(queryInfo); + if (queryInfo.active && queryInfo.currentWindow) { + return [sessionTab]; + } + return [sessionTab]; + }, + update: async () => {}, + }, + }, + completeNodeFromBackground: async (nodeId, payload) => { + completed.push({ nodeId, payload }); + }, + createSub2ApiApi: () => ({ + importCurrentChatGptSession: async (state, options) => { + importedPayloads.push({ state, options }); + return { + verifiedStatus: 'SUB2API 会话导入完成:新建 1,更新 0,跳过 0,失败 0', + sub2apiImportCreated: 1, + sub2apiImportUpdated: 0, + sub2apiImportSkipped: 0, + sub2apiImportFailed: 0, + }; + }, + }), + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + getTabId: async () => null, + isTabAlive: async () => false, + normalizeSub2ApiUrl: (value) => value, + registerTab: async (source, tabId) => { + registerCalls.push({ source, tabId }); + }, + sendTabMessageUntilStopped: async (tabId, source, message) => { + sentMessages.push({ tabId, source, message }); + return { + session: { + accessToken: 'session-access-token', + user: { + email: 'fallback@example.com', + }, + }, + accessToken: 'session-access-token', + }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + waitForTabCompleteUntilStopped: async () => {}, + DEFAULT_SUB2API_GROUP_NAME: 'codex', + }); + + await executor.executeSub2ApiSessionImport({ + nodeId: 'sub2api-session-import', + visibleStep: 10, + sub2apiUrl: 'https://sub.example/admin/accounts', + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiGroupName: 'codex', + }); + + assert.deepStrictEqual(queryCalls, [ + { active: true, currentWindow: true }, + {}, + ]); + assert.deepStrictEqual(registerCalls, [{ + source: 'plus-checkout', + tabId: 77, + }]); + assert.equal(sentMessages.length, 1); + assert.equal(sentMessages[0].tabId, 77); + assert.equal(importedPayloads.length, 1); + assert.equal(importedPayloads[0].state.session.user.email, 'fallback@example.com'); + assert.equal(completed.length, 1); +}); + +test('session import step ignores unusable tracked tabs and prefers a real ChatGPT tab from open tabs', async () => { + const moduleApi = loadSub2ApiSessionImportModule(); + const registerCalls = []; + const sentMessages = []; + + const tabsById = { + 91: { + id: 91, + url: 'https://www.paypal.com/checkoutnow', + active: false, + currentWindow: false, + lastAccessed: 10, + }, + 101: { + id: 101, + url: 'https://platform.openai.com/settings/profile', + active: true, + currentWindow: true, + lastAccessed: 50, + }, + 203: { + id: 203, + url: 'https://chatgpt.com/c/abc123', + active: false, + currentWindow: false, + lastAccessed: 40, + }, + }; + + const executor = moduleApi.createSub2ApiSessionImportExecutor({ + addLog: async () => {}, + chrome: { + tabs: { + get: async (tabId) => tabsById[tabId] || null, + query: async (queryInfo = {}) => { + if (queryInfo.active && queryInfo.currentWindow) { + return [tabsById[101]]; + } + return [tabsById[101], tabsById[203]]; + }, + update: async () => {}, + }, + }, + completeNodeFromBackground: async () => {}, + createSub2ApiApi: () => ({ + importCurrentChatGptSession: async () => ({ + verifiedStatus: 'SUB2API 会话导入完成:新建 1,更新 0,跳过 0,失败 0', + }), + }), + ensureContentScriptReadyOnTabUntilStopped: async () => {}, + getTabId: async () => 91, + isTabAlive: async () => true, + normalizeSub2ApiUrl: (value) => value, + registerTab: async (source, tabId) => { + registerCalls.push({ source, tabId }); + }, + sendTabMessageUntilStopped: async (tabId, source, message) => { + sentMessages.push({ tabId, source, message }); + return { + session: { + accessToken: 'session-access-token', + user: { + email: 'best-match@example.com', + }, + }, + accessToken: 'session-access-token', + }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + waitForTabCompleteUntilStopped: async () => {}, + DEFAULT_SUB2API_GROUP_NAME: 'codex', + }); + + await executor.executeSub2ApiSessionImport({ + nodeId: 'sub2api-session-import', + visibleStep: 10, + sub2apiUrl: 'https://sub.example/admin/accounts', + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiGroupName: 'codex', + }); + + assert.deepStrictEqual(registerCalls, [{ + source: 'plus-checkout', + tabId: 203, + }]); + assert.equal(sentMessages.length, 1); + assert.equal(sentMessages[0].tabId, 203); +}); + +test('session import step reports missing readable session tab when tracked tabs are unusable', async () => { const moduleApi = loadSub2ApiSessionImportModule(); let sendCalled = false; @@ -275,7 +464,7 @@ test('session import step rejects unsupported non-chatgpt tabs before reading se nodeId: 'sub2api-session-import', visibleStep: 10, }), - /当前标签页不在 ChatGPT \/ OpenAI 页面/ + /未找到可读取 ChatGPT 会话的标签页/ ); assert.equal(sendCalled, false);