dsh-im
xmanrui
通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。 Connect IM bots to DeepSeek Harness via QR code or credentials (9 channels).
cmfok/dsh-feishucard
DSH <-> Feishu (Lark) bridge, self-developed (not a fork): streaming reply card / many bots in ONE instance (each with own workspace + AGENTS.md) / mention-only group replies / approval cards / /switch workspace & session picker / identity injection / rate-limit + circuit-breaker fallback. 自研 DSH 飞书桥:流式回复卡|单实例多 bot|群里 @ 才回复|审批卡|工作区切换|身份注入
PROJECT TOPICS
INSTALL REFERENCE
dsh plugin --profile web add github:cmfok/dsh-feishucard
该命令指向仓库当前默认分支;尚无绑定当前 commit 的完整验证结果。
PROJECT README
把飞书(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记"哪条判据由哪个用例钉住"。
| 功能 | 说明 |
|---|---|
| 流式回复卡片 | 过程话语内联 + 工具调用折叠面板(带状态符号)+ 内联 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 仓库)。
长连接收发 / Long-connection messaging:im.message.receive_v1 官方 SDK WebSocket → 注入 Agent 会话 → 交互卡片回复同一会话,全程无需公网地址。Official SDK WebSocket; no public IP, domain, or tunnel required.
流式回复卡片 / Streaming reply card:
每聊天独立会话 / 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 关闭)。OnIt reaction 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 推导,不再写死)、会话取消自动取消——避免飞书通道下审批无人应答导致会话永久挂起。Approval approval/request for 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-empts approval/request at the root scope; the plugin now wraps the
approval service's decide() 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 at danger-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; an agent/status hook 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 先发工作区卡 —— 列出 DSH workspaceRegistry 里注册的工作区(与 GUI 侧边栏同源),每行显示路径、会话数、🟡 运行中数量、目录是否还在(⚠️ 不存在),并标出 ▶ 当前工作区,按钮「N 进入看会话」/「N 在这里新建」;点「进入」(或 /switch <工作区序号>)再发该工作区的会话卡,每行「接管 / 新建」,底部「← 返回工作区列表」。🟡 运行中的会话只给"新建"(同一会话被两处同时驱动会写坏历史)。文字兜底 /switch <工作区序号> [<会话序号>|new];/list = 当前工作区的会话(当前工作区 = 活跃会话的 cwd)。新建/接管时会话会挂进工作区注册表(best-effort)⇒ GUI 侧边栏也立刻看得到。/switch posts a workspace card first (from the DSH workspaceRegistry, the same source the GUI sidebar uses), then that workspace's session card; text fallbacks /switch <ws> [<session>|new], and /list lists the current workspace's sessions.
文件收件 / File inbox(2026-09-09):飞书文件/图片/语音消息不再被静默丢弃——自动下载到 fileInbox(配置项,缺省 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-bot notifyAgentNotices:false disables 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-level user-questions/request takeover, 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_question option buttons now carry a type (primary / danger / default) instead of all being grey.
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:<本包目录>。
写在 ~/.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.
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 → add im.message.receive_v1飞书开放平台 ⇄ 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.agents.list() 命中 → 上下文保留),其次 resume 持久化会话,最后才新建。Reuse live sessions first, then resume persisted ones, create only as a last resort.POST /im/v1/messages 创建 → PATCH /im/v1/messages/{id} 更新 → sealed 终态。默认关闭。不开就完全不碰身份链路:不查表、不调 python、不写上下文、不拦工具,零副作用。
飞书入站事件里带的 open_id 是服务端填的、伪造不了;而消息正文里任何人都能打一段
「[飞书 ou_…]」来冒充。开着这道闸门时:
open_id 查身份表 ⇒ 得到本轮 actor,存进本轮上下文(按 agentId,带 TTL);非飞书回合(GUI / 子代理 / 定时任务)一律放行 —— 否则会把你自己的电脑锁死。
在 bot 配置(feishu.config.json)里加一项:
{ "bots": [ { "name": "work", "appId": "cli_…", "appSecret": "…", "identityGuard": true } ] }
⚠️ 该字段已进配置白名单;不写就是关。
按顺序取第一个存在的:
MAILBOX_IDENTITY_MAPidentity_map.json(服务器上的固定位置;想换路径就设 MAILBOX_IDENTITY_MAP)<你的工作区>/output/g9-identity/identity_map.json每台机器可另放一份【本地增量】 ~/.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(明细行只进会话上下文,卡片上不出现):
| 信息 | 形态 | 来源 |
|---|---|---|
| 这条消息谁发的 | 前缀 [飞书 姓名] + 尾部 【发送方】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 消息生效,人发的消息永远不吃预算。
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.
发版由维护者的私有工具链执行 —— 发布脚本与内部规则不随本仓库分发 (公开仓库里只留机制说明,避免把内部流程与措辞带进发布物)。
每次发版强制 四条硬闸门,任何一条不过就拒绝执行(绝不静默跳过):
masterCHANGELOG.md 必须已有 ## [<version>] 段 —— 这是防「漏写 CHANGELOG」的关键另有一道发布物完整性闸门:递归比对「代码里相对 import/require 的文件」与 package.json 的 files,
缺任何一个就拒绝发版(同一检查也进了 CI,每次 push / PR 都会跑)。
为什么必需:本地冒烟绿 ≠ 发出去的包能跑 ——
实测过 files 漏一个被相对导入的文件,本机全绿,而 npm i 装到别人机器上直接 failed to import。
为什么要有闸门:发版要做五件事,全靠人记就必漏。实测后果是 CHANGELOG 缺了一版、 tag 从
v0.3.0之后再没打过 ⇒ 仓库首页的 Release 停在两个月前 ⇒ 看起来像停更。
推送前过一道独立的 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 全真、零误报)。本节把散在台账/CHANGELOG 里的权限类裁决固化成速查,agent 不用再翻台账或问 CM:
bot.cfg.mode(缺省 full),群聊一律 stable。stable 折叠过程叙述,/switch 直接拒绝——员工一个会话就够(CM 裁决:普通员工切会话默认关闭、不能打开)。approvalForm。identityGuard 默认关(0.7.18,本仓库会发布给外部)。开启后:按飞书服务端 open_id 查身份表覆写身份字段,拿不到身份 ⇒ 拒绝执行(fail-closed——CM 2026-10-04:「执行不了总比资料泄露好」)。feishu.config.json(/home/agt*×7 与 /home/ubuntu 各 1 个 bot、/srv/aiad 4 个 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 打点观察,再逐个推进。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 口径这不作为部署门槛,见功能基线第十五轮门槛段)。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 上一直是"目录缺席"的降级态。state-clia97b99468779dbb4.json(appId cli_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。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 的调度自己开口。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「验证要用生产的方式」同源。/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 的实证)。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.js c73d76f209dac25c106b57ed6ce09633(598909 B)、scripts/smoke.mjs ee4f10feeff783a716dc44d655c6b541、package.json ba0b033ab3b42b333ed9aa7e1a4cf851。🔴 闸门字母 AA 已作废(不是脏,是被超越):AA 那次跑完之后为了收紧一条弱断言又动了 scripts/smoke.mjs 的字节 ⇒ 按「单次干净运行 + 跑完后字节没再动过」的口径整轮不作数,定版字母顺延为 AB。⚠️ 本会话自己踩的 A28 坑(已记 CHANGELOG):AB 的第一次尝试被 Bash 工具的 10 分钟上限杀掉(日志冻结在冒烟中段),setsid nohup 也随 shell 一起死,最后只有 PowerShell Start-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 条。step49-deploy-083.sh 单次 DEPLOY_RC=0(日志 output/deploy083-step49.log)⇒ 10 份副本 × 5 件运行文件全部落在 AB md5(每份先存 .bak-pre083)、逐份 node --check OK;中台 #40 同批收口(/srv/aiad/.dsh-feishucard/feishu.config.json root: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.js 10/10 同一个 md5 c73d76f2…、helper.cjs 10/10 62ac162d…、package.json 10/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 明确授权前不推。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 failed 0 行、CONCLUSION LOST 0 行、relay 相关 0 行,重启后 error 级行 0、5 个 helper 1 秒内全连上 ⇒ 这两条路径在生产上从未被走到;M2 按预防性修复挂账,M1 补断言要改 scripts/smoke.mjs(闸门 Z 八件字节之一)⇒ 与缺口 #10 同批,不塞进 0.8.2。详见 CHANGELOG「本轮发现但不在本版修」。MIT
CLASSIFICATION EVIDENCE
系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。当前命中: chatbot、feishu、lark、lark-bot。