@cubeai/dsh-feishu-channels 0.2.0 → 0.2.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.
@@ -0,0 +1,538 @@
1
+ # DSH飞书频道技术实现说明
2
+
3
+ > 本文面向需要了解插件内部机制、排障或二次开发的读者。安装、添加频道等日常操作请见 [README.md](../README.md)。
4
+
5
+ ## 1. 定位与边界
6
+
7
+ 「DSH飞书频道」用于把飞书机器人应用接入本机 DeepSeek Harness。插件只支持飞书,不包含其他 IM 通道,也不依赖云端中转:飞书消息由本机 Host 通过飞书长连接接收,再交给 DSH 的 Agent 处理。
8
+
9
+ 一个**频道**对应:
10
+
11
+ - 一个飞书自建机器人应用(App ID / App Secret);
12
+ - 一条飞书长连接;
13
+ - 一组本地配置:模型、工作区、权限、Agent 预设、私聊准入、发送方式和命令权限。
14
+
15
+ 添加频道不会创建新的飞书用户账号,而是在当前飞书账号/租户下创建或绑定一个机器人应用。插件内部的 `account` / `accountId` 是沿用下来的数据模型名称,对应界面上的“频道”。
16
+
17
+ ## 2. 包结构与加载
18
+
19
+ | 部分 | 文件 | 作用 |
20
+ |---|---|---|
21
+ | Cordis bundle patch | `cordis.patch.yml` | 向 profile 插入 Host 插件行 `feishu-channel` |
22
+ | Host 入口 | `src/index.js` | 注册 Config、初始化状态目录和日志、创建 `ChannelManager` |
23
+ | Host 核心 | `src/manager.js` | 频道生命周期、状态、凭据、HTTP API、扫码/连接/诊断 |
24
+ | 消息引擎 | `src/engine/*` | 会话路由、消息合并、Agent 调用、审批/提问、命令、投递 |
25
+ | 飞书通道 | `src/channels/*` | 飞书长连接、事件解析、媒体、扫码建应用、应用配置 |
26
+ | Client 入口 | `client.js` | 注册页签、频道页、会话行图标和 composer |
27
+ | 本地化 | `locale/*.json` | 中文/英文插件元数据 |
28
+
29
+ `package.json` 中:
30
+
31
+ - `dsh.bundle.patch` 指向 `cordis.patch.yml`;
32
+ - `dsh.client.platform` 为 `web`;
33
+ - `dsh.client.inject` 注入 locale、slots 和 UI primitives。
34
+
35
+ Host Config:
36
+
37
+ | 配置 | 默认值 | 作用 |
38
+ |---|---|---|
39
+ | `stateDir` | 空(使用 `$DSH_HOME/dsh-feishu-channels`) | 状态目录和日志目录 |
40
+ | `cwd` | `process.cwd()` | 默认工作区 |
41
+ | `provider` | 空 | 新频道的默认模型提供商 |
42
+ | `model` | 空 | 新频道的默认模型 |
43
+ | `agentPreset` | `standard` | 默认 Agent 预设 |
44
+ | `mergeTimeoutSecs` | `5` | 连续消息合并等待时间 |
45
+
46
+ Host 启动顺序大致为:
47
+
48
+ ```text
49
+ apply()
50
+ → 解析 Config(stateDir/cwd/provider/model/agentPreset/mergeTimeoutSecs)
51
+ → 创建状态目录和 gateway.log
52
+ → new ChannelManager(...)
53
+ → manager.registerApi(ctx)
54
+ → manager.initEnabled() # 启动已启用的频道
55
+ → manager.attachMappedSessions() # 恢复已有 dfc: 会话
56
+ ```
57
+
58
+ `src/index.js` 的 `ctx.effect` 在卸载时调用 `disposeApi()`,停止 API、扫码、频道连接和消息引擎。
59
+
60
+ ## 3. Host 架构
61
+
62
+ ```text
63
+ src/index.js
64
+ └─ ChannelManager
65
+ ├─ channels.json / allowlist / pending
66
+ ├─ CredentialVault(ctx.credentials 或 secrets.json 回退)
67
+ ├─ PairingHub(飞书扫码)
68
+ ├─ FeishuChannelAdapter(飞书 WebSocket)
69
+ └─ ImEngine
70
+ ├─ SessionRouter(dfc: 会话)
71
+ ├─ SessionMerger / ReplyStreamHub
72
+ ├─ ApprovalBroker / QuestionBroker
73
+ ├─ ChatCommands / CommandPermissions
74
+ ├─ DeferredDelivery
75
+ └─ FileDelivery / ImageInput
76
+ ```
77
+
78
+ ### 3.1 ChannelManager
79
+
80
+ 负责:
81
+
82
+ - 加载、迁移和原子写入 `channels.json`;
83
+ - 维护频道启用状态、接收状态和运行中的 adapter;
84
+ - 保存/解析凭据引用,不把 Secret 写进普通状态文件;
85
+ - 扫码配对、连接、重连、诊断、移除;
86
+ - 提供管理 API;
87
+ - 将飞书消息交给 `ImEngine`,把 Agent 回复送回飞书;
88
+ - 维护 `dfc:` 会话映射,并挂载到对应工作区。
89
+
90
+ ### 3.2 PairingHub
91
+
92
+ 扫码绑定的入口。当前 UI 只暴露扫码流程:
93
+
94
+ - 生成飞书应用创建二维码;
95
+ - 二维码带频道名称和应用所需 scope 的预填参数;
96
+ - 轮询扫码状态;
97
+ - 保存 App ID/Secret;
98
+ - 扫码成功后创建频道并启动连接。
99
+
100
+ ### 3.3 FeishuChannelAdapter
101
+
102
+ 基于 `@larksuiteoapi/node-sdk` 的飞书长连接适配器:
103
+
104
+ - 用 App ID/App Secret 建立 WebSocket 长连接;
105
+ - 接收私聊、群聊 @、图片、文件、富文本等事件;
106
+ - 将飞书消息规范化为引擎事件;
107
+ - 把文本、卡片、图片、文件回复发送到飞书;
108
+ - 暴露连接状态和诊断信息。
109
+
110
+ ### 3.4 ImEngine
111
+
112
+ 与具体 IM 平台解耦的消息/会话层,负责:
113
+
114
+ - 将飞书聊天映射为 DSH 会话;
115
+ - 消息去重、合并、分流;
116
+ - 调用 DSH Agent;
117
+ - 审批、提问、选择按钮和命令处理;
118
+ - 流式回复、分片、失败重试和延迟投递;
119
+ - 私聊准入和群聊 @ 规则;
120
+ - 文件和图片输入输出;
121
+ - 会话标题、历史恢复和工作区挂载。
122
+
123
+ Host 通过 `ctx` 使用这些宿主服务:
124
+
125
+ ```text
126
+ webServer, credentials, agents, agentPresets,
127
+ agentDefaultModel, llm, permissionPresets
128
+ ```
129
+
130
+ `connection`、`sessionPersistence`、`workspaceRegistry`、`userQuestions` 等通过 `ctx.get(...)` 按需读取;宿主缺少对应服务时会降级,而不是让插件启动失败。
131
+
132
+ ## 4. 状态、凭据与会话
133
+
134
+ ### 4.1 状态目录
135
+
136
+ 默认:
137
+
138
+ ```text
139
+ $DSH_HOME/dsh-feishu-channels
140
+ ```
141
+
142
+ 未设置 `DSH_HOME` 时通常是 `~/.dsh/dsh-feishu-channels`。可通过 Host Config 的 `stateDir` 覆盖。
143
+
144
+ | 文件 | 内容 |
145
+ |---|---|
146
+ | `channels.json` | 频道配置、允许列表、待批准请求、默认设置 |
147
+ | `sessions.json` | `dfc:` 会话映射 |
148
+ | `seen.json` | 消息去重记录 |
149
+ | `deferred-delivery.json` | 发送失败后的延迟投递状态 |
150
+ | `gateway.log` | 插件运行日志(滚动文件) |
151
+ | `secrets.json` | 仅当 Host 没有 credentials 服务时的本地凭据回退文件 |
152
+
153
+ ### 4.2 channels.json
154
+
155
+ 当前结构版本为 2,核心字段如下:
156
+
157
+ ```jsonc
158
+ {
159
+ "version": 2,
160
+ "channels": {
161
+ "feishu_ab12cd34ef56": {
162
+ "id": "feishu_ab12cd34ef56",
163
+ "platform": "feishu",
164
+ "name": "飞书频道 1",
165
+ "nameOrdinal": 1,
166
+ "enabled": true,
167
+ "receiveEnabled": true,
168
+ "config": {
169
+ "appId": "cli_xxx",
170
+ "appSecretRef": "dfc_feishu_ab12cd34ef56_appSecret",
171
+ "ownerOpenId": "ou_xxx"
172
+ },
173
+ "assistant": { "provider": "deepseek-official", "model": "deepseek-flash" },
174
+ "agentPreset": "standard",
175
+ "cwd": "/path/to/workspace",
176
+ "permission": "workspace-write",
177
+ "commandPermissions": {},
178
+ "privateAccess": "approved",
179
+ "sendMode": "queue",
180
+ "lastCheckedAt": "2026-11-04T..."
181
+ }
182
+ },
183
+ "allowlist": { "feishu_ab12cd34ef56": ["ou_xxx"] },
184
+ "pending": { "feishu_ab12cd34ef56": [] },
185
+ "assistant": {},
186
+ "cwd": "/path/to/workspace",
187
+ "permission": "workspace-write"
188
+ }
189
+ ```
190
+
191
+ 说明:
192
+
193
+ - `accountId` 由 `platform + "\0" + App ID` 的 SHA-256 前 12 位生成,格式为 `feishu_<hash>`;
194
+ - 若状态文件残留 `config.provisioning` 快照,加载时会自动删除并 flush;当前实现不写入该字段;
195
+ - `lastCheckedAt` 只记录最近一次连接诊断时间,不保存诊断明细;
196
+ - `secrets.json` 只在没有宿主 credentials 服务时使用,文件权限为 0600。
197
+
198
+ ### 4.3 凭据
199
+
200
+ 优先使用 DSH credentials 服务:
201
+
202
+ ```text
203
+ dfc_<channelId>_<field>
204
+ ```
205
+
206
+ 例如:
207
+
208
+ ```text
209
+ dfc_feishu_ab12cd34ef56_appSecret
210
+ ```
211
+
212
+ `channels.json` 只保存 `appSecretRef` 和 `hasAppSecret` 一类信息,不保存明文 Secret。普通 `/channels` 响应不会返回 Secret;只有用户明确请求 `/accounts/:id/secret` 时才会解析并返回。
213
+
214
+ ### 4.4 会话 ID 与映射
215
+
216
+ 飞书会话 ID 格式:
217
+
218
+ ```text
219
+ dfc:<channelId>:<dm|group>:<13位以上时间戳>:<chatId>
220
+ ```
221
+
222
+ 例如:
223
+
224
+ ```text
225
+ dfc:feishu_ab12cd34ef56:dm:1793773379160:oc_xxx
226
+ ```
227
+
228
+ - `dfc:` 与 im-connect 的 `im:` 会话完全分开;
229
+ - `sessions.json` 的 key 是 `channelId:dm|group:chatId`;
230
+ - `SessionRouter` 只恢复 `dfc:` 前缀的会话;
231
+ - 共享侧栏的 session filter 会把 `dfc:` 会话从“任务”页签中排除;
232
+ - 没有时间戳的 `dfc:` ID 也可被 parser 兼容。
233
+
234
+ ## 5. 管理 API
235
+
236
+ 基础路径:
237
+
238
+ ```text
239
+ /api/dsh-feishu-channels
240
+ ```
241
+
242
+ 请求约束:
243
+
244
+ - 入口调用宿主 `connection.requestRejection(req)` 做登录/来源校验;宿主认证服务不可用时返回 503;
245
+ - 变更请求必须带:
246
+
247
+ ```text
248
+ x-dsh-feishu-channels-client: 1
249
+ content-type: application/json
250
+ ```
251
+
252
+ - 请求体上限 1 MB;超限返回 413;
253
+ - GET 不做变更标记校验;
254
+ - 响应格式统一为 `{ ok, ... }`,错误只返回安全文案,不返回凭据。
255
+
256
+ 主要路由:
257
+
258
+ | 方法 | 路径 | 作用 |
259
+ |---|---|---|
260
+ | GET | `/channels` | 频道、会话分组、待批准请求和默认设置 |
261
+ | GET | `/assistant` | 默认模型/权限/预设/工作区和模型目录 |
262
+ | POST | `/assistant` | 保存默认设置 |
263
+ | POST | `/sessions/rename` | 重命名飞书会话 |
264
+ | POST | `/sessions/remove` | 删除飞书会话 |
265
+ | POST | `/sessions/cleanup-missing` | 清理已不存在的会话 |
266
+ | POST | `/sessions/ensure` | 确保会话已挂载 |
267
+ | GET | `/channels/feishu/qr/status` | 查询扫码状态 |
268
+ | POST | `/channels/feishu/qr/start` | 开始扫码 |
269
+ | POST | `/channels/feishu/qr/refresh` | 刷新二维码 |
270
+ | POST | `/channels/feishu/qr/cancel` | 取消扫码 |
271
+ | POST | `/channels/feishu/connect` | 使用 App ID/Secret 建立频道 |
272
+ | POST | `/channels/feishu/receive` | 设置接收开关 |
273
+ | POST | `/channels/feishu/disconnect` | 断开连接 |
274
+ | POST | `/channels/feishu/remove` | 移除频道 |
275
+ | POST | `/channels/feishu/approve` | 批准私聊用户 |
276
+ | POST | `/channels/feishu/deny` | 拒绝私聊用户 |
277
+ | POST | `/accounts/:id/settings` | 保存频道级设置 |
278
+ | POST | `/accounts/:id/secret` | 显式读取 App Secret |
279
+ | POST | `/accounts/:id/provision` | 一次性检查/应用飞书应用配置 |
280
+ | POST | `/accounts/:id/receive` | 设置频道接收开关 |
281
+ | POST | `/accounts/:id/reconnect` | 重连频道 |
282
+ | POST | `/accounts/:id/check` | 运行连接诊断 |
283
+ | POST | `/accounts/:id/remove` | 移除频道 |
284
+ | POST | `/accounts/:id/approve` | 批准频道用户 |
285
+ | POST | `/accounts/:id/deny` | 拒绝频道用户 |
286
+
287
+ 当前 Client 页面主要使用 `/channels`、`/assistant`、二维码、`/accounts/:id/*` 和会话操作;`/channels/feishu/connect` 保留在 Host 侧,但当前界面不提供手动 App ID/Secret 表单。
288
+
289
+ ## 6. 消息运行流程
290
+
291
+ ### 6.1 添加频道
292
+
293
+ ```text
294
+ 用户填写频道名称
295
+ → POST /channels/feishu/qr/start
296
+ → PairingHub 生成飞书应用创建二维码
297
+ → 用户用手机飞书扫码并确认
298
+ → PairingHub 获得 App ID / App Secret
299
+ → 保存凭据到 credentials
300
+ → ChannelManager.connect()
301
+ → 建立飞书长连接
302
+ → 可选执行应用配置 provisioning
303
+ → 频道出现在频道页
304
+ ```
305
+
306
+ ### 6.2 入站消息
307
+
308
+ ```text
309
+ 飞书 WebSocket 事件
310
+ → FeishuChannelAdapter 解析
311
+ → ImEngine
312
+ → 去重(seen.json)
313
+ → 私聊准入 / 群聊 @ 判定
314
+ → 斜杠命令或普通消息
315
+ → SessionMerger / SessionRouter
316
+ → DSH Agent
317
+ → 流式回复 / 审批 / 提问 / 文件
318
+ → FeishuChannelAdapter 发送
319
+ → 成功后记录投递状态
320
+ ```
321
+
322
+ ### 6.3 出站与失败处理
323
+
324
+ - 文本按飞书消息大小限制分片;
325
+ - Markdown 优先使用卡片;卡片不可用或发送失败时回退纯文本;
326
+ - 图片、文件通过飞书上传接口发送;
327
+ - 处理中可显示状态表情;
328
+ - 发送失败按分片退避重试;
329
+ - 明确失败或超时可能进入 `deferred-delivery.json`,由后台恢复逻辑重试;
330
+ - Agent 运行期间的用户问题/工具审批会转成飞书卡片或文本交互,结果再回填 DSH。
331
+
332
+ ### 6.4 访问控制与命令
333
+
334
+ - 私聊准入支持 `approved` 和 `all`;
335
+ - 首个用户/owner 会加入允许列表;
336
+ - 未批准用户在 `pending` 中显示,可在频道页批准/拒绝;
337
+ - 群聊按 @ 机器人判定;
338
+ - 每个频道可配置命令权限(私聊/群聊、允许的命令);
339
+ - 常用命令包括 `/status`、`/workspace`、`/new`、`/model` 等,具体以当前命令实现为准;
340
+ - 命令行回复使用频道的语言环境。
341
+
342
+ ## 7. 飞书应用 provisioning
343
+
344
+ 扫码创建应用时,插件会在二维码 URL 中预填:
345
+
346
+ - 应用名称;
347
+ - `source=dsh-feishu-channels`;
348
+ - `createOnly=true`;
349
+ - 应用 scope addons。
350
+
351
+ 当前 scope 常量:
352
+
353
+ | Scope | 类型 | 用途 |
354
+ |---|---|---|
355
+ | `im:message` | tenant | 接收/发送消息 |
356
+ | `im:message.send_as_user` | user | 以用户身份发送消息 |
357
+ | `application:application:patch` | tenant | 写入应用配置(名称、重定向 URL 等) |
358
+
359
+ 扫码完成后,`provisionFeishuApp()` 会尽力执行:
360
+
361
+ 1. 通过 `application.v7.applicationBase.patch` 写入应用名称;
362
+ 2. 通过 `application.v7.applicationConfig.patch` 写入 scope 和重定向 URL;
363
+ 3. v7 不适用时回退到 v6;
364
+ 4. 读取应用配置验证结果。
365
+
366
+ 该操作是 **best-effort**:飞书租户可能拒绝某些写入;调用方不能把它当作连接成功的必要条件。当前实现不会把 provisioning 结果写入 `channels.json`:
367
+
368
+ - `/accounts/:id/provision` 的响应会带本次结果;
369
+ - 自动 provisioning 的失败摘要会写 `gateway.log`;`/accounts/:id/provision` 的本次结果在响应中返回;
370
+ - 状态文件中残留的 `config.provisioning` 会在加载时清理。
371
+
372
+ 重定向 URL 常量位于 `src/channels/feishu-app.js`,主要是本机 `dsh-remote-feishu` OAuth 回调地址和部署域名回调地址。本插件只负责把这些 URL 写入飞书应用配置,不处理这些 OAuth 回调。
373
+
374
+ ## 8. Client 结构与界面
375
+
376
+ `client.js` 通过 `window.__ModuleLoader__.load` 注册模块,模块 id 为:
377
+
378
+ ```text
379
+ @cubeai/dsh-feishu-channels
380
+ ```
381
+
382
+ ### 8.1 侧边栏页签
383
+
384
+ DSH 没有公开的侧栏页签 API,`sidebar.workspaces` 只有一个 occupant。插件实现与 `dsh-im-connect` 兼容的私有 **Registry v1**:
385
+
386
+ - 如果官方 `sidebar.workspaces` 还没有被包装,插件包装它并挂上 registry;
387
+ - 如果已有合法 registry,插件把自己的 `feishu` 页签插入;
388
+ - 页签顺序:`channels` = 20,`feishu` = 30;
389
+ - 最终显示:
390
+
391
+ ```text
392
+ 任务 | 频道 | 飞书频道
393
+ ```
394
+
395
+ 只有本插件时显示:
396
+
397
+ ```text
398
+ 任务 | 飞书频道
399
+ ```
400
+
401
+ 页签插入/移除通过 `dsh-native-sidebar-change` 事件协调;所有 disposer 只清理自己插入的 tab/filter/opener。
402
+
403
+ ### 8.2 频道页
404
+
405
+ 频道页承载:
406
+
407
+ - 全宽“添加飞书频道”按钮;
408
+ - 频道列表:名称、连接状态、设置入口;
409
+ - 每个频道的飞书会话列表;
410
+ - 当前会话高亮;
411
+ - 待批准访问请求;
412
+ - 空状态和错误提示;
413
+ - 设置弹窗:
414
+ - 频道名称;
415
+ - 飞书机器人应用信息(App ID / App Secret 的显示、复制);
416
+ - 工作区;
417
+ - 模型和推理等级;
418
+ - 权限;
419
+ - Agent 预设;
420
+ - 私聊准入;
421
+ - 发送方式(排队/插话);
422
+ - 命令权限;
423
+ - 连接诊断、重连、移除。
424
+
425
+ 添加频道弹窗当前只保留扫码流程,包含频道名称和初始频道设置。
426
+
427
+ ### 8.3 会话行图标
428
+
429
+ 通过 `sidebar.session.row.leading` 槽位,为飞书会话显示飞书品牌小图标。它和任务行的其他 leading 图标使用同一槽位,不修改宿主 DOM。
430
+
431
+ ### 8.4 Composer
432
+
433
+ 对 `dfc:` 会话,插件注册 `conversation.composer`:
434
+
435
+ - 如果会话存在待审批/待回答交互,让位给宿主自身的交互 composer;
436
+ - 否则替换为飞书只读提示栏:
437
+ - 当前模型只读展示;
438
+ - 不允许在本地输入或发送;
439
+ - 模型切换在频道设置中完成;
440
+ - 运行中显示黑色圆形“停止”按钮;
441
+ - 点击停止先弹确认框,确认后调用会话级 `conversation.cancel()`,中断当前推理但保留队列。
442
+
443
+ ### 8.5 国际化与样式
444
+
445
+ - 中文/英文词典内置在 client 中;
446
+ - 服务端错误通过 `server.*` 键映射,未知错误显示通用文案;
447
+ - 样式选择器统一使用 `.dfc-` 前缀;
448
+ - 颜色尽量使用 DSH 主题 token(`--dsw-alias-*`);
449
+ - 不覆盖宿主全局样式,不写 `body`/`#root` 规则。
450
+
451
+ ## 9. 与 dsh-im-connect 的共存
452
+
453
+ 两个插件共享同一个 `sidebar.workspaces` 槽位,但不是 npm 依赖,也不共享 Host 状态:
454
+
455
+ | 项目 | dsh-im-connect | 本插件 |
456
+ |---|---|---|
457
+ | 会话前缀 | `im:` | `dfc:` |
458
+ | 页签 id | `channels` | `feishu` |
459
+ | 页签顺序 | 20 | 30 |
460
+ | 状态目录 | 各自独立 | 各自独立 |
461
+ | API 前缀 | 各自独立 | 各自独立 |
462
+ | 凭据 | 各自独立 | 各自独立 |
463
+
464
+ Registry v1 是私有约定,不是 DSH 公共 API。当前策略:
465
+
466
+ - 任一方都可以作为 wrapper;
467
+ - 另一方把页签插入 registry;
468
+ - 加载顺序不同都能得到 `任务 | 频道 | 飞书频道`;
469
+ - 无法识别外来宿主时降级为官方侧栏;
470
+ - 同一飞书 App ID 不能同时被两个插件连接(飞书长连接会冲突);
471
+ - 卸载时如果 DSH 报 `bundle-in-use`,先禁用本插件,再卸载 `dsh-im-connect`,之后可以重新启用本插件。
472
+
473
+ ## 10. 安全与隔离
474
+
475
+ - 管理 API 复用宿主登录/来源校验,不自行实现登录;
476
+ - 变更请求要求客户端标记头和 JSON content-type,降低跨站表单误操作风险;
477
+ - Secret 只通过显式接口读取,正常列表只暴露 `hasAppSecret`;
478
+ - 优先使用宿主 credentials 服务;
479
+ - `secrets.json` 回退文件权限为 0600;
480
+ - 频道状态、会话、凭据和 API 与 im-connect 独立;
481
+ - 插件不自带更新器,不修改其他插件的状态或代码。
482
+
483
+ ## 11. 诊断与运维
484
+
485
+ ### 11.1 日志
486
+
487
+ 默认日志:
488
+
489
+ ```text
490
+ $DSH_HOME/dsh-feishu-channels/gateway.log
491
+ ```
492
+
493
+ 日志包含启动、频道启动/停止、扫码、API 注册、消息投递和错误摘要。日志滚动策略由 `createRotatingFileAppender` 控制。
494
+
495
+ ### 11.2 连接诊断
496
+
497
+ `POST /accounts/:id/check` 会执行连接探测,返回:
498
+
499
+ - 当前频道状态;
500
+ - 凭据是否可用;
501
+ - 平台响应摘要;
502
+ - 诊断检查项。
503
+
504
+ 频道设置中的“诊断连接”会展示结果,失败时显示安全错误提示。诊断结果只用于当前操作,不写入 `channels.json`。
505
+
506
+ ### 11.3 重连与移除
507
+
508
+ - 重连:`POST /accounts/:id/reconnect`;
509
+ - 移除:`POST /accounts/:id/remove`,删除频道配置和对应凭据引用;
510
+ - 卸载插件本身不会自动删除状态目录;若要彻底清除,需要手动删除状态目录、凭据引用和 `dfc:` 会话记录。
511
+
512
+ ## 12. 已知限制
513
+
514
+ - 只支持飞书(Feishu),不包含 Lark 或其他 IM 通道;
515
+ - 当前界面以扫码创建应用为主,不提供手动 App ID/Secret 表单;
516
+ - 应用配置 provisioning 受飞书租户权限限制,是 best-effort;
517
+ - 共享侧栏依赖 Registry v1 私有约定,未来 im-connect 变更时需要适配;
518
+ - 同一 App ID 不能同时被本插件和 im-connect 使用;
519
+ - 插件不负责刷新/更新自身版本;
520
+ - 宿主认证服务不可用时,管理 API 会返回 503,页面无法加载频道数据;
521
+ - 客户端页面依赖 DSH Web profile 的 slots、locale 和 UI primitives。
522
+
523
+ ## 13. 验证
524
+
525
+ 当前自动化检查:
526
+
527
+ ```bash
528
+ pnpm test
529
+ pnpm run check
530
+ ```
531
+
532
+ 测试覆盖 Host API、扫码/配对、飞书应用配置、会话、消息处理、命令、客户端页签、频道页、composer 和共存 harness。
533
+
534
+ 相关代码:
535
+
536
+ - [README.md](../README.md):安装和使用;
537
+ - `src/channels/feishu-app.js`:scope、redirect URL 和 provisioning 的实际常量;
538
+ - `src/manager.js`:API 路由和状态字段的最终事实来源。
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "DSH飞书频道",
4
- "description": "将飞书机器人接入本机 DeepSeek Harness。"
4
+ "description": "将飞书机器人应用接入本机 DeepSeek Harness。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cubeai/dsh-feishu-channels",
3
- "version": "0.2.0",
4
- "description": "DSH飞书频道 — 将飞书机器人接入本机 DeepSeek Harness。",
3
+ "version": "0.2.3",
4
+ "description": "DSH飞书频道 — 将飞书机器人应用接入本机 DeepSeek Harness。",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -21,7 +21,9 @@ export const FEISHU_REDIRECT_URLS = [
21
21
  'http://127.0.0.1:3080/api/dsh-remote-feishu/oauth/callback',
22
22
  'http://localhost:3080/api/dsh-remote-feishu/oauth/callback',
23
23
  'http://127.0.0.1:32123/api/dsh-remote-feishu/oauth/callback',
24
+ 'http://localhost:32123/api/dsh-remote-feishu/oauth/callback',
24
25
  'http://127.0.0.1:8731/api/oauth/callback',
26
+ 'http://localhost:8731/api/oauth/callback',
25
27
  'https://www.cubeai.com:6443/cubeaispace-dsh1-1234567890-dsh/api/dsh-remote-feishu/oauth/callback',
26
28
  ]
27
29
 
@@ -28,7 +28,7 @@ export function parseFeishuPoll(body) {
28
28
  const userInfo = asRecord(record.user_info);
29
29
  const brand = cleanString(userInfo.tenant_brand);
30
30
  if (brand?.toLowerCase() === 'lark')
31
- return { status: 'failed', error: '检测到 Lark 账号;本插件仅支持飞书账号。' };
31
+ return { status: 'failed', error: '检测到 Lark 账号;本插件仅支持飞书频道。' };
32
32
  const appId = cleanString(record.client_id);
33
33
  const appSecret = cleanString(record.client_secret);
34
34
  if (appId && appSecret) {
@@ -166,7 +166,7 @@ export class PairingHub {
166
166
  session.status = 'saving';
167
167
  session.qrUrl = undefined;
168
168
  session.qrImage = undefined;
169
- session.hint = '正在保存账号';
169
+ session.hint = '正在保存频道';
170
170
  const credentials = { ...session.extra, ...polled.credentials };
171
171
  try {
172
172
  await this.onSuccess?.(session.channelId, credentials);
@@ -224,6 +224,6 @@ export class PairingHub {
224
224
  }
225
225
  function hintOf(id) {
226
226
  if (id === 'feishu')
227
- return '请使用飞书扫描二维码,将自动创建机器人';
227
+ return '请在手机上使用飞书App扫描二维码,将自动创建飞书机器人应用';
228
228
  return '请使用飞书扫描二维码';
229
229
  }
@@ -303,7 +303,7 @@ export class ChatCommands {
303
303
  text = channel.choiceLimits || section ? heading : text;
304
304
  text += ` · ${page}/${pages}`;
305
305
  if (section === 'presets')
306
- text += '\n' + replyText('选择后在当前工作区新建并切换会话,旧会话保留。账号默认预设不变。');
306
+ text += '\n' + replyText('选择后在当前工作区新建并切换会话,旧会话保留。频道默认预设不变。');
307
307
  if (!choices.length)
308
308
  text += '\n' + replyText('暂无可选项,可返回菜单选择其他操作。');
309
309
  choices = slices[page - 1];
@@ -454,7 +454,7 @@ export class ChatCommands {
454
454
  const alternative = items.findIndex((item) => !current || !item.sessionIds?.includes(current.sessionId));
455
455
  return items.length ? replyText('工作区列表 · 共 {0} 个\n\n', items.length) + items.map((item, i) => `${i + 1}. ${item.title ? oneLine(item.title) : oneLine(item.path)}${item.title ? `\n${item.path}` : ''}${current && item.sessionIds?.includes(current.sessionId) ? replyText('〔当前〕') : ''}`).join('\n') + related(alternative >= 0
456
456
  ? replyText('新建并切换:/workspace {0}', alternative + 1)
457
- : replyText('当前只有这个工作区。直接发消息继续,或 /new 开启新会话。'), replyText('也可使用已列出的绝对路径;旧会话保留,账号默认目录不变。'), replyText('别名:/workspacelist;序号 15 分钟内有效。')) : replyText('还没有可用工作区。请先在网页添加,再发送 /workspaces。');
457
+ : replyText('当前只有这个工作区。直接发消息继续,或 /new 开启新会话。'), replyText('也可使用已列出的绝对路径;旧会话保留,频道默认目录不变。'), replyText('别名:/workspacelist;序号 15 分钟内有效。')) : replyText('还没有可用工作区。请先在网页添加,再发送 /workspaces。');
458
458
  }
459
459
  const target = this.resolve(scope + ':workspaces', input);
460
460
  const workspace = items.find((item) => item.workspaceId === target || item.path === target);
@@ -487,7 +487,7 @@ export class ChatCommands {
487
487
  }
488
488
  if (command === 'presets' || command === 'presetlist' || command === 'preset') {
489
489
  const presets = await this.presets();
490
- const hint = replyText('选择后在当前工作区新建并切换会话,旧会话保留。账号默认预设不变。');
490
+ const hint = replyText('选择后在当前工作区新建并切换会话,旧会话保留。频道默认预设不变。');
491
491
  if (command !== 'preset') {
492
492
  this.remember(scope + ':presets', presets.map(item => `id:${item.id}`));
493
493
  return replyText('Agent 预设') + '\n' + presets.map((item, i) => `${i + 1}. ${oneLine(item.name || item.id)} (${item.id})${item.isDefault ? replyText('〔默认〕') : ''}${item.broken ? replyText('〔不可用〕') : ''}${item.description ? '\n' + oneLine(item.description) : ''}`).join('\n\n') + related(hint, replyText('用法:/preset 序号或ID;纯数字 ID:/preset id:ID;跟随默认:/preset --default'));
@@ -600,7 +600,7 @@ export class ChatCommands {
600
600
  replyText('状态:{0}', row?.running === true ? replyText('运行中') : row?.running === false ? replyText('空闲') : replyText('暂时无法读取')),
601
601
  replyText('工作区:{0}', row?.cwd || replyText('暂时无法读取')),
602
602
  model ? replyText('模型:{0}/{1};推理:{2}', model.provider, model.model, model.reasoningEffort || replyText('默认')) : replyText('模型:暂时无法读取'),
603
- ...details, replyText('账号:{0}({1})', channel.label, localizeStatus(this.host, channel.status())), replyText('会话 ID:{0}', sessionId),
603
+ ...details, replyText('频道:{0}({1})', channel.label, localizeStatus(this.host, channel.status())), replyText('会话 ID:{0}', sessionId),
604
604
  ].join('\n') + related(...(row?.running === true ? [replyText('补充要求:/steer 补充内容')] : []), ...actions.slice(0, 3).map(action => replyText('{0}:{1}', action.label, action.value)));
605
605
  }
606
606
  if (command === 'stop') {
@@ -49,7 +49,7 @@ export const commandEnglish = {
49
49
  "预设不存在或不可用,请发送 /presets 重新选择。": "This preset is missing or unavailable. Send /presets to choose again.",
50
50
  "当前 Agent 预设:{0}": "Current Agent preset: {0}",
51
51
  "用法:/preset 序号或ID;纯数字 ID:/preset id:ID;跟随默认:/preset --default": "Use /preset number or ID; for numeric IDs use /preset id:ID. Use /preset --default for the default preset.",
52
- "选择后在当前工作区新建并切换会话,旧会话保留。账号默认预设不变。": "Selecting a preset starts and switches to a new session in this workspace. Previous sessions are kept. The account default stays unchanged.",
52
+ "选择后在当前工作区新建并切换会话,旧会话保留。频道默认预设不变。": "Selecting a preset starts and switches to a new session in this workspace. Previous sessions are kept. The channel default stays unchanged.",
53
53
  "跟随默认预设": "Use default preset",
54
54
  "选择预设": "Choose preset",
55
55
  "Agent 预设": "Agent presets",
@@ -213,7 +213,7 @@ export const commandEnglish = {
213
213
  "最近记录:/history": "Recent messages: /history",
214
214
  "重新开始:/new": "Start over: /new",
215
215
  "工作区列表 · 共 {0} 个\n\n": "Workspaces · {0} total\n\n",
216
- "也可使用已列出的绝对路径;旧会话保留,账号默认目录不变。": "You can also use a listed absolute path. Previous sessions and the account default directory are preserved.",
216
+ "也可使用已列出的绝对路径;旧会话保留,频道默认目录不变。": "You can also use a listed absolute path. Previous sessions and the channel default directory are preserved.",
217
217
  "别名:/workspacelist;序号 15 分钟内有效。": "Alias: /workspacelist. Numbers expire after 15 minutes.",
218
218
  "还没有可用工作区。请先在网页添加,再发送 /workspaces。": "No workspaces available. Add one on the web, then send /workspaces.",
219
219
  "没有找到这个已添加的工作区,尚未切换。发送 /workspaces 查看可选项;新目录请先在网页添加。": "That workspace has not been added. Nothing was switched. Send /workspaces for options; add new directories on the web first.",
@@ -242,7 +242,7 @@ export const commandEnglish = {
242
242
  已将排队消息调整为补充指令: "Changed queued message to an additional instruction",
243
243
  "\n新内容:": "\nNew content: ",
244
244
  "查看队列:/queue": "View queue: /queue",
245
- "账号:{0}({1})": "Account: {0} ({1})",
245
+ "频道:{0}({1})": "Channel: {0} ({1})",
246
246
  "会话:{0}": "Session: {0}",
247
247
  运行中: "Running",
248
248
  空闲: "Idle",
@@ -256,7 +256,7 @@ export class ImEngine {
256
256
  async reloadChannel(channelId, options = {}) {
257
257
  this.cancelInputs(channelId);
258
258
  for (const sessionId of this.router.sessionIdsForChannel(channelId)) {
259
- this.cancelSessionInteractions(sessionId, new Error('账号配置已更新'));
259
+ this.cancelSessionInteractions(sessionId, new Error('频道配置已更新'));
260
260
  }
261
261
  if (options.resetSessions)
262
262
  await this.router.resetChannelSessions(channelId);