botmux 2.101.1 → 2.103.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 (145) hide show
  1. package/dist/adapters/backend/sandbox.d.ts +13 -1
  2. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  3. package/dist/adapters/backend/sandbox.js +33 -12
  4. package/dist/adapters/backend/sandbox.js.map +1 -1
  5. package/dist/adapters/cli/coco.d.ts.map +1 -1
  6. package/dist/adapters/cli/coco.js +6 -1
  7. package/dist/adapters/cli/coco.js.map +1 -1
  8. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  9. package/dist/adapters/cli/codex-app.js +3 -1
  10. package/dist/adapters/cli/codex-app.js.map +1 -1
  11. package/dist/adapters/cli/codex.d.ts.map +1 -1
  12. package/dist/adapters/cli/codex.js +7 -1
  13. package/dist/adapters/cli/codex.js.map +1 -1
  14. package/dist/adapters/cli/mtr.d.ts.map +1 -1
  15. package/dist/adapters/cli/mtr.js +4 -1
  16. package/dist/adapters/cli/mtr.js.map +1 -1
  17. package/dist/adapters/cli/opencode.d.ts.map +1 -1
  18. package/dist/adapters/cli/opencode.js +4 -1
  19. package/dist/adapters/cli/opencode.js.map +1 -1
  20. package/dist/adapters/cli/traex.d.ts.map +1 -1
  21. package/dist/adapters/cli/traex.js +4 -1
  22. package/dist/adapters/cli/traex.js.map +1 -1
  23. package/dist/adapters/cli/types.d.ts +4 -1
  24. package/dist/adapters/cli/types.d.ts.map +1 -1
  25. package/dist/bot-registry.d.ts +14 -0
  26. package/dist/bot-registry.d.ts.map +1 -1
  27. package/dist/bot-registry.js +9 -0
  28. package/dist/bot-registry.js.map +1 -1
  29. package/dist/cli.d.ts +9 -0
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +141 -6
  32. package/dist/cli.js.map +1 -1
  33. package/dist/core/command-handler.d.ts.map +1 -1
  34. package/dist/core/command-handler.js +22 -8
  35. package/dist/core/command-handler.js.map +1 -1
  36. package/dist/core/cost-calculator.d.ts.map +1 -1
  37. package/dist/core/cost-calculator.js +6 -0
  38. package/dist/core/cost-calculator.js.map +1 -1
  39. package/dist/core/dashboard-ipc-server.d.ts +9 -0
  40. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  41. package/dist/core/dashboard-ipc-server.js +96 -6
  42. package/dist/core/dashboard-ipc-server.js.map +1 -1
  43. package/dist/core/local-terminal-opener.d.ts +43 -0
  44. package/dist/core/local-terminal-opener.d.ts.map +1 -0
  45. package/dist/core/local-terminal-opener.js +221 -0
  46. package/dist/core/local-terminal-opener.js.map +1 -0
  47. package/dist/core/working-dir.d.ts +12 -1
  48. package/dist/core/working-dir.d.ts.map +1 -1
  49. package/dist/core/working-dir.js +20 -3
  50. package/dist/core/working-dir.js.map +1 -1
  51. package/dist/daemon.d.ts.map +1 -1
  52. package/dist/daemon.js +52 -8
  53. package/dist/daemon.js.map +1 -1
  54. package/dist/dashboard/bot-onboarding.d.ts +28 -0
  55. package/dist/dashboard/bot-onboarding.d.ts.map +1 -1
  56. package/dist/dashboard/bot-onboarding.js +20 -0
  57. package/dist/dashboard/bot-onboarding.js.map +1 -1
  58. package/dist/dashboard/bot-payload.d.ts +2 -0
  59. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  60. package/dist/dashboard/bot-payload.js +4 -0
  61. package/dist/dashboard/bot-payload.js.map +1 -1
  62. package/dist/dashboard/feishu-login.d.ts +64 -0
  63. package/dist/dashboard/feishu-login.d.ts.map +1 -0
  64. package/dist/dashboard/feishu-login.js +99 -0
  65. package/dist/dashboard/feishu-login.js.map +1 -0
  66. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  67. package/dist/dashboard/web/bot-defaults.js +243 -1
  68. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  69. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  70. package/dist/dashboard/web/bot-onboarding.js +4 -1
  71. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  72. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  73. package/dist/dashboard/web/i18n.js +52 -8
  74. package/dist/dashboard/web/i18n.js.map +1 -1
  75. package/dist/dashboard-web/app.js +6 -6
  76. package/dist/dashboard-web/chunks/bot-defaults-page-ZVBKVYIG.js +380 -0
  77. package/dist/dashboard-web/chunks/chunk-HC7ANVX7.js +31 -0
  78. package/dist/dashboard-web/chunks/{chunk-ECXPBQ3J.js → chunk-NNXPEJ7S.js} +1 -1
  79. package/dist/dashboard-web/chunks/{connectors-UGTF4Q4I.js → connectors-POZADN75.js} +1 -1
  80. package/dist/dashboard-web/chunks/{groups-page-IKTCMQ4A.js → groups-page-UQFQ6DRE.js} +1 -1
  81. package/dist/dashboard-web/chunks/{insights-page-7CCSHEKM.js → insights-page-ATW2W5KP.js} +1 -1
  82. package/dist/dashboard-web/chunks/{overview-page-JOJW65P5.js → overview-page-Z7OV54XU.js} +1 -1
  83. package/dist/dashboard-web/chunks/{roles-page-E2VN2DLV.js → roles-page-4BJVXK2E.js} +1 -1
  84. package/dist/dashboard-web/chunks/{schedules-6QIHJIHX.js → schedules-XLETZ2EP.js} +1 -1
  85. package/dist/dashboard-web/chunks/{sessions-page-QN2TARF2.js → sessions-page-RMMQUGXJ.js} +1 -1
  86. package/dist/dashboard-web/chunks/{settings-page-WWTXB577.js → settings-page-PWKI4TWF.js} +1 -1
  87. package/dist/dashboard-web/chunks/{skills-page-2FAUPQ6H.js → skills-page-SH7KUD45.js} +1 -1
  88. package/dist/dashboard-web/chunks/{team-federation-page-PTBQQSFS.js → team-federation-page-JAJXFLXA.js} +1 -1
  89. package/dist/dashboard-web/chunks/{workflows-ZNBB5KFV.js → workflows-FRJ2DJAO.js} +1 -1
  90. package/dist/dashboard-web/style.css +99 -0
  91. package/dist/dashboard.js +170 -17
  92. package/dist/dashboard.js.map +1 -1
  93. package/dist/i18n/en.d.ts.map +1 -1
  94. package/dist/i18n/en.js +13 -4
  95. package/dist/i18n/en.js.map +1 -1
  96. package/dist/i18n/zh.d.ts.map +1 -1
  97. package/dist/i18n/zh.js +13 -4
  98. package/dist/i18n/zh.js.map +1 -1
  99. package/dist/im/lark/card-builder.d.ts.map +1 -1
  100. package/dist/im/lark/card-builder.js +20 -0
  101. package/dist/im/lark/card-builder.js.map +1 -1
  102. package/dist/im/lark/card-handler.d.ts.map +1 -1
  103. package/dist/im/lark/card-handler.js +36 -12
  104. package/dist/im/lark/card-handler.js.map +1 -1
  105. package/dist/im/lark/client.d.ts.map +1 -1
  106. package/dist/im/lark/client.js +25 -5
  107. package/dist/im/lark/client.js.map +1 -1
  108. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  109. package/dist/im/lark/event-dispatcher.js +36 -3
  110. package/dist/im/lark/event-dispatcher.js.map +1 -1
  111. package/dist/services/bot-config-store.d.ts +4 -1
  112. package/dist/services/bot-config-store.d.ts.map +1 -1
  113. package/dist/services/bot-config-store.js +19 -1
  114. package/dist/services/bot-config-store.js.map +1 -1
  115. package/dist/services/bot-union-ids-store.d.ts +15 -0
  116. package/dist/services/bot-union-ids-store.d.ts.map +1 -1
  117. package/dist/services/bot-union-ids-store.js +25 -5
  118. package/dist/services/bot-union-ids-store.js.map +1 -1
  119. package/dist/services/open-platform-rename.d.ts +45 -0
  120. package/dist/services/open-platform-rename.d.ts.map +1 -0
  121. package/dist/services/open-platform-rename.js +182 -0
  122. package/dist/services/open-platform-rename.js.map +1 -0
  123. package/dist/services/platform-team-store.d.ts +5 -0
  124. package/dist/services/platform-team-store.d.ts.map +1 -1
  125. package/dist/services/platform-team-store.js +16 -0
  126. package/dist/services/platform-team-store.js.map +1 -1
  127. package/dist/services/sandbox-land.d.ts.map +1 -1
  128. package/dist/services/sandbox-land.js +16 -4
  129. package/dist/services/sandbox-land.js.map +1 -1
  130. package/dist/services/usage-ledger.d.ts.map +1 -1
  131. package/dist/services/usage-ledger.js +14 -2
  132. package/dist/services/usage-ledger.js.map +1 -1
  133. package/dist/setup/open-platform-automation.d.ts +8 -0
  134. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  135. package/dist/setup/open-platform-automation.js +5 -3
  136. package/dist/setup/open-platform-automation.js.map +1 -1
  137. package/dist/utils/bot-routing.d.ts +26 -3
  138. package/dist/utils/bot-routing.d.ts.map +1 -1
  139. package/dist/utils/bot-routing.js +38 -4
  140. package/dist/utils/bot-routing.js.map +1 -1
  141. package/dist/workflows/events/payloads.d.ts +6 -6
  142. package/dist/workflows/events/schema.d.ts +12 -12
  143. package/package.json +1 -1
  144. package/dist/dashboard-web/chunks/bot-defaults-page-HOGLABBC.js +0 -358
  145. package/dist/dashboard-web/chunks/chunk-MR57RUVP.js +0 -31
package/dist/dashboard.js CHANGED
@@ -26,12 +26,13 @@ import { handleFederationApi } from './dashboard/federation-api.js';
26
26
  import { handleFederationSpokeApi, syncAllMemberships, autoBindOwnerIfUnambiguous } from './dashboard/federation-spoke-api.js';
27
27
  import { getRunsDir } from './workflows/runs-dir.js';
28
28
  import { BotOnboardingManager } from './dashboard/bot-onboarding.js';
29
+ import { FeishuLoginManager } from './dashboard/feishu-login.js';
29
30
  import { CLI_SELECT_OPTIONS, resolveCliSelection, isTtadkWrapper, ttadkAcceptsModel, TTADK_DEFAULT_MODEL, TTADK_MODEL_SUGGESTIONS, } from './setup/cli-selection.js';
30
31
  import { invalidWorkingDirs } from './utils/working-dir.js';
31
32
  import { invalidateGlobalConfigCache, mergeGlobalConfig, readGlobalConfig } from './global-config.js';
32
33
  import { buildDashboardUrl } from './core/dashboard-url.js';
33
34
  import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js';
34
- import { isLocalDevInstall, botmuxVersion } from './utils/install-info.js';
35
+ import { isLocalDevInstall, botmuxVersion, botmuxCliEntry } from './utils/install-info.js';
35
36
  import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js';
36
37
  import { fetchLatestVersion, fetchReleasesSince, isNewerVersion } from './core/update-check.js';
37
38
  import { GITHUB_REPO } from './core/restart-report.js';
@@ -168,7 +169,73 @@ function signDaemonTokenHeaders() {
168
169
  mkdirSync(REGISTRY_DIR, { recursive: true });
169
170
  const registry = new DaemonRegistry(REGISTRY_DIR);
170
171
  const aggregator = new Aggregator();
171
- const botOnboarding = new BotOnboardingManager({ botsJsonPath: BOTS_JSON_PATH });
172
+ /**
173
+ * Bring a freshly-onboarded bot online without a fleet-wide restart by spawning
174
+ * `botmux start-bot <appId> --json` (see cli.ts:ensureBotDaemonStarted). The new
175
+ * daemon is forked+supervised by pm2 (reparented off this process), self-registers
176
+ * and opens its Feishu WSClient, then publishes a descriptor the DaemonRegistry
177
+ * auto-discovers — so no dashboard reload is needed either. Runs `botmux` on the
178
+ * SAME host as the dashboard (shared pm2 home / bots.json — the documented
179
+ * dashboard↔daemon co-location assumption). Resolves best-effort; the caller
180
+ * falls back to the restart hint on failure.
181
+ */
182
+ function spawnStartBotLive(appId) {
183
+ return new Promise((resolve) => {
184
+ let out = '';
185
+ let err = '';
186
+ let settled = false;
187
+ const done = (r) => { if (!settled) {
188
+ settled = true;
189
+ resolve(r);
190
+ } };
191
+ try {
192
+ const child = spawn(process.execPath, [botmuxCliEntry(), 'start-bot', appId, '--json'], {
193
+ stdio: ['ignore', 'pipe', 'pipe'],
194
+ env: process.env,
195
+ });
196
+ const timer = setTimeout(() => {
197
+ try {
198
+ child.kill('SIGKILL');
199
+ }
200
+ catch { /* already gone */ }
201
+ done({ ok: false, message: 'start-bot 超时(30s)' });
202
+ }, 30_000);
203
+ timer.unref?.();
204
+ child.stdout?.on('data', (d) => { out += String(d); });
205
+ child.stderr?.on('data', (d) => { err += String(d); });
206
+ child.on('error', (e) => {
207
+ clearTimeout(timer);
208
+ done({ ok: false, message: e instanceof Error ? e.message : String(e) });
209
+ });
210
+ child.on('exit', (code) => {
211
+ clearTimeout(timer);
212
+ // `start-bot --json` prints a single StartBotLiveResult object; prefer its
213
+ // own message/processName over the raw exit code.
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(out.trim());
217
+ }
218
+ catch { /* non-JSON → fall through */ }
219
+ if (code === 0) {
220
+ done({ ok: true, message: parsed?.processName ? `${parsed.processName} 已上线` : undefined });
221
+ }
222
+ else {
223
+ done({ ok: false, message: parsed?.message || err.trim() || `start-bot 退出码 ${code}` });
224
+ }
225
+ });
226
+ }
227
+ catch (e) {
228
+ done({ ok: false, message: e instanceof Error ? e.message : String(e) });
229
+ }
230
+ });
231
+ }
232
+ const botOnboarding = new BotOnboardingManager({
233
+ botsJsonPath: BOTS_JSON_PATH,
234
+ startBotLive: spawnStartBotLive,
235
+ });
236
+ // 飞书 Web 登录态刷新(机器人改名缺登录态时的 dashboard 扫码入口)。机器级单例,
237
+ // 写 ~/.botmux/feishu-session.json,与 setup / onboarding 复用同一份登录态。
238
+ const feishuLogin = new FeishuLoginManager();
172
239
  const subs = new Map();
173
240
  const attaching = new Set(); // dedup concurrent attaches per appId
174
241
  function resolveDashboardSettings() {
@@ -1521,6 +1588,15 @@ const server = createServer(async (req, res) => {
1521
1588
  return jsonRes(res, 404, { ok: false, error: 'unknown_onboarding_job' });
1522
1589
  return jsonRes(res, 200, { job });
1523
1590
  }
1591
+ // 飞书 Web 登录态刷新(改名缺登录态 → dashboard 扫码)。POST 受 dashboard 的
1592
+ // 写操作 auth 闸保护(非 GET 需 owner cookie);GET 仅暴露二维码+状态,扫码
1593
+ // 授权的是扫码人自己的账号,风险模型与 onboarding 第二个二维码一致。
1594
+ if (req.method === 'POST' && url.pathname === '/api/feishu-login/start') {
1595
+ return jsonRes(res, 202, { login: feishuLogin.start() });
1596
+ }
1597
+ if (req.method === 'GET' && url.pathname === '/api/feishu-login/status') {
1598
+ return jsonRes(res, 200, { login: feishuLogin.get() });
1599
+ }
1524
1600
  let m;
1525
1601
  if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/(close|locate|resume|restart|start)$/))) {
1526
1602
  const sid = decodeURIComponent(m[1]);
@@ -2204,6 +2280,26 @@ const server = createServer(async (req, res) => {
2204
2280
  res.end(await upstream.text());
2205
2281
  return;
2206
2282
  }
2283
+ // PUT /api/bots/:appId/rename — proxy to that bot's daemon. Body
2284
+ // `{ name: string }`. Daemon tries the Open Platform automation first
2285
+ // (really renames the Feishu app + publishes a version); on failure it
2286
+ // falls back to the botmux-side display name and reports `warning`.
2287
+ let mBotRename;
2288
+ if (req.method === 'PUT' && (mBotRename = url.pathname.match(/^\/api\/bots\/([^/]+)\/rename$/))) {
2289
+ const appId = decodeURIComponent(mBotRename[1]);
2290
+ const chunks = [];
2291
+ for await (const c of req)
2292
+ chunks.push(c);
2293
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
2294
+ const upstream = await proxyToDaemon(appId, `/api/bot-rename`, {
2295
+ method: 'PUT',
2296
+ headers: { 'content-type': 'application/json' },
2297
+ body: raw,
2298
+ });
2299
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
2300
+ res.end(await upstream.text());
2301
+ return;
2302
+ }
2207
2303
  // PUT /api/bots/:appId/max-live-workers — proxy to that bot's daemon. Body
2208
2304
  // `{ maxLiveWorkers: number | null }` (null = clear → fall back to the
2209
2305
  // built-in default of 30; a positive integer overrides it).
@@ -2703,17 +2799,52 @@ function handlePlatformTeamSync(payload) {
2703
2799
  logger.info(`[platform-tunnel] team-sync 已应用 rev=${applied.rev} teams=${applied.teams.length}`);
2704
2800
  void maybeAnnounceHallPresence();
2705
2801
  }
2706
- // 大厅打卡(union_id 自学触发)节流:per-bot 最小间隔 + 每进程尝试上限。打卡消息
2707
- // 本身只有 bot 可见(大厅是 bot-only 群),上限只是防御发送持续失败时的无谓重试。
2708
- const hallAnnounceState = new Map();
2802
+ // 大厅打卡节流:按「发送 bot ×大厅」记最小间隔与尝试上限——按 bot 记会让多团队
2803
+ // bot 在第一个大厅烧光预算后,新加入的大厅永远轮空(实测踩过)。只有真正发出
2804
+ // 消息才消耗次数;状态落盘,重启不重发(否则每次重启都往大厅刷一轮)。
2709
2805
  const HALL_ANNOUNCE_MIN_INTERVAL_MS = 10 * 60 * 1000;
2710
2806
  const HALL_ANNOUNCE_MAX_TRIES = 6;
2807
+ const hallAnnounceStatePath = () => join(config.session.dataDir, 'hall-announce-state.json');
2808
+ function readHallAnnounceState() {
2809
+ try {
2810
+ return JSON.parse(readFileSync(hallAnnounceStatePath(), 'utf-8'));
2811
+ }
2812
+ catch {
2813
+ return {};
2814
+ }
2815
+ }
2816
+ /** 记录一次打卡尝试。consumeTry=false 只刷新 lastAt(发送失败:保住 10 分钟退避
2817
+ * 但不烧预算——否则 daemon 掉线期间就把 6 次上限烧光、恢复后永久跳过,Codex review)。 */
2818
+ function bumpHallAnnounceState(key, consumeTry) {
2819
+ const all = readHallAnnounceState();
2820
+ const cur = all[key];
2821
+ all[key] = { lastAt: Date.now(), tries: (cur?.tries ?? 0) + (consumeTry ? 1 : 0) };
2822
+ try {
2823
+ atomicWriteFileSync(hallAnnounceStatePath(), JSON.stringify(all, null, 2) + '\n');
2824
+ }
2825
+ catch { /* 尽力而为 */ }
2826
+ }
2827
+ /** 发送方 daemon 的 mention cross-ref(name → 本 app 视角 open_id)。 */
2828
+ function readBotCrossRef(appId) {
2829
+ try {
2830
+ return JSON.parse(readFileSync(join(config.session.dataDir, `bot-openids-${appId}.json`), 'utf-8'));
2831
+ }
2832
+ catch {
2833
+ return {};
2834
+ }
2835
+ }
2711
2836
  /**
2712
- * 让还没学到自己 union_id 的本地 bot 去团队大厅发一条打卡消息:自家消息回声
2713
- * (event-dispatcher isSelfMessage 分支)带回 sender.sender_id.union_id → 落盘
2714
- * bot-union-ids 下个心跳上报平台进 roster。一辈子一次;学到即永久跳过。
2715
- * 顺带的副作用:大厅在各成员机器的 team-groups 里(team-sync 镜像),其他
2716
- * daemon 收到这条消息会直接 recordTeamBot 学到该 bot——roster 之外的即时互学。
2837
+ * 大厅打卡编排(union_id 自学)。实测大厅(bot-only 群)只有「直接点名 @」会
2838
+ * 投递事件——普通消息、自 @、@all 全部静默,自家回声在大多数应用上永远等不来。
2839
+ * 机制(与 event-dispatcher hall 分支对偶):
2840
+ * - 有未入册成员的大厅里,每个本机 bot 点名 @ 自己 cross-ref 能解析到的未入册
2841
+ * 成员(含别的机器的——mention 跨机器投递,对方跑新版即可学);被点到的直接
2842
+ * 从 mentions[] 学到自己的 union_id。已入册 bot 也参与——纯教学。
2843
+ * - 自己未入册时消息带 #hall-echo,被点到的 bot 回 @ 一次(open_id 取事件
2844
+ * sender_id,无需 cross-ref)→ 打卡者从回执学到自己。任一方向可解析即收敛。
2845
+ * 消息只在有意义时才发:解析不到任何目标时不发不计次(唯一例外:未入册 bot 的
2846
+ * 首次尝试发一条裸打卡,给有 receive-all scope 的应用留回声机会)。状态落盘,
2847
+ * 重启不重发——解析不到目标反复裸发刷屏这个坑踩过了(自动review 实测)。
2717
2848
  */
2718
2849
  async function maybeAnnounceHallPresence() {
2719
2850
  try {
@@ -2723,36 +2854,58 @@ async function maybeAnnounceHallPresence() {
2723
2854
  return;
2724
2855
  const localBotIds = new Set(readPlatformBotsInfo().map(b => b.appId));
2725
2856
  const now = Date.now();
2857
+ const state = readHallAnnounceState();
2726
2858
  for (const team of teams) {
2727
2859
  const hallChatId = team.groupChatIds[0];
2728
2860
  if (!hallChatId)
2729
2861
  continue;
2862
+ // 未入册成员(全大厅,含别的机器):本机的以本地 store 为准(比 roster 新鲜),
2863
+ // 远端的以 roster 的 unionId 为准。
2864
+ const isLearned = (b) => localBotIds.has(b.appId) ? !!getBotUnionId(dataDir, b.appId) : !!b.unionId;
2865
+ const unlearned = team.bots.filter(b => !isLearned(b));
2866
+ if (unlearned.length === 0)
2867
+ continue;
2868
+ const unlearnedNames = new Set(unlearned.map(b => b.name).filter(Boolean));
2730
2869
  for (const bot of team.bots) {
2731
2870
  if (!localBotIds.has(bot.appId))
2732
- continue; // 别的机器的 bot
2733
- if (getBotUnionId(dataDir, bot.appId))
2734
- continue; // 已学到 → 永久跳过
2735
- const st = hallAnnounceState.get(bot.appId);
2871
+ continue; // 只编排本机 bot
2872
+ const selfLearned = isLearned(bot);
2873
+ const throttleKey = `${bot.appId}::${hallChatId}`;
2874
+ const st = state[throttleKey];
2736
2875
  if (st && (now - st.lastAt < HALL_ANNOUNCE_MIN_INTERVAL_MS || st.tries >= HALL_ANNOUNCE_MAX_TRIES))
2737
2876
  continue;
2738
- hallAnnounceState.set(bot.appId, { lastAt: now, tries: (st?.tries ?? 0) + 1 });
2877
+ // 点名目标 = 自己 cross-ref 能解析到的未入册成员(发不出 @ 的目标点了也白点)。
2878
+ const crossRef = readBotCrossRef(bot.appId);
2879
+ const targets = [...unlearnedNames].filter(n => n !== bot.name && typeof crossRef[n] === 'string').slice(0, 4);
2880
+ // 没有可教的目标:已入册 → 无事可做;未入册 → 仅首次发裸打卡碰回声运气,
2881
+ // 之后静默等别人教(不发不计次,cross-ref 或 roster 变化后自然恢复)。
2882
+ if (targets.length === 0 && (selfLearned || (st?.tries ?? 0) > 0))
2883
+ continue;
2884
+ // 成功发出才消耗预算;失败只刷新 lastAt 保住退避间隔(见 bumpHallAnnounceState)。
2885
+ let sent = false;
2739
2886
  try {
2740
2887
  const r = await proxyToDaemon(bot.appId, '/api/platform/hall-announce', {
2741
2888
  method: 'POST',
2742
2889
  headers: { 'content-type': 'application/json' },
2743
- body: JSON.stringify({ chatId: hallChatId }),
2890
+ body: JSON.stringify({ chatId: hallChatId, mentionNames: targets }),
2744
2891
  });
2745
2892
  const j = await r.json().catch(() => ({}));
2746
2893
  if (!r.ok || !j.ok) {
2747
2894
  logger.warn(`[platform-tunnel] 大厅打卡失败 bot=${bot.appId} chat=${hallChatId.substring(0, 12)}: ${j.error ?? r.status}`);
2748
2895
  }
2749
2896
  else {
2750
- logger.info(`[platform-tunnel] 大厅打卡已发 bot=${bot.appId} chat=${hallChatId.substring(0, 12)}(等待回声学 union_id)`);
2897
+ sent = !j.skipped;
2898
+ const mentioned = j.mentioned ?? [];
2899
+ const unresolved = j.unresolved ?? [];
2900
+ if (sent)
2901
+ logger.info(`[platform-tunnel] 大厅打卡已发 bot=${bot.appId} chat=${hallChatId.substring(0, 12)}${mentioned.length ? ` 点名=[${mentioned.join(',')}]` : ''}${unresolved.length ? ` 未解析=[${unresolved.join(',')}]` : ''}`);
2751
2902
  }
2752
2903
  }
2753
2904
  catch (e) {
2754
2905
  logger.warn(`[platform-tunnel] 大厅打卡请求异常 bot=${bot.appId}: ${e.message}`);
2755
2906
  }
2907
+ bumpHallAnnounceState(throttleKey, sent);
2908
+ state[throttleKey] = { lastAt: now, tries: (st?.tries ?? 0) + (sent ? 1 : 0) };
2756
2909
  }
2757
2910
  }
2758
2911
  }