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

595 lines
23 KiB
TypeScript

// 在 MAIN world 下载图片并委托 OneTalk 已验证的本地 File 上传入口。
import type { OneTalkOutboundContent } from "@trade-message-center/onetalk-contract";
import { isObjectRecord } from "../../lib/guards.ts";
import { traceOneTalkImageSend } from "../diagnostics/image-send-trace.ts";
import type { PageCommandResult } from "../page-bridge/model.ts";
import { readChannelAccountId } from "./page-context.ts";
import type { OneTalkPageWindow } from "./model.ts";
import {
ONETALK_IMAGE_SEND_TIMEOUT_MS,
type SendObservationCorrelator,
} from "./message-observer/send-observation.ts";
export type OneTalkImageUploader = {
owner: Record<string, unknown>;
sendFileToOss: (input: Record<string, unknown>) => unknown;
sendFile: (...args: unknown[]) => unknown;
};
type FinalImageMetadata = {
sizeBytes: number;
/** OneTalk's upload relation callback always provides this stable content key. */
md5: string;
fileId?: string;
};
type FinalImageMetadataResolution = {
metadata: FinalImageMetadata | null;
candidateCount: number;
};
type FinalFileMetadata = {
fileName: string;
extension: string;
sizeBytes: number;
md5?: string;
fileId?: string;
parentId?: string;
};
type FinalFileMetadataResolution = {
metadata: FinalFileMetadata | null;
candidateCount: number;
};
type ImageTargetContext = {
channelAccountId: string;
conversationId: string;
contact: Record<string, unknown>;
fromTo: { from: string; to: string; fromAliId: string; toAliId: string };
};
type PendingImageUpload = {
kind: "image";
context: ImageTargetContext;
sendObservation: SendObservationCorrelator;
deadlineMs: number;
isCurrent: () => boolean;
settle: (result: PageCommandResult) => void;
settled: boolean;
requestId?: string;
};
type PendingFileUpload = Omit<PendingImageUpload, "kind"> & { kind: "file" };
type PendingMediaUpload = PendingImageUpload | PendingFileUpload;
type UploaderInterceptor = { pendingByTmpKey: Map<string, PendingMediaUpload> };
type ImageSendTimer = ReturnType<typeof setTimeout>;
type ImageSendRuntime = {
now: () => number;
schedule: (callback: () => void, delay?: number) => ImageSendTimer;
cancel: (timer: ImageSendTimer) => void;
};
type LocalCommandDeadline = {
expired: () => boolean;
remainingMs: () => number;
timeout: Promise<void>;
cancel: () => void;
};
const uploaderInterceptors = new WeakMap<Record<string, unknown>, UploaderInterceptor>();
const unknownResult = (reason: "send_connection_lost" | "send_state_lost" | "send_timeout") =>
({ status: "delivery_unknown", reason }) satisfies PageCommandResult;
const createLocalCommandDeadline = (runtime: ImageSendRuntime): LocalCommandDeadline => {
const startedAtMs = runtime.now();
const deadlineMs = startedAtMs + ONETALK_IMAGE_SEND_TIMEOUT_MS;
let resolve!: () => void;
const timeout = new Promise<void>((next) => {
resolve = next;
});
const timer = runtime.schedule(resolve, ONETALK_IMAGE_SEND_TIMEOUT_MS);
return {
expired: () => runtime.now() >= deadlineMs,
remainingMs: () => Math.max(0, deadlineMs - runtime.now()),
timeout,
cancel: () => runtime.cancel(timer),
};
};
const scalarId = (value: unknown): string | null => {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return null;
};
const imageTargetContext = (
pageWindow: OneTalkPageWindow,
conversationId: string,
): ImageTargetContext | null => {
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId || !Array.isArray(pageWindow.__conversationListFullData__)) return null;
const matches = pageWindow.__conversationListFullData__.filter(
(value): value is Record<string, unknown> =>
isObjectRecord(value) && value.cid === conversationId,
);
const contact = matches.length === 1 ? matches[0] : null;
if (!contact || !isObjectRecord(contact.owner)) return null;
const from = scalarId(contact.owner.accountId);
const fromAliId = scalarId(contact.owner.aliId);
const to = scalarId(contact.accountId);
const toAliId = scalarId(
isObjectRecord(contact.contact) ? contact.contact.aliId : contact.aliId,
);
if (!from || !fromAliId || !to || !toAliId || from !== channelAccountId) return null;
return { channelAccountId, conversationId, contact, fromTo: { from, to, fromAliId, toAliId } };
};
const isCurrentImageTarget = (
pageWindow: OneTalkPageWindow,
expected: ImageTargetContext,
): boolean => {
const current = imageTargetContext(pageWindow, expected.conversationId);
return (
current !== null &&
current.channelAccountId === expected.channelAccountId &&
current.contact === expected.contact &&
current.fromTo.from === expected.fromTo.from &&
current.fromTo.to === expected.fromTo.to &&
current.fromTo.fromAliId === expected.fromTo.fromAliId &&
current.fromTo.toAliId === expected.fromTo.toAliId
);
};
const uploaderFromReactFileInput = (pageWindow: OneTalkPageWindow): OneTalkImageUploader | null => {
const inputs = Array.from(pageWindow.document?.querySelectorAll("input[type=file]") ?? []);
for (const input of inputs) {
for (
let node: (Element & { parentElement: Element | null }) | null = input;
node;
node = node.parentElement
) {
const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber"));
const fiber = fiberKey ? (node as unknown as Record<string, unknown>)[fiberKey] : null;
for (
let current: { stateNode?: unknown; return?: unknown } | null = isObjectRecord(
fiber,
)
? fiber
: null;
current;
current = isObjectRecord(current.return) ? current.return : null
) {
const state = current.stateNode;
if (
isObjectRecord(state) &&
typeof state.sendFileToOss === "function" &&
typeof state.sendFile === "function"
) {
return {
owner: state,
sendFileToOss: state.sendFileToOss as (
input: Record<string, unknown>,
) => unknown,
sendFile: state.sendFile as (...args: unknown[]) => unknown,
};
}
}
}
}
return null;
};
const metadataFrom = (value: unknown): FinalImageMetadata | null => {
if (!isObjectRecord(value)) return null;
const sizeBytes = value.nodeSize ?? value.size;
const md5 = value.md5;
const fileId = value.fileId;
if (
typeof sizeBytes !== "number" ||
!Number.isSafeInteger(sizeBytes) ||
sizeBytes < 0 ||
typeof md5 !== "string" ||
md5.length === 0 ||
(fileId !== undefined && typeof fileId !== "string")
)
return null;
return {
sizeBytes,
md5,
...(fileId === undefined ? {} : { fileId }),
};
};
const tmpKeyFromSendFileArgs = (
args: unknown[],
pending: Map<string, PendingMediaUpload>,
): string | null => {
for (const value of args) {
if (typeof value === "string" && pending.has(value)) return value;
if (isObjectRecord(value) && typeof value.tmpKey === "string" && pending.has(value.tmpKey))
return value.tmpKey;
}
return null;
};
const argumentShapes = (args: unknown[]): string[] => {
return args.map((value) => {
if (Array.isArray(value)) return "array";
if (!isObjectRecord(value)) return typeof value;
return `record:${Object.keys(value).sort().slice(0, 16).join(",")}`;
});
};
const finalMetadataFromSendFileArgs = (args: unknown[]): FinalImageMetadataResolution => {
const candidates: FinalImageMetadata[] = [];
for (const value of args) {
const direct = metadataFrom(value);
if (direct) candidates.push(direct);
if (!isObjectRecord(value)) continue;
for (const nested of [value.mediaInfo, value.relationInfo, value.fileInfo]) {
const metadata = metadataFrom(nested);
if (metadata) candidates.push(metadata);
}
}
return {
metadata: candidates.length === 1 ? candidates[0] : null,
candidateCount: candidates.length,
};
};
const fileMetadataFrom = (value: unknown): FinalFileMetadata | null => {
if (!isObjectRecord(value)) return null;
const fileName = value.nodeName ?? value.name;
const extension = value.extensionType ?? value.materialType;
const sizeBytes = value.nodeSize ?? value.size;
if (
typeof fileName !== "string" ||
fileName.length === 0 ||
typeof extension !== "string" ||
extension.length === 0 ||
typeof sizeBytes !== "number" ||
!Number.isSafeInteger(sizeBytes) ||
sizeBytes < 0
) {
return null;
}
const md5 = value.md5;
const fileId = scalarId(value.fileId ?? value.id);
const parentId = scalarId(value.parentId);
if (md5 !== undefined && (typeof md5 !== "string" || md5.length === 0)) return null;
return {
fileName,
extension: extension.toLowerCase(),
sizeBytes,
...(md5 === undefined ? {} : { md5 }),
...(fileId === null ? {} : { fileId }),
...(parentId === null ? {} : { parentId }),
};
};
const finalFileMetadataFromSendFileArgs = (args: unknown[]): FinalFileMetadataResolution => {
const candidates: FinalFileMetadata[] = [];
for (const value of args) {
const direct = fileMetadataFrom(value);
if (direct) candidates.push(direct);
if (!isObjectRecord(value)) continue;
for (const nested of [value.mediaInfo, value.relationInfo, value.fileInfo]) {
const metadata = fileMetadataFrom(nested);
if (metadata) candidates.push(metadata);
}
}
return {
metadata: candidates.length === 1 ? candidates[0] : null,
candidateCount: candidates.length,
};
};
const installUploaderInterceptor = (
uploader: OneTalkImageUploader,
runtime: ImageSendRuntime,
): UploaderInterceptor => {
const existing = uploaderInterceptors.get(uploader.owner);
if (existing) return existing;
const pendingByTmpKey = new Map<string, PendingMediaUpload>();
const originalSendFile = uploader.sendFile;
uploader.owner.sendFile = function (...args: unknown[]): unknown {
const tmpKey = tmpKeyFromSendFileArgs(args, pendingByTmpKey);
if (!tmpKey) {
const onlyPending =
pendingByTmpKey.size === 1 ? [...pendingByTmpKey.values()][0] : undefined;
traceOneTalkImageSend(onlyPending?.requestId, {
stage: "native_send_received",
result: onlyPending ? "rejected" : "ignored",
count: pendingByTmpKey.size,
});
return originalSendFile.apply(uploader.owner, args);
}
const pending = pendingByTmpKey.get(tmpKey);
if (pending) {
traceOneTalkImageSend(pending.requestId, {
stage: "native_send_received",
result: "accepted",
});
} else {
traceOneTalkImageSend(undefined, {
stage: "native_send_received",
result: "ignored",
count: pendingByTmpKey.size,
});
}
if (!pending || pending.settled) return Promise.resolve(unknownResult("send_timeout"));
const metadata =
pending.kind === "image"
? finalMetadataFromSendFileArgs(args)
: finalFileMetadataFromSendFileArgs(args);
if (!metadata.metadata || !pending.isCurrent()) {
traceOneTalkImageSend(pending.requestId, {
stage: "metadata_resolved",
result: "rejected",
candidateCount: metadata.candidateCount,
argumentShapes: argumentShapes(args),
});
pending.settle(unknownResult("send_connection_lost"));
return Promise.resolve(unknownResult("send_connection_lost"));
}
traceOneTalkImageSend(pending.requestId, {
stage: "metadata_resolved",
result: "accepted",
});
const remainingMs = Math.max(0, pending.deadlineMs - runtime.now());
if (remainingMs === 0) {
pending.settle(unknownResult("send_timeout"));
return Promise.resolve(unknownResult("send_timeout"));
}
const options = {
timeoutMs: remainingMs,
isCurrent: pending.isCurrent,
onSettled: pending.settle,
...(pending.requestId === undefined ? {} : { requestId: pending.requestId }),
};
if (pending.kind === "image") {
const imageMetadata = finalMetadataFromSendFileArgs(args).metadata;
if (!imageMetadata) return Promise.resolve(unknownResult("send_connection_lost"));
return pending.sendObservation.executeImage(
pending.context.conversationId,
imageMetadata,
() => originalSendFile.apply(uploader.owner, args),
options,
);
}
const fileMetadata = finalFileMetadataFromSendFileArgs(args).metadata;
if (!fileMetadata) return Promise.resolve(unknownResult("send_connection_lost"));
return pending.sendObservation.executeFile(
pending.context.conversationId,
fileMetadata,
() => originalSendFile.apply(uploader.owner, args),
options,
);
};
const interceptor = { pendingByTmpKey };
uploaderInterceptors.set(uploader.owner, interceptor);
return interceptor;
};
const mediaUploadInput = (
context: ImageTargetContext,
file: File,
tmpKey: string,
kind: "image" | "file",
): Record<string, unknown> => {
const input = {
file: Object.assign(file, { uid: tmpKey }),
fromTo: context.fromTo,
tmpKey,
contact: context.contact,
traceId: tmpKey,
};
// Ordinary file cards take the native file path; previewUrl is image-only UI state.
return kind === "image" ? { ...input, previewUrl: URL.createObjectURL(file) } : input;
};
/** 下载短时 source,并以 tmpKey 隔离 native media 回调后才登记最终 live 观察。 */
const sendOneTalkMedia = async (input: {
pageWindow: OneTalkPageWindow;
conversationId: string;
content: Extract<OneTalkOutboundContent, { kind: "image" | "file" }>;
sendObservation: SendObservationCorrelator;
requestId?: string;
findUploader?: (pageWindow: OneTalkPageWindow) => OneTalkImageUploader | null;
runtime?: Partial<ImageSendRuntime>;
}): Promise<PageCommandResult> => {
const runtime: ImageSendRuntime = {
now: input.runtime?.now ?? Date.now,
schedule:
input.runtime?.schedule ??
((callback: () => void, delay?: number) => globalThis.setTimeout(callback, delay)),
cancel:
input.runtime?.cancel ?? ((timer: ImageSendTimer) => globalThis.clearTimeout(timer)),
};
const context = imageTargetContext(input.pageWindow, input.conversationId);
const uploader = (input.findUploader ?? uploaderFromReactFileInput)(input.pageWindow);
if (!context) {
traceOneTalkImageSend(input.requestId, {
stage: "target_context_resolved",
result: "rejected",
});
return unknownResult("send_state_lost");
}
traceOneTalkImageSend(input.requestId, {
stage: "target_context_resolved",
result: "accepted",
});
if (!uploader) {
traceOneTalkImageSend(input.requestId, { stage: "uploader_resolved", result: "rejected" });
return unknownResult("send_state_lost");
}
traceOneTalkImageSend(input.requestId, { stage: "uploader_resolved", result: "accepted" });
const isCurrent = (): boolean => isCurrentImageTarget(input.pageWindow, context);
// This MAIN-arrival cap only prevents a late local side effect. Bright remains terminal authority.
traceOneTalkImageSend(input.requestId, { stage: "deadline_created", result: "started" });
const localDeadline = createLocalCommandDeadline(runtime);
traceOneTalkImageSend(input.requestId, { stage: "deadline_created", result: "accepted" });
let fetchPromise: Promise<Response>;
try {
traceOneTalkImageSend(input.requestId, { stage: "download_started", result: "started" });
fetchPromise = Promise.resolve(fetch(input.content.source.downloadUrl));
} catch {
localDeadline.cancel();
traceOneTalkImageSend(input.requestId, { stage: "download_completed", result: "failed" });
return unknownResult("send_connection_lost");
}
const fetched = await Promise.race([
fetchPromise.then(
(response) => ({ kind: "response" as const, response }),
() => ({ kind: "fetch_failed" as const }),
),
localDeadline.timeout.then(() => ({ kind: "deadline" as const })),
]);
if (fetched.kind === "deadline") {
traceOneTalkImageSend(input.requestId, { stage: "download_completed", result: "failed" });
return unknownResult("send_timeout");
}
if (fetched.kind === "fetch_failed" || localDeadline.expired() || !isCurrent()) {
localDeadline.cancel();
traceOneTalkImageSend(input.requestId, { stage: "download_completed", result: "failed" });
return unknownResult("send_connection_lost");
}
if (!fetched.response.ok) {
localDeadline.cancel();
traceOneTalkImageSend(input.requestId, { stage: "download_completed", result: "rejected" });
return unknownResult("send_connection_lost");
}
traceOneTalkImageSend(input.requestId, { stage: "download_completed", result: "accepted" });
const blobbed = await Promise.race([
fetched.response.blob().then(
(blob) => ({ kind: "blob" as const, blob }),
() => ({ kind: "blob_failed" as const }),
),
localDeadline.timeout.then(() => ({ kind: "deadline" as const })),
]);
if (blobbed.kind === "deadline") {
traceOneTalkImageSend(input.requestId, { stage: "blob_read", result: "failed" });
return unknownResult("send_timeout");
}
if (blobbed.kind === "blob_failed" || localDeadline.expired() || !isCurrent()) {
localDeadline.cancel();
traceOneTalkImageSend(input.requestId, { stage: "blob_read", result: "failed" });
return unknownResult("send_connection_lost");
}
traceOneTalkImageSend(input.requestId, { stage: "blob_read", result: "accepted" });
let response: Response;
let file: File;
try {
response = fetched.response;
file = new File([blobbed.blob], input.content.source.fileName, {
type: input.content.source.mimeType,
});
} catch {
localDeadline.cancel();
traceOneTalkImageSend(input.requestId, { stage: "file_created", result: "failed" });
return unknownResult("send_connection_lost");
}
traceOneTalkImageSend(input.requestId, { stage: "file_created", result: "accepted" });
if (localDeadline.expired()) {
traceOneTalkImageSend(input.requestId, { stage: "upload_registered", result: "failed" });
return unknownResult("send_timeout");
}
if (!isCurrent()) {
localDeadline.cancel();
traceOneTalkImageSend(input.requestId, { stage: "upload_registered", result: "rejected" });
return unknownResult("send_connection_lost");
}
const tmpKey = crypto.randomUUID();
const uploadInput = mediaUploadInput(context, file, tmpKey, input.content.kind);
const interceptor = installUploaderInterceptor(uploader, runtime);
const previewUrl = uploadInput.previewUrl;
let uploadSettled = false;
let resolve!: (result: PageCommandResult) => void;
const result = new Promise<PageCommandResult>((next) => {
resolve = next;
});
const pending: PendingMediaUpload = {
kind: input.content.kind,
context,
sendObservation: input.sendObservation,
deadlineMs: runtime.now() + localDeadline.remainingMs(),
isCurrent,
settled: false,
...(input.requestId === undefined ? {} : { requestId: input.requestId }),
settle: (value) => {
if (pending.settled) return;
pending.settled = true;
localDeadline.cancel();
resolve(value);
if (uploadSettled) interceptor.pendingByTmpKey.delete(tmpKey);
},
};
interceptor.pendingByTmpKey.set(tmpKey, pending);
traceOneTalkImageSend(input.requestId, { stage: "upload_registered", result: "accepted" });
void localDeadline.timeout.then(() => pending.settle(unknownResult("send_timeout")));
if (localDeadline.expired()) {
pending.settle(unknownResult("send_timeout"));
return result;
}
if (!isCurrent()) {
pending.settle(unknownResult("send_connection_lost"));
return result;
}
const cleanupUpload = (): void => {
uploadSettled = true;
if (pending.settled) interceptor.pendingByTmpKey.delete(tmpKey);
if (typeof previewUrl === "string") URL.revokeObjectURL(previewUrl);
};
let upload: unknown;
try {
traceOneTalkImageSend(input.requestId, {
stage: "native_upload_started",
result: "started",
});
upload = uploader.sendFileToOss.call(uploader.owner, uploadInput);
} catch {
pending.settle(unknownResult("send_connection_lost"));
cleanupUpload();
traceOneTalkImageSend(input.requestId, {
stage: "native_upload_completed",
result: "failed",
});
return result;
}
void Promise.resolve(upload)
.then(() =>
traceOneTalkImageSend(input.requestId, {
stage: "native_upload_completed",
result: "completed",
}),
)
.catch(() => {
traceOneTalkImageSend(input.requestId, {
stage: "native_upload_completed",
result: "failed",
});
pending.settle(unknownResult("send_connection_lost"));
})
.finally(cleanupUpload);
return result;
};
/** 保留图片调用面的同时复用唯一的 native media File 生命周期。 */
export const sendOneTalkImage = (
input: Omit<Parameters<typeof sendOneTalkMedia>[0], "content"> & {
content: Extract<OneTalkOutboundContent, { kind: "image" }>;
},
): Promise<PageCommandResult> => sendOneTalkMedia(input);
/** 以 OneTalk 原生 File/relation/fileCard 链路发送普通附件。 */
export const sendOneTalkFile = (
input: Omit<Parameters<typeof sendOneTalkMedia>[0], "content"> & {
content: Extract<OneTalkOutboundContent, { kind: "file" }>;
},
): Promise<PageCommandResult> => sendOneTalkMedia(input);