mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat: add proactive delivery HTTP endpoint
This commit is contained in:
parent
31792d7bc8
commit
b90cb805c7
11 changed files with 703 additions and 294 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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))。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 被误认为已有完整鉴权 | 文档明确本期只复用可达性边界;真正远程开放前另行设计鉴权 |
|
||||
|
|
|
|||
408
lib/index.js
408
lib/index.js
File diff suppressed because one or more lines are too long
132
plugin-src/host/delivery-http.mjs
Normal file
132
plugin-src/host/delivery-http.mjs
Normal 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}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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
180
test/delivery-http.test.mjs
Normal 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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue