dsh-plugin-im-remote-control
用飞书(Lark)机器人远程控制一个 DSH 会话:消息进会话,回合结果回到聊天,ask 档的审批可以在飞书里远程批准。
当前版本只交付 direct 模式:插件自己持有飞书长连接,不需要公网 IP。微信接入和 gateway 冷启动模式尚未交付,代码保留但未启用(见「路线图」)。
功能
| 能力 | 说明 |
|---|---|
| 飞书长连接 | 插件进程内持有 WebSocket,不需要公网回调地址 |
| 私聊 / 群聊 | 私聊直接处理;群聊必须 @ 机器人 |
| 消息进会话 | 走 sessionController.prompt,模型看到的与桌面输入框一致,不绕过 session log |
| 回合回帖 | 回合结束把最后一步的 assistant 文本发回聊天;dispatch 来的任务带 【会话名】 首行 |
| 表情回执 | 收到消息贴 OK;会话正忙时贴 OneSecond |
| 分发接口 | 中控会话把任务投给任意会话,回复绑定由插件负责(Bearer token + chat allowlist) |
| 审批转发 | ask 档会话的审批发到飞书,/approve / /deny 远程决定,与桌面弹窗竞速 |
| 设置页 | 中控会话、允许用户、飞书凭据(只写 + 掩码状态 + 清除)、渠道开关、配置探测(设置 → 远程控制) |
/whoami |
返回发送者 open_id,用于配置 allowlist |
未交付:微信、gateway 冷启动、图片/文件、卡片消息、超限重试、多实例。
工作方式
飞书(私聊 / 群聊 @)
│ WebSocket 长连接(插件持有,无公网地址)
▼
dsh-plugin-im-remote-control(Host)
├─ sessionController.prompt → 目标会话新增一条 user 消息
├─ ctx.on('session/event') → 回合结束把结论回帖
├─ ctx.on('approval/request')→ 审批通知发到 chat,/approve /deny 决定
└─ ctx.webServer → /dispatch(Bearer)/ /check(探测)/ /status(状态)
requests: requestId → chat记录本次投递属于哪个聊天;user/message事件里带回rpcId,据此绑定turns: sessionId → chat。mode: 'queue'在宿主里是agent.followup():每条消息自成一个回合,回合之间不重叠,所以按 session 绑定是安全的。- 只有插件自己投递过的回合才回帖;桌面里手动发起的回合不受影响。
安装
# 从 npm 安装
dsh plugin --profile <profile> add dsh-plugin-im-remote-control
# 或者从 git checkout 安装:先装依赖并构建(lib/ 不入库)
cd /path/to/dsh-plugin-im-remote-control && npm install && npm run build
dsh plugin --profile <profile> add /path/to/dsh-plugin-im-remote-control
包的 dsh.bundle.patch 已经 insert 了插件行。profile 的 cordis.patch.yml 里只加一条同 id 的覆盖行(不要再 insert 一次:两条同 id 的行会各自挂载——两个飞书长连接、两份 token,而且设置页找不到这一行):
- id: dsh-plugin-im-remote-control
name: dsh-plugin-im-remote-control
config:
feishu:
appId: cli_xxxxxxxxxxxxxxxx
appSecret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
controllerSessionId: session-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
channels: [feishu]
allowedChats:
- oc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
allowedUsers:
- name: 我自己
openId: ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ack: true
reply: true
覆盖行的 config 是整体替换而不是深合并,需要哪些键就写全。
然后重启 DSH:Host 半边只在启动时加载,配置改动也只在重启后应用到连接。之后可以在「设置 → 远程控制」里编辑同样的字段(设置页的改动同样需要重启才生效)。
飞书应用准备
- 在飞书开放平台创建企业自建应用,开启「机器人」能力。
- 权限:接收消息(
im:message、im:message.group_msg)、以应用身份发消息(im:message:send_as_bot)。 - 事件订阅选长连接,订阅
im.message.receive_v1。这一步不需要公网地址。 - 发布版本,把机器人加进目标会话,或者在私聊里使用。
- 用
/whoami拿到自己的 open_id,填进allowedUsers。
配置
| 字段 | 默认 | 说明 |
|---|---|---|
feishu.appId / feishu.appSecret |
空 | 自建应用的凭据。设置页对 App Secret 读不到明文:只显示「已配置(****末四位)/ 未配置」,保存后不回显,留空表示不修改,另有「清除」 |
controllerSessionId |
空 | 中控会话 ID;兼容旧字段 sessionId |
channels |
未设置 = 飞书 | 支持的渠道列表;[] 表示不连飞书 |
allowedChats |
无 | 允许的 chat_id 列表 |
allowedUsers |
无 | { name, openId } 列表;openId 会并入 allowedOpenIds |
allowedOpenIds |
无 | 直接按发送者 open_id 限制 |
ack |
true |
收到消息是否贴表情回执 |
reply |
true |
回合结束是否回帖 |
enabled |
true |
false 时不启动(连探测路由也不注册) |
旧字段 appId / appSecret / sessionId 仍然可用,优先级低于 feishu.* / controllerSessionId。
访问控制是 fail-closed 的:allowedChats、allowedUsers、allowedOpenIds 至少要配一项,否则插件只注册设置页的配置探测、不连飞书(日志 no access control configured),dispatch 和消息处理都不会启动。数组类型写错会直接报错,不会被当成「空 = 不限制」。首次配置可以先填自己的 open_id(或者临时一个 chat),重启后用 /whoami 校准。
共享配置文件 $DSH_HOME/im-remote-control.json(默认 ~/.dsh/im-remote-control.json)可以作为兜底来源:feishu.*、appId、appSecret、sessionId、allowedChats、allowedUsers、allowedOpenIds、channels、ack、reply、enabled、mode。行里显式给出的值优先,数组类 allowlist 取并集。它主要服务两个场景:凭据不写进 profile 的部署,以及(尚未交付的)外部网关。
命令
| 命令 | 作用 |
|---|---|
/whoami |
回复发送者的 open_id |
/approve <id> |
批准一次审批(allowed-once) |
/deny <id> |
拒绝一次审批(rejected) |
群聊里发命令也要 @ 机器人;私聊不需要。
审批转发
会话处于 ask 审批档时,需要确认的工具调用(例如写工作区外的文件)会把审批发到绑定的飞书会话:
【审批 3fa9c1】股票分析 要执行 bash,需要你确认
允许本次操作使用 danger-full-access 权限:需要写入工作区外
回复 /approve 3fa9c1 批准,/deny 3fa9c1 拒绝(120 秒内有效)
第二行是提问方给的说明:有本地化文案就用本地化,没有才退回 原因:<原始 reason>。
| 回复 | 结果 |
|---|---|
/approve <id> |
allowed-once,只授权这一次 |
/deny <id> |
rejected |
| 回合被中止 / 请求撤销 | cancelled |
| 120 秒没人回 | 自动 rejected,并回帖告知 |
和桌面竞速:插件用 prepend 抢在浏览器应答器之前注册监听,所以两边同时在等,谁先决定谁生效。飞书先答时,桌面那个弹窗(连同被它占住的输入框)会被撤回;桌面先答时,飞书里的 id 随即失效(再回会得到「没有待审批的请求」)。
只有绑定过 chat 的会话才会转发审批(飞书消息投递进来的,或 dispatch 进来的);桌面上手动发起的会话照旧走桌面弹窗。
注意:never 档的会话不会产生审批请求,这个功能只对 ask 档有意义。
本地路由与鉴权
| 路由 | 用途 | 鉴权 |
|---|---|---|
POST /im-remote-control/dispatch |
中控把任务投给任意会话 | Authorization: Bearer <token>,且 chatId 必须在 allowedChats 里 |
POST /im-remote-control/check |
设置页的只读配置探测(3 次飞书调用) | 自定义头 x-imrc-check: 1 |
GET /im-remote-control/status |
设置页读取「哪些字段已配置」 | 无(布尔值 + App Secret 的掩码 ****abcd,不含明文) |
token 每次挂载随机生成,写到 $DSH_HOME/im-remote-control.token(0600,卸载即删)。scripts/dispatch.mjs 会自动读它;手工调用必须自己带上。
/check 只发三个只读请求(换 token、机器人信息、会话列表),不改状态,所以没有加 token;它有 10 秒超时。/status 只回答「哪些字段已配置」和一个末四位掩码,任何请求都拿不到密钥明文。
安全模型
- 飞书账号就是执行权:allowlist 里的账号(或群里被 allowlist 允许的成员)能让 agent 执行 shell、读写文件。请给飞书账号开二次验证,并保持 allowlist 尽量小。
- 同一系统用户下的进程互相信任:token、配置、会话日志都是 0600,能读它们的进程本来就能修改插件或配置;token 挡的是其他用户和随手脚本。
- 模型服务能看到消息内容和工具结果:这些内容在对话上下文里,和桌面使用没有区别。
- 审批档决定有没有刹车:从飞书驱动的会话建议用
workspace-write+ask;danger-full-access+never等于让远程消息直接执行任何操作。
已实现的防护:allowlist fail-closed、dispatch Bearer token + chat allowlist、App Secret 只写且最多回末四位掩码、审批 120 秒超时拒绝(fail-closed)、跨 chat 不能批准别人的审批、日志不记录消息正文。
DSH 的 GUI 有独立的浏览器认证(回环绑定、每进程随机启动 token、签名 cookie,未认证 401、跨站 403),但上面三条 /im-remote-control/* 路由是插件直接注册在 Host web server 上的,不经过这层 cookie 认证:dispatch 用独立 Bearer token,/check 和 /status 只读、只返回探测结果和掩码。它们默认只出现在回环地址上;如果把 Host 绑到非回环地址,这三条路由也会跟着暴露(/status 最坏泄露四个布尔值和末四位掩码)。
限制与已知问题
- 只支持文本:图片、文件不会转发。
- 回帖只发最后一步的 assistant 文本,过程叙述不发;超过 3500 字符按开头截断(飞书文本上限是 150 KB,这个值是本插件自己的上限)。
- 回合以
error/aborted/blocked等原因结束且没有文本时,会回一句「回合出错/被中止」,而不是静默。 - 一个实例只驱动一个
sessionId;一个飞书应用只能有一条长连接(不要再同时跑外部 bridge)。 - 审批只对
ask档生效;群聊里被 allowlist 允许的成员都能批准同一个群里的审批,要更严格就只配allowedUsers一个人。 - 插件重载会丢正在进行的回合的回帖绑定:重载时如果还有
dispatch在跑,那个回合的结果不会自动回到飞书。 - 首次配置必须先给出一项 allowlist,否则插件不连飞书,
/whoami也用不了。 - 设置页的 App Secret 保存后不回显明文:只显示「已配置(****末四位)」,Host 之外只出现这四个字符;要更换就粘贴新值,要删除点「清除」。
飞书接口频率限制
飞书开放平台对每个 API 按「应用 × 租户」限频,主流档位是 1000 次/分钟 & 50 次/秒,个别接口更低(10/20/100 次/分钟 或 1–100 次/秒),详见频控策略。发送消息还有额外限制:同一用户 5 QPS,同一群内所有机器人共享 5 QPS;文本消息体最大 150 KB(发送消息)。
超限时飞书返回 HTTP 429 + code: 99991400(部分旧接口是 400),响应头 x-ogw-ratelimit-reset 给出建议等待秒数;发消息接口也可能返回 230020。
本插件的用量:收消息走长连接不消耗接口配额;每条消息大约 1–2 次调用(贴表情 + 回帖),审批每条 1 次,设置页「检测配置」3 次。个人使用离上限很远。
插件不做重试:一旦触发限频,只会写一条 reply failed / approval notify failed 日志,那条回复就丢了。需要重试的话可以按 x-ogw-ratelimit-reset 等待后重发(im/v1/messages 还支持用 uuid 去重)。
运维
| 东西 | 位置 |
|---|---|
| 日志 | $DSH_HOME/im-remote-control.log(默认 ~/.dsh/im-remote-control.log),0600 |
| dispatch token | $DSH_HOME/im-remote-control.token,0600,挂载时写入、卸载时删除 |
| 共享配置 | $DSH_HOME/im-remote-control.json |
| 持久化会话 | DSH 自己的 $DSH_HOME/sessions/ |
DSH_HOME 环境变量优先,未设置时用 ~/.dsh,和 DSH 的 home 解析一致。
改配置要重启 DSH(Host 代码只在启动时加载);改设置页后同样要重启才应用到连接。设置页本身是客户端代码,更新插件后刷新浏览器即可。
排障:
| 现象 | 先看 |
|---|---|
| 飞书发消息没反应 | 日志里有没有 admitted / ignored sender / ignored chat |
| 回合结束没有回帖 | 这条回合是不是从飞书或 dispatch 发起的;日志里的 bound / sent |
| 没有收到审批 | 会话是不是 ask 档;这条回合有没有绑定 chat;日志 approval <id> asked |
/approve 回「没有待审批的请求」 |
id 抄错,或已超过 120 秒 |
| dispatch 返回 401 | token 文件不存在或已轮换;重启 DSH 或重读文件 |
日志 no access control configured |
先配 allowedChats 或 allowedUsers 再重启 |
启停:把行 disabled: true(或 enabled: false 配置项)后重启;彻底移除用 dsh plugin --profile <profile> remove dsh-plugin-im-remote-control。
开发
npm run typecheck # tsc --noEmit
npm run build # lib/index.js + lib/client.js + lib/approval.js + lib/platform.mjs
npm run smoke # 配置解析 + 审批状态机 + 客户端渲染(无网络、无凭据)
npm run rehearsal # 真实 mount 演练:假凭据 + 临时路径,验证 token/路由/鉴权/卸载(会连一次飞书)
npm run verify # typecheck + build + smoke
产物:lib/index.js(Host)、lib/client.js(设置页)、lib/approval.js(审批状态机,独立产物供测试)、lib/platform.mjs(脚本用的平台适配);icon.png 是插件列表图标(256×256 PNG,宿主限制 ≤256 KiB)。src/gateway/ 是暂停的网关源码,不构建、不随包发布。
目录:
src/index.ts Host 半边:连接、投递、回帖、路由、审批接线
src/approval.ts 审批状态机(短 id、超时、abort、竞速、撤回)
src/types.ts 手写的窄契约(只描述本插件用到的宿主接口)
src/client/ 设置页(settings.section)
src/gateway/ 暂停的网关源码(不构建)
src/platform/ 平台适配(发现 host 端口、启动应用、登录服务)
scripts/ build / smoke / rehearsal / dispatch / mode
docs/HANDOFF.md 交接文档:设计决定、接口事实、未做事项
类型策略:Host 与 Client 各自声明本插件用到的那部分契约,不直接依赖 @deepseek-ai/dsh-* 的发布类型。好处是运行时隔离,代价是宿主升级不会让本仓库的 tsc 失败——手写契约会静默漂移,升级 DSH 后要按 docs/HANDOFF.md 里的接口事实抽查一遍。
路线图
- 路由目录:
~/.dsh/im-remote-control.routes.json记录会话名称、用途、关键词,中控每轮读它,不再靠标题猜。 - 排队条数提示(现在只有表情)。
- 微信接入(企业微信自建应用或微信客服,需要公网回调网关)。
- gateway 冷启动模式:先补持久队列和按 chat 的回复绑定,再重新构建
src/gateway/。
卸载
dsh plugin --profile <profile> remove dsh-plugin-im-remote-control
然后删掉 profile cordis.patch.yml 里的覆盖行并重启 DSH。日志、token、共享配置文件可以直接删除。
License
MIT
No comments yet. Be the first to write one.