Files
trade-message-center/apps/chrome-extension/src/onetalk/page-bridge/main.ts
T

368 lines
14 KiB
TypeScript

// 连接 OneTalk MAIN 页面与可信桥接契约
import { readChannelAccountId, readConversationSelection } from "../main-page/page-context.ts";
import { subscribeToDocumentClicks } from "../main-page/dom/selection-events.ts";
import type { OneTalkPageWindow } from "../main-page/model.ts";
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
import type { HistoryPageProgress } from "../main-page/current-conversation-history/model.ts";
import type { OneTalkObservedMessageSink } from "../main-page/message-observer/model.ts";
import {
oneTalkImageSendErrorKind,
oneTalkImageSendErrorMessage,
traceOneTalkImageSend,
} from "../diagnostics/image-send-trace.ts";
import { traceOneTalkObservedMessages } from "../diagnostics/message-trace.ts";
import type {
OneTalkBuyerFact,
OneTalkContactProfile,
OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import {
createOneTalkPageCommandResultMessage,
createOneTalkPageHelloMessage,
createOneTalkPageObservedMessage,
decodeOneTalkPageMessage,
isOneTalkPageBindingStatusMessage,
isOneTalkPageCommandMessage,
isOneTalkPageConnectionStatusMessage,
isOneTalkIsolatedToMainMessage,
type OneTalkPageCommandMessage,
type OneTalkPageCommandResultMessage,
type OneTalkPageBridgeWindow,
type OneTalkPageMessage,
type OneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
createOneTalkPageBuyerFactsObservedMessage,
createOneTalkPageRenderedCardObservedMessage,
ONE_TALK_PAGE_BRIDGE_RECONNECT_SIGNAL,
type PageCommandResult,
} from "./model.ts";
export type OneTalkPageCommandHandler = (
message: OneTalkPageCommandMessage,
) => PageCommandResult | Promise<PageCommandResult>;
export type OneTalkPageConnectionStatusHandler = (disconnected: boolean) => void;
export type OneTalkPageBindingStatusHandler = (unbound: boolean) => void;
export type OneTalkPageHelloRetryOptions = {
retryDelayMs?: number;
maxAttempts?: number;
};
type OneTalkMainPageBridgeWindow = OneTalkPageBridgeWindow & OneTalkPageWindow;
const PAGE_HELLO_RETRY_DELAY_MS = 250;
const PAGE_HELLO_MAX_ATTEMPTS = 40;
const pageOrigin = (pageWindow: OneTalkPageBridgeWindow): string | null => {
const origin = pageWindow.location.origin;
if (typeof origin === "string" && origin.length > 0) return origin;
try {
return new URL(pageWindow.location.href).origin;
} catch {
return null;
}
};
const postPageMessage = (
pageWindow: OneTalkPageBridgeWindow,
origin: string,
message: OneTalkPageMessage,
): boolean => {
try {
if (!decodeOneTalkPageMessage(message)) return false;
pageWindow.postMessage(message, origin);
return true;
} catch {
// Bridge failures must not escape the MAIN page behavior.
return false;
}
};
const postPageHello = (pageWindow: OneTalkMainPageBridgeWindow, origin: string): boolean => {
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId) return false;
const selection = readConversationSelection(pageWindow);
const message = createOneTalkPageHelloMessage(
channelAccountId,
selection.kind === "single" ? selection.conversationId : undefined,
selection.kind === "none" || selection.kind === "multiple" ? selection.kind : undefined,
);
return postPageMessage(pageWindow, origin, message);
};
const installCommandConsumer = (
pageWindow: OneTalkPageBridgeWindow,
origin: string,
onCommand: OneTalkPageCommandHandler,
isActive: () => boolean,
): void => {
pageWindow.addEventListener("message", (event) => {
try {
if (!isActive() || event.source !== pageWindow || event.origin !== origin) return;
const message = decodeOneTalkPageMessage(event.data);
if (
!message ||
!isOneTalkIsolatedToMainMessage(message) ||
!isOneTalkPageCommandMessage(message)
)
return;
const content = message.command.content;
const isMediaSend =
message.command.action === "onetalk.send" &&
isPlainRecord(content) &&
(content.kind === "image" || content.kind === "file");
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_command_received",
result: "accepted",
});
void Promise.resolve(onCommand(message))
.then((result) => {
if (!isActive()) return;
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_command_completed",
result: "completed",
});
const resultMessage: OneTalkPageCommandResultMessage =
createOneTalkPageCommandResultMessage(message.requestId, result);
const forwarded = postPageMessage(pageWindow, origin, resultMessage);
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_result_forwarded",
result: forwarded ? "forwarded" : "failed",
});
})
.catch((error: unknown) => {
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_command_completed",
result: "failed",
errorKind: oneTalkImageSendErrorKind(error),
errorMessage: oneTalkImageSendErrorMessage(error),
});
// Command failures are owned by the injected page consumer.
});
} catch {
// Malformed bridge input must not affect OneTalk page execution.
}
});
};
const installConnectionStatusConsumer = (
pageWindow: OneTalkPageBridgeWindow,
origin: string,
onConnectionStatus: OneTalkPageConnectionStatusHandler,
isActive: () => boolean,
): void => {
pageWindow.addEventListener("message", (event) => {
try {
if (!isActive() || event.source !== pageWindow || event.origin !== origin) return;
const message = decodeOneTalkPageMessage(event.data);
if (
!message ||
!isOneTalkIsolatedToMainMessage(message) ||
!isOneTalkPageConnectionStatusMessage(message)
)
return;
onConnectionStatus(message.disconnected);
} catch {
// The display-only connection status must not affect MAIN page behavior.
}
});
};
const installBindingStatusConsumer = (
pageWindow: OneTalkPageBridgeWindow,
origin: string,
onBindingStatus: OneTalkPageBindingStatusHandler,
isActive: () => boolean,
): void => {
pageWindow.addEventListener("message", (event) => {
try {
if (!isActive() || event.source !== pageWindow || event.origin !== origin) return;
const message = decodeOneTalkPageMessage(event.data);
if (
!message ||
!isOneTalkIsolatedToMainMessage(message) ||
!isOneTalkPageBindingStatusMessage(message)
)
return;
onBindingStatus(message.unbound);
} catch {
// The display-only binding status must not affect MAIN page behavior.
}
});
};
/** 创建把观察批次发布到当前页面 origin 的 MAIN sink。 */
export const createOneTalkPageObservedSink = (
pageWindow: OneTalkPageBridgeWindow,
): OneTalkObservedMessageSink => {
return (batch) => {
const origin = pageOrigin(pageWindow);
if (!origin) return;
const message: OneTalkPageObservedMessage = createOneTalkPageObservedMessage(
batch.messages,
undefined,
batch.diagnostics,
);
const posted = postPageMessage(pageWindow, origin, message);
traceOneTalkObservedMessages(batch.messages, {
stage: "main_bridge",
result: posted ? "forwarded" : "rejected",
...(posted ? {} : { reason: "bridge_post_failed" }),
});
};
};
/** 创建只发送经 MAIN 白名单投影的渲染卡片补全 sink。 */
export const createOneTalkPageRenderedCardObservedSink = (
pageWindow: OneTalkMainPageBridgeWindow,
): ((observations: OneTalkRenderedCardObservation[]) => void) => {
const origin = pageOrigin(pageWindow);
return (observations) => {
if (!origin || observations.length === 0) return;
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId) return;
postPageMessage(
pageWindow,
origin,
createOneTalkPageRenderedCardObservedMessage(channelAccountId, observations),
);
};
};
/** 创建把历史分页进度发布到 Service Worker 的 MAIN sink。 */
export const createOneTalkPageHistoryProgressSink = (
pageWindow: OneTalkPageBridgeWindow,
historyRequestId?: string,
): ((progress: HistoryPageProgress) => void) => {
return (progress) => {
const origin = pageOrigin(pageWindow);
if (!origin) return;
const message: OneTalkPageObservedMessage = createOneTalkPageObservedMessage(
[],
{
conversationId: progress.conversationId,
latestMessageAtMs: progress.latestMessageAtMs,
page: progress.page,
mode: progress.mode,
anchorMessageId: progress.anchorMessageId,
nextTimeStamp: progress.nextTimeStamp,
historyComplete: progress.historyComplete,
...(progress.anchorFound === undefined
? {}
: { anchorFound: progress.anchorFound }),
},
undefined,
historyRequestId,
);
postPageMessage(pageWindow, origin, message);
};
};
/** 创建把清洗后的联系人资料发布到当前页面 origin 的 MAIN sink。 */
export const createOneTalkPageProfileObservedSink = (
pageWindow: OneTalkPageBridgeWindow,
): ((profiles: OneTalkContactProfile[], channelAccountId: string) => void) => {
return (profiles, channelAccountId) => {
if (profiles.length === 0 || channelAccountId.length === 0) return;
const origin = pageOrigin(pageWindow);
if (!origin) return;
postPageMessage(
pageWindow,
origin,
createOneTalkPageProfileObservedMessage(profiles, channelAccountId),
);
};
};
/** 创建把清洗后的买家事实发布到当前页面 origin 的 MAIN sink。 */
export const createOneTalkPageBuyerFactsObservedSink = (
pageWindow: OneTalkPageBridgeWindow,
): ((facts: OneTalkBuyerFact[], channelAccountId: string) => void) => {
return (facts, channelAccountId) => {
if (facts.length === 0 || channelAccountId.length === 0) return;
const origin = pageOrigin(pageWindow);
if (!origin) return;
postPageMessage(
pageWindow,
origin,
createOneTalkPageBuyerFactsObservedMessage(facts, channelAccountId),
);
};
};
/** 安装 MAIN 页面注册与可注入命令消费者。 */
export const installOneTalkMainPageBridge = (
pageWindow: OneTalkMainPageBridgeWindow,
onCommand?: OneTalkPageCommandHandler,
retryOptions: OneTalkPageHelloRetryOptions = {},
onConnectionStatus?: OneTalkPageConnectionStatusHandler,
onBindingStatus?: OneTalkPageBindingStatusHandler,
): void => {
try {
const origin = pageOrigin(pageWindow);
if (!origin) return;
let stopped = false;
if (onCommand) installCommandConsumer(pageWindow, origin, onCommand, () => !stopped);
if (onConnectionStatus) {
installConnectionStatusConsumer(pageWindow, origin, onConnectionStatus, () => !stopped);
}
if (onBindingStatus) {
installBindingStatusConsumer(pageWindow, origin, onBindingStatus, () => !stopped);
}
let attempts = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const retryDelay =
Number.isFinite(retryOptions.retryDelayMs) && (retryOptions.retryDelayMs ?? 0) >= 0
? retryOptions.retryDelayMs!
: PAGE_HELLO_RETRY_DELAY_MS;
const maxAttempts =
Number.isInteger(retryOptions.maxAttempts) && (retryOptions.maxAttempts ?? 0) > 0
? retryOptions.maxAttempts!
: PAGE_HELLO_MAX_ATTEMPTS;
const stop = (): void => {
stopped = true;
if (retryTimer !== null) {
globalThis.clearTimeout(retryTimer);
retryTimer = null;
}
};
const attemptHello = (): void => {
if (stopped || attempts >= maxAttempts) return;
attempts += 1;
if (postPageHello(pageWindow, origin) || attempts >= maxAttempts) return;
retryTimer = globalThis.setTimeout(() => {
retryTimer = null;
attemptHello();
}, retryDelay);
};
pageWindow.addEventListener("pagehide", stop);
pageWindow.addEventListener("message", (event) => {
if (
!stopped &&
event.source === pageWindow &&
event.origin === origin &&
event.data === ONE_TALK_PAGE_BRIDGE_RECONNECT_SIGNAL
) {
postPageHello(pageWindow, origin);
}
});
const refreshIdentity = (): void => {
if (!stopped) postPageHello(pageWindow, origin);
};
pageWindow.addEventListener("popstate", refreshIdentity);
pageWindow.addEventListener("hashchange", refreshIdentity);
subscribeToDocumentClicks(pageWindow.document, refreshIdentity);
attemptHello();
} catch {
// Bridge setup failures must not affect OneTalk page execution.
}
};