dsh-feishucard
连接与集成cmfok/dsh-feishucard
连接 DeepSeek Harness 与飞书(Lark)的桥接插件,提供流式回复卡片、长连接消息和可靠性保障。
- cordis
- deepseek-harness
- dsh
- dsh-plugin
- feishu
- lark
验证与兼容性
这里展示目录实际采集到的证据;未声明的信息会明确标为未知。
- 当前版本兼容性
- 已在当前目录版本验证
- 声明的 Harness 范围
- 未声明
- 声明的平台
- 未声明
- 适用 Profile
- web
- 构建授权
- 未知
- 权限声明
- 未声明
- 外部服务
- 未声明
- 遥测声明
- 未知
这不是安全背书;安装前仍应查看源码、权限和配置。
查看证据与判定范围
验证仅覆盖标出的来源、版本和 Harness 环境,不代表未来版本仍然兼容。
dsh-feishucard@0.1.0- 插件通过清单与静态结构检查,尚未完成运行时加载检查。
README
dsh-feishucard — DSH ↔ Feishu Streaming Card Bridge
把飞书(Lark)机器人接入 DeepSeek Harness 的 Agent 会话——完全自研(非 fork)。官方 SDK 长连接收发(无需公网 IP/域名/隧道)、每聊天独立专属会话、/new /switch /list /help 命令、处理中表情回执,以及流式回复卡片:过程话语内联 + 工具调用折叠面板 + 限流/退避/熔断/文本兜底。
A self-developed (not a fork) bridge between Feishu (Lark) chats and DeepSeek Harness agent sessions: official-SDK long connection (no public URL needed), dedicated per-chat sessions, /new /switch /list /help commands, a typing reaction, and a streaming reply card — inline agent notes, collapsible tool-call panels with status symbols, and rate-limit / backoff / circuit-breaker / plain-text fallback reliability.
📌 需求与功能状态的唯一登记处 =
需求与功能清单.md(CM 2026-10-06 裁决建立:「所有的功能清单放在那里,我提需求就在里面补一句,完成了就打个勾」)。 接手前先读它:☐=没做,🔴=已裁决但没实现(漂移,优先做),✅ 才有证据。本 README 只放门面与部署现状,CHANGELOG.md记"哪版改了什么",功能基线.md记"哪条判据由哪个用例钉住"。
功能矩阵 / Feature matrix
| 功能 | 说明 |
|---|---|
| 流式回复卡片 | 过程话语内联 + 工具调用折叠面板(带状态符号)+ 内联 agent 备注;失败时纯文本降级 |
| 单实例多 bot | 一个 DSH 实例挂多个飞书机器人,各自绑定 workspace 与 AGENTS.md |
| 群里 @ 才回复 | 群聊只在被 @ 时处理(避免多 bot 互相刷屏);单聊照常全处理 |
| 审批卡 / 提问卡 | 工具审批与 ask_user_question 直接落到飞书卡片,点按钮即回 |
/switch 两级选择 | 先选工作区(与 GUI 侧边栏同源),再选会话:接管或新建;另有 /list /new /help |
| 身份注入 | 入站消息按 open_id 解析为「人」;工具入参里的身份字段由身份表覆写 |
| Agent 互认(0.8.0) | 群里 @ 到了谁、这条消息是谁发的、卡片被谁点的——三个身份都会标注并喂给 agent;出站 @[名字] 展开成真 @(能唤醒对方 bot)。🔴 只标注、不授权:权限仍然只认身份闸门解析出来的那个人 |
| bot 名单采集 | scripts/collect_bot_roster.mjs 采集同群 bot 的 union_id / 本应用视角 ou,插件按 mtime 热读。飞书成员接口只返回人不返回 bot(真机取证 V5),所以名单必须自己采 |
| 群接力(默认关) | groupRelay 默认 self_only=与旧版一字不动(没 @ 本 bot 的群消息一律忽略);显式 opt-in 才放宽,内置三层防互刷预算 |
| 长连接收发 | 官方 SDK WebSocket 长连接,无需公网 IP / 域名 / 隧道 |
| 可靠性兜底 | 限流 / 退避 / 熔断 / 纯文本降级;helper 崩溃后可自动拉起 |
English summary: streaming reply card, many bots in one instance (own workspace + AGENTS.md each),
mention-gated group replies, approval / ask cards, two-level /switch picker, identity injection,
long-connection transport, and rate-limit / backoff / circuit-breaker / text fallback.
Since 0.8.0 also: agent mutual recognition (who was mentioned, who sent the message, who tapped the
card — annotated for the agent, never used as authorization), outbound @[name] that really pings
another bot, a collected bot roster, and an opt-in group relay with anti-loop budgets (default off).
单包即用:Host 插件(桥接逻辑)+ helper 子进程(长连接)+ bundle 补丁(自动注册)。One package, three pieces: host plugin, long-connection helper subprocess, and an auto-registered bundle patch.
独立自研,不依赖任何第三方 DSH 飞书插件。配置独立存放于
~/.dsh-feishucard/;检测到旧生态路径(~/.cc-connect/)有配置时启动自动迁移一次。不要与其他 DSH 飞书插件同时安装(同一飞书 App 的 WS 长连接互踢)。 Fully independent. Config lives in~/.dsh-feishucard/; a legacy config found at~/.cc-connect/is auto-migrated once on boot. Do not install alongside other DSH Feishu plugins (two WS long connections on one app kick each other).
✅ 更正(2026-10-02 实测,推翻本文件 2026-08-15 / 2026-09-18 的旧结论):本机 DSH profile 里
~/.dsh/profiles/web/node_modules/dsh-feishucard是目录联接(Junction),直接指向本仓库 (Get-Item报LinkType: Junction、Target: <本仓库路径>)⇒ 改源码 = 改 profile 副本, 配合@deepseek-ai/dsh-hmr(0.2 起;0.1.x 时期为cordis-plugin-hmr)保存即热重载。 实证:2026-10-02 同一轮里连续 4 次保存,日志依次出现plugin apply #8 / #9 / #10 / #11, 每次的md5/bytes都与源码一致。 旧结论「实体副本、HMR 覆盖不到源码、必须 sync + 重启」在本机不成立,已作废。 若你的安装方式不是 Junction(例如file:物化出实体副本),npm run sync仍是正确兜底 —— 体检 + 备份 + 同步 + 哈希复验,--dry-run只体检(退出码非 0 = 副本与源码不一致)。 复验"线上跑的是哪一版":grep 'plugin apply' <dsh日志>,行内含v<版本> md5=<前8位> bytes=<大小>。Corrected 2026-10-02: on this machine the profile entry is a junction to this repo, so the HMR watcher and the running copy coincide — saving the source hot-reloads the plugin (verified:
plugin apply #8–#11on four consecutive saves). The older "copy, not symlink ⇒ sync + restart required" note is stale;npm run syncremains the correct fallback for non-junction installs.
⚠️ 重启 dsh web 必须走带 key 的启动器(2026-09-08 事故教训):部署机上
DEEPSEEK_API_KEY通常放在用户环境变量(Windows 注册表HKCU\Environment)——它不在 dsh 凭据文件里、dsh 也没有 .env 层去读它。裸node <dsh包>/lib/bin.js web启动 = 进程没有 key = 所有 LLM 调用失败 = 飞书全部空白回复。需要重启时,请通过你本机的 dsh 启动脚本/计划任务(会先注入用户环境变量再拉起 dsh);不要 kill 进程后用裸 node 拉起。排查"为什么空白回复"先看进程环境里有没有DEEPSEEK_API_KEY。
⚠️ session 事件 API 跨版本差异(2026-09-14 事故教训):DSH **0.1.0-rc.5(公开版)**暴露的是
agent.session.events(数组,下标=seq);0.1.2-rc.1(内含 dev 版)把它废弃为agent.session.snapshotEvents()。两者不通用——写死任一版本都会在另一版本上炸 (0.1.0-rc.5 上写死snapshotEvents的症状 = 入站消息一进runTurn就TypeError: snapshotEvents is not a function,机器人收消息但永不回复)。 本包统一走sessionEvents(session)兼容取值(有 snapshot 用 snapshot,否则退回 events), 改这块代码前先确认目标版本的真实 API(以部署机上实际运行的那版为准,不是 dev 仓库)。
功能 / Features
-
长连接收发 / Long-connection messaging:
im.message.receive_v1官方 SDK WebSocket → 注入 Agent 会话 → 交互卡片回复同一会话,全程无需公网地址。Official SDK WebSocket; no public IP, domain, or tunnel required. -
流式回复卡片 / Streaming reply card:
- 收到消息即建卡「正在工作中…」,agent 干活时实时 PATCH 更新。A card appears instantly and is PATCH-updated live as the agent works.
- 过程话语(agent 每步说的话)按事件顺序内联可见。Inline agent narration, in event order.
- 工具调用折叠面板:🛠️ 每工具一行「状态符号 · 工具名 · 参数摘要 · 失败原因」,默认折叠。Collapsible tool-call panels: status symbol, tool name, arg summary, failure reason per line.
- 完成 sealed:最终回复入卡、状态行消失、面板保持折叠。Sealed with the final reply; status line removed.
- 可靠性:串行更新队列 + 400ms 限流合并 + 指数退避 + 5 次熔断 + 15s 超时 + 卡片失败自动降级纯文本。Serialized queue, 400ms coalescing, exponential backoff, 5-failure breaker, 15s timeout, text fallback.
-
每聊天独立会话 / Dedicated per-chat sessions:每个飞书聊天专属 Agent 会话池(绝不串进 GUI 会话);首条消息自动创建;持久化 + 重启恢复(live 会话直接复用、上下文不丢);
/new [名称]、/switch(先选工作区 → 再选该工作区的会话)、/list(当前工作区的会话)、/plan [off]、/goal、/compact(压缩上下文;agent 空闲时才可用)、/stop、/help。Never shares GUI sessions; auto-created on first message; live sessions are reused across restarts so context is preserved. -
处理中表情 / Typing reaction:消息到达加
OnIt,回复送达后撤销(reactionEmoji可配,none关闭)。OnItreaction added on arrival, removed after delivery. -
工具 / Model tool:
feishu_send(agent 主动发消息,appId指定机器人,缺省发到最近会话)。Proactive messaging from the agent. -
审批卡片 / Approval card:dsh 会话的工具调用需要确认时(audit 哨兵等),飞书弹出交互卡片「✅ 允许一次 / ❌ 拒绝」按钮,点击即回决策(
card.action.trigger长连接事件);10 分钟超时自动拒绝(2026-09-16 由 3 分钟延长;卡面文案由APPROVAL_TIMEOUT_MIN推导,不再写死)、会话取消自动取消——避免飞书通道下审批无人应答导致会话永久挂起。Approvalapproval/requestfor plugin-owned sessions is answered via a button card; timeout auto-rejects. 默认开启(见上方开关表)。 -
审批走服务层直连(2026-10-02,0.2 适配):DSH 0.2 起
@deepseek-ai/dsh-api-remotes的 forwarded waterfall 会在根级更早截走approval/request(只要浏览器端连着就交给 GUI),插件监听永远轮不到 —— 实测升级后approval/request received出现 0 次(0.1.x 时代有 70 次),症状就是「审批只弹电脑、 飞书收不到卡」。修法:包一层审批服务的decide()(工具是按对象取的:ctx.get('approval')), 只接管飞书自己的会话,GUI / 子代理的审批原样交回。现已覆盖沙箱越权升级 (sandbox_permissions+justification)这类此前拦不到的请求。 Since 0.2 the GUI bridge pre-emptsapproval/requestat the root scope; the plugin now wraps the approval service'sdecide()instead, so escalations reach Feishu (GUI/subagent approvals still delegate). -
已授全权时不弹卡(2026-10-02,0.4.18):插件的这道闸过去不看会话档位,在
danger-full-access(用户已给全部权限)下也会因为命令里出现ssh/curl等关键词而弹卡 —— 实测git push的$env:GIT_SSH_COMMAND = 'ssh …'就连发了两张。 现在tools/pre-execute先读ctx.get('sandboxPolicy').resolve({ session }),mode === 'danger-full-access'直接放行:全权模式 = 零审批卡;受限模式 = 只有越权才弹卡。 When the session already runs atdanger-full-access, the plugin adds no gate of its own. -
保活 / Keep-alive:helper 崩溃自动重启(5s 冷却防重复)+ 凭据变更自动重连 + SDK 自带重连 + 状态可观测。Crash-restart with spawn cooldown, auto-reconnect, observable connection status.
-
多机器人 / Multi-bot:一个实例多个机器人,各自绑定工作区。One instance, many bots, one workspace each.
-
目标模式进度卡 / Goal-round progress cards(2026-09-16,经评审确定方案 A):目标模式(goal 模式)的续轮不经过飞书入站,过去在飞书完全看不到它在干什么。现在插件订阅
agent/status:某轮由目标轮驱动 → 自动建卡「🎯 目标模式 · 第 N 轮开始,正在工作…」,复用与普通回合同一套流式卡(过程话语 / 工具面板 / 表格换卡全部生效),轮结束封口写「✅ 本轮结束」。只报目标轮,其它自动回合不建卡(不刷屏)。Goal-round continuations never pass through Feishu inbound, so they used to be invisible; anagent/statushook now opens the same streaming card per goal round. -
/goal命令 / Goal command:飞书里直接/goal <目标>即可让当前会话进入目标模式(透传到 harness 的command-goal,pause/resume/clear/edit <新目标>子命令同样可用;无参数 = 查看状态)。命令注册表不可用时兜底直连goals服务创建目标。/goal <objective>starts goal mode for that chat's session from Feishu; subcommands pass through to the harness command. -
/switch两级:工作区 → 会话 / Two-level workspace & session picker(2026-10-02 重做,维护者 定稿 A 方案):/switch先发工作区卡 —— 列出 DSHworkspaceRegistry里注册的工作区(与 GUI 侧边栏同源),每行显示路径、会话数、🟡 运行中数量、目录是否还在(⚠️ 不存在),并标出 ▶ 当前工作区,按钮「N 进入看会话」/「N 在这里新建」;点「进入」(或/switch <工作区序号>)再发该工作区的会话卡,每行「接管 / 新建」,底部「← 返回工作区列表」。🟡 运行中的会话只给"新建"(同一会话被两处同时驱动会写坏历史)。文字兜底/switch <工作区序号> [<会话序号>|new];/list= 当前工作区的会话(当前工作区 = 活跃会话的 cwd)。新建/接管时会话会挂进工作区注册表(best-effort)⇒ GUI 侧边栏也立刻看得到。/switchposts a workspace card first (from the DSHworkspaceRegistry, the same source the GUI sidebar uses), then that workspace's session card; text fallbacks/switch <ws> [<session>|new], and/listlists the current workspace's sessions. -
文件收件 / File inbox(2026-09-09):飞书文件/图片/语音消息不再被静默丢弃——自动下载到 fileInbox(配置项,缺省 /downloaded_files;2026-10-02 前实际从未生效:下载链读
evt.msg_type而normalizeEvent()只给message_type⇒ 文件消息被静默丢弃;现在两个键都给,失败也会明说 HTTP 码;2026-10-03 起:文件名没有扩展名时按文件头补(jpg/png/gif/webp/bmp/pdf/zip,认不出补.bin;已有扩展名的原样保留),时间戳用本地时间YYYY-MM-DD-HHMMSS),并向会话注入「收到文件+本地路径」,agent 可直接读取。Inbound Feishu file/image/audio messages are downloaded to fileInbox and surfaced to the agent with a local path. -
热重载打断提示 / Hot-reload interrupt notice(2026-10-02 复原并加固):保存插件源码即热重载(junction + HMR),而重载会拆掉插件作用域 ⇒ 正在跑的回合被
aborted(disposed)中断。此时插件会在该会话里发一条纯文本说明(「♻️ 插件已热重载:上一轮被热重载打断(不是模型出错,也不是你的操作)…」),免得看起来像"说到一半莫名停了"。同一会话只提示一次(幂等);重载时本来没有回合在跑 ⇒ 不发任何东西(无噪声)。When a hot reload aborts an in-flight turn, the plugin posts an explanatory notice into that chat (once per session; silent when nothing was running). -
子代理回执即播报 / Subagent settlement broadcast(2026-10-01 改造①):子代理一完成立刻播报 —— ① 纯文本(必达)② 详情卡(带正确的子代理 id)。判据是会话事件里的正式字段
source.kind === 'subagent-settled', 不看回合状态(旧实现在"回合启动那一拍"回扫,而回执比它晚落盘 ⇒ 永远不开卡)。 普通飞书回合正在跑时只发文本不建卡(避免同内容两张卡);childId#seq去重。 A settlement notice is broadcast the moment it lands (plain text + a card naming the right child), independent of turn state; per-botnotifyAgentNotices:falsedisables it. -
卡片底部「目标条」/ Goal footer(2026-10-01 改造③):卡片最底部一个折叠面板 —— 折叠=一行 「目标模式状态 | 🧠 上下文/窗口(占比) | 💾 缓存命中率」,点开=状态/续行/创建时间 + 目标全文。 含
⚠️ 已创建但续行已停(/goal resume 恢复)这一格:dsh 重启后会 disarm 所有活跃 goal,卡面直接可见。 A collapsible footer showing goal state + full objective + context usage (%) + prompt-cache hit rate. -
选项卡分栏换行 / Wrapping option card(2026-10-01 改造④):选项正文用 markdown(自动换行、全文显示), 按钮只负责"选这一项";
flex_mode:'stretch'让窄屏自动上下堆叠。选项数量不再限 5 个。 Option text wraps (JSON 2.0 column layout) instead of being truncated inside a button; no 5-option cap. -
引用回复透传 / Quote passthrough(2026-10-01 改造⑤):长按引用某条消息/某张卡片再回复时, 被引内容的摘要会随正文一起进会话(「(你在引用这条消息:…)」);只用本地登记表,不新增飞书权限。 Quoted-message摘要 is injected into the session so the agent knows what the reply refers to; no extra Feishu scope needed.
-
计划审查卡 / Plan-review card(2026-10-01 起;2026-10-02 改版):计划模式里
exit_plan_mode提交的退出申请现在会发到飞书 —— 卡头「📋 计划已写好,等你批准」,正文是完整计划,底下一排两个控件:批准=整块可点击的深绿色块(interactive_container+green-600,白字居中) /拒绝=红底白字按钮(danger_filled)。 也可以直接回文字(精确回「批准」/「同意」=批准;带补充说明=修改意见回给模型)。 三条实现约束(都是真机踩出来的):① 飞书 2.0 按钮颜色枚举里没有绿色;② 绿不能挂在column.background_style上 —— 官方注明该字段需客户端 v7.9+(维护者 手机上根本没渲染),且会被width:'fill'的按钮整列盖住 ⇒ 绿必须用整块可点击容器做;③ markdown 文字要text_align:'center'居中, 且加粗不能跨在<font>标签里外(<font color='white'>**批准</font>**会把标签原文当文字漏出来)。 为什么以前收不到:exit_plan_mode走的是userQuestions服务(不是ask_user_question工具), 旧实现只拦了工具那一层 ⇒ 申请只发给了连着的 GUI 客户端。现在补了服务层user-questions/request接管 (与approval/request同构,仅接管飞书自己的会话;GUI/子代理的提问一律交回 harness)。exit_plan_mode's review prompt now reaches Feishu via a service-leveluser-questions/requesttakeover, not just the tool-level one. -
审批单卡 / Approval form(2026-10-03 新增;0.6.1 按真机反馈改版;0.6.2 起为可选通道):AI 要发「权限变更审批单」这类可读可点的单子时用它 —— 卡面:短值字段两列并排(单号 / 置信度…)· 长文本各占一整行(变更类型 / 证据来源…;判据见下)→ ① 类别 × L 档变化(🔹不变 / 🔸收窄)→ ② 技能变化(➕新增 / ➖取消 / ✅不变)→ ③ 证据原文(引用块)→ ④ 影响面 → ⑤ 不批的后果 → 两个带色按钮:
✅ 采纳(primary 蓝) /❌ 驳回(danger 红)(没有「⑥ 操作」标题,也没有第三个「我要改」——要提意见直接回消息)。 短/长判据:字段显式short:true/false优先;否则纯 ASCII/数字且 ≤24 字 ⇒ 短(并排)、含中日韩文字 ⇒ 长(独占一行)。 ⚠️ 默认关(可选通道):要在~/.dsh-feishucard/feishu.config.json里给某个 bot 加"approvalForm": true才启用。 0.7.0 起改为「方案 B:工具始终注册」——这样 Agent 能主动告诉你"这里有审批单通道、要不要开"; 没开的 bot 调用会被明确拒绝并把"怎么打开"写在返回里(可被 AI 直接转告)。改完 10 秒内自动生效,不用重启。 分区可泛化:除了本仓的「身份标签」预设(categories/skills/evidence/impact/risk),也可以直接给sections: [{title, lines}]做任意审批单(标题自动补 ①~⑩)。 两条触达入口:工具feishu_approval_form(结构化参数,发卡后等他点,把选择当工具结果返回; 目标会话自动取调用方 agent 的飞书会话,非飞书会话明确报错、绝不瞎发)· 或ask_user_question的questions[0].card适配(回答按selected:[选择]回传,调用方零改动)。 硬规矩:一张卡只装一个人、一件事;30 分钟没点 ⇒ 可见地作废(卡变超时态 + 一条纯文本,绝不默认通过或驳回); 点完只换按钮那一行、正文全留(0.7.0;见下条"审批卡保留正文"),旧卡再点给可见提示而不是静默失败。 -
失败必须可见 / Fail-visible(0.7.0):卡片被飞书拒、熔断、重试耗尽 ⇒ ①先抢救正文(把发不出去的片段摘掉后重发纯文本) ②告诉用户(带飞书原始
code)③给 Agent 一条可读回执(它会知道自己"没送达",不会以为发成功了就收工)。 同一原因 2 分钟内只发一次;所有纯文本兜底都过唯一收口点stripUnsendable()(本地图片路径、卡片不支持的 HTML 标签)。 -
图片/文件真送达 / Real image & file delivery(0.7.0;0.7.1 补齐文件侧):飞书卡片图片只认
img_key(本地路径会让整张卡被拒), 所以发卡前会把正文里的自动上传换 key(im/v1/images),图片真的显示;0.7.1 起本地文件链接[季度报告.pdf](D:\…\x.pdf)走im/v1/files上传后作为文件消息发出(卡面只留「(文件已发送:…)」;同一 chat+path 只发一次, 路径不存在则原样不动,不误伤普通超链接)。超限(图片 >10MB / 文件 >30MB)与权限不足都给明确提示; 附件安全闸(0.7.1):扩展名白名单 + 图片按文件头验真 + 凭证/密钥类名字与内容一律不传; 授权类疑似二维码不自动上传(改发链接,遵守"授权只发链接"那条规矩)。 -
状态栏三模式 / Three session modes(0.7.0):状态栏首段是
🧭 普通模式 / 📋 计划模式 / 🎯 目标模式, 判据是现成可读源(会话事件plan/mode+ 目标快照),读不到就显示"普通",不猜。 -
计划 → 目标承接(0.7.0):计划审批卡 = 行1
批准/拒绝+ 行2整行🎯 以目标模式跑; 点行2 = 退出计划模式 + 用计划全文建目标 + 卡变回执(正文保留)。建目标失败会明说,不留半截状态。 -
审批卡保留正文 / Keep the body after a tap(0.7.0):审批类卡片(计划审批卡、审批单卡)点完不再撤消息、不再整卡重建, 只把按钮那一行换成「✅ 你已经审批过了:…(时间)」;超时/作废同理。
-
重载自动续跑 / Auto-resume after reload(0.7.0):插件热重载打断那一轮后,新实例不只播报, 还会自动把那一轮接上(注入「从断点继续,别重做」);每会话一次。
-
提问卡按钮上色 / Colored option buttons(2026-10-03):选项按钮现在带
type(飞书不传就是灰的)—— 规则:显式buttonType> 词义(驳回/拒绝/取消… ⇒ 红)> 第一个 ⇒ 蓝 > 其余灰;向后兼容,不传不报错。ask_user_questionoption buttons now carry atype(primary / danger / default) instead of all being grey.
快速开始 / Quick Start
dsh plugin --profile web add dsh-feishucard
dsh web # 重启 / restart
首次安装若提示
ERR_PNPM_IGNORED_BUILDS(pnpm ≥10 默认拦截protobufjs构建脚本):编辑$DSH_HOME/profiles/web/pnpm-workspace.yaml,把allowBuilds下的protobufjs改为true后重跑安装命令。 If pnpm blocksprotobufjs's build script, setallowBuilds.protobufjstotruein the profile'spnpm-workspace.yamland re-run.本地开发安装 / local install:
dsh plugin --profile web add <本包目录>或file:<本包目录>。
配置 / Configuration
写在 ~/.dsh-feishucard/feishu.config.json(与仓库解耦 / decoupled from any repo):
{
"bots": [
{
"name": "我的机器人",
"workspace": "<你的工作区绝对路径>",
"appId": "cli_xxxxxxxxxxxxxxxx",
"appSecret": "your_app_secret",
"reactionEmoji": "OnIt"
}
]
}
会话状态持久化在 ~/.dsh-feishucard/state-<appId>.json。配置支持热更新(10 秒轮询),改完无需重启。Session state persists to ~/.dsh-feishucard/state-<appId>.json; config is hot-reloaded every 10s.
开关 / Switches(环境变量作用于整个插件进程 / env vars are process-wide):
| 变量 / Variable | 缺省 / Default | 作用 / Effect |
|---|---|---|
DSH_FEISHU_GOAL_CARDS | 1(开) | 设 0 全局关闭「目标模式进度卡」。Set 0 to disable goal-round progress cards. |
DSH_FEISHU_APPROVAL | 1(开) | 默认开启。2026-10-02 改口径:「手机上得要能审批才行,不可以关了」(2026-09-16 的"默认关闭、不许改回"按 作废)。设 0/false/off/no 关闭。覆盖沙箱越权升级(sandbox_permissions)的审批。会话已是 danger-full-access(用户已授全权)时一律不弹卡 —— 插件不再加一道(2026-10-02 修,见 CHANGELOG 0.4.18)。 |
FS_CONFIG_DIR | ~/.dsh-feishucard | 配置文件目录(测试用)。 |
per-bot 配置项 notifyGoalRounds(false 关闭该机器人的目标卡)写在 feishu.config.json 的对应 bot 里。Per-bot notifyGoalRounds: false disables goal cards for that bot only.
splitConclusionMinMs(per-bot,毫秒,默认 30000) —— 本轮耗时 ≥ 该值才把结论单独发一张新卡
(新卡=新消息 ⇒ 飞书会提醒;过程卡退化成"工作日志",结论不会重复)。短任务维持单卡。
设 0 = 每轮都分卡。
✅ 2026-10-01 已修复(0.4.13):per-bot
splitConclusionMinMs现在真的生效 —— 此前配置归一化白名单(normalizeConfig)漏了这个字段,写进feishu.config.json会被丢掉 (由 smoke 38 首次测「配置通道」时抓出)。10 秒热读、不用重启。 优先级:bot 配置 → 环境变量DSH_FEISHU_SPLIT_MIN_MS→ 默认30000。
splitConclusionMinMs decides when a finished turn's conclusion is posted as a new card (so Feishu notifies).
Hot-reloaded from the bot config (10s), no restart needed — fixed in 0.4.13
(the field had been silently stripped by the config normaliser).
✅ 同一条结论不会重复发送(0.4.17):结论按「agent + 回复正文哈希」跨插件代际去重 (
globalThis上的表,先到先得,窗口 120 秒)—— 修复「插件热重载留下上一代仍在await whenIdle()的回合链条 ⇒ 新老两条链在同一秒各拆一张结论卡」造成的重复。 只在"本来就会开结论卡"的长回合生效(短回合/纯旁白完全不受影响)。 命中时打日志[fs] duplicate conclusion suppressed: agent=… hash=… age_ms=…。 Duplicate conclusions are de-duplicated per agent + reply hash across plugin generations (120s window).
从旧插件迁移 / migrating from the legacy plugin:无需手动操作。首次启动若新路径无配置而
~/.cc-connect/feishu.config.json存在,自动复制迁移(日志migrated config from legacy ...)。Nothing to do — the legacy config is copied automatically on first boot.
飞书开放平台一次性配置 / One-time Feishu Open Platform setup
- 创建企业自建应用,启用机器人 / create an enterprise self-built app, enable the bot
- 权限 / permissions:
im:message.p2p_msg:readonly、im:message.group_at_msg:readonly、im:message:send_as_bot、im:message.reaction(可选 / optional) - 事件与回调 → 订阅方式选「使用长连接接收事件」→ 添加事件
im.message.receive_v1/ events & callbacks → long-connection mode → addim.message.receive_v1 - 创建版本并发布 / create a version and publish
架构 / Architecture
飞书开放平台 ⇄ WebSocket 长连接 ⇄ helper.cjs(官方 SDK WSClient / official SDK)
⇅ stdout JSON 行(ready/status/event/error)
index.js(Host 插件,id=feishu-stream)
⇅ ctx.agents(dedicated 会话)/ fetch(卡片 API)
Agent 会话(绑定配置工作区 / bound workspace)
- 事件轮询:
agent.session.events(assistant/message → note;tool/call、tool/result → 工具面板),300ms 轮询 + seal 前补扫(快速回合不丢中间过程)。300ms polling plus a seal-time catch-up scan so fast turns keep their narration. - 会话恢复 / session recovery:优先复用 live 会话(
agents.list()命中 → 上下文保留),其次 resume 持久化会话,最后才新建。Reuse live sessions first, then resume persisted ones, create only as a last resort. - 卡片 / card:
POST /im/v1/messages创建 →PATCH /im/v1/messages/{id}更新 → sealed 终态。 - 配置热读 / hot config:10s 轮询;helper 每机器人一个子进程,崩溃自动重启(5s 冷却)。One helper subprocess per bot, crash-restarted with a 5s cooldown.
身份闸门(可选,默认关)/ Identity Guard (opt-in)
默认关闭。不开就完全不碰身份链路:不查表、不调 python、不写上下文、不拦工具,零副作用。
它解决什么
飞书入站事件里带的 open_id 是服务端填的、伪造不了;而消息正文里任何人都能打一段
「[飞书 ou_…]」来冒充。开着这道闸门时:
- 入站按事件自带的
open_id查身份表 ⇒ 得到本轮actor,存进本轮上下文(按 agentId,带 TTL); - 每次工具调用前,把 agent 传来的任何身份字段覆写成表里的真值(白名单外的字段删除);
- 拿不到身份 ⇒ 拒绝执行(fail-closed)—— 「执行不了」好过「资料泄露」。
非飞书回合(GUI / 子代理 / 定时任务)一律放行 —— 否则会把你自己的电脑锁死。
怎么开
在 bot 配置(feishu.config.json)里加一项:
{ "bots": [ { "name": "work", "appId": "cli_…", "appSecret": "…", "identityGuard": true } ] }
⚠️ 该字段已进配置白名单;不写就是关。
身份表放哪
按顺序取第一个存在的:
- 环境变量
MAILBOX_IDENTITY_MAP - 部署目录下的
identity_map.json(服务器上的固定位置;想换路径就设MAILBOX_IDENTITY_MAP) <你的工作区>/output/g9-identity/identity_map.json- 从当前工作目录逐级向上 6 层
- 内置的已知工作区兜底
每台机器可另放一份【本地增量】 ~/.dsh-feishucard/identity_map.local.json
(放在工作区之外 ⇒ 不参与文件同步 ⇒ 多台机器各写各的、互不覆盖),只补本机 bot 的 open_ids。
怎么生成表
python scripts/build_identity_map.py --seed seed.json --app-id cli_xxxxxxxx --apply
--seed:你自己的人事清单(JSON 数组),元素形如{"name":"张三","job_id":"运营","channel":"运营组","status":"在职","open_id":"ou_…"}--app-id:open_ids那一层的 key —— 必须是收到消息的那个 bot 的 app_id (飞书open_id每个应用各不相同;用错 app 的 id 必然对不上,跨应用查会直接报open_id cross app)--via-contact:可选,用应用身份调contact.user.get反查补union_id(注意 API 额度,用--max-calls限制)- 不加
--apply就是 dry-run(只打印将要发生的变化);脚本幂等,重复跑不产生重复条目
表结构见 identity_map.example.json
(真实表含个人标识与人名,不进本仓库)。
解析接口
resolveActor(open_id, identity_map) -> (actor | None, err | None) —— 见
scripts/resolve_actor.py:纯函数、无网络依赖,自带 --selftest。
错误码:map_unavailable / no_open_id / unknown_person / duplicate_open_id /
open_id_missing / job_not_granted / not_active
(人已离职 / 终止办理 ⇒ 正常拒绝;与「不认识」分开报)。
not_active是 2026-10-04 追加的:原先「离职」与「完全不认识这个open_id」共用unknown_person,上层若按它做兜底(例如问姓名),离职的人会被当成陌生人来处理 —— 而离职是正常拒绝,不该被兜底。
Agent 互认与 @ 的语义(0.8.0)
三个此前完全丢失的信息现在都会喂给 agent(明细行只进会话上下文,卡片上不出现):
| 信息 | 形态 | 来源 |
|---|---|---|
| 这条消息谁发的 | 前缀 [飞书 姓名] + 尾部 【发送方】kind=user|bot name=… open_id=… | sender.sender_id + roster |
| 这条消息 @ 了谁 | 正文里 @_user_N 还原成 @名字,尾部 【本条 @ 的对象】@名(kind id=前10位) | mentions[](V1:对象或字符串两种形态都认) |
| 卡片是谁点的 | 审批/提问结果里的 clicker = [点击者 姓名|ou前8位] | card.action.trigger 的 data.operator(V3 形状) |
出站 @:feishu_send 的 at 参数,或正文里手写 @[名字] / @「名字」 / @all,命中后展开成真 @。
@all必须是独立 token:后面紧跟 ASCII 标识符字符(@all-hands、@all.png、@all_x) 时视为别的词,不展开;紧跟中文(@all大家安静)时照常展开——那是本产品最常见的 「广播指令+正文」写法,判成非 token 等于把你要的全群通知悄悄取消。
⚠️ @ 是会通知人的:@ 到活人=对方收到消息提醒,@ 到别的 bot=唤醒那个 agent 跑一轮。 所以解析不到时保留原文并追加
(未能 @ 出:X),重名歧义时一个都不 @ —— 宁可 @ 不出来,也不发幽灵 @。 卡片内 @ 失效时可整条降级为msg_type=post(DSH_FEISHU_AT_MODE=post;post 内容必须包zh_cn层)。
🔴 旁证不等于授权:以上三条只解决「看得见」,不改变权限口径。能不能执行某动作, 仍然只认身份闸门
resolveActor(open_id)解析到的人;名字对得上、roster 里有、卡片 operator 是熟脸——这些都不是授权依据。identityGuard关着时,互认照常显示,权限照常不判。
bot 名单(跨应用 id 目录):node scripts/collect_bot_roster.mjs 生成到
FS_CONFIG_DIR(默认 ~/.dsh-feishucard/)bot_roster.json,0600、放在工作区之外,
插件按 mtime 热读;查不到 id 时打 [fs] roster miss 留痕,不静默。
必须自采的原因见取证 V5:飞书成员接口返回的成员只有人不包含 bot。
群接力 groupRelay:默认 self_only=没 @ 本 bot 的群消息一律忽略(与 0.7.x 一字不动)。
实测两个 bot 同群 5 分钟能刷出 3666 条事件,所以放宽只在 bot 配置或 groupRelayChats
群白名单显式 opt-in 时发生,并且内置三层预算:同一发送方 90s 内接力 >3 条 ⇒ 冻结该配对
10 分钟;同群 60s 内 >8 条 ⇒ 整群丢弃;群里出现任意人的消息 ⇒ 立刻清零复臂。
计数器只对经 relay 转发的 bot 消息生效,人发的消息永远不吃预算。
per-bot 预设 agentPreset(0.8.5,中台 #171 的收口机制):默认不写=挂全局默认预设,
行为与旧版一字不动。写了 ⇒ 该 bot 的所有会话(新建与复用)按 id 挂载指定的 agent 预设
(registry mount(ctx, id?) 官方通道,配置 10 秒热读生效)。id 查无 ⇒ preset mount failed
日志可见(带 (bot=名, agentPreset=id) 后缀),不静默回落——配置错误要显式失败。
用途:多 bot 共实例时把第三方工具只留在专用预设、其余 bot 显式挂基础预设,收掉
「工具挂 default ⇒ 实例内全员可见」的跨 bot 暴露面。
开发 / Development
npm i # 安装依赖 / install deps
npm run check # node --check 语法检查 / syntax check
npm run smoke # 冒烟测试:mock DSH ctx + mock 飞书 API,跑完整回合链路
npm run sync # 同步到 profile 副本(含体检 + 校验,--dry-run 只体检)
冒烟测试覆盖 / covered by the smoke suite:helper 注册、入站消息管线(会话创建/消息投递)、流式卡片(create/PATCH/schema/工具面板/状态符号/note/seal)、命令处理、链路稳定性。Helper registration, inbound pipeline, streaming card lifecycle, commands, end-to-end stability.
发布 / Releasing
发版由维护者的私有工具链执行 —— 发布脚本与内部规则不随本仓库分发 (公开仓库里只留机制说明,避免把内部流程与措辞带进发布物)。
每次发版强制 四条硬闸门,任何一条不过就拒绝执行(绝不静默跳过):
- 必须在 git 仓库里,且分支是
master - 工作区干净(不把半成品发出去)
- 🔑
CHANGELOG.md必须已有## [<version>]段 —— 这是防「漏写 CHANGELOG」的关键 - 该 tag 未存在
另有一道发布物完整性闸门:递归比对「代码里相对 import/require 的文件」与 package.json 的 files,
缺任何一个就拒绝发版(同一检查也进了 CI,每次 push / PR 都会跑)。
为什么必需:本地冒烟绿 ≠ 发出去的包能跑 ——
实测过 files 漏一个被相对导入的文件,本机全绿,而 npm i 装到别人机器上直接 failed to import。
为什么要有闸门:发版要做五件事,全靠人记就必漏。实测后果是 CHANGELOG 缺了一版、 tag 从
v0.3.0之后再没打过 ⇒ 仓库首页的 Release 停在两个月前 ⇒ 看起来像停更。
独立审查门槛 / Review gate
推送前过一道独立的 AI 代码审查(code-review-gate skill,底座 alibaba/open-code-review,模型 deepseek-flash)。
判据:出现 critical / high 即 BLOCK,不得放行;只出现 medium 为 WARN。报告落盘 output/code-review/<repo>-<时间戳>/REPORT.md。
- 门槛只审不改 —— 出报告 → 逐条打开源码复核(区分真缺陷与误报)→ 修 → 重跑一次取证,不"改了就说好了"。
- 当前状态:
0.4.25修完上一轮 BLOCK 的全部 high/medium(复核 4/4 全真、零误报)。
权限设计裁决速查(CM 裁决回写,2026-10-05)
本节把散在台账/CHANGELOG 里的权限类裁决固化成速查,agent 不用再翻台账或问 CM:
- 审批形式 = 原生审批单(D7,2026-09-25)。开通受限功能一律发审批单报 CM;审批向 CM、批 1 次长期有效、维护者(CM)免审批(0.7.17 经评审定案)。
- 两模式 full / stable(0.7.17):私聊按
bot.cfg.mode(缺省 full),群聊一律 stable。stable 折叠过程叙述,/switch直接拒绝——员工一个会话就够(CM 裁决:普通员工切会话默认关闭、不能打开)。 - 员工档三开关的落点:员工 preset 禁 plan/goal 工具;员工 bot 不开
approvalForm。 - 身份闸门
identityGuard默认关(0.7.18,本仓库会发布给外部)。开启后:按飞书服务端open_id查身份表覆写身份字段,拿不到身份 ⇒ 拒绝执行(fail-closed——CM 2026-10-04:「执行不了总比资料泄露好」)。 - 最小权限原则(CM 2026-10-03):权限首先收到最小,可以不开的先不开;有问题的列清单待 CM 拍板。
- 数据权限模型(D23/D24,2026-09-30):角色 × 数据域 × 级别;兼职只给 L0 公开信息(D43)。权限在取数层不在说话层(D146)——工具拿不到的数据就当不存在。
- 🔴 线上现状(2026-10-06 06:58 部署 0.8.2 后复核):服务器 9 份
feishu.config.json(/home/agt*×7 与/home/ubuntu各 1 个 bot、/srv/aiad4 个 bot)均未写identityGuard(默认关)⇒ 收紧在线上尚未生效。⚠️ 0.8.1/0.8.2 两次整包覆盖只换代码五件 + 补建采集脚本,不碰配置(部署脚本里没有写 config 的步骤)⇒ 本条结论是实测而非推定(逐份grep -c identityGuard全为 0,bots=那列是同一次遍历里grep -c appId的计数)。拦截器「有 owner 无记录 = 放行」与内核 deny 语义相反的偏差已按 CM 裁决(中台 #36)排进 0.8.3(功能基线缺口清单 #11),开启走金丝雀:先 1 个 bot 打点观察,再逐个推进。 - 🔴 0.8.2 已上线(2026-10-06 06:58 服务器本地时间 = 22:58 UTC,全部为实测输出):
step46-deploy-082.sh单次跑完RC=0(日志output/step46-deploy.log)⇒ 10 份副本的index.js/helper.cjs/identity-inject.mjs/package.json/scripts/collect_bot_roster.mjs各只剩一个 md5(2d992282…/62ac162d…/ccacd1b1…/0861efe0…/7deb957a…=闸门 Z 清单)、version全为0.8.2、每份node --check全 OK;一次重启dsh-feishu-aiad+dsh-feishu(其余单元 disabled 未动)⇒ 两单元active、长连接 aiad=4 / main=1、drain error各 0、5 个 helper 进程启动时间全在本次重启之后(06:58:15)、ps里明文凭证行数 = 0(全部--cred <文件>形态);两个实例的[fs] plugin apply自报v0.8.2 md5=2d992282(与 Z 的index.js同源,A25 口径)。/root/OPS_CHANGELOG.md已追加。🔴 本批专属取证仍待真人点卡:aiad 一个进程挂 4 个 bot=「点 ✕ 卡片不动」那条缺陷的真实形态,需要 CM 在任一 bot 会话里发/switch后点「✕ 取消」,判据是卡片当场消失(按 CM 口径这不作为部署门槛,见功能基线第十五轮门槛段)。 - ⚠️ 采集脚本在服务器上的路径坑(2026-10-06 首次运行实测):
dsh-feishu-aiad单元里HOME=/srv/aiad、FS_CONFIG_DIR=/srv/aiad/.dsh-feishucard是 systemd 注入的,而aiad账号的真实 home 是/home/aiad(那里没有.dsh-feishucard)⇒ 直接sudo -u aiad node scripts/collect_bot_roster.mjs会去读/home/aiad/…并报「找不到配置」而非零退出(脚本行为正确:读不到配置不写空名单)。跑得通的形态必须显式带上这两个变量:sudo -u aiad env HOME=/srv/aiad FS_CONFIG_DIR=/srv/aiad/.dsh-feishucard node <绝对路径>/scripts/collect_bot_roster.mjs。这是「以后挂 cron 时必须照抄」的口径——cron 不继承 unit 的Environment=,用默认路径的 cron 会天天失败退出(且不会写坏文件,因为那道闸在)。本次产出:/srv/aiad/.dsh-feishucard/bot_roster.json(0600,aiad:aiad,bots=4 people=15 chats=9;dry-run 与正式跑各一次,全天 2 次 ≤3 次上限)。服务器此前一直没有这份名单(全盘find -name bot_roster.json为空)⇒ 0.8.0 上线的互认功能在 aiad 上一直是"目录缺席"的降级态。 - 🔴 服务器运行态的文件归属实测(2026-10-06 部署后取证):五条都不是本批字节引入,已全部投中台。处置现状(CM 2026-10-06 授权「三件都做」,同日落地):① #39 已修、④ #42 已修、⑤ #44 已修;② #40 未修(改属主必须与重启同批,排下一次合法重启)、③ #41 是事实记录不是缺陷(其余会话的 11 次 restart,无法回溯修)(①②③ 由部署后翻日志取证抓到,④ 由为补闸门而新写的只读巡检脚本首跑抓到,⑤ 由取证 roster 时的调度面排查抓到)。
- ① hr 的会话状态写不进盘:
state-clia97b99468779dbb4.json(appIdcli_a97b99468779dbb4= aiad 四个 bot 里的 hr)属主是ubuntu:ubuntu 664,而单元是User=aiad / Group=agtagents⇒ 决定性判定是sudo -u aiad test -w返回不可写;全天 6 次[fs] state save failed: EACCES(04:56:03–05:42:16)全部命中这一个文件,同目录另外三个state-*.json都是aiad:agtagents 664可写。写手是 04:55 一次以 ubuntu 身份执行的 clearsessions(同 mtime 的.bak-20261006-clearsessions为证)。⚠️ 「06:58 重启后该错误计数为 0」不是自愈——那个窗口里 hr 没有写盘事件(aiad 侧只有 1 条plugin apply),恢复流量即复现。🔴 07:4x 二次实测已把这句话钉成数据:重启(06:58:13)后 40 多分钟里该目录没有任何一个运行态文件被写过——message-index.json最后写05:42:20、四个state-*.json分别停在05:26:40/05:22:50/04:55:45/10-05 04:08:33,唯一变动的是bot_roster.json(采集脚本写的,与 bot 无关)⇒ 错误计数为 0 的原因确实是零写入事件,不是问题消失。修法一条sudo chown aiad:agtagents(不改内容、不需重启、可逆)⇒ 中台 #39,✅ 2026-10-06 已修(CM 授权后由step48-apply-perm-fixes.sh执行;改完决定性复测sudo -u aiad test -w= 可写,step47复跑该文件从「!!」变「ok 可写」,未重启、其余字节未动)。 - ② 凭证文件权限形态不一致:同一目录的
feishu.config.json是root:root 644(里面装着 hr/analyst/okr/knowledge 四个 bot 的appSecret),而其余 8 份全是「运行用户:agtagents 600」。实事求是定级:当前不是正在泄露——父目录drwx------ aiad:agtagents,实测www-data/nobody/agtokr读 1 字节一律Permission denied(namei链卡在.dsh-feishucard那一级的 700)。但服务读到它靠的是 others 可读位,所以将来谁把它收成 600 而不同步改属主,aiad 下次重启就读不到配置、四个 bot 直接失联。修法必须与重启同批 ⇒ 排下一次合法重启,中台 #40(🔴 未修,且经 ①④ 修完后的step47复跑确认它是当前唯一残留违规:汇总「违规 1 处/偏松告警 0 处」,退出码仍为 1)。 - ③ 一条运行事实:
04:00–05:42之间dsh-feishu-aiad被其他会话 restart 11 次(OPS_CHANGELOG 可辨是 G5 插件与 G8 feedback 通道的部署),我方06:58那次是第 12 次,也是 0.8.2 唯一需要的一次;已复核上线后 10 份副本五件 md5 仍等于闸门 Z ⇒ 不影响本次部署判定,但违反红线⑳「改动攒批、一次完成」,已建议约定「动 aiad 服务前先查中台租约」,中台 #41。 - ④ main 单元的运行态权限偏松:为补闸门新写的只读巡检
output/server-inspect-20261004/step47-check-runtime-perms.sh首跑抓到(存档output/step47-perms.log:违规 2 处/偏松告警 14 处/未落运行态的单元 0 个,退出码 1)——/home/ubuntu/.dsh-feishucard是 775(组外开放穿越),其下 9 个state-*.json与message-index.json全是 644,即会话内容对 others 全开。与 ①② 的区别是这条落在告警级(实测不可达),已投中台 #42(medium)。判定同样走实测而非看权限位:www-data/aiad/nobody/agtagents逐一试读 1 字节一律Permission denied,namei链显示卡点在/home/ubuntu那一级是drwxr-x---(750)⇒ 当前不是正在泄露,但形态与 ② 同族(靠上一层权限挡着),一旦有人善意放开/home/ubuntu立刻全局可读。修法一条chmod 700+chmod 600,属主不变 ⇒ 不需重启、可逆。🔴 闸门自身经一次独立审查(判定 BLOCK,三条 critical 全是假绿通路)后重写:sudo -u <probe> test -r会把「sudo runas 被拒」和「读不到」混成同一个非 0 ⇒ 真泄露会被静默降级成告警,所以现在实测失败一律记违规(远端入口sudo -n true不通即整体红、每单元先做 runas 对照、无可用探针返回UNKNOWN按违规处理),且每个单元都打出「可用探针」清单可核。⚠️ 那 14 处若按权限位直接定级,会被写成 14 条「真泄露」。✅ 2026-10-06 已修(CM 授权):目录775→700、9 个运行态文件644→600,属主未动 ⇒ 未重启;复测两条判据同时成立——sudo -u ubuntu test -w对全部运行态文件仍可写(服务没被打断)、www-data已读不到message-index.json;step47复跑「偏松告警 14 → 0」。同批把 aiad 侧 5 个664的运行态文件一并收成660(同类追加,CM 总则 #36「能不开的都不开」),复测仍可写。 - ⑤
bot_roster.json没有任何调度在采它:为取证 roster 合并语义而排查调度面,实测/etc/cron.d/(12 个文件)、/etc/crontab、/etc/cron.*/、root/aiad/ubuntu 三份 crontab、systemctl list-timers(16 个)一律查不到采集条目;index.js只读名单(index.js:4136)不生成,10 份部署副本scripts/里那份采集脚本没有任何调用方 ⇒ 跨 bot 互认(真 @、认出发话者)依赖的这张表只会静默变旧,退群/新群/改名都不会自动反映,也没有报警(解析不到只打index.js:4187一行roster miss)。同批还查到:线上名单07:00:41被重写过(mtime=ctime,符合脚本「写临时文件再 rename」形态),但 root/aiad/ubuntu 三份 shell history 与/root/OPS_CHANGELOG.md都没有这次操作 ⇒ 谁跑的查不出来,与 ③(多会话并发、互相不知情)同族。修法两步、都不需重启:挂/etc/cron.d/dsh-roster(脚本头部纪律 ≤3 次/天 ⇒ 建议每天 1 次,aiad身份、--config/--out指 aiad 那份)+把bots/people/chats三计数落日志让退化可见。⇒ 中台 #44/功能基线缺口 #13,✅ 2026-10-06 已修(CM 授权后新增/etc/cron.d/dsh-roster,root:root 644,内容为每天 1 次(30 4,远低于脚本头部 ≤3 次/天的纪律上限)以aiad身份带显式绝对路径跑collect_bot_roster.mjs --config/--out指 aiad 那份、输出追加/var/log/dsh-roster.log(aiad:aiad 640)——这里必须显式带路径,因为 §上一条评论记录的那个坑:cron 不继承 unit 的Environment=,用默认路径的 cron 会天天失败退出)。挂前逐条验过可执行前提:cron单元 active、/usr/local/bin/node对 aiad 可执行、脚本本身可读、日志可写、文件末尾换行 OK;挂后step47复跑显示bot_roster.json为aiad:aiad 600且运行用户可写。没有预跑采集——当天已发生 2 次采集(07:00:41那次来源不明 + 我07:41的取证探针),留给 04:30 的调度自己开口。
- ① hr 的会话状态写不进盘:
- ✅ 三件已授权服务器修正落地(2026-10-06,CM「三件都做」):
step48-apply-perm-fixes.sh(存档同目录,前置快照step48-perm-before.txt+ 首跑快照step48-perm-before-run1.txt)一次跑完rc=0⇒ #39 chown、#42 chmod 700/600、#44 挂/etc/cron.d/dsh-roster;两单元全程active、长连接 5 个 helper 未断(脚本设计为不 restart,故未重启)。复测用同一把只读闸门step47(存档step47-perms-postfix.log):违规 2 → 1、偏松告警 14 → 0,剩下那条就是 #40。🔴 本批踩到一个自己造的坑,值得记下来:首跑里 #39 与 #44 被静默跳过并报告「文件不在预期位置」——真根因是判据写成[ -f "$HR" ],而这条命令是以ubuntu身份执行的,目标在drwx------ aiad的目录里 ⇒ 非特权账号对"存在但无权限进入"的路径一律返回 false,看起来像文件缺失。改成sudo -n test -f后两项都正常执行。同批第二个坑:快照用find -printf '%a'取到的是访问时间不是权限位(应为%m),首跑那份快照里根本没有权限形态 ⇒ 重跑前先把首跑快照另存,避免把"改后状态"覆盖成"改前基线"。两条都是判定方式错而非东西不存在,与 A25「验证要用生产的方式」同源。 - ✅ 0.8.2 生产回归五场景自验(2026-10-06 13:4x–13:5x,CM 破例授权「用现有机器人发」,例外与测试消息 id 已记
/root/OPS_CHANGELOG.md): 单聊✅(om_x100b637cd03cc0a8c4f6e1ee5a646a1→ 正常回卡);群 @✅(Ai内部测试群oc_0240dfe292c3055ac1feac327d5aa63a,om_x100b637ceee3d0a4dfaa4992eb5974d); 无 @ 丢弃✅,且顺带证伪了「多 bot 同群串扰」的担心——同群双 bot 时只有被 @ 的那个响应,飞书open_id是按应用隔离的(同一个 bot 在不同 app 下是不同ou_),桥的按 bot @ 判据在真机口径正确;/switch✅(stable 模式 bot 按设计不进会话切换流程,回一张拒绝卡;⚠️ 本会话踩到 harness 坑:Git Bash 把/switch转成C:/Program Files/Git/switch,须MSYS_NO_PATHCONV=1,属发信方式问题不是产品问题); 审批卡⚠️ 无触发面 ≠ 已验证——线上 5 个 bot 的 config 全部没有approvalForm键(员工档,按 #36 总则本就不开),且mode未设 danger-full-access ⇒ 生产上点不出这张卡,本场景判"取不到证据",不记为通过(与 M1/M2 同一口径)。 🔴/model顺带查出三条真缺陷(CM 的急件「卡片里切换模型从来没成功过」):0.8.2 的切换机制本身已在生产生效(/model deepseek-official/deepseek-v4-pro→ 日志selectModel ok、current=随即变成 v4-pro、卡上 ▶ 跟着移),失效的是边界三条——非清单模型名宿主抛英文原文、卡面<provider>/<model>占位符被飞书当标签吞成「/model /」导致用户按猜的写法发裸模型名而静默重发一张卡、文字档失败一行日志都不留。已投中台 #47(high),三条修复+冒烟用例 104 归 0.8.3 批次。测试后 analyst 会话模型已恢复为宿主清单内的deepseek-official/deepseek-flash(原值deepseek-v4-flash不在清单里,切回必被宿主拒绝——这条本身就是缺陷 A 的实证)。 - ✅ 0.8.3 定版 = 闸门 AB(2026-10-06,17 步全
RC=0,日志output/gate083ab.log+ 清单output/bytes083ab.txt):全量冒烟 831 ✅ / 0 ❌、SMOKE PASS (sentCards=519, sessions=33),SMOKE_COLD三变体(form/notice/goal-off)各跑一遍同绿,STEP9 与 manifest 与现场字节八件全等、锁目录已释放。八件里本批只动三件:index.jsc73d76f209dac25c106b57ed6ce09633(598909 B)、scripts/smoke.mjsee4f10feeff783a716dc44d655c6b541、package.jsonba0b033ab3b42b333ed9aa7e1a4cf851。🔴 闸门字母 AA 已作废(不是脏,是被超越):AA 那次跑完之后为了收紧一条弱断言又动了scripts/smoke.mjs的字节 ⇒ 按「单次干净运行 + 跑完后字节没再动过」的口径整轮不作数,定版字母顺延为 AB。⚠️ 本会话自己踩的 A28 坑(已记 CHANGELOG):AB 的第一次尝试被 Bash 工具的 10 分钟上限杀掉(日志冻结在冒烟中段),setsid nohup也随 shell 一起死,最后只有 PowerShellStart-Process起的形式活下来——长任务必须脱离进程启动。 独立审查门槛两轮:第二十一轮(AA 字节)WARN ⇒ 4 条核实为真并修掉(出站回显脱敏echoSafe、清单读不到时不断言"清单里没有"、sendModelPicker空清单跟进语的第二个尖括号、日志标签统一),1 条按理由拒绝;第二十二轮(AB 字节)PASS ⇒ 仅 1 条 low(sampleChoicePath()空清单分支仍返回字面provider/model),核对为当前不可达的死分支后拒绝改动(三道提前返回在index.js:7365/7425/7474,本批字节已冻结,为一条不可达分支再作废一次闸门不值)。反证第三轮(output/negctrl104f-20261006.log,用 AA 的生产方式跑同一套冒烟):新断言 5 红 / 3 绿——红的证明这 5 条不是恒真断言,3 条绿逐条给出判定(1 条前提断言 + 2 条"两版都该成立"的回归护栏,机制是 @all 展开后仍留id=all,所以"没有@"与"保留 all"在两版同真)。用例 104 由 16 条扩到 24 条。 - ✅ 0.8.3 已上线并真机回归(2026-10-06 16:09 部署 + 16:15–16:17 真机验证,全部实测):
step49-deploy-083.sh单次DEPLOY_RC=0(日志output/deploy083-step49.log)⇒ 10 份副本 × 5 件运行文件全部落在 AB md5(每份先存.bak-pre083)、逐份node --checkOK;中台 #40 同批收口(/srv/aiad/.dsh-feishucard/feishu.config.jsonroot:root 644→aiad:agtagents 600,改前改后各实测一次「服务用户 aiad 仍可读写自己的配置」);一次systemctl restart dsh-feishu-aiad dsh-feishu⇒ 两单元active、长连接 aiad=4 / main=1、drain error各 0、5 个 helper 启动时间全为16:09:51、ps里明文凭证行数 = 0;两个实例的[fs] plugin apply自报v0.8.3 md5=c73d76f2 bytes=598909(与 AB 的index.js同源,A25 口径);/root/OPS_CHANGELOG.md已追加。🔴 踩坑一条:预检第一次报MISMATCH index.js 本地=c73d76f2 闸门=9a837813——根因是部署脚本MANIFEST默认值还指着已作废的 AA 清单(我此前"已经改过了"的记忆是错的),改默认值为bytes083ab.txt后预检rc=0。 真机三条取证(CM 破例授权「用现有机器人发」的口径沿用,例外与三条测试消息 id 已记/root/OPS_CHANGELOG.md;lark-cli--as user从生产入口发,bot 只被动回复),会话=analyst 单聊oc_809f7c00ed97c0a2fcb41926642553bb: ① 缺陷 B(卡面提示被飞书吞成「/model /」)✅ 已消失——发/model(om_x100b637eacbffca4c345a5c0a54eebb)回卡正文尾部现在是「点一下即切换;也可发文字:/model provider/model(例:/model deepseek-official/deepseek-flash)」,真例路径完整可见;日志[fs] /model: choices=2 providers=deepseek-official=2。 ② 缺陷 C(裸模型名静默重发一张卡)✅ 已修——发/model deepseek-flash(不带 provider,om_x100b637eab2cb8a4c29bd80ffac4ea5)⇒ 回卡「✅ 模型已切换为deepseek-official/deepseek-flash(下一次请求开始用)」,日志[fs] /model: selectModel ok deepseek-official/deepseek-flash session=fs-main-muvrahja(反查 provider 生效,且没有多发一张选择卡)。 ③ 缺陷 A(宿主英文原文 + 失败零日志)✅ 已修——发/model nosuchmodel-xyz(om_x100b637ea6bb40a8de2a63db917a457)⇒ 回卡中文「没找到模型nosuchmodel-xyz(宿主可用清单里没有这个名字)…」无Select an available model…原文,且日志新增一行[fs] /model 文字档无法解析: arg=nosuchmodel-xyz → 没找到模型…(0.8.2 的失败路径一行都不留,这行就是"留痕"本身)。线上模型状态测后仍为deepseek-official/deepseek-flash(与测前一致,无需复原)。 ✅ 原「唯一未取证项」已由 CM 真机点击销项(2026-10-06 16:5x):点卡上按钮 ⇒ 同一张卡原地 PATCH 成「✅ 已切换模型」(index.js:7447-7458)——飞书的card.action.trigger只能由真人点击产生,程序无法伪造,故本地证据(冒烟用例 104 的「点卡不发新卡」断言 + 反证非恒真)之外必须等一次真人点击。CM 点击后原话:「切换模型组件现在正常能切换模型,它也能更新卡片,这个验证通过」⇒ 判据成立,0.8.3 本批取证全清。出处=CM 本会话口述,无独立消息 id;机器侧复核入口journalctl -u dsh-feishu* | grep '/model click'。 🔴 定版字节与运行态的独立复核(2026-10-06 16:39,只读脚本output/server-inspect-20261004/step50-verify-083-live.sh,存档output/step50-verify-083-live.log):不复用部署日志,重新遍历那 10 份副本 ⇒index.js10/10 同一个 md5c73d76f2…、helper.cjs10/1062ac162d…、package.json10/10"version": "0.8.3";两单元active,16:09:50之后long connection ready计数 aiad=4 / main=1,同窗口 error 级 0 行;/srv/aiad/.dsh-feishucard/feishu.config.json仍为aiad:agtagents 600且实测服务用户 aiad 可读(#40 的收口状态没被后续动作回退);ps里appSecret=命令行形态 0 行。⚠️ 另记一条与 Git 无关的发布面缺口:npm registry 上dsh-feishucard最高仍是 0.7.21(npm view … versions实测[0.1.0, 0.3.0, 0.7.21])⇒ 0.8.0~0.8.3 从未发布到 npm,本仓库是"发给外部"的定位与此不匹配;发布属对外动作,未获 CM 明确授权前不推。 ✅ 发布面缺口已清(2026-10-09,CM 裁决「发布并推送,要用专门技能」):dsh-feishucard@0.8.4已发布到 npm registry——先npm publish --dry-run审上传清单(12 文件 500.5 kB,无敏感内容),正式发布后双通道验证dist-tags latest=0.8.4、registry 全版本['0.1.0','0.3.0','0.7.21','0.8.4'];本机 npm 身份cmfok。与 0.7.21 之间跳过的 0.8.0-0.8.3 不补发(版本号单调递增合规,历史版本无回补需求)。 - ✅ 0.8.4 定版 = 闸门 AC(2026-10-08,17 步全
RC=0,日志output/gate084ac.log+ 清单output/bytes084ac.txt):全量冒烟 959 ✅ / 0 ❌、SMOKE PASS (sentCards=637, sessions=45);SMOKE_COLD三变体(form/notice/goal-off)各跑一遍同绿;scripts/test-collect-roster.mjs12 格 / 44 条全绿;反证三轮精确归因(M4b 3 红 / M5 1 红 / M6 2 红)+ NEG(把index.js换回 0.8.3 字节)红集对照=NEW 只允许新增判别格、GONE 必须为 0;STEP0==STEP9==工作区字节、锁目录已释放。本批运行面只动两件:index.jscadbab1cf74e3957822fc68420737737(707798 B)、package.json9aa0151a95fb74bfbc04afca5a62fac6。 门槛账(CM 2026-10-08 分层裁决 L1/L2):本批外部全量门槛(ocr)累计 31 轮(10-07 02:44 → 10-08 10:28)=1 BLOCK / 25 WARN / 5 PASS、4.31 亿 token、约 299 分钟、247 条 findings(0 critical / 2 high / 70 medium / 175 low),第 52 轮 PASS(0 中危 / 4 低危)为终态外部证据;第 53 轮起账号余额被跑空(402 Insufficient Balance)⇒ 经 CM 裁定本次定版不再跑外部门槛,改用 L1(内置 CodeReview 子代理审终态字节,结论=无 P0–P2 级问题)。第 52 轮那 4 条 low 中,2 条按「核对后不改、理由落在注释里」处置(index.js:4543的loadGrants只取rows、index.js:10559的 teardown 不清pendingCapRequests)。分层规则已回写AIAD/.qoder/rules/always-code-review.md+code-review-gate技能,L2 起用前必须报「原因/目的/是否有必要/成本」。 - ✅ 0.8.4 已上线(2026-10-08 16:50,CM「三个都批准,不跑代码审查」= 定版推送 + 部署):
step50-deploy-084.sh单次rc=0(日志output/deploy084-step50.log)⇒ 现存 9 份副本 × 5 件运行文件全部落在 AC md5(每份先存.bak-pre084)、package.json9/9"version": "0.8.4"、逐份node --checkOK、scripts/collect_bot_roster.mjs在 8 份缺失副本上补建成功;一次systemctl restart dsh-feishu-aiad dsh-feishu⇒ 两单元active、长连接 aiad=4 / main=1、drain error各 0、mount failed各 0、5 个 helper 启动时间全为16:50:05 / 16:50:10(本次重启之后)、ps里明文凭证行数 = 0;两个实例的[fs] plugin apply自报v0.8.4 md5=cadbab1c bytes=707798(与 AC 的index.js同源,A25 口径);/srv/aiad/.dsh-feishucard/feishu.config.json复核仍是aiad:agtagents 600(#40 未回退);/root/OPS_CHANGELOG.md已追加(2026-10-08 08:50 UTC行)。 🔴 副本数 10 → 9 的落差已查明,不是漏覆盖:第 10 份/opt/dshprof/profiles/feishu/node_modules/dsh-feishucard所在目录/opt/dshprof已退役——/root/OPS_CHANGELOG.md2026-10-08 12:36那条(G8#18 尾巴清理)明确记录/home/ubuntu/.dsh/node_modules下 240 条软链全部指向已删除的/opt/dshprof;本次部署前重新find /srv /home /opt -type d -name dsh-feishucard⇒ 全机只剩 9 个目录,脚本按目录遍历覆盖 ⇒ 对现存副本是 9/9,零遗漏。 ⚠️ 中台 #56(streamIdleTimeoutMs5→2)未随本批做,实况回写池内:运行 profile/srv/aiad/.dsh/profiles/feishu/cordis.patch.yml里根本没有这个键,全机唯一出现处是 SDK 样例/opt/dsh/node_modules/@deepseek-ai/dsh-sdk-minimal/cordis.patch.yml:31(值172800000=48 小时,不是"5")⇒ 找不到"5"在哪就不瞎改;且该 profile 的cordis.patch.yml当天 07:51 刚被别的操作者改过(aiad:agtagents),盲写会覆盖他人动作。 ✅ #56 已钉死并落地(2026-10-09,CM 裁决 7C):源码级核查钉死参数形态——streamIdleTimeoutMs是@deepseek-ai/dsh-llm-deepseek/dsh-llm-pi-ai适配器真实配置键,默认 300000ms(5 分钟)、字段 volatile、值域 (0, 2147483647];「5→2」是「5 分钟→2 分钟」的单位丢失简写(300000→120000ms,出处 CHANGELOG 框架侧建议,经会话交接转抄变形),从来不存在字面量 5,本机找不到属必然。实况:feishu profile 走llm-pi-ai(非 deepseek 适配器),已在zai-coding-cnprovider 段插入streamIdleTimeoutMs: 120000(备份cordis.patch.yml.bak-20261009,sudo YAML 解析验证 1 处值=120000),未重启桥——下次systemctl restart dsh-feishu*时生效;按 CHANGELOG 原定性它只防「流完全无数据」,防不了「有零星数据但零进展」的黑洞。 - ✅ 第七轮三条服务器挂账(M1/M2/M3)取证收口(2026-10-06 07:4x,全程只读):M3 已用生产真实配置取证通过——把线上
bot_roster.json复制成/tmp探针文件、用已部署的那份脚本+生产同一份 config 打真接口跑一遍(合并逻辑读的就是--out目标 ⇒ 线上名单一个字节不动,md5 前后同为ab7be3c2…):四个 bot 全采成功(hr 2 群/analyst 6/okr 1/knowledge 5,去重chats=9),bots=4 people=15 chats=9不变、每个 bot 的views键一个没少、29 行逐条摘要diff为 0 行 ⇒ 「再采一遍不抹views」成立;桩侧那条views断言仍缺(挂 0.8.3)。M1/M2 结论是「取不到证据」而不是「验证通过」:全 journal 保留期内cred file write failed0 行、CONCLUSION LOST0 行、relay 相关 0 行,重启后 error 级行 0、5 个 helper 1 秒内全连上 ⇒ 这两条路径在生产上从未被走到;M2 按预防性修复挂账,M1 补断言要改scripts/smoke.mjs(闸门 Z 八件字节之一)⇒ 与缺口 #10 同批,不塞进 0.8.2。详见 CHANGELOG「本轮发现但不在本版修」。
License
MIT