mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
368 lines
14 KiB
TypeScript
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.
|
|
}
|
|
};
|