botmux 2.53.0 → 2.55.0

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.
Files changed (142) hide show
  1. package/dist/adapters/backend/zellij-observe-backend.d.ts +15 -0
  2. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  3. package/dist/adapters/backend/zellij-observe-backend.js +37 -0
  4. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  5. package/dist/adapters/cli/claude-code.d.ts +3 -3
  6. package/dist/adapters/cli/claude-code.js.map +1 -1
  7. package/dist/adapters/cli/codex.d.ts.map +1 -1
  8. package/dist/adapters/cli/codex.js +44 -28
  9. package/dist/adapters/cli/codex.js.map +1 -1
  10. package/dist/adapters/cli/seed.d.ts +1 -1
  11. package/dist/adapters/cli/seed.js +2 -2
  12. package/dist/adapters/cli/seed.js.map +1 -1
  13. package/dist/adapters/cli/types.d.ts +5 -1
  14. package/dist/adapters/cli/types.d.ts.map +1 -1
  15. package/dist/bot-registry.d.ts +8 -0
  16. package/dist/bot-registry.d.ts.map +1 -1
  17. package/dist/bot-registry.js +31 -5
  18. package/dist/bot-registry.js.map +1 -1
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +282 -113
  21. package/dist/cli.js.map +1 -1
  22. package/dist/core/dispatch.d.ts +23 -0
  23. package/dist/core/dispatch.d.ts.map +1 -1
  24. package/dist/core/dispatch.js +17 -0
  25. package/dist/core/dispatch.js.map +1 -1
  26. package/dist/core/pending-response.d.ts +25 -0
  27. package/dist/core/pending-response.d.ts.map +1 -0
  28. package/dist/core/pending-response.js +82 -0
  29. package/dist/core/pending-response.js.map +1 -0
  30. package/dist/core/session-discovery.d.ts.map +1 -1
  31. package/dist/core/session-discovery.js +5 -0
  32. package/dist/core/session-discovery.js.map +1 -1
  33. package/dist/core/session-manager.d.ts.map +1 -1
  34. package/dist/core/session-manager.js +16 -1
  35. package/dist/core/session-manager.js.map +1 -1
  36. package/dist/core/types.d.ts +3 -0
  37. package/dist/core/types.d.ts.map +1 -1
  38. package/dist/core/types.js.map +1 -1
  39. package/dist/core/worker-pool.d.ts +27 -1
  40. package/dist/core/worker-pool.d.ts.map +1 -1
  41. package/dist/core/worker-pool.js +134 -6
  42. package/dist/core/worker-pool.js.map +1 -1
  43. package/dist/core/zellij-adopt-discovery.d.ts.map +1 -1
  44. package/dist/core/zellij-adopt-discovery.js +53 -32
  45. package/dist/core/zellij-adopt-discovery.js.map +1 -1
  46. package/dist/daemon.d.ts.map +1 -1
  47. package/dist/daemon.js +63 -1
  48. package/dist/daemon.js.map +1 -1
  49. package/dist/dashboard/bot-onboarding.d.ts +46 -3
  50. package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
  51. package/dist/dashboard/bot-onboarding.js +86 -7
  52. package/dist/dashboard/bot-onboarding.js.map +1 -1
  53. package/dist/dashboard/federated-group-core.js +8 -4
  54. package/dist/dashboard/federated-group-core.js.map +1 -1
  55. package/dist/dashboard/federation-api.d.ts.map +1 -1
  56. package/dist/dashboard/federation-api.js +22 -2
  57. package/dist/dashboard/federation-api.js.map +1 -1
  58. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  59. package/dist/dashboard/web/bot-onboarding.js +139 -20
  60. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  61. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  62. package/dist/dashboard/web/i18n.js +36 -6
  63. package/dist/dashboard/web/i18n.js.map +1 -1
  64. package/dist/dashboard-web/app.js +171 -143
  65. package/dist/dashboard-web/style.css +58 -0
  66. package/dist/dashboard.js +37 -1
  67. package/dist/dashboard.js.map +1 -1
  68. package/dist/global-config.d.ts +5 -0
  69. package/dist/global-config.d.ts.map +1 -1
  70. package/dist/global-config.js +17 -0
  71. package/dist/global-config.js.map +1 -1
  72. package/dist/i18n/en.d.ts.map +1 -1
  73. package/dist/i18n/en.js +8 -0
  74. package/dist/i18n/en.js.map +1 -1
  75. package/dist/i18n/zh.d.ts.map +1 -1
  76. package/dist/i18n/zh.js +8 -0
  77. package/dist/i18n/zh.js.map +1 -1
  78. package/dist/im/lark/card-builder.d.ts +4 -1
  79. package/dist/im/lark/card-builder.d.ts.map +1 -1
  80. package/dist/im/lark/card-builder.js +34 -1
  81. package/dist/im/lark/card-builder.js.map +1 -1
  82. package/dist/im/lark/card-handler.d.ts.map +1 -1
  83. package/dist/im/lark/card-handler.js +72 -11
  84. package/dist/im/lark/card-handler.js.map +1 -1
  85. package/dist/im/lark/client.d.ts +3 -1
  86. package/dist/im/lark/client.d.ts.map +1 -1
  87. package/dist/im/lark/client.js +7 -2
  88. package/dist/im/lark/client.js.map +1 -1
  89. package/dist/im/lark/event-dispatcher.d.ts +8 -1
  90. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  91. package/dist/im/lark/event-dispatcher.js +36 -6
  92. package/dist/im/lark/event-dispatcher.js.map +1 -1
  93. package/dist/services/codex-paths.d.ts +7 -0
  94. package/dist/services/codex-paths.d.ts.map +1 -0
  95. package/dist/services/codex-paths.js +19 -0
  96. package/dist/services/codex-paths.js.map +1 -0
  97. package/dist/services/codex-transcript.d.ts.map +1 -1
  98. package/dist/services/codex-transcript.js +5 -4
  99. package/dist/services/codex-transcript.js.map +1 -1
  100. package/dist/services/pending-response-transaction-store.d.ts +12 -0
  101. package/dist/services/pending-response-transaction-store.d.ts.map +1 -0
  102. package/dist/services/pending-response-transaction-store.js +52 -0
  103. package/dist/services/pending-response-transaction-store.js.map +1 -0
  104. package/dist/services/session-store.d.ts.map +1 -1
  105. package/dist/services/session-store.js +15 -1
  106. package/dist/services/session-store.js.map +1 -1
  107. package/dist/services/voice/audio.d.ts +32 -0
  108. package/dist/services/voice/audio.d.ts.map +1 -0
  109. package/dist/services/voice/audio.js +114 -0
  110. package/dist/services/voice/audio.js.map +1 -0
  111. package/dist/services/voice/index.d.ts +32 -0
  112. package/dist/services/voice/index.d.ts.map +1 -0
  113. package/dist/services/voice/index.js +98 -0
  114. package/dist/services/voice/index.js.map +1 -0
  115. package/dist/services/voice/openai.d.ts +24 -0
  116. package/dist/services/voice/openai.d.ts.map +1 -0
  117. package/dist/services/voice/openai.js +47 -0
  118. package/dist/services/voice/openai.js.map +1 -0
  119. package/dist/services/voice/sami.d.ts +22 -0
  120. package/dist/services/voice/sami.d.ts.map +1 -0
  121. package/dist/services/voice/sami.js +128 -0
  122. package/dist/services/voice/sami.js.map +1 -0
  123. package/dist/services/voice/types.d.ts +32 -0
  124. package/dist/services/voice/types.d.ts.map +1 -0
  125. package/dist/services/voice/types.js +2 -0
  126. package/dist/services/voice/types.js.map +1 -0
  127. package/dist/setup/bot-config-editor.d.ts +9 -0
  128. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  129. package/dist/setup/bot-config-editor.js +26 -0
  130. package/dist/setup/bot-config-editor.js.map +1 -1
  131. package/dist/setup/verify-permissions.js +3 -3
  132. package/dist/setup/verify-permissions.js.map +1 -1
  133. package/dist/skills/definitions.d.ts.map +1 -1
  134. package/dist/skills/definitions.js +0 -1
  135. package/dist/skills/definitions.js.map +1 -1
  136. package/dist/types.d.ts +5 -0
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/worker.js +147 -7
  139. package/dist/worker.js.map +1 -1
  140. package/dist/workflows/events/payloads.d.ts +20 -20
  141. package/dist/workflows/events/schema.d.ts +188 -188
  142. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -26,7 +26,7 @@ import { createInterface } from 'node:readline';
26
26
  import { createRequire } from 'node:module';
27
27
  import { createHmac, randomBytes } from 'node:crypto';
28
28
  import { validateWorkingDir } from './core/working-dir.js';
29
- import { parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, offTopicSubBotTopic, resolveReportTarget } from './core/dispatch.js';
29
+ import { parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget } from './core/dispatch.js';
30
30
  import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
31
31
  import { tmuxEnv } from './setup/ensure-tmux.js';
32
32
  import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
@@ -621,7 +621,7 @@ async function promptBotConfig(rl) {
621
621
  return null;
622
622
  }
623
623
  console.log('✅ 凭证有效(tenant_access_token 已成功获取)\n');
624
- console.log('支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed');
624
+ console.log('支持的 CLI: 1) claude-code 2) aiden 3) coco(别名 traecli) 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed');
625
625
  const cliChoice = await ask(rl, 'CLI 适配器 [1]: ');
626
626
  let cliId;
627
627
  try {
@@ -711,9 +711,10 @@ async function promptEditBotConfig(rl, bot) {
711
711
  '留空保留当前值。',
712
712
  ]);
713
713
  input.larkAppSecret = await ask(rl, `LARK_APP_SECRET [保留当前值]: `);
714
- console.log('\n支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed');
714
+ console.log('\n支持的 CLI: 1) claude-code 2) aiden 3) coco(别名 traecli) 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed');
715
715
  printInputHelp('CLI 适配器', [
716
716
  '选择 botmux 需要套用哪一种 CLI 参数协议和会话恢复方式。',
717
+ 'coco 的别名 traecli 走同一适配器;二进制名是 traecli 也选 coco 即可。',
717
718
  '留空保留当前值;可以输入序号,也可以直接输入适配器 ID。',
718
719
  ]);
719
720
  input.cliChoice = await ask(rl, `CLI 适配器 [${bot.cliId ?? 'claude-code'}]: `);
@@ -1510,6 +1511,9 @@ function loadSessions() {
1510
1511
  return sessions;
1511
1512
  }
1512
1513
  /** Save a single session back to its appropriate file based on larkAppId. */
1514
+ function loadSessionFresh(session) {
1515
+ return loadSessions().get(session.sessionId);
1516
+ }
1513
1517
  function saveSession(session) {
1514
1518
  const dataDir = resolveDataDir();
1515
1519
  const fileName = session.larkAppId ? `sessions-${session.larkAppId}.json` : 'sessions.json';
@@ -1522,7 +1526,7 @@ function saveSession(session) {
1522
1526
  }
1523
1527
  catch { /* start fresh */ }
1524
1528
  }
1525
- data[session.sessionId] = session;
1529
+ data[session.sessionId] = mergePendingResponseState(session, data[session.sessionId]);
1526
1530
  // Clean up entries where file key doesn't match the entry's sessionId (data corruption)
1527
1531
  for (const [key, val] of Object.entries(data)) {
1528
1532
  if (val && typeof val === 'object' && 'sessionId' in val && val.sessionId !== key) {
@@ -2168,6 +2172,9 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
2168
2172
  lang [zh|en] 切换 UI 语言(无参 = 查看当前设置)
2169
2173
  --bot N 仅改 bots.json 中第 N 个 bot 的 lang
2170
2174
  --unset 清除(global 或 --bot N 配合)
2175
+ voice 配置语音总结(高级功能,独立于 setup)— 交互式填 TTS 引擎+凭证
2176
+ voice status 查看当前语音配置(凭证打码)
2177
+ voice disable 关闭语音功能(移除配置)
2171
2178
 
2172
2179
  定时任务(可在 CLI 会话内自动推断 chat):
2173
2180
  schedule list 列出所有任务
@@ -2185,7 +2192,7 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
2185
2192
  --no-mention 明确声明本条不@任何人
2186
2193
  --quote <message_id> 指定引用某条消息(普通群,默认引用本轮触发消息)
2187
2194
  --no-quote 不引用,发独立消息(普通群)
2188
- --card | --text 强制卡片 / 纯文本(默认按 md 语法自动判断)
2195
+ --voice "<口语文字>" 合成语音气泡发出(需先 botmux voice 配置 TTS)
2189
2196
  --top-level 发顶层消息(不回复进当前话题)
2190
2197
  --chat-id <oc_xxx> 指定目标群(默认当前话题所在群)
2191
2198
  --anyway 跳过「@ 到活跃子 bot」护栏强发(见下)
@@ -2637,7 +2644,8 @@ function argValues(args, ...flags) {
2637
2644
  // Card v2 body builder helpers — extracted to im/lark/md-card.ts so the
2638
2645
  // daemon's bridge fallback path can produce identical cards. cmdSend
2639
2646
  // keeps using `buildCardBodyElements` and `hasMarkdown` from there.
2640
- import { buildCardBodyElements, hasMarkdown, brandFooterSegment } from './im/lark/md-card.js';
2647
+ import { buildCardBodyElements, brandFooterSegment } from './im/lark/md-card.js';
2648
+ import { claimPendingResponseCard, markPendingResponseCardPatchedIfCurrent, mergePendingResponseState } from './core/pending-response.js';
2641
2649
  import { resolveBrandLabel } from './bot-registry.js';
2642
2650
  import { config } from './config.js';
2643
2651
  import { resolveQuoteTarget, validateMentionDecision } from './services/send-policy.js';
@@ -2661,14 +2669,22 @@ async function cmdSend(rest) {
2661
2669
  const files = argValues(rest, '--file', '--files');
2662
2670
  const mentionArgs = argValues(rest, '--mention'); // "open_id:Display Name"
2663
2671
  const contentFile = argValue(rest, '--content-file');
2664
- const forceCard = rest.includes('--card');
2665
- const forceText = rest.includes('--text');
2672
+ // 回复一律走交互卡片。`--card` / `--text` 仅为向后兼容被容忍并忽略:纯文本 post
2673
+ // 路径已删除——只有卡片能承载「🔊 语音总结」按钮,且守护进程兜底也一直只发卡片。
2666
2674
  // Publish-mode flags: post a fresh top-level message in a chat instead of
2667
2675
  // replying into the bound thread. Lets a session "publish" to a different
2668
2676
  // chat (e.g. a public release-notes group) while keeping its own thread
2669
2677
  // for streaming-card / progress UI.
2670
2678
  const sendTopLevel = rest.includes('--top-level');
2671
2679
  const overrideChatId = argValue(rest, '--chat-id');
2680
+ // --into <话题根id>: reply this send into a specific topic (a sub-bot's topic,
2681
+ // another thread, etc.) instead of the session's own location. Wins over the
2682
+ // auto/scope default; `dispatch` opens topics, `send --into` posts into them.
2683
+ const sendInto = argValue(rest, '--into');
2684
+ // --voice: synthesize the content into a Feishu voice bubble instead of a
2685
+ // text/card message. The content should be spoken-style prose (the 🔊 button
2686
+ // injects a condense-first instruction before the model calls this).
2687
+ const asVoice = rest.includes('--voice');
2672
2688
  // Quote chain (chat scope): --quote <message_id> overrides the auto target,
2673
2689
  // --no-quote forces a plain (un-quoted) send.
2674
2690
  const explicitQuote = argValue(rest, '--quote');
@@ -2701,7 +2717,7 @@ async function cmdSend(rest) {
2701
2717
  content = readFileSync(contentFile, 'utf-8');
2702
2718
  }
2703
2719
  else {
2704
- const pos = positionals(rest, ['--card', '--text', '--top-level', '--no-quote', '--mention-back', '--no-mention', '--anyway']);
2720
+ const pos = positionals(rest, ['--card', '--text', '--top-level', '--no-quote', '--mention-back', '--no-mention', '--anyway', '--voice']);
2705
2721
  if (pos.length > 0) {
2706
2722
  content = pos.join(' ');
2707
2723
  }
@@ -2713,6 +2729,70 @@ async function cmdSend(rest) {
2713
2729
  console.error('没有内容可发送。用法:\n echo "消息" | botmux send\n botmux send "消息"\n botmux send --content-file /tmp/msg.md --images /tmp/chart.png');
2714
2730
  process.exit(1);
2715
2731
  }
2732
+ // ── Voice mode ──────────────────────────────────────────────────────────
2733
+ // Synthesize the (already-condensed, colloquial) content into a Feishu voice
2734
+ // bubble and return. Deliberately bypasses the text/card path's mentions,
2735
+ // footer, and @-hard-gate — a voice bubble addresses nobody. Lands in the
2736
+ // same thread/chat the session would normally reply to.
2737
+ if (asVoice) {
2738
+ if (!content.trim()) {
2739
+ console.error('--voice 需要要朗读的文字');
2740
+ process.exit(1);
2741
+ }
2742
+ const { registerBot, loadBotConfigs } = await import('./bot-registry.js');
2743
+ try {
2744
+ for (const cfg of loadBotConfigs())
2745
+ registerBot(cfg);
2746
+ }
2747
+ catch { /* */ }
2748
+ const { uploadFile, sendMessage, replyMessage } = await import('./im/lark/client.js');
2749
+ const { synthesizeVoiceOpus } = await import('./services/voice/index.js');
2750
+ const { rmSync } = await import('node:fs');
2751
+ const appId = s.larkAppId;
2752
+ const targetChatId = overrideChatId ?? s.chatId;
2753
+ const target = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: s.scope === 'chat', chatId: targetChatId, rootMessageId: s.rootMessageId });
2754
+ const sendAudio = (fileKey) => target.mode === 'plain'
2755
+ ? sendMessage(appId, target.chatId, JSON.stringify({ file_key: fileKey }), 'audio')
2756
+ : replyMessage(appId, target.root, JSON.stringify({ file_key: fileKey }), 'audio', true);
2757
+ let dir;
2758
+ try {
2759
+ const out = await synthesizeVoiceOpus(appId, content);
2760
+ dir = out.dir;
2761
+ const fileKey = await uploadFile(appId, out.path, { duration: out.durationMs });
2762
+ const sentAtMs = Date.now();
2763
+ const messageId = await sendAudio(fileKey);
2764
+ // 语音也是一次回复:写 bridge fallback marker,否则本轮会被判为"没发 botmux send"
2765
+ // 而触发兜底,多补一张文本卡。与文本/卡片路径同口径:仅同话题回复才记。
2766
+ if (!sendTopLevel && !overrideChatId && !sendInto) {
2767
+ try {
2768
+ const markerDir = join(resolveDataDir(), 'turn-sends');
2769
+ if (!existsSync(markerDir))
2770
+ mkdirSync(markerDir, { recursive: true });
2771
+ appendFileSync(join(markerDir, `${sid}.jsonl`), JSON.stringify({ sentAtMs, messageId }) + '\n');
2772
+ }
2773
+ catch { /* best-effort:漏记只多一条兜底,不致命 */ }
2774
+ }
2775
+ console.error(`✓ 已发送语音 ${messageId} | ${Math.round(out.durationMs / 1000)}s`);
2776
+ console.log(JSON.stringify({ success: true, messageId, sessionId: sid, kind: 'voice', durationMs: out.durationMs }));
2777
+ }
2778
+ catch (e) {
2779
+ console.error(`语音发送失败:${e?.message ?? e}`);
2780
+ if (dir) {
2781
+ try {
2782
+ rmSync(dir, { recursive: true, force: true });
2783
+ }
2784
+ catch { /* */ }
2785
+ }
2786
+ process.exit(1);
2787
+ }
2788
+ if (dir) {
2789
+ try {
2790
+ rmSync(dir, { recursive: true, force: true });
2791
+ }
2792
+ catch { /* */ }
2793
+ }
2794
+ return;
2795
+ }
2716
2796
  // Parse mentions: "open_id:Display Name" or bare "open_id"
2717
2797
  // Bare form appends a trailing <at id=...> to the message and still writes
2718
2798
  // a bot-mention signal — useful when the sender doesn't know the target's
@@ -2763,7 +2843,7 @@ async function cmdSend(rest) {
2763
2843
  registerBot(cfg);
2764
2844
  }
2765
2845
  catch { /* */ }
2766
- const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError } = await import('./im/lark/client.js');
2846
+ const { sendMessage, replyMessage, uploadImage, uploadFile, deleteMessage, MessageWithdrawnError } = await import('./im/lark/client.js');
2767
2847
  const appId = s.larkAppId;
2768
2848
  // Effective target chat for top-level mode (defaults to session's chat)
2769
2849
  const targetChatId = overrideChatId ?? s.chatId;
@@ -2799,17 +2879,15 @@ async function cmdSend(rest) {
2799
2879
  // Explicit --mention / --mention-back of an off-topic sub-bot → block + point to
2800
2880
  // the right command (--anyway overrides). Prose @Name injection is filtered
2801
2881
  // (dropped, not blocked) at its own site below.
2802
- if (!rest.includes('--anyway')) {
2803
- for (const m of mentions) {
2804
- const seed = offTopicSubBotSeed(m.open_id);
2805
- if (seed) {
2806
- console.error(`⚠️ ${m.open_id}${m.name ? `(${m.name})` : ''} 是 botmux dispatch 派进子话题 ${seed} 的子 bot——\n` +
2807
- `它的会话在那条子话题里:在主群 @ 它收不到,反而会另起一个无上下文的新会话。\n` +
2808
- `要跟它说,把消息发进它的子话题:\n` +
2809
- ` botmux dispatch --into ${seed} --bot ${m.open_id} --brief "..."\n` +
2810
- `(确属新会话/有意为之,加 --anyway 强发。)`);
2811
- process.exit(2);
2812
- }
2882
+ // Inform, don't block: if @-ing a bot whose session lives in a sub-topic, this
2883
+ // send lands a NEW conversation at the current location. To reply into that
2884
+ // topic instead, use `--into <seed>`. The model picks the destination — no hard
2885
+ // block (that was too aggressive; @-ing a bot in the group to start a fresh
2886
+ // conversation is a legitimate, common intent).
2887
+ for (const m of mentions) {
2888
+ const seed = offTopicSubBotSeed(m.open_id);
2889
+ if (seed) {
2890
+ console.error(`ℹ️ ${m.open_id}${m.name ? `(${m.name})` : ''} 在子话题 ${seed} 里也有会话;本条发到当前位置(新对话)。要发进那个话题改用 --into ${seed}。`);
2813
2891
  }
2814
2892
  }
2815
2893
  // Oncall addressing only meaningful for replies inside the session's own
@@ -2817,17 +2895,40 @@ async function cmdSend(rest) {
2817
2895
  // oncall as chat-level: in multi-daemon setups this session's bot may not
2818
2896
  // be the one that persisted the binding, but users still expect footer
2819
2897
  // addressing to go to the last caller in the shared oncall workspace.
2820
- const oncallEntry = !sendTopLevel && !overrideChatId && s.chatId
2898
+ const oncallEntry = !sendTopLevel && !overrideChatId && !sendInto && s.chatId
2821
2899
  ? findOncallChatForAnyBot(s.chatId) : undefined;
2822
2900
  // Dispatch helper: top-level / chat-scope send vs reply-in-thread, single
2823
2901
  // decision point. Used for file attachments (always plain in chat scope).
2824
- const dispatch = (content, msgType) => (sendTopLevel || isChatScope)
2825
- ? sendMessage(appId, targetChatId, content, msgType)
2826
- : replyMessage(appId, s.rootMessageId, content, msgType, true);
2902
+ const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId });
2903
+ const dispatch = (content, msgType) => sendTarget.mode === 'plain'
2904
+ ? sendMessage(appId, sendTarget.chatId, content, msgType)
2905
+ : replyMessage(appId, sendTarget.root, content, msgType, true);
2906
+ const recordBridgeSendMarker = (sentAtMs, messageId) => {
2907
+ try {
2908
+ const markerDir = join(resolveDataDir(), 'turn-sends');
2909
+ if (!existsSync(markerDir))
2910
+ mkdirSync(markerDir, { recursive: true });
2911
+ const line = JSON.stringify({ sentAtMs, messageId }) + '\n';
2912
+ appendFileSync(join(markerDir, `${sid}.jsonl`), line);
2913
+ }
2914
+ catch { /* best-effort: marker miss only causes a redundant fallback message */ }
2915
+ };
2916
+ const shouldRecordBridgeMarker = !sendTopLevel && !overrideChatId && !sendInto;
2917
+ const dispatchOrPatchPending = async (content, msgType) => {
2918
+ const pendingCardId = msgType === 'interactive' ? claimPendingResponseCard(s) : undefined;
2919
+ const sentId = await dispatchPrimary(content, msgType);
2920
+ const latest = pendingCardId ? loadSessionFresh(s) : undefined;
2921
+ if (pendingCardId && latest?.pendingResponseCardId === pendingCardId) {
2922
+ deleteMessage(appId, pendingCardId)
2923
+ .then(() => { markPendingResponseCardPatchedIfCurrent(latest, pendingCardId); saveSession(latest); })
2924
+ .catch((err) => logger.warn(`[send:${sid.substring(0, 8)}] failed to withdraw pending card after explicit send: ${err?.message ?? err}`));
2925
+ }
2926
+ return sentId;
2927
+ };
2827
2928
  // Quote chain (普通群): the primary message replies to the turn's target so
2828
2929
  // Lark renders a 引用 chain. --quote overrides, --no-quote opts out. Thread
2829
2930
  // scope and --top-level never quote. Withdrawn target → fall back to plain.
2830
- const quoteTargetId = resolveQuoteTarget({
2931
+ const quoteTargetId = sendInto ? undefined : resolveQuoteTarget({
2831
2932
  isChatScope, sendTopLevel, noQuote, explicitQuote,
2832
2933
  sessionQuoteTargetId: s.quoteTargetId,
2833
2934
  });
@@ -2953,16 +3054,9 @@ async function cmdSend(rest) {
2953
3054
  }
2954
3055
  if (alreadyMentioned.has(senderScopedId))
2955
3056
  break;
2956
- // Footgun guard at the auto-injection source: don't turn a prose
2957
- // `@OtherSubBot` into a real @ for a dispatched sub-bot that's off-topic
2958
- // here that would spawn a context-less session in the main chat (the
2959
- // explicit guard above only saw --mention/--mention-back). Drop the
2960
- // injection (don't block the whole send); --anyway forces it through.
2961
- const injOffSeed = rest.includes('--anyway') ? null : offTopicSubBotSeed(senderScopedId);
2962
- if (injOffSeed) {
2963
- console.error(`[botmux send] 跳过正文 @${entry.botName} 自动注入:它是 dispatch 派进子话题 ${injOffSeed} 的子 bot、不在本会话——避免在主群另起无上下文会话(要强发加 --anyway)。`);
2964
- break;
2965
- }
3057
+ // Prose `@OtherBot` auto-injection: inject normally. (The off-topic
3058
+ // sub-bot guard used to DROP this; we now let the model @ freely and
3059
+ // pick the destination with --into instead of being silently dropped.)
2966
3060
  mentions.push({ open_id: senderScopedId, name: entry.botName });
2967
3061
  alreadyMentioned.add(senderScopedId);
2968
3062
  break;
@@ -2983,9 +3077,6 @@ async function cmdSend(rest) {
2983
3077
  hasExplicitBotMention: explicitKnownBotMention,
2984
3078
  knownBotOpenIds,
2985
3079
  });
2986
- // Decide: interactive card (renders markdown) vs. post (plain text).
2987
- // Explicit --card / --text wins; otherwise auto-detect markdown syntax.
2988
- const useCard = forceCard || (!forceText && hasMarkdown(text));
2989
3080
  const mentionMap = new Map();
2990
3081
  for (const m of mentions)
2991
3082
  if (m.name)
@@ -3002,7 +3093,8 @@ async function cmdSend(rest) {
3002
3093
  // we committed to sending — that's the boundary the gate cares about.
3003
3094
  const sentAtMs = Date.now();
3004
3095
  let messageId;
3005
- if (useCard) {
3096
+ {
3097
+ // 回复一律卡片(纯文本 post 路径已删)。
3006
3098
  // Inline @mention → <at id=open_id></at>; explicit --mention args that
3007
3099
  // weren't inlined are appended to the body. The session owner is
3008
3100
  // rendered in the footer note instead of the body.
@@ -3065,87 +3157,71 @@ async function cmdSend(rest) {
3065
3157
  if (footerRecipients.length > 0) {
3066
3158
  footerParts.push(`发送给:${footerRecipients.map(id => `<at id=${id}></at>`).join(' ')}`);
3067
3159
  }
3068
- // Empty brand + no recipients no footer at all (skip the orphan HR).
3069
- if (footerParts.length > 0) {
3160
+ // Footer line (brand 个性签名 + 发送给) and the optional 🔊 语音总结 button
3161
+ // share ONE row: footer text on the left (weighted, fills), button pinned
3162
+ // to the far right (auto width). When voice isn't configured the footer
3163
+ // renders alone, as before. Button only on a reply (not --top-level).
3164
+ // v2 cards put buttons inside column_set/column — never the 1.x
3165
+ // `tag:'action'` container (Feishu rejects it, error 200861).
3166
+ let voiceOn = false;
3167
+ if (!sendTopLevel) {
3168
+ try {
3169
+ const { isVoiceConfigured } = await import('./services/voice/index.js');
3170
+ voiceOn = isVoiceConfigured(appId);
3171
+ }
3172
+ catch { /* voice module/config unavailable → no button */ }
3173
+ }
3174
+ const footerContent = footerParts.length > 0
3175
+ ? `<font color='grey'>${footerParts.join(' · ')}</font>`
3176
+ : '';
3177
+ if (footerContent || voiceOn) {
3070
3178
  elements.push({ tag: 'hr' });
3071
- elements.push({
3072
- tag: 'markdown',
3073
- text_size: 'notation_small_v2',
3074
- content: `<font color='grey'>${footerParts.join(' · ')}</font>`,
3075
- });
3179
+ if (voiceOn) {
3180
+ const anchorId = (isChatScope ? s.chatId : s.rootMessageId) ?? s.chatId;
3181
+ elements.push({
3182
+ tag: 'column_set',
3183
+ flex_mode: 'none',
3184
+ horizontal_spacing: 'default',
3185
+ columns: [
3186
+ {
3187
+ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center',
3188
+ elements: [{ tag: 'markdown', text_size: 'notation_small_v2', content: footerContent || ' ' }],
3189
+ },
3190
+ {
3191
+ tag: 'column', width: 'auto', vertical_align: 'center',
3192
+ elements: [{
3193
+ tag: 'button',
3194
+ text: { tag: 'plain_text', content: '🔊 语音总结' },
3195
+ type: 'default',
3196
+ behaviors: [{
3197
+ type: 'callback',
3198
+ value: { action: 'voice_summary', session_id: sid, root_id: anchorId, lark_app_id: appId, chat_id: targetChatId },
3199
+ }],
3200
+ }],
3201
+ },
3202
+ ],
3203
+ });
3204
+ }
3205
+ else {
3206
+ elements.push({
3207
+ tag: 'markdown',
3208
+ text_size: 'notation_small_v2',
3209
+ content: footerContent,
3210
+ });
3211
+ }
3076
3212
  }
3077
3213
  const cardJson = JSON.stringify({
3078
3214
  schema: '2.0',
3079
3215
  config: { update_multi: true },
3080
3216
  body: { direction: 'vertical', elements },
3081
3217
  });
3082
- messageId = await dispatchPrimary(cardJson, 'interactive');
3083
- }
3084
- else {
3085
- // Plain-text path: build post content, paragraph per line.
3086
- const postContent = text ? text.split('\n').map((line) => {
3087
- if (!mentionPattern)
3088
- return [{ tag: 'text', text: line }];
3089
- const nodes = [];
3090
- let lastIndex = 0;
3091
- for (const match of line.matchAll(mentionPattern)) {
3092
- const openId = mentionMap.get(match[1].toLowerCase());
3093
- if (!openId)
3094
- continue;
3095
- if (match.index > lastIndex)
3096
- nodes.push({ tag: 'text', text: line.slice(lastIndex, match.index) });
3097
- nodes.push({ tag: 'at', user_id: openId });
3098
- lastIndex = match.index + match[0].length;
3099
- }
3100
- if (lastIndex < line.length)
3101
- nodes.push({ tag: 'text', text: line.slice(lastIndex) });
3102
- return nodes.length > 0 ? nodes : [{ tag: 'text', text: line }];
3103
- }) : [];
3104
- for (const key of imageKeys)
3105
- postContent.push([{ tag: 'img', image_key: key }]);
3106
- // Footer: mirror the card layout — all real mentions go on one
3107
- // `发送给:` line (human addressee first, then explicit targets, then cc),
3108
- // separated from the body by a blank paragraph. Ids already inlined in the
3109
- // body prose are skipped. Top-level publish keeps sendTo empty.
3110
- const inlinedIds = new Set();
3111
- for (const para of postContent)
3112
- for (const n of para)
3113
- if (n.tag === 'at')
3114
- inlinedIds.add(n.user_id);
3115
- const footerRecipients = orderedFooterRecipients({
3116
- sendTo: footerAddressing.sendTo,
3117
- mentionIds: mentions.map(m => m.open_id),
3118
- cc: footerAddressing.cc,
3119
- inlinedIds,
3120
- });
3121
- if (footerRecipients.length > 0) {
3122
- if (postContent.length > 0)
3123
- postContent.push([{ tag: 'text', text: '' }]);
3124
- postContent.push([
3125
- { tag: 'text', text: '发送给:' },
3126
- ...footerRecipients.map(id => ({ tag: 'at', user_id: id })),
3127
- ]);
3128
- }
3129
- const postJson = JSON.stringify({ zh_cn: { title: '', content: postContent } });
3130
- messageId = await dispatchPrimary(postJson, 'post');
3131
- }
3132
- // Bridge fallback marker — append-only jsonl per session. The worker
3133
- // gates its non-adopt transcript-driven fallback on whether any send
3134
- // happened within the current Lark turn's window. Only when this send
3135
- // landed in the session's own thread (not --top-level, not --chat-id
3136
- // override) does it cancel that turn's fallback.
3137
- if (!sendTopLevel && !overrideChatId) {
3138
- try {
3139
- const markerDir = join(resolveDataDir(), 'turn-sends');
3140
- if (!existsSync(markerDir))
3141
- mkdirSync(markerDir, { recursive: true });
3142
- // sentAtMs was captured pre-dispatch (see above). messageId is the
3143
- // confirmed Lark message id from the now-successful dispatch.
3144
- const line = JSON.stringify({ sentAtMs, messageId }) + '\n';
3145
- appendFileSync(join(markerDir, `${sid}.jsonl`), line);
3146
- }
3147
- catch { /* best-effort: marker miss only causes a redundant fallback message */ }
3218
+ messageId = await dispatchOrPatchPending(cardJson, 'interactive');
3148
3219
  }
3220
+ // Bridge fallback marker — append-only jsonl per session. Same-thread
3221
+ // sends always suppress transcript fallback; detoured sends suppress only
3222
+ // when they closed a pending response card for this turn.
3223
+ if (shouldRecordBridgeMarker)
3224
+ recordBridgeSendMarker(sentAtMs, messageId);
3149
3225
  // Send file attachments as separate messages
3150
3226
  const fileIds = [];
3151
3227
  for (const fp of files) {
@@ -4157,6 +4233,96 @@ if (process.env.BOTMUX_WORKFLOW === '1') {
4157
4233
  process.exit(2);
4158
4234
  }
4159
4235
  }
4236
+ /**
4237
+ * `botmux voice` — standalone voice-summary configuration (advanced feature,
4238
+ * intentionally NOT folded into `botmux setup`). Writes the global `voice`
4239
+ * block to ~/.botmux/config.json. Subcommands: (none)=interactive setup,
4240
+ * `status`=show masked config, `disable`=remove.
4241
+ */
4242
+ async function cmdVoiceSetup(args) {
4243
+ const sub = (args[0] ?? '').toLowerCase();
4244
+ const { readGlobalConfig, mergeGlobalConfig } = await import('./global-config.js');
4245
+ const { DEFAULT_SAMI_SPEAKER, DEFAULT_OPENAI_SPEAKER } = await import('./services/voice/index.js');
4246
+ const mask = (s) => (s ? `${s.slice(0, 4)}***` : '(未设)');
4247
+ if (sub === 'status') {
4248
+ const v = readGlobalConfig().voice;
4249
+ if (!v) {
4250
+ console.log('语音功能未配置。运行 `botmux voice` 配置。');
4251
+ return;
4252
+ }
4253
+ console.log('当前语音配置(全局 ~/.botmux/config.json):');
4254
+ console.log(` 引擎: ${v.engine ?? '(自动)'}`);
4255
+ console.log(` 音色: ${v.speaker ?? '(默认)'}`);
4256
+ if (typeof v.rate === 'number')
4257
+ console.log(` 语速: ${v.rate}`);
4258
+ if (v.sami)
4259
+ console.log(` SAMI: accessKey=${mask(v.sami.accessKey)} secretKey=${mask(v.sami.secretKey)} appkey=${v.sami.appkey ?? '(未设)'}${v.sami.tokenUrl ? ` tokenUrl=${v.sami.tokenUrl}` : ''}`);
4260
+ if (v.openai)
4261
+ console.log(` OpenAI: baseUrl=${v.openai.baseUrl ?? '(未设)'} model=${v.openai.model ?? '(未设)'} apiKey=${mask(v.openai.apiKey)}`);
4262
+ return;
4263
+ }
4264
+ if (sub === 'disable' || sub === 'off') {
4265
+ mergeGlobalConfig({ voice: null });
4266
+ console.log('✅ 已移除全局语音配置(回复卡片不再显示「🔊 语音总结」按钮)。重启 daemon 生效。');
4267
+ return;
4268
+ }
4269
+ if (sub && sub !== 'setup') {
4270
+ console.error('用法: botmux voice [status|disable](无参 = 交互式配置)');
4271
+ process.exit(1);
4272
+ }
4273
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4274
+ try {
4275
+ console.log('🔊 配置语音总结(高级功能)。写入全局 ~/.botmux/config.json,重启后生效。\n');
4276
+ const eng = (await ask(rl, '选择 TTS 引擎 [1] SAMI(需 AK/SK/appkey) [2] OpenAI 兼容(自带 baseUrl/key): ')).trim();
4277
+ const voice = {};
4278
+ if (eng === '2' || /openai/i.test(eng)) {
4279
+ voice.engine = 'openai';
4280
+ const baseUrl = (await ask(rl, 'baseUrl(如 https://api.openai.com/v1,自托管如 http://127.0.0.1:8880/v1): ')).trim();
4281
+ const apiKey = (await ask(rl, 'apiKey(无则留空): ')).trim();
4282
+ const model = (await ask(rl, 'model(如 tts-1 / kokoro): ')).trim();
4283
+ if (!baseUrl || !model) {
4284
+ console.error('❌ baseUrl 和 model 必填,未写入。');
4285
+ return;
4286
+ }
4287
+ voice.openai = { baseUrl, apiKey, model };
4288
+ const sp = (await ask(rl, `音色 voice(留空=默认 ${DEFAULT_OPENAI_SPEAKER}): `)).trim();
4289
+ if (sp)
4290
+ voice.speaker = sp;
4291
+ }
4292
+ else {
4293
+ voice.engine = 'sami';
4294
+ const accessKey = (await ask(rl, 'SAMI accessKey: ')).trim();
4295
+ const secretKey = (await ask(rl, 'SAMI secretKey: ')).trim();
4296
+ const appkey = (await ask(rl, 'SAMI appkey: ')).trim();
4297
+ if (!accessKey || !secretKey || !appkey) {
4298
+ console.error('❌ accessKey/secretKey/appkey 都必填,未写入。');
4299
+ return;
4300
+ }
4301
+ voice.sami = { accessKey, secretKey, appkey };
4302
+ const sp = (await ask(rl, `音色 speaker(留空=默认灿灿 ${DEFAULT_SAMI_SPEAKER}): `)).trim();
4303
+ if (sp)
4304
+ voice.speaker = sp;
4305
+ const adv = (await ask(rl, '自定义 SAMI 端点?一般不用,回车跳过 (y/N): ')).trim().toLowerCase();
4306
+ if (adv === 'y') {
4307
+ const tokenUrl = (await ask(rl, 'tokenUrl(留空用默认): ')).trim();
4308
+ const wsUrl = (await ask(rl, 'wsUrl(留空用默认): ')).trim();
4309
+ if (tokenUrl)
4310
+ voice.sami.tokenUrl = tokenUrl;
4311
+ if (wsUrl)
4312
+ voice.sami.wsUrl = wsUrl;
4313
+ }
4314
+ }
4315
+ const rate = (await ask(rl, '语速倍率(留空=1.1): ')).trim();
4316
+ if (rate && !Number.isNaN(Number(rate)))
4317
+ voice.rate = Number(rate);
4318
+ mergeGlobalConfig({ voice: voice });
4319
+ console.log('\n✅ 已写入 voice 配置。`botmux restart` 后,配了语音的机器人回复卡片底部会出现「🔊 语音总结」按钮。');
4320
+ console.log(' 查看:`botmux voice status` 关闭:`botmux voice disable`');
4321
+ }
4322
+ finally {
4323
+ rl.close();
4324
+ }
4325
+ }
4160
4326
  switch (command) {
4161
4327
  case '--version':
4162
4328
  case '-v':
@@ -4244,6 +4410,9 @@ switch (command) {
4244
4410
  case 'lang':
4245
4411
  cmdLang(process.argv.slice(3));
4246
4412
  break;
4413
+ case 'voice':
4414
+ await cmdVoiceSetup(process.argv.slice(3));
4415
+ break;
4247
4416
  case 'thread': {
4248
4417
  // Removed in favor of `botmux history` (普通群也兼容). Friendly stderr so
4249
4418
  // pre-rename scripts/skills surface the rename instead of "unknown command".