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.
- package/dist/adapters/backend/zellij-observe-backend.d.ts +15 -0
- package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.js +37 -0
- package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
- package/dist/adapters/cli/claude-code.d.ts +3 -3
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +44 -28
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/seed.d.ts +1 -1
- package/dist/adapters/cli/seed.js +2 -2
- package/dist/adapters/cli/seed.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +5 -1
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +8 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +31 -5
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +282 -113
- package/dist/cli.js.map +1 -1
- package/dist/core/dispatch.d.ts +23 -0
- package/dist/core/dispatch.d.ts.map +1 -1
- package/dist/core/dispatch.js +17 -0
- package/dist/core/dispatch.js.map +1 -1
- package/dist/core/pending-response.d.ts +25 -0
- package/dist/core/pending-response.d.ts.map +1 -0
- package/dist/core/pending-response.js +82 -0
- package/dist/core/pending-response.js.map +1 -0
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +5 -0
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +16 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +27 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +134 -6
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/core/zellij-adopt-discovery.d.ts.map +1 -1
- package/dist/core/zellij-adopt-discovery.js +53 -32
- package/dist/core/zellij-adopt-discovery.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +63 -1
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-onboarding.d.ts +46 -3
- package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
- package/dist/dashboard/bot-onboarding.js +86 -7
- package/dist/dashboard/bot-onboarding.js.map +1 -1
- package/dist/dashboard/federated-group-core.js +8 -4
- package/dist/dashboard/federated-group-core.js.map +1 -1
- package/dist/dashboard/federation-api.d.ts.map +1 -1
- package/dist/dashboard/federation-api.js +22 -2
- package/dist/dashboard/federation-api.js.map +1 -1
- package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
- package/dist/dashboard/web/bot-onboarding.js +139 -20
- package/dist/dashboard/web/bot-onboarding.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +36 -6
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard-web/app.js +171 -143
- package/dist/dashboard-web/style.css +58 -0
- package/dist/dashboard.js +37 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +5 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +17 -0
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +8 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +8 -0
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +4 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +34 -1
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +72 -11
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +3 -1
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +7 -2
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +8 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +36 -6
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/services/codex-paths.d.ts +7 -0
- package/dist/services/codex-paths.d.ts.map +1 -0
- package/dist/services/codex-paths.js +19 -0
- package/dist/services/codex-paths.js.map +1 -0
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +5 -4
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/pending-response-transaction-store.d.ts +12 -0
- package/dist/services/pending-response-transaction-store.d.ts.map +1 -0
- package/dist/services/pending-response-transaction-store.js +52 -0
- package/dist/services/pending-response-transaction-store.js.map +1 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +15 -1
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/voice/audio.d.ts +32 -0
- package/dist/services/voice/audio.d.ts.map +1 -0
- package/dist/services/voice/audio.js +114 -0
- package/dist/services/voice/audio.js.map +1 -0
- package/dist/services/voice/index.d.ts +32 -0
- package/dist/services/voice/index.d.ts.map +1 -0
- package/dist/services/voice/index.js +98 -0
- package/dist/services/voice/index.js.map +1 -0
- package/dist/services/voice/openai.d.ts +24 -0
- package/dist/services/voice/openai.d.ts.map +1 -0
- package/dist/services/voice/openai.js +47 -0
- package/dist/services/voice/openai.js.map +1 -0
- package/dist/services/voice/sami.d.ts +22 -0
- package/dist/services/voice/sami.d.ts.map +1 -0
- package/dist/services/voice/sami.js +128 -0
- package/dist/services/voice/sami.js.map +1 -0
- package/dist/services/voice/types.d.ts +32 -0
- package/dist/services/voice/types.d.ts.map +1 -0
- package/dist/services/voice/types.js +2 -0
- package/dist/services/voice/types.js.map +1 -0
- package/dist/setup/bot-config-editor.d.ts +9 -0
- package/dist/setup/bot-config-editor.d.ts.map +1 -1
- package/dist/setup/bot-config-editor.js +26 -0
- package/dist/setup/bot-config-editor.js.map +1 -1
- package/dist/setup/verify-permissions.js +3 -3
- package/dist/setup/verify-permissions.js.map +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +0 -1
- package/dist/skills/definitions.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.js +147 -7
- package/dist/worker.js.map +1 -1
- package/dist/workflows/events/payloads.d.ts +20 -20
- package/dist/workflows/events/schema.d.ts +188 -188
- 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
|
-
--
|
|
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,
|
|
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
|
-
|
|
2665
|
-
|
|
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
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
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
|
|
2825
|
-
|
|
2826
|
-
|
|
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
|
-
//
|
|
2957
|
-
//
|
|
2958
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
3069
|
-
|
|
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
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
|
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".
|