dsh-im-ops/PROACTIVE_DELIVERY.md
oliver 01924e5f7e Expose conversation/target session lookup for cron result mirroring.
Schedulers can resolve the live Harness session for a bot+target or conversationKey without changing proactive send() semantics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 12:45:36 +08:00

323 lines
15 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.

# 主动投递使用指南
**简体中文** · [English](PROACTIVE_DELIVERY.en.md)
主动投递让应用在没有用户新消息的情况下,通过已经接入 DSH-IM 的机器人发送文字消息。调用方只需保存一组稳定的 `botId + targetId`,不需要保存 Harness `sessionId`、聊天引用、消息 ID 或临时 Webhook。
九个内置渠道均支持主动投递:微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp。
## 快速开始
1. 打开「设置 → IM机器人」,找到需要发消息的机器人。
2. 点击机器人卡片右上角的齿轮图标。
3. 在「调用标识」中复制 `Bot ID`。
4. 点击「新建目标」,从已聊会话中选择,或点击「手动填写(高级)」填写平台原生 ID。
5. 填写或确认 `Target ID`、目标类型和平台原生 ID。
6. 点击「测试」。目标收到 `DSH-IM 主动投递测试成功。` 后,再点击「保存目标」。
7. 在已保存目标上点击「复制调用参数」,得到可供应用保存的 `{ botId, targetId }`。
8. 使用 HTTP POST、同 Host 的 `ctx.dshIm.send()` 或 Connection RPC 的 `message.send` 发送消息。
## 配置投递目标
### 1. 获取 Bot ID
`botId` 是当前已接入机器人的真实调用标识。请从设置页复制并将它视为不透明字符串,不要根据前缀推断渠道,也不要使用机器人名称、平台 App ID 或卡片中的脱敏 ID 代替。
目标配置跟随当前机器人保存。移除机器人时,它的投递目标也会被清理;重新接入后应重新复制 `botId` 并配置目标。
### 2. 新建目标
点击「新建目标」后,默认显示「从已聊过的会话选择」下拉框:
- 候选来自该机器人已经持久化的会话映射,不是平台通讯录,也不是严格按时间排序的完整最近会话列表。
- 候选只包含投递所需的目标类型和平台原生 ID,不包含 Harness `sessionId`、消息正文、消息 ID、会话名称或最后活跃时间。
- 已配置的相同目标会显示「已添加」并禁用。
- 没有候选时,先在对应平台与机器人聊一条消息,再点击「刷新」。仍未出现时可使用「手动填写(高级)」。
选择候选只会预填表单,不会自动保存。
### 3. 理解 Target ID
`targetId` 是你为调用方定义的稳定别名,不是平台用户 ID、群 ID 或频道 ID。
- 它只需在同一个机器人下唯一;不同机器人可以使用相同的 `targetId`。
- 可使用大小写字母、数字、点、下划线、冒号、`@` 或连字符,长度为 1–128 个字符。
- 新建时页面默认生成 `tgt_` 加 16 位十六进制随机串,例如 `tgt_7f3a91c8d2e64b10`。
- 首次保存前可以修改,例如改成 `daily-report` 或 `release-alerts`。
- 保存后 `targetId` 不可修改,但名称、目标类型和平台原生路由可以修改;调用方仍使用原来的 `botId + targetId`。
### 4. 测试、保存和复制
从会话新建、手动新建和编辑目标时,表单底部都会显示「测试」按钮:
- 机器人在线且当前目标所需的平台 ID 已填写完整时,测试按钮才可用。
- 测试使用当前表单中的目标类型和平台 ID,不会先创建、更新或保存目标。
- 修改测试过的平台 ID 后,旧的成功提示会清除,需要重新测试。
- 已保存目标列表中的「测试」按钮测试该目标当前保存的路由。
- 测试成功表示平台发送接口已接受请求或 SDK 成功返回,不代表消息已经被阅读。
保存后点击「复制调用参数」,页面会复制如下 JSON:
```json
{
"botId": "bot_9577c8572d454122a4ef7fb4d8420a91",
"targetId": "release-alerts"
}
```
### 配置示例:飞书告警群
假设飞书机器人已经在告警群中收到过消息:
1. 打开该机器人的设置页并复制 `Bot ID`。
2. 点击「新建目标」,在下拉框中选择告警群。
3. 将自动生成的 `Target ID` 改为 `release-alerts`,显示名称填为「发布告警群」。
4. 确认目标类型为「群聊」,群 Chat ID 已自动填入。
5. 点击「测试」,到飞书群中确认测试消息。
6. 点击「保存目标」,再点击「复制调用参数」。
以后即使编辑并更换群 Chat ID,调用方仍可继续使用同一组 `botId + release-alerts`。
## 九渠道手动填写字段
优先从已聊会话中选择。只有目标未出现在候选中时,才需要手动取得以下平台原生 ID。
| 渠道 | 目标类型 | 需要填写的字段 | 示例或说明 |
| --- | --- | --- | --- |
| 微信 | `user` | 微信用户 ID(`toUserId`) | 填写接收消息的微信用户 ID |
| 飞书 | `user` | Open ID(`openId`) | 例如 `ou_xxx` |
| 飞书 | `group` | 群 Chat ID(`chatId`) | 例如 `oc_xxx` |
| 钉钉 | `user` | 用户 ID(`userId`) | 填写钉钉用户 ID |
| 钉钉 | `group` | 群 Open Conversation ID(`openConversationId`) | 主动投递不使用临时 `sessionWebhook` |
| 企业微信 | `user` | 用户 ID(路由字段为 `chatId`) | 私聊填写用户 ID |
| 企业微信 | `group` | 群 Chat ID(`chatId`) | 群聊填写群 `chatid` |
| QQ | `user` | 用户 Open ID(`userOpenId`) | 平台提供的 `user_openid` |
| QQ | `group` | 群 Open ID(`groupOpenId`) | 平台提供的 `group_openid` |
| Slack | `conversation` | Channel ID(`channelId`) | 例如 `C0123456789` |
| Slack | `thread` | Channel ID + Thread 时间戳(`channelId`, `threadTs`) | 例如 `1712345678.123456` |
| Telegram | `chat` | Chat ID(`chatId`) | 十进制字符串,例如 `-1001234567890` |
| Telegram | `topic` | Chat ID + Topic ID(`chatId`, `messageThreadId`) | Topic ID 必须是正整数 |
| Discord | `channel` | Channel ID(`channelId`) | 私信、频道和 Thread 都使用可发消息的 Channel ID |
| WhatsApp | `user` | 用户 JID(`jid`) | 例如 `8613800000000@s.whatsapp.net` |
| WhatsApp | `group` | 群 JID(`jid`) | 例如 `1234567890-123456@g.us` |
平台 ID 字符串不能为空或带首尾空格。一个目标只接受所选渠道和类型要求的字段,额外字段会被拒绝。
## 通过 HTTP POST 发送
普通外部应用可以直接调用 Host 的主动投递接口:
```bash
curl --request POST \
http://127.0.0.1:3080/api/dsh-im/delivery/messages \
--header 'Content-Type: application/json' \
--data '{
"botId": "bot_9577c8572d454122a4ef7fb4d8420a91",
"targetId": "release-alerts",
"text": "构建已经完成。"
}'
```
成功返回:
```json
{ "sent": true }
```
请求体严格只接受 `botId`、`targetId` 和 `text`,JSON 总大小不能超过 1 MiB。不要附加平台原生路由、`sessionId`、`chatRef`、临时 Webhook 或 `idempotencyKey`。
接口路径固定为 `POST /api/dsh-im/delivery/messages`,复用当前 DSH Host 的 WebServer,不会另开端口。示例中的 `3080` 是 Web profile 的默认端口;实际地址以 Host 启动时显示的地址为准。
当前 HTTP 接口不包含鉴权,也不提供 CORS。只应在本机或可信网络中使用,不要直接暴露到公网。
## 在同一 Host 的插件中发送
消费插件声明 `dshIm` 注入后,可以直接调用共享服务,不经过 Connection RPC。
下面是一个最小示例;插件加载时会发送一次消息:
```js
export const inject = ['dshIm'];
export async function apply(ctx) {
const result = await ctx.dshIm.send(
'bot_9577c8572d454122a4ef7fb4d8420a91',
'release-alerts',
'构建已经完成。',
);
if (result.sent !== true) {
throw new Error('主动投递没有返回成功结果');
}
}
```
实际使用时,把 `ctx.dshIm.send()` 放进你的定时任务、构建回调或业务事件处理函数中。可选的第四个参数当前支持取消信号:
```js
await ctx.dshIm.send(botId, targetId, text, { signal });
```
同 Host 插件也可以列出某个机器人的已保存目标:
```js
const targets = await ctx.dshIm.listTargets(botId);
// [{ targetId, name?, kind, route }, ...]
```
调用失败时 Promise 会拒绝,`error.code` 使用本文后面的公共错误码。
## 通过 Connection RPC 发送
Connection RPC 适合已经持有当前 DSH Host `connection` 客户端的调用方,也是设置页管理目标所使用的接口。普通外部应用优先使用上面的 HTTP POST。
先封装 RPC 成功与错误包络:
```js
const DELIVERY_CHANNEL = '/dsh-im-delivery';
async function callDelivery(connection, endpoint, payload, signal) {
const result = await connection.rpc.call(
DELIVERY_CHANNEL,
endpoint,
payload,
signal,
);
if (result?.ok !== true) {
const error = new Error(result?.error?.message || 'delivery-failed');
error.code = result?.error?.code || 'delivery-failed';
throw error;
}
return result.value;
}
```
然后使用已复制的 `botId + targetId` 发送文字:
```js
const result = await callDelivery(connection, 'message.send', {
botId: 'bot_9577c8572d454122a4ef7fb4d8420a91',
targetId: 'release-alerts',
text: '构建已经完成。',
});
// result: { sent: true }
```
`message.send` 只接受 `{ botId, targetId, text }`。不要附加平台路由、`sessionId`、`chatRef`、临时 Webhook 或 `idempotencyKey`。
### 示例:投递每日报告
```js
async function sendDailyReport(connection, summary) {
try {
await callDelivery(connection, 'message.send', {
botId: 'bot_9577c8572d454122a4ef7fb4d8420a91',
targetId: 'daily-report',
text: `今日运行摘要\n\n${summary}`,
});
} catch (error) {
if (error.code === 'bot-not-connected') {
// 等待机器人恢复连接后,由业务决定是否重试。
return { delivered: false, reason: 'offline' };
}
throw error;
}
return { delivered: true };
}
```
## 管理 RPC 参考
设置页使用同一个 Connection RPC 通道管理目标。普通调用方通常只需要 `message.send`;需要自行管理目标时再使用其他端点。
所有响应均为 `{ ok: true, value }` 或 `{ ok: false, error: { code, message, details } }`。
| 端点 | Payload | 成功时的 `value` |
| --- | --- | --- |
| `message.send` | `{ botId, targetId, text }` | `{ sent: true }` |
| `target.list` | `{ botId }` | `{ botId, channel, targets }` |
| `target.suggestion.list` | `{ botId }` | `{ botId, channel, suggestions }` |
| `target.create` | `{ botId, target: { targetId, name?, kind, route } }` | 已创建的完整目标 |
| `target.update` | `{ botId, targetId, target: { name?, kind, route } }` | 更新后的完整目标 |
| `target.delete` | `{ botId, targetId }` | `{ deleted: true }` |
| `target.test` | `{ botId, targetId }` | `{ sent: true }` |
| `target.test` | `{ botId, target: { kind, route } }` | `{ sent: true }`;测试草稿,不保存 |
接口严格校验字段。`target.update` 的内部 `target` 不能包含 `targetId`;草稿测试不能包含 `targetId` 或 `name`。
## 错误处理
HTTP 失败响应格式为 `{ "error": { "code", "message", "details" } }`。同 Host 和 RPC 使用相同错误码,但没有 HTTP 状态码。
| 错误码 | HTTP 状态 | 含义与处理建议 |
| --- | --- | --- |
| `bad-request` | 400 | 请求结构、ID 格式、JSON 或文字无效;检查字段名并移除额外字段 |
| `unknown-bot` | 404 | `botId` 不属于当前 Host;重新从机器人设置页复制 |
| `unknown-target` | 404 | 该机器人下不存在 `targetId`;检查是否复制错误或目标已被删除 |
| `target-conflict` | 409 | 同一机器人下已经存在相同 `targetId`;更换别名 |
| `invalid-target` | 422 | 目标类型或平台原生 ID 不符合当前渠道规则;重新选择类型并核对 ID |
| `bot-not-connected` | 503 | 机器人当前离线;恢复连接后由调用方决定是否重试 |
| `target-rejected` | 422 | 平台明确拒绝目标或机器人缺少发送权限;检查平台权限和目标 ID |
| `delivery-failed` | 502 | 网络、平台或其他无法安全细分的发送失败;检查连接状态和 Host 日志 |
| `cancelled` | 408 | 调用被取消;按业务需要结束或重新发起 |
HTTP 协议层还可能返回 `method-not-allowed`(405)、`unsupported-media-type`(415)或 `payload-too-large`(413)。
## 投递语义与限制
- 当前主动投递只发送非空文字,不支持在该接口中发送图片、文件、卡片或富文本。
- HTTP JSON 请求体上限为 1 MiB。
- `{ sent: true }` 表示平台发送接口接受请求或 SDK 成功返回,不承诺最终送达或已读。
- DSH-IM 不保存主动投递历史,不生成 `deliveryHandle` 或 `idempotencyKey`,也不自动重试。
- 调用方超时后重试可能产生重复消息;需要业务幂等时,由调用方保存自己的业务事件 ID 和处理结果。
- 正式发送只使用已保存的 `botId + targetId`。平台原生路由保存在目标配置中,不应随每次消息发送。
- 一个调用只发送到一个目标。需要通知多个目标时,应分别调用并分别处理结果。
- 机器人离线时仍可编辑目标,但不能测试或主动发送。
## HTTP 与 RPC 可达范围
HTTP 接口只在当前 Host 提供 WebServer 时注册,并使用同一个监听地址和端口。默认 Web profile 地址通常是 `127.0.0.1:3080`,只能由本机访问。若要从其他机器调用,需要在对应 profile 的 `cordis.patch.yml` 中把 WebServer 绑定到可达地址并重启 Host,例如:
```yaml
- id: webserver
config:
host: '0.0.0.0'
```
这会同时扩大该 WebServer 上其他页面和路由的网络可达范围。当前主动投递 HTTP 接口没有鉴权,因此只能配合可信局域网、防火墙或反向代理使用,不能直接暴露到公网。
Connection RPC 默认只允许当前 Host 的回环调用。若 Web profile 明确运行在受信任局域网,可在该 profile 的 `cordis.patch.yml` 中复用现有 Host authority:
```yaml
- id: xmanrui-dsh-im
config:
rpcAuthority: trusted-host
```
`trusted-host` 只是 Host/Origin 可达性边界,不是用户认证。启用后,能访问该受信网络 authority 的调用方也能访问机器人管理接口;只应在可信网络中使用。
## 常见问题
### 下拉框找不到目标会话
先在对应平台向该机器人发送一条消息,再返回设置页刷新。候选不是平台的完整会话目录;仍然找不到时使用「手动填写(高级)」。
### 测试按钮不可点击
确认机器人在线,并填写当前目标类型要求的全部平台原生 ID。Slack Thread 和 Telegram Topic 都需要两个字段。
### 保存后能否修改 Target ID
不能。你可以编辑名称、类型和平台路由而保持调用参数不变;若必须更换 `targetId`,请新建目标并让调用方切换后再删除旧目标。
### 为什么不用 sessionId
`sessionId` 标识 Harness 会话,不是九个平台统一、稳定的消息投递地址。主动投递只使用机器人和已保存目标的稳定组合。
同 Host 的调度插件若需要把投递摘要写回可继续聊的 Harness 会话,应另行调用 `resolveConversationSession` / `resolveTargetSession`(由渠道注册的会话绑定反查),再自行 `session.append` / `inject`;`send()` 本身不写会话日志。
### 测试成功但对方没有看到消息
测试成功只证明平台接口接受发送。请继续检查机器人权限、平台限制、目标是否正确,以及客户端侧的消息过滤或归档设置。