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