import type { ApiEnvelope, PublisherBindRequest, PublisherBindResponse, PublisherLocalPlatformState, PublisherPluginPingResponse, PublisherPublishArticleRequest, PublisherPublishResponse, PublisherPublishTaskResult, PublisherRegisterInstallationPayload, PublisherRegisterInstallationResult, } from "@geo/shared-types"; import { browser } from "wxt/browser"; import { detectPlatformState, publishViaAdapter } from "./adapters"; import { buildPublishedUrl } from "./platforms"; import { getExtensionState, patchExtensionState } from "./storage"; type PublisherAction = "ping" | "detectPlatforms" | "registerInstallation" | "bindAccount" | "publishArticle"; function normalizeBaseUrl(baseUrl: string): string { return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; } function normalizeText(value?: string | null): string | null { const trimmed = value?.trim(); return trimmed ? trimmed : null; } function logPublishRuntime(level: "info" | "warn" | "error", event: string, payload: Record): void { const prefix = `[geo-publisher] ${event}`; if (level === "error") { console.error(prefix, payload); return; } if (level === "warn") { console.warn(prefix, payload); return; } console.info(prefix, payload); } function normalizeUrl(value?: string | null, platformId?: string | null, externalArticleId?: string | null): string | null { const explicit = normalizeText(value); if (explicit) { return explicit; } if (!platformId || !externalArticleId) { return null; } return normalizeText(buildPublishedUrl(platformId, externalArticleId)); } async function postCallback( baseUrl: string, path: string, payload: Record, ): Promise { const state = await getExtensionState(); const response = await fetch(new URL(path.replace(/^\//, ""), normalizeBaseUrl(baseUrl)).toString(), { method: "POST", headers: { "Content-Type": "application/json", ...(state.installation_token ? { "X-Geo-Installation-Token": state.installation_token } : {}), ...(state.plugin_installation_id ? { "X-Geo-Installation-Id": String(state.plugin_installation_id) } : {}), }, body: JSON.stringify(payload), }); const body = (await response.json()) as ApiEnvelope | { message?: string }; if (!response.ok || !("data" in body)) { throw new Error(body.message || "publisher_callback_failed"); } return body.data; } function asFailedAdapterResult(error: unknown) { const message = error instanceof Error ? error.message : "publisher_adapter_failed"; return { success: false, status: "failed" as const, externalArticleId: null, externalArticleUrl: null, externalManageUrl: null, message, responsePayload: { mode: "platform-adapter", error: message, stack: error instanceof Error ? error.stack ?? null : null, }, }; } async function postPublishCallback( callbackBaseUrl: string, payload: PublisherPublishArticleRequest, task: PublisherPublishArticleRequest["tasks"][number], adapterResult: { success: boolean; status: PublisherPublishTaskResult["status"]; externalArticleId?: string | null; externalArticleUrl?: string | null; externalManageUrl?: string | null; message?: string | null; responsePayload?: Record; }, ) { const externalArticleId = normalizeText(adapterResult.externalArticleId); const externalArticleUrl = normalizeUrl(adapterResult.externalArticleUrl, task.platform_id, externalArticleId); const externalManageUrl = normalizeText(adapterResult.externalManageUrl); await postCallback(callbackBaseUrl, "/api/callback/plugin/publish", { plugin_session_id: task.plugin_session_id, session_token: task.session_token, article_id: payload.article_id, publish_record_id: task.publish_record_id, platform_account_id: task.platform_account_id, platform_id: task.platform_id, status: adapterResult.status, external_article_id: externalArticleId, external_article_url: externalArticleUrl, external_manage_url: externalManageUrl, published_at: adapterResult.success ? new Date().toISOString() : null, error_message: adapterResult.success ? null : adapterResult.message, client_version: browser.runtime.getManifest().version, request_payload: { title: payload.title, cover_asset_url: normalizeText(payload.cover_asset_url), publish_type: payload.publish_type, }, response_payload: adapterResult.responsePayload ?? { mode: "platform-adapter", }, }); return { publish_record_id: task.publish_record_id, platform_account_id: task.platform_account_id, platform_id: task.platform_id, success: adapterResult.success, status: adapterResult.status, external_article_id: externalArticleId, external_article_url: externalArticleUrl, external_manage_url: externalManageUrl, message: adapterResult.message ?? null, } satisfies PublisherPublishTaskResult; } export async function handlePublisherAction( action: PublisherAction, payload?: unknown, ): Promise< | PublisherPluginPingResponse | PublisherLocalPlatformState[] | PublisherRegisterInstallationResult | PublisherBindResponse | PublisherPublishResponse > { switch (action) { case "ping": return handlePing(); case "detectPlatforms": return handleDetectPlatforms(); case "registerInstallation": return handleRegisterInstallation(payload as PublisherRegisterInstallationPayload); case "bindAccount": return handleBindAccount(payload as PublisherBindRequest); case "publishArticle": return handlePublishArticle(payload as PublisherPublishArticleRequest); default: throw new Error("publisher_action_not_supported"); } } async function handlePing(): Promise { const state = await getExtensionState(); return { installed: true, version: browser.runtime.getManifest().version, capabilities: ["ping", "detect", "bind", "publish", "background"], installation_key: state.installation_key, plugin_installation_id: state.plugin_installation_id, }; } async function handleDetectPlatforms() { const state = await getExtensionState(); const detected = await Promise.all(state.platforms.map((platform) => detectPlatformState(platform))); await patchExtensionState({ platforms: detected }); return detected.map((platform) => ({ platform_id: platform.platform_id, connected: platform.connected, platform_uid: platform.platform_uid ?? null, nickname: platform.nickname ?? null, avatar_url: platform.avatar_url ?? null, message: platform.message ?? null, })); } async function handleRegisterInstallation( payload: PublisherRegisterInstallationPayload, ): Promise { await patchExtensionState({ plugin_installation_id: payload.plugin_installation_id, installation_token: payload.installation_token, api_base_url: payload.api_base_url, }); return { success: true, plugin_installation_id: payload.plugin_installation_id, }; } async function handleBindAccount(payload: PublisherBindRequest): Promise { const state = await getExtensionState(); const rawPlatform = state.platforms.find((item) => item.platform_id === payload.platform_id); const platform = rawPlatform ? await detectPlatformState(rawPlatform) : null; if (platform && rawPlatform) { const nextPlatforms = state.platforms.map((item) => (item.platform_id === platform.platform_id ? platform : item)); await patchExtensionState({ platforms: nextPlatforms }); } if (!platform?.connected) { if (payload.login_url) { await browser.tabs.create({ url: payload.login_url }); } return { success: false, platform_id: payload.platform_id, message: "No local session detected for this platform. Configure it in the extension popup first.", }; } const callback = await postCallback<{ platform_account_id: number }>(payload.callback_base_url, "/api/callback/plugin/bind", { plugin_session_id: payload.plugin_session_id, session_token: payload.session_token, platform_id: payload.platform_id, platform_uid: platform.platform_uid, nickname: platform.nickname, avatar_url: platform.avatar_url, status: "active", client_version: browser.runtime.getManifest().version, metadata: { installation_key: state.installation_key, plugin_installation_id: state.plugin_installation_id, }, }); return { success: true, platform_id: payload.platform_id, platform_uid: platform.platform_uid ?? null, nickname: platform.nickname ?? null, avatar_url: platform.avatar_url ?? null, platform_account_id: callback.platform_account_id, message: "Platform account bound successfully.", }; } async function handlePublishArticle(payload: PublisherPublishArticleRequest): Promise { const state = await getExtensionState(); const results: PublisherPublishTaskResult[] = []; for (const task of payload.tasks) { const platform = state.platforms.find((item) => item.platform_id === task.platform_id); const adapterResult = await publishViaAdapter(task.platform_id, { article: payload, task, }).catch(asFailedAdapterResult); if (adapterResult) { if (!adapterResult.success) { logPublishRuntime("error", "publish-task-failed", { article_id: payload.article_id, publish_record_id: task.publish_record_id, platform_account_id: task.platform_account_id, platform_id: task.platform_id, message: adapterResult.message ?? null, response_payload: adapterResult.responsePayload ?? null, }); } results.push(await postPublishCallback(payload.callback_base_url, payload, task, adapterResult)); continue; } const fallbackFailure = !platform?.connected ? { success: false, status: "failed" as const, externalArticleId: null, externalArticleUrl: null, externalManageUrl: normalizeText(platform?.login_url), message: "Local platform session is missing in the extension runtime.", responsePayload: { mode: "missing-session", installation_key: state.installation_key, }, } : { success: false, status: "failed" as const, externalArticleId: null, externalArticleUrl: null, externalManageUrl: normalizeText(platform.login_url), message: "No direct-publish adapter is available for this platform yet.", responsePayload: { mode: "missing-adapter", installation_key: state.installation_key, }, }; logPublishRuntime("warn", "publish-task-rejected", { article_id: payload.article_id, publish_record_id: task.publish_record_id, platform_account_id: task.platform_account_id, platform_id: task.platform_id, reason: fallbackFailure.message, response_payload: fallbackFailure.responsePayload, }); results.push(await postPublishCallback(payload.callback_base_url, payload, task, fallbackFailure)); } const successCount = results.filter((item) => item.success).length; const failedCount = results.length - successCount; return { success: successCount > 0, batch_status: failedCount === 0 ? "success" : successCount > 0 ? "partial_success" : "failed", results, }; }