dsh-im-ops/docs/方案/出站图片原生呈现落地方案.md

390 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 出站图片原生呈现落地方案
> 状态:已实施;九渠道真机验收通过
>
> 日期:2026-08-24
>
> 关联:[Issue #36](https://github.com/xmanrui/dsh-im/issues/36)、[ADR-0001:Semantic Core with Native Channel Adapters](../adr/0001-semantic-core-native-channel-adapters.md)、[渠道原生能力建设方案](./渠道原生能力建设方案.md)
## 1. 结论
本方案不新建一套“媒体消息框架”,而是在现有出站产物链路上增加一种呈现选择:
1. Agent 仍然只通过 `dsh_im_return_file` 返回产物,图片也是 `OutboundArtifact`,不增加 `return_image` 工具。
2. 语义核心继续负责产物绑定、物化、校验、释放、回执和失败语义。
3. 共享投递逻辑根据已物化产物的 `mediaType` 识别 `image/*`。
4. 渠道已有 `sendImage` 时调用渠道原生图片 API;没有独立图片 API、但现有附件本身就能原生预览的渠道继续调用 `sendFile`。
5. 原生图片发送被渠道明确拒绝时,自动降级为现有 `sendFile`;发送结果不确定时禁止再次发送,避免重复消息。
最小的新增抽象只有两项:
- 一个共享的产物投递函数,用来替代各 Bridge 中重复的 `#deliverArtifacts` 循环;
- 各渠道现有 API/Runtime 对象上的可选 `sendImage` 方法。
不引入新的产物类型、基类、注册表、配置开关、回执版本或本地图片格式白名单。
### 1.1 实施结果(2026-08-24)
本方案已按上述最小边界落地:
- 新增 `src/channels/shared/semantic/artifact-delivery.mjs`,统一负责 `image/*` 选择、确定性失败降级、`artifact-delivery-uncertain`/Abort 防重复、回执合并和 Artifact 释放。
- 共享 `TextHarnessBridge` 及飞书、微信、钉钉、企业微信、QQ Bridge 均改用该函数,不再各自维护图片识别与降级循环。
- 飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 接入各自原生图片发送;Slack、Discord 继续复用一次原生附件上传并显示图片预览。
- `dsh_im_return_file`、`OutboundArtifact`、`DeliveryReceipt` v1 和现有 Bot 配置结构均保持不变。
- `npm run check` 通过:1089/1089 测试通过,构建和发布包校验通过;独立代码复审未发现剩余 P0–P2 问题。
## 2. “语义核心 + 渠道原生适配”在本方案中的含义
| 层次 | 现有代码 | 本方案中的职责 |
| --- | --- | --- |
| 语义入口 | `src/channels/shared/semantic/artifact.mjs` 中的 `dsh_im_return_file` | 表达“把这个已生成产物交付给用户”,不表达飞书 `image_key`、Telegram `photo` 等渠道协议 |
| 语义核心 | `OutboundArtifact`、`materializeOutboundArtifact`、`releaseOutboundArtifact` | 校验 Session/Turn 归属,生成不可变字节快照,提供文件名、MIME、交付键并管理生命周期 |
| 共享交付语义 | `src/channels/shared/semantic/delivery.mjs` | 统一成功、拒绝、失败、结果不确定等回执语义 |
| 渠道原生适配 | 各渠道 `sendFile`,以及本方案新增的可选 `sendImage` | 把同一个图片产物翻译成渠道的原生图片上传与消息请求 |
| Bridge | 各渠道 Bridge、`text-harness-bridge.mjs` | 只注入目标、回复上下文和发送函数,不再各自维护图片识别与降级规则 |
也就是说,核心只知道“这是一个 `image/*` 产物,优先以图片呈现”;渠道适配器才知道它应当变成飞书的 `image_key`、Telegram 的 `sendPhoto`,还是 Discord 的图片附件。
这符合 ADR-0001 中的两个边界:公共语义只实现一次;供应商协议留在渠道边界。
## 3. 现状与缺口
### 3.1 已经具备、应直接复用的能力
- `dsh_im_return_file` 已经是受控的出站产物入口。
- `OutboundArtifact` 已携带 `artifactId`、`deliveryKey`、`fileName`、`mediaType`、`size` 和字节快照。
- MIME 推断已经覆盖 PNG、JPEG、GIF、WebP 等常见图片。
- `materializeOutboundArtifact`、`releaseOutboundArtifact` 已处理安全读取和生命周期。
- `DeliveryReceipt` v1 已能表示 `sent`、`rejected`、`failed`、`unknown`。
- `artifact-delivery-uncertain` 已表示“请求可能已经到达平台,但无法确认结果”。
- 九个渠道都已有可工作的 `sendFile` 路径,可作为统一降级路径。
### 3.2 当前问题
- 图片和普通文件最终都进入 `sendFile`,没有共享的“图片优先”选择规则。
- `#deliverArtifacts` 在共享 Bridge 及飞书、微信、钉钉、企业微信、QQ 等 Bridge 中重复,后续策略容易分叉。
- 飞书已经有一段用于修复二维码的原生图片上传代码,但尚未成为通用产物发送能力。
- 其他渠道有原生图片 API 或图片消息类型,尚未接入当前产物链路。
## 4. 设计原则
### 4.1 必须保持的约束
- 图片仍然是 Artifact,不创建第二条“图片产物”生命周期。
- 不解析模型文本中的 Markdown 图片、URL 或本地路径来触发发送。
- `mediaType` 只作为呈现提示,不作为核心层的准入判断。
- 图片格式、大小、权限等限制以渠道 API 的实际响应为准。
- 非图片产物的现有行为不得改变。
- 所有发送都继续服从 Abort、超时、幂等键和 `artifact-delivery-uncertain` 语义。
- 原生呈现失败时必须提供明确的文件降级或失败通知,不能静默丢失。
### 4.2 本期不做
- 不实现完整的 `SemanticDelivery`、`ChannelCapabilitySnapshot` 或 `ChannelAdapter` 类体系。
- 不新增 `OutboundMedia`、`NativeMediaDriver`、插件注册表或渠道配置开关。
- 不升级 `DeliveryReceipt` schema。
- 不做图片转码、压缩、缩略图、图集、富媒体排版或 caption 合并。
- 不要求各渠道支持完全相同的 GIF/WebP 动画效果;平台明确不支持时降级为文件。
- 不为 Slack、Discord 强行增加一个与现有附件上传完全相同的 `sendImage` 别名。
## 5. 最小架构
```text
Agent
│ dsh_im_return_file(path)
▼
OutboundArtifact 现有语义核心
│ materialize + mediaType
▼
deliverOutboundArtifacts() 新增的一处共享投递逻辑
│
├─ 非 image/* ───────────────────────────────► sendFile
│
└─ image/*
├─ 渠道提供 sendImage ─► sendImage
│ ├─ 成功 ───────► image receipt
│ ├─ 明确失败 ───► sendFile
│ └─ 结果不确定 ─► unknown receipt,不重发
│
└─ 渠道未提供 sendImage ────────────────► sendFile
(平台附件原生预览)
```
### 5.1 共享投递函数
新增:
```text
src/channels/shared/semantic/artifact-delivery.mjs
```
建议导出一个函数,不定义基类:
```js
deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId,
channelKey,
signal,
sendFile, // 必填:(file) => provider result
sendImage, // 可选:(file) => provider result
sendFailureNotice, // 必填:(artifact, error) => provider result
logger,
})
```
返回值保持贴近各 Bridge 现在需要的数据:
```js
{
receipt,
userVisible,
artifactsSent,
artifactSendErrors,
}
```
该函数只集中现有重复逻辑:
1. 逐个检查 Abort。
2. 物化 Artifact。
3. 选择 `sendImage` 或 `sendFile`。
4. 生成、合并现有 `DeliveryReceipt` v1。
5. 失败时调用渠道注入的安全通知函数。
6. 在 `finally` 中释放 Artifact。
Bridge 继续拥有会话目标、回复消息 ID、线程/话题、context token 等渠道上下文,通过闭包传入即可。例如:
```js
return deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
channelKey: 'telegram',
signal: this.#signal,
sendImage: typeof this.#bot.sendImage === 'function'
? (file) => this.#bot.sendImage(target, file)
: undefined,
sendFile: (file) => this.#bot.sendFile(target, file),
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact.fileName, error, this.#descriptor),
),
logger: this.#logger,
});
```
`typeof sendImage === 'function'` 就是本期所需的最小运行时能力判断,不需要先建设一套能力注册系统。
### 5.2 呈现选择规则
```js
const imagePreferred = file.mediaType?.startsWith('image/');
if (!imagePreferred || typeof sendImage !== 'function') {
return sendFile(file);
}
try {
return await sendImage(file);
} catch (error) {
if (signal?.aborted) throw error;
if (error?.code === 'artifact-delivery-uncertain') throw error;
return sendFile(file);
}
```
实际实现应记录最终使用的 presentation:
- 原生图片成功:`${channelKey}-image`
- 文件或附件成功:`${channelKey}-file`
- 失败:继续使用现有 `createArtifactFailureReceipt`
- 多项合并:第一阶段保留已有 aggregate presentation 值,避免不必要的兼容变更
这里不设置 `png/jpeg/...` 白名单。比如某平台不接受 WebP,`sendImage` 应把平台的明确拒绝映射为确定性错误,共享层随后调用 `sendFile`;核心层无需追踪每个平台不断变化的格式矩阵。
### 5.3 降级与防重复规则
| 原生图片结果 | 是否调用 `sendFile` | 最终语义 | 原因 |
| --- | --- | --- | --- |
| 成功 | 否 | `sent` | 已原生展示 |
| 上传前或平台明确拒绝 | 是 | 以文件发送结果为准 | 已知没有产生图片消息,可以安全降级 |
| `artifact-delivery-uncertain` | 否 | `unknown` | 图片消息可能已送达,再发文件会造成重复 |
| Abort | 否 | 向上抛出 | 尊重任务取消,不产生额外副作用 |
| 文件降级也失败 | 否 | 现有失败回执与安全通知 | 不再增加第三条路径 |
渠道适配器必须正确划分错误阶段:
- 图片字节校验、上传 URL 获取、明确的上传拒绝等,可映射为确定性失败。
- 最终消息请求发出后发生超时、连接中断或无法判断响应时,必须映射为 `artifact-delivery-uncertain`。
## 6. 各渠道落地方式
| 渠道 | 原生图片实现 | 复用现有代码 | 本期改动 |
| --- | --- | --- | --- |
| 飞书 | 上传图片取得 `image_key`,发送 `msg_type: image` | `bridge.mjs` 中 `#sendRepairQr` 已有同类代码;`FeishuChannel.sendFile` 已有超时、跟踪与错误映射 | 在 `FeishuChannel` 增加 `sendImage`,抽取/复用图片上传发送步骤;普通产物由共享投递函数选择 |
| 微信 | 加密上传到 CDN,发送 `image_item` | 现有 `weixin-api.mjs#sendFile` 的鉴权、加密上传、context token、run id 和错误映射;腾讯上游已有图片发送实现 | 抽取上传公共部分,新增 `sendImage`,图片类型使用微信图片消息协议 |
| 钉钉 | 上传图片媒体,发送 `sampleImageMsg` | `dingtalk-api.mjs#sendFile` 的 access token、媒体上传、目标选择和错误映射 | 增加 `sendImage`,只替换媒体类型和最终消息类型 |
| 企业微信 | `uploadMedia(type: image)` 后发送 image media message | 当前 Bridge 已用 SDK `uploadMedia` 和 `sendMediaMessage` 发送 file | 增加图片闭包或 Runtime 方法,分别传 `image`;不复制上传循环 |
| QQ | 调用 SDK `sendImage` | 已安装 QQ SDK 的图片方法;当前 Bridge 的超时、provider promise 跟踪和错误包装 | 在现有 Runtime/API 边界暴露 `sendImage`,保持和 `sendFile` 相同的消息上下文与错误语义 |
| Telegram | Bot API `sendPhoto` | `telegram-api.mjs` 的请求、超时、reply/thread 参数和错误映射;Runtime 已有 `sendFile` | 增加 `sendPhoto`/`sendImage` 薄封装 |
| WhatsApp | Baileys image message `{ image, mimetype }` | `whatsapp-runtime.mjs#sendFile` 的稳定消息 ID、echo 抑制、超时和不确定错误处理 | 增加 `sendImage`,只改变消息 content 类型 |
| Slack | 继续现有文件上传;图片 MIME 会显示附件图片预览 | `files.getUploadURLExternal`、`files.completeUploadExternal` 现有链路 | 不增加 `sendImage`;补契约测试和真机验证即可 |
| Discord | 继续现有 multipart attachment;图片附件会内联显示 | `discord-api.mjs` 现有附件上传和 Content-Type | 不增加 `sendImage`;补契约测试和真机验证,不引入 embed |
说明:这里的“各渠道原生显示”指用户在原生客户端中看到图片,而不是要求所有平台都必须有一个名字叫 `sendImage` 的独立接口。Slack 和 Discord 的原生图片呈现方式就是图片附件预览,复用现有 `sendFile` 更简单,也避免对同一个 API 进行无意义的失败重试。
## 7. 代码改动清单
### 7.1 共享层
- 新增 `src/channels/shared/semantic/artifact-delivery.mjs`。
- 将 `text-harness-bridge.mjs` 中现有 `#deliverArtifacts` 行为原样抽取到共享函数。
- 让飞书、微信、钉钉、企业微信、QQ 的重复循环逐步改为调用共享函数。
- Slack、Telegram、Discord、WhatsApp 已经走 `text-harness-bridge.mjs` 的部分直接获得新策略,无需各自增加路由判断。
- 在 `artifact.mjs` 中把工具说明和系统提示由“返回文件”扩展为“返回文件或生成的图片”,工具名和输入结构不变。
### 7.2 渠道层
- 只在确实有独立图片消息协议的渠道对象上增加 `sendImage`。
- `sendImage` 接受与现有 `sendFile` 相同的物化文件对象,并尽量保持相同的目标、reply、thread、signal 和超时参数形式。
- 共享层不引用任何平台 SDK;渠道层不重新实现 Artifact 物化、释放、回执合并或降级策略。
### 7.3 不改动的数据契约
- `OutboundArtifact` 字段不变。
- `dsh_im_return_file` 名称和参数不变。
- `DeliveryReceipt` 保持 schema version 1。
- 现有错误码继续使用;只有渠道缺少精确映射时,补齐到已有 `artifact-*` 错误语义。
- Bot 配置文件不增加 `nativeImage` 或格式列表。
## 8. 实施顺序
### 阶段 1:纯抽取,共享现有文件投递
1. 从 `text-harness-bridge.mjs` 抽取 `deliverOutboundArtifacts`。
2. 首先只调用 `sendFile`,确保行为、回执、失败通知和状态计数不变。
3. 迁移重复的 Bridge 循环,每迁移一个渠道就运行其现有测试。
这一阶段不改变用户可见行为,便于把“代码抽取问题”和“原生图片协议问题”分开排查。
### 阶段 2:加入共享图片选择策略
1. 增加 `image/*` 判断和可选 `sendImage`。
2. 落实“明确失败可降级、结果不确定不重发”的规则。
3. 更新工具说明和系统提示,使模型知道生成图片也通过现有 Artifact 工具交付。
### 阶段 3:以飞书作为参考渠道
1. 将现有修复二维码的图片上传/发送能力整理为 `FeishuChannel.sendImage`。
2. 接入普通 Artifact,并验证 PNG、JPEG、非图片文件和失败降级。
3. 若复用修复二维码路径会扩大改动面,可先保留其调用点,仅复用底层私有函数;不强求一次消除所有重复。
### 阶段 4:完成 Issue #36 与其余渠道
1. 微信:参考腾讯上游已工作的图片上传/消息结构,优先完成 Issue #36。
2. 企业微信、QQ、Telegram、WhatsApp、钉钉:各自增加薄 `sendImage` 适配。
3. Slack、Discord:不写新发送路径,只验证当前附件在官方客户端中确实内联显示。
### 阶段 5:清理重复实现
- 在所有渠道契约测试通过后,删除已被共享函数取代的 `#deliverArtifacts` 重复代码。
- 不顺便重构文本、卡片、语音等其他投递路径。
## 9. 测试方案
### 9.1 共享单元测试
至少覆盖以下决策表:
| 场景 | 预期 |
| --- | --- |
| 非图片产物 | 只调用一次 `sendFile` |
| 图片且存在 `sendImage` | 只调用一次 `sendImage`,presentation 为 image |
| 图片但不存在 `sendImage` | 调用一次 `sendFile` |
| `sendImage` 明确拒绝 | 随后调用一次 `sendFile` |
| `sendImage` 返回不确定错误 | 不调用 `sendFile`,Artifact outcome 为 `unknown` |
| 投递期间 Abort | 不降级、不发送失败通知,继续向上抛出 |
| 图片和文件混合多产物 | 保持原有顺序,分别生成并合并回执 |
| 部分成功、部分失败 | 成功项不重发,失败项有正确回执和安全通知 |
| 任意结果 | 每个 Artifact 最终只释放一次 |
### 9.2 渠道契约测试
对飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 分别验证:
- 图片上传请求的媒体类型正确。
- 最终消息请求使用图片类型而非文件类型。
- reply/thread/context token 等原有上下文没有丢失。
- 平台明确拒绝与结果不确定被映射到不同错误语义。
- 相同 `deliveryKey` 继续参与现有幂等或稳定消息 ID 机制。
对 Slack、Discord 验证:
- `image/png` 和 `image/jpeg` 继续走一次附件上传。
- 请求保留正确 MIME/Content-Type 和文件名。
- 不会因为不存在 `sendImage` 而额外尝试第二次上传。
### 9.3 真机验收
每个渠道至少保留以下证据:
1. PNG/JPEG 在官方客户端中的消息截图或录屏。
2. 非图片文件仍按原行为发送。
3. 一个平台不支持的图片格式或模拟明确拒绝能降级为文件。
4. 模拟最终发送超时不会紧接着出现重复文件。
自动化测试能验证协议形状,不能完全代替客户端是否“原生显示”的验收。
### 9.4 九渠道真机验收记录(2026-08-24)
统一使用工作区图片 `assets/logo-plugin-phone.png`(PNG,85,920 字节),由用户在每个渠道的真实会话中要求 Agent 调用 `dsh_im_return_file`。以下时间均为 Asia/Taipei:
| 渠道 | 实际发送路径 | 官方客户端可见结果 | 结果 |
| --- | --- | --- | --- |
| Telegram | `sendPhoto` | 03:07 出现一张原生图片消息 | 通过 |
| Slack | 单次 external file upload | 03:08 在消息列中显示一张内联图片预览 | 通过 |
| Discord | 单次 multipart attachment | 03:10 在 Bot 私信中显示一张内联图片附件 | 通过 |
| 飞书 | `image.create` + `msg_type: image` | 03:11 在“梁子文件传送”会话中显示一张原生图片 | 通过 |
| 微信 | CDN 图片上传 + `image_item` | 03:12 在 DeepSeek Harness 会话中显示一张图片气泡 | 通过 |
| 钉钉 | image media + `sampleImageMsg` | 03:14 在“梁子来了”会话中显示一张原生图片,侧栏标记 `[Image]` | 通过 |
| 企业微信 | `uploadMedia(image)` + image media message | 03:15 在“今天是梁子”会话中显示一张原生 `[Photo]` | 通过 |
| QQ | SDK `sendImage` | 03:15 在 `winBot` 私聊中显示一张原生图片,侧栏标记 `[图片]` | 通过 |
| WhatsApp | Baileys `{ image, mimetype }` | 03:16 在账号自聊中显示一张原生图片并保留引用上下文 | 通过 |
九个客户端均只出现一份图片产物,没有额外文件 fallback、重复图片或失败提示。事后状态检查中九个目标 Bot 均为 `connected` / `healthy`;除飞书状态投影不含本轮计数外,其余八个渠道均记录 1 条接收、1 条回复,Host 日志未出现 Artifact 投递失败、结果不确定或失败通知。
## 10. 验收标准
- 九个渠道中的 PNG、JPEG 都能在原生客户端直接看到图片或图片附件预览。
- 飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 使用各自的图片消息能力。
- Slack、Discord 使用其原生图片附件预览,且只上传一次。
- 非 `image/*` 产物仍只走现有 `sendFile`。
- 原生图片被明确拒绝时自动降级为文件。
- 结果不确定或 Abort 时不会二次发送。
- Session/Turn 绑定、不可变快照、摘要校验、释放生命周期均保持不变。
- 回执仍为 `DeliveryReceipt` v1,成功、失败、拒绝、不确定语义可区分。
- 不新增工具、Bot 配置项、本地格式白名单或完整渠道能力框架。
- 相关单元测试、渠道测试、`npm test` 和 `npm run build` 通过,并完成九渠道真机验收。
## 11. 风险与控制
| 风险 | 控制方式 |
| --- | --- |
| 原生发送超时后降级导致重复图片 | 复用并严格执行 `artifact-delivery-uncertain`;不确定时禁止降级 |
| 各平台图片格式限制不同 | 不在核心写死矩阵;平台明确拒绝后走文件 |
| 抽取共享函数造成既有文件回归 | 先做“纯抽取”提交,行为无变化,再加入图片策略 |
| 渠道 Bridge 上下文差异过大 | 通过闭包注入目标和上下文,不设计统一的大参数对象 |
| 飞书二维码重构影响修复流程 | 优先复用底层私有函数;是否迁移旧调用点由测试结果决定 |
| Slack/Discord 被误判为未实现 | 以官方客户端中的原生附件预览作为验收标准,并保留真机证据 |
## 12. 参考资料
- [Tencent openclaw-weixin:send-media.ts](https://github.com/Tencent/openclaw-weixin/blob/main/src/messaging/send-media.ts)
- [Tencent openclaw-weixin:CDN upload.ts](https://github.com/Tencent/openclaw-weixin/blob/main/src/cdn/upload.ts)
- [Telegram Bot API:sendPhoto](https://core.telegram.org/bots/api/#sendphoto)
- [钉钉机器人消息类型](https://open.dingtalk.com/document/development/robot-message-type)
- [Slack:Working with files](https://docs.slack.dev/messaging/working-with-files/)
- [Discord:Uploading Files](https://docs.discord.com/developers/reference#uploading-files)
- [Baileys](https://github.com/WhiskeySockets/Baileys)