feat: implement browser extension for media publishing and add backend support for media management
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user