@mrrisega/dsh-remote 0.7.12-beta.1 → 0.7.12-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dingtalk-bot.mjs +1361 -0
- package/clients/dsh-remote/docs/feishu-bot-contract.md +217 -0
- package/clients/dsh-remote/dsh-bridge.mjs +404 -90
- package/clients/dsh-remote/feishu-bot.mjs +1852 -0
- package/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
- package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
- package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +630 -0
- package/clients/dsh-remote/test/feishu-bot.test.mjs +1089 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
- package/dsh-setup.mjs +1 -1
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +500 -4
- package/packages/dsh-remote-web/lib/index.js +581 -27
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +404 -84
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +78 -7
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +14 -1
- package/packages/dsh-remote-web/test/im-bots.test.mjs +376 -2
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +397 -103
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# 飞书机器人通道 —— 模块 API / 契约 / 真实联调步骤(2026-11-08)
|
|
2
|
+
|
|
3
|
+
> 实现:`clients/dsh-remote/feishu-bot.mjs`(bridge 侧,懒加载官方 SDK)
|
|
4
|
+
> 调研依据:`research/2026-11-strategy/06-multiagent/feishu-long-connection.md`
|
|
5
|
+
> 依赖策略:`research/2026-11-strategy/06-multiagent/feishu-lazy-sdk-plan.md`
|
|
6
|
+
|
|
7
|
+
## 0. 一句话
|
|
8
|
+
|
|
9
|
+
飞书「企业自建应用」长连接机器人:**收消息 / 发通知 / 卡片拍板**,与企微、钉钉、微信并列的第四条通道;
|
|
10
|
+
**官方 SDK 不随安装包分发**(约 41MB),用户在面板点「下载依赖」后才懒加载;`package.json` 里**没有**这个依赖。
|
|
11
|
+
**接收人不需要用户填**:默认自动绑定(连上后用 `chat.list` 选机器人所在的群;或用户第一句话即绑定)。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. 依赖策略(业主明确要求)
|
|
16
|
+
|
|
17
|
+
| 项 | 做法 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `package.json` | **不声明** `@larksuiteoapi/node-sdk`(安装体积不变) |
|
|
20
|
+
| 下载位置 | `<relayDir>/im-deps/node_modules/@larksuiteoapi/node-sdk`(npm `--prefix` 本地安装;relayDir 默认 `~/.dsh-remote`) |
|
|
21
|
+
| 下载命令(插件/安装器执行) | `npm install --prefix <relayDir>/im-deps --no-save --no-audit --no-fund @larksuiteoapi/node-sdk` |
|
|
22
|
+
| 进度/失败 | 面板显示下载/解压/完成/失败;失败给人话(网络/磁盘/权限),可重试 |
|
|
23
|
+
| 运行时 | `loadFeishuSdk()` 动态 `import()` 入口(从 `package.json` 的 `main` 解析)+ 缓存;**没启用飞书就不加载** |
|
|
24
|
+
| 未下载 | 状态 `feishu.state = "sdk_missing"`、`deps.feishu.installed=false`,**不崩、不阻塞其它通道**;面板显示「下载依赖(约 41MB)」 |
|
|
25
|
+
| 卸载 | 「彻底卸载」默认一并删除 `im-deps/`(与调研方案一致) |
|
|
26
|
+
|
|
27
|
+
> 可选环境变量:`DSH_IM_DEPS_DIR` 覆盖依赖根目录(自建/测试用)。
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. 配置文件契约:`im-bots.json`(插件写、桥只读,0600)
|
|
32
|
+
|
|
33
|
+
```jsonc
|
|
34
|
+
{
|
|
35
|
+
"version": 1,
|
|
36
|
+
"wecom": { "...": "不变" },
|
|
37
|
+
"dingtalk": { "...": "不变" },
|
|
38
|
+
"feishu": {
|
|
39
|
+
"enabled": false,
|
|
40
|
+
"appId": "", // cli_ 开头;SDK 有严格正则(cli_+16 hex),不匹配会"保存成功但连不上"
|
|
41
|
+
"appSecret": "",
|
|
42
|
+
"domain": "feishu", // feishu=open.feishu.cn | lark=open.larksuite.com(国际版)
|
|
43
|
+
"receiveId": "", // **可选/高级场景**:手工指定推送目标;空 = 自动绑定(推荐留空)
|
|
44
|
+
"receiveIdType": "", // **可选**:"open_id" | "chat_id";空 = 按 boundChatType 自动判断
|
|
45
|
+
"boundChatId": "", // 桥写回/可预填:自动绑定的目标(面板只读展示)
|
|
46
|
+
"allowFrom": [], // open_id 白名单(空 = 不限制;建议填写)
|
|
47
|
+
"cardApproval": true, // 审批用卡片按钮(false = 纯文本 + 回电脑拍板)
|
|
48
|
+
"sdkVersion": "", // 下载成功后回写(面板展示)
|
|
49
|
+
"sessionId": "", // 可选:固定转发目标会话(空 = 跟随当前活跃会话)
|
|
50
|
+
"sessionTitle": ""
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**不加** `verifyToken` / `encryptKey`:长连接模式下事件/回调由 SDK 内部解密分发,那两个字段只属于 HTTP 回调模式。
|
|
56
|
+
|
|
57
|
+
## 3. 状态契约:`im-bot-status.json`(桥写,原子替换 + 0600)
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
{
|
|
61
|
+
"version": 1, "updatedAt": 0,
|
|
62
|
+
"wecom": { "...": "不变" },
|
|
63
|
+
"dingtalk": { "...": "不变" },
|
|
64
|
+
"feishu": {
|
|
65
|
+
"enabled": true, "state": "idle|connecting|connected|reconnecting|failed|sdk_missing",
|
|
66
|
+
"connected": true, "authenticated": true,
|
|
67
|
+
"lastError": "", "lastConnectedAt": 0, "lastMessageAt": 0,
|
|
68
|
+
"boundChatId": "oc_xxx 或 ou_xxx", // 自动绑定的目标
|
|
69
|
+
"boundChatType": "chat_id|open_id", // 决定发送用的 receive_id_type
|
|
70
|
+
"boundChatName": "项目群", // 群名(面板显示「已自动绑定:<群名>」)
|
|
71
|
+
"chatCount": 1, // 机器人所在群数(chat.list;null=没权限/没查到)
|
|
72
|
+
"receiveIdSource": "auto_group|auto|config|none", // 接收人来源
|
|
73
|
+
"receiveIdConfigured": false, // 用户是否手工填了 receiveId
|
|
74
|
+
"appId": "cli_9f42***b1", "domain": "feishu", "receiveIdType": "open_id",
|
|
75
|
+
"cardApproval": true, "sdkVersion": "1.76.0",
|
|
76
|
+
"tier": "free", "caps": ["..."], "limits": { "messages_per_month": 20 },
|
|
77
|
+
"usage": { "month": "2026-11", "messages": 0 },
|
|
78
|
+
"ackCount": 0, "decisionCount": 0
|
|
79
|
+
},
|
|
80
|
+
"deps": {
|
|
81
|
+
"feishu": { "installed": true, "version": "1.76.0", "path": "<...>/node_modules/@larksuiteoapi/node-sdk/index.js", "lastError": "" }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- 与企微/钉钉字段对齐;飞书**多一个 `state`**(5 态便于排查)。
|
|
87
|
+
- **只加不改**:写状态时先读旧文件再合并,绝不覆盖 `wecom` / `dingtalk` 段。
|
|
88
|
+
- `secret` 不出现在状态文件与日志里。
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 4. 模块 API
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import {
|
|
96
|
+
feishuSdkStatus, loadFeishuSdk, // 依赖
|
|
97
|
+
validateAppId, normalizeFeishuConfig, readFeishuConfig,
|
|
98
|
+
buildApprovalCard, buildDecidedCard, parseCardAction,
|
|
99
|
+
formatFeishuText, extractFeishuMessageText, normalizeInboundEvent,
|
|
100
|
+
feishuErrorHint, mapConnectError,
|
|
101
|
+
createFeishuSender, createFeishuBot, createFeishuBotController,
|
|
102
|
+
FEISHU_SETUP_GUIDE, FEISHU_ERROR_HINTS
|
|
103
|
+
} from "./feishu-bot.mjs";
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| 导出 | 用途 |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `feishuSdkStatus({relayDir, env})` | 只读探测依赖:`{installed,version,path,depsDir,lastError}`(面板用;不发网络) |
|
|
109
|
+
| `loadFeishuSdk({relayDir, env, sdkFactory, importImpl})` | 懒加载 + 缓存;`sdkFactory` 注入供测试 |
|
|
110
|
+
| `validateAppId(appId)` | `{ok,strict,warning?}`:宽松只拦明显错;不通过 SDK 严格正则时给 warning |
|
|
111
|
+
| `normalizeFeishuConfig / readFeishuConfig` | 配置归一 / 读 `im-bots.json` |
|
|
112
|
+
| `buildApprovalCard / buildDecidedCard` | 卡片 JSON 2.0(按钮 `behaviors:[{type:"callback",value}]`) |
|
|
113
|
+
| `parseCardAction` | 解析 `card.action.trigger`(operator/context/expiresAt) |
|
|
114
|
+
| `createFeishuSender` | tenant_access_token(缓存+失效刷新一次)+ 发消息 + PATCH 卡片 |
|
|
115
|
+
| `createFeishuBot` | `{start,stop,sendText,sendCard,patchCard,status,on}`;事件 `message/cardAction/ready/status` |
|
|
116
|
+
| `createFeishuBotController` | bridge 只接这个:`{start,stop,status}`;读配置 + 2s 热更新 + 状态原子写 + 会员闸 + 通知 |
|
|
117
|
+
| `FEISHU_SETUP_GUIDE` | 官方链接 + **8 步向导文案** + 顺序坑提示(插件半直接渲染) |
|
|
118
|
+
|
|
119
|
+
### 4.1 卡片回执时序(3 秒铁律)
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
用户点按钮 → card.action.trigger(长连接)
|
|
123
|
+
├─① 同步返回 {toast:{type:"success"}, card:{type:"raw",data:已决定卡片}} ← 必须在 3s 内
|
|
124
|
+
├─② 幂等:同一 requestId+action 第二次点只回 toast(10 分钟 TTL)
|
|
125
|
+
├─③ 过期(expiresAt 已过)→ toast「已过期(未回复按拒绝处理)」,**不交决策**
|
|
126
|
+
└─④ 异步把决策交给 onApprovalDecision(requestId, "allow"|"deny")
|
|
127
|
+
↓ bridge 注入的是 applyApprovalDecision(与手机拍板/企微卡片同一条路)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`allow` 只对应「允许一次」,不会变成永久放行;无决策来源时只 ACK 不生效(并记日志)。
|
|
131
|
+
|
|
132
|
+
### 4.2 接收人自动绑定(业主口径:不用用户填)
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
连接成功 ──▶ chat.list({page_size:20}) ← 需要可选权限 im:chat:readonly
|
|
136
|
+
├─ 1 个群 → 绑定它(receiveIdSource=auto_group,状态写 boundChatId/boundChatType=chat_id/boundChatName)
|
|
137
|
+
├─ 多个群 → 取第一个并计 chatCount(用户发消息即改绑;面板可提示"多个群")
|
|
138
|
+
├─ 空列表 → 不绑定,等首条消息
|
|
139
|
+
└─ 无权限/失败 → 只记日志(连接、发消息、消息绑定都不受影响)
|
|
140
|
+
首次收到消息 ──▶ 绑定该会话(单聊=sender.open_id;群=chat_id),覆盖 auto_group(receiveIdSource=auto)
|
|
141
|
+
配置里填了 receiveId ──▶ 永远优先(receiveIdSource=config;chat.list 只记录 chatCount,不覆盖)
|
|
142
|
+
未绑定 ──▶ 主动通知/审批卡跳过 + 记一条可读日志(不报错、不发请求);绑定成功后立即写状态
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- 发送时 `receive_id_type` 优先级:显式传参(内部回复)> **配置 `receiveId` 存在时**的 `receiveIdType` > 按 `boundChatType` 自动判断(`chat_id`/`open_id`)。
|
|
146
|
+
注意:`receiveId` 为空时,插件写的默认 `receiveIdType:"open_id"` **不生效**(否则会把群 `oc_…` 当 open_id 发)。
|
|
147
|
+
- chat.list 拿到群列表后,即使不覆盖绑定(配置优先/预填),也会按 `boundChatId` 补上 `boundChatName`(面板显示群名)。
|
|
148
|
+
- 配置里可预填 `boundChatId`(兼容旧配置/排障):`oc_` 开头按群、其余按 open_id;
|
|
149
|
+
**预填优先于 chat.list**(不会被"第一个群"覆盖,群数仍记录)。
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 5. 配置页向导(插件半照抄 `FEISHU_SETUP_GUIDE`)
|
|
154
|
+
|
|
155
|
+
**主路径只有两件事**(展示在第一层):
|
|
156
|
+
1. 创建一个企业自建应用,复制 **App ID / App Secret**(接收人不用填)
|
|
157
|
+
2. 填进面板 → 下载依赖 → 在飞书里给机器人**发一句话**(或机器人已在群里则自动绑定)
|
|
158
|
+
|
|
159
|
+
官方链接(业主 2026-11-08 提供,一字不改):
|
|
160
|
+
- 创建企业自建应用:<https://open.feishu.cn/app?lang=zh-CN>
|
|
161
|
+
- 卡片交互机器人文档:<https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot>
|
|
162
|
+
- 使用长连接接收事件:<https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case>
|
|
163
|
+
|
|
164
|
+
其余后台步骤折叠在「**需要更细的步骤**」里(首次配置仍需做完):
|
|
165
|
+
|
|
166
|
+
| # | 步骤 |
|
|
167
|
+
|---|---|
|
|
168
|
+
| 1 | 打开飞书开放平台 → 创建**企业自建应用**(商店应用不支持长连接) |
|
|
169
|
+
| 2 | 「凭证与基础信息」→ 复制 **App ID / App Secret** |
|
|
170
|
+
| 3 | 「添加应用能力」→ 开启**机器人** |
|
|
171
|
+
| 4 | 「权限管理」→ 申请收消息(`im:message.p2p_msg:readonly`、`im:message.group_at_msg:readonly`)与发消息(`im:message:send_as_bot`) |
|
|
172
|
+
| 5 | 「事件与回调 → 事件配置」→ 订阅方式选**使用长连接接收事件** → 添加「接收消息 v2.0」 |
|
|
173
|
+
| 6 | 「事件与回调 → 回调配置」→ 订阅方式选**使用长连接接收回调** → 添加 `card.action.trigger` |
|
|
174
|
+
| 7 | 「版本管理与发布」→ 创建版本并**发布**(不发布不生效);「可用范围」包含自己/目标成员 |
|
|
175
|
+
| 8 | 回到面板:填 App ID / Secret → **下载依赖** → **测试连接** → 在飞书里给机器人发一句话完成绑定 |
|
|
176
|
+
|
|
177
|
+
⚠️ **顺序坑**:保存事件/回调配置时,**必须已经有一个长连接在线**(否则提示「应用未建立长连接」)。
|
|
178
|
+
所以先在本机填凭据让连接起来,再去后台保存配置。
|
|
179
|
+
⚠️ **一个应用只挂一台电脑**(官方集群随机分发,多台会互抢事件;单应用上限 50 连接)。
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 6. 真实联调步骤(最小路径:只填 App ID/Secret)
|
|
184
|
+
|
|
185
|
+
> Lead 2026-11-08 用业主真实凭据实测:SDK 动态加载成功、`tenant_access_token` OK(781ms)、
|
|
186
|
+
> `WSClient` `state:"connected"`(**只要 App ID/Secret 就能连上,不需要额外配置**)、
|
|
187
|
+
> `client.im.v1.chat.list()` 能列出机器人所在群、`im.v1.message.create({receive_id_type:"chat_id"})` 发送成功。
|
|
188
|
+
|
|
189
|
+
1. **建应用**(企业自建)→ 复制 **App ID / App Secret**;开机器人能力、申请收发消息权限、事件/回调切长连接、发版(按 §5 折叠的步骤)。
|
|
190
|
+
2. **本机下载依赖**(面板按钮等价命令):
|
|
191
|
+
`npm install --prefix ~/.dsh-remote/im-deps --no-save --no-audit --no-fund @larksuiteoapi/node-sdk`
|
|
192
|
+
3. **写配置(只需要两样)**:`im-bots.json` 的 `feishu.{enabled:true, appId, appSecret}`。
|
|
193
|
+
**`receiveId` / `receiveIdType` 留空** —— 不需要填。
|
|
194
|
+
4. **启动 bridge** → 状态 `feishu.state → connected`;随后自动绑定:
|
|
195
|
+
- 机器人已在群里 → `chat.list` 自动选中并写 `boundChatId`(`receiveIdSource:"auto_group"`,面板显示「已自动绑定:<群名>」);
|
|
196
|
+
- 机器人还没进群 → 状态 `boundChatId:""`,此时通知会跳过(只记日志),**在飞书里给机器人发一句话**即自动绑定(私聊记 `open_id`、群聊记 `chat_id`)。
|
|
197
|
+
5. **验收**:
|
|
198
|
+
- 出站:电脑上让 agent 跑一个任务 → 飞书收到「任务完成/出错」通知;
|
|
199
|
+
- 入站:飞书发一句话 → 电脑端当前会话收到(免费档每月 20 条,超出有升级提示);
|
|
200
|
+
- 拍板:触发一次需要审批的操作 → 飞书收到卡片 → 点「允许一次」→ 卡片原地变「已允许」+ 电脑端继续;点「拒绝」→ 电脑端拒绝;**不回** → 90s 后按拒绝处理。
|
|
201
|
+
8. **排障对照**(状态文件 `feishu.lastError`):
|
|
202
|
+
- 凭据错 / 应用未发版 / 权限未申请 / 订阅方式没切长连接 / 可用范围不含你 / 机器人不在群 —— 都有对应人话(`FEISHU_ERROR_HINTS` / `mapConnectError`)。
|
|
203
|
+
- 企业网络走代理 → 明确提示「长连接不支持代理」。
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## 7. 已知限制 / 未核实
|
|
208
|
+
|
|
209
|
+
1. **本模块未做真机联调**:SDK/网络全部注入假实现单测(51 条);真实建连、卡片回调、发版生效需按 §6 验一次。
|
|
210
|
+
(已做只读验证:本机真实 SDK 1.76.0 能被 `loadFeishuSdk` 动态加载,`Client/WSClient/EventDispatcher` 均存在。)
|
|
211
|
+
2. **卡片回调能否走长连接**:官方文档说可以;SDK README 有一句相反表述(调研 R8)。真机点一次按钮确认。
|
|
212
|
+
3. `patchCard`(原地更新)用 REST PATCH,官方权限/版本限制未核实;失败只记日志(不影响 ACK)。
|
|
213
|
+
4. 流式回复:飞书没有该能力(调研 §4.3),本模块不提供;通知/审批不需要。
|
|
214
|
+
5. 群聊只处理 @机器人 的消息由飞书侧决定(我们按收到的消息处理);多设备抢事件需「一个应用一台电脑」。
|
|
215
|
+
6. **自动选群**(`chat.list`)需要可选权限 `im:chat:readonly`:**没这个权限也能用**(回落到"首条消息绑定"),
|
|
216
|
+
只是不能"连上即绑定群";多个群时取第一个(用户发消息会改绑),面板可用 `chatCount` 提示。
|
|
217
|
+
7. 私聊绑定记的是 `sender.open_id`(不是 chat_id):用户换群/换会话不会发错地方;群绑定记 `chat_id`。
|