Skip to content
dsh.fish
Bundle

dsh-feishu-cui

不用自己处理运维的远程 DSH:拿飞书私聊当操作界面(CUI),消息与卡片走飞书机器人长连接

Source
wanjiaju3108
License
MIT
Updated
Updated 22 days ago

Readme

# dsh-feishu-cui

**免运维的远程 DSH:一套完整的远程使用方案**:拿飞书私聊当操作界面(`CUI`)——装一个插件、填一对凭据、在飞书里配对一次,之后**在手机上**就能用 DSH;常用操作都是卡片和菜单里点,不用像 `TUI` 那样记命令:发消息排进当前会话的队列、回答回到你那条消息上,工作区 / 会话(切换与改名)/ 模型 / 推理深度 / 权限 / 余额都在飞书里点,agent 反问与工具审批也在飞书答。

往下看:[这是什么](#这是什么) · [怎么用](#怎么用) · [边界与约定](#边界与约定) · [代码结构](#代码结构) · [用例脉络](#用例脉络) · [看日志](#看日志) · [术语表](#术语表) · [还没做](#还没做)

## 这是什么

**一套远程使用 DSH 的入口**:拿飞书私聊当操作界面(`CUI`)——提问、切会话、切模型与权限、打断、答反问、批工具都在飞书里做。**执行面在宿主**:真正跑命令、改文件的是 DSH 里的 agent,沙箱与审批策略由 DSH 决定。它由两半组成——宿主半边的插件(`lib/`,跑在 `dsh web` 里)和浏览器半边的设置页(`lib/settings/client.js`,挂在 DSH 的 Web 界面上)。

**它替你省掉的运维**(这是它跟「自己搭一套远程访问」最大的区别):

- **不用公网入口**:不需要公网 IP / 域名 / HTTPS 证书 / 反向代理。
- **不用内网穿透**:不需要 frp / ngrok / tmate 这类东西。
- **不用开端口、改防火墙 / 安全组**:机器人往飞书**出站**建一条长连接,网络侧什么都不用动。
- **不用自建中转服务**:消息走飞书官方通道(机器人长连接 + 卡片回调)。
- **不用写客户端**:**飞书本身就是客户端**——私聊收回答,卡片做选择,菜单做入口。
- **不用自己搓鉴权**:配对码一次性绑定 owner,机器人只服务他一个人。
- **不用盯着断线**:自动重连 + 存活看门狗(见[长连接的存活与恢复](#长连接的存活与恢复))。
- **不用额外起服务**:它就是 `dsh web` 的一个插件,装完跟着宿主加载。
- **不用搭服务端**:服务端就是飞书。要自己动手的只有开发者后台那几步——建一个自建应用、加 3 项订阅(两个事件 + 卡片回调)、补 2 条权限、配 8 个菜单项、发一次版本(见[飞书那边](#飞书那边))。

**跟 `TUI` 比,省的是「记」**:终端里每一步都要敲命令、记参数、盯滚动;这里工作区 / 会话 / 模型 / 推理深度 / 权限 / 余额各是一张卡,切会话、改名、打断、答反问、批审批都在飞书的消息和按钮里完成,回答做成卡片回到你发消息的那条上。

**能做什么**

- 私聊发一句话 → 排进「当前会话」的队列(不打断正在跑的那一轮),回答**回到那条消息上**:卡片跟着状态走——被取走后是「处理中」(带「停止」按钮),刚进队列还没被取走是「排队中」(带「撤回」按钮),之后逐段把正文接上去,跑完收尾成「已完成」/「已停止」/「处理失败」。正文超过卡片体积上限(30KB)就换成「任务失败」+ 一句「去网页端看」。
- 菜单八项:申请配对、工作区列表、会话列表(含「新建」)、会话重命名、模型、推理深度、权限、余额。
- agent 反问(`ask_user_question`)与工具审批(工具要授权)都在飞书弹卡:**谁触发的谁答**。
- 别处(网页端 / CLI)改了当前会话的模型 / 权限 / 会话名 → 主动私聊通知 owner 一张「模型已修改」/「权限已修改」/「会话名已修改」卡。
- 长连接自愈:休眠 / 半开连接有存活看门狗(见[长连接的存活与恢复](#长连接的存活与恢复))。

**不做什么(能力边界,详细取舍见[边界与约定](#边界与约定))**

- **只服务 owner 一个人**:别人发消息、点菜单一律回同一句「CUI会话未匹配」,且不区分「还没绑定」和「不是 owner」。
- **只认私聊、只认文本**:图片 / 文件 / 附件**永不支持**。
- **只认「飞书当前会话」**:别的会话怎么聊都不回答、也不推送。
- 反问只接「有选项、且不是多选」的题;多选题与全靠打字的题让给网页端(让路之前先回一句「这题飞书答不了」)。
- 模型 / 推理深度 / 权限都是**会话级**设置,只作用于当前会话。
- 卡片上不能自由输入文字(只有配对填配对码、会话重命名填名字这两张卡能输)。

**前置条件**

- 本机已装 DSH(`dsh` 与 `dsh plugin` 可用),profile 用 `web`——设置页挂在它的 Web 服务器上。
- 一个**自己的**飞书自建应用(要能开长连接),并且**只给自己用**:开发者后台的可用范围只勾自己。
- 别跟别的飞书插件共用同一个飞书应用:长连接是集群模式、不广播,同 App 会互相抢事件。本插件读自己的凭据引用名 `FEISHU_CUI_APP_ID` / `FEISHU_CUI_APP_SECRET`。

**依赖的宿主能力**(缺了哪个,对应功能就自动退化,并在日志里说明原因)

`credentials`、`settings`、`webServer`、`sessionController`(投喂 / 打断 / 撤排队项 / 模型目录 / 会话句柄)这四项写在 `inject` 里;另外按需取 `sessionQuery`(列会话)、`workspaceRegistry`(读工作区)、`permissionPresets`(读权限预设)、`userQuestions` 与 `approval`(两条要人答的 waterfall)、`sessionProjections`(读当前模型)。卡片按飞书**卡片 JSON 2.0** 写。

## 怎么用

### 安装与前置

```sh
dsh plugin --profile web add dsh-feishu-cui
```

装完重启 `dsh web`。装在别的 profile 上就把 `--profile` 换成那个名字;本机改这个插件自己的代码时,用 `link:` 指到仓库目录也一样。

> **DSH 版本**:当前 `0.2.x` 需要 DSH `0.1.7-rc.1` 及以上——设置走的是 0.1.7 起的插件条目 config(带 `volatile` 那几个字段)。
> 还在 DSH 0.1.5 上的,装最后一个支持它的版本:
>
> ```sh
> dsh plugin --profile web add dsh-feishu-cui@0.1.0
> ```

> **装的时候如果收尾报 `ERR_PNPM_IGNORED_BUILDS: protobufjs`,插件其实没装上。**
> `@larksuiteoapi/node-sdk` 的依赖里有 `protobufjs`,它带 postinstall 脚本,pnpm 默认不跑;dsh 把 pnpm 的非零退出当成整体失败,于是没把插件登记进 profile。先放行再装一次:
>
> ```yaml
> # ~/.dsh/profiles/<profile>/pnpm-workspace.yaml
> allowBuilds:
>   protobufjs: true
> ```

插件分两半:宿主半边 `lib/index.js` 跟着 `dsh web` 加载;浏览器半边是设置页,插件装上后在 DSH 的 Web 界面里出现「飞书CUI会话」分区。

改完代码要生效得重启 `dsh web`——插件只在启动时加载一次。

### 飞书那边

开发者后台 → 你的自建应用:

- 事件与回调的订阅方式选 **长连接**;
- 事件加 `im.message.receive_v1`、`application.bot.menu_v6`;
- 卡片回调加 `card.action.trigger`;
- 权限补 `im:message.p2p_msg:readonly`、`im:message:send_as_bot`;
- **可用范围只勾自己**——机器人只对你一个人开放,别放给部门或全员。

**机器人自定义菜单**:下面这套是**推荐配置**,不是硬要求。

硬约束只有三条,少一条就不工作:

- 子菜单都选 **推送事件**;
- `event_key` 必须和下表完全一致(插件按它分流);
- 父菜单只当容器、**不配事件**——配了它会推一个插件不认识的 `event_key`:点了没反应(它不执行动作,也不回话),只有日志里多一句「这个菜单项还没有去处」。

至于分几个父菜单、叫什么名字、谁挂在谁下面、子菜单怎么排序,插件一概不看(它只认叶子的 `event_key`),怎么分都行;展示形式也随你。飞书后台的顺序是**先建子菜单、再建父菜单**。

下面这套是现在后台里的实际配置,可以照抄:

| 父菜单 | 子菜单(从上到下) | `event_key` | 点了会怎样 |
|---|---|---|---|
| 会话设置 | 模型 | `model` | 发「模型」卡 |
|  | 推理深度 | `effort` | 发「推理深度」卡 |
|  | 重命名 | `session-rename` | 发一张输入框卡片,填新名字(改的是当前会话) |
|  | 权限 | `permission` | 发「权限」卡 |
| 导航 | 会话列表 | `sessions` | 发「选择会话」卡(底部多一个「新建」) |
|  | 工作区列表 | `workspaces` | 发「选择工作区」卡 |
| 操作 | 查询余额 | `balance` | 发「账户余额」卡 |
|  | 申请配对 | `pairing` | 发一张输入框卡片,让你填配对码(见下一节) |

「模型」和「推理深度」是**两张独立的卡**,所以两个菜单项都要配;工作区 / 会话 / 模型 / 推理深度 / 权限各是**一张卡带一组选项**,一个菜单项就够,不需要为每个选项各配一项。权限那三个预设(`read-only` / `workspace-write` / `danger-full-access`)配不配都行,配了就也打开权限卡,只是冗余。

- 创建版本并**发布**,菜单生效要等 5 分钟左右。

> **机器人只对 owner 一个人开放。** 可用范围里只勾你自己,这是第一道闸;插件这层是第二道:
> 除了配对,别人发消息、点菜单一律只收到「CUI会话未匹配」,卡片上的操作也只回一句同文案的提示——
> 不静默、不执行任何动作,而且**不区分「还没绑定」和「不是 owner」**(对外不暴露绑定状态)。
> 所以聊天里不会提示你去配对,配对指路只在设置页和本 README 里。

> 别和别的飞书插件共用同一个飞书应用:长连接是集群模式、不广播,同 App 会互相抢事件。

### 插件里配对

**在飞书里点菜单「申请配对」**,机器人发一张输入框卡片,卡片上写着「设置页里有一串配对码,把它填到下面这个输入框里。」打开 DSH 的设置页 →「飞书CUI会话」,那里显示这串码;把它填进卡片、点「确定」,填的那个人就成为 owner。

- 配对码 10 分钟有效、只能用一次;已经绑过就不再生成(再点菜单「申请配对」只会收到「存在绑定会话,无法申请」)。
- 设置页的状态快照里有:长连接是否建立、当前绑定的 user、在册的配对码、两项凭据配没配。**页面不轮询**,随时点「刷新」重新读一次。
- **换人要先解绑**:设置页点「解绑」→ 清掉绑定的 user、在册的配对码一并作废;同时给原 owner 私聊发一张「已解绑」的卡(发不出去只记日志,不影响解绑)。之后回飞书重新点「申请配对」拿新码。
  解绑**不掐正在跑的那一轮、也不撤排队里的消息**:那一轮照旧跑完、回答卡也会走完,但原 owner 之后发消息、点卡片就只会收到「CUI会话未匹配」。
- 凭据(App ID / App Secret)也在同一页填:保存后不再回显,存进 `$DSH_HOME/.credentials.yaml`,保存即按新凭据重建长连接。

### 日常用法

- 私聊机器人发消息(owner 专用):**回你一张卡片**(回复在你发的那条消息上),之后这一轮每产出一段就把那张卡**整张换掉**,跑完收尾。
  **上屏的只有正文**(助手消息里的 text 块):思考、工具调用、工具结果都不上屏——思考一轮能写几万字、工具结果常常是整份文件,一张卡片装不下。想看完整过程在网页端看。
  这句话同时**排进当前会话的队列**,**不打断**正在跑的那一轮;队列不设上限。
  卡片的时机:进队列时先不开卡;被这一轮取走就是「处理中」;排了一小会儿(100ms)还没被取走,就先开一张「排队中」。**不做兜底**:两种信号一条都没来就不开卡。
  跑完了却没有正文,卡片上补一句「这一轮没有可显示的正文」,不会一直挂在「处理中」。
  **「停止」**:打断当前这一轮(跟网页端「停止生成」同一件事,走会话控制器的取消)——只停正在跑的这轮,**已经在队列里排着的不受影响**。点完先变「正在停止」,这一轮真正收尾时把已产出的正文留着、标题改成「已停止」。按钮只出现在**飞书自己发起**的那一轮。
  **「撤回」**:把还没开跑的那条从队列里撤掉(走宿主的排队项移除),卡片换成「对话已取消」。
  正文装不进一张卡片(超过 30KB)就**不再往上接**:卡片换成「任务失败」+「回答内容过多,卡片无法全部展示,请到网页端查看」。
  还没选会话时回一句「还没有当前会话」;不是文本的消息回一句「只支持文本消息」(**图片 / 附件永不支持**)。
  投喂走宿主的会话控制器(`mode: 'queue'`),不是自己往会话日志里塞事件——模型可用性、会话是否还在这些校验都交给宿主。
- **会话列表**里只列**当前工作区**的会话,摘要写「本工作区有 N 个会话」;一次都没跑过的空会话(没标题那种)不列。底部「新建」会在当前工作区里开一个新会话并切过去。
- **会话重命名**改的是**当前会话**:点菜单发一张输入框卡片,填新名字、点「确定」;名字由宿主归一化(去掉转义序列与控制字符、空白压成一个、超长按字节截断),卡片先换成「已请求:会话改名为【X】」,宿主要是没收下就另发一张卡写「会话改名失败」;真改成了由宿主那条事件发一张「会话名已修改」。
- **有活没干完时不让换会话、换工作区**:还有回答卡在册(排队中 / 处理中 / 正在停止)时,那两张卡的「确定」「新建」会被挡下来,回一句「有正在进行的任务,无法切换会话 / 工作区」。

### 主动通知

- 当前会话的**模型**(连同推理深度)、**权限**或**会话名**被改了,就往飞书发一张卡:标题「模型已修改」/「权限已修改」/「会话名已修改」,正文「模型改成【X】」「推理深度改成【X】」「权限改成【X】」「会话名改成【X】」。
- **不管是谁改的**:宿主那三条事件(`model/selection`、`permission/preset`、`session/title`)里没有「谁改的」这个信息,所以网页端改、CLI 改、以及**你自己从飞书那几张设置卡改**,都会收到这张卡。
  从飞书改的那一次,它正好就是这次请求的回应:设置卡先变成「已请求:…」,紧接着这张「…已修改」的新卡到。
- 会话名那条事件要挑一下:自动起的标题(模型生成的、兜底的)也走它,只有 `source.kind` 是 `user`(人手动改名)才通知。
- 一次权限变更只发一张:预设名变了才通知,跟着变的沙箱模式与审批策略不单独报(它们就是预设的内容)。

### 长连接的存活与恢复

- **存活看门狗**:发出去的 ping 15 秒没收到任何回帧就判定连接死了,拆掉重连。SDK 默认**不开**这个看门狗,不开的代价很隐蔽:Mac 休眠再回来、或者 NAT 悄悄掐掉连接之后 TCP 是半开的,没有 FIN/RST,socket 层永不报错——连接看着还是「已连接」,而飞书事件从此一条都收不到。
- **自动重连**:SDK 自己重连(日志里是「飞书长连接断开,开始重连」→「飞书长连接已建立」)。断开这段时间的消息飞书会补推,恢复之后可能一次到一批。
- **迟到的消息直接丢**:事件时间比本机时间早 3 秒以上的,不回执也不投喂(补推来的旧消息不能当成新话)。判据是本机时钟,机器时间快 3 秒以上会把所有消息都判成迟到。
- **同一条消息只处理一次**:按飞书消息 ID 记账,最近 200 条(重投只发生在断线重连前后,够用)。

### 自检与排查

设置页「飞书CUI会话」那一块就能看大半:长连接是否建立、绑的是谁、在册的配对码、凭据配没配。页面不轮询,点「刷新」。

常见症状:

| 症状 | 大概是什么 |
|---|---|
| 发消息没任何反应 | 长连接断了(看日志里「飞书长连接断开」);或者飞书后台的事件订阅没配 |
| 菜单点了没反应,日志里写「这个菜单项还没有去处:…」 | 飞书后台那一项的 `event_key` 和上表不一致;**或者你给父菜单配了事件**(父菜单只当容器,不该推事件) |
| 回「CUI会话未匹配」 | 还没配对,或者发消息的人不是 owner |
| 回「还没有当前会话」 | 先去菜单里选一个会话 |
| 回「只支持文本消息」 | 发的是图片 / 文件 / 附件(永不支持) |
| 卡片点了只回「这张卡片已失效…」 | 这张卡已经被新卡顶掉,或者已经处理过了 |
| 设置卡点完只留「已请求:…」 | 这是设计:请求交出去了,结果一律另发一张卡——成功是宿主那条事件发的「…已修改」,失败是「…失败」那张 |
| 反问卡没弹到飞书 | 这一轮不是飞书发起的(让给网页端了);或者题目是多选 / 没选项 |

## 边界与约定

- **谁触发的谁答**:只有**飞书这边发起**、而且正在跑的那一轮,反问卡和审批卡才会弹到飞书;网页端发起的轮次一律让给网页端的 UI(不然人坐在网页那边会干等)。判据是运行期那个「正在跑的那一轮是哪条飞书消息」的槽。
- **子代理问不到人**:两条路都被宿主挡着——反问那边,被别人拥有的子代理一问就抛 `DELEGATED_CALLER`;审批那边,子会话的审批策略在派发时被钉成 `never`,需要审批的操作当场被拒。所以子代理的反问 / 审批根本走不到飞书。
- **会话级设置,下一次请求生效**:模型、推理深度、权限都是写进当前会话的,不影响别的会话;改完从下一次提问开始按新的来。
- **设置卡点完只留「已请求」**:模型、推理深度、权限、会话名这四张卡都一样——点「确定」之后卡片变「已请求:…」(校验过了、请求交出去了),后台才真正提交;**结果一律另发一张卡**:交成了由宿主那条事件发「…已修改」,没交成发一张写失败原因的卡。确定那张卡此后不再动,一直停在「已请求」。宿主那条会话名事件还带着自动起的标题(模型生成的和兜底的),这里只认人手动改名(`source.kind` 是 `user`)那一种,不然每开一个新会话都要通知一次。
- **换会话 / 换工作区要有活没干完**:有回答卡在册时挡下来(见[日常用法](#日常用法))。
- **卡片体积**:一张卡上限 30KB,回答卡到顶就换成「任务失败」;选项卡、反问卡在发之前也量一次,装不下的让给网页端。
- **反问只接单选且有选项**:多选题、没有选项的题,飞书这边先回一句「这题飞书答不了(没有选项或者是多选题),去网页端答吧」,再把请求让给网页端。
- **反问可以不答**:一道题都没选也能点「确定」,没答的题按跳过交回(`selected: []`,跟网页端「跳过本题」交回的形状一样);点完换成的那张文字卡上,没答的题写「已跳过」。
- **空会话不列**:一次都没跑过(0 轮)的会话不进会话列表——它没有标题,列出来只能显示一串会话 ID。读不到运行期投影时照样列(宁可多列不可漏列)。
- **在册的选项类卡片只有一张**:全插件同一时刻只认一张;新发一张就把上一张作废(换成「…已取消」)。回答卡、反问卡、审批卡不在这张名册里,各自按自己的键在册。

## 代码结构

按「谁跟谁说话」分层,上层依赖下层,反过来不依赖:

```
dsh-feishu-cui/
├── package.json        三条脚本:check(对每个模块 node --check)、test(回归用例)、prepublishOnly(发布前跑前两条)
├── README.md           装 / 配 / 用法 / 通知 / 休眠 / 边界 / 结构 / 脉络 / 日志 / 术语
├── CHANGELOG.md        每一版改了什么
├── test/               22 个文件  2285 行   回归用例:不联网;单元那份用假出站,端到端那份真起插件(假 SDK + 假宿主)
└── lib/
    ├── index.js              1 个文件   164 行   装配:造对象、接线、生命周期
    ├── init.js               1 个文件    26 行   启动时的初始化:把当前会话缓存填上
    ├── notices.js            1 个文件    73 行   给绑定的人发卡:连上通告、解绑
    ├── cache/                7 个文件   245 行   需要跨文件读写的运行期状态
    ├── common/               4 个文件   427 行   文案总表、飞书事件、CUI 事件、凭据引用名
    ├── driving/              6 个文件   559 行   判定与分派(飞书那头 / 宿主那头)
    ├── handler/              19 个文件  2353 行   干活:菜单、卡片、回答、反问、审批、通知
    ├── infra/                13 个文件  1220 行   跟外面打交道:飞书出站、宿主服务、插件配置、本机防休眠
    ├── settings/             4 个文件   750 行   设置页(四条回环路由 + 浏览器半边)
    ├── transport/            6 个文件   284 行   连接:飞书长连接 / REST,宿主事件订阅、waterfall
    └── ui/                   6 个文件   501 行   卡片长什么样(只出 JSON)
```

各层干什么:

- **`lib/index.js`**:唯一的入口。`apply()` 里按顺序造出所有对象、把依赖递进去(transport → push → 各 handler → 路由 → 准入 → 两条宿主事件订阅 + 两条 waterfall 订阅),注册设置页,最后按凭据建连。**不写业务**。
- **`init.js`**:启动时那一步初始化。读配置里配的当前会话,问宿主还在不在(`session.js` 的 `hasSession`),把结论写进 `cache/current-session.js`;之后上层读 `readSettings()` 拿到的就是这个缓存,不再问宿主。
- **`notices.js`**:给绑定的那个人发卡。连接状态变了发一张「dsh 已连接,当前会话为【…】」;解绑时把 user 清掉、在册的配对码一起作废,再给原 user 发一张「已解绑」。
- **`transport/`**:门外那一段。`feishu/` 是飞书侧(`websocket-client.js` 长连接与心跳看门狗、`http-client.js` REST、`transport.js` 把两个客户端一起持有、换凭据时整组重建);`host/` 是宿主侧(`session-events.js` 订 `session/event`、`agent-events.js` 订收件箱三条、`waterfall.js` 订要人答的两条 waterfall)。
- **`driving/`**:判定和分派。`feishu/` 把飞书原始事件转成 CUI 事件(`receiver.js`)、判准入(`admission.js`:去重、迟到、鉴权、只认文本、有没有当前会话)、按事件分给处理函数(`router.js`);`host/` 对宿主事件做同样三件事,`router.js` 只把要盯的那几种事件分给 `handler/host/`。
- **`handler/`**:干活的地方。`feishu/` 是菜单和卡片点出来的(七个菜单各一张卡 + `option-card-flow.js`、`input-card-flow.js` 两套共用骨架 + `deferred-submit.js` 两套共用的后台提交 + `message.js` 投喂 + `pairing.js` 配对 + `session-rename.js` 会话重命名 + `warn.js` 判定没过时回话);`host/` 是宿主推过来的(`waterfall.js` 是反问与审批共用的骨架 + `answer.js` 回答卡、`question.js` 反问卡、`approval.js` 审批卡、`settings-watch.js` 设置变更通知)。
- **`infra/`**:跟外面的接口。`feishu/push.js` 发出站(发卡 / 换卡,失败重试);`host/` 是宿主服务的封装(会话、工作区、模型目录、权限、余额,以及按名字借服务的取用口);`plugin/` 是插件自己的配置与凭据(走宿主的 `settings` / `credentials` 服务);`system/sleep-guard.js` 是插件自己起本机进程那块——持有一个 `caffeinate -s`,插件跑着就防休眠。
- **`ui/`**:只造卡片 JSON。六种:`text-card.js`(只有正文 / 带标题栏两种)、`option-card.js`(选项卡:确定|取消,和确定|新建|取消两种)、`input-card.js`(输入框)、`answer-card.js`(带一个按钮的回答卡)、`approval-card.js`(允许|拒绝)、`card.js`(共用件)。给人看的字一律不在这里。
- **`cache/`**:需要跨文件读写的运行期状态,只在内存里——在册的卡片、正在跑的那一轮、处理过的飞书消息、配对码、设置句柄、铸号。各 handler 自己私有那份(回答卡的定时器与改动队列、在册的提问、在册的审批)留在各自工厂里,不在这里。
- **`common/`**:各层共用的形状、常量与文案——CUI 事件(`cui-event-schema.js`)、飞书事件名(`feishu-event.js`)、凭据引用名(`credential-refs.js`)、给人看的文案总表(`copy.js`)。
- **`settings/`**:设置页。四条回环路由(`state` / `credentials` / `user/unbind` / `sleep-guard`,只判回环、`Host` 是回环主机名、写请求同源,以及 JSON;不做身份校验)+ 浏览器半边。

依赖方向:`driving/` 认 `handler/` 和 `ui/`(只为拿卡片类型与判定原因那几个常量),`handler/` 能 import `ui/`、`infra/`、`cache/`、`common/`(文案在 `common/copy.js`),`ui/` 只 import `ui/`,`transport/` 只认 `common/`(事件名与凭据引用名),收到的东西交给装配时递进来的回调。

改完跑两条:`npm run check`(每个模块过一遍 `node --check`)、`npm test`(跑 `test/` 下的 18 个用例,全部不联网)。用例分两档:`*-check.mjs` 里的单元那份只造要测的那几个对象(假出站、假会话目录);端到端那份(`connection` / `no-credentials` / `inbound` / `answer-card` / `waterfall` / `settings-routes`)用 `harness.mjs` 的 `startPlugin()` 真调一遍 `apply()`,把上下文、宿主服务和飞书 SDK 都换成假的(`fake-lark.mjs` 配 `lark-hooks.mjs` 顶掉那个 SDK)。

## 用例脉络

### A. 入站:飞书 → 会话

`transport/feishu/websocket-client.js`(长连接收到私聊消息)→ `driving/feishu/receiver.js`(转成 CUI 事件:正文、消息 ID、操作者)→ `driving/feishu/admission.js`(过期丢弃 → 查重记账 → 是不是 owner → 是不是文本 → 有没有当前会话)→ `driving/feishu/router.js` → `handler/feishu/message.js` → `infra/host/session.js`(`prompt`)→ 宿主的会话控制器(`mode: 'queue'`)。

投喂进去的那条随后会被宿主取走:`transport/host/agent-events.js`(`agent/inbox/inserted` / `claimed` / `discarded`)→ `driving/host/receiver.js` → `driving/host/router.js` → `handler/host/answer.js`(开卡 / 改标题 / 撤回),并把「这一轮是哪条飞书消息」记进 `cache/running-turn.js`。

### B. 出站:会话 → 飞书

`transport/host/session-events.js`(`session/event` 那条 firehose)→ `driving/host/receiver.js`(`assistant/message` 里只取 text 块当正文;`turn/end` 里取结束原因)→ `driving/host/router.js` → `handler/host/answer.js` → `ui/answer-card.js`(有按钮那几张)/ `ui/text-card.js`(终态)→ `infra/feishu/push.js`(`sendCard` / `patchCard`)。

### C. 菜单与五张选择卡

菜单:`websocket-client.js`(`application.bot.menu_v6`)→ `driving/feishu/receiver.js`(`tag` = 飞书的 `event_key`)→ `admission.js`(配对放行,其余只给 owner)→ `router.js` → 七个 handler(`sessions` / `session-rename` / `workspaces` / `model` / `effort` / `permission` / `balance`,外加 `pairing`)→ `ui/option-card.js` / `ui/input-card.js` / `ui/text-card.js` → `push.sendCard`,消息 ID 记进 `cache/pending-cards.js`。

卡片回调(`card.action.trigger`)→ `admission.admitCard`(是不是本人;换会话、换工作区那两个按钮还要看有没有活没干完)→ `router.js` → `handler/feishu/option-card-flow.js`(点一行只选中 → 重画;确定 → 交给各家的 `onConfirm`;取消 / 没选就确定 → 按取消)或 `input-card-flow.js`(重命名那张卡:确定 → 交给 `onSubmit`)→ `infra/host/{session,workspace,models,permissions}.js` → 宿主。四张设置卡都先回「已请求」,后台提交(`handler/feishu/deferred-submit.js`)——交成了由宿主那边的事件发「…已修改」(`handler/host/settings-watch.js`),没交成由这里另发一张写失败原因的卡。

### D. 要人来答的两条口

反问:`transport/host/waterfall.js`(`user-questions/request`,带 `prepend` 抢在网页端前面)→ `handler/host/question.js`(四道检查:是当前会话、这一轮是飞书发起的、题目画得出来、卡片发得出去;发 `ui/option-card.js` 的卡)→ 卡片回调 → 点「确定」交回 `{ answers }`(没答的题是空选择);取消拒 `ASK_CANCELLED`;这一轮被停掉作废并拒 `ASK_ABORTED`。

审批:同一条 transport(`approval/request`)→ `handler/host/approval.js`(同样四道检查,发 `ui/approval-card.js`)→ 卡片回调 → 交回 `allowed-once` / `rejected`;这一轮被停掉交回 `cancelled` 并把卡片收成「审批已结束」。批完那张换成 `ui/text-card.js` 的带标题栏卡片,正文照旧留着。

### E. 生命周期、换人与换会话

启动:`lib/index.js`(`apply`)→ `infra/plugin/config.js`(注册 `feishu-cui` 那个设置命名空间)+ `infra/plugin/credentials.js`(凭据存储)→ `init.js`(把当前会话缓存填上)→ `transport/feishu/transport.js`(按凭据建连)→ `notices.js`(连上给 owner 发一条「dsh 已连接,当前会话为【…】」);`apply` 里同时按开关起 `infra/system/sleep-guard.js`(防休眠)。

换会话 / 换工作区:菜单 → `handler/feishu/sessions.js` / `workspaces.js` → `infra/host/session.js`(`create` / `open`)或 `infra/host/workspace.js` → 写回 `feishu-cui` 那几个设置键。

换人(解绑):设置页 → `settings/panel.js`(`/dsh-feishu-cui/user/unbind`)→ `notices.js` 的 `unbindUser`(先把 userId 读出来 → 清 userId + 作废配对码 → 给原 user 发一张 `ui/text-card.js` 的「已解绑」卡,这一发送不等着发完)。

### F. 设置页(浏览器半边)

`settings/panel.js` 挂四条路由(`settings/http.js`:只判回环、`Host` 必须是 `localhost` / `127.0.0.1` / `[::1]`、写请求带 `Origin` 时必须与 `Host` 同源,入参出参走 JSON)→ `settings/client.js` 在 DSH 设置面板里注册「飞书CUI会话」分区:填凭据、看状态与配对码、点解绑。

### G. 主动通知

`session/event` 里的 `model/selection` / `permission/preset` / `session/title` → `driving/host/receiver.js`(把事件载荷一起带出来)→ `driving/host/router.js` → `handler/host/settings-watch.js`(拼标题与正文;会话名那条只认人手动改名)→ `ui/text-card.js`(带标题栏)→ `push.sendCard`。

## 看日志

插件不自己写日志文件:`infra/plugin/log-exporter.js` 把这一路日志按级别交给 `stdout` / `stderr`,由启动 `dsh web` 的那一层落盘(用 supervisor 跑的话就是它的日志)。

排查时值得搜的几行:

- `已订阅 session/event`、`已订阅 agent/inbox/...`、`已订阅 user-questions/request`、`已订阅 approval/request`——插件加载时各订了什么;
- `飞书长连接已建立` / `飞书长连接断开`——连接状态;
- `已把消息交给会话 <会话 ID>`——投喂成功;
- `回答卡片已开出` / `画回答卡片(…,N 字)`——这一轮的卡片在长;
- `反问卡片已发出` / `反问卡片:… 第 N/M 题选了【…】` / `… 被人取消`——反问那条口;
- `审批卡片已发出` / `审批卡片:… allowed-once`——审批那条口;
- `会话设置被改了,通知绑定的人:…`——主动通知;
- `这批题飞书答不了`、`这一轮不是飞书这边发起来的,让给网页端`——让路的原因。

## 术语表

| 说法 | 指什么 |
|---|---|
| **owner** | 配对绑定的那个人,也就是设置里那个 `userId`;机器人只服务他 |
| **会话** | 宿主里的一个 DSH 会话;插件同一时刻只认「当前会话」那一个 |
| **一轮** | 宿主的一次问答(`turn/start` 到 `turn/end`),对应一条飞书消息 |
| **CUI**(conversational user interface) | 拿「人跟系统来回对话」当界面的那一类形态,跟 `GUI`(图形界面)、`TUI`(终端界面)并列;名字出自维基百科 [Conversational user interface](https://en.wikipedia.org/wiki/Conversational_user_interface) 这个词条。飞书私聊这一套就是:发消息提问、点卡片选择、机器人回话 |
| **ChatOps** | 在聊天软件里操作一个系统的通行叫法,最初是 GitHub 内部这么叫、后来运维圈写开了([CMU SEI](https://sei.cmu.edu/blog/chatops-in-the-devops-team/)、[Rapid7](https://www.rapid7.com/fundamentals/chatops/) 都有文章)。这个插件干的就是它,差别是 `ChatOps` 一般指「敲命令触发脚本」,这里是「问一个会思考的 agent」 |
| **CUI 事件** | `common/cui-event-schema.js` 定的六个字段:`event` / `tag` / `time` / `content` / `messageId` / `operatorId` |
| **卡片类型**(`tag`) | 发卡时写进按钮 value 的 `tag`,卡片回调按它路由;菜单项的 `event_key` 是同类东西 |
| **在册** | 内存里还记着的一张卡片(`cache/`):不在册的卡片上的操作一律不执行,只回一句话 |
| **让路**(`next()`) | waterfall 上的说法:不接这个请求,交给排在后面的回答者(网页端) |
| **回答卡** | 一轮回答那张卡(回复在你发的那条消息上),标题从「排队中」到终态 |
| **选项卡** | 若干组选项行 + 底部按钮:会话 / 工作区 / 模型 / 推理深度 / 权限这五张,以及反问卡 |
| **反问卡** | agent 调 `ask_user_question` 时弹的卡(`user-questions/request`) |
| **审批卡** | 工具要授权时弹的卡(`approval/request`) |
| **文字卡** | 只有正文(`buildTextCard`)或带标题栏(`buildHeaderTextCard`)的纯提示卡,回执和终态都用它 |

## 还没做

- 反问里的多选题与全靠打字的题(让给网页端);卡片上的自由输入(只有配对填码、会话重命名填名字这两张卡用)。
- 卡片翻页(超 30KB 就只提示去网页端看)。

## 更新日志

见 [CHANGELOG.md](./CHANGELOG.md)。

Install

dsh plugin --profile web add github:wanjiaju3108/dsh-feishu-cui

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source