Saltar al contenido principal
W

dsh-feishu-cui

wanjiaju3108/dsh-feishu-cui

Remote DSH access that needs no hosting of your own — the Feishu DM chat is the interface over the bot's outbound long connection, with cards for workspace, session (switch and rename), model, reasoning effort, permission preset and balance, and tool approvals and agent questions answered in that same chat.

Instalar

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

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 服务器上。
  • 一个自己的飞书自建应用(要能开长连接),并且只给自己用:开发者后台的可用范围只勾自己。
  • 别跟 dsh-feishu-assistant 共用同一个飞书应用:长连接是集群模式、不广播,同 App 会互相抢事件。凭据引用名也各用各的(这个插件读 FEISHU_CUI_APP_ID / FEISHU_CUI_APP_SECRET)。

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

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

怎么用

安装与前置

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 上的,装最后一个支持它的版本:

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。先放行再装一次:

# ~/.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 里。

别和 dsh-feishu-assistant 共用同一个飞书应用:长连接是集群模式、不广播,同 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,只判回环和 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 挂四条只判回环与 JSON 的路由(settings/http.js)→ 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 这个词条。飞书私聊这一套就是:发消息提问、点卡片选择、机器人回话
ChatOps在聊天软件里操作一个系统的通行叫法,最初是 GitHub 内部这么叫、后来运维圈写开了(CMU SEI、Rapid7 都有文章)。这个插件干的就是它,差别是 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。

Plugins relacionados