feat: support OneTalk file sending

This commit is contained in:
YBF
2026-09-10 21:12:49 +08:00
parent 9430aafc0f
commit 8654576ee6
21 changed files with 860 additions and 76 deletions
@@ -0,0 +1,4 @@
{"file":".trellis/spec/project/architecture.md","reason":"核查唯一 owner、跨层契约、异步状态与无重复实现。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"扩展 typecheck、build、focused tests 与构建产物质量门槛。"}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"服务端测试、60 秒超时和类型/构建质量门槛。"}
{"file":".trellis/tasks/09-10-mind-onetalk-file-send/design.md","reason":"核对 fileCard native path、能力 URL 边界和 fail-closed confirmation 设计。"}
@@ -0,0 +1,61 @@
# 技术设计:Mind 附件发送到 OneTalk
## 设计结论
附件不是一条新传输链路。它复用图片的 Mind OSS source、Bright 授权与单发协调、Service Worker 精确页面路由、JSON page bridge、以及 live WebSocket 事实确认;唯一的业务分歧是 MAIN world 把浏览器 `File` 交给 OneTalk 的普通文件上传路径,最终生成 `fileCard`,并以 `OneTalkFileContent` 关联确认。
```text
Mind(格式/大小策略、OSS 上传、短时读取授权)
-> send.request({ content: { kind: "file", source } })
-> Bright(既有授权、唯一插件、attempt/single-flight、三态 result
-> Service Worker / ISOLATED(既有受限 JSON 精确路由)
-> MAIN(下载 File -> OneTalk prepare/upload/relation/fileCard -> sendUIMessages
-> live WebSocket(完整 sent file
-> 既有 send.confirmation / send.result
```
## 所有权与安全边界
- Mind 是唯一的文件格式、MIME、大小策略、OSS 上传、对象就绪和发送授权 owner。它为每次发送生成短时、HTTPS 的读取 capability;这不是文件内容或可展示 URL。
- `@trade-message-center/onetalk-contract` 唯一拥有跨 Mind/Bright/插件的 `text | image | file` 判别合同和 exact-shape decoder。图片与附件共用同一种 source 结构:`downloadUrl``fileName``mimeType`
- Bright 的 `OneTalkPendingSendCoordinator` 继续唯一拥有授权、唯一插件选择、同会话 single-flight、attempt timer、终态去重与 result 投影。附件不创建专用 registry、timer 或 confirmation channel;它使用与图片相同的 45 秒总 attempt 预算。
- Service Worker 与 ISOLATED 只路由 JSON command,不能 fetch source、保存 URL、申请 Mind OSS host permission 或跨 runtime message 传递 `Blob`/`File`
- MAIN 是唯一下载 source、读取 OneTalk 完整会话模型、持有短生命周期 `File` 并调用 OneTalk 内部上传实现的地方。上传 policy、Cookie、完整 URL、SDK raw payload、`Blob``File` 均不能离开 MAIN。
- 成功后的 durable fact 只能是现有 `OneTalkFileContent``urlScope: "onetalk_session"` 的 OneTalk URL。Mind source URL、文件二进制及 OneTalk raw payload 均不得写入数据库、日志、诊断、frame result 或最终展示。
## 合同与数据流
1.`OneTalkOutboundImageSource` 提升为图片/附件共享的 source 类型(或等价的唯一 canonical type),令 `OneTalkOutboundContent` 成为:
```ts
| { kind: "text"; text: string }
| { kind: "image"; source: OneTalkOutboundMediaSource }
| { kind: "file"; source: OneTalkOutboundMediaSource }
```
decoder 继续只做协议边界验证:exact shape、非空/长度和无凭据的 HTTPS URL 结构。它不维护后缀/MIME/大小业务白名单,也不读取下载响应来复验。
2. `send.request`、`send.command`、`send.confirmation`、`send.result` 的 frame 形状、scope、request identity 和三态状态不变。服务端仅把 file content 沿既有 request/command 路径转发,并将 `file` 与 `image` 同样纳入 45 秒 attempt timeout 和既有 `channelAccountId + conversationId` gate。
3. 现有图片发送 MAIN 实现应按共同变化原因收敛为一个 native media-file sender,而不是复制一个文件 uploader。它共用:source 下载、短生命周期 File、目标会话完整 context、tmpKey interceptor、命令有效性检查、资源清理与 result 结算。图片/文件各自只提供 OneTalk upload input 和 post-upload expected metadata 的解析。
4. 普通文件 upload input 遵循已观测的 OneTalk File 流程:`File`、`tmpKey`、`traceId`、完整 `contact` 与 `fromTo`。它不得伪造图片 preview 字段或将目标从当前 selected conversation 推断出来。实际 native path 仍由 OneTalk 的 `sendFileToOss` / `sendFile` 驱动 prepare、去重或 OSS POST、build relation 和 `fileCard` SDK send。
5. `SendObservationCorrelator` 保持一份 pending 集合,扩展为 text/image/file 判别联合。文件 pending 只在 native relation 已返回最终 metadata、且原生 `sendFile` 即将调用 `sendUIMessages` 时登记;不得从 Mind 文件选择、source 下载或 OneTalk 上传开始使用短确认计时。
6. 关联优先级固定:可靠的 native candidate message ID 优先;无 ID 时只接受同会话、`direction: "sent"`、`content.kind: "file"`、文件名、扩展名、大小、可选 MD5/`fileId`/`parentId` 与短发送窗的唯一复合匹配。一个 live 文件命中多条 pending 时,所有涉及 attempt 统一结算为 `delivery_unknown/send_ambiguous`。
7. MAIN decoder 维持现有严格文件 guard`contentType=101`、`custom.type=10010`、安全 Base64/UTF-8/JSON、`cardType=12` 和完整 params schema 缺一不可。任何非文件业务卡、历史响应或不完整 raw payload 都不能成为 file send confirmation。
## 兼容与失败语义
- text/image 行为、协议版本和既有 result reason 不变;只增加 file 判别分支。
- 文件 source 下载失败、文件输入/会话 context 不可用、OneTalk native upload/relation/send 失败、页面桥断开或总 attempt 超时,都按既有的 `rejected_before_send` 或 `delivery_unknown` 收敛;不自动重试。
- 只有 live observer 提供完整目标 sent file 才会产生 `confirmed_sent`。HTTP 200、页面事件、SDK resolve、clientId/opId 和历史读取都不产生成功。
- 同一文件资源重复/并发时,不以 pending 创建顺序、消息数组顺序或当前页面会话猜测归属。产品若未来要求一一关联,必须取得可回显的客户端关联 ID,或在 Mind 显式串行化同会话同指纹请求。
## 发布、验证与回滚
- 发布前验证 Mind OSS 短时读取 URL 仅向 `https://onetalk.alibaba.com` 返回 CORS;不得使用 `*`、宽泛 extension host permission 或字节 JSON transport 代替。
- Chromium/CDP 验证必须含一个不命中去重的非敏感测试附件,以覆盖真实 OSS 二进制上传;输出只记录阶段、状态和脱敏元数据,不输出 capability URL、ID、token、cookie、MD5 原值或 payload。
- 回滚移除 file command entry、native file sender 和 file pending 分支;不迁移数据库、不清理已由 OneTalk 发送的既有文件事实,也不改变 text/image。
@@ -0,0 +1,4 @@
{"file":".trellis/spec/project/architecture.md","reason":"共享出站合同、跨 await 生命周期与类型唯一所有权的项目级约束。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/send-sop.md","reason":"OneTalk SDK-only 发送、cid 路由与 live 事实确认边界。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"MAIN/ISOLATED/Service Worker 的 JSON bridge、页面身份和命令路由规则。"}
{"file":".trellis/tasks/09-10-mind-onetalk-image-send/design.md","reason":"附件复用的已批准图片发送边界、source capability 与 pending-send 设计。"}
@@ -0,0 +1,57 @@
# 执行计划:Mind 附件发送到 OneTalk
## 1. 先扩展唯一的出站合同
-`apps/onetalk-contract/src/sending.ts` 将图片 source 收敛为图片/附件的唯一共享 source 类型,并将 `OneTalkOutboundContent` 扩展为 `text | image | file`
- 保持 exact-shape、HTTPS capability、长度限制和现有三态 frame decoder;禁止引入文件格式/MIME/大小策略或 response MIME/长度复验。
- 更新 `apps/onetalk-contract/test/contract.test.ts`:合法 file command、非法/冗余字段、错误 source、text/image 回归和不重复业务策略。
检查:`node --experimental-strip-types --test apps/onetalk-contract/test/contract.test.ts`,随后 `pnpm --filter @trade-message-center/onetalk-contract typecheck`
## 2. 让 Bright 透明地协调 file attempt
-`apps/server/src/websocket/pending-send-coordinator.ts` 把图片专用 45 秒预算明确扩展至 image/file,并保留 text 的现有 timeout、唯一 pending map 和跨媒体 `channelAccountId + conversationId` gate。
- 审计 send request flow、plugin confirmation ingest 和 result projectionsource 只能在内存 request/command 中短暂存在,canonical file observation 才能持久化/发布。
-`apps/server/test/onetalk-websocket.test.ts` 覆盖 file 的授权、唯一插件、timeout、text/image/file single-flight、断线、duplicate、canonical confirmed file 与 source URL 无泄漏。
检查:`node --experimental-strip-types --test --test-timeout=60000 apps/server/test/onetalk-websocket.test.ts`
## 3. 复用页面桥和 MAIN native File 生命周期
- 将 Service Worker `send-command-flow` 和 page bridge 中的图片专用命令识别提升为 media(image/file)识别;保持请求 ID、唯一页面路由和 canonical result adapter,不创建第二条 port 或 runtime message 协议。
- 在 MAIN 将 `image-send.ts` 的共享下载、临时 `File`、目标上下文、tmpKey interceptor、deadline、cleanup 逻辑提升为 media file sender;图片调用路径必须保持原有行为。
-`kind: "file"` 构造已观测的原生文件输入,调用 OneTalk 普通文件路径并取得 relation metadata;不要传入图片 preview 语义或猜测 selected conversation。
- 增加/调整 MAIN unit tests,使用无敏感的 fixture URL;不在断言、diagnostic 或 trace 中保留 capability URL 或字节。
检查:focused page bridge/send flow tests、`apps/chrome-extension/test/onetalk-image-send.test.js` 的回归,以及新增 native file sender test。
## 4. 在唯一 observer 集合中加入文件关联
-`apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts` 将 image pending 提升为 image/file media pending union;保持所有 pending 位于同一集合和同一 settle 逻辑。
- 文件 pending 仅在 native build relation 成功后、最终 `sendFile`/SDK send 之前登记。解析 file metadata 时要求文件名、扩展名和安全整数大小;可选 MD5、`fileId``parentId` 仅增强复合指纹,不能单独确认。
- 保留 candidate ID 优先和多匹配 fail-closed;验证 `custom.type=10010``cardType !== 12` 的 raw 继续是 unsupported,不会完成 pending。
- 更新 `onetalk-send-observation``onetalk-media-content-decoder``onetalk-websocket-tap` 和新的 file sender tests,覆盖目标会话不等于 selected、source/native 失败、candidate ID、复合匹配、错会话/received、并发歧义、timeout 与 image/text 回归。
检查:
```bash
node --experimental-strip-types --test \
apps/chrome-extension/test/onetalk-media-content-decoder.test.js \
apps/chrome-extension/test/onetalk-websocket-tap.test.js \
apps/chrome-extension/test/onetalk-send-observation.test.js \
apps/chrome-extension/test/onetalk-image-send.test.js \
apps/chrome-extension/test/onetalk-file-send.test.js
```
## 5. 集成验证、审查与回滚门槛
- 按顺序运行 focused tests、`pnpm typecheck``pnpm build``pnpm format:check``git diff --check`;用 GitNexus 在每次修改 symbol 前执行 upstream impact,并在提交前执行 `detect_changes`
- 在 Chromium/CDP 测试环境中向非当前 selected 的目标会话发送一份新生成、无敏感内容的普通附件。复验 Mind OSS CORS 精确 origin、prepare、真实 OSS POST(未命中去重)、build relation、fileCard、live `kind=file` observation 和最终 `confirmed_sent`
- 再发送同一文件并发请求,确认不猜测归属而以 `send_ambiguous` 结算;验证页面/SDK/HTTP 成功不会提早确认。记录只含状态、类型、计数、耗时和脱敏 metadata 的结果。
- 回归图片和文本发送;若 OneTalk 运行时 native file 入口、relation metadata 或定向会话行为与报告不一致,停止实现并更新任务研究,不用 SDK 快捷 URL API 或平行确认链路绕过。
## 风险文件与实施门槛
- 高耦合边界:`apps/onetalk-contract/src/sending.ts``apps/server/src/websocket/pending-send-coordinator.ts``apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts``apps/chrome-extension/src/onetalk/main-page/image-send.ts``apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts`、Service Worker send Flow 与 page bridge。
- 开始任何 symbol 编辑前必须运行 GitNexus impact。若报告 HIGH 或 CRITICAL,先向用户报告 direct callers、受影响流程和风险再继续。
- 不引入 Bright 媒体存储/代理、extension 字节 transport、自动重试、文件格式 fallback、URL identity matching 或第二个 pending/timeout owner。
@@ -0,0 +1,53 @@
# Mind 附件发送到 OneTalk
## 目标
让 Mind 用户能够向指定 OneTalk 会话发送普通附件。Mind 负责其已有的文件校验、对象上传、会话授权和短时读取能力;Bright 只沿用现有的授权、唯一插件路由、attempt 和三态结果闭环;OneTalk MAIN world 下载为浏览器 `File` 并调用已验证的原生文件发送路径。只有 live WebSocket 观察到目标会话完整的 `direction: sent` 文件消息时,Mind 才收到 `confirmed_sent`
用户价值是在不牺牲 OneTalk 原生文件关系、会话身份或发送事实可靠性的前提下,从 Mind 会话工作台发送附件。
## 已确认事实
- 输入材料《OneTalk 文件发送与 WebSocket 观测可行性报告》(2026-09-10)已验证当前选中会话的 ZIP 文件发送、云盘 prepare、去重后的 file relation、目标历史文件消息及 live WebSocket 归一化。未验证真实 OSS 二进制上传、非当前选中会话定向文件发送和相同文件并发的一一对应。
- OneTalk 普通文件路径为:`File` 校验/MD5 → `prepareSendFileWithGroup` → 可选 OSS 上传 → `buildFileRelationWithGroup``sendFile`/SDK `sendUIMessages`。上传策略、会话完整身份、Cookie、原始 SDK payload 和完整媒体 URL 都必须留在 MAIN world。
- 附件整体链路以图片发送为基线复用:Mind OSS source、Bright 路由/三态闭环、Service Worker/JSON page bridge 以及 live observer 的最终确认机制不变;跨边界仅将出站判别由 `kind: "image"` 增加为 `kind: "file"`。唯一业务分歧是 MAIN 内调用 OneTalk 的普通文件上传/建关系/fileCard 路径,而不是图片上传路径。
- 最终 OneTalk 文件消息由 `contentType=101``custom.type=10010`、Base64 JSON、`cardType=12` 与完整 `params` schema 共同决定;`custom.type=10010` 本身不足以证明是文件。
- 现有 MAIN decoder 已将文件归一化为 `OneTalkFileContent`,并包含 `fileId``parentId`、文件名、扩展名、大小、可选 MD5、预览/缩略图/下载 URL、下载状态和 `urlScope: "onetalk_session"`。它已拒绝 `cardType` 非 12 的业务卡片。
- 现有出站合同 `OneTalkOutboundContent` 仅支持 `text | image`;现有 `SendObservationCorrelator` 共用一个 pending 集合,但其判别分支仅支持 text/image。附件必须扩展这两处,而不是建立平行发送或确认系统。
- SDK Promise resolve、页面成功事件、prepare/上传/build relation HTTP 200 和本地 clientId 都只代表阶段进展;最终成功唯一来自目标会话的完整 live sent 文件事实。
- 文件 pending 只能在上传和 relation 完成、取得最终文件元数据后,且在 `sendUIMessages` 前登记。候选消息 ID 可用时优先匹配;否则只可用会话、sent 方向、文件名、扩展名、大小、可选 MD5/`fileId`/`parentId` 和短时间窗的复合指纹。一个 live 消息匹配多个 pending 时必须以 `delivery_unknown/send_ambiguous` 失败关闭。
- 与图片任务一致,Mind source URL 只是单次下载能力:不得进入 Bright 数据库、日志、诊断、`send.result`、发布消息或最终 Mind 展示。成功事实只能使用 OneTalk 归一化文件内容和 OneTalk 会话范围 URL。
- 服务端 `OneTalkPendingSendCoordinator` 已是授权、唯一插件选择、attempt 生命周期、终态去重和 result 投影的唯一 owner;同一 `channelAccountId + conversationId` 的 single-flight gate 对文本、图片和附件都必须一致。
## 范围
1. 将 Mind → Bright → 插件的出站内容合同从 `text | image` 扩展为包含附件的严格判别联合;source 仅含 Mind OSS 的短时 HTTPS 读取能力、原始文件名和声明 MIME,且不传输文件字节、`Blob``File`
2. 附件与图片完全一致:Mind 是文件格式、MIME、大小和上传策略的唯一 owner。Mind 完成其校验、OSS 上传及发送者/工作区/会话/用途授权后,才创建附件发送请求;Bright、Service Worker、MAIN 和 OneTalk 适配层不维护格式白名单或大小上限,也不因响应 MIME/长度重复拒绝。交互状态沿用文本/图片的“发送中 → confirmed_sent 或终态失败”语义。
3. Bright 复用既有 scope/权限、唯一插件、per-conversation single-flight、attempt 和三态 `send.result`;它不下载、缓存、代理、检验二进制或持久化附件 source。
4. Chrome Service Worker 与 ISOLATED bridge 仅路由受限 JSON command,并复用图片发送的页面命令与 result 回传链路。MAIN world 在受限 CORS 读取条件下下载附件,构造短生命周期浏览器 `File`,取得目标 `conversationId` 的完整会话上下文,并调用 OneTalk 原生普通文件的 prepare/可选 OSS/build relation/fileCard 路径。
5. 将最终 post-upload 文件 metadata 登记进现有发送观察器,保持候选消息 ID 优先、复合指纹回退和歧义 fail-closed;仅 live observer 的完整 `kind: "file"` sent 事实可确认发送。
6. 补充共享合同、服务端、Service Worker、MAIN、观察器及 Mind/harness 的测试,并以 Chromium/CDP 复验定向发送和 live confirmation。
## 明确不在范围内
- 改写既有文本或图片的发送、接收、历史同步或展示语义。
- Bright 或扩展运行时消息传输附件二进制、Base64、`Blob``File`、OneTalk 会话身份、上传 policy、Cookie 或完整媒体 URL。
- 以网络 HTTP 成功、本地乐观消息、SDK resolve 或历史轮询冒充 `confirmed_sent`,以及 timeout 后自动重试。
- 依据文件后缀或 `File.type` 声称验证了二进制真实格式;OneTalk observer 没有文件本体。
- 用 URL、文件名、大小、MD5、`fileId``parentId` 中的任意单个字段作为发送关联唯一键。
- 在本任务中承诺或实现跨会话 URL 持久化、接收设备下载/展示确认,或解决相同文件并发发送的稳定一一关联。
## 验收标准
- 只有 Mind 通过自身附件策略、完成 OSS 对象就绪和授权后,才会创建 `kind: "file"` 发送请求;Bright、Service Worker、MAIN 和 OneTalk 适配层不复制 Mind 的文件格式、MIME 或大小策略,也不根据下载响应重复校验。
- 新增文件内容合同保持 exact-shape、HTTPS source、字段长度等边界安全验证,且既有 text/image frame 的兼容性和三态 result 语义不变。
- Bright 对任一附件请求仍只向唯一、已授权的插件页面路由;同一账号/会话已有文本、图片或附件在途时,复用稳定的 `send_in_progress` 拒绝,而非另建附件 gate。
- MAIN 不切换当前 selected conversation,也不由 DOM 或 URL 推测目标;它使用 command 的 `conversationId` 和完整会话模型执行文件上传与 SDK 发送。
- 最终文件 metadata 获得后、SDK `sendUIMessages` 前才开始文件确认计时;live observer 仅在完整文件 schema(含 `cardType=12`)通过时参与确认。
- 有可靠 candidate message ID 时按 ID 确认;无 ID 时仅在复合指纹唯一匹配时确认。重复资源并发导致同一 live 消息匹配多个 pending 时,所有相关请求均为 `delivery_unknown/send_ambiguous`
- `confirmed_sent` 的 message 为 `content.kind="file"` 的 OneTalk canonical factMind/Bright 不持久化或展示 Mind source URL,并且不以 Mind OSS 原附件回退 OneTalk URL 失效。
- 自动化覆盖成功、目标会话不等于当前选中会话、缺失/不合法目标上下文、source 下载失败、OneTalk 上传/relation/SDK 失败、timeout、candidate ID、复合指纹、非法 `10010` 非文件卡片、并发歧义和 text/image 回归;Chromium/CDP 最小联调验证一次真实附件发送与 live 观察。
## 规划状态
这是跨 Mind、共享合同、Bright 服务端、Chrome Service Worker 与 OneTalk MAIN world 的复杂任务。现有范围已明确:文件格式和上传规则归 Mind,Bridge/插件只传递受限 source 并使用 OneTalk 原生文件链路。后续需补充 `design.md``implement.md`、调研/上下文清单,并经用户审阅规划摘要后才可激活实施。
@@ -0,0 +1,26 @@
{
"id": "mind-onetalk-file-send",
"name": "mind-onetalk-file-send",
"title": "Mind 附件发送到 OneTalk",
"description": "在已实现图片发送的基础上,扩展 Mind 通过 OneTalk 发送普通附件并由 live WebSocket 确认投递。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-10",
"completedAt": null,
"branch": "09-10-mind-onetalk-image-send",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -18,7 +18,7 @@ import { syncCurrentConversationHistory, type AllConversationHistoryResult } fro
import { collectDirectConversations, syncCollectedConversation } from "./all-conversations.ts";
import type { ConversationAnchors, ConversationModes, ConversationSyncMode } from "./model.ts";
import type { HistoryBootstrapProgressTooltip } from "./bootstrap-progress-tooltip.ts";
import { sendOneTalkImage } from "../image-send.ts";
import { sendOneTalkFile, sendOneTalkImage } from "../image-send.ts";
const isSyncMode = (value: unknown): value is ConversationSyncMode => {
return value === "full" || value === "incremental";
@@ -186,6 +186,15 @@ export const handleOneTalkHistoryCommand = async (
requestId: message.requestId,
});
}
if (content.kind === "file") {
return sendOneTalkFile({
pageWindow,
conversationId,
content,
sendObservation,
requestId: message.requestId,
});
}
try {
const sdkRoot = pageWindow.IcbuIM;
if (
@@ -33,6 +33,20 @@ type FinalImageMetadataResolution = {
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;
@@ -41,6 +55,7 @@ type ImageTargetContext = {
};
type PendingImageUpload = {
kind: "image";
context: ImageTargetContext;
sendObservation: SendObservationCorrelator;
deadlineMs: number;
@@ -50,7 +65,10 @@ type PendingImageUpload = {
requestId?: string;
};
type UploaderInterceptor = { pendingByTmpKey: Map<string, PendingImageUpload> };
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;
@@ -201,7 +219,7 @@ const metadataFrom = (value: unknown): FinalImageMetadata | null => {
const tmpKeyFromSendFileArgs = (
args: unknown[],
pending: Map<string, PendingImageUpload>,
pending: Map<string, PendingMediaUpload>,
): string | null => {
for (const value of args) {
if (typeof value === "string" && pending.has(value)) return value;
@@ -236,13 +254,60 @@ const finalMetadataFromSendFileArgs = (args: unknown[]): FinalImageMetadataResol
};
};
const fileMetadataFrom = (value: unknown): FinalFileMetadata | null => {
if (!isRecord(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 (!isRecord(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, PendingImageUpload>();
const pendingByTmpKey = new Map<string, PendingMediaUpload>();
const originalSendFile = uploader.sendFile;
uploader.owner.sendFile = function (...args: unknown[]): unknown {
const tmpKey = tmpKeyFromSendFileArgs(args, pendingByTmpKey);
@@ -270,7 +335,10 @@ const installUploaderInterceptor = (
});
}
if (!pending || pending.settled) return Promise.resolve(unknownResult("send_timeout"));
const metadata = finalMetadataFromSendFileArgs(args);
const metadata =
pending.kind === "image"
? finalMetadataFromSendFileArgs(args)
: finalFileMetadataFromSendFileArgs(args);
if (!metadata.metadata || !pending.isCurrent()) {
traceOneTalkImageSend(pending.requestId, {
stage: "metadata_resolved",
@@ -290,16 +358,29 @@ const installUploaderInterceptor = (
pending.settle(unknownResult("send_timeout"));
return Promise.resolve(unknownResult("send_timeout"));
}
return pending.sendObservation.executeImage(
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,
metadata.metadata,
fileMetadata,
() => originalSendFile.apply(uploader.owner, args),
{
timeoutMs: remainingMs,
isCurrent: pending.isCurrent,
onSettled: pending.settle,
requestId: pending.requestId,
},
options,
);
};
const interceptor = { pendingByTmpKey };
@@ -307,24 +388,28 @@ const installUploaderInterceptor = (
return interceptor;
};
const imageUploadInput = (
const mediaUploadInput = (
context: ImageTargetContext,
file: File,
tmpKey: string,
): Record<string, unknown> => ({
file: Object.assign(file, { uid: tmpKey }),
fromTo: context.fromTo,
tmpKey,
contact: context.contact,
previewUrl: URL.createObjectURL(file),
traceId: tmpKey,
});
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 隔离并发 uploader 回调后才登记最终图片观察。 */
export const sendOneTalkImage = async (input: {
/** 下载短时 source,并以 tmpKey 隔离 native media 回调后才登记最终 live 观察。 */
const sendOneTalkMedia = async (input: {
pageWindow: OneTalkPageWindow;
conversationId: string;
content: Extract<OneTalkOutboundContent, { kind: "image" }>;
content: Extract<OneTalkOutboundContent, { kind: "image" | "file" }>;
sendObservation: SendObservationCorrelator;
requestId?: string;
findUploader?: (pageWindow: OneTalkPageWindow) => OneTalkImageUploader | null;
@@ -433,7 +518,7 @@ export const sendOneTalkImage = async (input: {
return unknownResult("send_connection_lost");
}
const tmpKey = crypto.randomUUID();
const uploadInput = imageUploadInput(context, file, tmpKey);
const uploadInput = mediaUploadInput(context, file, tmpKey, input.content.kind);
const interceptor = installUploaderInterceptor(uploader, runtime);
const previewUrl = uploadInput.previewUrl;
let uploadSettled = false;
@@ -441,7 +526,8 @@ export const sendOneTalkImage = async (input: {
const result = new Promise<PageCommandResult>((next) => {
resolve = next;
});
const pending: PendingImageUpload = {
const pending: PendingMediaUpload = {
kind: input.content.kind,
context,
sendObservation: input.sendObservation,
deadlineMs: runtime.now() + localDeadline.remainingMs(),
@@ -505,3 +591,17 @@ export const sendOneTalkImage = async (input: {
.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);
@@ -40,7 +40,28 @@ type PendingImageSend = {
requestId?: string;
};
type PendingObservation = PendingSend | PendingImageSend;
type PendingFileSend = {
kind: "file";
conversationId: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
expected: {
fileName: string;
extension: string;
sizeBytes: number;
md5?: string;
fileId?: string;
parentId?: string;
};
resolve: (result: PageCommandResult) => void;
timer?: ReturnType<typeof setTimeout>;
isCurrent: () => boolean;
onSettled?: (result: PageCommandResult) => void;
requestId?: string;
};
type PendingMediaSend = PendingImageSend | PendingFileSend;
type PendingObservation = PendingSend | PendingMediaSend;
export type SendObservationCorrelator = {
execute: (
@@ -65,6 +86,17 @@ export type SendObservationCorrelator = {
requestId?: string;
},
) => Promise<PageCommandResult>;
executeFile: (
conversationId: string,
expected: PendingFileSend["expected"],
send: () => unknown,
options?: {
timeoutMs?: number;
isCurrent?: () => boolean;
onSettled?: (result: PageCommandResult) => void;
requestId?: string;
},
) => Promise<PageCommandResult>;
observe: (batch: ObservedOneTalkMessage[]) => void;
};
@@ -92,6 +124,22 @@ const matchesImage = (
return expected.fileId === undefined || message.content.fileId === expected.fileId;
};
const matchesFile = (
expected: PendingFileSend["expected"],
message: ObservedOneTalkMessage,
): boolean => {
if (message.content.kind !== "file") return false;
if (
message.content.fileName !== expected.fileName ||
message.content.extension !== expected.extension ||
message.content.sizeBytes !== expected.sizeBytes
)
return false;
if (expected.md5 !== undefined && message.content.md5 !== expected.md5) return false;
if (expected.fileId !== undefined && message.content.fileId !== expected.fileId) return false;
return expected.parentId === undefined || message.content.parentId === expected.parentId;
};
const candidateIds = (value: unknown): string[] => {
if (!isRecord(value)) return [];
return ["messageId", "msgId", "id", "externalMessageId", "clientMessageId"].flatMap((key) => {
@@ -113,7 +161,7 @@ const matches = (
): boolean => {
if (message.direction !== "sent" || message.conversationId !== pending.conversationId)
return false;
if (pending.kind === "image" && message.content.kind !== "image") return false;
if (pending.kind !== "text" && message.content.kind !== pending.kind) return false;
if (pending.candidateMessageIds.size > 0) {
return (
typeof message.messageId === "string" &&
@@ -127,9 +175,10 @@ const matches = (
sentAtMs > nowMs + MATCH_WINDOW_MS
)
return false;
return pending.kind === "text"
? textOf(message) === pending.content
: matchesImage(pending.expected, message);
if (pending.kind === "text") return textOf(message) === pending.content;
return pending.kind === "image"
? matchesImage(pending.expected, message)
: matchesFile(pending.expected, message);
};
export const createSendObservationCorrelator = (
@@ -152,11 +201,13 @@ export const createSendObservationCorrelator = (
const finish = (record: PendingObservation, result: PageCommandResult): void => {
if (!pending.delete(record)) return;
cancel(record.timer);
if (record.kind === "image") {
traceOneTalkImageSend(record.requestId, {
stage: "observation_settled",
result: result.status === "confirmed_sent" ? "completed" : "failed",
});
if (record.kind !== "text") {
if (record.kind === "image") {
traceOneTalkImageSend(record.requestId, {
stage: "observation_settled",
result: result.status === "confirmed_sent" ? "completed" : "failed",
});
}
record.onSettled?.(result);
}
record.resolve(result);
@@ -255,6 +306,41 @@ export const createSendObservationCorrelator = (
}
});
},
executeFile: async (conversationId, expected, send, fileOptions = {}) => {
const sentAfterMs = now();
return new Promise<PageCommandResult>((resolve) => {
const record: PendingFileSend = {
kind: "file",
conversationId,
sentAfterMs,
candidateMessageIds: new Set<string>(),
expected,
resolve,
isCurrent: fileOptions.isCurrent ?? (() => true),
...(fileOptions.onSettled === undefined
? {}
: { onSettled: fileOptions.onSettled }),
...(fileOptions.requestId === undefined
? {}
: { requestId: fileOptions.requestId }),
};
record.timer = schedule(
() => finish(record, unknownResult("send_timeout")),
fileOptions.timeoutMs ?? ONETALK_IMAGE_SEND_TIMEOUT_MS,
);
pending.add(record);
try {
Promise.resolve(send())
.then((candidate) => {
for (const id of candidateIds(candidate))
record.candidateMessageIds.add(id);
})
.catch(() => finish(record, unknownResult("send_connection_lost")));
} catch {
finish(record, unknownResult("send_connection_lost"));
}
});
},
observe: (batch) => {
const nowMs = now();
for (const record of pending) {
@@ -269,7 +355,7 @@ export const createSendObservationCorrelator = (
const complete = completeSentMessage(message);
if (!complete) continue;
for (const record of [...pending]) {
if (record.kind === "image" && !record.isCurrent()) {
if (record.kind !== "text" && !record.isCurrent()) {
finish(record, unknownResult("send_connection_lost"));
}
}
@@ -55,7 +55,7 @@ export const installOneTalkIsolatedPageBridge = (
if (typeof origin !== "string" || origin.length === 0) return;
let connected = true;
const pendingImageCommands = new Set<string>();
const pendingMediaCommands = new Set<string>();
const onWindowMessage = (event: MessageEvent<unknown>): void => {
try {
if (!connected || !isCurrentPageMessage(pageWindow, event)) return;
@@ -64,7 +64,7 @@ export const installOneTalkIsolatedPageBridge = (
port.postMessage(message);
if (
message.type === "onetalk.page.command-result" &&
pendingImageCommands.delete(message.requestId)
pendingMediaCommands.delete(message.requestId)
) {
traceOneTalkImageSend(message.requestId, {
stage: "isolated_result_forwarded",
@@ -89,15 +89,15 @@ export const installOneTalkIsolatedPageBridge = (
const message = decodeOneTalkPageMessage(value);
if (!message || !isOneTalkIsolatedToMainMessage(message)) return;
const content = message.command.content;
const isImageSend =
const isMediaSend =
message.command.action === "onetalk.send" &&
isRecord(content) &&
content.kind === "image";
if (isImageSend) {
pendingImageCommands.add(message.requestId);
(content.kind === "image" || content.kind === "file");
if (isMediaSend) {
pendingMediaCommands.add(message.requestId);
}
pageWindow.postMessage(message, origin);
if (isImageSend)
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "isolated_command_forwarded",
result: "forwarded",
@@ -110,7 +110,7 @@ export const installOneTalkIsolatedPageBridge = (
const onDisconnect = (): void => {
connected = false;
pendingImageCommands.clear();
pendingMediaCommands.clear();
};
pageWindow.addEventListener("message", onWindowMessage);
@@ -93,11 +93,11 @@ const installCommandConsumer = (
const message = decodeOneTalkPageMessage(event.data);
if (!message || !isOneTalkIsolatedToMainMessage(message)) return;
const content = message.command.content;
const isImageSend =
const isMediaSend =
message.command.action === "onetalk.send" &&
isRecord(content) &&
content.kind === "image";
if (isImageSend)
(content.kind === "image" || content.kind === "file");
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_command_received",
result: "accepted",
@@ -106,7 +106,7 @@ const installCommandConsumer = (
void Promise.resolve(onCommand(message))
.then((result) => {
if (!isActive()) return;
if (isImageSend)
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_command_completed",
result: "completed",
@@ -114,14 +114,14 @@ const installCommandConsumer = (
const resultMessage: OneTalkPageCommandResultMessage =
createOneTalkPageCommandResultMessage(message.requestId, result);
const forwarded = postPageMessage(pageWindow, origin, resultMessage);
if (isImageSend)
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_result_forwarded",
result: forwarded ? "forwarded" : "failed",
});
})
.catch((error: unknown) => {
if (isImageSend)
if (isMediaSend)
traceOneTalkImageSend(message.requestId, {
stage: "main_command_completed",
result: "failed",
@@ -36,13 +36,14 @@ export const createOneTalkSendCommandFlow = (options: {
const handle: OneTalkSendCommandFlow["handle"] = (frame) => {
if (typeof frame.sendRequestId !== "string" || frame.sendRequestId.length === 0) return;
const sendRequestId = frame.sendRequestId;
const isImage = frame.payload.content.kind === "image";
if (isImage)
const isMedia =
frame.payload.content.kind === "image" || frame.payload.content.kind === "file";
if (isMedia)
traceOneTalkImageSend(frame.requestId, {
stage: "worker_command_received",
result: "accepted",
});
if (isImage)
if (isMedia)
traceOneTalkImageSend(frame.requestId, {
stage: "worker_page_route_started",
result: "started",
@@ -60,7 +61,7 @@ export const createOneTalkSendCommandFlow = (options: {
})
.then((result) => {
const outcome = narrowOneTalkPageSendOutcome(result);
if (isImage)
if (isMedia)
traceOneTalkImageSend(frame.requestId, {
stage: "worker_page_result_received",
result: "completed",
@@ -74,7 +75,7 @@ export const createOneTalkSendCommandFlow = (options: {
// 诊断回调不能改变发送确认的生命周期。
}
const sent = confirm({ sendRequestId, ...outcome.payload });
if (isImage)
if (isMedia)
traceOneTalkImageSend(frame.requestId, {
stage: "worker_confirmation_written",
result: sent ? "forwarded" : "failed",
@@ -86,7 +87,7 @@ export const createOneTalkSendCommandFlow = (options: {
status: "delivery_unknown",
reason: "send_state_lost",
});
if (isImage)
if (isMedia)
traceOneTalkImageSend(frame.requestId, {
stage: "worker_confirmation_written",
result: sent ? "forwarded" : "failed",
@@ -0,0 +1,178 @@
// 验证普通附件只在 MAIN 下载,并在 relation metadata 就绪后由 live file 事实确认。
import assert from "node:assert/strict";
import test from "node:test";
import { sendOneTalkFile } from "../src/onetalk/main-page/image-send.ts";
import { createSendObservationCorrelator } from "../src/onetalk/main-page/message-observer/send-observation.ts";
const createClock = () => {
let now = 0;
let sequence = 0;
const timers = new Map();
const schedule = (callback, delay) => {
const id = ++sequence;
timers.set(id, { callback, due: now + delay });
return id;
};
const cancel = (id) => timers.delete(id);
return { now: () => now, schedule, cancel };
};
const createPageWindow = () => ({
location: { href: "https://onetalk.alibaba.com/message/weblitePWA.htm" },
addEventListener: () => {},
currentUserAccountId: "seller-account",
__conversationListFullData__: [
{
cid: "conversation-1",
accountId: "buyer-account-1",
aliId: "buyer-ali-1",
contact: { aliId: "buyer-ali-1" },
owner: { accountId: "seller-account", aliId: "seller-ali" },
},
{
cid: "conversation-2",
accountId: "buyer-account-2",
aliId: "buyer-ali-2",
contact: { aliId: "buyer-ali-2" },
owner: { accountId: "seller-account", aliId: "seller-ali" },
},
],
});
const fileSource = () => ({
kind: "file",
source: {
downloadUrl: "https://mind.example.test/bridge/attachment?temporary=secret",
fileName: "quotation.zip",
mimeType: "application/x-mind-approved-file",
},
});
const relationMetadata = () => ({
mediaInfo: {
nodeName: "quotation.zip",
materialType: "zip",
nodeSize: 456,
md5: "hash-1",
fileId: "file-1",
parentId: "parent-1",
},
});
const observedFile = (overrides = {}) => ({
messageType: "new",
upstreamType: 1,
messageId: "message-1",
conversationId: "conversation-2",
senderId: "seller@icbu",
direction: "sent",
sentAtMs: 1,
content: {
version: 1,
kind: "file",
fileId: "file-1",
parentId: "parent-1",
fileName: "quotation.zip",
extension: "zip",
sizeBytes: 456,
md5: "hash-1",
previewUrl: null,
thumbnailUrl: null,
downloadUrl: null,
downloadState: "not_provided",
urlScope: "onetalk_session",
},
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
...overrides,
});
const createUploader = () => {
const uploads = [];
const owner = {
sendFile: () => undefined,
sendFileToOss: (input) => {
uploads.push(input);
return undefined;
},
};
return {
owner,
uploads,
uploader: { owner, sendFile: owner.sendFile, sendFileToOss: owner.sendFileToOss },
};
};
test("sends a file through the exact target's native fileCard path without preview or source leakage", async () => {
const originalFetch = globalThis.fetch;
const pageWindow = createPageWindow();
const clock = createClock();
const { owner, uploads, uploader } = createUploader();
globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 });
try {
const correlator = createSendObservationCorrelator({ ...clock, textTimeoutMs: 10 });
const result = sendOneTalkFile({
pageWindow,
conversationId: "conversation-2",
content: fileSource(),
sendObservation: correlator,
findUploader: () => uploader,
runtime: clock,
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(uploads.length, 1);
assert.equal(uploads[0].file.name, "quotation.zip");
assert.equal(uploads[0].file.type, "application/x-mind-approved-file");
assert.equal(Object.hasOwn(uploads[0], "previewUrl"), false);
assert.equal(uploads[0].contact.cid, "conversation-2");
assert.deepEqual(uploads[0].fromTo, {
from: "seller-account",
to: "buyer-account-2",
fromAliId: "seller-ali",
toAliId: "buyer-ali-2",
});
assert.equal(JSON.stringify(uploads[0]).includes("mind.example.test"), false);
void owner.sendFile(relationMetadata(), uploads[0].tmpKey, { cid: "conversation-2" });
correlator.observe([observedFile()]);
assert.equal((await result).status, "confirmed_sent");
} finally {
globalThis.fetch = originalFetch;
}
});
test("fails closed before native SDK send when file relation metadata is incomplete", async () => {
const originalFetch = globalThis.fetch;
const pageWindow = createPageWindow();
const { owner, uploads, uploader } = createUploader();
let originalSendCalls = 0;
owner.sendFile = () => {
originalSendCalls += 1;
return undefined;
};
uploader.sendFile = owner.sendFile;
globalThis.fetch = async () => new Response(new Uint8Array([1]), { status: 200 });
try {
const result = sendOneTalkFile({
pageWindow,
conversationId: "conversation-2",
content: fileSource(),
sendObservation: createSendObservationCorrelator(),
findUploader: () => uploader,
});
await new Promise((resolve) => setImmediate(resolve));
void owner.sendFile({ mediaInfo: { nodeSize: 456 } }, uploads[0].tmpKey);
assert.deepEqual(await result, {
status: "delivery_unknown",
reason: "send_connection_lost",
});
assert.equal(originalSendCalls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -222,3 +222,83 @@ test("confirms images from relation metadata and fails closed on same-image ambi
assert.deepEqual(await first, { status: "delivery_unknown", reason: "send_ambiguous" });
assert.deepEqual(await second, { status: "delivery_unknown", reason: "send_ambiguous" });
});
test("confirms files only by a unique relation fingerprint or candidate message ID", async () => {
const expected = {
fileName: "quotation.zip",
extension: "zip",
sizeBytes: 456,
md5: "hash-1",
fileId: "file-1",
parentId: "parent-1",
};
const file = (overrides = {}) =>
completeSent({
sentAtMs: Date.now() + 1,
content: {
version: 1,
kind: "file",
fileId: "file-1",
parentId: "parent-1",
fileName: "quotation.zip",
extension: "zip",
sizeBytes: 456,
md5: "hash-1",
previewUrl: null,
thumbnailUrl: null,
downloadUrl: null,
downloadState: "not_provided",
urlScope: "onetalk_session",
},
...overrides,
});
const candidate = createSendObservationCorrelator(100);
const candidateResult = candidate.executeFile(
"conversation-1",
expected,
() => ({ id: "message-1" }),
{ timeoutMs: 100 },
);
await new Promise((resolve) => setImmediate(resolve));
candidate.observe([
file({
messageId: "message-1",
content: { ...file().content, fileName: "different-name.zip", sizeBytes: 999 },
}),
]);
assert.equal((await candidateResult).status, "confirmed_sent");
const fingerprint = createSendObservationCorrelator(100);
const fingerprintResult = fingerprint.executeFile("conversation-1", expected, () => undefined, {
timeoutMs: 100,
});
await new Promise((resolve) => setImmediate(resolve));
fingerprint.observe([file()]);
assert.equal((await fingerprintResult).status, "confirmed_sent");
const wrongConversation = createSendObservationCorrelator(10);
const wrongConversationResult = wrongConversation.executeFile(
"conversation-1",
expected,
() => undefined,
{ timeoutMs: 10 },
);
await new Promise((resolve) => setImmediate(resolve));
wrongConversation.observe([{ ...file(), conversationId: "conversation-2" }]);
assert.deepEqual(await wrongConversationResult, {
status: "delivery_unknown",
reason: "send_timeout",
});
const ambiguous = createSendObservationCorrelator(100);
const first = ambiguous.executeFile("conversation-1", expected, () => undefined, {
timeoutMs: 100,
});
const second = ambiguous.executeFile("conversation-1", expected, () => undefined, {
timeoutMs: 100,
});
await new Promise((resolve) => setImmediate(resolve));
ambiguous.observe([file()]);
assert.deepEqual(await first, { status: "delivery_unknown", reason: "send_ambiguous" });
assert.deepEqual(await second, { status: "delivery_unknown", reason: "send_ambiguous" });
});
@@ -204,6 +204,33 @@ test("does not use selected conversation as a send gate", async () => {
assert.deepEqual(invalidContent.calls, []);
});
test("fails closed for file commands when MAIN cannot resolve the native uploader", async () => {
const { pageWindow, calls } = createSendPage(["conversation-2"]);
const result = await handleOneTalkHistoryCommand(
pageWindow,
{
requestId: "file-send",
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: {
kind: "file",
source: {
downloadUrl: "https://mind.example.test/bridge/attachment-1",
fileName: "quotation.zip",
mimeType: "application/x-mind-approved-file",
},
},
},
},
createSendObservationCorrelator(1),
);
assert.deepEqual(result, { status: "delivery_unknown", reason: "send_state_lost" });
assert.deepEqual(calls, []);
});
test("requires sendUIMessages even when an unverified send API is present", async () => {
const { pageWindow, rejectedCalls } = createSendPage();
delete pageWindow.IcbuIM.IMBaaSSDK.default.getMessageService;
+49 -11
View File
@@ -75,8 +75,12 @@ export const createOneTalkHarnessHtml = (
<button id="send" class="secondary" type="button" disabled>发送</button>
<label>已上传图片 OSS URL<input id="image-source-url" autocomplete="off" inputmode="url" placeholder="https://bucket.oss.example.com/path/image.jpg?..." spellcheck="false"></label>
<button id="send-image" class="secondary" type="button" disabled>发送图片 URL</button>
<label>已上传附件 HTTPS URL<input id="file-source-url" autocomplete="off" inputmode="url" placeholder="https://bucket.oss.example.com/path/attachment?..." spellcheck="false"></label>
<label>附件文件名<input id="file-name" autocomplete="off" placeholder="quotation.zip"></label>
<label>声明 MIME<input id="file-mime-type" autocomplete="off" placeholder="application/zip"></label>
<button id="send-file" class="secondary" type="button" disabled>发送附件 URL</button>
</div>
<p class="hint">图片由操作者自行上传到 OSS 后填写 HTTPS 地址;此页面不上传、读取或保存图片,也不持有 OSS 凭据。仅支持从 URL 路径识别的常见图片后缀WebSocket 帧展示会脱敏地址。</p>
<p class="hint">媒体由操作者按 Mind 的格式、大小、授权和上传策略准备 HTTPS source;此页面不上传、读取或保存媒体,也不持有 OSS 凭据。图片仅支持从 URL 路径识别的常见图片后缀;附件文件名和 MIME 必须单独提供。WebSocket 帧展示会脱敏 source 地址。</p>
<p class="hint">HTTP 列表和消息分页都只保存并回传 Bright 返回的 opaque cursor。同步锚点不会作为页面游标。Cookie 快捷入口仅写当前页面同源的开发 Cookie,生产 HttpOnly Cookie 仍由 Mind 登录设置。</p>
</section>
@@ -149,6 +153,10 @@ export const createOneTalkHarnessHtml = (
sendContent: element('send-content'),
imageSourceUrl: element('image-source-url'),
sendImage: element('send-image'),
fileSourceUrl: element('file-source-url'),
fileName: element('file-name'),
fileMimeType: element('file-mime-type'),
sendFile: element('send-file'),
historyStatus: element('history-status'),
connectionStatus: element('connection-status'),
cookieStatus: element('cookie-status'),
@@ -208,7 +216,7 @@ export const createOneTalkHarnessHtml = (
try {
const frame = JSON.parse(serialized);
const content = frame?.payload?.content;
if (content?.kind !== 'image' || !content.source || typeof content.source.downloadUrl !== 'string') return serialized;
if ((content?.kind !== 'image' && content?.kind !== 'file') || !content.source || typeof content.source.downloadUrl !== 'string') return serialized;
return JSON.stringify({
...frame,
payload: {
@@ -223,23 +231,36 @@ export const createOneTalkHarnessHtml = (
return serialized;
}
};
const imageContentFromUrl = (value) => {
const mediaSourceUrl = (value) => {
const sourceUrl = value.trim();
if (!sourceUrl || sourceUrl.length > 8192) return null;
try {
const url = new URL(sourceUrl);
if (url.protocol !== 'https:' || !url.hostname || url.port || url.username || url.password || url.hash) return null;
const encodedFileName = url.pathname.slice(url.pathname.lastIndexOf('/') + 1);
const fileName = decodeURIComponent(encodedFileName);
if (!isNonBlankString(fileName) || fileName.length > 512) return null;
const suffix = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
if (!Object.hasOwn(imageMimeTypes, suffix)) return null;
const mimeType = imageMimeTypes[suffix];
return { kind: 'image', source: { downloadUrl: url.toString(), fileName, mimeType } };
return url.protocol === 'https:' && url.hostname && !url.port && !url.username && !url.password && !url.hash ? url : null;
} catch (_) {
return null;
}
};
const mediaContentFromSource = (kind, url, fileName, mimeType) => {
if (!url || !isNonBlankString(fileName) || fileName.length > 512 || !isNonBlankString(mimeType) || mimeType.length > 256) return null;
return { kind, source: { downloadUrl: url.toString(), fileName, mimeType } };
};
const imageContentFromUrl = (value) => {
const url = mediaSourceUrl(value);
if (!url) return null;
try {
const encodedFileName = url.pathname.slice(url.pathname.lastIndexOf('/') + 1);
const fileName = decodeURIComponent(encodedFileName);
const suffix = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
if (!Object.hasOwn(imageMimeTypes, suffix)) return null;
return mediaContentFromSource('image', url, fileName, imageMimeTypes[suffix]);
} catch (_) {
return null;
}
};
const fileContentFromInputs = (sourceUrl, fileName, mimeType) => {
return mediaContentFromSource('file', mediaSourceUrl(sourceUrl), fileName.trim(), mimeType.trim());
};
const isNonNegativeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
const isNormalizedContent = (value) => {
if (!isRecord(value)) return false;
@@ -463,6 +484,7 @@ export const createOneTalkHarnessHtml = (
&& currentScope();
fields.send.disabled = !enabled;
fields.sendImage.disabled = !enabled;
fields.sendFile.disabled = !enabled;
};
const requestSend = (content) => {
@@ -895,6 +917,22 @@ export const createOneTalkHarnessHtml = (
}
if (requestSend(content)) fields.imageSourceUrl.value = '';
});
fields.sendFile.addEventListener('click', () => {
const content = fileContentFromInputs(
fields.fileSourceUrl.value,
fields.fileName.value,
fields.fileMimeType.value,
);
if (!content) {
setStatus(fields.syncStatus, '请输入 HTTPS 附件地址、文件名和声明 MIME', 'error');
return;
}
if (requestSend(content)) {
fields.fileSourceUrl.value = '';
fields.fileName.value = '';
fields.fileMimeType.value = '';
}
});
setPluginStatus('offline');
renderMessages();
})();
+1
View File
@@ -171,6 +171,7 @@ export type {
OneTalkSendRejectedPayload,
OneTalkSendConfirmationPayload,
OneTalkSendResultPayload,
OneTalkOutboundMediaSource,
OneTalkOutboundImageSource,
OneTalkOutboundContent,
OneTalkJsonValue,
+7 -4
View File
@@ -30,14 +30,17 @@ export const ONETALK_SEND_RESULT_REASONS = [
"send_in_progress",
] as const;
export type OneTalkSendResultReason = (typeof ONETALK_SEND_RESULT_REASONS)[number];
export type OneTalkOutboundImageSource = {
export type OneTalkOutboundMediaSource = {
downloadUrl: string;
fileName: string;
mimeType: string;
};
/** @deprecated Use OneTalkOutboundMediaSource for image and file commands. */
export type OneTalkOutboundImageSource = OneTalkOutboundMediaSource;
export type OneTalkOutboundContent =
| { kind: "text"; text: string }
| { kind: "image"; source: OneTalkOutboundImageSource };
| { kind: "image"; source: OneTalkOutboundMediaSource }
| { kind: "file"; source: OneTalkOutboundMediaSource };
export type OneTalkJsonValue =
| null
| boolean
@@ -113,14 +116,14 @@ const isSecureDownloadUrl = (value: unknown): value is string => {
}
};
/** 严格验证跨 Mind、Bright 与页面的单发文本或图片命令内容。 */
/** 严格验证跨 Mind、Bright 与页面的单发文本或媒体命令内容。 */
export const isOneTalkOutboundContent = (value: unknown): value is OneTalkOutboundContent => {
if (!isRecord(value) || typeof value.kind !== "string") return false;
if (value.kind === "text") {
return hasExactKeys(value, ["kind", "text"]) && isNonEmptyString(value.text);
}
return (
value.kind === "image" &&
(value.kind === "image" || value.kind === "file") &&
hasExactKeys(value, ["kind", "source"]) &&
isRecord(value.source) &&
hasExactKeys(value.source, ["downloadUrl", "fileName", "mimeType"]) &&
+12 -1
View File
@@ -912,7 +912,7 @@ test("accepts multiline text while rejecting non-text control characters", () =>
}
});
test("accepts only exact outbound image sources without media policy duplication", () => {
test("accepts only exact outbound media sources without media policy duplication", () => {
const base = {
...frameBase,
type: "send.command",
@@ -930,6 +930,17 @@ test("accepts only exact outbound image sources without media policy duplication
},
};
assert.equal(decodeOneTalkFrame(base).ok, true);
assert.equal(
decodeOneTalkFrame({
...base,
sendRequestId: "file-command",
payload: {
...base.payload,
content: { ...base.payload.content, kind: "file" },
},
}).ok,
true,
);
for (const content of [
{ ...base.payload.content, source: { ...base.payload.content.source, extra: true } },
{
@@ -65,7 +65,7 @@ const conversationKey = (frame: OneTalkSendRequestFrame): string => {
};
const timeoutFor = (frame: OneTalkSendRequestFrame, textTimeoutMs: number): number => {
return frame.payload.content.kind === "image" ? 45_000 : textTimeoutMs;
return frame.payload.content.kind === "text" ? textTimeoutMs : 45_000;
};
/** 创建唯一拥有 pending-send 状态和确认流程的协调器。 */
+46 -1
View File
@@ -1649,6 +1649,29 @@ test("reserves a sendRequestId before authorization and dispatches only once", a
status: "rejected_before_send",
reason: "send_in_progress",
});
const fileWhileTextPending = await registry.requestSend({
mindSocket,
frame: {
...frame,
requestId: "file-during-text",
sendRequestId: "file-during-text",
payload: {
conversationId: "conversation-1",
content: {
kind: "file",
source: {
downloadUrl: "https://mind.example.test/bridge/attachment-1",
fileName: "quotation.zip",
mimeType: "application/x-mind-approved-file",
},
},
},
},
});
assert.deepEqual(fileWhileTextPending, {
status: "rejected_before_send",
reason: "send_in_progress",
});
const duplicate = await registry.requestSend({ mindSocket, frame });
assert.deepEqual(duplicate, {
status: "rejected_before_send",
@@ -1730,7 +1753,7 @@ test("keeps a pre-dispatch disconnect in rejected_before_send", async () => {
});
});
test("uses the fixed 45-second terminal budget for image sends", async () => {
test("uses the fixed 45-second terminal budget for image and file sends", async () => {
const sentCommands: string[] = [];
let timeoutDelay = 0;
const authorization = createMockAuthorizationReader([authorizationRecord]);
@@ -1796,6 +1819,28 @@ test("uses the fixed 45-second terminal budget for image sends", async () => {
status: "rejected_before_send",
reason: "duplicate_request",
});
const fileFrame: OneTalkSendRequestFrame = {
...frame,
requestId: "request-file-timeout",
sendRequestId: "file-timeout-id",
payload: {
conversationId: "conversation-1",
content: {
kind: "file",
source: {
downloadUrl: "https://mind.example.test/bridge/attachment-1",
fileName: "quotation.zip",
mimeType: "application/x-mind-approved-file",
},
},
},
};
assert.deepEqual(await registry.requestSend({ mindSocket, frame: fileFrame }), {
status: "delivery_unknown",
reason: "send_timeout",
});
assert.equal(timeoutDelay, 45_000);
assert.equal(JSON.parse(sentCommands[1]).type, "send.command");
});
test("claims confirmation once and makes a terminal late confirmation a no-op", async () => {