botmux 2.105.0 → 2.106.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 (82) hide show
  1. package/dist/adapters/cli/read-isolation.d.ts +2 -1
  2. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  3. package/dist/adapters/cli/read-isolation.js +51 -4
  4. package/dist/adapters/cli/read-isolation.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +19 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/command-handler.d.ts.map +1 -1
  9. package/dist/core/command-handler.js +2 -1
  10. package/dist/core/command-handler.js.map +1 -1
  11. package/dist/core/passthrough-commands.d.ts.map +1 -1
  12. package/dist/core/passthrough-commands.js +1 -1
  13. package/dist/core/passthrough-commands.js.map +1 -1
  14. package/dist/core/session-manager.d.ts +1 -1
  15. package/dist/core/session-manager.d.ts.map +1 -1
  16. package/dist/core/session-manager.js +22 -3
  17. package/dist/core/session-manager.js.map +1 -1
  18. package/dist/core/types.d.ts +3 -3
  19. package/dist/core/types.d.ts.map +1 -1
  20. package/dist/core/worker-pool.d.ts +4 -0
  21. package/dist/core/worker-pool.d.ts.map +1 -1
  22. package/dist/core/worker-pool.js +27 -9
  23. package/dist/core/worker-pool.js.map +1 -1
  24. package/dist/daemon.d.ts +31 -8
  25. package/dist/daemon.d.ts.map +1 -1
  26. package/dist/daemon.js +750 -67
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/dashboard/settings-write-applier.d.ts +5 -0
  29. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  30. package/dist/dashboard/settings-write-applier.js +2 -2
  31. package/dist/dashboard/settings-write-applier.js.map +1 -1
  32. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  33. package/dist/dashboard/web/settings-page.js +13 -4
  34. package/dist/dashboard/web/settings-page.js.map +1 -1
  35. package/dist/dashboard-web/app.js +1 -1
  36. package/dist/dashboard-web/chunks/settings-page-R23I3IZG.js +2 -0
  37. package/dist/dashboard-web/style.css +11 -0
  38. package/dist/dashboard.js +239 -62
  39. package/dist/dashboard.js.map +1 -1
  40. package/dist/i18n/en.d.ts.map +1 -1
  41. package/dist/i18n/en.js +1 -0
  42. package/dist/i18n/en.js.map +1 -1
  43. package/dist/i18n/zh.d.ts.map +1 -1
  44. package/dist/i18n/zh.js +1 -0
  45. package/dist/i18n/zh.js.map +1 -1
  46. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  47. package/dist/im/lark/event-dispatcher.js +11 -0
  48. package/dist/im/lark/event-dispatcher.js.map +1 -1
  49. package/dist/im/lark/message-parser.d.ts +6 -0
  50. package/dist/im/lark/message-parser.d.ts.map +1 -1
  51. package/dist/im/lark/message-parser.js +14 -0
  52. package/dist/im/lark/message-parser.js.map +1 -1
  53. package/dist/services/vc-meeting-runtime-store.d.ts +4 -0
  54. package/dist/services/vc-meeting-runtime-store.d.ts.map +1 -1
  55. package/dist/services/vc-meeting-runtime-store.js +26 -0
  56. package/dist/services/vc-meeting-runtime-store.js.map +1 -1
  57. package/dist/setup/open-platform-automation.d.ts +20 -0
  58. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  59. package/dist/setup/open-platform-automation.js +78 -1
  60. package/dist/setup/open-platform-automation.js.map +1 -1
  61. package/dist/types.d.ts +5 -0
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/vc-agent/cards.d.ts +2 -0
  64. package/dist/vc-agent/cards.d.ts.map +1 -1
  65. package/dist/vc-agent/cards.js +18 -2
  66. package/dist/vc-agent/cards.js.map +1 -1
  67. package/dist/vc-agent/normalizer.d.ts.map +1 -1
  68. package/dist/vc-agent/normalizer.js +59 -0
  69. package/dist/vc-agent/normalizer.js.map +1 -1
  70. package/dist/vc-agent/polling-source.d.ts +28 -0
  71. package/dist/vc-agent/polling-source.d.ts.map +1 -1
  72. package/dist/vc-agent/polling-source.js +57 -5
  73. package/dist/vc-agent/polling-source.js.map +1 -1
  74. package/dist/vc-agent/types.d.ts +1 -0
  75. package/dist/vc-agent/types.d.ts.map +1 -1
  76. package/dist/worker.js +48 -8
  77. package/dist/worker.js.map +1 -1
  78. package/dist/workflows/definition.d.ts +14 -14
  79. package/dist/workflows/events/payloads.d.ts +8 -8
  80. package/dist/workflows/events/schema.d.ts +452 -452
  81. package/package.json +1 -1
  82. package/dist/dashboard-web/chunks/settings-page-CRDIHRNE.js +0 -2
package/dist/dashboard.js CHANGED
@@ -63,8 +63,10 @@ import { startPlatformTunnelClient } from './platform/tunnel-client.js';
63
63
  import { applyPlatformTeamSync, getPlatformTeamSyncRev, listPlatformTeams } from './services/platform-team-store.js';
64
64
  import { getBotUnionId } from './services/bot-union-ids-store.js';
65
65
  import { cleanupIdleSessions, parseIdleCleanupHours } from './dashboard/session-cleanup.js';
66
+ import { automateOpenPlatformSetup } from './setup/open-platform-automation.js';
67
+ import { VC_MEETING_FEATURE_SCOPES, VC_MEETING_REALTIME_VOICE_SCOPES } from './setup/verify-permissions.js';
68
+ import { checkLarkCliVersion, MIN_LARK_CLI_VERSION_FOR_VC_BOT } from './vc-agent/polling-source.js';
66
69
  import { larkHosts } from './im/lark/lark-hosts.js';
67
- import { VC_MEETING_FEATURE_SCOPES, VC_MEETING_REALTIME_VOICE_SCOPES, } from './setup/verify-permissions.js';
68
70
  const SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret');
69
71
  const TOKEN_PATH = join(homedir(), '.botmux', '.dashboard-token');
70
72
  /** Per-daemon budget for the cross-daemon insight overview fan-out — bounds
@@ -261,52 +263,6 @@ function vcMeetingListenerBotOptions() {
261
263
  return [];
262
264
  }
263
265
  }
264
- async function fetchGrantedScopesForBotConfig(bot) {
265
- const brand = bot.brand === 'lark' ? 'lark' : 'feishu';
266
- const openApi = larkHosts(brand).openApi;
267
- const ac = new AbortController();
268
- const timer = setTimeout(() => ac.abort(), 10_000);
269
- try {
270
- const tokenRes = await fetch(`${openApi}/open-apis/auth/v3/tenant_access_token/internal`, {
271
- method: 'POST',
272
- headers: { 'Content-Type': 'application/json' },
273
- body: JSON.stringify({ app_id: bot.larkAppId, app_secret: bot.larkAppSecret }),
274
- signal: ac.signal,
275
- });
276
- const tokenData = await tokenRes.json();
277
- if (tokenData?.code !== 0 || typeof tokenData?.tenant_access_token !== 'string') {
278
- return { ok: false, error: `vcMeetingAgent_listenerBot_invalid_credentials: code=${tokenData?.code ?? '?'} msg=${tokenData?.msg ?? ''}` };
279
- }
280
- const infoRes = await fetch(`${openApi}/open-apis/application/v6/applications/${bot.larkAppId}?lang=zh_cn`, { headers: { Authorization: `Bearer ${tokenData.tenant_access_token}` }, signal: ac.signal });
281
- const infoData = await infoRes.json();
282
- if (infoData?.code === 99991672) {
283
- return { ok: false, error: 'vcMeetingAgent_listenerBot_scope_check_unavailable: missing application:application:self_manage' };
284
- }
285
- if (infoData?.code !== 0) {
286
- return { ok: false, error: `vcMeetingAgent_listenerBot_scope_check_failed: code=${infoData?.code ?? '?'} msg=${infoData?.msg ?? ''}` };
287
- }
288
- const scopesRaw = infoData.data?.app?.scopes
289
- ?? infoData.data?.application?.scopes
290
- ?? infoData.data?.scopes
291
- ?? [];
292
- if (!Array.isArray(scopesRaw) || scopesRaw.length === 0) {
293
- return { ok: false, error: 'vcMeetingAgent_listenerBot_scope_check_failed: empty scopes' };
294
- }
295
- const granted = new Set(scopesRaw.map(s => typeof s === 'string' ? s : s?.scope).filter(Boolean));
296
- return { ok: true, granted };
297
- }
298
- catch (err) {
299
- return {
300
- ok: false,
301
- error: ac.signal.aborted
302
- ? 'vcMeetingAgent_listenerBot_scope_check_timeout'
303
- : `vcMeetingAgent_listenerBot_scope_check_failed: ${err?.message ?? err}`,
304
- };
305
- }
306
- finally {
307
- clearTimeout(timer);
308
- }
309
- }
310
266
  async function validateVcMeetingListenerBotAppId(appId) {
311
267
  let bots;
312
268
  try {
@@ -318,19 +274,6 @@ async function validateVcMeetingListenerBotAppId(appId) {
318
274
  const bot = bots.find(b => b.larkAppId === appId);
319
275
  if (!bot)
320
276
  return { ok: false, error: 'vcMeetingAgent_listenerBot_unknown' };
321
- const cfg = bot.vcMeetingAgent ?? {};
322
- if (!cfg.larkCliProfile)
323
- return { ok: false, error: 'vcMeetingAgent_listenerBot_missing_larkCliProfile' };
324
- const granted = await fetchGrantedScopesForBotConfig(bot);
325
- if (!granted.ok)
326
- return granted;
327
- const required = cfg.realtimeVoice?.enabled === true
328
- ? [...VC_MEETING_FEATURE_SCOPES, ...VC_MEETING_REALTIME_VOICE_SCOPES]
329
- : VC_MEETING_FEATURE_SCOPES;
330
- const missing = required.filter(scope => !granted.granted.has(scope.name));
331
- if (missing.length > 0) {
332
- return { ok: false, error: `vcMeetingAgent_listenerBot_missing_scopes: ${missing.map(s => s.name).join(',')}` };
333
- }
334
277
  return { ok: true };
335
278
  }
336
279
  function normalizeVcMeetingAgentRecord(raw) {
@@ -368,11 +311,220 @@ async function reloadVcMeetingBotConfigOnDaemons(appIds) {
368
311
  }).catch(() => undefined);
369
312
  }));
370
313
  }
314
+ /**
315
+ * Fetch the set of actually granted scopes for a bot app via Feishu Open API.
316
+ * Used after automateOpenPlatformSetup to verify that VC meeting scopes were
317
+ * actually applied (not just "requested").
318
+ */
319
+ async function fetchGrantedScopesForBot(bot) {
320
+ const brand = bot.brand === 'lark' ? 'lark' : 'feishu';
321
+ const openApi = larkHosts(brand).openApi;
322
+ const ac = new AbortController();
323
+ const timer = setTimeout(() => ac.abort(), 10_000);
324
+ try {
325
+ const tokenRes = await fetch(`${openApi}/open-apis/auth/v3/tenant_access_token/internal`, {
326
+ method: 'POST',
327
+ headers: { 'Content-Type': 'application/json' },
328
+ body: JSON.stringify({ app_id: bot.larkAppId, app_secret: bot.larkAppSecret }),
329
+ signal: ac.signal,
330
+ });
331
+ const tokenData = await tokenRes.json();
332
+ if (tokenData?.code !== 0 || typeof tokenData?.tenant_access_token !== 'string') {
333
+ return { ok: false, error: `invalid_credentials: code=${tokenData?.code ?? '?'} msg=${tokenData?.msg ?? ''}` };
334
+ }
335
+ const infoRes = await fetch(`${openApi}/open-apis/application/v6/applications/${bot.larkAppId}?lang=zh_cn`, { headers: { Authorization: `Bearer ${tokenData.tenant_access_token}` }, signal: ac.signal });
336
+ const infoData = await infoRes.json();
337
+ if (infoData?.code === 99991672) {
338
+ return { ok: false, error: 'missing application:application:self_manage' };
339
+ }
340
+ if (infoData?.code !== 0) {
341
+ return { ok: false, error: `scope_check_failed: code=${infoData?.code ?? '?'} msg=${infoData?.msg ?? ''}` };
342
+ }
343
+ const scopesRaw = infoData.data?.app?.scopes
344
+ ?? infoData.data?.application?.scopes
345
+ ?? infoData.data?.scopes
346
+ ?? [];
347
+ const granted = new Set(scopesRaw.map((s) => typeof s === 'string' ? s : s?.scope).filter(Boolean));
348
+ return { ok: true, granted };
349
+ }
350
+ catch (err) {
351
+ return {
352
+ ok: false,
353
+ error: ac.signal.aborted ? 'timeout' : `${err?.message ?? err}`,
354
+ };
355
+ }
356
+ finally {
357
+ clearTimeout(timer);
358
+ }
359
+ }
360
+ /**
361
+ * Validate that a bot has the required VC meeting scopes granted.
362
+ * Checks both VC_MEETING_FEATURE_SCOPES and (if realtimeVoice is enabled)
363
+ * VC_MEETING_REALTIME_VOICE_SCOPES.
364
+ */
365
+ async function validateVcMeetingScopesForBot(bot) {
366
+ const result = await fetchGrantedScopesForBot(bot);
367
+ if (!result.ok)
368
+ return { ok: false, error: result.error };
369
+ const needsRealtime = bot.vcMeetingAgent?.realtimeVoice?.enabled === true;
370
+ const required = needsRealtime
371
+ ? [...VC_MEETING_FEATURE_SCOPES, ...VC_MEETING_REALTIME_VOICE_SCOPES]
372
+ : VC_MEETING_FEATURE_SCOPES;
373
+ const missing = required.filter(s => !result.granted.has(s.name));
374
+ if (missing.length > 0) {
375
+ return { ok: false, error: `缺少权限: ${missing.map(s => s.name).join(', ')}` };
376
+ }
377
+ return { ok: true };
378
+ }
379
+ /**
380
+ * Wait for FeishuLoginManager to produce a QR code after start().
381
+ * The start() method returns immediately with status='starting'; the QR code
382
+ * is set asynchronously in the onQrCode callback. Poll until qrDataUrl appears
383
+ * or we hit the timeout.
384
+ */
385
+ async function waitForFeishuLoginQr(timeoutMs = 8_000, intervalMs = 200) {
386
+ const start = Date.now();
387
+ while (Date.now() - start < timeoutMs) {
388
+ const snap = feishuLogin.get();
389
+ if (snap?.qrDataUrl)
390
+ return snap.qrDataUrl;
391
+ // Also stop waiting if login already failed
392
+ if (snap?.status === 'failed' || snap?.status === 'success')
393
+ return null;
394
+ await new Promise(r => setTimeout(r, intervalMs));
395
+ }
396
+ return null;
397
+ }
371
398
  async function syncVcMeetingListenerBotConfig(listenerBotAppId, previousListenerBotAppId) {
372
399
  const nextAppId = listenerBotAppId?.trim() || null;
373
400
  const prevAppId = previousListenerBotAppId?.trim() || null;
374
401
  if (!nextAppId && !prevAppId)
375
402
  return { ok: true };
403
+ // Require lark-cli >= MIN_LARK_CLI_VERSION_FOR_VC_BOT for VC bot meeting commands
404
+ // (vc +meeting-join/events/message-send --as bot). Earlier versions silently reject
405
+ // `--as bot` with "this command only supports: user", so the listener bot can
406
+ // never actually join a meeting.
407
+ if (nextAppId) {
408
+ const larkCli = checkLarkCliVersion();
409
+ if (!larkCli) {
410
+ return { ok: false, error: 'vcMeetingAgent_listenerBot_larkCli_not_found: 未检测到 lark-cli,请先安装 `npm i -g @larksuite/cli`' };
411
+ }
412
+ if (!larkCli.meetsVcBotRequirement) {
413
+ return {
414
+ ok: false,
415
+ error: `vcMeetingAgent_listenerBot_larkCli_too_old: 当前 lark-cli ${larkCli.version} 不支持 VC bot 入会,需要 >= ${MIN_LARK_CLI_VERSION_FOR_VC_BOT}。请运行 \`npm i -g @larksuite/cli@latest\` 升级`,
416
+ };
417
+ }
418
+ }
419
+ // Best-effort auto-import VC meeting scopes via Open Platform automation.
420
+ // Run BEFORE writing bots.json so that hard failures (missing session, needs QR)
421
+ // don't leave per-bot vcMeetingAgent in a half-configured state.
422
+ // For `brand: 'lark'` bots the open-platform automation only supports feishu.cn;
423
+ // skip it silently and let the user configure manually.
424
+ if (nextAppId) {
425
+ const bots = loadBotConfigs();
426
+ const bot = bots.find(b => b.larkAppId === nextAppId);
427
+ const brand = bot?.brand === 'lark' ? 'lark' : 'feishu';
428
+ if (brand === 'lark') {
429
+ logger.info(`[vc-agent] skipping open-platform automation for lark-brand bot ${nextAppId} (feishu.cn only)`);
430
+ // For lark brand, still validate that required scopes exist before saving
431
+ if (bot) {
432
+ const scopeCheck = await validateVcMeetingScopesForBot(bot);
433
+ if (!scopeCheck.ok) {
434
+ return { ok: false, error: `vcMeetingAgent_listenerBot_missing_scopes: ${scopeCheck.error}` };
435
+ }
436
+ }
437
+ }
438
+ else {
439
+ try {
440
+ const result = await automateOpenPlatformSetup({
441
+ appId: nextAppId,
442
+ brand,
443
+ maxWaitMs: 5_000,
444
+ onStatus: (msg) => logger.info(`[vc-agent] scope auto-import: ${msg}`),
445
+ });
446
+ if (result.ok) {
447
+ logger.info(`[vc-agent] auto-imported ${result.scopeCount} scopes, subscribed ${result.subscribedEventCount} events for listener bot ${nextAppId}`);
448
+ if (result.scopeWarning)
449
+ logger.warn(`[vc-agent] scope import warning: ${result.scopeWarning}`);
450
+ if (result.eventWarning)
451
+ logger.warn(`[vc-agent] event subscription warning: ${result.eventWarning}`);
452
+ // Post-validation: verify VC meeting scopes are actually granted after automation.
453
+ // The internal scope/update may silently skip some scopes (e.g. not available
454
+ // in this tenant). Without this check, a bot without VC scopes could be saved
455
+ // as global listener and silently drop all meeting events.
456
+ if (bot) {
457
+ const scopeCheck = await validateVcMeetingScopesForBot(bot);
458
+ if (!scopeCheck.ok) {
459
+ return {
460
+ ok: false,
461
+ error: `vcMeetingAgent_listenerBot_missing_scopes_after_auto: ${scopeCheck.error}。请到开放平台手动开通 VC 会议权限后重试。`,
462
+ };
463
+ }
464
+ }
465
+ // Event subscription is also critical: if all 3 event update endpoints
466
+ // failed (eventWarning set, subscribedEventCount === 0), the bot won't
467
+ // receive vc.bot.meeting_*_v1 push events → meeting invite black hole.
468
+ if (result.eventWarning && result.subscribedEventCount === 0) {
469
+ return {
470
+ ok: false,
471
+ error: `vcMeetingAgent_listenerBot_event_subscribe_failed: 事件订阅全部失败(${result.eventWarning}),bot 无法接收会议邀请事件。请到开放平台手动订阅 VC 会议事件后重试。`,
472
+ };
473
+ }
474
+ }
475
+ else {
476
+ const reason = result.reason;
477
+ // Session/login-related failures are hard failures — return QR so user can re-login.
478
+ // Without a valid Open Platform session, scope/event auto-import is impossible.
479
+ if (reason === 'missing_session'
480
+ || reason === 'invalid_session'
481
+ || reason === 'missing_csrf'
482
+ || reason === 'qr_expired'
483
+ || reason === 'timeout'
484
+ || reason === 'login_failed') {
485
+ feishuLogin.start();
486
+ // feishuLogin.start() returns immediately with status='starting'; the QR
487
+ // code is set asynchronously in onQrCode. Wait briefly for it to be ready
488
+ // so the frontend can display it inline instead of showing an error without
489
+ // a scan entry.
490
+ const qrDataUrl = await waitForFeishuLoginQr();
491
+ const hint = '请用飞书扫码完成开放平台登录,登录后重新选择监听 bot 即可自动配置权限';
492
+ return {
493
+ ok: false,
494
+ error: `vcMeetingAgent_listenerBot_scope_auto_import_failed: ${reason}: ${hint}`,
495
+ feishuLoginQr: qrDataUrl ?? undefined,
496
+ };
497
+ }
498
+ // Non-login failures (network, api_error, etc.) are best-effort — don't
499
+ // block the save. The user can fix scopes manually in the console.
500
+ logger.warn(`[vc-agent] open-platform automation failed for ${nextAppId}: ${reason}: ${result.message}`);
501
+ // Even on non-login automation failure, verify scopes before saving —
502
+ // if the bot genuinely lacks VC permissions, don't silently make it listener.
503
+ if (bot) {
504
+ const scopeCheck = await validateVcMeetingScopesForBot(bot);
505
+ if (!scopeCheck.ok) {
506
+ return {
507
+ ok: false,
508
+ error: `vcMeetingAgent_listenerBot_missing_scopes: ${scopeCheck.error}。自动化配置失败(${reason})且权限未满足,请手动开通后重试。`,
509
+ };
510
+ }
511
+ }
512
+ // Also check event subscription status — if event update failed (all 3
513
+ // endpoints) before the downstream api_error hit, the bot still won't
514
+ // receive meeting invite events → listener black hole.
515
+ if (result.eventWarning && (result.subscribedEventCount ?? 0) === 0) {
516
+ return {
517
+ ok: false,
518
+ error: `vcMeetingAgent_listenerBot_event_subscribe_failed: 事件订阅失败(${result.eventWarning}),bot 无法接收会议邀请事件。自动化配置失败(${reason}),请手动订阅 VC 会议事件后重试。`,
519
+ };
520
+ }
521
+ }
522
+ }
523
+ catch (err) {
524
+ logger.warn(`[vc-agent] open-platform automation error for ${nextAppId}: ${err?.message ?? err}`);
525
+ }
526
+ }
527
+ }
376
528
  const changedAppIds = new Set();
377
529
  try {
378
530
  const path = requireConfigPath();
@@ -391,6 +543,23 @@ async function syncVcMeetingListenerBotConfig(listenerBotAppId, previousListener
391
543
  next.dashboardManagedListener = true;
392
544
  entryChanged = true;
393
545
  }
546
+ if (!next.larkCliProfile) {
547
+ next.larkCliProfile = nextAppId;
548
+ entryChanged = true;
549
+ }
550
+ const mc = next.meetingConsumer;
551
+ if (!mc || typeof mc !== 'object' || Array.isArray(mc)) {
552
+ next.meetingConsumer = { enabled: true };
553
+ entryChanged = true;
554
+ }
555
+ else {
556
+ const mcRec = mc;
557
+ if (mcRec.enabled !== true) {
558
+ mcRec.enabled = true;
559
+ next.meetingConsumer = mcRec;
560
+ entryChanged = true;
561
+ }
562
+ }
394
563
  if (entryChanged) {
395
564
  compactVcMeetingAgentEntry(entry, next);
396
565
  changed = true;
@@ -426,6 +595,7 @@ async function syncVcMeetingListenerBotConfig(listenerBotAppId, previousListener
426
595
  function resolveDashboardSettings() {
427
596
  const global = readGlobalConfig();
428
597
  const dashboard = global.dashboard ?? {};
598
+ const larkCli = checkLarkCliVersion();
429
599
  return {
430
600
  publicReadOnly: dashboard.publicReadOnly ?? config.dashboard.publicReadOnly,
431
601
  openTerminalInFeishu: dashboard.openTerminalInFeishu === true,
@@ -434,6 +604,9 @@ function resolveDashboardSettings() {
434
604
  enabled: global.vcMeetingAgent?.enabled !== false,
435
605
  listenerBotAppId: global.vcMeetingAgent?.listenerBotAppId ?? null,
436
606
  listenerBotOptions: vcMeetingListenerBotOptions(),
607
+ larkCliVersion: larkCli?.version ?? null,
608
+ larkCliMeetsRequirement: larkCli?.meetsVcBotRequirement ?? false,
609
+ larkCliMinVersion: MIN_LARK_CLI_VERSION_FOR_VC_BOT,
437
610
  },
438
611
  repoPickerMode: global.repoPickerMode ?? 'all',
439
612
  maintenance: global.maintenance ?? {},
@@ -1453,8 +1626,12 @@ const server = createServer(async (req, res) => {
1453
1626
  return jsonRes(res, 400, { ok: false, error: 'bad_json' });
1454
1627
  }
1455
1628
  const result = await applySettingsWrite(parsed, settingsWriteApplierDeps);
1456
- if (!result.ok)
1457
- return jsonRes(res, 400, { ok: false, error: result.error });
1629
+ if (!result.ok) {
1630
+ const body = { ok: false, error: result.error };
1631
+ if ('feishuLoginQr' in result && result.feishuLoginQr)
1632
+ body.feishuLoginQr = result.feishuLoginQr;
1633
+ return jsonRes(res, 400, body);
1634
+ }
1458
1635
  return jsonRes(res, 200, { ok: true, settings: result.settings });
1459
1636
  }
1460
1637
  // ─── Version & manual update ─────────────────────────────────────────────