Files
geo/apps/browser-extension/src/runtime.ts
T

311 lines
11 KiB
TypeScript
Raw Normal View History

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;
}
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 nextExternalArticleId(taskId: number): string {
return `${Date.now()}${String(taskId).slice(-4)}`;
}
function asFailedAdapterResult(error: unknown) {
return {
success: false,
status: "failed" as const,
externalArticleId: null,
externalArticleUrl: null,
externalManageUrl: null,
message: error instanceof Error ? error.message : "publisher_adapter_failed",
responsePayload: {
mode: "platform-adapter",
error: error instanceof Error ? error.message : "publisher_adapter_failed",
},
};
}
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) {
await postCallback(payload.callback_base_url, "/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: normalizeText(adapterResult.externalArticleId),
external_article_url: normalizeText(adapterResult.externalArticleUrl),
external_manage_url: normalizeText(adapterResult.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",
},
});
results.push({
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: normalizeText(adapterResult.externalArticleId),
external_article_url: normalizeText(adapterResult.externalArticleUrl),
external_manage_url: normalizeText(adapterResult.externalManageUrl),
message: adapterResult.message ?? null,
});
continue;
}
if (!platform?.connected) {
await postCallback(payload.callback_base_url, "/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: "failed",
error_message: "Local platform session is missing in the extension runtime.",
client_version: browser.runtime.getManifest().version,
request_payload: {
title: payload.title,
publish_type: payload.publish_type,
},
});
results.push({
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
success: false,
status: "failed",
message: "Local platform session is missing.",
});
continue;
}
const externalArticleId = nextExternalArticleId(task.publish_record_id);
const externalArticleUrl = buildPublishedUrl(task.platform_id, externalArticleId);
await postCallback(payload.callback_base_url, "/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: "success",
external_article_id: externalArticleId,
external_article_url: externalArticleUrl,
external_manage_url: normalizeText(platform.login_url),
published_at: new Date().toISOString(),
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: {
mode: "mock-adapter",
installation_key: state.installation_key,
},
});
results.push({
publish_record_id: task.publish_record_id,
platform_account_id: task.platform_account_id,
platform_id: task.platform_id,
success: true,
status: "success",
external_article_id: externalArticleId,
external_article_url: externalArticleUrl,
external_manage_url: normalizeText(platform.login_url),
message: "Published through the mock adapter.",
});
}
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,
};
}