Files
FlowPilot/background/contribution-oauth.js
T

757 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function attachBackgroundContributionOAuth(root, factory) {
root.MultiPageBackgroundContributionOAuth = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() {
const API_BASE_URL = 'https://apikey.qzz.io/oauth/api';
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
const CALLBACK_FINAL_STATUSES = new Set(['submitted']);
const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']);
const RUNTIME_DEFAULTS = {
contributionMode: false,
contributionModeExpected: false,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionNickname: '',
contributionQq: '',
contributionSessionId: '',
contributionAuthUrl: '',
contributionAuthState: '',
contributionCallbackUrl: '',
contributionStatus: '',
contributionStatusMessage: '',
contributionLastPollAt: 0,
contributionCallbackStatus: 'idle',
contributionCallbackMessage: '',
contributionAuthOpenedAt: 0,
contributionAuthTabId: 0,
};
const RUNTIME_KEYS = Object.keys(RUNTIME_DEFAULTS);
function createContributionOAuthManager(deps = {}) {
const {
addLog,
broadcastDataUpdate,
chrome,
closeLocalhostCallbackTabs,
getState,
setState,
} = deps;
let listenersBound = false;
const pendingCallbackSubmissions = new Map();
const pendingCapturedCallbacks = new Map();
function normalizeString(value = '') {
return String(value || '').trim();
}
function normalizePositiveInteger(value, fallback = 0) {
const numeric = Math.floor(Number(value) || 0);
return numeric > 0 ? numeric : fallback;
}
function normalizeContributionStatus(value = '') {
const normalized = normalizeString(value).toLowerCase();
switch (normalized) {
case 'started':
return 'started';
case 'waiting':
case 'wait':
return 'waiting';
case 'processing':
return 'processing';
case 'auto_approved':
case 'approved':
return 'auto_approved';
case 'auto_rejected':
case 'rejected':
return 'auto_rejected';
case 'manual_review_required':
case 'manual_review':
return 'manual_review_required';
case 'expired':
case 'timeout':
return 'expired';
case 'error':
case 'failed':
return 'error';
default:
return '';
}
}
function normalizeContributionCallbackStatus(value = '') {
const normalized = normalizeString(value).toLowerCase();
switch (normalized) {
case 'idle':
return 'idle';
case 'waiting':
case 'pending':
return 'waiting';
case 'captured':
return 'captured';
case 'submitting':
case 'processing':
return 'submitting';
case 'submitted':
case 'success':
case 'done':
return 'submitted';
case 'failed':
case 'error':
return 'failed';
default:
return '';
}
}
function isContributionFinalStatus(status = '') {
return FINAL_STATUSES.has(normalizeContributionStatus(status));
}
function getStatusLabel(status = '') {
switch (normalizeContributionStatus(status)) {
case 'started':
return '已生成登录地址';
case 'waiting':
return '等待提交回调';
case 'processing':
return '已提交回调,等待 CPA 确认';
case 'auto_approved':
return '贡献成功,CPA 已确认';
case 'auto_rejected':
return '贡献未通过确认';
case 'manual_review_required':
return '已提交,等待人工处理';
case 'expired':
return '贡献会话已超时';
case 'error':
return '贡献流程失败';
default:
return '等待开始贡献';
}
}
function getCallbackLabel(status = '') {
switch (normalizeContributionCallbackStatus(status)) {
case 'waiting':
case 'idle':
return '等待回调';
case 'captured':
return '已捕获回调地址';
case 'submitting':
return '正在提交回调';
case 'submitted':
return '已提交回调';
case 'failed':
return '回调提交失败';
default:
return '等待回调';
}
}
function unwrapPayload(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return {};
}
if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
return { ...payload.data, ...payload };
}
return payload;
}
function getErrorMessage(payload, responseStatus = 500) {
const details = [
payload?.message,
payload?.detail,
payload?.error,
payload?.reason,
]
.map((item) => normalizeString(item))
.find(Boolean);
if (details) {
return details;
}
return `贡献服务请求失败(HTTP ${responseStatus})。`;
}
async function fetchContributionJson(endpoint, options = {}) {
const controller = new AbortController();
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 15000));
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
let rawPayload = {};
try {
rawPayload = await response.json();
} catch {
rawPayload = {};
}
const payload = unwrapPayload(rawPayload);
if (!response.ok || payload.ok === false) {
const error = new Error(getErrorMessage(payload, response.status));
error.payload = payload;
error.responseStatus = response.status;
throw error;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error('贡献服务请求超时,请稍后重试。');
}
throw error;
} finally {
clearTimeout(timer);
}
}
function pickContributionState(state = {}) {
const picked = {};
for (const key of RUNTIME_KEYS) {
picked[key] = state[key] !== undefined ? state[key] : RUNTIME_DEFAULTS[key];
}
return picked;
}
async function applyRuntimeUpdates(updates = {}) {
if (!updates || typeof updates !== 'object' || Array.isArray(updates) || Object.keys(updates).length === 0) {
return getState();
}
await setState(updates);
broadcastDataUpdate(updates);
return getState();
}
function extractAuthStateFromUrl(authUrl = '') {
try {
return new URL(authUrl).searchParams.get('state') || '';
} catch {
return '';
}
}
function buildNickname(state = {}, preferredNickname = '') {
const nickname = normalizeString(preferredNickname)
|| normalizeString(state.contributionNickname);
return nickname || '';
}
function buildContributionQq(state = {}, preferredQq = '') {
const qq = normalizeString(preferredQq) || normalizeString(state.contributionQq);
return qq;
}
function isPlusModeState(state = {}) {
return Boolean(state?.plusModeEnabled);
}
function normalizeContributionModeSource(value = '') {
const normalized = normalizeString(value).toLowerCase();
return normalized === 'sub2api' ? 'sub2api' : 'cpa';
}
function resolveContributionModeRoutingState(state = {}) {
const currentStatus = normalizeString(state?.contributionStatus).toLowerCase();
const currentSource = normalizeContributionModeSource(state?.contributionSource);
const hasActiveSession = Boolean(
normalizeString(state?.contributionSessionId)
&& currentStatus
&& !FINAL_STATUSES.has(currentStatus)
);
if (hasActiveSession) {
return {
source: currentSource,
targetGroupName: currentSource === 'sub2api'
? (normalizeString(state?.contributionTargetGroupName) || 'codex号池')
: '',
};
}
return {
source: 'sub2api',
targetGroupName: isPlusModeState(state)
? 'openai-plus'
: (normalizeString(state?.contributionTargetGroupName) || 'codex号池'),
};
}
function buildStatusMessage(status, payload = {}) {
const label = getStatusLabel(status);
const details = [
payload.status_message,
payload.statusMessage,
payload.message,
payload.detail,
payload.reason,
]
.map((item) => normalizeString(item))
.find(Boolean);
if (!details || details === label) {
return label;
}
return `${label}${details}`;
}
function buildCallbackMessage(status, payload = {}) {
const label = getCallbackLabel(status);
const details = [
payload.callback_message,
payload.callbackMessage,
payload.message,
payload.detail,
payload.reason,
]
.map((item) => normalizeString(item))
.find(Boolean);
if (!details || details === label) {
return label;
}
return `${label}${details}`;
}
function deriveCallbackState(payload = {}, state = {}) {
const existingStatus = normalizeContributionCallbackStatus(state.contributionCallbackStatus);
const callbackUrl = normalizeString(
payload.callback_url
|| payload.callbackUrl
|| state.contributionCallbackUrl
);
const explicitStatus = normalizeContributionCallbackStatus(
payload.callback_status
|| payload.callbackStatus
);
if (explicitStatus) {
return {
status: explicitStatus,
message: buildCallbackMessage(explicitStatus, payload),
callbackUrl,
};
}
if (payload.callback_submitted === true || payload.callbackSubmitted === true) {
return {
status: 'submitted',
message: buildCallbackMessage('submitted', payload),
callbackUrl,
};
}
if (callbackUrl) {
return {
status: CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured',
message: buildCallbackMessage(CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', payload),
callbackUrl,
};
}
if (CALLBACK_FINAL_STATUSES.has(existingStatus) || existingStatus === 'failed') {
return {
status: existingStatus,
message: normalizeString(state.contributionCallbackMessage) || buildCallbackMessage(existingStatus),
callbackUrl: normalizeString(state.contributionCallbackUrl),
};
}
return {
status: 'waiting',
message: buildCallbackMessage('waiting', payload),
callbackUrl: '',
};
}
function isContributionCallbackUrl(rawUrl, state = {}) {
const urlText = normalizeString(rawUrl);
if (!urlText) {
return false;
}
let parsed;
try {
parsed = new URL(urlText);
} catch {
return false;
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false;
}
const code = normalizeString(parsed.searchParams.get('code'));
const errorText = normalizeString(parsed.searchParams.get('error'))
|| normalizeString(parsed.searchParams.get('error_description'));
const authState = normalizeString(parsed.searchParams.get('state'));
if ((!code && !errorText) || !authState) {
return false;
}
const hostLooksLocal = ['localhost', '127.0.0.1'].includes(parsed.hostname);
const pathLooksLikeCallback = /callback/i.test(parsed.pathname || '');
if (!hostLooksLocal && !pathLooksLikeCallback) {
return false;
}
const expectedState = normalizeString(state.contributionAuthState);
return !expectedState || expectedState === authState;
}
async function openContributionAuthUrl(authUrl, options = {}) {
const normalizedUrl = normalizeString(authUrl);
if (!normalizedUrl) {
throw new Error('贡献服务未返回有效的登录地址。');
}
const currentState = options.stateOverride || await getState();
const preferredTabId = normalizePositiveInteger(options.tabId || currentState.contributionAuthTabId, 0);
let tab = null;
if (preferredTabId) {
tab = await chrome.tabs.update(preferredTabId, {
url: normalizedUrl,
active: true,
}).catch(() => null);
}
if (!tab) {
tab = await chrome.tabs.create({ url: normalizedUrl, active: true });
}
await applyRuntimeUpdates({
contributionAuthUrl: normalizedUrl,
contributionAuthOpenedAt: Date.now(),
contributionAuthTabId: normalizePositiveInteger(tab?.id, 0),
});
return tab;
}
async function fetchContributionResult(sessionId) {
try {
return await fetchContributionJson(`/result?session_id=${encodeURIComponent(sessionId)}`);
} catch (error) {
if (typeof addLog === 'function') {
await addLog(`贡献模式:获取最终结果失败:${error.message}`, 'warn');
}
return null;
}
}
async function submitContributionCallback(callbackUrl, options = {}) {
const currentState = options.stateOverride || await getState();
const sessionId = normalizeString(currentState.contributionSessionId);
const normalizedUrl = normalizeString(callbackUrl);
if (!sessionId || !normalizedUrl) {
return currentState;
}
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
if (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') {
return currentState;
}
const dedupeKey = `${sessionId}::${normalizedUrl}`;
if (pendingCallbackSubmissions.has(dedupeKey)) {
return pendingCallbackSubmissions.get(dedupeKey);
}
const task = (async () => {
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'submitting',
contributionCallbackMessage: buildCallbackMessage('submitting'),
});
try {
const payload = await fetchContributionJson('/submit-callback', {
method: 'POST',
body: {
session_id: sessionId,
callback_url: normalizedUrl,
},
});
const nextStatus = 'submitted';
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: nextStatus,
contributionCallbackMessage: buildCallbackMessage(nextStatus, payload),
});
if (typeof closeLocalhostCallbackTabs === 'function') {
await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {});
}
return await pollContributionStatus({ reason: options.reason || 'submit_callback' });
} catch (error) {
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'failed',
contributionCallbackMessage: `回调提交失败:${error.message}`,
});
if (typeof addLog === 'function') {
await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn');
}
throw error;
} finally {
pendingCallbackSubmissions.delete(dedupeKey);
}
})();
pendingCallbackSubmissions.set(dedupeKey, task);
return task;
}
async function handleCapturedCallback(rawUrl, metadata = {}) {
const currentState = await getState();
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
return currentState;
}
if (!isContributionCallbackUrl(rawUrl, currentState)) {
return currentState;
}
const normalizedUrl = normalizeString(rawUrl);
const callbackDedupeKey = `${normalizeString(currentState.contributionSessionId)}::${normalizedUrl}`;
if (pendingCapturedCallbacks.has(callbackDedupeKey)) {
return pendingCapturedCallbacks.get(callbackDedupeKey);
}
if (pendingCallbackSubmissions.has(callbackDedupeKey)) {
return pendingCallbackSubmissions.get(callbackDedupeKey);
}
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
if (
normalizedUrl
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
) {
return currentState;
}
const task = (async () => {
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'captured',
contributionCallbackMessage: buildCallbackMessage('captured'),
});
if (typeof addLog === 'function') {
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
}
try {
return await submitContributionCallback(normalizedUrl, {
reason: metadata.source || 'navigation',
stateOverride: await getState(),
});
} catch {
return getState();
} finally {
pendingCapturedCallbacks.delete(callbackDedupeKey);
}
})();
pendingCapturedCallbacks.set(callbackDedupeKey, task);
return task;
}
async function pollContributionStatus(options = {}) {
const currentState = options.stateOverride || await getState();
const sessionId = normalizeString(currentState.contributionSessionId);
if (!sessionId) {
return currentState;
}
const payload = await fetchContributionJson(`/status?session_id=${encodeURIComponent(sessionId)}`);
const nextStatus = normalizeContributionStatus(payload.status || payload.state || payload.phase) || currentState.contributionStatus || 'waiting';
let finalPayload = null;
if (isContributionFinalStatus(nextStatus)) {
finalPayload = await fetchContributionResult(sessionId);
}
const mergedPayload = finalPayload ? { ...payload, ...finalPayload } : payload;
const normalizedStatus = normalizeContributionStatus(mergedPayload.status || mergedPayload.state || mergedPayload.phase) || nextStatus;
const callbackState = deriveCallbackState(mergedPayload, currentState);
const updates = {
contributionLastPollAt: Date.now(),
contributionStatus: normalizedStatus,
contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload),
contributionCallbackUrl: callbackState.callbackUrl,
contributionCallbackStatus: callbackState.status,
contributionCallbackMessage: callbackState.message,
};
const authUrl = normalizeString(mergedPayload.auth_url || mergedPayload.authUrl);
if (authUrl) {
updates.contributionAuthUrl = authUrl;
}
const authState = normalizeString(mergedPayload.state || mergedPayload.auth_state || mergedPayload.authState)
|| (authUrl ? extractAuthStateFromUrl(authUrl) : '');
if (authState) {
updates.contributionAuthState = authState;
}
await applyRuntimeUpdates(updates);
const nextState = await getState();
if (
normalizeString(nextState.contributionCallbackUrl)
&& CALLBACK_WAITING_STATUSES.has(normalizeContributionCallbackStatus(nextState.contributionCallbackStatus))
) {
try {
return await submitContributionCallback(nextState.contributionCallbackUrl, {
reason: options.reason || 'status_poll',
stateOverride: nextState,
});
} catch {
return getState();
}
}
return nextState;
}
async function startContributionFlow(options = {}) {
const currentState = options.stateOverride || await getState();
const shouldOpenAuthTab = options.openAuthTab !== false;
if (!currentState.contributionMode) {
throw new Error('请先进入贡献模式。');
}
const currentSessionId = normalizeString(currentState.contributionSessionId);
const currentStatus = normalizeContributionStatus(currentState.contributionStatus);
if (currentSessionId && ACTIVE_STATUSES.has(currentStatus)) {
if (normalizeString(currentState.contributionAuthUrl)) {
if (shouldOpenAuthTab) {
await openContributionAuthUrl(currentState.contributionAuthUrl, {
stateOverride: currentState,
}).catch(() => null);
}
}
return pollContributionStatus({ reason: 'resume_existing' });
}
const routingState = resolveContributionModeRoutingState(currentState);
const payload = await fetchContributionJson('/start', {
method: 'POST',
body: {
nickname: buildNickname(currentState, options.nickname),
qq: buildContributionQq(currentState, options.qq),
email: normalizeString(currentState.email),
source: routingState.source,
target_group_name: routingState.targetGroupName,
channel: 'codex-extension',
},
});
const sessionId = normalizeString(payload.session_id || payload.sessionId);
const authUrl = normalizeString(payload.auth_url || payload.authUrl);
const authState = normalizeString(payload.state || payload.auth_state || payload.authState) || extractAuthStateFromUrl(authUrl);
if (!sessionId || !authUrl) {
throw new Error('贡献服务未返回有效的 session_id 或 auth_url。');
}
await applyRuntimeUpdates({
contributionSessionId: sessionId,
contributionAuthUrl: authUrl,
contributionAuthState: authState,
contributionCallbackUrl: '',
contributionStatus: normalizeContributionStatus(payload.status) || 'started',
contributionStatusMessage: buildStatusMessage(normalizeContributionStatus(payload.status) || 'started', payload),
contributionLastPollAt: 0,
contributionCallbackStatus: 'waiting',
contributionCallbackMessage: buildCallbackMessage('waiting'),
contributionAuthOpenedAt: 0,
contributionAuthTabId: 0,
});
if (shouldOpenAuthTab) {
await openContributionAuthUrl(authUrl);
}
return pollContributionStatus({ reason: 'after_start' });
}
function onNavigationEvent(details = {}, source) {
if (details?.frameId !== undefined && Number(details.frameId) !== 0) {
return;
}
handleCapturedCallback(details?.url || '', {
source,
tabId: normalizePositiveInteger(details?.tabId, 0),
}).catch(() => {});
}
function onTabUpdated(tabId, changeInfo, tab) {
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
if (!candidateUrl) {
return;
}
handleCapturedCallback(candidateUrl, {
source: 'tabs.onUpdated',
tabId: normalizePositiveInteger(tabId, 0),
}).catch(() => {});
}
function ensureCallbackListeners() {
if (listenersBound) {
return;
}
chrome.webNavigation.onCommitted.addListener((details) => {
onNavigationEvent(details, 'webNavigation.onCommitted');
});
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
onNavigationEvent(details, 'webNavigation.onHistoryStateUpdated');
});
chrome.tabs.onUpdated.addListener(onTabUpdated);
listenersBound = true;
}
return {
ensureCallbackListeners,
handleCapturedCallback,
isContributionCallbackUrl,
isContributionFinalStatus,
pollContributionStatus,
startContributionFlow,
submitContributionCallback,
};
}
return {
ACTIVE_STATUSES,
FINAL_STATUSES,
RUNTIME_DEFAULTS,
RUNTIME_KEYS,
createContributionOAuthManager,
};
});