在飞书私聊里远程指挥 DSH 干活
29.8/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/9/27 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add wanjiaju3108/dsh-feishu-cui该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:仅索引
是什么
dsh 原生插件 · platform
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 39 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令发起外部网络请求
查看扫描证据(共 5 处,展示前 5 条)
- 发起网络请求lib/settings/client.js:287
const response = await fetch(STATE_ROUTE, { credentials: 'same-origin' }); - 发起网络请求lib/settings/client.js:308
const response = await fetch(CREDENTIALS_ROUTE, { - 发起网络请求lib/settings/client.js:334
const response = await fetch(UNBIND_USER_ROUTE, { method: 'POST', credentials: 'same-origin' }); - 发起网络请求lib/settings/client.js:350
const response = await fetch(SLEEP_GUARD_ROUTE, { - 执行外部命令来自配置/变量lib/infra/system/sleep-guard.js:40
const proc = spawn(CAFFEINATE, [...CAFFEINATE_ARGS, '-w', String(process.pid)], {
扫描于 2026/11/4(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
依赖的 DSH / Cordis 模块
@deepseek-ai/schemastery@deepseek-ai/dsh给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 飞书私聊远程操作
- 机器人长连接收发消息
- 无需公网入口
用飞书私聊远程操作 DSH:消息与卡片走机器人长连接,不用公网入口、不用证书、不用内网穿透
README
由 DeepSeek 最新模型翻译生成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 这类东西。
展开完整文档(README 剩余 1 节)
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//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)就不再往上接:卡片换成「任务失败」+「回答内容过多,卡片无法全部展示,请到网页端查看」。
还没选会话时回一句「还没有当前会话」;不是文本的消息回一句「只支持文本消息」(图片 / 附件永不支持)。
The session controller that feeds into the host (mode: 'queue') does not stuff events into the session log itself—checks like model availability and whether the session still exists are all left to the host.
- The session list only lists sessions in the current workspace, with the summary reading "This workspace has N sessions"; empty sessions that have never been run (the ones without a title) are not listed. The "New" button at the bottom opens a new session in the current workspace and switches to it.
- Session renaming changes the current session: clicking the menu sends an input box card; fill in the new name and click "Confirm"; the name is normalized by the host (escape sequences and control characters removed, whitespace collapsed into one, overlong names truncated by bytes). The card first changes to "Requested: session renamed to [X]"; if the host does not accept it, another card is sent saying "Session rename failed"; if it actually succeeds, the host's event sends a "Session name changed" card.
- When there is unfinished work, switching sessions or workspaces is not allowed: while there are still answer cards on the books (queued / processing / stopping), the "Confirm" and "New" buttons on those two cards are blocked, replying "There is an ongoing task, cannot switch session / workspace."
Proactive notifications
- When the model (along with reasoning depth), permissions, or session name of the current session is changed, a card is sent to Feishu: title "Model changed" / "Permissions changed" / "Session name changed", body "Model changed to [X]" / "Reasoning depth changed to [X]" / "Permissions changed to [X]" / "Session name changed to [X]".
- No matter who changed it: the host's three events (model/selection, permission/preset, session/title) do not contain information about "who changed it", so changes made from the web side, from the CLI, and by yourself from those settings cards in Feishu will all receive this card.
The one changed from Feishu happens to be the response to this request: the settings card first changes to "Requested: …", and immediately after, this new "… changed" card arrives.
- The session name event needs to be filtered: automatically generated titles (model-generated, fallback) also go through it, and only when source.kind is user (manually renamed by a person) is the notification sent.
- Only one card is sent per permission change: only a change in the preset name triggers a notification; the sandbox mode and approval policy that change along with it are not reported separately (they are the contents of the preset).
Liveness and recovery of the long connection
- Liveness watchdog: if no frame is received within 15 seconds after a ping is sent, the connection is judged dead, torn down, and reconnected. The SDK does not enable this watchdog by default; the cost of not enabling it is subtle: after a Mac sleeps and wakes, or after NAT quietly cuts the connection, TCP is half-open, with no FIN/RST, and the socket layer never reports an error—the connection still looks "connected", while Feishu events are never received again.
- Automatic reconnection: the SDK reconnects on its own (in the logs: "Feishu long connection disconnected, starting reconnection" → "Feishu long connection established"). Messages during the disconnection period are re-pushed by Feishu, and after recovery a batch may arrive at once.
- Late messages are simply dropped: if the event time is more than 3 seconds earlier than the local time, no acknowledgment is sent and it is not fed in (old messages from re-pushes cannot be treated as new speech). The criterion is the local clock; if the machine time is more than 3 seconds fast, all messages will be judged as late.
- The same message is processed only once: accounting is by Feishu message ID, for the most recent 200 (re-delivery only happens around disconnection and reconnection, which is enough).
Self-check and troubleshooting
Most of it can be seen in the "Feishu CUI Session" section of the settings page: whether the long connection is established, who is bound, the registered pairing codes, and whether credentials are configured. The page does not poll; click "Refresh".
Common symptoms:
| Symptom | What it probably is |
| No reaction at all after sending a message | The long connection is down (look for "Feishu long connection disconnected" in the logs); or the event subscription in the Feishu backend is not configured |
| Clicking the menu does nothing, and the log says "This menu item has no destination yet: …" | The event_key of that item in the Feishu backend does not match the table above; or you configured an event for a parent menu (a parent menu is only a container and should not push events) |
| Replies "CUI session not matched" | Not paired yet, or the sender is not the owner |
| Replies "No current session yet" | First select a session from the menu |
| Replies "Only text messages are supported" | An image / file / attachment was sent (never supported) |
| Clicking the card only replies "This card has expired…" | This card has been superseded by a new card, or has already been processed |
| After clicking the settings card, only "Requested: …" remains | This is by design: the request has been handed off, and the result is always sent as a separate card—success is the "… changed" sent by the host's event, failure is the "… failed" card |
| The follow-up question card did not pop up in Feishu | This round was not initiated by Feishu (it was left to the web side); or the question is multiple-choice / has no options |
Boundaries and conventions
- Whoever triggers it answers it: only rounds that are initiated from the Feishu side and are currently running will have follow-up question cards and approval cards pop up in Feishu; rounds initiated from the web side are always left to the web UI (otherwise the person sitting on the web side would wait in vain). The criterion is the runtime slot for "which Feishu message the currently running round is".
- Subagents cannot reach a person: both paths are blocked by the host—on the follow-up question side, a subagent owned by someone else throws DELEGATED_CALLER as soon as it asks; on the approval side, the approval policy of a sub-session is pinned to never at dispatch time, and operations requiring approval are rejected on the spot. So a subagent's follow-up questions / approvals never reach Feishu.
- Session-level settings take effect on the next request: model, reasoning depth, and permissions are all written into the current session and do not affect other sessions; after the change, the new settings apply starting from the next question.
- After clicking the settings card, only "Requested" remains: the four cards—model, reasoning depth, permissions, and session name—are all the same—after clicking "Confirm", the card changes to "Requested: …" (validation passed, request handed off), and only then is it actually submitted in the background; the result is always sent as a separate card: if submitted successfully, the host's event sends "… changed"; if not, a card is sent with the failure reason. The confirm card no longer changes after that and stays at "Requested". The host's session name event also carries automatically generated titles (model-generated and fallback); here only manual renaming by a person (source.kind is user) is recognized, otherwise a notification would be sent every time a new session is opened.
- Switching sessions / workspaces requires unfinished work: it is blocked while answer cards are on the books (see Daily usage).
- Card size: a card has a 30KB limit; when an answer card reaches the limit, it is replaced with "Task failed"; option cards and follow-up question cards are also measured once before sending, and those that do not fit are left to the web side.
- Follow-up questions only accept single-choice with options: for multiple-choice questions or questions with no options, the Feishu side first replies "This question cannot be answered in Feishu (no options or it is multiple-choice), go answer it on the web side", then hands the request over to the web side.
- Follow-up questions may be left unanswered: you can click "Confirm" even if no question is selected; unanswered questions are handed back as skipped (selected: [], the same shape as the web side's "Skip this question"); on the text card it changes to after clicking, unanswered questions are written as "Skipped".
- Empty sessions are not listed: sessions that have never been run (0 rounds) do not enter the session list—they have no title, and listing them would only show a string of session IDs. When the runtime projection cannot be read, they are still listed (better to list too many than to miss one).
- There is only one registered option-type card: the entire plugin recognizes only one at a time; sending a new one invalidates the previous one (changing it to "… cancelled"). Answer cards, follow-up question cards, and approval cards are not in this registry; each is registered under its own key.
Code structure
Layered by "who talks to whom"; upper layers depend on lower layers, and not the reverse:
dsh-feishu-cui/
├── package.json Three scripts: check (node --check for each module), test (regression cases), prepublishOnly (runs the first two before publishing)
├── README.md Install / configure / usage / notifications / sleep / boundaries / structure / context / logs / terminology
├── 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——插件加载时各订了什么;
- 飞书长连接已建立 / 飞书长连接断开——连接状态;
- 已把消息交给会话 ——投喂成功;
- 回答卡片已开出 / 画回答卡片(…,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。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
wanjiaju3108/dsh-feishu-cui
在飞书私聊里远程指挥 DSH 干活
相关插件 · 平台集成
智能体工程课walkinglabs/learn-harness-engineering
一门项目制课程,教你搭建环境、管理状态、做验证、加控制机制,让 AI 编程智能体稳定干活。
★ 21,155已汉化DSH
71.7
视频翻译配音liuzhao1225/YouDub-webui
开源视频本地化工作台:导入视频,识别、翻译并逐句克隆原声,生成字幕和配音成片。
★ 5,792已汉化Cordis
71.4
失败经验库Ikalus1988/MisakaNet
📚 零依赖、由 git 支撑的微课程库,让 AI 智能体异步分享并检索经过验证的调试经验。| https://misakanet.org
★ 550已汉化DSH
71.2
IM 机器人接入xmanrui/dsh-im
让 DeepSeek Harness 触手可及 Connecting DeepSeek Harness
★ 3,170已汉化DeepSeek 客户端
71
桌面智能体工作区freestylefly/wesight
WeSight 是开源桌面控制台,面向本地 AI 智能体。它帮你安装或复用 Claude…
★ 933已汉化MCP
71
中国平台研究引擎Jesseovo/last30days-skill-cn
给 AI Agent 的中文互联网研究 Skill:检索 8 大平台最近 30…
★ 2,041已汉化DSH
70.7
这是你的仓库?认领条目