mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
update md
This commit is contained in:
parent
6fcb12c90f
commit
dbc0a4b730
1 changed files with 362 additions and 0 deletions
362
docs/方案/出站图片原生呈现落地方案.md
Normal file
362
docs/方案/出站图片原生呈现落地方案.md
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue