diff --git a/docs/方案/出站图片原生呈现落地方案.md b/docs/方案/出站图片原生呈现落地方案.md new file mode 100644 index 0000000..552cf1e --- /dev/null +++ b/docs/方案/出站图片原生呈现落地方案.md @@ -0,0 +1,362 @@ +# 出站图片原生呈现落地方案 + +> 状态:待实施 +> +> 日期: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` 方法。 + +不引入新的产物类型、基类、注册表、配置开关、回执版本或本地图片格式白名单。 + +## 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. 模拟最终发送超时不会紧接着出现重复文件。 + +自动化测试能验证协议形状,不能完全代替客户端是否“原生显示”的验收。 + +## 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)