mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
feat: add stable proactive delivery
This commit is contained in:
parent
410cb822d7
commit
31792d7bc8
88 changed files with 6427 additions and 488 deletions
|
|
@ -6,6 +6,14 @@ This file records the notable changes in each dsh-im release. Its format follows
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.
|
||||
|
||||
- 新增中英文主动投递使用指南,覆盖设置流程、九渠道字段、同 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.
|
||||
|
||||
## [4.0.1] - 2026-08-30
|
||||
|
||||
### Fixed / 修复
|
||||
|
|
|
|||
279
PROACTIVE_DELIVERY.en.md
Normal file
279
PROACTIVE_DELIVERY.en.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# Proactive Delivery Guide
|
||||
|
||||
[简体中文](PROACTIVE_DELIVERY.md) · **English**
|
||||
|
||||
Proactive delivery lets an application send a text message through a bot connected to DSH-IM without waiting for a new user message. The caller stores only a stable `botId + targetId` pair—never a Harness `sessionId`, chat reference, message ID, or temporary webhook.
|
||||
|
||||
All nine built-in channels support proactive delivery: Weixin, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Open **Settings → IM Bot** and find the bot that should send the message.
|
||||
2. Select the gear icon in the bot card's upper-right corner.
|
||||
3. Copy the **Bot ID** under **Call identifiers**.
|
||||
4. Select **New target**, then choose a known conversation or select **Enter manually (advanced)** and enter the platform-native ID.
|
||||
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.
|
||||
|
||||
## Configure a delivery target
|
||||
|
||||
### 1. Get the Bot ID
|
||||
|
||||
`botId` is the real call identifier of the currently connected bot. Copy it from the settings page and treat it as an opaque string. Do not infer the channel from its prefix, and do not substitute the bot name, a platform App ID, or a masked ID from a card.
|
||||
|
||||
Targets belong to this bot record. Removing a bot also removes its delivery targets. After connecting it again, copy its `botId` again and recreate the required targets.
|
||||
|
||||
### 2. Create a target
|
||||
|
||||
After selecting **New target**, the page opens a **Choose from conversations** dropdown:
|
||||
|
||||
- Suggestions come from conversation mappings already persisted for this bot. They are neither a platform address book nor a complete, strictly time-ordered recent-chat list.
|
||||
- A suggestion contains only the target type and native platform ID required for delivery. It does not contain a Harness `sessionId`, message text, message ID, conversation name, or last-active time.
|
||||
- A target already configured in the dropdown is marked **Added** and disabled.
|
||||
- When the dropdown is empty, send the bot a message on that platform and select **Refresh**. If it still does not appear, use **Enter manually (advanced)**.
|
||||
|
||||
Choosing a conversation only pre-fills a draft. It does not save anything automatically.
|
||||
|
||||
### 3. Understand Target ID
|
||||
|
||||
`targetId` is the stable alias you define for callers. It is not a platform user, group, or channel ID.
|
||||
|
||||
- It must be unique only within one bot. Different bots may use the same `targetId`.
|
||||
- It may contain uppercase and lowercase letters, numbers, dots, underscores, colons, `@`, or hyphens, with a length of 1–128 characters.
|
||||
- New targets default to `tgt_` plus 16 random hexadecimal characters, such as `tgt_7f3a91c8d2e64b10`.
|
||||
- You may change it before the first save—for example, to `daily-report` or `release-alerts`.
|
||||
- After saving, `targetId` is read-only. You may still edit the name, target type, and native route while callers keep using the same `botId + targetId` pair.
|
||||
|
||||
### 4. Test, save, and copy
|
||||
|
||||
The **Test** button appears in conversation-filled drafts, manually entered drafts, and edit forms:
|
||||
|
||||
- It is enabled only while the bot is online and every native ID required by the current target type is present.
|
||||
- It tests the target type and native ID currently shown in the form. It does not create, update, or save the target first.
|
||||
- Changing a tested native ID clears the old success result; test the new value again.
|
||||
- The **Test** button on a saved target row tests that target's currently saved route.
|
||||
- A successful test means the platform accepted the send request or its SDK returned success. It does not mean the message was read.
|
||||
|
||||
After saving, **Copy call parameters** copies JSON in this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"botId": "bot_9577c8572d454122a4ef7fb4d8420a91",
|
||||
"targetId": "release-alerts"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration example: a Feishu alert group
|
||||
|
||||
Assume the Feishu bot has already received a message in the alert group:
|
||||
|
||||
1. Open that bot's settings page and copy its **Bot ID**.
|
||||
2. Select **New target**, then choose the alert group from the dropdown.
|
||||
3. Change the generated **Target ID** to `release-alerts` and set the display name to `Release alerts`.
|
||||
4. Confirm that the target type is **Group** and the group Chat ID is filled in.
|
||||
5. Select **Test** and confirm the test message in the Feishu group.
|
||||
6. Select **Save target**, then **Copy call parameters**.
|
||||
|
||||
If the group Chat ID is edited later, callers can continue using the same `botId + release-alerts` pair.
|
||||
|
||||
## Native fields for all nine channels
|
||||
|
||||
Choose a known conversation whenever possible. Obtain and enter a platform-native ID manually only when the target is missing from the suggestions.
|
||||
|
||||
| Channel | Target type | Required field | Example or note |
|
||||
| --- | --- | --- | --- |
|
||||
| Weixin | `user` | Weixin user ID (`toUserId`) | Enter the user ID that should receive messages |
|
||||
| Feishu | `user` | Open ID (`openId`) | For example, `ou_xxx` |
|
||||
| Feishu | `group` | Group Chat ID (`chatId`) | For example, `oc_xxx` |
|
||||
| DingTalk | `user` | User ID (`userId`) | Enter the DingTalk user ID |
|
||||
| DingTalk | `group` | Group Open Conversation ID (`openConversationId`) | Proactive delivery never uses a temporary `sessionWebhook` |
|
||||
| WeCom | `user` | User ID (route field: `chatId`) | Enter the user ID for a direct message |
|
||||
| WeCom | `group` | Group Chat ID (`chatId`) | Enter the group's `chatid` |
|
||||
| QQ | `user` | User Open ID (`userOpenId`) | The platform's `user_openid` |
|
||||
| QQ | `group` | Group Open ID (`groupOpenId`) | The platform's `group_openid` |
|
||||
| Slack | `conversation` | Channel ID (`channelId`) | For example, `C0123456789` |
|
||||
| Slack | `thread` | Channel ID + thread timestamp (`channelId`, `threadTs`) | For example, `1712345678.123456` |
|
||||
| Telegram | `chat` | Chat ID (`chatId`) | A decimal string, such as `-1001234567890` |
|
||||
| Telegram | `topic` | Chat ID + Topic ID (`chatId`, `messageThreadId`) | Topic ID must be a positive integer |
|
||||
| Discord | `channel` | Channel ID (`channelId`) | DMs, channels, and Threads all use a messageable Channel ID |
|
||||
| WhatsApp | `user` | User JID (`jid`) | For example, `8613800000000@s.whatsapp.net` |
|
||||
| WhatsApp | `group` | Group JID (`jid`) | For example, `1234567890-123456@g.us` |
|
||||
|
||||
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 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.
|
||||
|
||||
This minimal example sends one message when the plugin loads:
|
||||
|
||||
```js
|
||||
export const inject = ['dshIm'];
|
||||
|
||||
export async function apply(ctx) {
|
||||
const result = await ctx.dshIm.send(
|
||||
'bot_9577c8572d454122a4ef7fb4d8420a91',
|
||||
'release-alerts',
|
||||
'The build has completed.',
|
||||
);
|
||||
|
||||
if (result.sent !== true) {
|
||||
throw new Error('Proactive delivery did not return success');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In a real plugin, call `ctx.dshIm.send()` from your existing scheduled job, build callback, or business-event handler. Its optional fourth argument currently supports an abort signal:
|
||||
|
||||
```js
|
||||
await ctx.dshIm.send(botId, targetId, text, { signal });
|
||||
```
|
||||
|
||||
A same-Host plugin may also list the saved targets for one bot:
|
||||
|
||||
```js
|
||||
const targets = await ctx.dshIm.listTargets(botId);
|
||||
// [{ targetId, name?, kind, route }, ...]
|
||||
```
|
||||
|
||||
On failure, the Promise rejects with an Error whose `code` is one of the public error codes below.
|
||||
|
||||
## 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`.
|
||||
|
||||
First unwrap the RPC success and error envelopes:
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
Then send text with the copied `botId + targetId` pair:
|
||||
|
||||
```js
|
||||
const result = await callDelivery(connection, 'message.send', {
|
||||
botId: 'bot_9577c8572d454122a4ef7fb4d8420a91',
|
||||
targetId: 'release-alerts',
|
||||
text: 'The build has completed.',
|
||||
});
|
||||
|
||||
// result: { sent: true }
|
||||
```
|
||||
|
||||
`message.send` accepts exactly `{ botId, targetId, text }`. Do not add a native route, `sessionId`, `chatRef`, temporary webhook, or `idempotencyKey`.
|
||||
|
||||
### Example: deliver a daily report
|
||||
|
||||
```js
|
||||
async function sendDailyReport(connection, summary) {
|
||||
try {
|
||||
await callDelivery(connection, 'message.send', {
|
||||
botId: 'bot_9577c8572d454122a4ef7fb4d8420a91',
|
||||
targetId: 'daily-report',
|
||||
text: `Daily operations summary\n\n${summary}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'bot-not-connected') {
|
||||
// Let the application decide whether to retry after reconnection.
|
||||
return { delivered: false, reason: 'offline' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { delivered: true };
|
||||
}
|
||||
```
|
||||
|
||||
## Management RPC reference
|
||||
|
||||
The settings page manages targets through the same Connection RPC channel. Most callers need only `message.send`; use the other endpoints only when the caller must manage targets itself.
|
||||
|
||||
Every response is either `{ ok: true, value }` or `{ ok: false, error: { code, message, details } }`.
|
||||
|
||||
| Endpoint | Payload | Successful `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 } }` | The complete created target |
|
||||
| `target.update` | `{ botId, targetId, target: { name?, kind, route } }` | The complete updated target |
|
||||
| `target.delete` | `{ botId, targetId }` | `{ deleted: true }` |
|
||||
| `target.test` | `{ botId, targetId }` | `{ sent: true }` |
|
||||
| `target.test` | `{ botId, target: { kind, route } }` | `{ sent: true }`; tests a draft without saving it |
|
||||
|
||||
Payloads are validated with exact fields. The inner `target` in `target.update` must not contain `targetId`; a draft test must not contain `targetId` or `name`.
|
||||
|
||||
## 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 |
|
||||
|
||||
## Delivery semantics and limits
|
||||
|
||||
- Proactive delivery currently accepts nonempty text only. This API does not send images, files, cards, or rich content.
|
||||
- `{ 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.
|
||||
- Normal delivery uses only a saved `botId + targetId`. Keep the native route in target configuration instead of sending it with every message.
|
||||
- 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
|
||||
|
||||
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`:
|
||||
|
||||
```yaml
|
||||
- id: xmanrui-dsh-im
|
||||
config:
|
||||
rpcAuthority: trusted-host
|
||||
```
|
||||
|
||||
`trusted-host` is only a Host/Origin reachability boundary, not user authentication. Callers that can reach that trusted-network authority can also access bot-management endpoints. Enable it only on a trusted network.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The target conversation is missing from the dropdown
|
||||
|
||||
Send that bot a message on the platform, return to settings, and refresh. Suggestions are not a complete platform conversation directory. Use **Enter manually (advanced)** if the target still does not appear.
|
||||
|
||||
### The Test button is disabled
|
||||
|
||||
Make sure the bot is online and every native ID required by the current target type is present. A Slack Thread and a Telegram Topic both require two fields.
|
||||
|
||||
### Can Target ID be changed after saving?
|
||||
|
||||
No. You can edit its name, type, and native route without changing call parameters. If the alias itself must change, create a new target, migrate callers, and then delete the old target.
|
||||
|
||||
### Why not use sessionId?
|
||||
|
||||
A `sessionId` identifies a Harness Session. It is not a uniform, stable message address across the nine platforms. Proactive delivery uses the stable bot and saved-target pair instead.
|
||||
|
||||
### The test succeeded, but the recipient cannot see the message
|
||||
|
||||
A successful test proves only that the platform accepted the send. Check bot permissions, platform restrictions, target accuracy, and client-side filtering or archive settings.
|
||||
279
PROACTIVE_DELIVERY.md
Normal file
279
PROACTIVE_DELIVERY.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# 主动投递使用指南
|
||||
|
||||
**简体中文** · [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. 使用同 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 字符串不能为空或带首尾空格。一个目标只接受所选渠道和类型要求的字段,额外字段会被拒绝。
|
||||
|
||||
## 在同一 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、REST 或 Webhook 接口,因此不能把以下示例直接改写成 `curl`。
|
||||
|
||||
先封装 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`。
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 错误码 | 含义与处理建议 |
|
||||
| --- | --- |
|
||||
| `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` | 调用被取消;按业务需要结束或重新发起 |
|
||||
|
||||
## 投递语义与限制
|
||||
|
||||
- 当前主动投递只发送非空文字,不支持在该接口中发送图片、文件、卡片或富文本。
|
||||
- `{ sent: true }` 表示平台发送接口接受请求或 SDK 成功返回,不承诺最终送达或已读。
|
||||
- DSH-IM 不保存主动投递历史,不生成 `deliveryHandle` 或 `idempotencyKey`,也不自动重试。
|
||||
- 调用方超时后重试可能产生重复消息;需要业务幂等时,由调用方保存自己的业务事件 ID 和处理结果。
|
||||
- 正式发送只使用已保存的 `botId + targetId`。平台原生路由保存在目标配置中,不应随每次消息发送。
|
||||
- 一个调用只发送到一个目标。需要通知多个目标时,应分别调用并分别处理结果。
|
||||
- 机器人离线时仍可编辑目标,但不能测试或主动发送。
|
||||
|
||||
## RPC 可达范围
|
||||
|
||||
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 会话,不是九个平台统一、稳定的消息投递地址。主动投递只使用机器人和已保存目标的稳定组合。
|
||||
|
||||
### 测试成功但对方没有看到消息
|
||||
|
||||
测试成功只证明平台接口接受发送。请继续检查机器人权限、平台限制、目标是否正确,以及客户端侧的消息过滤或归档设置。
|
||||
|
|
@ -130,6 +130,12 @@ Use the proxy URL required by your network and restart the Host after changing i
|
|||
| Agent Preset | Each bot can choose an Agent Preset on its settings card. When none is chosen, new Sessions follow the Host's `agent-presets.default`. A channel-level `config.agentPreset` is only the default for later new bots on that channel. Changing the preset never modifies or clears existing Sessions; if the current chat already has a Session, send `/new` and then a regular message to create one with the new selection. |
|
||||
| Context enhancement | Open settings from a bot card to enable groups and DMs independently. Both switches default to off, including for existing bots after an upgrade. |
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
### Context enhancement
|
||||
|
||||
Open **Context enhancement** on a bot card to choose conversation scopes, source fields and guidance, then **Save**. The five available fields are `channel`, `conversationType`, `senderId`, `senderName` and `botId`; only `senderId` is selected by default. Only selected values already available in the incoming message are included; no platform profile API is queried. Weixin currently supports DMs only.
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ dsh web
|
|||
| Agent Preset | 每个机器人可在设置页卡片中选择 Agent Preset。未选择时跟随 Host 的 `agent-presets.default`;渠道级 `config.agentPreset` 只作为该渠道之后新接入机器人的默认值。切换不会修改或清空已有会话;若当前聊天已有会话,需先发送 `/new`,再发送一条普通消息,才会按新选择创建会话。 |
|
||||
| 上下文增强 | 从机器人卡片打开设置,分别决定群聊、私聊是否增强;两个开关默认均关闭,旧机器人升级后也不会自动开启。 |
|
||||
|
||||
### 主动投递
|
||||
|
||||
九个 IM 渠道都可以使用稳定的 `botId + targetId` 主动发送文字消息。机器人设置页支持从已聊会话选择或手工填写目标、保存前测试当前路由,以及复制调用参数;同 Host 插件和 Connection RPC 共用同一目标配置。
|
||||
|
||||
设置步骤、九渠道字段、完整调用示例、管理端点、错误码与排错说明请查看[《主动投递使用指南》](PROACTIVE_DELIVERY.md)([English](PROACTIVE_DELIVERY.en.md))。
|
||||
|
||||
### 上下文增强
|
||||
|
||||
点击机器人卡片中的「上下文增强」,设置群聊/私聊范围、来源字段和增强提示词,点击「保存」后生效。五个可选字段为 `channel`、`conversationType`、`senderId`、`senderName`、`botId`,默认只选择 `senderId`;只发送勾选且当前消息已有的值,不查询平台 API 补全。微信当前只支持私聊。
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Issue #65 / #84:基于 `botId + targetId` 的九渠道稳定主动投递方案
|
||||
|
||||
日期:2026-08-30。代码基线:v4.0.1 / `92ec91b`。状态:待实施。
|
||||
日期:2026-08-30。代码基线:v4.0.1 / `92ec91b`。状态:主动投递已实现并通过九渠道真实环境验收;已聊候选选择已通过九渠道自动化测试和最新版 DSH 真实页面验证。
|
||||
|
||||
需求来源:[Issue #65](https://github.com/xmanrui/dsh-im/issues/65)、[Issue #84](https://github.com/xmanrui/dsh-im/issues/84)。本文是实现设计和验收依据,不代表功能已经上线。
|
||||
需求来源:[Issue #65](https://github.com/xmanrui/dsh-im/issues/65)、[Issue #84](https://github.com/xmanrui/dsh-im/issues/84)。本文记录实现设计与已完成的验收结果。
|
||||
|
||||
## 1. 最终决定
|
||||
|
||||
|
|
@ -76,15 +76,16 @@
|
|||
3. 目标配置在 Host 重启后仍然存在;机器人删除时一并清理。
|
||||
4. #65 和 #84 的发送请求经过同一个核心服务和同一个渠道适配器。
|
||||
5. 机器人卡片右上角增加设置齿轮;卡片已有身份、状态、工作区、Agent Preset、上下文增强、连接检查和移除入口保持不变。
|
||||
6. 设置页展示可复制的真实 `botId`,并提供目标的新建、编辑、删除、复制调用参数和发送测试消息。
|
||||
6. 设置页展示可复制的真实 `botId`,并提供目标的新建、编辑、删除和复制调用参数;每个已保存的 `targetId` 都有自己的测试按钮。
|
||||
7. 目标配置不依赖机器人在线;实际发送和测试要求机器人当前已连接。
|
||||
8. 用户负责从对应平台取得原生用户、群、频道、话题或线程标识;页面提供字段名和格式提示。
|
||||
8. 设置页优先列出该机器人已持久化的聊天会话候选;用户选择后自动填入渠道原生路由,再确认稳定的 `targetId`。无候选或需要其他地址时,仍可手动填写并查看字段格式提示。
|
||||
9. 严格校验 RPC 端点、字段和渠道路由,不把平台原始错误、凭据或临时回复上下文返回给调用方。
|
||||
10. 保持所有现有入站回复、连接检查和文件发送行为不变。
|
||||
|
||||
### 3.2 本期明确不做
|
||||
|
||||
- 不自动扫描最近聊天,不提供 `listChats`,不从连接检查记忆或会话映射自动导入目标。
|
||||
- 不调用平台 API 扫描联系人、群或频道全量目录,不提供公共 `listChats/chatRef` 发送协议;候选仅来自 dsh-im 已持久化的 conversation key。
|
||||
- 不返回聊天正文、Harness `sessionId`、消息 ID 或临时回复对象,也不承诺候选具有会话名称、最后活跃时间或平台全量覆盖。
|
||||
- 不提供绑定码、认领流程、用户目录同步或跨渠道身份合并。
|
||||
- 不接受 `sessionId`、`chatRef`、`sessionWebhook`、消息 ID 或 `replyToMessageId` 作为公共发送参数。
|
||||
- 不支持图片、文件、卡片、Markdown 类型选择;首期公共能力只有文字,渠道内部继续复用现有文字分段逻辑。
|
||||
|
|
@ -100,11 +101,14 @@
|
|||
|
||||
```text
|
||||
Bot(现有机器人)
|
||||
└── DeliveryTarget(投递目标,0..n)
|
||||
├── targetId:对调用方稳定的键
|
||||
├── name:可选显示名称
|
||||
├── kind:渠道内的目标类型
|
||||
└── route:可更新的渠道原生路由
|
||||
├── DeliveryTarget(投递目标,0..n)
|
||||
│ ├── targetId:对调用方稳定的键
|
||||
│ ├── name:可选显示名称
|
||||
│ ├── kind:渠道内的目标类型
|
||||
│ └── route:可更新的渠道原生路由
|
||||
└── TargetSuggestion(临时候选,0..n)
|
||||
├── kind / route:从持久化 conversation key 解析
|
||||
└── 不含 targetId、sessionId 或聊天正文
|
||||
```
|
||||
|
||||
投递目标的唯一键是 `(botId, targetId)`,不是全局 `targetId`。两个机器人可以各自拥有名为 `daily-report` 的目标,互不影响。
|
||||
|
|
@ -119,6 +123,7 @@ Bot(现有机器人)
|
|||
- 更新目标时完整替换 `name + kind + route`,不做深层 PATCH,避免残留旧类型字段。
|
||||
- 发送开始时读取一份目标快照。并发更新可能使正在发送的那一次使用旧路由,但更新完成后的下一次发送必须使用新路由。
|
||||
- 删除目标不取消已经交给渠道的发送;删除完成后的新请求返回 `unknown-target`。
|
||||
- 候选不是已保存目标,不占用 `targetId`;只有用户选择候选、确认表单并保存后,才创建 `DeliveryTarget`。
|
||||
|
||||
## 5. 总体架构
|
||||
|
||||
|
|
@ -126,22 +131,23 @@ Bot(现有机器人)
|
|||
flowchart LR
|
||||
A[同 Host Cordis 插件<br/>Issue #65] -->|ctx.dshIm.send| C[DeliveryService]
|
||||
B[进程外调用方<br/>Issue #84] -->|Connection RPC| R[Delivery RPC]
|
||||
U[机器人设置页] -->|目标管理 / 测试| R
|
||||
U[机器人设置页] -->|候选 / 目标管理 / 测试| R
|
||||
R --> C
|
||||
C -->|按 botId 选择| G[九渠道 Adapter Registry]
|
||||
G --> T[BotWorkspaceStore<br/>目标配置]
|
||||
G --> K[各渠道持久会话状态<br/>conversation keys]
|
||||
G --> S[未包装 coreController<br/>现有 Runtime / BotClient]
|
||||
S --> P[IM 平台]
|
||||
```
|
||||
|
||||
| 组件 | 职责 | 不负责 |
|
||||
| --- | --- | --- |
|
||||
| `DeliveryService` | 参数校验、按 `botId` 选择适配器、目标 CRUD、统一发送和错误码 | 不理解九渠道原生字段,不保存消息 |
|
||||
| 渠道适配器 | 判断是否拥有机器人、校验本渠道 `kind/route`、调用对应核心 controller | 不暴露 RPC,不管理调用方业务 |
|
||||
| `BotWorkspaceStore` | 持久化机器人下的目标,复用现有原子写入、机器人队列和删除清理 | 不发送消息,不发现聊天 |
|
||||
| `DeliveryService` | 参数校验、按 `botId` 选择适配器、目标 CRUD、候选列表、统一发送和错误码 | 不理解九渠道 conversation key 或原生字段,不保存消息 |
|
||||
| 渠道适配器 | 判断是否拥有机器人、把本渠道持久化 conversation key 解析为候选、校验 `kind/route`、调用对应核心 controller | 不暴露 RPC,不管理调用方业务 |
|
||||
| `BotWorkspaceStore` | 持久化机器人下的已保存目标,复用现有原子写入、机器人队列和删除清理 | 不发送消息,不把候选自动保存为目标 |
|
||||
| Cordis 服务 | 把 #65 调用转发给 `DeliveryService` | 不复制渠道选择逻辑 |
|
||||
| Delivery RPC | 把 #84 和设置页请求转发给 `DeliveryService`,转换安全错误包络 | 不直接调用 Runtime |
|
||||
| 设置页 | 展示 `botId`、编辑渠道路由、调用测试 | 不推断或抓取目标 ID |
|
||||
| 设置页 | 展示 `botId`、让用户从已聊候选选择或手动编辑渠道路由、调用测试 | 不解析 conversation key,不读取聊天正文,不查询平台全量目录 |
|
||||
|
||||
### 5.1 为什么使用单独 RPC 通道
|
||||
|
||||
|
|
@ -217,6 +223,7 @@ deleteDeliveryTarget(botId, targetId)
|
|||
```js
|
||||
service.registerAdapter(adapter) // 返回 unregister
|
||||
service.listTargets(botId)
|
||||
service.listSuggestions(botId)
|
||||
service.createTarget(botId, target)
|
||||
service.updateTarget(botId, targetId, replacement)
|
||||
service.deleteTarget(botId, targetId)
|
||||
|
|
@ -239,6 +246,7 @@ service.send(botId, targetId, text, { signal } = {})
|
|||
channel: 'feishu',
|
||||
ownsBot(botId),
|
||||
listTargets(botId),
|
||||
listSuggestions(botId),
|
||||
createTarget(botId, target),
|
||||
updateTarget(botId, targetId, replacement),
|
||||
deleteTarget(botId, targetId),
|
||||
|
|
@ -247,6 +255,7 @@ service.send(botId, targetId, text, { signal } = {})
|
|||
```
|
||||
|
||||
- `ownsBot()` 使用同一生产组合中的 `workspaces.has(botId)`,删除中的机器人不会继续接受新发送。
|
||||
- `listSuggestions()` 只读该机器人已持久化的 conversation keys,输出经本渠道严格校验的 `kind/route`;它不要求 Runtime 在线,也不调用平台 API。
|
||||
- 公共服务不从 `botId` 前缀猜渠道;前缀只是现有实现细节。
|
||||
- 每个生产组合把自身 `workspaces` 和未包装 `coreController` 闭包进适配器。
|
||||
- 适配器只在对应渠道成功启动后注册,关闭渠道时注销。
|
||||
|
|
@ -303,7 +312,7 @@ await ctx.dshIm.send(
|
|||
|
||||
- 服务名沿用 #65 建议的 `dshIm`,方法名保持最小,只暴露 `send` 和 `listTargets`。
|
||||
- 目标 CRUD 由设置页/管理 RPC 完成,避免 Host 插件顺便承担配置 UI。
|
||||
- `listTargets()` 返回 `{ targetId, name, kind, route }[]`,不再提供会话发现语义的 `listChats()`。
|
||||
- `listTargets()` 返回已保存的 `{ targetId, name, kind, route }[]`。Cordis 公共服务不暴露 `listChats/chatRef`;设置页候选使用第 9 节的专用 RPC 端点。
|
||||
- Cordis 服务和 RPC 持有的是同一个 `DeliveryService` 实例;测试必须证明两者不是两套 registry。
|
||||
- Host/渠道关闭时用现有 `ctx.effect()` 注销服务、RPC 和渠道适配器,不留下失效 Runtime 引用。
|
||||
|
||||
|
|
@ -321,12 +330,15 @@ export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
|||
| --- | --- | --- |
|
||||
| `message.send` | `{ botId, targetId, text }` | `{ sent: true }` |
|
||||
| `target.list` | `{ botId }` | `{ botId, channel, targets }` |
|
||||
| `target.suggestion.list` | `{ botId }` | `{ botId, channel, suggestions: [{ kind, route }] }` |
|
||||
| `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, targetId }`;表单草稿:`{ botId, target: { kind, route } }` | `{ sent: true }` |
|
||||
|
||||
`target.test` 调用同一个 `DeliveryService.send()`,内容使用固定本地化文案“DSH-IM 主动投递测试成功。”。它与现有“检查连接”是两个功能:检查连接验证机器人连接和默认测试目标,目标测试验证用户配置的指定路由。
|
||||
`target.test` 使用固定本地化文案“DSH-IM 主动投递测试成功。”。已保存目标行传 `{ botId, targetId }`,由同一个 `DeliveryService.send()` 解析已保存路由;新建或编辑表单传 `{ botId, target: { kind, route } }`,直接校验并测试当前表单路由。两种 payload 严格二选一,表单测试不携带 `targetId/name`,也不创建、更新或落盘目标。它与现有“检查连接”是两个功能:检查连接验证机器人连接,目标测试验证指定渠道路由能否真正发送消息。
|
||||
|
||||
`target.suggestion.list` 是设置页专用的候选查询:渠道适配器从该机器人的持久化 conversation keys 解析出可主动投递的 `kind/route`。每个 suggestion 严格只含这两个字段,不含 `targetId`、`name`、时间、`sessionId`、聊天正文、消息 ID 或回复对象。选择 suggestion 只是预填新建表单,不会自动创建目标。
|
||||
|
||||
### 9.2 调用示例
|
||||
|
||||
|
|
@ -357,6 +369,7 @@ const result = await connection.rpc.call(
|
|||
- 顶层已有 `rpcAuthority: 'trusted-host'` 配置时沿用现有解析机制;不新增第二个同义配置。
|
||||
- `target.*` 和 `message.send` 使用相同可达性边界。本期不进一步区分管理员和发送者权限。
|
||||
- 每个端点只接受表中列出的键;缺字段、额外字段、数组冒充对象或已取消请求均拒绝。
|
||||
- `target.suggestion.list` 不要求机器人当前在线;未知机器人仍返回 `unknown-bot`,无候选时成功返回空数组。
|
||||
- 该 RPC 是 #84 的对外程序接口;不再另建 Express 路由、REST Server 或 Webhook 接收器。
|
||||
|
||||
## 10. 九渠道适配
|
||||
|
|
@ -375,9 +388,29 @@ const result = await connection.rpc.call(
|
|||
| Discord | `channel` | `{ channelId }`;私信、频道和 Thread 都使用可发消息的 Channel ID | `DiscordBotClient.sendText()`,不带回复消息 ID/notice |
|
||||
| WhatsApp | `user` / `group` | `{ jid }`,分别接受用户 JID 或群 JID | `WhatsappBotClient.sendText()`,不带 `quoted` |
|
||||
|
||||
注意:表中的 `route` 是内部持久化结构,#65/#84 的发送调用都不传这些字段。
|
||||
注意:表中的 `route` 是内部持久化结构,#65/#84 的发送调用都不传这些字段。设置页候选只用同一份 `kind/route` 预填表单。
|
||||
|
||||
### 10.2 各渠道的实现要点
|
||||
### 10.2 已聊候选的来源
|
||||
|
||||
候选不是平台聊天列表。它仅读取每个机器人状态文件中已持久化的 `sessions` conversation keys,按渠道规则转换并去重:
|
||||
|
||||
| 渠道 | 持久化 conversation key | suggestion `kind/route` |
|
||||
| --- | --- | --- |
|
||||
| 微信 | `p2p:<userId>` | `user / { toUserId }` |
|
||||
| 飞书 | `p2p:<openId>`、`group:<chatId>` | `user / { openId }`、`group / { chatId }` |
|
||||
| 钉钉 | `p2p:<staffId>`、`group:<conversationId>` | `user / { userId }`、`group / { openConversationId }` |
|
||||
| 企业微信 | `direct:<userId>`、`group:<chatId>` | `user / { chatId }`、`group / { chatId }` |
|
||||
| QQ | `c2c:<userOpenId>`、`group:<groupOpenId>` | `user / { userOpenId }`、`group / { groupOpenId }` |
|
||||
| Slack | `direct:<channelId>`、`group:<channelId>:<threadTs>` | `conversation / { channelId }`、`thread / { channelId, threadTs }` |
|
||||
| Telegram | `direct:<chatId>`、`group:<chatId>[:<messageThreadId>]` | `chat / { chatId }`、`topic / { chatId, messageThreadId }` |
|
||||
| Discord | `direct|group:<channelId>` | `channel / { channelId }` |
|
||||
| WhatsApp | `direct:<userJid>`、`group:<groupJid>` | `user / { jid }`、`group / { jid }` |
|
||||
|
||||
conversation key 只证明 dsh-im 曾经为该聊天建立持久会话映射。它不带最后活跃时间或可靠的平台会话名称,且可能因工作区切换、会话清理或从未建立 Harness Session 而不完整。因此产品文案使用“已聊过的会话”而不是“平台最近聊天”,手动填写始终作为高级兜底。
|
||||
|
||||
飞书私聊候选只接受能明确识别为 `open_id` 的 `ou_...` key;旧事件若仅提供 `user_id`,由于历史 key 没有记录 ID 类型,将安全忽略而不生成不可投递候选。WhatsApp 群候选同时接受当前数字群 JID 和带连字符的旧式群 JID。
|
||||
|
||||
### 10.3 各渠道的实现要点
|
||||
|
||||
1. **微信**:只支持用户目标。复用现有 API 的无上下文文字发送能力;不把入站 `contextToken` 或当前 `runId` 保存进目标。
|
||||
2. **飞书**:`kind` 决定 `receive_id_type` 为 `open_id` 或 `chat_id`。把连接检查中的局部发送函数提取为 Runtime 的稳定文字发送方法,继续使用同一 SDK Client。
|
||||
|
|
@ -389,12 +422,13 @@ const result = await connection.rpc.call(
|
|||
8. **Discord**:DM 也要求已经存在且机器人可访问的 Channel ID;Thread 本身同样是 Channel ID,因此无需额外线程字段。
|
||||
9. **WhatsApp**:只接受当前 SDK 支持的用户或群 JID,拒绝 Status/Broadcast 等特殊 JID。发送目标只有 `jid`,不保存引用消息对象或 `selfChat` 状态。
|
||||
|
||||
### 10.3 共用已有代码,但不复用临时目标
|
||||
### 10.4 共用已有代码,但不复用临时目标
|
||||
|
||||
- Slack、Telegram、Discord 已共用 `TextHarnessBridge`。为它增加一个只委托 `#bot.sendText()` 的公共方法,三个 Runtime 再统一暴露 `sendProactiveText()`,不复制分段代码。
|
||||
- Telegram 和 Discord 继续通过 `TokenBotController` 统一委托 Runtime;其他 controller 各增加同名薄方法。
|
||||
- 渠道的 `sendText()` 继续负责自身长度切分和平台错误分类;`DeliveryService` 不做统一切段。
|
||||
- 现有 `sendConnectionTest()` 可以复用底层发送函数,但它记住的测试目标不会自动成为投递目标。
|
||||
- 候选仅使用持久化 conversation key 中可证明为稳定地址的部分;连接检查 WeakMap、消息正文和平台临时路由都不是候选来源。
|
||||
- 所有主动路由明确拒绝 `sessionId`、`sessionWebhook`、`contextToken`、`runId`、`messageId`、`replyToMessageId`、`quoted` 等瞬时字段。
|
||||
|
||||
## 11. 机器人卡片与设置页
|
||||
|
|
@ -431,12 +465,12 @@ Bot ID
|
|||
bot_7f4c9d... [复制]
|
||||
|
||||
投递目标 [新建目标]
|
||||
每日汇报群
|
||||
targetId: daily-report 群聊 · chat_id oc_xxx
|
||||
每日汇报群 [群聊]
|
||||
targetId: daily-report
|
||||
[复制调用参数] [测试] [编辑] [删除]
|
||||
|
||||
告警负责人
|
||||
targetId: ops-oncall 私聊 · open_id ou_xxx
|
||||
告警负责人 [私聊]
|
||||
targetId: ops-oncall
|
||||
[复制调用参数] [测试] [编辑] [删除]
|
||||
```
|
||||
|
||||
|
|
@ -444,13 +478,24 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
|
||||
- Bot ID 显示 `status.bots[].botId` 的真实值,使用等宽字体,可单独复制;不显示卡片上经过遮罩的平台应用 ID 来冒充。
|
||||
- “复制调用参数”复制 JSON:`{ "botId": "...", "targetId": "..." }`,不包含路由和消息文本。
|
||||
- 空状态说明“尚未配置投递目标,请从对应平台取得用户、群、频道或话题标识后新建”。
|
||||
- 目标行展示名称、`targetId`、目标类型和一行渠道路由摘要;原生 ID 过长时视觉省略,复制值必须完整。
|
||||
- 点击“新建目标”后默认进入“从已聊过的会话选择”,并通过 `target.suggestion.list` 加载候选。候选使用一个原生下拉选择框展示,不平铺卡片;无候选时提示“先在对应平台与机器人聊一条消息,再刷新”,同时保留“手动填写(高级)”。
|
||||
- 下拉选项展示可用的本地名称、目标类型和脱敏路由摘要;不将这些展示值说成平台会话名称或最后活跃时间。
|
||||
- 已存在相同 `kind + route` 的选项显示“已添加”并禁用;去重不依赖 `targetId`。
|
||||
- 目标行预览只展示名称、目标类型徽标和 `targetId`;渠道原生路由仅在新建或编辑表单中显示,避免列表泄露实现细节并保持紧凑。
|
||||
- 每个已保存目标行都固定显示“测试”按钮,点击后只把该行的 `{ botId, targetId }` 发送给 `target.test`。所有新建和编辑表单也显示“测试”按钮,使用当前表单中的 `{ kind, route }` 发送 `{ botId, target: { kind, route } }`;这不会先保存目标,手动填写与会话候选预填的表单行为一致。
|
||||
- 测试期间仅禁用当前按钮并显示“测试中…”;平台发送成功后在对应目标行或表单内显示“测试消息已发送,请到目标会话确认”,失败则在同一位置显示该次安全错误并允许重试。测试结果只保留在页面内,不落盘、不改变目标健康状态。
|
||||
- 删除先在页面内确认,并提示“使用这个 targetId 的外部调用将返回 unknown-target”。
|
||||
- 返回机器人列表后保留当前渠道;尽量保留此前滚动位置,不重新切换渠道。
|
||||
|
||||
### 11.3 新建和编辑表单
|
||||
|
||||
新建流程:
|
||||
|
||||
1. 默认先显示已聊候选;点选后打开新建表单,自动预填 `kind/route`、本地兜底显示名称和未占用的随机 `targetId`(例如 `tgt_7f3a91c8d2e64b10`)。随机值不包含渠道或目标类型,用户保存前仍可改为有业务含义的别名。
|
||||
2. 预填不是保存;用户可继续修改名称、`targetId` 和路由,并必须点击“保存目标”才调用 `target.create`。
|
||||
3. 点击“手动填写(高级)”进入新建表单并同样预填随机 `targetId`,用于尚未出现在 conversation keys 或需要输入其他稳定地址的场景。
|
||||
4. 无论从候选还是手动进入,新建表单都可在保存前点击“测试”;请求只使用当前 `kind/route`,不会调用 `target.create`。
|
||||
|
||||
共享表单字段:
|
||||
|
||||
1. `targetId`:必填;编辑时只读。
|
||||
|
|
@ -458,9 +503,11 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
3. 目标类型:仅展示该渠道支持的 `kind`。
|
||||
4. 渠道路由字段:按第 10 节表格动态显示。
|
||||
|
||||
编辑表单同样测试当前字段值,而不是已保存记录中的旧路由;测试不调用 `target.update`。路由必填字段完整且机器人在线时按钮才可用,反馈显示在当前表单内。
|
||||
|
||||
实现一个共享 `DeliveryTargetSettingsPage`,用一份九渠道字段定义驱动标签、占位说明、类型选项和路由摘要,不复制九个页面。渠道自己的 `SettingsTab` 只维护 `selectedBot` 并传入 `channel/account/deliveryRpcCall/onBack`。
|
||||
|
||||
本期不提供“从最近聊天选择”“使用检查连接目标”“自动读取群列表”等快捷入口。用户负责取得平台原生 ID;页面只负责清楚说明当前字段需要哪一种 ID。
|
||||
候选不来自连接检查目标或平台群列表,页面也不会读取聊天正文。它只把服务端已归一化的 `kind/route` 作为建立稳定投递目标的输入。
|
||||
|
||||
## 12. 最小代码改动建议
|
||||
|
||||
|
|
@ -468,9 +515,10 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `plugin-src/host/delivery-service.mjs` | Adapter registry、目标 CRUD、统一 `send()`、Cordis 服务对象 |
|
||||
| `plugin-src/host/delivery-rpc.mjs` | `/dsh-im-delivery` 端点、严格 payload 校验、安全错误包络 |
|
||||
| `plugin-src/client/delivery-settings.js` | 设置页、目标表单、九渠道字段定义和调用参数复制 |
|
||||
| `plugin-src/host/delivery-service.mjs` | Adapter registry、目标 CRUD、候选列表、统一 `send()`、Cordis 服务对象 |
|
||||
| `plugin-src/host/delivery-suggestions.mjs` | 九渠道 conversation key 到稳定 `{ kind, route }` 的纯解析、畸形过滤和去重 |
|
||||
| `plugin-src/host/delivery-rpc.mjs` | `/dsh-im-delivery` 端点(包括 `target.suggestion.list`)、严格 payload 校验、安全错误包络 |
|
||||
| `plugin-src/client/delivery-settings.js` | 候选选择、设置页、目标表单、九渠道字段定义和调用参数复制 |
|
||||
|
||||
不需要新增第三方依赖。
|
||||
|
||||
|
|
@ -481,8 +529,9 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
| `src/channels/shared/bot-workspace-store.mjs` | v2 文档、`deliveryTargets` CRUD、迁移和机器人删除清理 |
|
||||
| `src/channels/shared/text-harness-bridge.mjs` | 为 Slack/Telegram/Discord 增加薄的文字发送委托 |
|
||||
| `src/channels/shared/token-bot-controller.mjs` | 委托 Telegram/Discord Runtime 的主动文字发送 |
|
||||
| `src/channels/wecom/state-store.mjs` | 补齐只读 `snapshot()`,使持久化 conversation keys 可与其他渠道一致提取候选 |
|
||||
| `plugin-src/host/index.mjs` | 创建唯一服务、提供 `dshIm`、安装 Delivery RPC、向九渠道传注册回调 |
|
||||
| `plugin-src/host/channels/shared/production.mjs` | 返回由 `coreController + workspaces` 构成的适配器 |
|
||||
| `plugin-src/host/channels/shared/production.mjs` | 返回由 `coreController + workspaces + stateFor` 构成的适配器,候选可从磁盘状态离线读取 |
|
||||
| `plugin-src/client/index.js` | 建立 `deliveryRpcCall` 并传给九渠道设置页 |
|
||||
| `plugin-src/client/loopback-recovery.js` | 将 Delivery RPC 纳入现有 loopback 恢复包装 |
|
||||
| `plugin-src/client/channel-card-meta.js` | 共享齿轮按钮 |
|
||||
|
|
@ -491,7 +540,7 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
### 12.3 渠道文件
|
||||
|
||||
- 共享 token 生产组合覆盖 Telegram、Discord;Slack 使用自己的生产组合。
|
||||
- 飞书、微信、钉钉、企业微信、QQ、Slack、WhatsApp 的 production 返回同形适配器。
|
||||
- 飞书、微信、钉钉、企业微信、QQ、Slack、WhatsApp 的 production 返回同形适配器,并向适配器注入对应的 `stateFor`。
|
||||
- 九渠道现有 controller/runtime 各增加一个薄的 `sendProactiveText()`;底层继续调用现有 BotClient/API。
|
||||
- 钉钉额外修改 `src/channels/dingtalk/dingtalk-api.mjs`,增加稳定用户/群文字发送方法。
|
||||
- 九个客户端卡片把现有 `BotStatusMeta` 与共享齿轮放进右侧工具组;卡片其余 JSX 不搬迁、不重排。
|
||||
|
|
@ -535,19 +584,36 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
| C07 | 平台拒绝和网络失败 | 映射为安全公共错误,不泄露原始响应 |
|
||||
| C08 | Cordis `send` 与 RPC `message.send` 使用同一 pair | 命中同一个 service spy、同一个适配器和同一返回语义 |
|
||||
| C09 | Host/渠道关闭 | 适配器注销,旧 Runtime 不再可调用 |
|
||||
| C10 | 发送成功 | 只返回 `{ sent: true }`,没有 handle、历史或幂等状态 |
|
||||
| C10 | 列出已聊候选 | 按 `botId` 命中同一适配器,只返回 `{ kind, route }`;离线可读,无候选返回空数组 |
|
||||
| C11 | 发送成功 | 只返回 `{ sent: true }`,没有 handle、历史或幂等状态 |
|
||||
|
||||
建议新增 `test/delivery-service.test.mjs`、`test/delivery-rpc.test.mjs`,并扩展 `test/host.test.mjs` 验证 `ctx.provide('dshIm')` 和清理生命周期。
|
||||
|
||||
### 13.3 RPC 契约
|
||||
### 13.3 #65 Cordis 入口测试
|
||||
|
||||
- 六个端点分别覆盖成功、缺字段、额外字段、错误类型、未知端点和取消。
|
||||
#65 只保留一个自动化入口用例;未知机器人、未知目标、离线和平台失败已经由第 13.2 节共享核心测试覆盖,不在 Cordis 入口重复一遍。真实环境则按第 13.7 节在九渠道各执行一次同样的最小发送。
|
||||
|
||||
**自动化用例:**在 Host 测试中提供 `dshIm`,让一个声明 `inject: ['dshIm']` 的假消费插件执行:
|
||||
|
||||
```js
|
||||
await ctx.dshIm.send('bot_test', 'daily-report', '测试消息');
|
||||
```
|
||||
|
||||
预置假目标和假渠道 sender,断言 sender 收到正确路由和文字一次、结果为 `{ sent: true }`,并断言 `connection.rpc.call` 为 0 次。这个用例证明 #65 使用 Cordis 服务而不是 #84 RPC。可直接放进现有 `test/host.test.mjs`,不新建复杂测试框架。
|
||||
|
||||
**真实冒烟用例:**每个渠道只选择一个当前可用机器人和一个可用目标,让同一 Host 内的最小测试插件调用一次 `ctx.dshIm.send()`。目标收到“#65 Cordis 主动投递测试”即通过,不要求把该渠道的私聊、群聊和线程类型全部测一遍。
|
||||
|
||||
### 13.4 RPC 契约
|
||||
|
||||
- 七个端点分别覆盖成功、缺字段、额外字段、错误类型、未知端点和取消。
|
||||
- `target.suggestion.list` 只接受 `{ botId }`,并严格返回 `{ botId, channel, suggestions: [{ kind, route }] }`;混入 `sessionId`、正文、名称、时间或回复字段的适配器结果必须被拒绝或归一化掉。
|
||||
- `target.test` 严格接受 `{ botId, targetId }` 或 `{ botId, target: { kind, route } }` 二者之一;前者测试已保存目标,后者测试未落盘的当前表单路由,且不得触发 `target.create/target.update`。
|
||||
- `target.update` 不能提交新的 `targetId`;`target.create` 不能覆盖已有记录。
|
||||
- 默认 authority 为 `loopback`,显式 `trusted-host` 走现有解析器;`/dsh-im` 更新 RPC 仍固定 loopback。
|
||||
- `message.send` 只接受 `{ botId, targetId, text }`,显式验证 `sessionId/chatRef/idempotencyKey/route` 等额外字段会被拒绝。
|
||||
- 错误包络只含允许的 code 和安全文案。
|
||||
|
||||
### 13.4 九渠道适配器契约
|
||||
### 13.5 九渠道适配器契约
|
||||
|
||||
每个渠道至少有以下自动化用例:
|
||||
|
||||
|
|
@ -557,6 +623,7 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
4. 长文本仍由该渠道现有分段函数处理,核心服务不重复切段。
|
||||
5. 主动发送不携带回复消息、引用对象或最近会话状态。
|
||||
6. 平台 sender 只调用一次业务发送入口;其内部分段次数按现有规则。
|
||||
7. 从该渠道的持久化 conversation keys 解析候选,过滤畸形 key,对相同 `kind + route` 去重,且不读取 Harness `sessionId` 值。
|
||||
|
||||
重点断言:
|
||||
|
||||
|
|
@ -572,9 +639,11 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
| Discord | 只传 `channelId + content`,没有 reply/notice |
|
||||
| WhatsApp | 只传 `jid + text`,没有 `quoted` |
|
||||
|
||||
候选提取另用一张九渠道表驱动测试固定第 10.2 节的映射。每个渠道至少同时提供合法 key、重复 key 和畸形 key,断言输出项严格只有 `kind/route`,不含 `targetId/name/time/sessionId/text/messageId/replyTarget`。
|
||||
|
||||
这些测试加入各渠道现有 runtime/controller/production 测试文件,不为同一行为新建九套测试框架。
|
||||
|
||||
### 13.5 客户端测试
|
||||
### 13.6 客户端测试
|
||||
|
||||
| 编号 | 用例 | 预期 |
|
||||
| --- | --- | --- |
|
||||
|
|
@ -585,42 +654,81 @@ targetId: ops-oncall 私聊 · open_id ou_xxx
|
|||
| U05 | 编辑目标 | `targetId` 只读,名称/类型/路由可完整替换 |
|
||||
| U06 | 删除目标 | 先确认,成功后移除;失败时保留并显示安全错误 |
|
||||
| U07 | 复制调用参数 | JSON 只含正确 `botId + targetId` |
|
||||
| U08 | 在线/离线测试 | 离线禁用测试但可编辑;在线测试调用 `target.test` |
|
||||
| U08 | 每个目标行测试 | 每个已保存 `targetId` 都有测试按钮;点击哪一行就用该行 pair 调用 `target.test`,成功和失败反馈只显示在该行 |
|
||||
| U09 | 窄屏和键盘操作 | 不横向溢出,焦点顺序、Tooltip、状态播报可用 |
|
||||
| U10 | Delivery RPC 不可用 | 只影响目标设置,不破坏机器人列表和现有连接操作 |
|
||||
| U11 | 候选列表 | 点击“新建目标”调用 `target.suggestion.list`,展示类型和脱敏路由摘要 |
|
||||
| U12 | 选择候选 | 只预填 `kind/route`、本地兜底名称和未占用的随机 `targetId`;选择时不调用 `target.create` |
|
||||
| U13 | 已添加候选 | 相同 `kind + route` 显示“已添加”并不可再选 |
|
||||
| U14 | 候选空状态和高级兜底 | 提示先与机器人聊天再刷新;“手动填写(高级)”始终可用 |
|
||||
| U15 | 新建/编辑表单测试 | 候选新建、手动新建和编辑表单均显示测试按钮;只提交当前 `{ kind, route }`,不调用 create/update,成功或失败反馈仅显示在当前表单 |
|
||||
|
||||
复用现有 `test/client-ui.test.mjs` 和九渠道 `client-ui.test.mjs` 的渲染方式;共享设置页可新增 `test/client-delivery-settings.test.mjs`。
|
||||
|
||||
### 13.6 回归和真实环境
|
||||
### 13.7 回归和真实环境
|
||||
|
||||
自动化必须通过 `npm run check`,并特别回归:机器人接入/删除、断线重连、检查连接、工作区切换、Agent Preset、上下文增强、入站回复、Telegram 长轮询和 WhatsApp 回声过滤。
|
||||
|
||||
发布前为九渠道各配置至少一个真实目标并执行 `target.test`;支持群/线程的渠道再各验证一个非私聊目标。验收记录应保存渠道、目标类型、时间、结果和脱敏错误,不保存凭据或完整用户 ID。
|
||||
本次按下表执行最小真实验收。每个渠道只选一个当前可用机器人和一个目标,并对同一组 `botId + targetId` 分别发送两条容易区分的消息:
|
||||
|
||||
- #65:同 Host 测试插件调用 `ctx.dshIm.send()`,消息带 `#65` 标识。
|
||||
- #84:进程外测试调用 `/dsh-im-delivery` 的 `message.send`,消息带 `#84` 标识。
|
||||
|
||||
| 渠道 | 机器人和目标 | #65 | #84 | 目标测试 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 微信 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| 飞书 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| 钉钉 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| 企业微信 | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| QQ | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| Slack | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| Telegram | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| Discord | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
| WhatsApp | 1 组已连接机器人和脱敏目标 | 通过 | 通过 | 通过 |
|
||||
|
||||
每个入口和目标测试各验证一次成功发送,不扩展到该渠道的所有目标类型,也不增加重启、并发或故障注入。验收记录只保存渠道、脱敏 `botId/targetId`、两个入口的时间和结果,不保存凭据或完整原生用户 ID。
|
||||
|
||||
### 13.8 当前实施验证记录(2026-08-30)
|
||||
|
||||
- `npm run check` 已通过:全部测试、构建和发布文件校验通过。
|
||||
- 使用最新版 DSH 源码 `0.1.2-alpha.1-cd5ef81` 启动当前 `web` profile,最新版 Connection RPC 包络兼容验证通过。
|
||||
- 真实宿主页面共加载 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 成功。
|
||||
- 首次真实 #65 验证发现 `dshIm` 在最新版 DSH 的现代依赖注入组合中被提供在过窄作用域。实现已改为在 Host 插件根上下文提供服务,再把同一个 `DeliveryService` 传入延迟激活的渠道;现代 Cordis 注入回归测试已固定该行为。
|
||||
- Host 重启后,一次性同 Host Cordis 插件仅注入 `dshIm` 并调用 `ctx.dshIm.send()`,九渠道各真实发送一次,9/9 成功;测试插件没有注入或调用 Connection RPC。
|
||||
- 每个已保存目标又通过测试按钮所调用的 `target.test` 端点真实发送一次,九渠道 9/9 成功。WhatsApp 测试前发生一次平台连接离线,使用现有 `bot.reconnect` 恢复后,同一 `botId + targetId` 无需修改即测试成功。
|
||||
- 验收记录只保留渠道和结果,不记录完整 `botId`、平台原生地址、凭据或会话 ID。
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
以下条件全部满足才能关闭 #65 和 #84:
|
||||
|
||||
1. 同 Host 插件可以注入 `dshIm`,使用 `send(botId, targetId, text)` 完成投递。
|
||||
2. 进程外调用方可以通过 `/dsh-im-delivery` 的 `message.send` 使用同一组参数完成投递。
|
||||
3. 自动化测试证明两个入口调用同一个 `DeliveryService`,不存在两套路由解析或渠道连接。
|
||||
4. 九渠道均有生产适配器、自动化契约测试和一次真实环境成功记录。
|
||||
5. Host 重启后同一 `botId + targetId` 仍可使用;编辑渠道路由后公共 pair 不变且下一次发送走新路由。
|
||||
6. 公共发送接口没有 `sessionId`、`chatRef`、`sessionWebhook`、`deliveryHandle` 或 `idempotencyKey`。
|
||||
7. 钉钉主动投递只使用稳定用户/群接口;临时 `sessionWebhook` 仅保留在原即时回复链。
|
||||
8. 每种机器人卡片右上角都有齿轮,原卡片内容、顺序和连接检查功能没有退化。
|
||||
9. 设置页可复制真实 Bot ID,并完整支持目标的新建、编辑、删除、复制 pair 和测试。
|
||||
10. 一个机器人可配置多个目标;相同 `targetId` 在不同机器人下隔离。
|
||||
11. 机器人离线时仍可管理目标;发送返回明确的 `bot-not-connected`,不建立隐式队列。
|
||||
12. 删除机器人会清理其全部目标;删除失败回滚不会丢失目标。
|
||||
13. dsh-im 不落主动发送历史、不自动重试、不管理幂等状态。
|
||||
14. `npm run check` 全部通过,九渠道现有接入、回复和连接检查回归通过。
|
||||
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` 均真实发送成功一次。
|
||||
6. Host 重启后同一 `botId + targetId` 仍可使用;编辑渠道路由后公共 pair 不变且下一次发送走新路由。
|
||||
7. 公共发送接口没有 `sessionId`、`chatRef`、`sessionWebhook`、`deliveryHandle` 或 `idempotencyKey`。
|
||||
8. 钉钉主动投递只使用稳定用户/群接口;临时 `sessionWebhook` 仅保留在原即时回复链。
|
||||
9. 每种机器人卡片右上角都有齿轮,原卡片内容、顺序和连接检查功能没有退化。
|
||||
10. 设置页可从已聊过的会话候选预填新目标,候选仅含 `kind/route`;九渠道 conversation key 映射均有自动化测试,且手动填写高级兜底始终可用。
|
||||
11. 设置页可复制真实 Bot ID,并完整支持目标的新建、编辑、删除和复制 pair;每个已保存 `targetId` 都有独立测试按钮,新建和编辑表单也可在不保存的情况下测试当前路由。
|
||||
12. 一个机器人可配置多个目标;相同 `targetId` 在不同机器人下隔离。
|
||||
13. 机器人离线时仍可管理目标并读取已持久化的候选;发送返回明确的 `bot-not-connected`,不建立隐式队列。
|
||||
14. 删除机器人会清理其全部目标;删除失败回滚不会丢失目标。
|
||||
15. dsh-im 不落主动发送历史、不自动重试、不管理幂等状态。
|
||||
16. `npm run check` 全部通过,九渠道现有接入、回复和连接检查回归通过。
|
||||
|
||||
## 15. 风险与控制
|
||||
|
||||
| 风险 | 控制方式 |
|
||||
| --- | --- |
|
||||
| 用户填错平台 ID | 渠道严格校验、字段级提示和目标测试;不尝试从字符串猜测 |
|
||||
| 用户填错平台 ID | 默认从已聊候选选择;手动输入作为高级兜底时,仍使用渠道严格校验、字段级提示和目标测试 |
|
||||
| 候选被误解为平台全量最近聊天 | 文案明确仅来自持久化 conversation keys,不显示伪造的名称/时间,缺少时使用手动兜底 |
|
||||
| 平台目标以后失效 | 保持 `targetId` 不变,用户只更新内部 route |
|
||||
| 配置文件升级损坏原设置 | v1→v2 迁移、原子写入、失败回滚和迁移测试 |
|
||||
| #65 与 #84 行为逐渐分叉 | 两个入口只做协议转换,测试直接断言同一 service 实例 |
|
||||
|
|
|
|||
1294
lib/client.js
1294
lib/client.js
File diff suppressed because it is too large
Load diff
440
lib/index.js
440
lib/index.js
File diff suppressed because one or more lines are too long
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
|||
"dsh-im": "bin/dsh-im.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||
"esbuild": "0.25.9",
|
||||
|
|
@ -82,6 +83,39 @@
|
|||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deepseek-ai/cordis": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.1.tgz",
|
||||
"integrity": "sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/cosmokit": "^1.8.2",
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"cordis": "bin.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "^1.0.6",
|
||||
"@deepseek-ai/cordis-plugin-loader": "^1.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/cordis-plugin-include": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/cordis-plugin-loader": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@deepseek-ai/cosmokit": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.2.tgz",
|
||||
"integrity": "sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
|
|
@ -1220,6 +1254,13 @@
|
|||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tencent-connect/qqbot-connector": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tencent-connect/qqbot-connector/-/qqbot-connector-1.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@
|
|||
"plugin-src",
|
||||
"scripts",
|
||||
"src",
|
||||
"PROACTIVE_DELIVERY.md",
|
||||
"PROACTIVE_DELIVERY.en.md",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"README.en.md",
|
||||
|
|
@ -100,6 +102,7 @@
|
|||
"qrcode": "1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||
"esbuild": "0.25.9",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,50 @@ import * as React from 'react';
|
|||
|
||||
import { h, isEnglish } from './i18n.js';
|
||||
|
||||
export const BotSettingsContext = React.createContext(Object.freeze({
|
||||
openBotSettings() {},
|
||||
}));
|
||||
|
||||
function SettingsGlyph() {
|
||||
return h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
width: 16,
|
||||
height: 16,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
h('circle', { cx: 12, cy: 12, r: 3 }),
|
||||
h('path', { d: 'M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.55V21h-4v-.08A1.7 1.7 0 0 0 8.97 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.52-1.03H3v-4h.08A1.7 1.7 0 0 0 4.6 8.97a1.7 1.7 0 0 0-.34-1.88l-.06-.06L7.03 4.2l.06.06a1.7 1.7 0 0 0 1.88.34A1.7 1.7 0 0 0 10 3.08V3h4v.08a1.7 1.7 0 0 0 1.03 1.52 1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06a1.7 1.7 0 0 0-.34 1.88A1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15Z' }));
|
||||
}
|
||||
|
||||
export function BotSettingsButton({ channel, botId, botName, connected }) {
|
||||
const { openBotSettings } = React.useContext(BotSettingsContext);
|
||||
const tooltipId = React.useId();
|
||||
return h('span', { className: 'dim-botSettingsAction' },
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: 'dim-botSettingsButton',
|
||||
'data-delivery-channel': channel,
|
||||
'aria-label': '机器人设置',
|
||||
'aria-describedby': tooltipId,
|
||||
onClick: () => openBotSettings?.({
|
||||
channel,
|
||||
botId,
|
||||
botName,
|
||||
connected: Boolean(connected),
|
||||
}),
|
||||
}, h(SettingsGlyph)),
|
||||
h('span', {
|
||||
id: tooltipId,
|
||||
className: 'dim-botSettingsTooltip',
|
||||
role: 'tooltip',
|
||||
}, '机器人设置'));
|
||||
}
|
||||
|
||||
function messageErrorTime(value) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(isEnglish() ? 'en-US' : 'zh-CN', {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -238,14 +239,21 @@ export function AccountCard({
|
|||
h('div', { className: 'dim-botName' },
|
||||
h('h3', { title: account.bot.name }, account.bot.name),
|
||||
h('p', { title: account.bot.clientIdMasked }, account.bot.clientIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'dingtalk',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from "../../agent-preset.js";
|
||||
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -593,15 +594,22 @@ export function BotCard({
|
|||
h("h3", { id: titleId, title: bot.name }, bot.name),
|
||||
h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")),
|
||||
),
|
||||
h(BotStatusMeta, {
|
||||
className: "bxf-healthPill",
|
||||
dotClassName: "bxf-dot",
|
||||
tone,
|
||||
stateLabel: HEALTH_LABELS[stateForDisplay] ?? "状态未知",
|
||||
lastCheckedAt: health.lastCheckedAt,
|
||||
formatCheckedTime,
|
||||
healthState: stateForDisplay,
|
||||
}),
|
||||
h("div", { className: "dim-botCardTools" },
|
||||
h(BotStatusMeta, {
|
||||
className: "bxf-healthPill",
|
||||
dotClassName: "bxf-dot",
|
||||
tone,
|
||||
stateLabel: HEALTH_LABELS[stateForDisplay] ?? "状态未知",
|
||||
lastCheckedAt: health.lastCheckedAt,
|
||||
formatCheckedTime,
|
||||
healthState: stateForDisplay,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: "feishu",
|
||||
botId: connection.botId,
|
||||
botName: bot.name,
|
||||
connected,
|
||||
})),
|
||||
),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: connection.workspace,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -182,14 +183,21 @@ export function AccountCard({
|
|||
h('div', { className: 'ddt-avatar dim-botAvatar dqq-avatar', 'aria-hidden': 'true' }, h(QqLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'qq',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -88,14 +89,21 @@ export function createTokenChannelSettings(definition) {
|
|||
h(LogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, identity))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: channel.toLowerCase(),
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -181,14 +182,21 @@ export function AccountCard({
|
|||
h('div', { className: 'ddt-avatar dim-botAvatar dwecom-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'wecom',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -222,14 +223,21 @@ export function AccountCard({
|
|||
h('div', { className: 'dxw-accountIdentity dim-botIdentity' },
|
||||
h('div', { className: 'dxw-avatar dim-botAvatar', 'aria-hidden': 'true' }, h(WeixinLogoGlyph, { size: 27 })),
|
||||
h('div', { className: 'dim-botName' }, h('h3', null, account.bot.name), h('p', null, account.bot.accountIdMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'dxw-health',
|
||||
dotClassName: 'dxw-dot',
|
||||
tone,
|
||||
stateLabel: account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪',
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'dxw-health',
|
||||
dotClassName: 'dxw-dot',
|
||||
tone,
|
||||
stateLabel: account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪',
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'weixin',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../agent-preset.js';
|
||||
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
|
||||
import {
|
||||
BotSettingsButton,
|
||||
BotStatusMeta,
|
||||
ChannelListHeading,
|
||||
LastMessageErrorSummary,
|
||||
|
|
@ -318,14 +319,21 @@ export function WhatsappAccountCard({
|
|||
}, h(WhatsappLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.idMasked))),
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
})),
|
||||
h('div', { className: 'dim-botCardTools' },
|
||||
h(BotStatusMeta, {
|
||||
className: 'ddt-health',
|
||||
dotClassName: 'ddt-dot',
|
||||
tone,
|
||||
stateLabel,
|
||||
lastCheckedAt: account.health.lastCheckedAt,
|
||||
formatCheckedTime: checkedTime,
|
||||
}),
|
||||
h(BotSettingsButton, {
|
||||
channel: 'whatsapp',
|
||||
botId: account.botId,
|
||||
botName: account.bot.name,
|
||||
connected: account.connected,
|
||||
}))),
|
||||
h(WorkspaceEditor, {
|
||||
workspace: account.workspace,
|
||||
disabled: Boolean(busy),
|
||||
|
|
|
|||
749
plugin-src/client/delivery-settings.js
Normal file
749
plugin-src/client/delivery-settings.js
Normal file
|
|
@ -0,0 +1,749 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { h, isEnglish, localizeText } from './i18n.js';
|
||||
|
||||
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
||||
|
||||
const DELIVERY_DOCS_URL = Object.freeze({
|
||||
zh: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.md',
|
||||
en: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
|
||||
});
|
||||
|
||||
export const DELIVERY_ENDPOINTS = Object.freeze({
|
||||
list: 'target.list',
|
||||
listSuggestions: 'target.suggestion.list',
|
||||
create: 'target.create',
|
||||
update: 'target.update',
|
||||
delete: 'target.delete',
|
||||
test: 'target.test',
|
||||
});
|
||||
|
||||
const CHANNEL_DEFINITIONS = Object.freeze({
|
||||
weixin: {
|
||||
label: '微信',
|
||||
kinds: [{ value: 'user', label: '用户' }],
|
||||
fields: { user: [{ key: 'toUserId', label: '微信用户 ID', placeholder: '填写接收消息的用户 ID' }] },
|
||||
},
|
||||
feishu: {
|
||||
label: '飞书',
|
||||
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'openId', label: 'Open ID', placeholder: 'ou_xxx' }],
|
||||
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: 'oc_xxx' }],
|
||||
},
|
||||
},
|
||||
dingtalk: {
|
||||
label: '钉钉',
|
||||
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'userId', label: '用户 ID', placeholder: '填写钉钉用户 ID' }],
|
||||
group: [{ key: 'openConversationId', label: '群 Open Conversation ID', placeholder: 'cidxxx' }],
|
||||
},
|
||||
},
|
||||
wecom: {
|
||||
label: '企业微信',
|
||||
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'chatId', label: '用户 ID', placeholder: '填写企业微信用户 ID' }],
|
||||
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: '填写群 chatid' }],
|
||||
},
|
||||
},
|
||||
qq: {
|
||||
label: 'QQ',
|
||||
kinds: [{ value: 'user', label: '单聊' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'userOpenId', label: '用户 Open ID', placeholder: '填写 user_openid' }],
|
||||
group: [{ key: 'groupOpenId', label: '群 Open ID', placeholder: '填写 group_openid' }],
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
label: 'Slack',
|
||||
kinds: [{ value: 'conversation', label: '会话' }, { value: 'thread', label: '线程' }],
|
||||
fields: {
|
||||
conversation: [{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' }],
|
||||
thread: [
|
||||
{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' },
|
||||
{ key: 'threadTs', label: 'Thread 时间戳', placeholder: '1712345678.123456' },
|
||||
],
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
label: 'Telegram',
|
||||
kinds: [{ value: 'chat', label: '聊天' }, { value: 'topic', label: '话题' }],
|
||||
fields: {
|
||||
chat: [{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' }],
|
||||
topic: [
|
||||
{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' },
|
||||
{ key: 'messageThreadId', label: 'Topic ID', placeholder: '123', inputMode: 'numeric', integer: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
kinds: [{ value: 'channel', label: '频道或私信' }],
|
||||
fields: { channel: [{ key: 'channelId', label: 'Channel ID', placeholder: '填写可发消息的 Channel ID', inputMode: 'numeric' }] },
|
||||
},
|
||||
whatsapp: {
|
||||
label: 'WhatsApp',
|
||||
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
||||
fields: {
|
||||
user: [{ key: 'jid', label: '用户 JID', placeholder: '8613800000000@s.whatsapp.net' }],
|
||||
group: [{ key: 'jid', label: '群 JID', placeholder: '1234567890@g.us' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const DELIVERY_CHANNEL_DEFINITIONS = CHANNEL_DEFINITIONS;
|
||||
|
||||
function presentError(error, fallback) {
|
||||
return error?.message || fallback;
|
||||
}
|
||||
|
||||
function unwrapRpcResult(result) {
|
||||
if (result?.ok === true) return result.value;
|
||||
if (result?.ok === false) {
|
||||
const error = new Error(result.error?.message || '请求失败,请稍后重试。');
|
||||
error.code = result.error?.code;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function targetsFrom(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return Array.isArray(value?.targets) ? value.targets : [];
|
||||
}
|
||||
|
||||
function suggestionsFrom(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return Array.isArray(value?.suggestions) ? value.suggestions : [];
|
||||
}
|
||||
|
||||
function kindLabel(definition, kind) {
|
||||
return definition.kinds.find((entry) => entry.value === kind)?.label ?? kind;
|
||||
}
|
||||
|
||||
function fieldsFor(definition, kind) {
|
||||
return definition.fields[kind] ?? [];
|
||||
}
|
||||
|
||||
function cleanDisplayName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 80) : null;
|
||||
}
|
||||
|
||||
function suggestionDisplayName(suggestion) {
|
||||
return cleanDisplayName(
|
||||
suggestion?.name ?? suggestion?.displayName ?? suggestion?.label,
|
||||
);
|
||||
}
|
||||
|
||||
function routeIdentity(definition, candidate) {
|
||||
const fields = fieldsFor(definition, candidate?.kind);
|
||||
if (fields.length === 0 || !candidate?.route || typeof candidate.route !== 'object') return null;
|
||||
const values = [];
|
||||
for (const field of fields) {
|
||||
const value = candidate.route[field.key];
|
||||
if (value === undefined || value === null || String(value).trim() === '') return null;
|
||||
values.push(String(value).trim());
|
||||
}
|
||||
return JSON.stringify([candidate.kind, ...values]);
|
||||
}
|
||||
|
||||
function validSuggestions(definition, value) {
|
||||
const seen = new Set();
|
||||
return suggestionsFrom(value).filter((suggestion) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
if (!identity || seen.has(identity)) return false;
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function maskRouteValue(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
const at = text.indexOf('@');
|
||||
if (at > 0) {
|
||||
const local = text.slice(0, at);
|
||||
const domain = text.slice(at);
|
||||
const visible = local.length <= 4
|
||||
? `${local.slice(0, 1)}••${local.slice(-1)}`
|
||||
: `${local.slice(0, 3)}…${local.slice(-2)}`;
|
||||
return `${visible}${domain}`;
|
||||
}
|
||||
if (text.length <= 6) return `${text.slice(0, 2)}…${text.slice(-1)}`;
|
||||
if (text.length <= 12) return `${text.slice(0, 3)}…${text.slice(-3)}`;
|
||||
return `${text.slice(0, 5)}…${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
function suggestionFallbackName(definition, suggestion) {
|
||||
const displayName = suggestionDisplayName(suggestion);
|
||||
if (displayName) return displayName;
|
||||
const firstField = fieldsFor(definition, suggestion.kind)[0];
|
||||
const route = firstField ? maskRouteValue(suggestion.route?.[firstField.key]) : '';
|
||||
return `${localizeText(kindLabel(definition, suggestion.kind))}${route ? ` · ${route}` : ''}`;
|
||||
}
|
||||
|
||||
function randomTargetId(targets) {
|
||||
const used = new Set(targets.map((target) => target?.targetId));
|
||||
for (let attempt = 0; attempt < 32; attempt += 1) {
|
||||
const bytes = new Uint8Array(8);
|
||||
const crypto = globalThis.crypto;
|
||||
if (typeof crypto?.getRandomValues === 'function') {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
const suffix = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
||||
const targetId = `tgt_${suffix}`;
|
||||
if (!used.has(targetId)) return targetId;
|
||||
}
|
||||
throw new Error('无法生成未占用的 Target ID,请重试。');
|
||||
}
|
||||
|
||||
function DeliveryButton({ children, kind = 'secondary', className = '', ...props }) {
|
||||
return h('button', {
|
||||
...props,
|
||||
type: props.type ?? 'button',
|
||||
className: `dim-deliveryButton ${className}`.trim(),
|
||||
'data-kind': kind,
|
||||
}, children);
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
const clipboard = globalThis.navigator?.clipboard;
|
||||
if (typeof clipboard?.writeText !== 'function') {
|
||||
throw new Error('当前浏览器不支持自动复制,请手动选择复制。');
|
||||
}
|
||||
await clipboard.writeText(value);
|
||||
}
|
||||
|
||||
function TargetForm({
|
||||
definition,
|
||||
mode,
|
||||
initialValue,
|
||||
source,
|
||||
busy,
|
||||
connected,
|
||||
onCancel,
|
||||
onSave,
|
||||
onTest,
|
||||
}) {
|
||||
const editing = mode === 'edit';
|
||||
const initialKind = initialValue?.kind && definition.fields[initialValue.kind]
|
||||
? initialValue.kind
|
||||
: definition.kinds[0].value;
|
||||
const [targetId, setTargetId] = React.useState(initialValue?.targetId ?? '');
|
||||
const [name, setName] = React.useState(initialValue?.name ?? '');
|
||||
const [kind, setKind] = React.useState(initialKind);
|
||||
const [route, setRoute] = React.useState(initialValue?.route ?? {});
|
||||
const [error, setError] = React.useState(null);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [testState, setTestState] = React.useState(null);
|
||||
|
||||
const currentTarget = () => {
|
||||
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
|
||||
const raw = String(route[field.key] ?? '').trim();
|
||||
return [field.key, field.integer ? Number(raw) : raw];
|
||||
}));
|
||||
return {
|
||||
targetId: targetId.trim(),
|
||||
...(name.trim() ? { name: name.trim() } : {}),
|
||||
kind,
|
||||
route: normalizedRoute,
|
||||
};
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...currentTarget(),
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(presentError(caught, '目标保存失败,请检查后重试。'));
|
||||
}
|
||||
};
|
||||
|
||||
const testTarget = async () => {
|
||||
if (typeof onTest !== 'function') return;
|
||||
setTesting(true);
|
||||
setTestState(null);
|
||||
try {
|
||||
const target = currentTarget();
|
||||
await onTest({ kind: target.kind, route: target.route });
|
||||
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
||||
} catch (caught) {
|
||||
setTestState({ tone: 'error', message: presentError(caught, '测试发送失败,请稍后重试。') });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
const testReady = fieldsFor(definition, kind).every((field) => (
|
||||
String(route[field.key] ?? '').trim()
|
||||
));
|
||||
|
||||
return h('form', { className: 'dim-targetForm', onSubmit: submit },
|
||||
h('div', { className: 'dim-targetFormHeading' },
|
||||
h('h3', null, editing ? '编辑投递目标' : '新建投递目标'),
|
||||
editing
|
||||
? null
|
||||
: h('p', null, source === 'suggestion'
|
||||
? '已从会话自动填入目标信息;确认后再保存。'
|
||||
: '请手动填写从对应平台取得的原生标识。')),
|
||||
h('div', { className: 'dim-targetFormGrid' },
|
||||
h('label', { className: 'dim-targetField' },
|
||||
h('span', null, 'Target ID'),
|
||||
h('input', {
|
||||
name: 'targetId',
|
||||
value: targetId,
|
||||
readOnly: editing,
|
||||
disabled: testing,
|
||||
required: true,
|
||||
pattern: '[A-Za-z0-9._:@-]{1,128}',
|
||||
maxLength: 128,
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
placeholder: '例如 daily-report',
|
||||
onChange: (event) => setTargetId(event.target.value),
|
||||
})),
|
||||
h('label', { className: 'dim-targetField' },
|
||||
h('span', null, '显示名称(可选)'),
|
||||
h('input', {
|
||||
name: 'name',
|
||||
value: name,
|
||||
disabled: testing,
|
||||
maxLength: 80,
|
||||
placeholder: '例如 每日汇报群',
|
||||
onChange: (event) => setName(event.target.value),
|
||||
})),
|
||||
h('label', { className: 'dim-targetField' },
|
||||
h('span', null, '目标类型'),
|
||||
h('select', {
|
||||
name: 'kind',
|
||||
value: kind,
|
||||
disabled: testing,
|
||||
onChange: (event) => {
|
||||
setKind(event.target.value);
|
||||
setRoute({});
|
||||
setTestState(null);
|
||||
},
|
||||
}, definition.kinds.map((entry) => h('option', {
|
||||
key: entry.value,
|
||||
value: entry.value,
|
||||
}, entry.label)))),
|
||||
fieldsFor(definition, kind).map((field) => h('label', {
|
||||
key: field.key,
|
||||
className: 'dim-targetField',
|
||||
},
|
||||
h('span', null, field.label),
|
||||
h('input', {
|
||||
name: field.key,
|
||||
value: route[field.key] ?? '',
|
||||
disabled: testing,
|
||||
required: true,
|
||||
inputMode: field.inputMode,
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
placeholder: field.placeholder,
|
||||
onChange: (event) => {
|
||||
setRoute((current) => ({
|
||||
...current,
|
||||
[field.key]: event.target.value,
|
||||
}));
|
||||
setTestState(null);
|
||||
},
|
||||
})))),
|
||||
error ? h('p', { className: 'dim-targetFormError', role: 'alert' }, error) : null,
|
||||
testState ? h('p', {
|
||||
className: 'dim-targetFeedback',
|
||||
'data-tone': testState.tone,
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, testState.message) : null,
|
||||
h('div', { className: 'dim-targetFormActions' },
|
||||
h(DeliveryButton, { onClick: onCancel, disabled: busy || testing }, '取消'),
|
||||
h(DeliveryButton, {
|
||||
onClick: () => void testTarget(),
|
||||
disabled: busy || testing || !connected || !testReady || typeof onTest !== 'function',
|
||||
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
||||
'aria-label': '测试投递目标',
|
||||
}, testing ? '测试中…' : '测试'),
|
||||
h(DeliveryButton, { type: 'submit', kind: 'primary', disabled: busy || testing },
|
||||
busy ? '正在保存…' : '保存目标')));
|
||||
}
|
||||
|
||||
function suggestionOptionLabel(definition, suggestion, added) {
|
||||
const name = suggestionDisplayName(suggestion);
|
||||
const type = localizeText(kindLabel(definition, suggestion.kind));
|
||||
const route = fieldsFor(definition, suggestion.kind)
|
||||
.map((field) => maskRouteValue(suggestion.route?.[field.key]))
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return [name, type, route, added ? localizeText('已添加') : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function TargetSuggestionPicker({
|
||||
definition,
|
||||
phase,
|
||||
suggestions,
|
||||
error,
|
||||
targets,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
onManual,
|
||||
onCancel,
|
||||
}) {
|
||||
const configured = new Set(targets.map((target) => routeIdentity(definition, target)).filter(Boolean));
|
||||
|
||||
return h('section', { className: 'dim-targetSuggestions', 'aria-label': '从已聊过的会话选择' },
|
||||
h('div', { className: 'dim-targetSuggestionHeading' },
|
||||
h('div', null,
|
||||
h('h3', null, '从已聊过的会话选择'),
|
||||
h('p', null, '选择后会自动填写目标信息和调用别名,确认后再保存。')),
|
||||
h(DeliveryButton, {
|
||||
onClick: () => void onRefresh(),
|
||||
disabled: phase === 'loading',
|
||||
}, phase === 'loading' ? '正在刷新…' : '刷新')),
|
||||
phase === 'loading'
|
||||
? h('div', { className: 'dim-targetSuggestionState', 'aria-busy': 'true' }, '正在读取已聊会话…')
|
||||
: phase === 'error'
|
||||
? h('div', { className: 'dim-targetSuggestionState', role: 'alert' },
|
||||
h('p', null, error),
|
||||
h(DeliveryButton, { onClick: () => void onRefresh() }, '重新读取'))
|
||||
: suggestions.length === 0
|
||||
? h('div', { className: 'dim-targetSuggestionState' },
|
||||
h('strong', null, '还没有可选择的会话'),
|
||||
h('p', null, '先在对应平台与机器人聊一条消息,再刷新。'))
|
||||
: h('label', { className: 'dim-targetSuggestionField' },
|
||||
h('span', null, '已聊会话'),
|
||||
h('select', {
|
||||
name: 'suggestion',
|
||||
value: '',
|
||||
onChange: (event) => {
|
||||
if (event.target.value === '') return;
|
||||
const suggestion = suggestions[Number(event.target.value)];
|
||||
if (suggestion) onSelect(suggestion);
|
||||
},
|
||||
},
|
||||
h('option', { value: '', disabled: true }, '从会话选择targetID'),
|
||||
suggestions.map((suggestion, index) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
const added = configured.has(identity);
|
||||
return React.createElement('option', {
|
||||
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
|
||||
value: String(index),
|
||||
disabled: added,
|
||||
}, suggestionOptionLabel(definition, suggestion, added));
|
||||
}))),
|
||||
h('div', { className: 'dim-targetFormActions' },
|
||||
h(DeliveryButton, { onClick: onCancel }, '取消'),
|
||||
h(DeliveryButton, { onClick: onManual }, '手动填写(高级)')));
|
||||
}
|
||||
|
||||
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
|
||||
const [action, setAction] = React.useState(null);
|
||||
const [testState, setTestState] = React.useState(null);
|
||||
const [copyState, setCopyState] = React.useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
const testTarget = async () => {
|
||||
setAction('test');
|
||||
setTestState(null);
|
||||
try {
|
||||
await rpcCall(DELIVERY_ENDPOINTS.test, { botId, targetId: target.targetId });
|
||||
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
||||
} catch (error) {
|
||||
setTestState({ tone: 'error', message: presentError(error, '测试发送失败,请稍后重试。') });
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const copyPair = async () => {
|
||||
setCopyState(null);
|
||||
try {
|
||||
await copyText(JSON.stringify({ botId, targetId: target.targetId }));
|
||||
setCopyState('已复制调用参数');
|
||||
} catch (error) {
|
||||
setCopyState(presentError(error, '复制失败。'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTarget = async () => {
|
||||
setAction('delete');
|
||||
try {
|
||||
await rpcCall(DELIVERY_ENDPOINTS.delete, { botId, targetId: target.targetId });
|
||||
await onChanged();
|
||||
} catch (error) {
|
||||
setTestState({ tone: 'error', message: presentError(error, '删除失败,请稍后重试。') });
|
||||
setAction(null);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
return h('li', { className: 'dim-targetRow', 'data-target-id': target.targetId },
|
||||
h('div', { className: 'dim-targetSummary' },
|
||||
h('div', { className: 'dim-targetTitle' },
|
||||
React.createElement('strong', null, target.name || target.targetId),
|
||||
h('span', null, kindLabel(definition, target.kind))),
|
||||
h('code', null, `targetId: ${target.targetId}`)),
|
||||
h('div', { className: 'dim-targetActions' },
|
||||
h(DeliveryButton, { onClick: () => void copyPair() }, '复制调用参数'),
|
||||
h(DeliveryButton, {
|
||||
onClick: () => void testTarget(),
|
||||
disabled: !connected || action === 'test',
|
||||
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
||||
'aria-label': '测试投递目标',
|
||||
}, action === 'test' ? '测试中…' : '测试'),
|
||||
h(DeliveryButton, { onClick: onEdit, disabled: Boolean(action) }, '编辑'),
|
||||
h(DeliveryButton, {
|
||||
kind: 'danger',
|
||||
onClick: () => setConfirmDelete(true),
|
||||
disabled: Boolean(action),
|
||||
}, '删除')),
|
||||
copyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, copyState) : null,
|
||||
testState ? h('p', {
|
||||
className: 'dim-targetFeedback',
|
||||
'data-tone': testState.tone,
|
||||
role: 'status',
|
||||
'aria-live': 'polite',
|
||||
}, testState.message) : null,
|
||||
confirmDelete ? h('div', { className: 'dim-targetDeleteConfirm', role: 'alertdialog' },
|
||||
h('p', null,
|
||||
'删除 ', h('code', null, target.targetId),
|
||||
'?使用这个 targetId 的外部调用将返回 unknown-target。'),
|
||||
h('div', { className: 'dim-targetFormActions' },
|
||||
h(DeliveryButton, {
|
||||
onClick: () => setConfirmDelete(false),
|
||||
disabled: action === 'delete',
|
||||
}, '取消'),
|
||||
h(DeliveryButton, {
|
||||
kind: 'danger',
|
||||
onClick: () => void deleteTarget(),
|
||||
disabled: action === 'delete',
|
||||
}, action === 'delete' ? '正在删除…' : '确认删除')))
|
||||
: null);
|
||||
}
|
||||
|
||||
export function DeliveryTargetSettingsPage({ channel, account, rpcCall, onBack }) {
|
||||
const definition = CHANNEL_DEFINITIONS[channel];
|
||||
const [phase, setPhase] = React.useState('loading');
|
||||
const [targets, setTargets] = React.useState([]);
|
||||
const [suggestionPhase, setSuggestionPhase] = React.useState('idle');
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [suggestionError, setSuggestionError] = React.useState(null);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [editor, setEditor] = React.useState(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [botCopyState, setBotCopyState] = React.useState(null);
|
||||
const mounted = React.useRef(true);
|
||||
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
|
||||
const loadTargets = React.useCallback(async ({ signal, silent = false } = {}) => {
|
||||
if (!silent) setPhase('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const value = await invoke(DELIVERY_ENDPOINTS.list, { botId: account.botId }, signal);
|
||||
if (signal?.aborted || !mounted.current) return;
|
||||
setTargets(targetsFrom(value));
|
||||
setPhase('ready');
|
||||
} catch (caught) {
|
||||
if (signal?.aborted || caught?.name === 'AbortError' || !mounted.current) return;
|
||||
setError(presentError(caught, '无法读取投递目标,请稍后重试。'));
|
||||
setPhase('error');
|
||||
}
|
||||
}, [account.botId, invoke]);
|
||||
|
||||
const loadSuggestions = React.useCallback(async () => {
|
||||
setSuggestionPhase('loading');
|
||||
setSuggestionError(null);
|
||||
try {
|
||||
const value = await invoke(DELIVERY_ENDPOINTS.listSuggestions, { botId: account.botId });
|
||||
if (!mounted.current) return;
|
||||
setSuggestions(validSuggestions(definition, value));
|
||||
setSuggestionPhase('ready');
|
||||
} catch (caught) {
|
||||
if (caught?.name === 'AbortError' || !mounted.current) return;
|
||||
setSuggestionError(presentError(caught, '无法读取已聊会话,请稍后重试。'));
|
||||
setSuggestionPhase('error');
|
||||
}
|
||||
}, [account.botId, definition, invoke]);
|
||||
|
||||
React.useEffect(() => {
|
||||
mounted.current = true;
|
||||
const controller = new AbortController();
|
||||
void loadTargets({ signal: controller.signal });
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [loadTargets]);
|
||||
|
||||
if (!definition) {
|
||||
return h('section', { className: 'dim-deliveryPage' },
|
||||
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表'),
|
||||
h('p', { role: 'alert' }, '当前渠道暂不支持投递目标。'));
|
||||
}
|
||||
|
||||
const saveTarget = async (target) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editor?.mode === 'edit') {
|
||||
await invoke(DELIVERY_ENDPOINTS.update, {
|
||||
botId: account.botId,
|
||||
targetId: editor.target.targetId,
|
||||
target: {
|
||||
...(target.name ? { name: target.name } : {}),
|
||||
kind: target.kind,
|
||||
route: target.route,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await invoke(DELIVERY_ENDPOINTS.create, { botId: account.botId, target });
|
||||
}
|
||||
await loadTargets({ silent: true });
|
||||
if (mounted.current) setEditor(null);
|
||||
} finally {
|
||||
if (mounted.current) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openSuggestionPicker = () => {
|
||||
setEditor({ mode: 'suggestions' });
|
||||
void loadSuggestions();
|
||||
};
|
||||
|
||||
const selectSuggestion = (suggestion) => {
|
||||
const identity = routeIdentity(definition, suggestion);
|
||||
if (!identity) return;
|
||||
const route = Object.fromEntries(fieldsFor(definition, suggestion.kind).map((field) => [
|
||||
field.key,
|
||||
suggestion.route[field.key],
|
||||
]));
|
||||
setEditor({
|
||||
mode: 'create',
|
||||
source: 'suggestion',
|
||||
draftKey: identity,
|
||||
initialValue: {
|
||||
targetId: randomTargetId(targets),
|
||||
name: suggestionFallbackName(definition, suggestion),
|
||||
kind: suggestion.kind,
|
||||
route,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyBotId = async () => {
|
||||
setBotCopyState(null);
|
||||
try {
|
||||
await copyText(account.botId);
|
||||
setBotCopyState('已复制 Bot ID');
|
||||
} catch (caught) {
|
||||
setBotCopyState(presentError(caught, '复制失败。'));
|
||||
}
|
||||
};
|
||||
|
||||
return h('section', {
|
||||
className: 'dim-deliveryPage',
|
||||
'aria-label': `${account.botName || definition.label}机器人设置`,
|
||||
},
|
||||
h('header', { className: 'dim-deliveryHeader' },
|
||||
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表'),
|
||||
h('div', { className: 'dim-deliveryHeaderMeta' },
|
||||
h('a', {
|
||||
className: 'dim-deliveryDocsLink',
|
||||
href: isEnglish() ? DELIVERY_DOCS_URL.en : DELIVERY_DOCS_URL.zh,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
'aria-label': '打开主动投递使用文档',
|
||||
},
|
||||
h('span', null, '使用文档'),
|
||||
h('span', { 'aria-hidden': 'true' }, '↗')),
|
||||
h('h2', null, account.botName || '机器人设置'))),
|
||||
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-identity-title' },
|
||||
h('div', null,
|
||||
h('h3', { id: 'dim-delivery-identity-title' }, '调用标识')),
|
||||
h('div', { className: 'dim-deliveryBotId' },
|
||||
h('span', null, 'Bot ID'),
|
||||
h('code', { title: account.botId }, account.botId),
|
||||
h(DeliveryButton, { onClick: () => void copyBotId() }, '复制')),
|
||||
botCopyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, botCopyState) : null),
|
||||
h('section', { className: 'dim-deliveryTargets', 'aria-labelledby': 'dim-delivery-targets-title' },
|
||||
h('div', { className: 'dim-deliverySectionHeading' },
|
||||
h('div', null,
|
||||
h('h3', { id: 'dim-delivery-targets-title' }, '投递目标'),
|
||||
account.connected
|
||||
? null
|
||||
: h('p', null, '机器人当前离线;仍可配置目标,恢复连接后再测试。')),
|
||||
h(DeliveryButton, {
|
||||
kind: 'primary',
|
||||
onClick: openSuggestionPicker,
|
||||
disabled: Boolean(editor) || phase !== 'ready',
|
||||
title: phase === 'ready' ? undefined : '请先完成投递目标读取',
|
||||
}, '新建目标')),
|
||||
editor?.mode === 'suggestions'
|
||||
? h(TargetSuggestionPicker, {
|
||||
definition,
|
||||
phase: suggestionPhase,
|
||||
suggestions,
|
||||
error: suggestionError,
|
||||
targets,
|
||||
onRefresh: loadSuggestions,
|
||||
onSelect: selectSuggestion,
|
||||
onManual: () => setEditor({
|
||||
mode: 'create',
|
||||
source: 'manual',
|
||||
draftKey: 'manual',
|
||||
initialValue: { targetId: randomTargetId(targets) },
|
||||
}),
|
||||
onCancel: () => setEditor(null),
|
||||
})
|
||||
: editor ? h(TargetForm, {
|
||||
key: editor.mode === 'edit' ? editor.target.targetId : editor.draftKey,
|
||||
definition,
|
||||
mode: editor.mode,
|
||||
initialValue: editor.mode === 'edit' ? editor.target : editor.initialValue,
|
||||
source: editor.source,
|
||||
busy: saving,
|
||||
connected: account.connected,
|
||||
onCancel: () => setEditor(editor.source === 'suggestion'
|
||||
? { mode: 'suggestions' }
|
||||
: null),
|
||||
onSave: saveTarget,
|
||||
onTest: (target) => invoke(DELIVERY_ENDPOINTS.test, {
|
||||
botId: account.botId,
|
||||
target,
|
||||
}),
|
||||
}) : null,
|
||||
phase === 'loading'
|
||||
? h('div', { className: 'dim-deliveryState', 'aria-busy': 'true' }, '正在读取投递目标…')
|
||||
: phase === 'error'
|
||||
? h('div', { className: 'dim-deliveryState', role: 'alert' },
|
||||
h('p', null, error),
|
||||
h(DeliveryButton, { onClick: () => void loadTargets() }, '重新读取'))
|
||||
: targets.length === 0
|
||||
? h('div', { className: 'dim-deliveryState dim-deliveryEmpty' },
|
||||
h('strong', null, '尚未配置投递目标'),
|
||||
h('p', null, '点击“新建目标”可从已聊过的会话选择,也可手动填写。'))
|
||||
: h('ul', { className: 'dim-targetList' }, targets.map((target) => h(TargetRow, {
|
||||
key: target.targetId,
|
||||
definition,
|
||||
target,
|
||||
botId: account.botId,
|
||||
connected: account.connected,
|
||||
rpcCall: invoke,
|
||||
onChanged: () => loadTargets({ silent: true }),
|
||||
onEdit: () => setEditor({ mode: 'edit', target, source: 'edit' }),
|
||||
})))));
|
||||
}
|
||||
|
|
@ -673,6 +673,88 @@ const EN = Object.freeze({
|
|||
'WhatsApp 服务没有返回有效的扫码任务': 'WhatsApp did not return a valid setup attempt',
|
||||
'WhatsApp 服务没有返回有效的机器人列表': 'WhatsApp did not return a valid account list',
|
||||
'用于关联 WhatsApp 设备的一次性二维码': 'One-time QR code for linking a WhatsApp device',
|
||||
'用户': 'User',
|
||||
'微信用户 ID': 'WeChat user ID',
|
||||
'填写接收消息的用户 ID': 'Enter the recipient user ID',
|
||||
'私聊': 'Direct message',
|
||||
'群聊': 'Group',
|
||||
'群 Chat ID': 'Group Chat ID',
|
||||
'用户 ID': 'User ID',
|
||||
'填写钉钉用户 ID': 'Enter the DingTalk user ID',
|
||||
'群 Open Conversation ID': 'Group Open Conversation ID',
|
||||
'填写企业微信用户 ID': 'Enter the WeCom user ID',
|
||||
'填写群 chatid': 'Enter the group chatid',
|
||||
'单聊': 'Direct message',
|
||||
'用户 Open ID': 'User Open ID',
|
||||
'填写 user_openid': 'Enter the user_openid',
|
||||
'群 Open ID': 'Group Open ID',
|
||||
'填写 group_openid': 'Enter the group_openid',
|
||||
'会话': 'Conversation',
|
||||
'线程': 'Thread',
|
||||
'Thread 时间戳': 'Thread timestamp',
|
||||
'聊天': 'Chat',
|
||||
'话题': 'Topic',
|
||||
'频道或私信': 'Channel or DM',
|
||||
'填写可发消息的 Channel ID': 'Enter a Channel ID the bot can message',
|
||||
'用户 JID': 'User JID',
|
||||
'群 JID': 'Group JID',
|
||||
'请求失败,请稍后重试。': 'Request failed. Try again later.',
|
||||
'当前浏览器不支持自动复制,请手动选择复制。': 'Automatic copy is unavailable. Select and copy the value manually.',
|
||||
'目标保存失败,请检查后重试。': 'Could not save the target. Check it and try again.',
|
||||
'无法生成未占用的 Target ID,请重试。': 'Could not generate an unused Target ID. Try again.',
|
||||
'编辑投递目标': 'Edit delivery target',
|
||||
'新建投递目标': 'New delivery target',
|
||||
'已从会话自动填入目标信息;确认后再保存。': 'Target details were filled from the conversation. Review them before saving.',
|
||||
'请手动填写从对应平台取得的原生标识。': 'Enter the native identifier from the platform manually.',
|
||||
'例如 daily-report': 'For example, daily-report',
|
||||
'使用大小写字母、数字、点、下划线、冒号、@ 或连字符。': 'Use letters, numbers, dots, underscores, colons, @, or hyphens.',
|
||||
'显示名称(可选)': 'Display name (optional)',
|
||||
'例如 每日汇报群': 'For example, Daily reports',
|
||||
'目标类型': 'Target type',
|
||||
'保存目标': 'Save target',
|
||||
'请先完成投递目标读取': 'Wait for delivery targets to finish loading',
|
||||
'从已聊过的会话选择': 'Choose from conversations',
|
||||
'已聊会话': 'Conversation',
|
||||
'从会话选择targetID': 'Select a targetID from conversations',
|
||||
'选择后会自动填写目标信息和调用别名,确认后再保存。': 'Selecting a conversation fills the target details and call alias. Review them before saving.',
|
||||
'正在刷新…': 'Refreshing…',
|
||||
'刷新': 'Refresh',
|
||||
'正在读取已聊会话…': 'Loading known conversations…',
|
||||
'无法读取已聊会话,请稍后重试。': 'Could not load known conversations. Try again later.',
|
||||
'还没有可选择的会话': 'No conversations to choose from',
|
||||
'先在对应平台与机器人聊一条消息,再刷新。': 'Send the bot a message on the platform, then refresh.',
|
||||
'已添加': 'Added',
|
||||
'手动填写(高级)': 'Enter manually (advanced)',
|
||||
'测试消息已发送,请到目标会话确认。': 'Test message sent. Confirm it in the target conversation.',
|
||||
'测试发送失败,请稍后重试。': 'Test delivery failed. Try again later.',
|
||||
'已复制调用参数': 'Call parameters copied',
|
||||
'复制失败。': 'Copy failed.',
|
||||
'删除失败,请稍后重试。': 'Could not delete the target. Try again later.',
|
||||
'复制调用参数': 'Copy call parameters',
|
||||
'机器人离线时不可发送测试消息': 'Test delivery is unavailable while the bot is offline',
|
||||
'测试投递目标': 'Test delivery target',
|
||||
'测试': 'Test',
|
||||
'编辑': 'Edit',
|
||||
'删除': 'Delete',
|
||||
'删除 ': 'Delete ',
|
||||
'?使用这个 targetId 的外部调用将返回 unknown-target。': '? External calls using this targetId will return unknown-target.',
|
||||
'正在删除…': 'Deleting…',
|
||||
'确认删除': 'Delete target',
|
||||
'投递目标设置暂不可用。': 'Delivery target settings are unavailable.',
|
||||
'无法读取投递目标,请稍后重试。': 'Could not load delivery targets. Try again later.',
|
||||
'← 返回机器人列表': '← Back to bots',
|
||||
'使用文档': 'User guide',
|
||||
'打开主动投递使用文档': 'Open the proactive delivery guide',
|
||||
'当前渠道暂不支持投递目标。': 'Delivery targets are not supported for this channel.',
|
||||
'已复制 Bot ID': 'Bot ID copied',
|
||||
'调用标识': 'Call identifiers',
|
||||
'复制': 'Copy',
|
||||
'投递目标': 'Delivery targets',
|
||||
'机器人当前离线;仍可配置目标,恢复连接后再测试。': 'The bot is offline. You can still configure targets and test them after reconnection.',
|
||||
'新建目标': 'New target',
|
||||
'正在读取投递目标…': 'Loading delivery targets…',
|
||||
'尚未配置投递目标': 'No delivery targets yet',
|
||||
'点击“新建目标”可从已聊过的会话选择,也可手动填写。': 'Select New target to choose a conversation or enter the details manually.',
|
||||
});
|
||||
|
||||
export const en = EN;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ import { WHATSAPP_RPC_CHANNEL } from './channels/whatsapp/api.js';
|
|||
import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
|
||||
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
|
||||
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
|
||||
import { BotSettingsContext } from './channel-card-meta.js';
|
||||
import {
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
DeliveryTargetSettingsPage,
|
||||
} from './delivery-settings.js';
|
||||
import {
|
||||
createLoopbackAwareRpcCalls,
|
||||
replacePageLocation,
|
||||
|
|
@ -167,6 +172,7 @@ export function IMSettingsTab({
|
|||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
updateRpcCall,
|
||||
deliveryRpcCall,
|
||||
workspaceDirectoryPicker,
|
||||
browserLocation = globalThis.location,
|
||||
navigateToRecoveryUrl = replacePageLocation,
|
||||
|
|
@ -174,6 +180,7 @@ export function IMSettingsTab({
|
|||
const [selected, setSelected] = React.useState('weixin');
|
||||
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
|
||||
const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
|
||||
const [deliverySettings, setDeliverySettings] = React.useState(null);
|
||||
const githubTooltipId = React.useId();
|
||||
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
||||
const reportLoopbackRecovery = React.useCallback((recovery) => {
|
||||
|
|
@ -194,6 +201,7 @@ export function IMSettingsTab({
|
|||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
updateRpcCall,
|
||||
deliveryRpcCall,
|
||||
}, {
|
||||
location: browserLocation,
|
||||
onRecovery: reportLoopbackRecovery,
|
||||
|
|
@ -201,6 +209,7 @@ export function IMSettingsTab({
|
|||
browserLocation,
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
deliveryRpcCall,
|
||||
feishuRpcCall,
|
||||
officeRpcCall,
|
||||
qqRpcCall,
|
||||
|
|
@ -212,6 +221,9 @@ export function IMSettingsTab({
|
|||
weixinRpcCall,
|
||||
whatsappRpcCall,
|
||||
]);
|
||||
const botSettingsContext = React.useMemo(() => Object.freeze({
|
||||
openBotSettings: setDeliverySettings,
|
||||
}), []);
|
||||
return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
|
||||
h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
|
||||
h('header', { className: 'dim-title' },
|
||||
|
|
@ -253,7 +265,10 @@ export function IMSettingsTab({
|
|||
className: 'dim-channel',
|
||||
'aria-selected': channel.id === active.id,
|
||||
'aria-controls': `dim-panel-${channel.id}`,
|
||||
onClick: () => setSelected(channel.id),
|
||||
onClick: () => {
|
||||
setSelected(channel.id);
|
||||
setDeliverySettings(null);
|
||||
},
|
||||
},
|
||||
h(ChannelLogo, { channel: channel.id }),
|
||||
h('span', { className: 'dim-channelCopy' },
|
||||
|
|
@ -273,25 +288,33 @@ export function IMSettingsTab({
|
|||
onNavigate: navigateToRecoveryUrl,
|
||||
})
|
||||
: null,
|
||||
active.id === 'weixin'
|
||||
? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
|
||||
: active.id === 'feishu'
|
||||
? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
|
||||
: active.id === 'dingtalk'
|
||||
? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
|
||||
: active.id === 'wecom'
|
||||
? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
|
||||
: active.id === 'qq'
|
||||
? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
|
||||
: active.id === 'slack'
|
||||
? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
|
||||
: active.id === 'telegram'
|
||||
? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
|
||||
: active.id === 'discord'
|
||||
? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
|
||||
: active.id === 'whatsapp'
|
||||
? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
|
||||
: h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall })),
|
||||
h(BotSettingsContext.Provider, { value: botSettingsContext },
|
||||
deliverySettings?.channel === active.id
|
||||
? h(DeliveryTargetSettingsPage, {
|
||||
channel: active.id,
|
||||
account: deliverySettings,
|
||||
rpcCall: rpcCalls.deliveryRpcCall,
|
||||
onBack: () => setDeliverySettings(null),
|
||||
})
|
||||
: active.id === 'weixin'
|
||||
? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
|
||||
: active.id === 'feishu'
|
||||
? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
|
||||
: active.id === 'dingtalk'
|
||||
? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
|
||||
: active.id === 'wecom'
|
||||
? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
|
||||
: active.id === 'qq'
|
||||
? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
|
||||
: active.id === 'slack'
|
||||
? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
|
||||
: active.id === 'telegram'
|
||||
? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
|
||||
: active.id === 'discord'
|
||||
? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
|
||||
: active.id === 'whatsapp'
|
||||
? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
|
||||
: h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall }))),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -344,6 +367,8 @@ export function apply(ctx) {
|
|||
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const updateRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(UPDATE_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const deliveryRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(DELIVERY_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const workspaceDirectoryPicker = Object.freeze({
|
||||
listDirectory: (path, signal) =>
|
||||
callWorkspaceDirectoryApi(ctx, 'listDirectory', path, signal),
|
||||
|
|
@ -368,6 +393,7 @@ export function apply(ctx) {
|
|||
whatsappRpcCall,
|
||||
officeRpcCall,
|
||||
updateRpcCall,
|
||||
deliveryRpcCall,
|
||||
workspaceDirectoryPicker,
|
||||
}),
|
||||
}, IMSettingsTab));
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ const CSS = String.raw`
|
|||
.dim-panel .dim-botName { min-width: 0; }
|
||||
.dim-panel .dim-botName h3 { overflow: hidden; margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; font-weight: 650; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-panel .dim-botName p { overflow: hidden; margin: 4px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-panel .dim-botCardTools { flex: none; display: flex; align-items: flex-start; gap: 8px; }
|
||||
.dim-panel .dim-botHealthGroup { min-width: 0; max-width: 100%; flex: none; display: grid; justify-items: end; gap: 5px; }
|
||||
.dim-panel .dim-botCard .dim-botHealth { flex: none; min-height: 0; display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; border-radius: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; }
|
||||
.dim-panel .dim-lastChecked { display: inline-flex; align-items: baseline; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); font: inherit; font-size: 11px; font-weight: 400; line-height: normal; white-space: nowrap; }
|
||||
|
|
@ -357,6 +358,69 @@ const CSS = String.raw`
|
|||
.dim-panel .dim-botCard .dim-healthDot[data-tone="success"] { background: var(--dsw-alias-state-success-primary, #20a162); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #20a162) 14%, transparent); }
|
||||
.dim-panel .dim-botCard .dim-healthDot[data-tone="warning"] { background: var(--dsw-alias-state-warn-primary, #d97706); }
|
||||
.dim-panel .dim-botCard .dim-healthDot[data-tone="error"] { background: var(--dsw-alias-state-error-primary, #d54941); }
|
||||
.dim-botSettingsAction { position: relative; flex: none; display: inline-flex; }
|
||||
.dim-botSettingsButton { width: 32px; height: 32px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; cursor: pointer; transition: color .15s ease, background .15s ease; }
|
||||
.dim-botSettingsButton:hover { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-botSettingsButton:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
|
||||
.dim-botSettingsTooltip { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; width: max-content; padding: 6px 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 8px 24px rgb(31 35 41 / 14%); font-size: 11px; line-height: 16px; opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
|
||||
.dim-botSettingsAction:hover .dim-botSettingsTooltip, .dim-botSettingsAction:focus-within .dim-botSettingsTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||
.dim-deliveryPage { min-width: 0; display: grid; gap: 14px; }
|
||||
.dim-deliveryHeader { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.dim-deliveryHeaderMeta { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 10px; text-align: right; }
|
||||
.dim-deliveryHeader h2 { min-width: 0; overflow: hidden; margin: 0; font-size: 18px; line-height: 24px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-deliveryDocsLink { min-height: 30px; flex: none; display: inline-flex; align-items: center; gap: 4px; padding: 5px 8px; border-radius: 7px; color: var(--dsw-alias-state-business-primary, #3370ff); font-size: 12px; line-height: 18px; text-decoration: none; white-space: nowrap; }
|
||||
.dim-deliveryDocsLink:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 8%, transparent); }
|
||||
.dim-deliveryDocsLink:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
|
||||
.dim-deliveryButton { min-height: 30px; display: inline-flex; align-items: center; justify-content: center; padding: 5px 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
|
||||
.dim-deliveryButton:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-deliveryButton:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
|
||||
.dim-deliveryButton:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.dim-deliveryButton[data-kind="primary"] { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-deliveryButton[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); }
|
||||
.dim-deliveryBack { flex: none; border-color: transparent; background: transparent; }
|
||||
.dim-deliveryIdentity, .dim-deliveryTargets { min-width: 0; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-bg-layer-3, #fff); }
|
||||
.dim-deliveryIdentity > div:first-child h3, .dim-deliverySectionHeading h3 { margin: 0; font-size: 15px; line-height: 22px; font-weight: 650; }
|
||||
.dim-deliveryIdentity > div:first-child p, .dim-deliverySectionHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
||||
.dim-deliveryBotId { min-width: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 10px; margin-top: 12px; padding: 10px 12px; border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-deliveryBotId > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
|
||||
.dim-deliveryBotId code { min-width: 0; overflow: hidden; font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-deliverySectionHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.dim-deliveryState { margin-top: 14px; padding: 24px 16px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; text-align: center; }
|
||||
.dim-deliveryState p { margin: 5px 0; }
|
||||
.dim-deliveryEmpty strong { color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; }
|
||||
.dim-targetList { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||
.dim-targetRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px 14px; padding: 13px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff); }
|
||||
.dim-targetSummary { min-width: 0; }
|
||||
.dim-targetTitle { min-width: 0; display: flex; align-items: center; gap: 7px; }
|
||||
.dim-targetTitle strong { overflow: hidden; font-size: 13px; line-height: 20px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-targetTitle span { flex: none; padding: 1px 6px; border-radius: 5px; color: var(--dsw-alias-state-business-primary, #3370ff); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 9%, transparent); font-size: 10px; line-height: 16px; }
|
||||
.dim-targetSummary code { display: block; overflow: hidden; margin-top: 3px; color: var(--dsw-alias-label-secondary, #646a73); font: 11px/17px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-targetActions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
|
||||
.dim-targetFeedback { grid-column: 1 / -1; margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
|
||||
.dim-targetFeedback[data-tone="success"] { color: var(--dsw-alias-state-success-primary, #20a162); }
|
||||
.dim-targetFeedback[data-tone="error"], .dim-targetFormError { color: var(--dsw-alias-state-error-primary, #d54941); }
|
||||
.dim-targetDeleteConfirm { grid-column: 1 / -1; padding: 10px 12px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.dim-targetDeleteConfirm p { margin: 0 0 8px; font-size: 12px; line-height: 18px; }
|
||||
.dim-targetSuggestions { margin-top: 14px; padding: 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-targetSuggestionHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.dim-targetSuggestionHeading h3 { margin: 0; font-size: 14px; line-height: 21px; }
|
||||
.dim-targetSuggestionHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
|
||||
.dim-targetSuggestionState { margin-top: 12px; padding: 18px 12px; border: 1px dashed var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; text-align: center; }
|
||||
.dim-targetSuggestionState p { margin: 4px 0; }
|
||||
.dim-targetSuggestionState strong { color: var(--dsw-alias-label-primary, #1f2329); }
|
||||
.dim-targetSuggestionField { min-width: 0; display: grid; gap: 5px; margin-top: 12px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
||||
.dim-targetSuggestionField select { width: 100%; min-width: 0; height: 38px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.dim-targetSuggestionField select:focus { outline: 2px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 28%, transparent); border-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-targetForm { margin-top: 14px; padding: 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-targetFormHeading h3 { margin: 0; font-size: 14px; line-height: 21px; }
|
||||
.dim-targetFormHeading p { margin: 2px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 17px; }
|
||||
.dim-targetFormGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px 12px; margin-top: 12px; }
|
||||
.dim-targetField { min-width: 0; display: grid; align-content: start; gap: 5px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
||||
.dim-targetField input, .dim-targetField select { width: 100%; min-width: 0; height: 34px; padding: 0 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; }
|
||||
.dim-targetField input:focus, .dim-targetField select:focus { outline: 2px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 28%, transparent); border-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-targetField input[readonly] { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-targetFormError { margin: 10px 0 0; font-size: 12px; line-height: 18px; }
|
||||
.dim-targetFormActions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; }
|
||||
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
|
||||
.dim-panel .ddt-headingCopy { display: none; }
|
||||
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
|
||||
|
|
@ -376,6 +440,10 @@ const CSS = String.raw`
|
|||
.dim-panel .ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; }
|
||||
.dim-panel .ddt-qrColumn { width: 100%; min-width: 0; }
|
||||
.dim-panel .ddt-qrCopy { width: 100%; min-width: 0; overflow-wrap: anywhere; }
|
||||
.dim-targetRow { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-targetActions { justify-content: flex-start; }
|
||||
.dim-targetFormGrid { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-targetSuggestionHeading { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
@media (max-width: 840px) {
|
||||
.dim-title { align-items: flex-start; }
|
||||
|
|
@ -399,6 +467,11 @@ const CSS = String.raw`
|
|||
.dim-rail { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
|
||||
.dim-loopbackRecoveryAction { width: 100%; }
|
||||
.dim-deliveryHeader, .dim-deliverySectionHeading { align-items: stretch; flex-direction: column; }
|
||||
.dim-deliveryHeaderMeta { justify-content: space-between; text-align: left; }
|
||||
.dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
|
||||
.dim-deliveryBotId > span { grid-column: 1 / -1; }
|
||||
.dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
|
||||
.dim-directoryPickerBackdrop { padding: 10px; }
|
||||
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
|
||||
.dim-directoryPickerHeader { padding: 18px 17px 14px; }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export async function apply(ctx, config = {}) {
|
|||
}
|
||||
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installDingtalkRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
|
|
@ -17,6 +19,7 @@ export async function apply(ctx, config = {}) {
|
|||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-dingtalk: close bot connections');
|
||||
return disposeRpc;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -149,6 +150,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'dingtalk', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ export async function apply(ctx, config = {}) {
|
|||
return installDiscordRpc(ctx, config.controller, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installDiscordRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Discord bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Discord bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export async function apply(ctx, config = {}) {
|
|||
}
|
||||
|
||||
const production = await createProductionController(ctx, config);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installFeishuRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
|
|
@ -36,6 +38,7 @@ export async function apply(ctx, config = {}) {
|
|||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-feishu: close controller and live connection');
|
||||
return disposeRpc;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
|
||||
function webSocketProxyUrl(env) {
|
||||
for (const key of ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY']) {
|
||||
|
|
@ -204,6 +205,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'feishu', workspaces, coreController, stateFor: stateForBotId,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ export async function apply(ctx, config = {}) {
|
|||
return installQqRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installQqRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close QQ bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close QQ bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -139,6 +140,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({ channel: 'qq', workspaces, coreController, stateFor }),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import {
|
||||
createDeliveryAdapter,
|
||||
supportsDeliveryChannel,
|
||||
} from '../../delivery-adapter.mjs';
|
||||
|
||||
export function pluginPaths(config, channel) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
|
|
@ -142,6 +146,9 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
...(supportsDeliveryChannel(channel) ? {
|
||||
deliveryAdapter: createDeliveryAdapter({ channel, workspaces, coreController, stateFor }),
|
||||
} : {}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ export const inject = ['connection', 'credentials', 'typertGateway'];
|
|||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installSlackRpc(ctx, config.controller, config.rpcAuthority);
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installSlackRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Slack bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Slack bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { pluginPaths } from '../shared/production.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
|
|
@ -129,6 +130,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'slack', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ export async function apply(ctx, config = {}) {
|
|||
return installTelegramRpc(ctx, config.controller, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installTelegramRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Telegram bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Telegram bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ export async function apply(ctx, config = {}) {
|
|||
return installWecomRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installWecomRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Enterprise WeChat bot connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close Enterprise WeChat bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -143,6 +144,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'wecom', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export async function apply(ctx, config = {}) {
|
|||
}
|
||||
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installWeixinRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
|
|
@ -17,6 +19,7 @@ export async function apply(ctx, config = {}) {
|
|||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-weixin: close account connections');
|
||||
return disposeRpc;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -146,6 +147,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'weixin', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ export async function apply(ctx, config = {}) {
|
|||
return installWhatsappRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const unregisterDelivery = config.deliveryService && production.deliveryAdapter
|
||||
? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
|
||||
const disposeRpc = installWhatsappRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close WhatsApp Web connections');
|
||||
ctx.effect(() => async () => {
|
||||
await unregisterDelivery?.();
|
||||
await production.close();
|
||||
}, 'dsh-im: close WhatsApp Web connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
observeBotWorkspaceRemovals,
|
||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
|
@ -151,6 +152,9 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
}).start();
|
||||
return {
|
||||
controller,
|
||||
deliveryAdapter: createDeliveryAdapter({
|
||||
channel: 'whatsapp', workspaces, coreController, stateFor,
|
||||
}),
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
|
|
|
|||
179
plugin-src/host/delivery-adapter.mjs
Normal file
179
plugin-src/host/delivery-adapter.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { deliverySuggestionsFromSessions } from './delivery-suggestions.mjs';
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'weixin',
|
||||
'feishu',
|
||||
'dingtalk',
|
||||
'wecom',
|
||||
'qq',
|
||||
'slack',
|
||||
'telegram',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
]);
|
||||
|
||||
export function supportsDeliveryChannel(channel) {
|
||||
return CHANNELS.has(channel);
|
||||
}
|
||||
|
||||
function invalidTarget(message) {
|
||||
const error = new Error(message);
|
||||
error.code = 'invalid-target';
|
||||
return error;
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value, allowed, label) {
|
||||
if (!isRecord(value)) throw invalidTarget(`${label} must be an object`);
|
||||
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
||||
if (extra !== undefined) throw invalidTarget(`${label} contains an unknown field`);
|
||||
}
|
||||
|
||||
function requiredString(value, label) {
|
||||
if (typeof value !== 'string' || !value || value.trim() !== value) {
|
||||
throw invalidTarget(`${label} must be a non-empty string without surrounding whitespace`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetName(value) {
|
||||
if (typeof value !== 'string' || !value.trim() || value.trim().length > 80) {
|
||||
throw invalidTarget('target.name must contain 1 to 80 characters');
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function routeWithStrings(route, fields) {
|
||||
exactKeys(route, fields, 'route');
|
||||
const normalized = {};
|
||||
for (const field of fields) normalized[field] = requiredString(route[field], `route.${field}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function oneOf(value, choices) {
|
||||
if (!choices.includes(value)) throw invalidTarget('target kind is not supported by this channel');
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeRoute(channel, kind, route) {
|
||||
switch (channel) {
|
||||
case 'weixin':
|
||||
oneOf(kind, ['user']);
|
||||
return routeWithStrings(route, ['toUserId']);
|
||||
case 'feishu':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, kind === 'user' ? ['openId'] : ['chatId']);
|
||||
case 'dingtalk':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, kind === 'user' ? ['userId'] : ['openConversationId']);
|
||||
case 'wecom':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, ['chatId']);
|
||||
case 'qq':
|
||||
oneOf(kind, ['user', 'group']);
|
||||
return routeWithStrings(route, kind === 'user' ? ['userOpenId'] : ['groupOpenId']);
|
||||
case 'slack': {
|
||||
oneOf(kind, ['conversation', 'thread']);
|
||||
const normalized = routeWithStrings(
|
||||
route,
|
||||
kind === 'conversation' ? ['channelId'] : ['channelId', 'threadTs'],
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
case 'telegram': {
|
||||
oneOf(kind, ['chat', 'topic']);
|
||||
exactKeys(route, kind === 'chat' ? ['chatId'] : ['chatId', 'messageThreadId'], 'route');
|
||||
const chatId = requiredString(route.chatId, 'route.chatId');
|
||||
if (!/^-?\d+$/.test(chatId)) throw invalidTarget('route.chatId must be a decimal string');
|
||||
if (kind === 'chat') return { chatId };
|
||||
if (!Number.isSafeInteger(route.messageThreadId) || route.messageThreadId <= 0) {
|
||||
throw invalidTarget('route.messageThreadId must be a positive integer');
|
||||
}
|
||||
return { chatId, messageThreadId: route.messageThreadId };
|
||||
}
|
||||
case 'discord':
|
||||
oneOf(kind, ['channel']);
|
||||
return routeWithStrings(route, ['channelId']);
|
||||
case 'whatsapp': {
|
||||
oneOf(kind, ['user', 'group']);
|
||||
const normalized = routeWithStrings(route, ['jid']);
|
||||
const valid = kind === 'user'
|
||||
? /^\d{5,32}@(s\.whatsapp\.net|lid)$/.test(normalized.jid)
|
||||
: /^\d{5,32}(?:-\d{1,32})?@g\.us$/.test(normalized.jid);
|
||||
if (!valid) {
|
||||
throw invalidTarget(`route.jid must be a ${kind} WhatsApp JID`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
default:
|
||||
throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strictly validate and copy one channel delivery target. */
|
||||
export function normalizeDeliveryTarget(channel, value, { targetIdRequired = true } = {}) {
|
||||
if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
||||
exactKeys(
|
||||
value,
|
||||
targetIdRequired ? ['targetId', 'name', 'kind', 'route'] : ['name', 'kind', 'route'],
|
||||
'target',
|
||||
);
|
||||
const normalized = {};
|
||||
if (targetIdRequired) normalized.targetId = requiredString(value.targetId, 'target.targetId');
|
||||
if (value.name !== undefined) normalized.name = targetName(value.name);
|
||||
normalized.kind = requiredString(value.kind, 'target.kind');
|
||||
normalized.route = normalizeRoute(channel, normalized.kind, value.route);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Bind one channel's existing workspace store and unwrapped controller to DeliveryService. */
|
||||
export function createDeliveryAdapter({ channel, workspaces, coreController, stateFor }) {
|
||||
if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
||||
if (!workspaces || typeof workspaces !== 'object') {
|
||||
throw new TypeError('delivery adapter requires a workspace store');
|
||||
}
|
||||
if (!coreController || typeof coreController !== 'object') {
|
||||
throw new TypeError('delivery adapter requires a proactive text controller');
|
||||
}
|
||||
if (typeof stateFor !== 'function') {
|
||||
throw new TypeError('delivery adapter requires a bot state getter');
|
||||
}
|
||||
return Object.freeze({
|
||||
channel,
|
||||
ownsBot: (botId) => workspaces.has(botId),
|
||||
listTargets: (botId) => workspaces.listDeliveryTargets(botId),
|
||||
async listSuggestions(botId) {
|
||||
const state = await stateFor(botId);
|
||||
if (!state || typeof state.snapshot !== 'function') {
|
||||
throw new TypeError('delivery suggestion state cannot be inspected');
|
||||
}
|
||||
const suggestions = deliverySuggestionsFromSessions(channel, state.snapshot()?.sessions);
|
||||
return suggestions.map((suggestion) => normalizeDeliveryTarget(
|
||||
channel,
|
||||
suggestion,
|
||||
{ targetIdRequired: false },
|
||||
));
|
||||
},
|
||||
createTarget: (botId, target) => workspaces.createDeliveryTarget(
|
||||
botId,
|
||||
normalizeDeliveryTarget(channel, target),
|
||||
),
|
||||
updateTarget: (botId, targetId, replacement) => workspaces.updateDeliveryTarget(
|
||||
botId,
|
||||
targetId,
|
||||
normalizeDeliveryTarget(channel, replacement, { targetIdRequired: false }),
|
||||
),
|
||||
deleteTarget: (botId, targetId) => workspaces.deleteDeliveryTarget(botId, targetId),
|
||||
async sendText(botId, target, text, options = {}) {
|
||||
const normalized = normalizeDeliveryTarget(channel, target);
|
||||
if (typeof coreController.sendProactiveText !== 'function') {
|
||||
throw new TypeError('delivery controller cannot send proactive text');
|
||||
}
|
||||
await coreController.sendProactiveText(botId, normalized, text, options);
|
||||
return { sent: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
158
plugin-src/host/delivery-rpc.mjs
Normal file
158
plugin-src/host/delivery-rpc.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { resolveRpcAuthority } from './rpc-authority.mjs';
|
||||
|
||||
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
||||
export const DELIVERY_TEST_MESSAGE = 'DSH-IM 主动投递测试成功。';
|
||||
export const DELIVERY_ENDPOINTS = Object.freeze({
|
||||
send: 'message.send',
|
||||
listTargets: 'target.list',
|
||||
listSuggestions: 'target.suggestion.list',
|
||||
createTarget: 'target.create',
|
||||
updateTarget: 'target.update',
|
||||
deleteTarget: 'target.delete',
|
||||
testTarget: 'target.test',
|
||||
});
|
||||
|
||||
const ENDPOINTS = new Set(Object.values(DELIVERY_ENDPOINTS));
|
||||
const PUBLIC_ERRORS = new Set([
|
||||
'bad-request',
|
||||
'unknown-bot',
|
||||
'unknown-target',
|
||||
'target-conflict',
|
||||
'invalid-target',
|
||||
'bot-not-connected',
|
||||
'target-rejected',
|
||||
'delivery-failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value, keys) {
|
||||
return isRecord(value)
|
||||
&& Object.keys(value).length === keys.length
|
||||
&& Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
|
||||
function validBotId(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validTargetId(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9._:@-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validTarget(value, { targetId }) {
|
||||
const keys = targetId ? ['targetId', 'name', 'kind', 'route'] : ['name', 'kind', 'route'];
|
||||
if (!isRecord(value) || Object.keys(value).some((key) => !keys.includes(key))) return false;
|
||||
if (targetId && !validTargetId(value.targetId)) return false;
|
||||
if (value.name !== undefined
|
||||
&& (typeof value.name !== 'string' || !value.name.trim() || value.name.trim().length > 80)) return false;
|
||||
return typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
||||
&& isRecord(value.route);
|
||||
}
|
||||
|
||||
function validDraftTarget(value) {
|
||||
return exactKeys(value, ['kind', 'route'])
|
||||
&& typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
||||
&& isRecord(value.route);
|
||||
}
|
||||
|
||||
function validPayload(endpoint, payload) {
|
||||
if (!ENDPOINTS.has(endpoint) || !isRecord(payload)) return false;
|
||||
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
||||
return exactKeys(payload, ['botId', 'targetId', 'text'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
||||
&& typeof payload.text === 'string' && Boolean(payload.text.trim());
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.listTargets
|
||||
|| endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
return exactKeys(payload, ['botId']) && validBotId(payload.botId);
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
||||
return exactKeys(payload, ['botId', 'target'])
|
||||
&& validBotId(payload.botId) && validTarget(payload.target, { targetId: true });
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
||||
return exactKeys(payload, ['botId', 'targetId', 'target'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
||||
&& validTarget(payload.target, { targetId: false });
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.testTarget) {
|
||||
return (exactKeys(payload, ['botId', 'targetId'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId))
|
||||
|| (exactKeys(payload, ['botId', 'target'])
|
||||
&& validBotId(payload.botId) && validDraftTarget(payload.target));
|
||||
}
|
||||
return exactKeys(payload, ['botId', 'targetId'])
|
||||
&& validBotId(payload.botId) && validTargetId(payload.targetId);
|
||||
}
|
||||
|
||||
function publicError(error) {
|
||||
const code = PUBLIC_ERRORS.has(error?.code) ? error.code : 'delivery-failed';
|
||||
return { code, message: code, details: {} };
|
||||
}
|
||||
|
||||
export function createDeliveryRpcHandler(service) {
|
||||
for (const method of [
|
||||
'send',
|
||||
'listTargets',
|
||||
'listSuggestions',
|
||||
'createTarget',
|
||||
'updateTarget',
|
||||
'deleteTarget',
|
||||
]) {
|
||||
if (typeof service?.[method] !== 'function') {
|
||||
throw new TypeError(`A complete delivery service is required (${method})`);
|
||||
}
|
||||
}
|
||||
return async (endpoint, payload, signal) => {
|
||||
if (!validPayload(endpoint, payload)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'bad-request', message: 'Invalid delivery request.', details: {} },
|
||||
};
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } };
|
||||
}
|
||||
try {
|
||||
let value;
|
||||
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
||||
value = await service.send(payload.botId, payload.targetId, payload.text, { signal });
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.listTargets) {
|
||||
value = await service.listTargets(payload.botId);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
value = await service.listSuggestions(payload.botId);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
||||
value = await service.createTarget(payload.botId, payload.target);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
||||
value = await service.updateTarget(payload.botId, payload.targetId, payload.target);
|
||||
} else if (endpoint === DELIVERY_ENDPOINTS.deleteTarget) {
|
||||
value = await service.deleteTarget(payload.botId, payload.targetId);
|
||||
} else {
|
||||
value = await service.send(
|
||||
payload.botId,
|
||||
Object.hasOwn(payload, 'target') ? payload.target : payload.targetId,
|
||||
DELIVERY_TEST_MESSAGE,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return { ok: false, error: publicError(error) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function installDeliveryRpc(ctx, service, { authority } = {}) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
createDeliveryRpcHandler(service),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
224
plugin-src/host/delivery-service.mjs
Normal file
224
plugin-src/host/delivery-service.mjs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
|
||||
|
||||
const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
|
||||
const CHANNEL_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const DRAFT_TARGET_ID = '__test__';
|
||||
|
||||
const ADAPTER_METHODS = Object.freeze([
|
||||
'ownsBot',
|
||||
'listTargets',
|
||||
'listSuggestions',
|
||||
'createTarget',
|
||||
'updateTarget',
|
||||
'deleteTarget',
|
||||
'sendText',
|
||||
]);
|
||||
|
||||
const DELIVERY_ERROR_CODES = new Set([
|
||||
'bad-request',
|
||||
'unknown-bot',
|
||||
'unknown-target',
|
||||
'target-conflict',
|
||||
'invalid-target',
|
||||
'bot-not-connected',
|
||||
'target-rejected',
|
||||
'delivery-failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
function deliveryError(code, message = code, options) {
|
||||
const error = new Error(message, options);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function botIdOf(value) {
|
||||
if (typeof value !== 'string' || !BOT_ID_PATTERN.test(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid bot id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetIdOf(value) {
|
||||
if (typeof value !== 'string' || !TARGET_ID_PATTERN.test(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid target id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetObject(value, { includesTargetId } = {}) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid target');
|
||||
}
|
||||
if (includesTargetId) targetIdOf(value.targetId);
|
||||
else if (Object.hasOwn(value, 'targetId')) {
|
||||
throw deliveryError('bad-request', 'A target id cannot be changed');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function draftTargetObject(value) {
|
||||
targetObject(value);
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 2 || !keys.includes('kind') || !keys.includes('route')) {
|
||||
throw deliveryError('bad-request', 'Invalid draft target');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cancellation(signal) {
|
||||
if (signal?.aborted) throw deliveryError('cancelled', 'Request cancelled');
|
||||
}
|
||||
|
||||
function publicOperationError(error, fallback = 'delivery-failed') {
|
||||
if (error?.code === 'workspace-bot-not-found') {
|
||||
return deliveryError('unknown-bot', 'Unknown bot', { cause: error });
|
||||
}
|
||||
if (DELIVERY_ERROR_CODES.has(error?.code)) return error;
|
||||
return deliveryError(fallback, fallback, { cause: error });
|
||||
}
|
||||
|
||||
function validateAdapter(adapter) {
|
||||
if (!adapter || typeof adapter !== 'object'
|
||||
|| typeof adapter.channel !== 'string' || !CHANNEL_PATTERN.test(adapter.channel)) {
|
||||
throw new TypeError('A delivery adapter with a valid channel is required');
|
||||
}
|
||||
for (const method of ADAPTER_METHODS) {
|
||||
if (typeof adapter[method] !== 'function') {
|
||||
throw new TypeError(`A complete delivery adapter is required (${method})`);
|
||||
}
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export class DeliveryService {
|
||||
#adapters = new Map();
|
||||
|
||||
registerAdapter(value) {
|
||||
const adapter = validateAdapter(value);
|
||||
const registration = Object.freeze({ adapter });
|
||||
this.#adapters.set(adapter.channel, registration);
|
||||
return () => {
|
||||
if (this.#adapters.get(adapter.channel) !== registration) return false;
|
||||
this.#adapters.delete(adapter.channel);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
async listTargets(botId) {
|
||||
const id = botIdOf(botId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
const targets = await adapter.listTargets(id);
|
||||
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
||||
return { botId: id, channel: adapter.channel, targets };
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listSuggestions(botId) {
|
||||
const id = botIdOf(botId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
const suggestions = await adapter.listSuggestions(id);
|
||||
if (!Array.isArray(suggestions)) throw new TypeError('Adapter returned invalid suggestions');
|
||||
return {
|
||||
botId: id,
|
||||
channel: adapter.channel,
|
||||
suggestions: suggestions.map((suggestion) => normalizeDeliveryTarget(
|
||||
adapter.channel,
|
||||
suggestion,
|
||||
{ targetIdRequired: false },
|
||||
)),
|
||||
};
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createTarget(botId, target) {
|
||||
const id = botIdOf(botId);
|
||||
targetObject(target, { includesTargetId: true });
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
return await adapter.createTarget(id, target);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTarget(botId, targetId, replacement) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
targetObject(replacement);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
return await adapter.updateTarget(id, targetKey, replacement);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTarget(botId, targetId) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
await adapter.deleteTarget(id, targetKey);
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async send(botId, targetIdOrDraft, text, { signal } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = typeof targetIdOrDraft === 'string'
|
||||
? targetIdOf(targetIdOrDraft)
|
||||
: null;
|
||||
const draft = targetKey === null ? draftTargetObject(targetIdOrDraft) : null;
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw deliveryError('bad-request', 'Message text is required');
|
||||
}
|
||||
cancellation(signal);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
let target;
|
||||
if (draft) {
|
||||
target = { targetId: DRAFT_TARGET_ID, ...draft };
|
||||
} else {
|
||||
const targets = await adapter.listTargets(id);
|
||||
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
||||
target = targets.find((candidate) => candidate?.targetId === targetKey);
|
||||
if (!target) throw deliveryError('unknown-target', 'Unknown target');
|
||||
}
|
||||
cancellation(signal);
|
||||
await adapter.sendText(id, target, text, { signal });
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
||||
throw deliveryError('cancelled', 'Request cancelled', { cause: error });
|
||||
}
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #adapterFor(botId) {
|
||||
for (const { adapter } of this.#adapters.values()) {
|
||||
let ownsBot;
|
||||
try {
|
||||
ownsBot = await adapter.ownsBot(botId);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
if (ownsBot) return adapter;
|
||||
}
|
||||
throw deliveryError('unknown-bot', 'Unknown bot');
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeliveryService() {
|
||||
return new DeliveryService();
|
||||
}
|
||||
135
plugin-src/host/delivery-suggestions.mjs
Normal file
135
plugin-src/host/delivery-suggestions.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
const CHANNELS = new Set([
|
||||
'weixin',
|
||||
'feishu',
|
||||
'dingtalk',
|
||||
'wecom',
|
||||
'qq',
|
||||
'slack',
|
||||
'telegram',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function opaqueId(value) {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& value.length <= 512
|
||||
&& value.trim() === value
|
||||
&& !/[\s:\u0000-\u001f\u007f]/u.test(value);
|
||||
}
|
||||
|
||||
function afterPrefix(key, prefix) {
|
||||
const marker = `${prefix}:`;
|
||||
if (!key.startsWith(marker)) return null;
|
||||
const value = key.slice(marker.length);
|
||||
return opaqueId(value) ? value : null;
|
||||
}
|
||||
|
||||
function simpleSuggestion(key, definitions) {
|
||||
for (const [prefix, kind, field] of definitions) {
|
||||
const value = afterPrefix(key, prefix);
|
||||
if (value) return { kind, route: { [field]: value } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function feishuSuggestion(key) {
|
||||
const openId = afterPrefix(key, 'p2p');
|
||||
if (openId?.startsWith('ou_')) {
|
||||
return { kind: 'user', route: { openId } };
|
||||
}
|
||||
return simpleSuggestion(key, [['group', 'group', 'chatId']]);
|
||||
}
|
||||
|
||||
function slackSuggestion(key) {
|
||||
const directChannel = afterPrefix(key, 'direct');
|
||||
if (directChannel && /^[A-Za-z0-9_-]{1,128}$/.test(directChannel)) {
|
||||
return { kind: 'conversation', route: { channelId: directChannel } };
|
||||
}
|
||||
const match = /^group:([^:]+):(\d{1,20}(?:\.\d{1,20})?)$/.exec(key);
|
||||
if (!match || !/^[A-Za-z0-9_-]{1,128}$/.test(match[1])) return null;
|
||||
return { kind: 'thread', route: { channelId: match[1], threadTs: match[2] } };
|
||||
}
|
||||
|
||||
function telegramSuggestion(key) {
|
||||
const match = /^(direct|group):(-?\d+)(?::([1-9]\d*))?$/.exec(key);
|
||||
if (!match) return null;
|
||||
const chatId = Number(match[2]);
|
||||
if (!Number.isSafeInteger(chatId)) return null;
|
||||
if (match[1] === 'direct' && match[3] !== undefined) return null;
|
||||
if (match[3] === undefined) return { kind: 'chat', route: { chatId: match[2] } };
|
||||
const messageThreadId = Number(match[3]);
|
||||
if (!Number.isSafeInteger(messageThreadId) || messageThreadId <= 0) return null;
|
||||
return { kind: 'topic', route: { chatId: match[2], messageThreadId } };
|
||||
}
|
||||
|
||||
function discordSuggestion(key) {
|
||||
const match = /^(?:direct|group):(\d{1,32})$/.exec(key);
|
||||
return match ? { kind: 'channel', route: { channelId: match[1] } } : null;
|
||||
}
|
||||
|
||||
function whatsappSuggestion(key) {
|
||||
const direct = /^direct:(\d{5,32}@(s\.whatsapp\.net|lid))$/.exec(key);
|
||||
if (direct) return { kind: 'user', route: { jid: direct[1] } };
|
||||
const group = /^group:(\d{5,32}(?:-\d{1,32})?@g\.us)$/.exec(key);
|
||||
return group ? { kind: 'group', route: { jid: group[1] } } : null;
|
||||
}
|
||||
|
||||
/** Convert one persisted conversation key into a stable proactive-delivery route. */
|
||||
export function deliverySuggestionFromConversationKey(channel, key) {
|
||||
if (!CHANNELS.has(channel) || typeof key !== 'string') return null;
|
||||
switch (channel) {
|
||||
case 'weixin':
|
||||
return simpleSuggestion(key, [['p2p', 'user', 'toUserId']]);
|
||||
case 'feishu':
|
||||
return feishuSuggestion(key);
|
||||
case 'dingtalk':
|
||||
return simpleSuggestion(key, [
|
||||
['p2p', 'user', 'userId'],
|
||||
['group', 'group', 'openConversationId'],
|
||||
]);
|
||||
case 'wecom':
|
||||
return simpleSuggestion(key, [
|
||||
['direct', 'user', 'chatId'],
|
||||
['group', 'group', 'chatId'],
|
||||
]);
|
||||
case 'qq':
|
||||
return simpleSuggestion(key, [
|
||||
['c2c', 'user', 'userOpenId'],
|
||||
['group', 'group', 'groupOpenId'],
|
||||
]);
|
||||
case 'slack':
|
||||
return slackSuggestion(key);
|
||||
case 'telegram':
|
||||
return telegramSuggestion(key);
|
||||
case 'discord':
|
||||
return discordSuggestion(key);
|
||||
case 'whatsapp':
|
||||
return whatsappSuggestion(key);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and de-duplicate stable delivery routes from a persisted sessions map.
|
||||
* Session ids and every other state field are intentionally ignored.
|
||||
*/
|
||||
export function deliverySuggestionsFromSessions(channel, sessions) {
|
||||
if (!CHANNELS.has(channel) || !isRecord(sessions)) return [];
|
||||
const suggestions = [];
|
||||
const seen = new Set();
|
||||
for (const key of Object.keys(sessions)) {
|
||||
const suggestion = deliverySuggestionFromConversationKey(channel, key);
|
||||
if (!suggestion) continue;
|
||||
const identity = JSON.stringify(suggestion);
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
suggestions.push(suggestion);
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
|||
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 { createDeliveryService } from './delivery-service.mjs';
|
||||
import { installUpdateRpc } from './update-rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
|
|
@ -19,15 +21,18 @@ export const inject = [
|
|||
'typertGateway',
|
||||
];
|
||||
|
||||
function channelConfig(config, name) {
|
||||
function channelConfig(config, name, deliveryService) {
|
||||
const channel = config[name] ?? {};
|
||||
return config.rpcAuthority === undefined
|
||||
const withAuthority = config.rpcAuthority === undefined
|
||||
? channel
|
||||
: { ...channel, rpcAuthority: config.rpcAuthority };
|
||||
return name === 'office' ? withAuthority : { ...withAuthority, deliveryService };
|
||||
}
|
||||
|
||||
export function createImHostPlugin(internals = {}) {
|
||||
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
|
||||
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
|
||||
const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
|
||||
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
||||
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
||||
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
|
||||
|
|
@ -54,8 +59,17 @@ export function createImHostPlugin(internals = {}) {
|
|||
name,
|
||||
inject,
|
||||
async apply(ctx, config = {}) {
|
||||
const deliveryService = makeDeliveryService();
|
||||
if (typeof ctx?.provide === 'function') {
|
||||
ctx.provide('dshIm', Object.freeze({
|
||||
send: (botId, targetId, text, options) => (
|
||||
deliveryService.send(botId, targetId, text, options)
|
||||
),
|
||||
listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
|
||||
}));
|
||||
}
|
||||
const activate = async (readyCtx) => {
|
||||
await activateChannels(readyCtx, config);
|
||||
await activateChannels(readyCtx, config, deliveryService);
|
||||
};
|
||||
if (typeof ctx?.inject === 'function') {
|
||||
const modern = typeof ctx?.typertGateway?.stream === 'function';
|
||||
|
|
@ -69,7 +83,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
},
|
||||
});
|
||||
|
||||
async function activateChannels(ctx, config) {
|
||||
async function activateChannels(ctx, config, deliveryService) {
|
||||
setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
|
||||
if (typeof ctx?.inject === 'function') {
|
||||
ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
|
||||
|
|
@ -87,11 +101,16 @@ export function createImHostPlugin(internals = {}) {
|
|||
} catch (error) {
|
||||
logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
|
||||
}
|
||||
try {
|
||||
startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
|
||||
} catch (error) {
|
||||
logger.error?.('[dsh-im] failed to activate delivery management; continuing with channels', error);
|
||||
}
|
||||
}
|
||||
const failures = [];
|
||||
for (const [channel, start] of channels) {
|
||||
try {
|
||||
await start(ctx, channelConfig(config, channel));
|
||||
await start(ctx, channelConfig(config, channel, deliveryService));
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ const required = [
|
|||
'bin/dsh-im.mjs',
|
||||
'cordis.patch.yml',
|
||||
'README.md',
|
||||
'README.en.md',
|
||||
'PROACTIVE_DELIVERY.md',
|
||||
'PROACTIVE_DELIVERY.en.md',
|
||||
'THIRD_PARTY_NOTICES.md',
|
||||
'plugin-src/client/channels/dingtalk/index.js',
|
||||
'plugin-src/client/channels/slack/index.js',
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,39 @@ export function createDingtalkApi({
|
|||
return true;
|
||||
},
|
||||
|
||||
async sendRobotText({ clientId, clientSecret, target, text, signal }) {
|
||||
if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
|
||||
const content = text;
|
||||
const normalizedTarget = normalizeFileTarget(target);
|
||||
const token = await accessToken({ clientId, clientSecret, signal });
|
||||
const body = {
|
||||
robotCode: normalizedTarget.robotCode,
|
||||
msgKey: 'sampleText',
|
||||
msgParam: JSON.stringify({ content }),
|
||||
...(normalizedTarget.type === 'group'
|
||||
? { openConversationId: normalizedTarget.openConversationId }
|
||||
: { userIds: [normalizedTarget.userId] }),
|
||||
};
|
||||
const pathname = normalizedTarget.type === 'group'
|
||||
? 'v1.0/robot/groupMessages/send'
|
||||
: 'v1.0/robot/oToMessages/batchSend';
|
||||
const response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
|
||||
body,
|
||||
headers: { 'x-acs-dingtalk-access-token': token },
|
||||
signal,
|
||||
action: '主动文字消息发送',
|
||||
});
|
||||
const rejection = rejectedProviderResponse(response);
|
||||
if (rejection) {
|
||||
throw new DingtalkApiError(
|
||||
'send-rejected',
|
||||
'钉钉服务拒绝了主动文字消息。',
|
||||
{ providerCode: rejection },
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
async sendFile(request) {
|
||||
return sendArtifact(request, {
|
||||
uploadType: 'file',
|
||||
|
|
|
|||
|
|
@ -406,6 +406,20 @@ export class DingtalkController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown DingTalk bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error(t('钉钉消息连接当前离线'));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes one bot, its secret, runtime, and local conversation state. */
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
|
|
|
|||
|
|
@ -388,6 +388,45 @@ export class DingtalkRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
const userId = typeof target?.route?.userId === 'string'
|
||||
? target.route.userId.trim() : '';
|
||||
const openConversationId = typeof target?.route?.openConversationId === 'string'
|
||||
? target.route.openConversationId.trim() : '';
|
||||
if ((target?.kind === 'user' && (!userId || openConversationId))
|
||||
|| (target?.kind === 'group' && (!openConversationId || userId))
|
||||
|| (target?.kind !== 'user' && target?.kind !== 'group')) {
|
||||
const error = new TypeError('Invalid DingTalk proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
if (!this.#status.ready || !this.#abortController) {
|
||||
const error = new Error('DingTalk runtime is not connected');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
await this.#api.sendRobotText({
|
||||
clientId: this.#config.clientId,
|
||||
clientSecret: this.#clientSecret,
|
||||
target: {
|
||||
type: target.kind,
|
||||
robotCode: this.#config.clientId,
|
||||
...(target.kind === 'user' ? { userId } : { openConversationId }),
|
||||
},
|
||||
text,
|
||||
signal: signal ?? this.#abortController.signal,
|
||||
});
|
||||
} catch (cause) {
|
||||
if (cause?.code !== 'send-rejected') throw cause;
|
||||
const error = new Error('DingTalk rejected the proactive delivery target', { cause });
|
||||
error.code = 'target-rejected';
|
||||
throw error;
|
||||
}
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
#pendingSenders() {
|
||||
return typeof this.#state.pendingSenders === 'function'
|
||||
? this.#state.pendingSenders()
|
||||
|
|
|
|||
|
|
@ -501,6 +501,22 @@ export class DiscordRuntime {
|
|||
return this.#bridge.sendConnectionTest(text);
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, options = {}) {
|
||||
if (!this.#status.ready || !this.#bridge) {
|
||||
const error = new Error('Discord bot is not connected');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
const channelId = typeof target?.route?.channelId === 'string'
|
||||
? target.route.channelId.trim() : '';
|
||||
if (target?.kind !== 'channel' || !channelId) {
|
||||
const error = new TypeError('Invalid Discord proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
return this.#bridge.sendProactiveText({ channelId }, text, options);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#socket) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -563,6 +563,41 @@ export class FeishuRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
if (!this.#status.ready || !this.#client) {
|
||||
const error = new Error('飞书机器人尚未连接');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
const receiveIdType = target?.kind === 'user' ? 'open_id'
|
||||
: target?.kind === 'group' ? 'chat_id' : null;
|
||||
const receiveId = target?.kind === 'user'
|
||||
? nonEmptyString(target?.route?.openId)
|
||||
: target?.kind === 'group'
|
||||
? nonEmptyString(target?.route?.chatId)
|
||||
: null;
|
||||
if (!receiveIdType || !receiveId) {
|
||||
const error = new TypeError('Invalid Feishu proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const response = await this.#client.im.v1.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text }),
|
||||
},
|
||||
});
|
||||
if (response?.code && response.code !== 0) {
|
||||
const error = new Error(`Feishu proactive delivery failed: ${response.msg || response.code}`);
|
||||
error.code = 'target-rejected';
|
||||
throw error;
|
||||
}
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
stop(options = {}) {
|
||||
if (this.#stopping) return this.#stopping;
|
||||
|
||||
|
|
|
|||
|
|
@ -537,6 +537,21 @@ export class MultiBotDshFeishuController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
this.#assertOpen();
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
this.#requireBot(botId);
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!isConnected(connectionStatus(runtime))
|
||||
|| typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error('飞书机器人尚未连接');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectBot(botId) {
|
||||
this.#assertOpen();
|
||||
// An operational pause only: credentials/config remain durable, so the
|
||||
|
|
|
|||
|
|
@ -268,6 +268,20 @@ export class QqController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown QQ bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error(t('QQ机器人尚未连接'));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown QQ bot');
|
||||
|
|
|
|||
|
|
@ -97,6 +97,29 @@ export class QqRuntime {
|
|||
return { sent: true };
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
const nativeId = target?.kind === 'user'
|
||||
? (typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '')
|
||||
: target?.kind === 'group'
|
||||
? (typeof target?.route?.groupOpenId === 'string' ? target.route.groupOpenId.trim() : '')
|
||||
: '';
|
||||
if (!nativeId) {
|
||||
const error = new TypeError('Invalid QQ proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
if (!this.#status.ready || !this.#bot) {
|
||||
const error = new Error('QQ bot is not connected');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return this.#bot.sendText({
|
||||
scope: target.kind === 'user' ? 'c2c' : 'group',
|
||||
targetId: nativeId,
|
||||
}, text);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#bot) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -49,8 +49,90 @@ function botIdOf(value) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function deliveryTargetError(code, message, options) {
|
||||
const error = new Error(message, options);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function targetIdOf(value) {
|
||||
if (typeof value !== 'string'
|
||||
|| !/^[A-Za-z0-9._:@-]{1,128}$/.test(value)) {
|
||||
throw deliveryTargetError('invalid-target', 'Invalid target id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonRecord(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw deliveryTargetError('invalid-target', 'Invalid target route');
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError('route is not JSON');
|
||||
const route = JSON.parse(serialized);
|
||||
if (!route || typeof route !== 'object' || Array.isArray(route)) {
|
||||
throw new TypeError('route is not an object');
|
||||
}
|
||||
return route;
|
||||
} catch (cause) {
|
||||
throw deliveryTargetError('invalid-target', 'Invalid target route', { cause });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeliveryTarget(value, { targetId } = {}) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw deliveryTargetError('invalid-target', 'Invalid delivery target');
|
||||
}
|
||||
const allowed = targetId === undefined
|
||||
? new Set(['targetId', 'name', 'kind', 'route'])
|
||||
: new Set(['name', 'kind', 'route']);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw deliveryTargetError('invalid-target', 'Invalid delivery target');
|
||||
}
|
||||
const id = targetIdOf(targetId ?? value.targetId);
|
||||
if (typeof value.kind !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(value.kind)) {
|
||||
throw deliveryTargetError('invalid-target', 'Invalid target kind');
|
||||
}
|
||||
let name;
|
||||
if (value.name !== undefined) {
|
||||
if (typeof value.name !== 'string' || !value.name.trim() || value.name.trim().length > 80) {
|
||||
throw deliveryTargetError('invalid-target', 'Invalid target name');
|
||||
}
|
||||
name = value.name.trim();
|
||||
}
|
||||
return {
|
||||
targetId: id,
|
||||
...(name === undefined ? {} : { name }),
|
||||
kind: value.kind,
|
||||
route: jsonRecord(value.route),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeliveryTargets(value) {
|
||||
const deliveryTargets = Object.create(null);
|
||||
if (value === undefined) return deliveryTargets;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
try {
|
||||
for (const [botId, targets] of Object.entries(value)) {
|
||||
botIdOf(botId);
|
||||
if (!targets || typeof targets !== 'object' || Array.isArray(targets)) return null;
|
||||
const normalizedTargets = Object.create(null);
|
||||
for (const [targetId, target] of Object.entries(targets)) {
|
||||
const normalized = normalizeDeliveryTarget(target, { targetId });
|
||||
const { targetId: _targetId, ...stored } = normalized;
|
||||
normalizedTargets[targetId] = stored;
|
||||
}
|
||||
deliveryTargets[botId] = normalizedTargets;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return deliveryTargets;
|
||||
}
|
||||
|
||||
function normalizeDocument(value) {
|
||||
if (!value || value.version !== 1 || !value.workspaces
|
||||
if (!value || ![1, 2].includes(value.version) || !value.workspaces
|
||||
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
|
||||
const workspaces = {};
|
||||
for (const [botId, workspace] of Object.entries(value.workspaces)) {
|
||||
|
|
@ -83,7 +165,16 @@ function normalizeDocument(value) {
|
|||
}
|
||||
}
|
||||
}
|
||||
return { version: 1, workspaces, agentPresets, contextEnhancement };
|
||||
if (value.version === 1 && value.deliveryTargets !== undefined) return null;
|
||||
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
|
||||
if (!deliveryTargets) return null;
|
||||
return {
|
||||
version: value.version,
|
||||
workspaces,
|
||||
agentPresets,
|
||||
contextEnhancement,
|
||||
deliveryTargets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateWorkspacePath(value) {
|
||||
|
|
@ -112,9 +203,11 @@ export async function validateWorkspacePath(value) {
|
|||
export class BotWorkspaceStore {
|
||||
#path;
|
||||
#defaultWorkspace;
|
||||
#version = 1;
|
||||
#workspaces = {};
|
||||
#agentPresets = {};
|
||||
#contextEnhancement = {};
|
||||
#deliveryTargets = Object.create(null);
|
||||
#generations = new Map();
|
||||
#nextGeneration = 1;
|
||||
#incarnations = new Map();
|
||||
|
|
@ -135,14 +228,18 @@ export class BotWorkspaceStore {
|
|||
try {
|
||||
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
||||
if (!normalized) throw new Error('dsh-im workspace config is invalid');
|
||||
this.#version = normalized.version;
|
||||
this.#workspaces = normalized.workspaces;
|
||||
this.#agentPresets = normalized.agentPresets;
|
||||
this.#contextEnhancement = normalized.contextEnhancement;
|
||||
this.#deliveryTargets = normalized.deliveryTargets;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
this.#version = 1;
|
||||
this.#workspaces = {};
|
||||
this.#agentPresets = {};
|
||||
this.#contextEnhancement = {};
|
||||
this.#deliveryTargets = Object.create(null);
|
||||
}
|
||||
this.#generations.clear();
|
||||
this.#nextGeneration = 1;
|
||||
|
|
@ -181,6 +278,83 @@ export class BotWorkspaceStore {
|
|||
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
|
||||
}
|
||||
|
||||
listDeliveryTargets(botId) {
|
||||
const id = botIdOf(botId);
|
||||
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
||||
return Object.entries(this.#deliveryTargets[id] ?? {})
|
||||
.map(([targetId, target]) => normalizeDeliveryTarget(target, { targetId }))
|
||||
.sort((left, right) => left.targetId.localeCompare(right.targetId));
|
||||
}
|
||||
|
||||
deliveryTargetFor(botId, targetId) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
||||
const target = this.#deliveryTargets[id]?.[targetKey];
|
||||
return target ? normalizeDeliveryTarget(target, { targetId: targetKey }) : null;
|
||||
}
|
||||
|
||||
async createDeliveryTarget(botId, value) {
|
||||
const id = botIdOf(botId);
|
||||
const target = normalizeDeliveryTarget(value);
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
||||
if (Object.hasOwn(this.#deliveryTargets[id] ?? {}, target.targetId)) {
|
||||
throw deliveryTargetError('target-conflict', 'Target already exists');
|
||||
}
|
||||
const { targetId, ...stored } = target;
|
||||
const next = {
|
||||
...this.#deliveryTargets,
|
||||
[id]: { ...(this.#deliveryTargets[id] ?? {}), [targetId]: stored },
|
||||
};
|
||||
await this.#persist(this.#contextEnhancement, next, 2);
|
||||
this.#deliveryTargets = next;
|
||||
this.#version = 2;
|
||||
return normalizeDeliveryTarget(stored, { targetId });
|
||||
});
|
||||
}
|
||||
|
||||
async updateDeliveryTarget(botId, targetId, value) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
const replacement = normalizeDeliveryTarget(value, { targetId: targetKey });
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
||||
if (!Object.hasOwn(this.#deliveryTargets[id] ?? {}, targetKey)) {
|
||||
throw deliveryTargetError('unknown-target', 'Unknown target');
|
||||
}
|
||||
const { targetId: _targetId, ...stored } = replacement;
|
||||
const next = {
|
||||
...this.#deliveryTargets,
|
||||
[id]: { ...this.#deliveryTargets[id], [targetKey]: stored },
|
||||
};
|
||||
await this.#persist(this.#contextEnhancement, next, 2);
|
||||
this.#deliveryTargets = next;
|
||||
this.#version = 2;
|
||||
return normalizeDeliveryTarget(stored, { targetId: targetKey });
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDeliveryTarget(botId, targetId) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
||||
if (!Object.hasOwn(this.#deliveryTargets[id] ?? {}, targetKey)) {
|
||||
throw deliveryTargetError('unknown-target', 'Unknown target');
|
||||
}
|
||||
const botTargets = { ...this.#deliveryTargets[id] };
|
||||
delete botTargets[targetKey];
|
||||
const next = { ...this.#deliveryTargets };
|
||||
if (Object.keys(botTargets).length > 0) next[id] = botTargets;
|
||||
else delete next[id];
|
||||
await this.#persist(this.#contextEnhancement, next, 2);
|
||||
this.#deliveryTargets = next;
|
||||
this.#version = 2;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
generationFor(botId) {
|
||||
return this.#generations.get(botIdOf(botId)) ?? null;
|
||||
}
|
||||
|
|
@ -471,6 +645,7 @@ export class BotWorkspaceStore {
|
|||
...Object.keys(this.#workspaces),
|
||||
...Object.keys(this.#agentPresets),
|
||||
...Object.keys(this.#contextEnhancement),
|
||||
...Object.keys(this.#deliveryTargets),
|
||||
...this.#dirtyRemovals,
|
||||
]);
|
||||
for (const botId of candidates) {
|
||||
|
|
@ -518,10 +693,13 @@ export class BotWorkspaceStore {
|
|||
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
|
||||
const hadPreset = Object.hasOwn(this.#agentPresets, id);
|
||||
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
|
||||
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement || this.#dirtyRemovals.has(id);
|
||||
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
|
||||
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement
|
||||
|| hadDeliveryTargets || this.#dirtyRemovals.has(id);
|
||||
delete this.#workspaces[id];
|
||||
delete this.#agentPresets[id];
|
||||
delete this.#contextEnhancement[id];
|
||||
delete this.#deliveryTargets[id];
|
||||
this.#generations.delete(id);
|
||||
this.#incarnations.delete(id);
|
||||
if (!needsCleanup) return {
|
||||
|
|
@ -551,14 +729,21 @@ export class BotWorkspaceStore {
|
|||
return queued;
|
||||
}
|
||||
|
||||
async #persist(contextEnhancement = this.#contextEnhancement) {
|
||||
const document = { version: 1, workspaces: this.#workspaces };
|
||||
async #persist(
|
||||
contextEnhancement = this.#contextEnhancement,
|
||||
deliveryTargets = this.#deliveryTargets,
|
||||
version = this.#version,
|
||||
) {
|
||||
const document = { version, workspaces: this.#workspaces };
|
||||
if (Object.keys(this.#agentPresets).length > 0) {
|
||||
document.agentPresets = this.#agentPresets;
|
||||
}
|
||||
if (Object.keys(contextEnhancement).length > 0) {
|
||||
document.contextEnhancement = contextEnhancement;
|
||||
}
|
||||
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
|
||||
document.deliveryTargets = deliveryTargets;
|
||||
}
|
||||
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.#path}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
||||
|
|
@ -572,7 +757,8 @@ export class BotWorkspaceStore {
|
|||
async #persistCurrentDocument() {
|
||||
if (Object.keys(this.#workspaces).length > 0
|
||||
|| Object.keys(this.#agentPresets).length > 0
|
||||
|| Object.keys(this.#contextEnhancement).length > 0) {
|
||||
|| Object.keys(this.#contextEnhancement).length > 0
|
||||
|| Object.keys(this.#deliveryTargets).length > 0) {
|
||||
await this.#persist();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -469,6 +469,11 @@ export class TextHarnessBridge {
|
|||
});
|
||||
}
|
||||
|
||||
sendProactiveText(target, text, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
return this.#bot.sendText(target, text);
|
||||
}
|
||||
|
||||
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts,
|
||||
|
|
|
|||
|
|
@ -227,6 +227,22 @@ export class TokenBotController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error(t('{label}机器人尚未连接', {
|
||||
label: this.#descriptor.label,
|
||||
}));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
||||
|
|
|
|||
|
|
@ -189,6 +189,20 @@ export class SlackController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Slack bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error(t('Slack机器人尚未连接'));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Slack bot');
|
||||
|
|
|
|||
|
|
@ -407,6 +407,30 @@ export class SlackRuntime {
|
|||
return this.#bridge.sendConnectionTest(text);
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, options = {}) {
|
||||
if (!this.#status.ready || !this.#bridge) {
|
||||
const error = new Error('Slack bot is not connected');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
const channelId = typeof target?.route?.channelId === 'string'
|
||||
? target.route.channelId.trim() : '';
|
||||
const threadTs = typeof target?.route?.threadTs === 'string'
|
||||
? target.route.threadTs.trim() : '';
|
||||
if (!channelId
|
||||
|| (target?.kind !== 'conversation' && target?.kind !== 'thread')
|
||||
|| (target.kind === 'thread' && !threadTs)
|
||||
|| (target.kind === 'conversation' && threadTs)) {
|
||||
const error = new TypeError('Invalid Slack proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
return this.#bridge.sendProactiveText({
|
||||
channelId,
|
||||
...(threadTs ? { threadTs } : {}),
|
||||
}, text, options);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#socket) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -719,6 +719,30 @@ export class TelegramRuntime {
|
|||
return this.#bridge.sendConnectionTest(text);
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, options = {}) {
|
||||
if (!this.#status.ready || !this.#bridge) {
|
||||
const error = new Error('Telegram bot is not connected');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
const chatIdText = typeof target?.route?.chatId === 'string'
|
||||
? target.route.chatId.trim() : '';
|
||||
const chatId = Number(chatIdText);
|
||||
const messageThreadId = target?.route?.messageThreadId;
|
||||
if (!/^-?\d+$/.test(chatIdText) || !Number.isSafeInteger(chatId)
|
||||
|| (target?.kind !== 'chat' && target?.kind !== 'topic')
|
||||
|| (target.kind === 'topic' && (!Number.isSafeInteger(messageThreadId) || messageThreadId <= 0))
|
||||
|| (target.kind === 'chat' && messageThreadId !== undefined)) {
|
||||
const error = new TypeError('Invalid Telegram proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
return this.#bridge.sendProactiveText({
|
||||
chatId,
|
||||
...(target.kind === 'topic' ? { messageThreadId } : {}),
|
||||
}, text, options);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#pollTask) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ export class WecomStateStore {
|
|||
await this.#persist();
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return structuredClone(this.#state);
|
||||
}
|
||||
|
||||
async remove() {
|
||||
try {
|
||||
await unlink(this.#path);
|
||||
|
|
|
|||
|
|
@ -257,6 +257,20 @@ export class WecomController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error(t('企业微信连接当前离线'));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
||||
|
|
|
|||
|
|
@ -220,6 +220,27 @@ export class WecomRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
const chatId = typeof target?.route?.chatId === 'string'
|
||||
? target.route.chatId.trim() : '';
|
||||
if ((target?.kind !== 'user' && target?.kind !== 'group') || !chatId) {
|
||||
const error = new TypeError('Invalid Enterprise WeChat proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
if (!this.#status.ready || !this.#client) {
|
||||
const error = new Error('Enterprise WeChat runtime is not connected');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await this.#client.sendMessage(chatId, {
|
||||
msgtype: 'markdown',
|
||||
markdown: { content: text },
|
||||
});
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async #stopActive() {
|
||||
const client = this.#client;
|
||||
const bridge = this.#bridge;
|
||||
|
|
|
|||
|
|
@ -299,6 +299,20 @@ export class WeixinController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Weixin account');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error(t('微信连接当前离线'));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown Weixin account');
|
||||
|
|
|
|||
|
|
@ -329,4 +329,28 @@ export class WeixinRuntime {
|
|||
});
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
const toUserId = typeof target?.route?.toUserId === 'string'
|
||||
? target.route.toUserId.trim() : '';
|
||||
if (target?.kind !== 'user' || !toUserId) {
|
||||
const error = new TypeError('Invalid Weixin proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
if (!this.#status.ready || !this.#abortController) {
|
||||
const error = new Error('Weixin runtime is not connected');
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await this.#api.sendText({
|
||||
baseUrl: this.#config.baseUrl,
|
||||
token: this.#token,
|
||||
toUserId,
|
||||
text,
|
||||
signal: signal ?? this.#abortController.signal,
|
||||
});
|
||||
return { sent: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,6 +224,20 @@ export class WhatsappController {
|
|||
});
|
||||
}
|
||||
|
||||
async sendProactiveText(botId, target, text, options = {}) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown WhatsApp bot');
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
||||
const error = new Error(t('WhatsApp机器人尚未连接'));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
return runtime.sendProactiveText(target, text, options);
|
||||
});
|
||||
}
|
||||
|
||||
async setAccessPolicy(botId, value) {
|
||||
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
||||
const accessPolicy = normalizeWhatsappAccessPolicy(value);
|
||||
|
|
|
|||
|
|
@ -719,6 +719,25 @@ export class WhatsappRuntime {
|
|||
return { sent: true };
|
||||
}
|
||||
|
||||
async sendProactiveText(target, text, { signal } = {}) {
|
||||
const jid = typeof target?.route?.jid === 'string' ? target.route.jid.trim() : '';
|
||||
const validUser = target?.kind === 'user'
|
||||
&& /^[^@\s]+@(s\.whatsapp\.net|lid)$/.test(jid);
|
||||
const validGroup = target?.kind === 'group' && /^[^@\s]+@g\.us$/.test(jid);
|
||||
if (!validUser && !validGroup) {
|
||||
const error = new TypeError('Invalid WhatsApp proactive delivery target');
|
||||
error.code = 'invalid-target';
|
||||
throw error;
|
||||
}
|
||||
if (!this.#status.ready || !this.#client) {
|
||||
const error = new Error(t('WhatsApp机器人尚未连接'));
|
||||
error.code = 'bot-not-connected';
|
||||
throw error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return this.#client.sendText({ jid }, text);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const session = this.#session;
|
||||
const client = this.#client;
|
||||
|
|
|
|||
|
|
@ -153,6 +153,70 @@ test('session text replies include populated mention targets and omit empty ment
|
|||
for (const body of sent) assert.equal(body.text.content, '回答');
|
||||
});
|
||||
|
||||
test('DingTalk sends proactive text through stable user and group robot endpoints', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'proactive-access-token', expireIn: 7_200 });
|
||||
}
|
||||
return jsonResponse({ processQueryKey: `query-${calls.length}` });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
await api.sendRobotText({
|
||||
clientId: 'ding-proactive-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: { type: 'user', robotCode: 'ding-proactive-client', userId: 'staff-one' },
|
||||
text: ' 用户主动消息\n',
|
||||
});
|
||||
await api.sendRobotText({
|
||||
clientId: 'ding-proactive-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: {
|
||||
type: 'group',
|
||||
robotCode: 'ding-proactive-client',
|
||||
openConversationId: 'cid-one',
|
||||
},
|
||||
text: '群主动消息',
|
||||
});
|
||||
|
||||
assert.equal(calls[1].url, `${DINGTALK_API_BASE_URL}v1.0/robot/oToMessages/batchSend`);
|
||||
assert.equal(calls[2].url, `${DINGTALK_API_BASE_URL}v1.0/robot/groupMessages/send`);
|
||||
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'proactive-access-token');
|
||||
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'proactive-access-token');
|
||||
assert.deepEqual(JSON.parse(calls[1].options.body), {
|
||||
robotCode: 'ding-proactive-client',
|
||||
msgKey: 'sampleText',
|
||||
msgParam: JSON.stringify({ content: ' 用户主动消息\n' }),
|
||||
userIds: ['staff-one'],
|
||||
});
|
||||
assert.deepEqual(JSON.parse(calls[2].options.body), {
|
||||
robotCode: 'ding-proactive-client',
|
||||
msgKey: 'sampleText',
|
||||
msgParam: JSON.stringify({ content: '群主动消息' }),
|
||||
openConversationId: 'cid-one',
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(calls.slice(1)), /sessionWebhook|sendBySession/);
|
||||
});
|
||||
|
||||
test('DingTalk exposes a stable rejection code for rejected proactive text', async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: async (url) => url.pathname.endsWith('/oauth2/accessToken')
|
||||
? jsonResponse({ accessToken: 'rejected-text-token', expireIn: 7_200 })
|
||||
: jsonResponse({ code: 'Forbidden.AccessDenied' }),
|
||||
});
|
||||
await assert.rejects(() => api.sendRobotText({
|
||||
clientId: 'ding-rejected-text-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: { type: 'user', robotCode: 'ding-rejected-text-client', userId: 'staff-one' },
|
||||
text: '主动投递',
|
||||
}), (error) => {
|
||||
assert.equal(error.code, 'send-rejected');
|
||||
assert.equal(error.providerCode, 'Forbidden.AccessDenied');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('DingTalk adds, recalls, and replaces its native status reactions', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
|
|
|
|||
|
|
@ -57,9 +57,11 @@ function configFixture(initial = [], events = []) {
|
|||
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const proactiveSends = [];
|
||||
return {
|
||||
runtimes,
|
||||
connectionTests,
|
||||
proactiveSends,
|
||||
createRuntime: async ({ botId, config, clientSecret }) => {
|
||||
events.push(['create', botId]);
|
||||
let ready = false;
|
||||
|
|
@ -96,6 +98,10 @@ function runtimeFactory({ events = [], pendingByClient = new Map(), failStart =
|
|||
async sendConnectionTest(text) {
|
||||
connectionTests.push({ botId, text });
|
||||
},
|
||||
async sendProactiveText(...args) {
|
||||
proactiveSends.push({ botId, args });
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
|
|
@ -154,6 +160,14 @@ test('successful QR poll stores secret then config then starts runtime without p
|
|||
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
|
||||
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(runtimes.connectionTests[0].text, /钉钉机器人(ding••••vate)/);
|
||||
const target = { kind: 'user', route: { userId: 'staff-one' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(completed.botId, target, '主动投递'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(runtimes.proactiveSends, [{
|
||||
botId: completed.botId,
|
||||
args: [target, '主动投递', {}],
|
||||
}]);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ function stateFixture() {
|
|||
test('runtime sends a DingTalk connection test only through the remembered private webhook', async () => {
|
||||
const state = stateFixture();
|
||||
const sends = [];
|
||||
const proactiveSends = [];
|
||||
let proactiveFailure = null;
|
||||
const client = {
|
||||
connected: true,
|
||||
socket: { readyState: 1 },
|
||||
|
|
@ -61,7 +63,13 @@ test('runtime sends a DingTalk connection test only through the remembered priva
|
|||
clientSecret: 'host-secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
api: { sendText: async (request) => sends.push(request) },
|
||||
api: {
|
||||
sendText: async (request) => sends.push(request),
|
||||
sendRobotText: async (request) => {
|
||||
proactiveSends.push(request);
|
||||
if (proactiveFailure) throw proactiveFailure;
|
||||
},
|
||||
},
|
||||
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||
});
|
||||
|
||||
|
|
@ -78,6 +86,23 @@ test('runtime sends a DingTalk connection test only through the remembered priva
|
|||
assert.equal(sends[0].clientSecret, 'host-secret');
|
||||
assert.equal(sends[0].sessionWebhook, 'https://oapi.dingtalk.com/robot/reply?ticket=inbound-private');
|
||||
assert.equal(sends[0].text, '连接测试');
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'group',
|
||||
route: { openConversationId: 'cid-proactive' },
|
||||
}, '主动投递'), { sent: true });
|
||||
assert.equal(proactiveSends.length, 1);
|
||||
assert.deepEqual(proactiveSends[0].target, {
|
||||
type: 'group',
|
||||
robotCode: 'ding-client',
|
||||
openConversationId: 'cid-proactive',
|
||||
});
|
||||
assert.equal(proactiveSends[0].text, '主动投递');
|
||||
assert.equal('sessionWebhook' in proactiveSends[0], false);
|
||||
proactiveFailure = Object.assign(new Error('provider detail'), { code: 'send-rejected' });
|
||||
await assert.rejects(() => runtime.sendProactiveText({
|
||||
kind: 'user',
|
||||
route: { userId: 'staff-one' },
|
||||
}, '失败投递'), { code: 'target-rejected' });
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ test('Discord controller persists a credential reference and exposes only masked
|
|||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new DiscordConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const proactiveSends = [];
|
||||
const controller = new DiscordController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
|
|
@ -409,6 +410,10 @@ test('Discord controller persists a credential reference and exposes only masked
|
|||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async sendProactiveText(...args) {
|
||||
proactiveSends.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
});
|
||||
const status = await controller.bindCredentials({ token: TOKEN });
|
||||
|
|
@ -426,6 +431,11 @@ test('Discord controller persists a credential reference and exposes only masked
|
|||
const identity = deriveDiscordBotIdentity('1234567890123456789');
|
||||
assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN);
|
||||
assert.doesNotMatch(await readFile(configPath, 'utf8'), new RegExp(TOKEN.replaceAll('.', '\\.')));
|
||||
const target = { kind: 'channel', route: { channelId: '222222222222222222' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(identity.botId, target, 'proactive-test'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.tokenRef), false);
|
||||
});
|
||||
|
|
@ -1608,6 +1618,7 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
|
|||
const abortMark = deferred();
|
||||
let abortMarkStarted = false;
|
||||
const errors = [];
|
||||
const proactiveCalls = [];
|
||||
const runtime = new DiscordRuntime({
|
||||
config: {
|
||||
botId: 'discord_test',
|
||||
|
|
@ -1632,6 +1643,10 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
|
|||
createApi: () => ({
|
||||
getCurrentUser: async () => ({ id: '1234567890123456789', bot: true }),
|
||||
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
|
||||
createMessage: async (request) => {
|
||||
proactiveCalls.push(request);
|
||||
return { id: '111111111111111900' };
|
||||
},
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
|
|
@ -1648,6 +1663,14 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
|
|||
});
|
||||
await runtime.start();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'channel',
|
||||
route: { channelId: '222222222222222900' },
|
||||
}, 'proactive-test'), { providerMessageIds: ['111111111111111900'] });
|
||||
assert.equal(proactiveCalls.length, 1);
|
||||
assert.equal(proactiveCalls[0].channelId, '222222222222222900');
|
||||
assert.equal(proactiveCalls[0].content, 'proactive-test');
|
||||
assert.equal(proactiveCalls[0].replyToMessageId, undefined);
|
||||
const identify = socket.sent.find((packet) => packet.op === 2);
|
||||
assert.equal(identify.d.token, TOKEN);
|
||||
assert.equal(identify.d.intents, 37_377);
|
||||
|
|
|
|||
|
|
@ -176,6 +176,19 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
},
|
||||
}]);
|
||||
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'group',
|
||||
route: { chatId: 'oc_proactive_group' },
|
||||
}, '主动投递'), { sent: true });
|
||||
assert.deepEqual(FakeClient.sent[1], {
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: 'oc_proactive_group',
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text: '主动投递' }),
|
||||
},
|
||||
});
|
||||
|
||||
const stopped = await runtime.stop();
|
||||
assert.equal(stopped.ready, false);
|
||||
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ function fixture({
|
|||
starts: 0,
|
||||
stops: 0,
|
||||
sentTests: [],
|
||||
proactiveSends: [],
|
||||
probes: [],
|
||||
responseModes: [],
|
||||
repair,
|
||||
|
|
@ -127,6 +128,10 @@ function fixture({
|
|||
runtime.sentTests.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
async sendProactiveText(...args) {
|
||||
runtime.proactiveSends.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
setGroupResponseMode(mode) {
|
||||
runtime.responseModes.push(mode);
|
||||
runtime.config.groupResponseMode = mode;
|
||||
|
|
@ -793,6 +798,11 @@ test('connection test uses the selected bot runtime and shared message copy', as
|
|||
assert.deepEqual(fx.runtimes.get(healthy.id)[0].sentTests, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
|
||||
]);
|
||||
const target = { kind: 'group', route: { chatId: 'oc_target' } };
|
||||
assert.deepEqual(await fx.controller.sendProactiveText(healthy.id, target, '主动投递'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(fx.runtimes.get(healthy.id)[0].proactiveSends, [[target, '主动投递', {}]]);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ test('QQ controller delegates the shared connection-test message to the selected
|
|||
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
|
||||
};
|
||||
const sent = [];
|
||||
const proactiveSends = [];
|
||||
const controller = new QqController({
|
||||
qrAuth: { start() { return () => {}; } },
|
||||
credentials: {
|
||||
|
|
@ -116,6 +117,10 @@ test('QQ controller delegates the shared connection-test message to the selected
|
|||
start: async () => {},
|
||||
stop: async () => {},
|
||||
sendConnectionTest: async (text) => sent.push(text),
|
||||
sendProactiveText: async (...args) => {
|
||||
proactiveSends.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -124,6 +129,11 @@ test('QQ controller delegates the shared connection-test message to the selected
|
|||
assert.deepEqual(sent, [
|
||||
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
|
||||
]);
|
||||
const target = { kind: 'user', route: { userOpenId: 'user-openid' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(config.botId, target, '主动投递'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(proactiveSends, [[target, '主动投递', {}]]);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ test('QQ runtime sends a proactive connection test to the explicit owner fallbac
|
|||
target: { scope: 'c2c', targetId: 'owner-openid' },
|
||||
text: 'connection-test',
|
||||
}]);
|
||||
await runtime.sendProactiveText({
|
||||
kind: 'group',
|
||||
route: { groupOpenId: 'group-openid' },
|
||||
}, 'proactive-test');
|
||||
assert.deepEqual(bot.sent[1], {
|
||||
target: { scope: 'group', targetId: 'group-openid' },
|
||||
text: 'proactive-test',
|
||||
});
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -709,6 +709,7 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
const configStore = await new SlackConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const connectionTests = [];
|
||||
const proactiveSends = [];
|
||||
const controller = new SlackController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
|
|
@ -729,6 +730,10 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
async start() {},
|
||||
async stop() {},
|
||||
async sendConnectionTest(text) { connectionTests.push(text); },
|
||||
async sendProactiveText(...args) {
|
||||
proactiveSends.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
});
|
||||
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
|
||||
|
|
@ -744,6 +749,11 @@ test('Slack controller stores two protected credential references and exposes ne
|
|||
await controller.sendConnectionTest(identity.botId);
|
||||
assert.match(connectionTests[0], /DeepSeek Harness/);
|
||||
assert.match(connectionTests[0], /T1234••• · U1234•••/);
|
||||
const target = { kind: 'conversation', route: { channelId: 'C12345678' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(identity.botId, target, 'proactive-test'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.botTokenRef), false);
|
||||
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
|
||||
|
|
@ -1048,6 +1058,7 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
|
|||
const abortMark = deferred();
|
||||
let abortMarkStarted = false;
|
||||
const errors = [];
|
||||
const proactiveCalls = [];
|
||||
const runtime = new SlackRuntime({
|
||||
config: {
|
||||
botId: 'slack_test',
|
||||
|
|
@ -1073,6 +1084,10 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
|
|||
createApi: () => ({
|
||||
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
|
||||
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
|
||||
postMessage: async (request) => {
|
||||
proactiveCalls.push(request);
|
||||
return { ts: '1700000000.900' };
|
||||
},
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
|
|
@ -1091,6 +1106,14 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
|
|||
});
|
||||
await runtime.start();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'thread',
|
||||
route: { channelId: 'C12345678', threadTs: '1700000000.100' },
|
||||
}, 'proactive-test'), { providerMessageIds: ['1700000000.900'] });
|
||||
assert.equal(proactiveCalls.length, 1);
|
||||
assert.equal(proactiveCalls[0].channelId, 'C12345678');
|
||||
assert.equal(proactiveCalls[0].threadTs, '1700000000.100');
|
||||
assert.equal(proactiveCalls[0].text, 'proactive-test');
|
||||
socket.emit('message', {
|
||||
data: JSON.stringify({
|
||||
envelope_id: 'env-1',
|
||||
|
|
|
|||
|
|
@ -630,6 +630,7 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
const credentialStore = credentials();
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const proactiveSends = [];
|
||||
const controller = new TelegramController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
|
|
@ -647,6 +648,10 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
async start() {},
|
||||
async stop() {},
|
||||
async sendConnectionTest(text) { connectionTests.push(text); },
|
||||
async sendProactiveText(...args) {
|
||||
proactiveSends.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
|
|
@ -673,6 +678,11 @@ test('Telegram config and controller store only a credential reference in bot da
|
|||
await controller.sendConnectionTest(identity.botId);
|
||||
assert.match(connectionTests[0], /Harness Telegram/);
|
||||
assert.match(connectionTests[0], /123•••/);
|
||||
const target = { kind: 'chat', route: { chatId: '123456' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(identity.botId, target, 'proactive-test'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.tokenRef), false);
|
||||
assert.equal(controller.status().totals.configured, 0);
|
||||
|
|
@ -1352,6 +1362,10 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
|
|||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
}, { once: true }));
|
||||
},
|
||||
sendMessage: async (request) => {
|
||||
calls.push({ method: 'sendMessage', ...request });
|
||||
return { message_id: 900 };
|
||||
},
|
||||
};
|
||||
const runtime = new TelegramRuntime({
|
||||
config: {
|
||||
|
|
@ -1367,6 +1381,15 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
|
|||
await runtime.start();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.equal(runtime.status.connectionState, 'connected');
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'topic',
|
||||
route: { chatId: '-1001234567890', messageThreadId: 42 },
|
||||
}, 'proactive-test'), { providerMessageIds: ['900'] });
|
||||
const proactiveCall = calls.find(({ method }) => method === 'sendMessage');
|
||||
assert.equal(proactiveCall.chatId, -1001234567890);
|
||||
assert.equal(proactiveCall.messageThreadId, 42);
|
||||
assert.equal(proactiveCall.replyToMessageId, undefined);
|
||||
assert.equal(proactiveCall.text, 'proactive-test');
|
||||
await runtime.stop();
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.deepEqual(calls[0], { method: 'setMyCommands', commands: TELEGRAM_COMMAND_MENU });
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
const configs = [];
|
||||
let runtimeArgs;
|
||||
let connectionTestText;
|
||||
const proactiveSends = [];
|
||||
const controller = new WecomController({
|
||||
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
|
||||
credentials: {
|
||||
|
|
@ -77,6 +78,10 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {}, stop: async () => {},
|
||||
sendConnectionTest: async (text) => { connectionTestText = text; },
|
||||
sendProactiveText: async (...args) => {
|
||||
proactiveSends.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -88,6 +93,11 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
|
|||
await controller.sendConnectionTest(status.bots[0].botId);
|
||||
assert.match(connectionTestText, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(connectionTestText, /企业微信机器人(remote••••nual)/);
|
||||
const target = { kind: 'group', route: { chatId: 'group-target' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(status.bots[0].botId, target, '主动投递'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(proactiveSends, [[target, '主动投递', {}]]);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ test('Enterprise WeChat runtime sends a connection test only to the remembered p
|
|||
chatId: 'member-private',
|
||||
body: { msgtype: 'markdown', markdown: { content: '测试' } },
|
||||
}]);
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'group',
|
||||
route: { chatId: 'group-target' },
|
||||
}, '主动投递'), { sent: true });
|
||||
assert.deepEqual(client.sent[1], {
|
||||
chatId: 'group-target',
|
||||
body: { msgtype: 'markdown', markdown: { content: '主动投递' } },
|
||||
});
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function configFixture() {
|
|||
function runtimeFactory({ failStart = false, startError, lastMessageError = null } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const proactiveSends = [];
|
||||
const createRuntime = async ({ config, token }) => {
|
||||
let ready = false;
|
||||
const runtime = {
|
||||
|
|
@ -71,11 +72,15 @@ function runtimeFactory({ failStart = false, startError, lastMessageError = null
|
|||
},
|
||||
async stop() { ready = false; },
|
||||
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
|
||||
async sendProactiveText(target, text, options) {
|
||||
proactiveSends.push({ botId: config.botId, target, text, options });
|
||||
return { sent: true };
|
||||
},
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
};
|
||||
return { runtimes, connectionTests, createRuntime };
|
||||
return { runtimes, connectionTests, proactiveSends, createRuntime };
|
||||
}
|
||||
|
||||
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
|
||||
|
|
@ -139,6 +144,16 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
|
|||
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
|
||||
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
|
||||
const target = { kind: 'user', route: { toUserId: 'target-user' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(completed.botId, target, '主动投递'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(runtimes.proactiveSends[0], {
|
||||
botId: completed.botId,
|
||||
target,
|
||||
text: '主动投递',
|
||||
options: {},
|
||||
});
|
||||
|
||||
await controller.deleteBot(completed.botId);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
|
|
|
|||
|
|
@ -165,6 +165,14 @@ test('runtime sends a connection test to the bound Weixin owner without reply co
|
|||
assert.equal(sends[0].text, '连接测试');
|
||||
assert.equal(sends[0].contextToken, undefined);
|
||||
assert.equal(sends[0].runId, undefined);
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'user',
|
||||
route: { toUserId: 'target-user' },
|
||||
}, '主动投递'), { sent: true });
|
||||
assert.equal(sends[1].toUserId, 'target-user');
|
||||
assert.equal(sends[1].text, '主动投递');
|
||||
assert.equal(sends[1].contextToken, undefined);
|
||||
assert.equal(sends[1].runId, undefined);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1341,7 +1341,14 @@ test('WhatsApp runtime sends a connection test to self and suppresses its outbou
|
|||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
|
||||
assert.deepEqual(await runtime.sendProactiveText({
|
||||
kind: 'group',
|
||||
route: { jid: '120363000000000000@g.us' },
|
||||
}, '主动投递'), { providerMessageIds: ['connection-test-1'] });
|
||||
assert.deepEqual(sent, [
|
||||
[ACCOUNT_JID, { text: '连接测试' }],
|
||||
['120363000000000000@g.us', { text: '主动投递' }],
|
||||
]);
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
|
||||
message: { conversation: '连接测试' },
|
||||
|
|
@ -1355,6 +1362,7 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
|
|||
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
|
||||
const config = await configStore.save(linkedConfig());
|
||||
const sent = [];
|
||||
const proactiveSends = [];
|
||||
const controller = new WhatsappController({
|
||||
configStore,
|
||||
authPath: (name) => join(root, 'auth', name),
|
||||
|
|
@ -1371,6 +1379,10 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
|
|||
sent.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
sendProactiveText: async (...args) => {
|
||||
proactiveSends.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
});
|
||||
t.after(() => controller.close());
|
||||
|
|
@ -1380,6 +1392,11 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
|
|||
assert.deepEqual(sent, [
|
||||
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
|
||||
]);
|
||||
const target = { kind: 'user', route: { jid: '16505550199@s.whatsapp.net' } };
|
||||
assert.deepEqual(await controller.sendProactiveText(config.botId, target, 'proactive-test'), {
|
||||
sent: true,
|
||||
});
|
||||
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
|
||||
});
|
||||
|
||||
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {
|
||||
|
|
|
|||
653
test/client-delivery-settings.test.mjs
Normal file
653
test/client-delivery-settings.test.mjs
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
DELIVERY_CHANNEL_DEFINITIONS,
|
||||
DELIVERY_ENDPOINTS,
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
DeliveryTargetSettingsPage,
|
||||
} from '../plugin-src/client/delivery-settings.js';
|
||||
import { en, setImTranslator } from '../plugin-src/client/i18n.js';
|
||||
import { BotCard as FeishuBotCard } from '../plugin-src/client/channels/feishu/index.js';
|
||||
import { AccountCard as WeixinAccountCard } from '../plugin-src/client/channels/weixin/index.js';
|
||||
import { AccountCard as DingtalkAccountCard } from '../plugin-src/client/channels/dingtalk/index.js';
|
||||
import { AccountCard as WecomAccountCard } from '../plugin-src/client/channels/wecom/index.js';
|
||||
import { AccountCard as QqAccountCard } from '../plugin-src/client/channels/qq/index.js';
|
||||
import { SlackAccountCard } from '../plugin-src/client/channels/slack/index.js';
|
||||
import { TelegramAccountCard } from '../plugin-src/client/channels/telegram/index.js';
|
||||
import { DiscordAccountCard } from '../plugin-src/client/channels/discord/index.js';
|
||||
import { WhatsappAccountCard } from '../plugin-src/client/channels/whatsapp/index.js';
|
||||
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
|
||||
function flush() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(textOf).join('');
|
||||
return textOf(node?.props?.children ?? '');
|
||||
}
|
||||
|
||||
function button(root, label) {
|
||||
return root.findAllByType('button').find((entry) => textOf(entry) === label);
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function mount(t, props) {
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(DeliveryTargetSettingsPage, props));
|
||||
await flush();
|
||||
});
|
||||
t.after(async () => {
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
return renderer;
|
||||
}
|
||||
|
||||
const connectedAccount = Object.freeze({
|
||||
botId: 'bot_feishu_01',
|
||||
botName: '通知机器人',
|
||||
connected: true,
|
||||
});
|
||||
|
||||
test('delivery settings define only the nine supported IM channel routes', () => {
|
||||
assert.equal(DELIVERY_RPC_CHANNEL, '/dsh-im-delivery');
|
||||
assert.deepEqual(Object.keys(DELIVERY_CHANNEL_DEFINITIONS), [
|
||||
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
|
||||
'slack', 'telegram', 'discord', 'whatsapp',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
DELIVERY_CHANNEL_DEFINITIONS.feishu.fields.user.map((field) => field.key),
|
||||
['openId'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
DELIVERY_CHANNEL_DEFINITIONS.dingtalk.fields.group.map((field) => field.key),
|
||||
['openConversationId'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
DELIVERY_CHANNEL_DEFINITIONS.telegram.fields.topic.map((field) => field.key),
|
||||
['chatId', 'messageThreadId'],
|
||||
);
|
||||
assert.equal('office' in DELIVERY_CHANNEL_DEFINITIONS, false);
|
||||
});
|
||||
|
||||
test('all nine robot cards add one accessible settings gear beside existing content', () => {
|
||||
const account = {
|
||||
botId: 'bot_card_01',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: {
|
||||
name: '机器人',
|
||||
idMasked: 'id••01',
|
||||
username: 'robot',
|
||||
appIdMasked: 'app••01',
|
||||
accountIdMasked: 'account••01',
|
||||
clientIdMasked: 'client••01',
|
||||
},
|
||||
health: { lastCheckedAt: null, summary: '运行正常' },
|
||||
workspace: null,
|
||||
};
|
||||
const cards = [
|
||||
['feishu', FeishuBotCard, { connection: account }],
|
||||
['weixin', WeixinAccountCard, { account }],
|
||||
['dingtalk', DingtalkAccountCard, { account }],
|
||||
['wecom', WecomAccountCard, { account }],
|
||||
['qq', QqAccountCard, { account }],
|
||||
['slack', SlackAccountCard, { account }],
|
||||
['telegram', TelegramAccountCard, { account }],
|
||||
['discord', DiscordAccountCard, { account }],
|
||||
['whatsapp', WhatsappAccountCard, { account }],
|
||||
];
|
||||
|
||||
for (const [channel, Card, props] of cards) {
|
||||
const markup = renderToStaticMarkup(React.createElement(Card, props));
|
||||
assert.equal((markup.match(/aria-label="机器人设置"/g) ?? []).length, 1);
|
||||
assert.match(markup, /class="dim-botCardTools"/);
|
||||
assert.match(markup, new RegExp(`data-delivery-channel="${channel}"`));
|
||||
assert.match(markup, /role="tooltip"[^>]*>机器人设置</);
|
||||
}
|
||||
});
|
||||
|
||||
test('the card gear opens a bot-scoped page in the current channel panel and returns in place', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; },
|
||||
clearInterval() {},
|
||||
setTimeout() { return 1; },
|
||||
clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return 1; },
|
||||
cancelAnimationFrame() {},
|
||||
};
|
||||
const bot = {
|
||||
botId: 'wx_stable_bot',
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: 'connected',
|
||||
bot: { name: '微信通知助手', accountIdMasked: 'wx••01' },
|
||||
health: { status: 'healthy', summary: '微信连接正常', lastCheckedAt: Date.now() },
|
||||
};
|
||||
const deliveryCalls = [];
|
||||
const renderer = await (async () => {
|
||||
let mounted;
|
||||
await act(async () => {
|
||||
mounted = create(React.createElement(IMSettingsTab, {
|
||||
browserLocation: { href: 'http://localhost:9527/settings' },
|
||||
weixinRpcCall: async (endpoint) => {
|
||||
assert.equal(endpoint, 'connection.status');
|
||||
return { ok: true, value: { revision: 1, bots: [bot] } };
|
||||
},
|
||||
deliveryRpcCall: async (endpoint, payload) => {
|
||||
deliveryCalls.push({ endpoint, payload });
|
||||
return { ok: true, value: { targets: [] } };
|
||||
},
|
||||
updateRpcCall: async () => ({
|
||||
ok: true,
|
||||
value: { runningVersion: '4.0.1', canInstall: false },
|
||||
}),
|
||||
}));
|
||||
await flush();
|
||||
});
|
||||
return mounted;
|
||||
})();
|
||||
t.after(async () => {
|
||||
await act(async () => { renderer.unmount(); await flush(); });
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const card = renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' });
|
||||
await act(async () => {
|
||||
card.findByProps({ 'aria-label': '机器人设置' }).props.onClick();
|
||||
await flush();
|
||||
});
|
||||
const page = renderer.root.findByProps({ className: 'dim-deliveryPage' });
|
||||
assert.match(textOf(page), /微信通知助手/);
|
||||
assert.equal(
|
||||
page.findByProps({ className: 'dim-deliveryHeader' }).findAllByType('span').length,
|
||||
2,
|
||||
);
|
||||
const docsLink = page.findByProps({ className: 'dim-deliveryDocsLink' });
|
||||
assert.equal(textOf(docsLink), '使用文档↗');
|
||||
assert.equal(
|
||||
docsLink.props.href,
|
||||
'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.md',
|
||||
);
|
||||
assert.equal(docsLink.props.target, '_blank');
|
||||
assert.equal(docsLink.props.rel, 'noopener noreferrer');
|
||||
assert.equal(page.findByType('code').props.children, 'wx_stable_bot');
|
||||
assert.deepEqual(deliveryCalls, [{
|
||||
endpoint: DELIVERY_ENDPOINTS.list,
|
||||
payload: { botId: 'wx_stable_bot' },
|
||||
}]);
|
||||
|
||||
await act(async () => {
|
||||
button(page, '← 返回机器人列表').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.ok(renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' }));
|
||||
assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true);
|
||||
});
|
||||
|
||||
test('new target waits for saved targets before generating aliases or checking duplicates', async (t) => {
|
||||
const pending = deferred();
|
||||
const renderer = await mount(t, {
|
||||
channel: 'feishu',
|
||||
account: connectedAccount,
|
||||
rpcCall: async (endpoint) => {
|
||||
assert.equal(endpoint, DELIVERY_ENDPOINTS.list);
|
||||
return pending.promise;
|
||||
},
|
||||
onBack() {},
|
||||
});
|
||||
|
||||
assert.equal(button(renderer.root, '新建目标').props.disabled, true);
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ ok: true, value: { targets: [] } });
|
||||
await flush();
|
||||
});
|
||||
|
||||
assert.equal(button(renderer.root, '新建目标').props.disabled, false);
|
||||
});
|
||||
|
||||
test('each saved target tests only its own botId and targetId and keeps row-local feedback', async (t) => {
|
||||
const pending = deferred();
|
||||
const calls = [];
|
||||
const targets = [
|
||||
{ targetId: 'daily-report', name: '日报群', kind: 'group', route: { chatId: 'oc_daily' } },
|
||||
{ targetId: 'oncall', name: '值班同学', kind: 'user', route: { openId: 'ou_oncall' } },
|
||||
];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets } };
|
||||
if (endpoint === DELIVERY_ENDPOINTS.test) return pending.promise;
|
||||
throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
};
|
||||
const renderer = await mount(t, {
|
||||
channel: 'feishu', account: connectedAccount, rpcCall, onBack() {},
|
||||
});
|
||||
const first = renderer.root.findByProps({ 'data-target-id': 'daily-report' });
|
||||
const second = renderer.root.findByProps({ 'data-target-id': 'oncall' });
|
||||
|
||||
await act(async () => {
|
||||
first.findByProps({ 'aria-label': '测试投递目标' }).props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.ok(first.findAllByType('button').some((entry) => textOf(entry) === '测试中…'));
|
||||
assert.ok(second.findAllByType('button').some((entry) => textOf(entry) === '测试'));
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
endpoint: DELIVERY_ENDPOINTS.test,
|
||||
payload: { botId: 'bot_feishu_01', targetId: 'daily-report' },
|
||||
});
|
||||
|
||||
pending.resolve({ ok: true, value: { sent: true } });
|
||||
await act(async () => { await flush(); });
|
||||
assert.match(textOf(first), /测试消息已发送,请到目标会话确认。/);
|
||||
assert.doesNotMatch(textOf(second), /测试消息已发送/);
|
||||
|
||||
await act(async () => {
|
||||
renderer.update(React.createElement(DeliveryTargetSettingsPage, {
|
||||
channel: 'feishu',
|
||||
account: { ...connectedAccount, connected: false },
|
||||
rpcCall,
|
||||
onBack() {},
|
||||
}));
|
||||
await flush();
|
||||
});
|
||||
for (const row of renderer.root.findAllByProps({ className: 'dim-targetRow' })) {
|
||||
assert.equal(row.findByProps({ 'aria-label': '测试投递目标' }).props.disabled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('new target defaults to recent conversations and selection creates an editable unsaved draft', async (t) => {
|
||||
const calls = [];
|
||||
let targets = [
|
||||
{ targetId: 'user-1', name: '值班同学', kind: 'user', route: { openId: 'ou_existing_secret_123456' } },
|
||||
];
|
||||
const suggestions = [
|
||||
{ kind: 'user', route: { openId: 'ou_existing_secret_123456' } },
|
||||
{ kind: 'user', route: { openId: 'ou_new_secret_123456' } },
|
||||
{ kind: 'group', route: { chatId: 'oc_group_secret_123456' } },
|
||||
];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === DELIVERY_ENDPOINTS.list) {
|
||||
return { ok: true, value: { targets: structuredClone(targets) } };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
return { ok: true, value: { suggestions: structuredClone(suggestions) } };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.test) {
|
||||
return { ok: true, value: { sent: true } };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.create) {
|
||||
targets = [...targets, structuredClone(payload.target)];
|
||||
return { ok: true, value: structuredClone(payload.target) };
|
||||
}
|
||||
throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
};
|
||||
const renderer = await mount(t, {
|
||||
channel: 'feishu', account: connectedAccount, rpcCall, onBack() {},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
button(renderer.root, '新建目标').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
endpoint: DELIVERY_ENDPOINTS.listSuggestions,
|
||||
payload: { botId: 'bot_feishu_01' },
|
||||
});
|
||||
const picker = renderer.root.findByProps({ 'aria-label': '从已聊过的会话选择' });
|
||||
const suggestionSelect = picker.findByProps({ name: 'suggestion' });
|
||||
const options = suggestionSelect.findAllByType('option');
|
||||
assert.equal(options.length, 4);
|
||||
const existing = options.find((option) => textOf(option).includes('ou_ex'));
|
||||
assert.equal(existing.props.disabled, true);
|
||||
assert.match(textOf(existing), /已添加/);
|
||||
assert.doesNotMatch(textOf(picker), /ou_existing_secret_123456|ou_new_secret_123456|oc_group_secret_123456/);
|
||||
|
||||
const groupIndex = options.find((option) => textOf(option).includes('oc_gr')).props.value;
|
||||
await act(async () => { suggestionSelect.props.onChange({ target: { value: groupIndex } }); });
|
||||
let form = renderer.root.findByType('form');
|
||||
const groupTargetId = form.findByProps({ name: 'targetId' }).props.value;
|
||||
assert.match(groupTargetId, /^tgt_[0-9a-f]{16}$/);
|
||||
assert.equal(form.findByProps({ name: 'targetId' }).props.readOnly, false);
|
||||
assert.equal(form.findByProps({ name: 'kind' }).props.value, 'group');
|
||||
assert.equal(form.findByProps({ name: 'chatId' }).props.value, 'oc_group_secret_123456');
|
||||
assert.match(form.findByProps({ name: 'name' }).props.value, /^群聊 · oc_gr/);
|
||||
assert.equal(calls.some((call) => call.endpoint === DELIVERY_ENDPOINTS.create), false);
|
||||
|
||||
await act(async () => {
|
||||
form.findByProps({ name: 'chatId' }).props.onChange({ target: { value: 'oc_unsaved_current' } });
|
||||
});
|
||||
form = renderer.root.findByType('form');
|
||||
assert.ok(button(form, '测试'));
|
||||
await act(async () => {
|
||||
button(form, '测试').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
endpoint: DELIVERY_ENDPOINTS.test,
|
||||
payload: {
|
||||
botId: 'bot_feishu_01',
|
||||
target: { kind: 'group', route: { chatId: 'oc_unsaved_current' } },
|
||||
},
|
||||
});
|
||||
assert.equal(calls.some((call) => (
|
||||
call.endpoint === DELIVERY_ENDPOINTS.create || call.endpoint === DELIVERY_ENDPOINTS.update
|
||||
)), false);
|
||||
assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/);
|
||||
|
||||
await act(async () => { button(renderer.root, '取消').props.onClick(); });
|
||||
const nextSelect = renderer.root.findByProps({ name: 'suggestion' });
|
||||
const userIndex = nextSelect.findAllByType('option')
|
||||
.find((option) => textOf(option).includes('ou_ne')).props.value;
|
||||
await act(async () => { nextSelect.props.onChange({ target: { value: userIndex } }); });
|
||||
form = renderer.root.findByType('form');
|
||||
const userTargetId = form.findByProps({ name: 'targetId' }).props.value;
|
||||
assert.match(userTargetId, /^tgt_[0-9a-f]{16}$/);
|
||||
assert.notEqual(userTargetId, groupTargetId);
|
||||
assert.match(form.findByProps({ name: 'name' }).props.value, /^私聊 · ou_ne/);
|
||||
assert.equal(form.findByProps({ name: 'kind' }).props.value, 'user');
|
||||
assert.equal(form.findByProps({ name: 'openId' }).props.value, 'ou_new_secret_123456');
|
||||
const fallbackName = form.findByProps({ name: 'name' }).props.value;
|
||||
|
||||
await act(async () => {
|
||||
form.findByProps({ name: 'targetId' }).props.onChange({ target: { value: 'project-chat' } });
|
||||
});
|
||||
await act(async () => {
|
||||
await form.props.onSubmit({ preventDefault() {} });
|
||||
await flush();
|
||||
});
|
||||
assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.create), {
|
||||
endpoint: DELIVERY_ENDPOINTS.create,
|
||||
payload: {
|
||||
botId: 'bot_feishu_01',
|
||||
target: {
|
||||
targetId: 'project-chat',
|
||||
name: fallbackName,
|
||||
kind: 'user',
|
||||
route: { openId: 'ou_new_secret_123456' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('recent conversation picker explains the empty state and refreshes on demand', async (t) => {
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets: [] } };
|
||||
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
return { ok: true, value: { suggestions: [] } };
|
||||
}
|
||||
throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
};
|
||||
const renderer = await mount(t, {
|
||||
channel: 'telegram', account: connectedAccount, rpcCall, onBack() {},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
button(renderer.root, '新建目标').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
|
||||
/先在对应平台与机器人聊一条消息,再刷新。/,
|
||||
);
|
||||
assert.ok(button(renderer.root, '手动填写(高级)'));
|
||||
await act(async () => {
|
||||
button(renderer.root, '刷新').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.equal(calls.filter((call) => call.endpoint === DELIVERY_ENDPOINTS.listSuggestions).length, 2);
|
||||
});
|
||||
|
||||
test('recent conversation names remain platform data in the English UI', async (t) => {
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => setImTranslator(null));
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === DELIVERY_ENDPOINTS.list) return { ok: true, value: { targets: [] } };
|
||||
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
suggestions: [{
|
||||
id: 'group', name: '飞书项目群', kind: 'group', route: { chatId: 'oc_secret_123456' },
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
};
|
||||
const renderer = await mount(t, {
|
||||
channel: 'feishu', account: connectedAccount, rpcCall, onBack() {},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
button(renderer.root, 'New target').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
const select = renderer.root.findByProps({ name: 'suggestion' });
|
||||
assert.match(textOf(select), /飞书项目群/);
|
||||
assert.doesNotMatch(textOf(select), /Feishu项目群/);
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ className: 'dim-targetSuggestions' })),
|
||||
/Choose from conversations/,
|
||||
);
|
||||
const docsLink = renderer.root.findByProps({ className: 'dim-deliveryDocsLink' });
|
||||
assert.equal(textOf(docsLink), 'User guide↗');
|
||||
assert.equal(
|
||||
docsLink.props.href,
|
||||
'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('target create, edit, copy, and delete use the minimal RPC payloads', async (t) => {
|
||||
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
const copied = [];
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: { clipboard: { async writeText(value) { copied.push(value); } } },
|
||||
});
|
||||
t.after(() => {
|
||||
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);
|
||||
else delete globalThis.navigator;
|
||||
});
|
||||
|
||||
const calls = [];
|
||||
let targets = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === DELIVERY_ENDPOINTS.list) {
|
||||
return { ok: true, value: { targets: structuredClone(targets) } };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
||||
return { ok: true, value: { suggestions: [] } };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.test) {
|
||||
return { ok: true, value: { sent: true } };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.create) {
|
||||
targets = [...targets, structuredClone(payload.target)];
|
||||
return { ok: true, value: structuredClone(payload.target) };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.update) {
|
||||
targets = targets.map((target) => target.targetId === payload.targetId
|
||||
? { targetId: payload.targetId, ...structuredClone(payload.target) }
|
||||
: target);
|
||||
return { ok: true, value: targets[0] };
|
||||
}
|
||||
if (endpoint === DELIVERY_ENDPOINTS.delete) {
|
||||
targets = targets.filter((target) => target.targetId !== payload.targetId);
|
||||
return { ok: true, value: { deleted: true } };
|
||||
}
|
||||
throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
};
|
||||
const renderer = await mount(t, {
|
||||
channel: 'telegram', account: connectedAccount, rpcCall, onBack() {},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
button(renderer.root, '复制').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.equal(copied.at(-1), 'bot_feishu_01');
|
||||
|
||||
await act(async () => {
|
||||
button(renderer.root, '新建目标').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.ok(renderer.root.findByProps({ 'aria-label': '从已聊过的会话选择' }));
|
||||
await act(async () => { button(renderer.root, '手动填写(高级)').props.onClick(); });
|
||||
let createForm = renderer.root.findByType('form');
|
||||
assert.ok(button(createForm, '测试'));
|
||||
assert.equal(button(createForm, '测试').props.disabled, true);
|
||||
assert.match(
|
||||
renderer.root.findByProps({ name: 'targetId' }).props.value,
|
||||
/^tgt_[0-9a-f]{16}$/,
|
||||
);
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ name: 'targetId' }).props.onChange({ target: { value: 'Ops:Topic@A' } });
|
||||
renderer.root.findByProps({ name: 'name' }).props.onChange({ target: { value: '告警话题' } });
|
||||
renderer.root.findByProps({ name: 'kind' }).props.onChange({ target: { value: 'topic' } });
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100123' } });
|
||||
renderer.root.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '42' } });
|
||||
});
|
||||
createForm = renderer.root.findByType('form');
|
||||
assert.equal(createForm.findByProps({ name: 'targetId' }).props.pattern, '[A-Za-z0-9._:@-]{1,128}');
|
||||
assert.equal(createForm.findByProps({ name: 'targetId' }).props.maxLength, 128);
|
||||
assert.equal(button(createForm, '测试').props.disabled, false);
|
||||
await act(async () => {
|
||||
button(createForm, '测试').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
endpoint: DELIVERY_ENDPOINTS.test,
|
||||
payload: {
|
||||
botId: 'bot_feishu_01',
|
||||
target: {
|
||||
kind: 'topic',
|
||||
route: { chatId: '-100123', messageThreadId: 42 },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(calls.some((call) => (
|
||||
call.endpoint === DELIVERY_ENDPOINTS.create || call.endpoint === DELIVERY_ENDPOINTS.update
|
||||
)), false);
|
||||
assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/);
|
||||
await act(async () => {
|
||||
await createForm.props.onSubmit({ preventDefault() {} });
|
||||
await flush();
|
||||
});
|
||||
assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.create), {
|
||||
endpoint: DELIVERY_ENDPOINTS.create,
|
||||
payload: {
|
||||
botId: 'bot_feishu_01',
|
||||
target: {
|
||||
targetId: 'Ops:Topic@A',
|
||||
name: '告警话题',
|
||||
kind: 'topic',
|
||||
route: { chatId: '-100123', messageThreadId: 42 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const row = () => renderer.root.findByProps({ 'data-target-id': 'Ops:Topic@A' });
|
||||
assert.match(textOf(row()), /告警话题/);
|
||||
assert.match(textOf(row()), /targetId: Ops:Topic@A/);
|
||||
assert.doesNotMatch(textOf(row()), /-100123/);
|
||||
const typeBadges = row().findByProps({ className: 'dim-targetTitle' }).findAllByType('span');
|
||||
assert.equal(typeBadges.length, 1);
|
||||
assert.equal(textOf(typeBadges[0]), '话题');
|
||||
await act(async () => {
|
||||
button(row(), '复制调用参数').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.equal(copied.at(-1), '{"botId":"bot_feishu_01","targetId":"Ops:Topic@A"}');
|
||||
|
||||
await act(async () => { button(row(), '编辑').props.onClick(); });
|
||||
let editForm = renderer.root.findByType('form');
|
||||
assert.equal(editForm.findByProps({ name: 'targetId' }).props.readOnly, true);
|
||||
assert.ok(button(editForm, '测试'));
|
||||
await act(async () => {
|
||||
editForm.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100999' } });
|
||||
editForm.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '77' } });
|
||||
});
|
||||
editForm = renderer.root.findByType('form');
|
||||
await act(async () => {
|
||||
button(editForm, '测试').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
endpoint: DELIVERY_ENDPOINTS.test,
|
||||
payload: {
|
||||
botId: 'bot_feishu_01',
|
||||
target: {
|
||||
kind: 'topic',
|
||||
route: { chatId: '-100999', messageThreadId: 77 },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(calls.filter((call) => call.endpoint === DELIVERY_ENDPOINTS.update).length, 0);
|
||||
assert.match(textOf(renderer.root.findByType('form')), /测试消息已发送,请到目标会话确认。/);
|
||||
await act(async () => {
|
||||
editForm.findByProps({ name: 'chatId' }).props.onChange({ target: { value: '-100123' } });
|
||||
editForm.findByProps({ name: 'messageThreadId' }).props.onChange({ target: { value: '42' } });
|
||||
editForm.findByProps({ name: 'name' }).props.onChange({ target: { value: '夜间告警' } });
|
||||
});
|
||||
editForm = renderer.root.findByType('form');
|
||||
await act(async () => {
|
||||
await editForm.props.onSubmit({ preventDefault() {} });
|
||||
await flush();
|
||||
});
|
||||
const updateCall = calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.update);
|
||||
assert.deepEqual(updateCall.payload, {
|
||||
botId: 'bot_feishu_01',
|
||||
targetId: 'Ops:Topic@A',
|
||||
target: {
|
||||
name: '夜间告警',
|
||||
kind: 'topic',
|
||||
route: { chatId: '-100123', messageThreadId: 42 },
|
||||
},
|
||||
});
|
||||
assert.equal('targetId' in updateCall.payload.target, false);
|
||||
|
||||
await act(async () => { button(row(), '删除').props.onClick(); });
|
||||
assert.match(textOf(row().findByProps({ role: 'alertdialog' })), /unknown-target/);
|
||||
await act(async () => {
|
||||
button(row(), '确认删除').props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.deepEqual(calls.find((call) => call.endpoint === DELIVERY_ENDPOINTS.delete), {
|
||||
endpoint: DELIVERY_ENDPOINTS.delete,
|
||||
payload: { botId: 'bot_feishu_01', targetId: 'Ops:Topic@A' },
|
||||
});
|
||||
assert.match(
|
||||
textOf(renderer.root.findByProps({ className: 'dim-deliveryState dim-deliveryEmpty' })),
|
||||
/尚未配置投递目标/,
|
||||
);
|
||||
});
|
||||
293
test/delivery-adapter.test.mjs
Normal file
293
test/delivery-adapter.test.mjs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DingtalkStateStore } from '../src/channels/dingtalk/state-store.mjs';
|
||||
import { DiscordStateStore } from '../src/channels/discord/state-store.mjs';
|
||||
import { StateStore as FeishuStateStore } from '../src/channels/feishu/state-store.mjs';
|
||||
import { QqStateStore } from '../src/channels/qq/state-store.mjs';
|
||||
import { SlackStateStore } from '../src/channels/slack/state-store.mjs';
|
||||
import { TelegramStateStore } from '../src/channels/telegram/state-store.mjs';
|
||||
import { WecomStateStore } from '../src/channels/wecom/state-store.mjs';
|
||||
import { WeixinStateStore } from '../src/channels/weixin/state-store.mjs';
|
||||
import { WhatsappStateStore } from '../src/channels/whatsapp/state-store.mjs';
|
||||
import {
|
||||
createDeliveryAdapter,
|
||||
normalizeDeliveryTarget,
|
||||
} from '../plugin-src/host/delivery-adapter.mjs';
|
||||
import { deliverySuggestionsFromSessions } from '../plugin-src/host/delivery-suggestions.mjs';
|
||||
|
||||
const TARGETS = {
|
||||
weixin: { targetId: 'daily', kind: 'user', route: { toUserId: 'wx-user' } },
|
||||
feishu: { targetId: 'daily', kind: 'group', route: { chatId: 'oc_group' } },
|
||||
dingtalk: {
|
||||
targetId: 'daily', kind: 'group', route: { openConversationId: 'cid_group' },
|
||||
},
|
||||
wecom: { targetId: 'daily', kind: 'user', route: { chatId: 'wecom-user' } },
|
||||
qq: { targetId: 'daily', kind: 'group', route: { groupOpenId: 'qq-group' } },
|
||||
slack: {
|
||||
targetId: 'daily', kind: 'thread', route: { channelId: 'C123', threadTs: '123.456' },
|
||||
},
|
||||
telegram: {
|
||||
targetId: 'daily', kind: 'topic', route: { chatId: '-100123', messageThreadId: 42 },
|
||||
},
|
||||
discord: { targetId: 'daily', kind: 'channel', route: { channelId: '123456789' } },
|
||||
whatsapp: {
|
||||
targetId: 'daily', kind: 'group', route: { jid: '123456789-120363000000000000@g.us' },
|
||||
},
|
||||
};
|
||||
|
||||
const STATE_STORES = {
|
||||
weixin: WeixinStateStore,
|
||||
feishu: FeishuStateStore,
|
||||
dingtalk: DingtalkStateStore,
|
||||
wecom: WecomStateStore,
|
||||
qq: QqStateStore,
|
||||
slack: SlackStateStore,
|
||||
telegram: TelegramStateStore,
|
||||
discord: DiscordStateStore,
|
||||
whatsapp: WhatsappStateStore,
|
||||
};
|
||||
|
||||
const SESSION_SUGGESTIONS = {
|
||||
weixin: {
|
||||
sessions: { 'p2p:wx-user': 'session-weixin', 'group:ignored': 'session-ignored' },
|
||||
suggestions: [{ kind: 'user', route: { toUserId: 'wx-user' } }],
|
||||
},
|
||||
feishu: {
|
||||
sessions: {
|
||||
'p2p:ou_user': 'session-user',
|
||||
'p2p:legacy-user-id': 'session-without-open-id',
|
||||
'group:oc_group': 'session-group',
|
||||
},
|
||||
suggestions: [
|
||||
{ kind: 'user', route: { openId: 'ou_user' } },
|
||||
{ kind: 'group', route: { chatId: 'oc_group' } },
|
||||
],
|
||||
},
|
||||
dingtalk: {
|
||||
sessions: { 'p2p:staff-one': 'session-user', 'group:cid_group': 'session-group' },
|
||||
suggestions: [
|
||||
{ kind: 'user', route: { userId: 'staff-one' } },
|
||||
{ kind: 'group', route: { openConversationId: 'cid_group' } },
|
||||
],
|
||||
},
|
||||
wecom: {
|
||||
sessions: { 'direct:member-one': 'session-user', 'group:wr_group': 'session-group' },
|
||||
suggestions: [
|
||||
{ kind: 'user', route: { chatId: 'member-one' } },
|
||||
{ kind: 'group', route: { chatId: 'wr_group' } },
|
||||
],
|
||||
},
|
||||
qq: {
|
||||
sessions: { 'c2c:user-openid': 'session-user', 'group:group-openid': 'session-group' },
|
||||
suggestions: [
|
||||
{ kind: 'user', route: { userOpenId: 'user-openid' } },
|
||||
{ kind: 'group', route: { groupOpenId: 'group-openid' } },
|
||||
],
|
||||
},
|
||||
slack: {
|
||||
sessions: {
|
||||
'direct:D123456': 'session-direct',
|
||||
'group:C123456:1712345678.123456': 'session-thread',
|
||||
},
|
||||
suggestions: [
|
||||
{ kind: 'conversation', route: { channelId: 'D123456' } },
|
||||
{ kind: 'thread', route: { channelId: 'C123456', threadTs: '1712345678.123456' } },
|
||||
],
|
||||
},
|
||||
telegram: {
|
||||
sessions: {
|
||||
'direct:88': 'session-direct',
|
||||
'group:88': 'session-duplicate-chat',
|
||||
'group:-1001234567890:42': 'session-topic',
|
||||
},
|
||||
suggestions: [
|
||||
{ kind: 'chat', route: { chatId: '88' } },
|
||||
{ kind: 'topic', route: { chatId: '-1001234567890', messageThreadId: 42 } },
|
||||
],
|
||||
},
|
||||
discord: {
|
||||
sessions: {
|
||||
'direct:123456789012345678': 'session-direct',
|
||||
'group:123456789012345678': 'session-duplicate-channel',
|
||||
'group:223456789012345678': 'session-thread',
|
||||
},
|
||||
suggestions: [
|
||||
{ kind: 'channel', route: { channelId: '123456789012345678' } },
|
||||
{ kind: 'channel', route: { channelId: '223456789012345678' } },
|
||||
],
|
||||
},
|
||||
whatsapp: {
|
||||
sessions: {
|
||||
'direct:16505550123@s.whatsapp.net': 'session-user',
|
||||
'group:120363000000000000@g.us': 'session-group',
|
||||
'group:123456789-120363000000000001@g.us': 'session-legacy-group',
|
||||
},
|
||||
suggestions: [
|
||||
{ kind: 'user', route: { jid: '16505550123@s.whatsapp.net' } },
|
||||
{ kind: 'group', route: { jid: '120363000000000000@g.us' } },
|
||||
{ kind: 'group', route: { jid: '123456789-120363000000000001@g.us' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test('all nine delivery adapters validate and forward one stable target', async () => {
|
||||
for (const [channel, target] of Object.entries(TARGETS)) {
|
||||
const calls = [];
|
||||
const workspaces = {
|
||||
has: (botId) => botId === `bot-${channel}`,
|
||||
listDeliveryTargets: () => [target],
|
||||
createDeliveryTarget: async (...args) => calls.push(['create', ...args]),
|
||||
updateDeliveryTarget: async (...args) => calls.push(['update', ...args]),
|
||||
deleteDeliveryTarget: async (...args) => calls.push(['delete', ...args]),
|
||||
};
|
||||
const coreController = {
|
||||
async sendProactiveText(...args) { calls.push(['send', ...args]); },
|
||||
};
|
||||
const { sessions, suggestions } = SESSION_SUGGESTIONS[channel];
|
||||
const adapter = createDeliveryAdapter({
|
||||
channel,
|
||||
workspaces,
|
||||
coreController,
|
||||
stateFor: async () => ({ snapshot: () => ({ sessions }) }),
|
||||
});
|
||||
|
||||
assert.equal(adapter.channel, channel);
|
||||
assert.equal(adapter.ownsBot(`bot-${channel}`), true);
|
||||
assert.equal(adapter.ownsBot('bot-other'), false);
|
||||
assert.deepEqual(adapter.listTargets(`bot-${channel}`), [target]);
|
||||
assert.deepEqual(await adapter.listSuggestions(`bot-${channel}`), suggestions);
|
||||
assert.deepEqual(
|
||||
await adapter.sendText(`bot-${channel}`, target, 'hello', { signal: undefined }),
|
||||
{ sent: true },
|
||||
);
|
||||
assert.deepEqual(calls[0], [
|
||||
'send', `bot-${channel}`, target, 'hello', { signal: undefined },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('delivery adapter delegates target CRUD to the existing workspace store', async () => {
|
||||
const calls = [];
|
||||
const workspaces = {
|
||||
has: () => true,
|
||||
listDeliveryTargets: () => [],
|
||||
createDeliveryTarget: async (...args) => calls.push(['create', ...args]),
|
||||
updateDeliveryTarget: async (...args) => calls.push(['update', ...args]),
|
||||
deleteDeliveryTarget: async (...args) => calls.push(['delete', ...args]),
|
||||
};
|
||||
const adapter = createDeliveryAdapter({
|
||||
channel: 'feishu',
|
||||
workspaces,
|
||||
coreController: { sendProactiveText() {} },
|
||||
stateFor: async () => ({ snapshot: () => ({ sessions: {} }) }),
|
||||
});
|
||||
const target = {
|
||||
targetId: 'ops', name: ' Ops ', kind: 'user', route: { openId: 'ou_ops' },
|
||||
};
|
||||
|
||||
await adapter.createTarget('bot-one', target);
|
||||
await adapter.updateTarget('bot-one', 'ops', {
|
||||
name: 'On call', kind: 'group', route: { chatId: 'oc_oncall' },
|
||||
});
|
||||
await adapter.deleteTarget('bot-one', 'ops');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['create', 'bot-one', {
|
||||
targetId: 'ops', name: 'Ops', kind: 'user', route: { openId: 'ou_ops' },
|
||||
}],
|
||||
['update', 'bot-one', 'ops', {
|
||||
name: 'On call', kind: 'group', route: { chatId: 'oc_oncall' },
|
||||
}],
|
||||
['delete', 'bot-one', 'ops'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('suggestion parser strictly filters malformed keys, de-duplicates routes, and never leaks state values', () => {
|
||||
const sessions = {
|
||||
'direct:88': 'secret-session-one',
|
||||
'group:88': { sessionId: 'secret-session-two', replyToMessageId: 7 },
|
||||
'group:-100:42': 'secret-topic-session',
|
||||
'direct:88:7': 'transient-reply-key',
|
||||
'group:-100:0': 'invalid-topic',
|
||||
'group:not-a-number': 'invalid-chat',
|
||||
'other:88': 'wrong-prefix',
|
||||
};
|
||||
const suggestions = deliverySuggestionsFromSessions('telegram', sessions);
|
||||
assert.deepEqual(suggestions, [
|
||||
{ kind: 'chat', route: { chatId: '88' } },
|
||||
{ kind: 'topic', route: { chatId: '-100', messageThreadId: 42 } },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
suggestions.map((suggestion) => Object.keys(suggestion)),
|
||||
[['kind', 'route'], ['kind', 'route']],
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(suggestions), /session|reply|secret/i);
|
||||
assert.deepEqual(deliverySuggestionsFromSessions('telegram', null), []);
|
||||
assert.deepEqual(deliverySuggestionsFromSessions('unknown', sessions), []);
|
||||
});
|
||||
|
||||
test('all nine adapters list suggestions from reloaded bot state while no runtime is connected', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-delivery-suggestions-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
for (const [channel, StateStore] of Object.entries(STATE_STORES)) {
|
||||
const path = join(directory, `${channel}.json`);
|
||||
const [conversationKey] = Object.keys(SESSION_SUGGESTIONS[channel].sessions);
|
||||
await (await new StateStore(path).load()).setSession(conversationKey, `session-${channel}`);
|
||||
const reloaded = await new StateStore(path).load();
|
||||
const adapter = createDeliveryAdapter({
|
||||
channel,
|
||||
workspaces: {
|
||||
has: () => true,
|
||||
listDeliveryTargets: () => [],
|
||||
createDeliveryTarget() {},
|
||||
updateDeliveryTarget() {},
|
||||
deleteDeliveryTarget() {},
|
||||
},
|
||||
coreController: {},
|
||||
stateFor: async () => reloaded,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await adapter.listSuggestions(`bot-${channel}`),
|
||||
SESSION_SUGGESTIONS[channel].suggestions.slice(0, 1),
|
||||
channel,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every channel rejects unknown or transient route fields', () => {
|
||||
for (const [channel, target] of Object.entries(TARGETS)) {
|
||||
assert.throws(
|
||||
() => normalizeDeliveryTarget(channel, {
|
||||
...target,
|
||||
route: { ...target.route, sessionId: 'unstable' },
|
||||
}),
|
||||
(error) => error?.code === 'invalid-target',
|
||||
channel,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel-specific route kinds stay strict', () => {
|
||||
assert.throws(
|
||||
() => normalizeDeliveryTarget('weixin', {
|
||||
targetId: 'group', kind: 'group', route: { toUserId: 'wx-user' },
|
||||
}),
|
||||
(error) => error?.code === 'invalid-target',
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDeliveryTarget('telegram', {
|
||||
targetId: 'topic', kind: 'topic', route: { chatId: '-100', messageThreadId: '42' },
|
||||
}),
|
||||
(error) => error?.code === 'invalid-target',
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDeliveryTarget('whatsapp', {
|
||||
targetId: 'broadcast', kind: 'group', route: { jid: 'status@broadcast' },
|
||||
}),
|
||||
(error) => error?.code === 'invalid-target',
|
||||
);
|
||||
});
|
||||
131
test/delivery-rpc.test.mjs
Normal file
131
test/delivery-rpc.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DELIVERY_RPC_CHANNEL,
|
||||
DELIVERY_TEST_MESSAGE,
|
||||
createDeliveryRpcHandler,
|
||||
installDeliveryRpc,
|
||||
} from '../plugin-src/host/delivery-rpc.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 };
|
||||
};
|
||||
}
|
||||
return { service, calls };
|
||||
}
|
||||
|
||||
test('delivery RPC forwards all endpoints and both target.test payloads to the shared service', async () => {
|
||||
const { service, calls } = serviceFixture();
|
||||
const handle = createDeliveryRpcHandler(service);
|
||||
const signal = new AbortController().signal;
|
||||
const target = {
|
||||
targetId: 'daily-report',
|
||||
name: 'Daily report',
|
||||
kind: 'group',
|
||||
route: { chatId: 'chat-one' },
|
||||
};
|
||||
const replacement = { kind: 'group', route: { chatId: 'chat-two' } };
|
||||
const draft = { kind: 'group', route: { chatId: 'chat-draft' } };
|
||||
|
||||
for (const [endpoint, payload] of [
|
||||
['message.send', { botId: 'bot_one', targetId: 'daily-report', text: 'hello' }],
|
||||
['target.list', { botId: 'bot_one' }],
|
||||
['target.suggestion.list', { botId: 'bot_one' }],
|
||||
['target.create', { botId: 'bot_one', target }],
|
||||
['target.update', { botId: 'bot_one', targetId: 'daily-report', target: replacement }],
|
||||
['target.delete', { botId: 'bot_one', targetId: 'daily-report' }],
|
||||
['target.test', { botId: 'bot_one', targetId: 'daily-report' }],
|
||||
['target.test', { botId: 'bot_one', target: draft }],
|
||||
]) {
|
||||
assert.equal((await handle(endpoint, payload, signal)).ok, true);
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['send', 'bot_one', 'daily-report', 'hello', { signal }],
|
||||
['listTargets', 'bot_one'],
|
||||
['listSuggestions', 'bot_one'],
|
||||
['createTarget', 'bot_one', target],
|
||||
['updateTarget', 'bot_one', 'daily-report', replacement],
|
||||
['deleteTarget', 'bot_one', 'daily-report'],
|
||||
['send', 'bot_one', 'daily-report', DELIVERY_TEST_MESSAGE, { signal }],
|
||||
['send', 'bot_one', draft, DELIVERY_TEST_MESSAGE, { signal }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('delivery RPC rejects unknown, missing, and extra fields before calling the service', async () => {
|
||||
const { service, calls } = serviceFixture();
|
||||
const handle = createDeliveryRpcHandler(service);
|
||||
for (const [endpoint, payload] of [
|
||||
['unknown', {}],
|
||||
['message.send', { botId: 'bot_one', targetId: 'target', text: 'hello', sessionId: 'no' }],
|
||||
['message.send', { botId: 'bot_one', targetId: 'target', text: ' ' }],
|
||||
['target.list', { botId: '../bot' }],
|
||||
['target.suggestion.list', { botId: 'bot_one', sessionId: 'no' }],
|
||||
['target.create', {
|
||||
botId: 'bot_one',
|
||||
target: { targetId: 'bad target', kind: 'group', route: { chatId: 'one' } },
|
||||
}],
|
||||
['target.update', {
|
||||
botId: 'bot_one',
|
||||
targetId: 'old',
|
||||
target: { targetId: 'new', kind: 'group', route: { chatId: 'one' } },
|
||||
}],
|
||||
['target.delete', { botId: 'bot_one', targetId: 'target', confirm: true }],
|
||||
['target.test', ['bot_one', 'target']],
|
||||
['target.test', { botId: 'bot_one', targetId: 'target', target: {
|
||||
kind: 'group', route: { chatId: 'one' },
|
||||
} }],
|
||||
['target.test', { botId: 'bot_one', target: {
|
||||
targetId: 'target', kind: 'group', route: { chatId: 'one' },
|
||||
} }],
|
||||
['target.test', { botId: 'bot_one', target: {
|
||||
name: 'Draft', kind: 'group', route: { chatId: 'one' },
|
||||
} }],
|
||||
['target.test', { botId: 'bot_one', target: { kind: 'group' } }],
|
||||
]) {
|
||||
assert.equal((await handle(endpoint, payload)).error.code, 'bad-request');
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('delivery RPC returns only stable public errors and handles pre-cancelled calls', async () => {
|
||||
const { service } = serviceFixture();
|
||||
service.listTargets = async () => {
|
||||
throw new Error('token=secret /Users/private/workspaces.json');
|
||||
};
|
||||
const handle = createDeliveryRpcHandler(service);
|
||||
assert.deepEqual(await handle('target.list', { botId: 'bot_one' }), {
|
||||
ok: false,
|
||||
error: { code: 'delivery-failed', message: 'delivery-failed', details: {} },
|
||||
});
|
||||
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
assert.deepEqual(await handle('target.list', { botId: 'bot_one' }, abort.signal), {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'cancelled', details: {} },
|
||||
});
|
||||
});
|
||||
|
||||
test('delivery RPC uses its own channel and the configured management authority', () => {
|
||||
const { service } = serviceFixture();
|
||||
const calls = [];
|
||||
installDeliveryRpc({
|
||||
connection: { rpc: { handle: (...args) => calls.push(args) } },
|
||||
}, service, { authority: 'trusted-host' });
|
||||
assert.equal(calls[0][0], DELIVERY_RPC_CHANNEL);
|
||||
assert.equal(typeof calls[0][1], 'function');
|
||||
assert.deepEqual(calls[0][2], { authority: 'trusted-host' });
|
||||
});
|
||||
226
test/delivery-service.test.mjs
Normal file
226
test/delivery-service.test.mjs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDeliveryAdapter } from '../plugin-src/host/delivery-adapter.mjs';
|
||||
import { createDeliveryService } from '../plugin-src/host/delivery-service.mjs';
|
||||
|
||||
function memoryAdapter({ channel = 'telegram', botId = 'bot_one' } = {}) {
|
||||
const targets = new Map();
|
||||
const sends = [];
|
||||
return {
|
||||
channel,
|
||||
sends,
|
||||
ownsBot: (candidate) => candidate === botId,
|
||||
listTargets: () => [...targets.values()].map((target) => structuredClone(target)),
|
||||
listSuggestions: () => [{ kind: 'chat', route: { chatId: '123' } }],
|
||||
async createTarget(_botId, target) {
|
||||
if (targets.has(target.targetId)) {
|
||||
const error = new Error('duplicate');
|
||||
error.code = 'target-conflict';
|
||||
throw error;
|
||||
}
|
||||
targets.set(target.targetId, structuredClone(target));
|
||||
return structuredClone(target);
|
||||
},
|
||||
async updateTarget(_botId, targetId, replacement) {
|
||||
if (!targets.has(targetId)) {
|
||||
const error = new Error('missing');
|
||||
error.code = 'unknown-target';
|
||||
throw error;
|
||||
}
|
||||
const target = { targetId, ...structuredClone(replacement) };
|
||||
targets.set(targetId, target);
|
||||
return structuredClone(target);
|
||||
},
|
||||
async deleteTarget(_botId, targetId) {
|
||||
if (!targets.delete(targetId)) {
|
||||
const error = new Error('missing');
|
||||
error.code = 'unknown-target';
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async sendText(...args) { sends.push(args); },
|
||||
};
|
||||
}
|
||||
|
||||
test('DeliveryService shares target CRUD and sending through one adapter', async () => {
|
||||
const service = createDeliveryService();
|
||||
const adapter = memoryAdapter();
|
||||
service.registerAdapter(adapter);
|
||||
const target = {
|
||||
targetId: 'daily-report',
|
||||
name: 'Daily report',
|
||||
kind: 'chat',
|
||||
route: { chatId: 123 },
|
||||
};
|
||||
|
||||
assert.deepEqual(await service.createTarget('bot_one', target), target);
|
||||
assert.deepEqual(await service.listTargets('bot_one'), {
|
||||
botId: 'bot_one',
|
||||
channel: 'telegram',
|
||||
targets: [target],
|
||||
});
|
||||
assert.deepEqual(await service.listSuggestions('bot_one'), {
|
||||
botId: 'bot_one',
|
||||
channel: 'telegram',
|
||||
suggestions: [{ kind: 'chat', route: { chatId: '123' } }],
|
||||
});
|
||||
assert.deepEqual(await service.updateTarget('bot_one', 'daily-report', {
|
||||
name: 'New target',
|
||||
kind: 'chat',
|
||||
route: { chatId: 456 },
|
||||
}), {
|
||||
targetId: 'daily-report',
|
||||
name: 'New target',
|
||||
kind: 'chat',
|
||||
route: { chatId: 456 },
|
||||
});
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
assert.deepEqual(
|
||||
await service.send('bot_one', 'daily-report', 'keep original whitespace\n', { signal }),
|
||||
{ sent: true },
|
||||
);
|
||||
assert.deepEqual(adapter.sends, [[
|
||||
'bot_one',
|
||||
{
|
||||
targetId: 'daily-report',
|
||||
name: 'New target',
|
||||
kind: 'chat',
|
||||
route: { chatId: 456 },
|
||||
},
|
||||
'keep original whitespace\n',
|
||||
{ signal },
|
||||
]]);
|
||||
assert.deepEqual(await service.deleteTarget('bot_one', 'daily-report'), { deleted: true });
|
||||
await assert.rejects(service.send('bot_one', 'daily-report', 'missing'), { code: 'unknown-target' });
|
||||
});
|
||||
|
||||
test('DeliveryService validates public ids, text, cancellation, and unknown bots', async () => {
|
||||
const service = createDeliveryService();
|
||||
service.registerAdapter(memoryAdapter());
|
||||
await assert.rejects(service.listTargets('../bot'), { code: 'bad-request' });
|
||||
await assert.rejects(service.listSuggestions('../bot'), { code: 'bad-request' });
|
||||
await assert.rejects(service.listTargets('bot_missing'), { code: 'unknown-bot' });
|
||||
await assert.rejects(service.listSuggestions('bot_missing'), { code: 'unknown-bot' });
|
||||
await assert.rejects(service.send('bot_one', 'bad target', 'hello'), { code: 'bad-request' });
|
||||
await assert.rejects(service.send('bot_one', 'target', ' '), { code: 'bad-request' });
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await assert.rejects(service.send('bot_one', 'target', 'hello', { signal: abort.signal }), {
|
||||
code: 'cancelled',
|
||||
});
|
||||
await assert.rejects(
|
||||
service.updateTarget('bot_one', 'target', {
|
||||
targetId: 'renamed', kind: 'chat', route: { chatId: 1 },
|
||||
}),
|
||||
{ code: 'bad-request' },
|
||||
);
|
||||
});
|
||||
|
||||
test('DeliveryService tests a validated draft route without reading or persisting targets', async () => {
|
||||
const service = createDeliveryService();
|
||||
const workspaceCalls = [];
|
||||
const sends = [];
|
||||
service.registerAdapter(createDeliveryAdapter({
|
||||
channel: 'telegram',
|
||||
workspaces: {
|
||||
has: (botId) => botId === 'bot_one',
|
||||
listDeliveryTargets: (...args) => workspaceCalls.push(['list', ...args]),
|
||||
createDeliveryTarget: (...args) => workspaceCalls.push(['create', ...args]),
|
||||
updateDeliveryTarget: (...args) => workspaceCalls.push(['update', ...args]),
|
||||
deleteDeliveryTarget: (...args) => workspaceCalls.push(['delete', ...args]),
|
||||
},
|
||||
coreController: {
|
||||
async sendProactiveText(...args) { sends.push(args); },
|
||||
},
|
||||
stateFor: async () => ({ snapshot: () => ({ sessions: {} }) }),
|
||||
}));
|
||||
const signal = new AbortController().signal;
|
||||
const draft = {
|
||||
kind: 'topic',
|
||||
route: { chatId: '-1001234567890', messageThreadId: 42 },
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
await service.send('bot_one', draft, 'draft test', { signal }),
|
||||
{ sent: true },
|
||||
);
|
||||
assert.deepEqual(workspaceCalls, []);
|
||||
assert.deepEqual(sends, [[
|
||||
'bot_one',
|
||||
{ targetId: '__test__', ...draft },
|
||||
'draft test',
|
||||
{ signal },
|
||||
]]);
|
||||
assert.deepEqual(draft, {
|
||||
kind: 'topic',
|
||||
route: { chatId: '-1001234567890', messageThreadId: 42 },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
service.send('bot_one', { ...draft, targetId: 'must-not-participate' }, 'draft test'),
|
||||
{ code: 'bad-request' },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.send('bot_one', { ...draft, name: 'must-not-participate' }, 'draft test'),
|
||||
{ code: 'bad-request' },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.send('bot_one', {
|
||||
kind: 'topic', route: { chatId: '-1001234567890', messageThreadId: '42' },
|
||||
}, 'draft test'),
|
||||
{ code: 'invalid-target' },
|
||||
);
|
||||
assert.deepEqual(workspaceCalls, []);
|
||||
assert.equal(sends.length, 1);
|
||||
});
|
||||
|
||||
test('DeliveryService revalidates suggestions before exposing adapter output', async () => {
|
||||
const service = createDeliveryService();
|
||||
const adapter = memoryAdapter();
|
||||
service.registerAdapter(adapter);
|
||||
|
||||
adapter.listSuggestions = () => [{
|
||||
kind: 'chat',
|
||||
route: { chatId: '123' },
|
||||
sessionId: 'secret-session',
|
||||
}];
|
||||
await assert.rejects(service.listSuggestions('bot_one'), { code: 'invalid-target' });
|
||||
|
||||
adapter.listSuggestions = () => [{
|
||||
kind: 'chat',
|
||||
route: { chatId: '123', replyTarget: 'secret-reply' },
|
||||
}];
|
||||
await assert.rejects(service.listSuggestions('bot_one'), { code: 'invalid-target' });
|
||||
});
|
||||
|
||||
test('DeliveryService maps an adapter abort to cancelled', async () => {
|
||||
const service = createDeliveryService();
|
||||
const adapter = memoryAdapter();
|
||||
adapter.sendText = async () => {
|
||||
const error = new Error('aborted downstream');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
};
|
||||
service.registerAdapter(adapter);
|
||||
await service.createTarget('bot_one', {
|
||||
targetId: 'target', kind: 'chat', route: { chatId: 1 },
|
||||
});
|
||||
await assert.rejects(service.send('bot_one', 'target', 'hello'), { code: 'cancelled' });
|
||||
});
|
||||
|
||||
test('DeliveryService adapter replacement has stale-safe unregister semantics', async () => {
|
||||
const service = createDeliveryService();
|
||||
const first = memoryAdapter();
|
||||
const second = memoryAdapter();
|
||||
const unregisterFirst = service.registerAdapter(first);
|
||||
const unregisterSecond = service.registerAdapter(second);
|
||||
assert.equal(unregisterFirst(), false);
|
||||
await service.createTarget('bot_one', {
|
||||
targetId: 'current', kind: 'chat', route: { chatId: 1 },
|
||||
});
|
||||
assert.equal((await service.listTargets('bot_one')).targets.length, 1);
|
||||
assert.equal(unregisterSecond(), true);
|
||||
await assert.rejects(service.listTargets('bot_one'), { code: 'unknown-bot' });
|
||||
});
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis';
|
||||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes nine IM channels and the AI Office connector inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const deliveryService = { marker: 'shared-delivery-service' };
|
||||
const plugin = createImHostPlugin({
|
||||
createDeliveryService: () => deliveryService,
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]),
|
||||
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
|
||||
|
|
@ -41,19 +45,109 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
|
|||
'typertGateway',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }],
|
||||
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }],
|
||||
['dingtalk', ctx, { ...config.dingtalk, rpcAuthority: 'trusted-host' }],
|
||||
['wecom', ctx, { ...config.wecom, rpcAuthority: 'trusted-host' }],
|
||||
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }],
|
||||
['slack', ctx, { ...config.slack, rpcAuthority: 'trusted-host' }],
|
||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
||||
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['dingtalk', ctx, { ...config.dingtalk, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['wecom', ctx, { ...config.wecom, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['slack', ctx, { ...config.slack, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host', deliveryService }],
|
||||
['office', ctx, { ...config.office, rpcAuthority: 'trusted-host' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Host provides #65 and installs #84 with the same delivery service', async () => {
|
||||
const sent = [];
|
||||
const deliveryService = {
|
||||
async send(...args) { sent.push(args); return { sent: true }; },
|
||||
async listTargets(botId) {
|
||||
return { botId, channel: 'telegram', targets: [{ targetId: 'target' }] };
|
||||
},
|
||||
};
|
||||
const provided = [];
|
||||
const rpc = [];
|
||||
const channelServices = [];
|
||||
const internals = Object.fromEntries(CHANNELS.map(([channel, applyName]) => [
|
||||
applyName,
|
||||
async (_ctx, config) => {
|
||||
if (channel !== 'office') channelServices.push(config.deliveryService);
|
||||
},
|
||||
]));
|
||||
Object.assign(internals, {
|
||||
createDeliveryService: () => deliveryService,
|
||||
installUpdateRpc: () => {},
|
||||
installDeliveryRpc: (...args) => rpc.push(args),
|
||||
});
|
||||
const ctx = {
|
||||
connection: { rpc: {} },
|
||||
provide: (...args) => provided.push(args),
|
||||
};
|
||||
|
||||
await createImHostPlugin(internals).apply(ctx, { rpcAuthority: 'trusted-host' });
|
||||
|
||||
assert.equal(provided[0][0], 'dshIm');
|
||||
assert.equal(rpc[0][1], deliveryService);
|
||||
assert.deepEqual(rpc[0][2], { authority: 'trusted-host' });
|
||||
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 });
|
||||
assert.deepEqual(sent, [['bot_one', 'target', 'hello', undefined]]);
|
||||
});
|
||||
|
||||
test('#65 activates a real Cordis consumer without crossing the Connection RPC', async (t) => {
|
||||
const ctx = new Context();
|
||||
const rpcCalls = [];
|
||||
ctx.provide('connection', {
|
||||
rpc: {
|
||||
handle: () => async () => {},
|
||||
call: (...args) => rpcCalls.push(args),
|
||||
},
|
||||
});
|
||||
ctx.provide('credentials', {});
|
||||
ctx.provide('typertGateway', { stream() {} });
|
||||
ctx.provide('sessionController', {});
|
||||
ctx.provide('workspaceController', {});
|
||||
|
||||
const sent = [];
|
||||
const deliveryService = {
|
||||
async send(...args) {
|
||||
sent.push(args);
|
||||
return { sent: true };
|
||||
},
|
||||
async listTargets() { return { targets: [] }; },
|
||||
};
|
||||
const internals = Object.fromEntries(CHANNELS.map(([, applyName]) => [
|
||||
applyName,
|
||||
async () => {},
|
||||
]));
|
||||
Object.assign(internals, {
|
||||
createDeliveryService: () => deliveryService,
|
||||
installUpdateRpc: () => async () => {},
|
||||
installDeliveryRpc: () => async () => {},
|
||||
});
|
||||
|
||||
const host = ctx.plugin(createImHostPlugin(internals));
|
||||
t.after(() => host.dispose());
|
||||
await host.await();
|
||||
|
||||
let result;
|
||||
const consumer = ctx.plugin({
|
||||
name: 'dsh-im-delivery-consumer-test',
|
||||
inject: ['dshIm'],
|
||||
async apply(consumerCtx) {
|
||||
result = await consumerCtx.dshIm.send('bot_one', 'daily-report', '测试消息');
|
||||
},
|
||||
});
|
||||
t.after(() => consumer.dispose());
|
||||
await consumer.await();
|
||||
|
||||
assert.deepEqual(result, { sent: true });
|
||||
assert.deepEqual(sent, [['bot_one', 'daily-report', '测试消息', undefined]]);
|
||||
assert.equal(rpcCalls.length, 0);
|
||||
});
|
||||
|
||||
test('Host waits for apiProxy on legacy Harness and Controllers on modern Harness', async () => {
|
||||
for (const [modern, expected] of [
|
||||
[false, ['apiProxy']],
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ test('Host update initialization failure leaves all channel activations availabl
|
|||
const channels = ['Feishu', 'Weixin', 'Dingtalk', 'Wecom', 'Qq', 'Slack', 'Telegram', 'Discord', 'Whatsapp', 'Office'];
|
||||
const internals = Object.fromEntries(channels.map((channel) => [`apply${channel}`, async () => calls.push(channel)]));
|
||||
internals.installUpdateRpc = () => { throw new Error('updater unavailable'); };
|
||||
internals.installDeliveryRpc = () => {};
|
||||
const errors = [];
|
||||
await createImHostPlugin(internals).apply({
|
||||
connection: { rpc: {} }, logger: { error: (...args) => errors.push(args) },
|
||||
|
|
|
|||
|
|
@ -100,6 +100,78 @@ test('BotWorkspaceStore persists the creation default and keeps bots isolated',
|
|||
assert.equal(reloaded.workspaceFor('bot_two'), defaultWorkspace);
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore migrates v1 on the first delivery target and persists target CRUD', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
await writeFile(path, `${JSON.stringify({
|
||||
version: 1,
|
||||
workspaces: { bot_delivery: defaultWorkspace },
|
||||
})}\n`);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
assert.deepEqual(store.listDeliveryTargets('bot_delivery'), []);
|
||||
|
||||
const input = {
|
||||
targetId: 'daily-report',
|
||||
name: ' 每日汇报群 ',
|
||||
kind: 'group',
|
||||
route: { chatId: 'chat-one' },
|
||||
};
|
||||
assert.deepEqual(await store.createDeliveryTarget('bot_delivery', input), {
|
||||
targetId: 'daily-report',
|
||||
name: '每日汇报群',
|
||||
kind: 'group',
|
||||
route: { chatId: 'chat-one' },
|
||||
});
|
||||
input.route.chatId = 'mutated-outside-store';
|
||||
await store.updateDeliveryTarget('bot_delivery', 'daily-report', {
|
||||
kind: 'group',
|
||||
route: { chatId: 'chat-two' },
|
||||
});
|
||||
|
||||
const saved = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(saved.version, 2);
|
||||
assert.deepEqual(saved.deliveryTargets.bot_delivery['daily-report'], {
|
||||
kind: 'group',
|
||||
route: { chatId: 'chat-two' },
|
||||
});
|
||||
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
assert.deepEqual(reloaded.deliveryTargetFor('bot_delivery', 'daily-report'), {
|
||||
targetId: 'daily-report',
|
||||
kind: 'group',
|
||||
route: { chatId: 'chat-two' },
|
||||
});
|
||||
assert.equal(await reloaded.deleteDeliveryTarget('bot_delivery', 'daily-report'), true);
|
||||
assert.deepEqual(reloaded.listDeliveryTargets('bot_delivery'), []);
|
||||
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, 2);
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore keeps delivery targets bot-scoped and removes them with the bot', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await Promise.all([store.ensure('bot_one'), store.ensure('bot_two')]);
|
||||
const target = {
|
||||
targetId: 'same-target',
|
||||
kind: 'user',
|
||||
route: { userId: 'user-one' },
|
||||
};
|
||||
await store.createDeliveryTarget('bot_one', target);
|
||||
await store.createDeliveryTarget('bot_two', {
|
||||
...target,
|
||||
route: { userId: 'user-two' },
|
||||
});
|
||||
await assert.rejects(store.createDeliveryTarget('bot_one', target), { code: 'target-conflict' });
|
||||
await assert.rejects(
|
||||
store.updateDeliveryTarget('bot_one', 'missing', { kind: 'user', route: { userId: 'x' } }),
|
||||
{ code: 'unknown-target' },
|
||||
);
|
||||
|
||||
await store.remove('bot_one');
|
||||
assert.throws(() => store.listDeliveryTargets('bot_one'), { code: 'unknown-bot' });
|
||||
assert.equal(store.deliveryTargetFor('bot_two', 'same-target').route.userId, 'user-two');
|
||||
const saved = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(saved.deliveryTargets.bot_one, undefined);
|
||||
assert.equal(saved.deliveryTargets.bot_two['same-target'].route.userId, 'user-two');
|
||||
});
|
||||
|
||||
test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspace', async (t) => {
|
||||
const { root } = await fixture(t);
|
||||
const store = await new BotWorkspaceStore(join(root, 'cwd-workspaces.json')).load();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue