dsh-lark-bot 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,11 @@ dsh --profile dsh-lark
85
85
 
86
86
  ③ 首次启动终端打印二维码 → 飞书 / Lark App 扫码创建或选择 PersonalAgent 应用 → 绑定后私聊直接发消息;群聊 / 话题默认 `@bot`,也可显式开启受白名单保护的无 @ 模式。
87
87
 
88
+ 卡片按钮按 Card JSON 2.0 的 `behaviors.callback` 协议发送;扫码向导会显式申请
89
+ `card.action.trigger` 回调能力。这两项都是计划、审批、问答等交互卡片按钮正常工作的
90
+ 必要条件。已有应用若是在旧版本向导中创建,请在飞书开放平台的“事件与回调 → 回调配置”中启用
91
+ 卡片回调后重新发布应用,否则消息收发正常但按钮点击不会送达 bot。
92
+
88
93
  `setup` 自动完成:定位本机 dsh → 预批准 pnpm 构建策略 → 标准 `dsh plugin add` → 默认安装「安全网守护」系统服务,一条命令完成全部安装。
89
94
 
90
95
  > **无需公网 IP / 域名 / 服务器 / 内网穿透**(飞书 WebSocket 出站长连接),Linux / macOS / Windows 通用。
@@ -235,7 +240,7 @@ guardian 仍只救援其配置的主实例。
235
240
 
236
241
  **任务中向你提问(问答卡)**:agent 需要你拍板、确认或补充信息时,通过 `lark_ask_user` 工具弹**问答卡**(单选 / 多选 / 自由文本)。可提交卡片,也可直接回复该卡片输入任意文字;单选/多选没有合适项时,回复文字就是补充答案。系统按被回复的 card messageId 精确匹配 pending 问题,回答后任务自动继续,等待期间运行超时看门狗暂停。(与 `/ask` 的“你主动提问”方向相反。)
237
242
 
238
- 计划、审批与问答卡提交后会立即显示成功提示、发送一条终态确认并撤回原卡,避免按钮仍停留在聊天中造成“未生效”的误解;确认或撤回失败不会影响已经提交给 agent 的决策、审批结果或答案。
243
+ 计划、审批与问答卡提交后会立即显示成功提示、发送一条终态确认并撤回原卡,避免按钮仍停留在聊天中造成“未生效”的误解;失效卡会返回明确错误提示,入站点击与失效原因写入结构化日志。确认或撤回失败不会影响已经提交给 agent 的决策、审批结果或答案。本地人机决策回调会以 JSON 空白流保活,避免 Node HTTP 客户端在等待 5 分钟后切断仍有效的卡片。
239
244
 
240
245
  **安全网守护**:独立于 dsh 进程、系统级常驻的最小守护进程(systemd / LaunchAgent / Windows 启动项),默认随 `setup` 安装。dsh 正常时静默;dsh 下线或无法 boot(如第三方插件破坏 profile 组合)时自动接管飞书通道,无需命令行即可自救:
241
246
 
package/README_EN.md CHANGED
@@ -87,6 +87,12 @@ dsh --profile dsh-lark
87
87
 
88
88
  ③ On first boot the terminal prints a QR code → scan it with the Feishu / Lark app to create or choose a PersonalAgent app → after binding, DM the bot directly; groups/topics use `@bot` by default, with an explicitly enabled allowlist-protected no-@ mode available.
89
89
 
90
+ Buttons use Card JSON 2.0 `behaviors.callback`, and the QR flow explicitly requests the
91
+ `card.action.trigger` callback capability required by plan, approval, and
92
+ question-card buttons. If an existing app was created by an older flow, enable card callbacks under Developer
93
+ Console → Events & Callbacks → Callback Configuration and publish the app again. Without it, messages still work
94
+ while card clicks never reach the bot.
95
+
90
96
  `setup` automatically: locates your local dsh → pre-approves pnpm's build policy (protobufjs) → runs the standard `dsh plugin --profile dsh-lark add dsh-lark-bot@<version>` (pinned to the running package) → installs the safety-net guardian system service. One command installs everything.
91
97
 
92
98
  > **No public IP / domain / server / tunneling required** (Feishu outbound WebSocket long connection); works on Linux / macOS / Windows.
@@ -239,6 +245,8 @@ stopping it cancels and recalls only that session's pending card. The legacy hea
239
245
 
240
246
  **Mid-task questions (question cards)**: when the agent needs a decision, confirmation, or missing information, it sends a **question card** via the `lark_ask_user` tool (single choice / multi choice / free text). Submit the form or reply directly to that card with any text—even when none of the listed choices fits. The replied card message id selects the exact pending question, the agent resumes automatically, and the run-timeout watchdog pauses while it waits. (The opposite direction of `/ask`, where you ask the agent.)
241
247
 
248
+ Plan, approval and question-card submissions immediately show a native toast, post a terminal confirmation and recall the original card. Stale cards return an explicit error toast, while received actions and stale reasons are written to structured logs. Confirmation/recall failures do not change the decision already delivered to the agent. Local human-decision callbacks stream insignificant JSON whitespace while waiting so Node's HTTP client cannot invalidate a live card after five minutes.
249
+
242
250
  **Safety-net guardian**: a minimal system-level resident process (systemd / LaunchAgent / Windows startup), independent of the dsh process and installed **by default with `setup`**. Silent while dsh runs, it takes over the Feishu channel when dsh goes down or fails to boot (e.g. a third-party plugin breaks the profile composition), so you can self-heal without touching the command line:
243
251
 
244
252
  - `/safemode`: enter **core-only safe mode** (only the official `dsh-base` + `dsh-headless` bundles, **no third-party plugins**) — prefers the SDK streaming engine, falls back to headless, and lets you locate / fix / disable the offending plugin right from the chat;
package/dist/cli.js CHANGED
@@ -6230,7 +6230,9 @@ function localizedCard(input) {
6230
6230
  if (JSON.stringify(zhCallbacks) !== JSON.stringify(enCallbacks)) {
6231
6231
  throw new Error("Localized card callback values must be identical");
6232
6232
  }
6233
- const body2 = localizeNode(input.zhCn.body, input.enUs.body, "body", input.bilingualFallback === true);
6233
+ const body2 = withV2CallbackBehaviors(
6234
+ localizeNode(input.zhCn.body, input.enUs.body, "body", input.bilingualFallback === true)
6235
+ );
6234
6236
  const header = input.zhCn.header ? localizeNode(
6235
6237
  input.zhCn.header,
6236
6238
  input.enUs.header ?? input.zhCn.header,
@@ -6255,6 +6257,24 @@ function localizedCard(input) {
6255
6257
  body: body2
6256
6258
  };
6257
6259
  }
6260
+ function withV2CallbackBehaviors(value) {
6261
+ if (Array.isArray(value)) return value.map(withV2CallbackBehaviors);
6262
+ if (!value || typeof value !== "object") return value;
6263
+ const source = value;
6264
+ const mapped = Object.fromEntries(
6265
+ Object.entries(source).map(([key, child]) => [key, withV2CallbackBehaviors(child)])
6266
+ );
6267
+ if (source.tag !== "button" || source.value === void 0) return mapped;
6268
+ const { value: callbackValue, ...button2 } = mapped;
6269
+ const behaviors = Array.isArray(button2.behaviors) ? button2.behaviors : [];
6270
+ return {
6271
+ ...button2,
6272
+ behaviors: [
6273
+ ...behaviors,
6274
+ { type: "callback", value: callbackValue }
6275
+ ]
6276
+ };
6277
+ }
6258
6278
  function bilingualMarkdown(zhCn, enUs) {
6259
6279
  if (zhCn === enUs) return zhCn;
6260
6280
  return `${zhCn}
@@ -12491,6 +12511,13 @@ ${msg.content}`,
12491
12511
  message: processMessage,
12492
12512
  cardAction: async (event) => {
12493
12513
  const value = event.action.value && typeof event.action.value === "object" ? event.action.value : void 0;
12514
+ const command = typeof value?.cmd === "string" ? value.cmd : void 0;
12515
+ log.info("card-action", "received", {
12516
+ chatId: event.chatId,
12517
+ messageId: event.messageId,
12518
+ operatorId: event.operator?.openId,
12519
+ command
12520
+ });
12494
12521
  const threadId = cardActionThreadId(event.raw);
12495
12522
  const currentScope = isolatedScope({
12496
12523
  chatId: event.chatId,
@@ -12564,7 +12591,15 @@ ${msg.content}`,
12564
12591
  if (value?.cmd === "approve" && typeof value.id === "string" && deps.approvals) {
12565
12592
  const outcome = value.outcome === "allow" ? "allowed-once" : "rejected";
12566
12593
  const settled = deps.approvals.resolve(scope, value.id, outcome);
12567
- if (!settled) return;
12594
+ if (!settled) {
12595
+ log.warn("card-action", "stale", { kind: "approval", scope, messageId: event.messageId });
12596
+ return {
12597
+ toast: {
12598
+ type: "error",
12599
+ content: "\u6B64\u5BA1\u6279\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u4F7F\u7528\u6700\u65B0\u5361\u7247 / This approval card is stale; use the latest card"
12600
+ }
12601
+ };
12602
+ }
12568
12603
  const allowed = outcome === "allowed-once";
12569
12604
  void settleActionCard(
12570
12605
  channel,
@@ -12592,7 +12627,15 @@ ${msg.content}`,
12592
12627
  question.options
12593
12628
  );
12594
12629
  const settled = deps.questions.resolve(scope, value.id, answer);
12595
- if (!settled) return;
12630
+ if (!settled) {
12631
+ log.warn("card-action", "stale", { kind: "question", scope, messageId: event.messageId });
12632
+ return {
12633
+ toast: {
12634
+ type: "error",
12635
+ content: "\u6B64\u95EE\u7B54\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u4F7F\u7528\u6700\u65B0\u5361\u7247 / This question card is stale; use the latest card"
12636
+ }
12637
+ };
12638
+ }
12596
12639
  void settleActionCard(
12597
12640
  channel,
12598
12641
  event.chatId,
@@ -12606,7 +12649,13 @@ ${msg.content}`,
12606
12649
  toast: { type: "success", content: "\u56DE\u7B54\u5DF2\u63D0\u4EA4 / Answer submitted" }
12607
12650
  };
12608
12651
  }
12609
- return;
12652
+ log.warn("card-action", "stale", { kind: "question", scope, messageId: event.messageId });
12653
+ return {
12654
+ toast: {
12655
+ type: "error",
12656
+ content: "\u6B64\u95EE\u7B54\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u4F7F\u7528\u6700\u65B0\u5361\u7247 / This question card is stale; use the latest card"
12657
+ }
12658
+ };
12610
12659
  }
12611
12660
  if (value?.cmd === "plan-submit" && typeof value.id === "string" && deps.plans) {
12612
12661
  const decision = value.decision === "approved" ? "approved" : "revise";
@@ -12616,7 +12665,15 @@ ${msg.content}`,
12616
12665
  decision,
12617
12666
  ...feedback ? { feedback } : {}
12618
12667
  });
12619
- if (!settled) return;
12668
+ if (!settled) {
12669
+ log.warn("card-action", "stale", { kind: "plan", scope, messageId: event.messageId });
12670
+ return {
12671
+ toast: {
12672
+ type: "error",
12673
+ content: "\u6B64\u8BA1\u5212\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u4F7F\u7528\u6700\u65B0\u5361\u7247 / This plan card is stale; use the latest card"
12674
+ }
12675
+ };
12676
+ }
12620
12677
  const approved = decision === "approved";
12621
12678
  void settleActionCard(
12622
12679
  channel,
@@ -12931,7 +12988,7 @@ var NotifyServer = class {
12931
12988
  }
12932
12989
  async handle(req, res) {
12933
12990
  const respond = (status, body2) => {
12934
- res.writeHead(status, { "content-type": "application/json" });
12991
+ if (!res.headersSent) res.writeHead(status, { "content-type": "application/json" });
12935
12992
  res.end(`${JSON.stringify(body2)}
12936
12993
  `);
12937
12994
  };
@@ -12955,12 +13012,17 @@ var NotifyServer = class {
12955
13012
  respond(400, { ok: false, error: "sessionId and question are required" });
12956
13013
  return;
12957
13014
  }
12958
- const result = await this.deps.ask(payload);
12959
- if (!result.ok) {
12960
- respond(404, { ok: false, ...result.error === void 0 ? {} : { error: result.error } });
12961
- return;
13015
+ const stopKeepAlive = startJsonKeepAlive(res);
13016
+ try {
13017
+ const result = await this.deps.ask(payload);
13018
+ if (!result.ok) {
13019
+ respond(404, { ok: false, ...result.error === void 0 ? {} : { error: result.error } });
13020
+ return;
13021
+ }
13022
+ respond(200, { ok: true, ...result.answer === void 0 ? {} : { answer: result.answer } });
13023
+ } finally {
13024
+ stopKeepAlive();
12962
13025
  }
12963
- respond(200, { ok: true, ...result.answer === void 0 ? {} : { answer: result.answer } });
12964
13026
  return;
12965
13027
  }
12966
13028
  if (req.url === "/plan") {
@@ -12985,9 +13047,11 @@ var NotifyServer = class {
12985
13047
  };
12986
13048
  res.once("close", onResponseClose);
12987
13049
  let result;
13050
+ const stopKeepAlive = startJsonKeepAlive(res);
12988
13051
  try {
12989
13052
  result = await this.deps.plan(payload, controller.signal);
12990
13053
  } finally {
13054
+ stopKeepAlive();
12991
13055
  req.off("aborted", abort);
12992
13056
  res.off("close", onResponseClose);
12993
13057
  }
@@ -13024,9 +13088,11 @@ var NotifyServer = class {
13024
13088
  };
13025
13089
  res.once("close", onResponseClose);
13026
13090
  let result;
13091
+ const stopKeepAlive = startJsonKeepAlive(res);
13027
13092
  try {
13028
13093
  result = await this.deps.approval(payload, controller.signal);
13029
13094
  } finally {
13095
+ stopKeepAlive();
13030
13096
  req.off("aborted", abort);
13031
13097
  res.off("close", onResponseClose);
13032
13098
  }
@@ -13092,6 +13158,16 @@ var NotifyServer = class {
13092
13158
  }
13093
13159
  }
13094
13160
  };
13161
+ var JSON_KEEP_ALIVE_MS = 3e4;
13162
+ function startJsonKeepAlive(res) {
13163
+ res.writeHead(200, { "content-type": "application/json" });
13164
+ res.write(" ");
13165
+ const timer = setInterval(() => {
13166
+ if (!res.destroyed && !res.writableEnded) res.write(" ");
13167
+ }, JSON_KEEP_ALIVE_MS);
13168
+ timer.unref?.();
13169
+ return () => clearInterval(timer);
13170
+ }
13095
13171
  function readBody(req) {
13096
13172
  return new Promise((resolve6, reject) => {
13097
13173
  const chunks = [];
@@ -13619,6 +13695,10 @@ async function onboardPersonalAgent(deps = {}) {
13619
13695
  print("");
13620
13696
  const result = await register({
13621
13697
  source: deps.source ?? DEFAULT_SOURCE,
13698
+ // Card callbacks are a separate application capability in Feishu/Lark.
13699
+ // A WebSocket can be healthy and receive messages while silently never
13700
+ // receiving card.action.trigger unless this callback is enabled.
13701
+ addons: { callbacks: { items: ["card.action.trigger"] } },
13622
13702
  ...deps.signal ? { signal: deps.signal } : {},
13623
13703
  onQRCodeReady: (info) => {
13624
13704
  print("\u8BF7\u4F7F\u7528\u98DE\u4E66 / Lark App \u626B\u63CF\u4EE5\u4E0B\u4E8C\u7EF4\u7801\uFF0C\u521B\u5EFA\u6216\u9009\u62E9 PersonalAgent \u5E94\u7528\uFF1A");