feat: add proactive delivery HTTP endpoint

This commit is contained in:
xmanrui 2026-08-30 11:52:01 +08:00
parent 31792d7bc8
commit b90cb805c7
11 changed files with 703 additions and 294 deletions

View file

@ -8,11 +8,11 @@ This file records the notable changes in each dsh-im release. Its format follows
### Added / 新增
- 九个 IM 渠道统一支持基于稳定 `botId + targetId` 的主动投递:同 Host Cordis 插件可通过 `ctx.dshIm` 调用,进程外程序可通过 `/dsh-im-delivery` Connection RPC 调用;两个入口共用同一投递核心。机器人卡片新增设置页,可复制 Bot ID、管理多个目标并逐个真实测试。新建目标时优先从九渠道已持久化的 conversation keys 选择已聊会话并自动预填稳定路由及随机 `targetId`,手动填写保留为高级兜底并同样预填随机 `targetId`。候选不包含 Harness Session ID、聊天正文、会话名称或活跃时间,也不代表平台全量聊天。
Added stable `botId + targetId` proactive delivery across all nine IM channels. Same-Host Cordis plugins can call `ctx.dshIm`, while external processes can use the `/dsh-im-delivery` Connection RPC; both entry points share one delivery core. Bot cards now open a settings page for copying Bot IDs, managing multiple targets, and testing each target with a real send. Creating a target now starts with conversations derived from persisted conversation keys across all nine channels and pre-fills both the stable native route and a random `targetId`; advanced manual entry also starts with a random `targetId`. Suggestions contain no Harness Session ID, message text, conversation name, or activity timestamp and are not a complete platform chat directory.
- 九个 IM 渠道统一支持基于稳定 `botId + targetId` 的主动投递:普通外部程序可调用 `POST /api/dsh-im/delivery/messages`,同 Host Cordis 插件可调用 `ctx.dshIm`,已有 Connection 客户端可调用 `/dsh-im-delivery` RPC;三个入口共用同一投递核心。机器人卡片新增设置页,可复制 Bot ID、管理多个目标并逐个真实测试。新建目标时优先从九渠道已持久化的 conversation keys 选择已聊会话并自动预填稳定路由及随机 `targetId`,手动填写保留为高级兜底并同样预填随机 `targetId`。候选不包含 Harness Session ID、聊天正文、会话名称或活跃时间,也不代表平台全量聊天。
Added stable `botId + targetId` proactive delivery across all nine IM channels. Ordinary external programs can call `POST /api/dsh-im/delivery/messages`, same-Host Cordis plugins can call `ctx.dshIm`, and existing Connection clients can call `/dsh-im-delivery` RPC; all three entry points share one delivery core. Bot cards now open a settings page for copying Bot IDs, managing multiple targets, and testing each target with a real send. Creating a target now starts with conversations derived from persisted conversation keys across all nine channels and pre-fills both the stable native route and a random `targetId`; advanced manual entry also starts with a random `targetId`. Suggestions contain no Harness Session ID, message text, conversation name, or activity timestamp and are not a complete platform chat directory.
- 新增中英文主动投递使用指南,覆盖设置流程、九渠道字段、同 Host 插件与 Connection RPC 示例、错误处理和排错;机器人投递设置页可按当前界面语言直接打开对应指南。
Added Chinese and English proactive-delivery guides covering setup, native fields for all nine channels, same-Host plugin and Connection RPC examples, errors, and troubleshooting. Bot delivery settings link directly to the guide matching the current UI language.
- 新增中英文主动投递使用指南,覆盖设置流程、九渠道字段、HTTP POST、同 Host 插件与 Connection RPC 示例、错误处理和排错;机器人投递设置页可按当前界面语言直接打开对应指南。
Added Chinese and English proactive-delivery guides covering setup, native fields for all nine channels, HTTP POST, same-Host plugin and Connection RPC examples, errors, and troubleshooting. Bot delivery settings link directly to the guide matching the current UI language.
## [4.0.1] - 2026-08-30

View file

@ -15,7 +15,7 @@ All nine built-in channels support proactive delivery: Weixin, Feishu, DingTalk,
5. Review or enter the **Target ID**, target type, and native platform ID.
6. Select **Test**. After the target receives `DSH-IM 主动投递测试成功。`, select **Save target**.
7. Select **Copy call parameters** on the saved target and store the resulting `{ botId, targetId }` in the calling application.
8. Send messages through same-Host `ctx.dshIm.send()` or the Connection RPC `message.send` endpoint.
8. Send messages through HTTP POST, same-Host `ctx.dshIm.send()`, or the Connection RPC `message.send` endpoint.
## Configure a delivery target
@ -103,6 +103,33 @@ Choose a known conversation whenever possible. Obtain and enter a platform-nativ
Native ID strings must be nonempty and have no leading or trailing whitespace. A target accepts only the fields required by the selected channel and type; extra fields are rejected.
## Send through HTTP POST
An ordinary external application can call the Host's proactive-delivery endpoint directly:
```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": "The build has completed."
}'
```
A successful request returns:
```json
{ "sent": true }
```
The body accepts exactly `botId`, `targetId`, and `text`, with a maximum total JSON size of 1 MiB. Do not add a native platform route, `sessionId`, `chatRef`, temporary webhook, or `idempotencyKey`.
The fixed endpoint is `POST /api/dsh-im/delivery/messages`. It reuses the current DSH Host WebServer and does not open another port. Port `3080` is the default for the Web profile; use the address printed by the running Host when it differs.
The HTTP endpoint currently has no authentication and does not provide CORS. Use it only on the local machine or a trusted network; never expose it directly to the public internet.
## Send from a plugin in the same Host
A consumer plugin can declare the `dshIm` injection and call the shared service directly without going through Connection RPC.
@ -142,7 +169,7 @@ On failure, the Promise rejects with an Error whose `code` is one of the public
## Send through Connection RPC
Connection RPC is for a local caller that already holds a `connection` client for the current DSH Host. Proactive delivery is not an HTTP, REST, or webhook endpoint, so the following example cannot be translated directly into `curl`.
Connection RPC is for a caller that already holds a `connection` client for the current DSH Host. The settings page also uses it to manage targets. Ordinary external applications should prefer the HTTP POST endpoint above.
First unwrap the RPC success and error envelopes:
@ -222,21 +249,26 @@ Payloads are validated with exact fields. The inner `target` in `target.update`
## Error handling
| Error code | Meaning and suggested action |
| --- | --- |
| `bad-request` | Invalid request shape, ID format, or text; check field names and remove extra fields |
| `unknown-bot` | The current Host does not own this `botId`; copy it again from bot settings |
| `unknown-target` | The bot has no such `targetId`; check the copied pair or whether the target was deleted |
| `target-conflict` | The same bot already has this `targetId`; choose another alias |
| `invalid-target` | The target type or native ID violates this channel's rules; select the correct type and verify the ID |
| `bot-not-connected` | The bot is offline; let the caller decide whether to retry after reconnection |
| `target-rejected` | The platform explicitly rejected the target or the bot lacks permission; check platform permissions and the target ID |
| `delivery-failed` | A network, platform, or other safely redacted delivery failure; check bot state and Host logs |
| `cancelled` | The call was cancelled; stop or start a new call as required by the application |
An HTTP failure returns `{ "error": { "code", "message", "details" } }`. Same-Host and RPC calls use the same error codes without an HTTP status.
| Error code | HTTP status | Meaning and suggested action |
| --- | --- | --- |
| `bad-request` | 400 | Invalid request shape, ID format, JSON, or text; check field names and remove extra fields |
| `unknown-bot` | 404 | The current Host does not own this `botId`; copy it again from bot settings |
| `unknown-target` | 404 | The bot has no such `targetId`; check the copied pair or whether the target was deleted |
| `target-conflict` | 409 | The same bot already has this `targetId`; choose another alias |
| `invalid-target` | 422 | The target type or native ID violates this channel's rules; select the correct type and verify the ID |
| `bot-not-connected` | 503 | The bot is offline; let the caller decide whether to retry after reconnection |
| `target-rejected` | 422 | The platform explicitly rejected the target or the bot lacks permission; check platform permissions and the target ID |
| `delivery-failed` | 502 | A network, platform, or other safely redacted delivery failure; check bot state and Host logs |
| `cancelled` | 408 | The call was cancelled; stop or start a new call as required by the application |
The HTTP protocol layer may also return `method-not-allowed` (405), `unsupported-media-type` (415), or `payload-too-large` (413).
## Delivery semantics and limits
- Proactive delivery currently accepts nonempty text only. This API does not send images, files, cards, or rich content.
- The maximum HTTP JSON request body is 1 MiB.
- `{ sent: true }` means the platform accepted the send request or its SDK returned success. It does not guarantee final delivery or a read receipt.
- DSH-IM stores no proactive-delivery history, generates no `deliveryHandle` or `idempotencyKey`, and performs no automatic retry.
- Retrying after a caller timeout can create duplicate messages. When business idempotency matters, the caller must store its own event ID and processing result.
@ -244,7 +276,17 @@ Payloads are validated with exact fields. The inner `target` in `target.update`
- One call sends to one target. Notify multiple targets with separate calls and handle each result separately.
- Targets remain editable while a bot is offline, but testing and delivery require a connected bot.
## RPC reachability
## HTTP and RPC reachability
The HTTP endpoint is registered only when the current Host provides a WebServer, and it uses that server's existing listen address and port. A Web profile normally defaults to `127.0.0.1:3080`, which is reachable only from the same machine. To call it from another machine, bind the WebServer to a reachable address in that profile's `cordis.patch.yml`, then restart the Host. For example:
```yaml
- id: webserver
config:
host: '0.0.0.0'
```
This also expands network reachability for the other pages and routes on that WebServer. Because the proactive-delivery HTTP endpoint currently has no authentication, use it only with a trusted LAN, firewall, or reverse proxy, and never expose it directly to the public internet.
Connection RPC accepts loopback callers by default. If a Web profile is deliberately served on a trusted LAN, it can reuse the existing Host authority in that profile's `cordis.patch.yml`:

View file

@ -15,7 +15,7 @@
5. 填写或确认 `Target ID`、目标类型和平台原生 ID。
6. 点击「测试」。目标收到 `DSH-IM 主动投递测试成功。` 后,再点击「保存目标」。
7. 在已保存目标上点击「复制调用参数」,得到可供应用保存的 `{ botId, targetId }`。
8. 使用同 Host 的 `ctx.dshIm.send()` 或 Connection RPC 的 `message.send` 发送消息。
8. 使用 HTTP POST、同 Host 的 `ctx.dshIm.send()` 或 Connection RPC 的 `message.send` 发送消息。
## 配置投递目标
@ -103,6 +103,33 @@
平台 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。
@ -142,7 +169,7 @@ const targets = await ctx.dshIm.listTargets(botId);
## 通过 Connection RPC 发送
Connection RPC 适合已经持有当前 DSH Host `connection` 客户端的本机调用方。主动投递不是 HTTP、REST 或 Webhook 接口,因此不能把以下示例直接改写成 `curl`。
Connection RPC 适合已经持有当前 DSH Host `connection` 客户端的调用方,也是设置页管理目标所使用的接口。普通外部应用优先使用上面的 HTTP POST。
先封装 RPC 成功与错误包络:
@ -222,21 +249,26 @@ async function sendDailyReport(connection, summary) {
## 错误处理
| 错误码 | 含义与处理建议 |
| --- | --- |
| `bad-request` | 请求结构、ID 格式或文字无效;检查字段名并移除额外字段 |
| `unknown-bot` | `botId` 不属于当前 Host;重新从机器人设置页复制 |
| `unknown-target` | 该机器人下不存在 `targetId`;检查是否复制错误或目标已被删除 |
| `target-conflict` | 同一机器人下已经存在相同 `targetId`;更换别名 |
| `invalid-target` | 目标类型或平台原生 ID 不符合当前渠道规则;重新选择类型并核对 ID |
| `bot-not-connected` | 机器人当前离线;恢复连接后由调用方决定是否重试 |
| `target-rejected` | 平台明确拒绝目标或机器人缺少发送权限;检查平台权限和目标 ID |
| `delivery-failed` | 网络、平台或其他无法安全细分的发送失败;检查连接状态和 Host 日志 |
| `cancelled` | 调用被取消;按业务需要结束或重新发起 |
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 和处理结果。
@ -244,7 +276,17 @@ async function sendDailyReport(connection, summary) {
- 一个调用只发送到一个目标。需要通知多个目标时,应分别调用并分别处理结果。
- 机器人离线时仍可编辑目标,但不能测试或主动发送。
## RPC 可达范围
## 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:

View file

@ -132,7 +132,7 @@ Use the proxy URL required by your network and restart the Host after changing i
### Proactive delivery
All nine IM channels can proactively send text through a stable `botId + targetId` pair. Bot settings support choosing a known conversation or entering a target manually, testing the current route before saving, and copying call parameters. Same-Host plugins and Connection RPC share the same target configuration.
All nine IM channels can proactively send text through a stable `botId + targetId` pair. Bot settings support choosing a known conversation or entering a target manually, testing the current route before saving, and copying call parameters. HTTP POST, same-Host plugins, and Connection RPC share the same target configuration and delivery core.
See the [Proactive Delivery Guide](PROACTIVE_DELIVERY.en.md) ([简体中文](PROACTIVE_DELIVERY.md)) for setup steps, native fields for all nine channels, complete call examples, management endpoints, error codes, and troubleshooting.

View file

@ -135,7 +135,7 @@ dsh web
### 主动投递
九个 IM 渠道都可以使用稳定的 `botId + targetId` 主动发送文字消息。机器人设置页支持从已聊会话选择或手工填写目标、保存前测试当前路由,以及复制调用参数;同 Host 插件和 Connection RPC 共用同一目标配置。
九个 IM 渠道都可以使用稳定的 `botId + targetId` 主动发送文字消息。机器人设置页支持从已聊会话选择或手工填写目标、保存前测试当前路由,以及复制调用参数;HTTP POST、同 Host 插件和 Connection RPC 共用同一目标配置与投递核心。
设置步骤、九渠道字段、完整调用示例、管理端点、错误码与排错说明请查看[《主动投递使用指南》](PROACTIVE_DELIVERY.md)([English](PROACTIVE_DELIVERY.en.md))。

View file

@ -6,14 +6,15 @@
## 1. 最终决定
两项需求一起实现,共用一套主动投递核心,只保留两个不同入口:
两项需求一起实现,共用一套主动投递核心,提供三个薄调用入口:
| 场景 | 调用入口 | 适用调用方 |
| --- | --- | --- |
| #65 | Host 进程内 Cordis 服务 `ctx.dshIm` | 与 dsh-im 运行在同一 Host 的 cron、提醒、看板等插件 |
| #84 | Connection RPC 通道 `/dsh-im-delivery` | Host 进程外的本机程序、编排器和管理页面 |
| #84 | `POST /api/dsh-im/delivery/messages` | 普通外部程序、自动化平台和编排器 |
| 内部管理 | Connection RPC 通道 `/dsh-im-delivery` | 机器人设置页和已有 Connection 客户端 |
两个入口都只使用 `botId + targetId` 定位投递位置,再加本次要发送的 `text`。它们必须调用同一个 `DeliveryService.send()`,不能各自解析路由、维护目标或连接渠道。
三个入口都只使用 `botId + targetId` 定位投递位置,再加本次要发送的 `text`。它们必须调用同一个 `DeliveryService.send()`,不能各自解析路由、维护目标或连接渠道。
```text
路由地址 = botId + targetId
@ -79,7 +80,7 @@
6. 设置页展示可复制的真实 `botId`,并提供目标的新建、编辑、删除和复制调用参数;每个已保存的 `targetId` 都有自己的测试按钮。
7. 目标配置不依赖机器人在线;实际发送和测试要求机器人当前已连接。
8. 设置页优先列出该机器人已持久化的聊天会话候选;用户选择后自动填入渠道原生路由,再确认稳定的 `targetId`。无候选或需要其他地址时,仍可手动填写并查看字段格式提示。
9. 严格校验 RPC 端点、字段和渠道路由,不把平台原始错误、凭据或临时回复上下文返回给调用方。
9. 严格校验 HTTP、RPC 端点、字段和渠道路由,不把平台原始错误、凭据或临时回复上下文返回给调用方。
10. 保持所有现有入站回复、连接检查和文件发送行为不变。
### 3.2 本期明确不做
@ -91,7 +92,7 @@
- 不支持图片、文件、卡片、Markdown 类型选择;首期公共能力只有文字,渠道内部继续复用现有文字分段逻辑。
- 不实现定时任务、消息队列、离线补发、自动重试、回执查询、已读状态或发送历史。
- 不接收或生成 `idempotencyKey`;调用方重试造成的重复消息由调用方负责。
- 不新增 API Key、签名或用户权限模型。本期只沿用现有 Connection RPC 的 `loopback` / `trusted-host` 可达性边界;该边界不是业务鉴权。
- 不新增 API Key、签名或用户权限模型。HTTP 只复用现有 WebServer 的监听地址,RPC 继续沿用 `loopback` / `trusted-host` 可达性边界;两者都不是业务鉴权,不能直接暴露到公网。
- 不为 AI Office 增加主动投递。本文“九渠道”不包含实验性的 AI Office。
- 不保证平台原生目标永久有效;被平台删除、机器人无权限或用户屏蔽后,发送应明确失败。
@ -130,7 +131,9 @@ Bot(现有机器人)
```mermaid
flowchart LR
A[同 Host Cordis 插件<br/>Issue #65] -->|ctx.dshIm.send| C[DeliveryService]
B[进程外调用方<br/>Issue #84] -->|Connection RPC| R[Delivery RPC]
B[普通外部调用方<br/>Issue #84] -->|HTTP POST| H[Delivery HTTP]
H --> C
E[已有 Connection 客户端] -->|message.send| R[Delivery RPC]
U[机器人设置页] -->|候选 / 目标管理 / 测试| R
R --> C
C -->|按 botId 选择| G[九渠道 Adapter Registry]
@ -146,14 +149,15 @@ flowchart LR
| 渠道适配器 | 判断是否拥有机器人、把本渠道持久化 conversation key 解析为候选、校验 `kind/route`、调用对应核心 controller | 不暴露 RPC,不管理调用方业务 |
| `BotWorkspaceStore` | 持久化机器人下的已保存目标,复用现有原子写入、机器人队列和删除清理 | 不发送消息,不把候选自动保存为目标 |
| Cordis 服务 | 把 #65 调用转发给 `DeliveryService` | 不复制渠道选择逻辑 |
| Delivery RPC | 把 #84 和设置页请求转发给 `DeliveryService`,转换安全错误包络 | 不直接调用 Runtime |
| Delivery HTTP | 把 #84 的普通 JSON POST 转发给 `DeliveryService`,映射安全 HTTP 状态和错误 | 不直接调用 Runtime,不另开端口 |
| Delivery RPC | 把设置页和已有 Connection 客户端请求转发给 `DeliveryService`,转换安全错误包络 | 不直接调用 Runtime |
| 设置页 | 展示 `botId`、让用户从已聊候选选择或手动编辑渠道路由、调用测试 | 不解析 conversation key,不读取聊天正文,不查询平台全量目录 |
### 5.1 为什么使用单独 RPC 通道
### 5.1 为什么 HTTP 和 RPC 都保留
现有 `/dsh-im` 通道只承载更新功能,并固定为 `loopback`。如果把主动投递直接塞进该通道,想让 #84 使用现有 `trusted-host` 配置时会同时改变更新接口的暴露边界。
因此新增 `/dsh-im-delivery`,但继续复用现有 `ctx.connection.rpc.handle/call`、统一结果包络、取消信号和 `resolveRpcAuthority()`,不新增 HTTP Server,也不改动 `/dsh-im` 更新接口。
因此设置页和已有 Connection 客户端继续使用 `/dsh-im-delivery`;普通外部应用使用 `POST /api/dsh-im/delivery/messages`。HTTP 路由通过现有 `ctx.webServer.register()` 注册,不新增 HTTP Server 或端口,并直接复用 RPC 的严格 payload 校验与同一个 `DeliveryService`。两者都不改动 `/dsh-im` 更新接口。
## 6. 目标持久化
@ -316,16 +320,35 @@ await ctx.dshIm.send(
- Cordis 服务和 RPC 持有的是同一个 `DeliveryService` 实例;测试必须证明两者不是两套 registry。
- Host/渠道关闭时用现有 `ctx.effect()` 注销服务、RPC 和渠道适配器,不留下失效 Runtime 引用。
## 9. #84:进程外 Connection RPC
## 9. #84:HTTP POST 与管理 RPC
### 9.1 通道和端点
### 9.1 普通外部调用接口
新增通道常量:
HTTP 只提供一个发送端点:
```js
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
```http
POST /api/dsh-im/delivery/messages
Content-Type: application/json
{
"botId": "bot_7f4c1234",
"targetId": "daily-report",
"text": "流水线正在等待审批。"
}
```
成功返回 HTTP 200:
```json
{ "sent": true }
```
HTTP handler 通过现有 `ctx.webServer.register()` 注册 exact route,不新增 Server 或端口。它调用 `createDeliveryRpcHandler(service)` 的 `message.send` 分支,复用严格字段校验、安全错误码和同一个 `DeliveryService.send()`。请求 JSON 上限为 1 MiB;当前不实现鉴权、CORS、目标 CRUD、队列或幂等。
### 9.2 Connection RPC 管理端点
设置页和已有 Connection 客户端继续使用通道 `/dsh-im-delivery`:
| endpoint | payload | 成功 value |
| --- | --- | --- |
| `message.send` | `{ botId, targetId, text }` | `{ sent: true }` |
@ -336,41 +359,17 @@ export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
| `target.delete` | `{ botId, targetId }` | `{ deleted: true }` |
| `target.test` | 已保存目标:`{ botId, targetId }`;表单草稿:`{ botId, target: { kind, route } }` | `{ sent: true }` |
`target.test` 使用固定本地化文案“DSH-IM 主动投递测试成功。”。已保存目标行传 `{ botId, targetId }`,由同一个 `DeliveryService.send()` 解析已保存路由;新建或编辑表单传 `{ botId, target: { kind, route } }`,直接校验并测试当前表单路由。两种 payload 严格二选一,表单测试不携带 `targetId/name`,也不创建、更新或落盘目标。它与现有“检查连接”是两个功能:检查连接验证机器人连接,目标测试验证指定渠道路由能否真正发送消息。
`target.test` 使用固定文案“DSH-IM 主动投递测试成功。”。表单草稿测试不携带 `targetId/name`,也不创建、更新或落盘目标。`target.suggestion.list` 只返回从持久 conversation keys 解析的 `kind/route`,不返回 `sessionId`、聊天正文或临时回复对象。
`target.suggestion.list` 是设置页专用的候选查询:渠道适配器从该机器人的持久化 conversation keys 解析出可主动投递的 `kind/route`。每个 suggestion 严格只含这两个字段,不含 `targetId`、`name`、时间、`sessionId`、聊天正文、消息 ID 或回复对象。选择 suggestion 只是预填新建表单,不会自动创建目标。
RPC 结果继续使用 `{ ok: true, value }` / `{ ok: false, error }` 包络;HTTP 则把成功 value 解包为 `{ sent: true }`,并把公共错误码映射为 4xx/5xx。
### 9.2 调用示例
### 9.3 可达性边界
```js
const result = await connection.rpc.call(
'/dsh-im-delivery',
'message.send',
{
botId: 'bot_7f4c...',
targetId: 'daily-report',
text: '流水线正在等待审批。',
},
signal,
);
```
结果继续使用仓库已有包络:
```json
{ "ok": true, "value": { "sent": true } }
```
调用方只需要长期保存 `botId` 和 `targetId`。没有 `deliveryHandle`、`sessionId`、`chatRef` 或 `idempotencyKey`。
### 9.3 RPC 边界
- 默认 `authority: 'loopback'`,满足同机进程外调用和管理页面。
- 顶层已有 `rpcAuthority: 'trusted-host'` 配置时沿用现有解析机制;不新增第二个同义配置。
- `target.*` 和 `message.send` 使用相同可达性边界。本期不进一步区分管理员和发送者权限。
- 每个端点只接受表中列出的键;缺字段、额外字段、数组冒充对象或已取消请求均拒绝。
- `target.suggestion.list` 不要求机器人当前在线;未知机器人仍返回 `unknown-bot`,无候选时成功返回空数组。
- 该 RPC 是 #84 的对外程序接口;不再另建 Express 路由、REST Server 或 Webhook 接收器。
- HTTP 仅在当前 Host 存在 WebServer 时注册,并复用其 host/port;默认回环地址只能本机调用。
- HTTP 当前没有鉴权,只能在本机、可信局域网、防火墙或反向代理之后使用,不能直接暴露公网。
- RPC 默认 `authority: 'loopback'`;顶层 `rpcAuthority: 'trusted-host'` 时沿用现有解析机制。
- 每个端点只接受列出的键;缺字段、额外字段、数组冒充对象或已取消请求均拒绝。
- `target.suggestion.list` 不要求机器人在线;未知机器人返回 `unknown-bot`,无候选时成功返回空数组。
## 10. 九渠道适配
@ -672,7 +671,7 @@ await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
本次按下表执行最小真实验收。每个渠道只选一个当前可用机器人和一个目标,并对同一组 `botId + targetId` 分别发送两条容易区分的消息:
- #65:同 Host 测试插件调用 `ctx.dshIm.send()`,消息带 `#65` 标识。
- #84:进程外测试调用 `/dsh-im-delivery` 的 `message.send`,消息带 `#84` 标识。
- #84:进程外测试调用 `POST /api/dsh-im/delivery/messages`,消息带 `#84 HTTP` 标识。
| 渠道 | 机器人和目标 | #65 | #84 | 目标测试 |
| --- | --- | --- | --- | --- |
@ -686,7 +685,7 @@ await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
| Discord | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
| WhatsApp | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
每个入口和目标测试各验证一次成功发送,不扩展到该渠道的所有目标类型,也不增加重启、并发或故障注入。验收记录只保存渠道、脱敏 `botId/targetId`、两个入口的时间和结果,不保存凭据或完整原生用户 ID。
每个要求验收的入口各验证一次成功发送,不扩展到该渠道的所有目标类型,也不增加并发或故障注入。验收记录只保存渠道、脱敏 `botId/targetId`、入口、时间和结果,不保存凭据或完整原生用户 ID。
### 13.8 当前实施验证记录(2026-08-30)
@ -695,10 +694,11 @@ await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
- 真实宿主页面共加载 19 张现有机器人卡片,九个渠道的机器人卡片数量与齿轮按钮数量完全一致;九个设置页均能显示 Bot ID、新建表单和对应渠道的原生路由字段,浏览器控制台无错误。
- 已增加九渠道表驱动候选测试,固定 conversation key 到 `kind/route` 的映射、去重、畸形 key 过滤和临时字段隔离;RPC 和客户端测试另覆盖 `target.suggestion.list`、选择预填、已添加禁用及手动高级兜底。
- 在最新版 DSH 的真实设置页面逐渠道调用 `target.suggestion.list`,九个渠道均读取到至少一个已有会话候选;QQ 在选择存在历史会话的机器人后同样读取成功。点选候选可自动预填目标类型、原生路由和未占用的随机调用别名;验证过程未保存草稿、未发送消息,浏览器控制台无错误。
- 经使用者明确授权,从九渠道已有真实私聊/自聊的持久化入站路由中提取平台原生地址,为每个渠道保存一个机器人级 `self` 目标;只把平台地址写入 `route`,没有把 `sessionId` 当作投递地址。九份 `workspaces.json` 均由 v1 正常迁移为 v2,并可通过 `target.list` 回读。
- #84 进程外调用 `/dsh-im-delivery` 的 `message.send`,九渠道各真实发送一次,9/9 成功。
- 经使用者明确授权,从九渠道已有真实私聊/自聊的持久化入站路由中提取平台原生地址,为每个渠道保存一个机器人级投递目标;只把平台地址写入 `route`,没有把 `sessionId` 当作投递地址。九份 `workspaces.json` 均由 v1 正常迁移为 v2,并可通过 `target.list` 回读。
- 在增加普通 HTTP 接口前,曾通过 `/dsh-im-delivery` 的 `message.send` 对九渠道各真实发送一次,9/9 成功;该记录保留为 Connection RPC 回归证据。
- 首次真实 #65 验证发现 `dshIm` 在最新版 DSH 的现代依赖注入组合中被提供在过窄作用域。实现已改为在 Host 插件根上下文提供服务,再把同一个 `DeliveryService` 传入延迟激活的渠道;现代 Cordis 注入回归测试已固定该行为。
- Host 重启后,一次性同 Host Cordis 插件仅注入 `dshIm` 并调用 `ctx.dshIm.send()`,九渠道各真实发送一次,9/9 成功;测试插件没有注入或调用 Connection RPC。
- 使用进程外脚本依次调用 `POST /api/dsh-im/delivery/messages`,从本机已有配置自动选择九渠道各一个在线机器人和私聊目标;九次请求均只提交 `botId + targetId + text`,全部返回 HTTP 200 与 `{ "sent": true }`,9/9 成功且没有自动重试。
- 每个已保存目标又通过测试按钮所调用的 `target.test` 端点真实发送一次,九渠道 9/9 成功。WhatsApp 测试前发生一次平台连接离线,使用现有 `bot.reconnect` 恢复后,同一 `botId + targetId` 无需修改即测试成功。
- 验收记录只保留渠道和结果,不记录完整 `botId`、平台原生地址、凭据或会话 ID。
@ -708,9 +708,9 @@ await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
1. 同 Host 插件可以注入 `dshIm`,使用 `send(botId, targetId, text)` 完成投递。
2. #65 自动化测试通过真实 Cordis `inject: ['dshIm']` 激活消费插件,成功发送期间 `connection.rpc.call` 为 0 次。
3. 进程外调用方可以通过 `/dsh-im-delivery` 的 `message.send` 使用同一组参数完成投递。
4. 自动化测试证明两个入口调用同一个 `DeliveryService`,不存在两套路由解析或渠道连接。
5. 九渠道各选择一个可用机器人和目标,#65 `ctx.dshIm.send()` 与 #84 `message.send` 均真实发送成功一次。
3. 普通进程外调用方可以通过 `POST /api/dsh-im/delivery/messages` 使用同一组参数完成投递。
4. 自动化测试证明 HTTP、Cordis 和 Connection RPC 三个入口调用同一个 `DeliveryService`,不存在多套路由解析或渠道连接。
5. 九渠道各选择一个可用机器人和私聊目标,#84 HTTP POST 均真实发送成功一次;已有 #65 与 Connection RPC 验收记录继续作为回归证据。
6. Host 重启后同一 `botId + targetId` 仍可使用;编辑渠道路由后公共 pair 不变且下一次发送走新路由。
7. 公共发送接口没有 `sessionId`、`chatRef`、`sessionWebhook`、`deliveryHandle` 或 `idempotencyKey`。
8. 钉钉主动投递只使用稳定用户/群接口;临时 `sessionWebhook` 仅保留在原即时回复链。
@ -721,7 +721,7 @@ await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
13. 机器人离线时仍可管理目标并读取已持久化的候选;发送返回明确的 `bot-not-connected`,不建立隐式队列。
14. 删除机器人会清理其全部目标;删除失败回滚不会丢失目标。
15. dsh-im 不落主动发送历史、不自动重试、不管理幂等状态。
16. `npm run check` 全部通过,九渠道现有接入、回复和连接检查回归通过。
16. `npm run check` 全部通过,HTTP 协议测试和九渠道现有接入、回复、主动投递与连接检查回归通过。
## 15. 风险与控制
@ -731,7 +731,7 @@ await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
| 候选被误解为平台全量最近聊天 | 文案明确仅来自持久化 conversation keys,不显示伪造的名称/时间,缺少时使用手动兜底 |
| 平台目标以后失效 | 保持 `targetId` 不变,用户只更新内部 route |
| 配置文件升级损坏原设置 | v1→v2 迁移、原子写入、失败回滚和迁移测试 |
| #65 与 #84 行为逐渐分叉 | 两个入口只做协议转换,测试直接断言同一 service 实例 |
| #65 与 #84 行为逐渐分叉 | 三个入口只做协议转换,测试直接断言同一 service 实例 |
| 九渠道复制实现 | 目标 Store、Service、RPC 和设置页共享;渠道层只保留路由校验和薄发送委托 |
| 钉钉误用临时 Webhook | 主动发送 API 不接受该字段,并以负向测试固定 |
| 对外 RPC 被误认为已有完整鉴权 | 文档明确本期只复用可达性边界;真正远程开放前另行设计鉴权 |

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,132 @@
import { createDeliveryRpcHandler, DELIVERY_ENDPOINTS } from './delivery-rpc.mjs';
export const DELIVERY_HTTP_PATH = '/api/dsh-im/delivery/messages';
const MAX_BODY_BYTES = 1024 * 1024;
const DELIVERY_ERROR_STATUS = Object.freeze({
'bad-request': 400,
'unknown-bot': 404,
'unknown-target': 404,
'target-conflict': 409,
'invalid-target': 422,
'bot-not-connected': 503,
'target-rejected': 422,
'delivery-failed': 502,
cancelled: 408,
});
class HttpRequestError extends Error {
constructor(status, code) {
super(code);
this.status = status;
this.code = code;
}
}
function json(response, status, value, headers = {}) {
if (response.destroyed || response.writableEnded) return;
const body = JSON.stringify(value);
response.writeHead(status, {
'cache-control': 'no-store',
'content-type': 'application/json; charset=utf-8',
'content-length': String(Buffer.byteLength(body)),
...headers,
});
response.end(body);
}
function isJsonContentType(value) {
return typeof value === 'string'
&& value.split(';', 1)[0].trim().toLowerCase() === 'application/json';
}
async function readJsonBody(request) {
const declaredLength = Number(request.headers['content-length']);
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
throw new HttpRequestError(413, 'payload-too-large');
}
const chunks = [];
let length = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
length += buffer.length;
if (length > MAX_BODY_BYTES) {
throw new HttpRequestError(413, 'payload-too-large');
}
chunks.push(buffer);
}
try {
return JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
} catch {
throw new HttpRequestError(400, 'bad-request');
}
}
export function createDeliveryHttpHandler(service) {
const dispatch = createDeliveryRpcHandler(service);
return async (request, response) => {
if (request.method !== 'POST') {
json(response, 405, {
error: { code: 'method-not-allowed', message: 'method-not-allowed', details: {} },
}, { allow: 'POST' });
return;
}
if (!isJsonContentType(request.headers['content-type'])) {
json(response, 415, {
error: { code: 'unsupported-media-type', message: 'unsupported-media-type', details: {} },
});
return;
}
const abort = new AbortController();
const cancel = () => abort.abort();
const cancelClosedResponse = () => {
if (!response.writableEnded) cancel();
};
request.once('aborted', cancel);
response.once('close', cancelClosedResponse);
try {
const payload = await readJsonBody(request);
const result = await dispatch(DELIVERY_ENDPOINTS.send, payload, abort.signal);
if (result.ok) {
json(response, 200, result.value);
return;
}
json(response, DELIVERY_ERROR_STATUS[result.error.code] ?? 500, {
error: result.error,
});
} catch (error) {
if (error instanceof HttpRequestError) {
json(response, error.status, {
error: { code: error.code, message: error.code, details: {} },
});
return;
}
json(response, 500, {
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
});
} finally {
request.off('aborted', cancel);
response.off('close', cancelClosedResponse);
}
};
}
export function installDeliveryHttp(ctx, service) {
if (!ctx?.webServer || typeof ctx.webServer.register !== 'function'
|| typeof ctx.effect !== 'function') {
throw new TypeError('DSH Host WebServer is required');
}
const route = {
kind: 'exact',
path: DELIVERY_HTTP_PATH,
handler: createDeliveryHttpHandler(service),
};
return ctx.effect(
() => ctx.webServer.register(route),
`dsh-im: ${DELIVERY_HTTP_PATH}`,
);
}

View file

@ -11,6 +11,7 @@ import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
import { installDeliveryRpc } from './delivery-rpc.mjs';
import { installDeliveryHttp } from './delivery-http.mjs';
import { createDeliveryService } from './delivery-service.mjs';
import { installUpdateRpc } from './update-rpc.mjs';
@ -32,6 +33,7 @@ function channelConfig(config, name, deliveryService) {
export function createImHostPlugin(internals = {}) {
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
const startFeishu = internals.applyFeishu ?? applyFeishu;
const startWeixin = internals.applyWeixin ?? applyWeixin;
@ -77,9 +79,15 @@ export function createImHostPlugin(internals = {}) {
modern ? ['sessionController', 'workspaceController'] : ['apiProxy'],
activate,
);
ctx.inject(['webServer'], (httpCtx) => {
startDeliveryHttp(httpCtx, deliveryService);
});
return;
}
await activate(ctx);
if (ctx?.webServer?.register && typeof ctx?.effect === 'function') {
startDeliveryHttp(ctx, deliveryService);
}
},
});

180
test/delivery-http.test.mjs Normal file
View file

@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer } from 'node:http';
import test from 'node:test';
import {
DELIVERY_HTTP_PATH,
createDeliveryHttpHandler,
installDeliveryHttp,
} from '../plugin-src/host/delivery-http.mjs';
function serviceFixture() {
const calls = [];
const service = {};
for (const method of [
'send',
'listTargets',
'listSuggestions',
'createTarget',
'updateTarget',
'deleteTarget',
]) {
service[method] = async (...args) => {
calls.push([method, ...args]);
return method === 'send' ? { sent: true } : { method };
};
}
return { service, calls };
}
async function withServer(handler, callback) {
const server = createServer(handler);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const address = server.address();
await callback(`http://127.0.0.1:${address.port}${DELIVERY_HTTP_PATH}`);
} finally {
server.close();
await once(server, 'close');
}
}
async function request(url, { method = 'POST', body, contentType = 'application/json' } = {}) {
const response = await fetch(url, {
method,
headers: contentType === undefined ? {} : { 'content-type': contentType },
body,
});
return {
status: response.status,
allow: response.headers.get('allow'),
body: await response.json(),
};
}
test('delivery HTTP POST forwards the exact public payload to the shared service', async () => {
const { service, calls } = serviceFixture();
await withServer(createDeliveryHttpHandler(service), async (url) => {
const result = await request(url, {
contentType: 'application/json; charset=utf-8',
body: JSON.stringify({
botId: 'bot_one',
targetId: 'daily-report',
text: '测试消息',
}),
});
assert.deepEqual(result, {
status: 200,
allow: null,
body: { sent: true },
});
});
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'send');
assert.deepEqual(calls[0].slice(1, 4), ['bot_one', 'daily-report', '测试消息']);
});
test('delivery HTTP rejects unsupported methods, media types, JSON, fields, and oversized bodies', async () => {
const { service, calls } = serviceFixture();
await withServer(createDeliveryHttpHandler(service), async (url) => {
const method = await request(url, { method: 'GET' });
assert.equal(method.status, 405);
assert.equal(method.allow, 'POST');
assert.equal(method.body.error.code, 'method-not-allowed');
const media = await request(url, { contentType: 'text/plain', body: '{}' });
assert.equal(media.status, 415);
assert.equal(media.body.error.code, 'unsupported-media-type');
const malformed = await request(url, { body: '{' });
assert.equal(malformed.status, 400);
assert.equal(malformed.body.error.code, 'bad-request');
const extra = await request(url, {
body: JSON.stringify({
botId: 'bot_one', targetId: 'target', text: 'hello', sessionId: 'unstable',
}),
});
assert.equal(extra.status, 400);
assert.equal(extra.body.error.code, 'bad-request');
const oversized = await request(url, {
body: JSON.stringify({
botId: 'bot_one', targetId: 'target', text: 'x'.repeat(1024 * 1024),
}),
});
assert.equal(oversized.status, 413);
assert.equal(oversized.body.error.code, 'payload-too-large');
});
assert.deepEqual(calls, []);
});
test('delivery HTTP maps only stable delivery errors to HTTP status codes', async () => {
const expected = new Map([
['bad-request', 400],
['unknown-bot', 404],
['unknown-target', 404],
['target-conflict', 409],
['invalid-target', 422],
['bot-not-connected', 503],
['target-rejected', 422],
['delivery-failed', 502],
['cancelled', 408],
]);
const { service } = serviceFixture();
let code = 'delivery-failed';
service.send = async () => {
const error = new Error(`private detail for ${code}`);
error.code = code;
throw error;
};
await withServer(createDeliveryHttpHandler(service), async (url) => {
for (const [candidate, status] of expected) {
code = candidate;
const result = await request(url, {
body: JSON.stringify({ botId: 'bot_one', targetId: 'target', text: 'hello' }),
});
assert.equal(result.status, status);
assert.deepEqual(result.body, {
error: { code: candidate, message: candidate, details: {} },
});
}
code = 'private-internal-error';
const hidden = await request(url, {
body: JSON.stringify({ botId: 'bot_one', targetId: 'target', text: 'hello' }),
});
assert.equal(hidden.status, 502);
assert.deepEqual(hidden.body, {
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
});
});
});
test('delivery HTTP installs one exact WebServer route with Cordis lifecycle ownership', () => {
const { service } = serviceFixture();
const registrations = [];
const effects = [];
const dispose = () => {};
const ctx = {
webServer: {
register(route) {
registrations.push(route);
return dispose;
},
},
effect(factory, label) {
effects.push(label);
return factory();
},
};
assert.equal(installDeliveryHttp(ctx, service), dispose);
assert.deepEqual(effects, [`dsh-im: ${DELIVERY_HTTP_PATH}`]);
assert.equal(registrations.length, 1);
assert.equal(registrations[0].kind, 'exact');
assert.equal(registrations[0].path, DELIVERY_HTTP_PATH);
assert.equal(typeof registrations[0].handler, 'function');
});

View file

@ -68,6 +68,7 @@ test('Host provides #65 and installs #84 with the same delivery service', async
};
const provided = [];
const rpc = [];
const http = [];
const channelServices = [];
const internals = Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
applyName,
@ -79,9 +80,12 @@ test('Host provides #65 and installs #84 with the same delivery service', async
createDeliveryService: () => deliveryService,
installUpdateRpc: () => {},
installDeliveryRpc: (...args) => rpc.push(args),
installDeliveryHttp: (...args) => http.push(args),
});
const ctx = {
connection: { rpc: {} },
webServer: { register() {} },
effect() {},
provide: (...args) => provided.push(args),
};
@ -90,6 +94,7 @@ test('Host provides #65 and installs #84 with the same delivery service', async
assert.equal(provided[0][0], 'dshIm');
assert.equal(rpc[0][1], deliveryService);
assert.deepEqual(rpc[0][2], { authority: 'trusted-host' });
assert.equal(http[0][1], deliveryService);
assert.ok(channelServices.every((service) => service === deliveryService));
assert.deepEqual(await provided[0][1].listTargets('bot_one'), [{ targetId: 'target' }]);
assert.deepEqual(await provided[0][1].send('bot_one', 'target', 'hello'), { sent: true });
@ -164,7 +169,7 @@ test('Host waits for apiProxy on legacy Harness and Controllers on modern Harnes
typertGateway: modern ? { stream() {} } : { invoke() {} },
inject(dependencies, callback) {
injections.push(dependencies);
if (dependencies.includes('tools')) return {};
if (dependencies.includes('tools') || dependencies.includes('webServer')) return {};
return {
then(resolve, reject) {
Promise.resolve(callback(ctx)).then(resolve, reject);