mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: support OneTalk file sending
This commit is contained in:
@@ -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 projection:source 只能在内存 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 fact;Mind/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": {}
|
||||
}
|
||||
+10
-1
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -171,6 +171,7 @@ export type {
|
||||
OneTalkSendRejectedPayload,
|
||||
OneTalkSendConfirmationPayload,
|
||||
OneTalkSendResultPayload,
|
||||
OneTalkOutboundMediaSource,
|
||||
OneTalkOutboundImageSource,
|
||||
OneTalkOutboundContent,
|
||||
OneTalkJsonValue,
|
||||
|
||||
@@ -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"]) &&
|
||||
|
||||
@@ -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 状态和确认流程的协调器。 */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user