2026-04-03 00:39:15 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 17:48:30 +08:00
|
|
|
function logPublishRuntime(level: "info" | "warn" | "error", event: string, payload: Record<string, unknown>): 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:39:15 +08:00
|
|
|
async function postCallback<T>(
|
|
|
|
|
baseUrl: string,
|
|
|
|
|
path: string,
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
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<T> | { message?: string };
|
|
|
|
|
if (!response.ok || !("data" in body)) {
|
|
|
|
|
throw new Error(body.message || "publisher_callback_failed");
|
|
|
|
|
}
|
|
|
|
|
return body.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asFailedAdapterResult(error: unknown) {
|
2026-04-03 17:48:30 +08:00
|
|
|
const message = error instanceof Error ? error.message : "publisher_adapter_failed";
|
2026-04-03 00:39:15 +08:00
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
status: "failed" as const,
|
|
|
|
|
externalArticleId: null,
|
|
|
|
|
externalArticleUrl: null,
|
|
|
|
|
externalManageUrl: null,
|
2026-04-03 17:48:30 +08:00
|
|
|
message,
|
2026-04-03 00:39:15 +08:00
|
|
|
responsePayload: {
|
|
|
|
|
mode: "platform-adapter",
|
2026-04-03 17:48:30 +08:00
|
|
|
error: message,
|
|
|
|
|
stack: error instanceof Error ? error.stack ?? null : null,
|
2026-04-03 00:39:15 +08:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 17:48:30 +08:00
|
|
|
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<string, unknown>;
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 00:39:15 +08:00
|
|
|
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<PublisherPluginPingResponse> {
|
|
|
|
|
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<PublisherRegisterInstallationResult> {
|
|
|
|
|
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<PublisherBindResponse> {
|
|
|
|
|
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<PublisherPublishResponse> {
|
|
|
|
|
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) {
|
2026-04-03 17:48:30 +08:00
|
|
|
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));
|
2026-04-03 00:39:15 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 17:48:30 +08:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-04-03 00:39:15 +08:00
|
|
|
|
2026-04-03 17:48:30 +08:00
|
|
|
logPublishRuntime("warn", "publish-task-rejected", {
|
2026-04-03 00:39:15 +08:00
|
|
|
article_id: payload.article_id,
|
|
|
|
|
publish_record_id: task.publish_record_id,
|
|
|
|
|
platform_account_id: task.platform_account_id,
|
|
|
|
|
platform_id: task.platform_id,
|
2026-04-03 17:48:30 +08:00
|
|
|
reason: fallbackFailure.message,
|
|
|
|
|
response_payload: fallbackFailure.responsePayload,
|
2026-04-03 00:39:15 +08:00
|
|
|
});
|
2026-04-03 17:48:30 +08:00
|
|
|
results.push(await postPublishCallback(payload.callback_base_url, payload, task, fallbackFailure));
|
2026-04-03 00:39:15 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|