botmux 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/dist/adapters/cli/codex.d.ts.map +1 -1
  4. package/dist/adapters/cli/codex.js +13 -1
  5. package/dist/adapters/cli/codex.js.map +1 -1
  6. package/dist/adapters/cli/traex.d.ts.map +1 -1
  7. package/dist/adapters/cli/traex.js +11 -1
  8. package/dist/adapters/cli/traex.js.map +1 -1
  9. package/dist/adapters/cli/types.d.ts +6 -0
  10. package/dist/adapters/cli/types.d.ts.map +1 -1
  11. package/dist/bot-registry.d.ts +7 -0
  12. package/dist/bot-registry.d.ts.map +1 -1
  13. package/dist/bot-registry.js +1 -0
  14. package/dist/bot-registry.js.map +1 -1
  15. package/dist/cli/daemon-lifecycle-env.d.ts +11 -0
  16. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -0
  17. package/dist/cli/daemon-lifecycle-env.js +25 -0
  18. package/dist/cli/daemon-lifecycle-env.js.map +1 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +11 -4
  21. package/dist/cli.js.map +1 -1
  22. package/dist/codex-rpc-engine.d.ts +110 -0
  23. package/dist/codex-rpc-engine.d.ts.map +1 -0
  24. package/dist/codex-rpc-engine.js +496 -0
  25. package/dist/codex-rpc-engine.js.map +1 -0
  26. package/dist/codex-rpc-lifecycle.d.ts +157 -0
  27. package/dist/codex-rpc-lifecycle.d.ts.map +1 -0
  28. package/dist/codex-rpc-lifecycle.js +210 -0
  29. package/dist/codex-rpc-lifecycle.js.map +1 -0
  30. package/dist/config.d.ts +1 -0
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +14 -2
  33. package/dist/config.js.map +1 -1
  34. package/dist/core/attachment-path.d.ts +3 -0
  35. package/dist/core/attachment-path.d.ts.map +1 -0
  36. package/dist/core/attachment-path.js +8 -0
  37. package/dist/core/attachment-path.js.map +1 -0
  38. package/dist/core/command-handler.d.ts.map +1 -1
  39. package/dist/core/command-handler.js +6 -1
  40. package/dist/core/command-handler.js.map +1 -1
  41. package/dist/core/dashboard-images.d.ts +29 -0
  42. package/dist/core/dashboard-images.d.ts.map +1 -0
  43. package/dist/core/dashboard-images.js +113 -0
  44. package/dist/core/dashboard-images.js.map +1 -0
  45. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  46. package/dist/core/dashboard-ipc-server.js +13 -1
  47. package/dist/core/dashboard-ipc-server.js.map +1 -1
  48. package/dist/core/github-auth.d.ts +5 -0
  49. package/dist/core/github-auth.d.ts.map +1 -1
  50. package/dist/core/github-auth.js +38 -0
  51. package/dist/core/github-auth.js.map +1 -1
  52. package/dist/core/maintenance.d.ts +1 -0
  53. package/dist/core/maintenance.d.ts.map +1 -1
  54. package/dist/core/maintenance.js +9 -1
  55. package/dist/core/maintenance.js.map +1 -1
  56. package/dist/core/session-create.d.ts +10 -0
  57. package/dist/core/session-create.d.ts.map +1 -1
  58. package/dist/core/session-create.js +21 -0
  59. package/dist/core/session-create.js.map +1 -1
  60. package/dist/core/session-manager.d.ts +4 -1
  61. package/dist/core/session-manager.d.ts.map +1 -1
  62. package/dist/core/session-manager.js +25 -13
  63. package/dist/core/session-manager.js.map +1 -1
  64. package/dist/core/trigger-session.d.ts +3 -0
  65. package/dist/core/trigger-session.d.ts.map +1 -1
  66. package/dist/core/trigger-session.js +17 -3
  67. package/dist/core/trigger-session.js.map +1 -1
  68. package/dist/core/worker-pool.d.ts.map +1 -1
  69. package/dist/core/worker-pool.js +2 -0
  70. package/dist/core/worker-pool.js.map +1 -1
  71. package/dist/daemon.d.ts.map +1 -1
  72. package/dist/daemon.js +19 -3
  73. package/dist/daemon.js.map +1 -1
  74. package/dist/dashboard/connector-api.d.ts.map +1 -1
  75. package/dist/dashboard/connector-api.js +19 -0
  76. package/dist/dashboard/connector-api.js.map +1 -1
  77. package/dist/dashboard/settings-write-applier.d.ts +2 -1
  78. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  79. package/dist/dashboard/settings-write-applier.js +6 -0
  80. package/dist/dashboard/settings-write-applier.js.map +1 -1
  81. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  82. package/dist/dashboard/web/connectors-page.js +23 -2
  83. package/dist/dashboard/web/connectors-page.js.map +1 -1
  84. package/dist/dashboard/web/create-session-composer.d.ts +35 -0
  85. package/dist/dashboard/web/create-session-composer.d.ts.map +1 -0
  86. package/dist/dashboard/web/create-session-composer.js +70 -0
  87. package/dist/dashboard/web/create-session-composer.js.map +1 -0
  88. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  89. package/dist/dashboard/web/i18n.js +65 -5
  90. package/dist/dashboard/web/i18n.js.map +1 -1
  91. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  92. package/dist/dashboard/web/sessions-page.js +147 -1
  93. package/dist/dashboard/web/sessions-page.js.map +1 -1
  94. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  95. package/dist/dashboard/web/settings-page.js +2 -1
  96. package/dist/dashboard/web/settings-page.js.map +1 -1
  97. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  98. package/dist/dashboard/web/skills-page.js +5 -2
  99. package/dist/dashboard/web/skills-page.js.map +1 -1
  100. package/dist/dashboard/webhook-routes.d.ts +4 -0
  101. package/dist/dashboard/webhook-routes.d.ts.map +1 -1
  102. package/dist/dashboard/webhook-routes.js +19 -0
  103. package/dist/dashboard/webhook-routes.js.map +1 -1
  104. package/dist/dashboard-web/app.js +1 -1
  105. package/dist/dashboard-web/chunks/{bot-defaults-page-4PM4T6QQ.js → bot-defaults-page-O37SAQD5.js} +1 -1
  106. package/dist/dashboard-web/chunks/{chunk-RXVA6XW3.js → chunk-6QHMLMIM.js} +6 -6
  107. package/dist/dashboard-web/chunks/{chunk-IKCZ677K.js → chunk-B42PCL5P.js} +1 -1
  108. package/dist/dashboard-web/chunks/{chunk-3PMN55Z6.js → chunk-FYALHCPG.js} +1 -1
  109. package/dist/dashboard-web/chunks/{chunk-AIDN2WZB.js → chunk-O2DPQL7C.js} +1 -1
  110. package/dist/dashboard-web/chunks/connectors-page-DZBQ42MH.js +1 -0
  111. package/dist/dashboard-web/chunks/{groups-page-TAU2UIVS.js → groups-page-3IEGRZKQ.js} +1 -1
  112. package/dist/dashboard-web/chunks/{insights-page-PJ2XVM6T.js → insights-page-X3ADRON3.js} +1 -1
  113. package/dist/dashboard-web/chunks/{monitor-room-D6445HJO.js → monitor-room-IV36RGQY.js} +1 -1
  114. package/dist/dashboard-web/chunks/{monitoring-page-GUHMCKSO.js → monitoring-page-ADFKVON4.js} +1 -1
  115. package/dist/dashboard-web/chunks/{office-page-6K6U2MS5.js → office-page-CALAOWNS.js} +1 -1
  116. package/dist/dashboard-web/chunks/{overview-page-USDYU7U5.js → overview-page-SLMGLK3M.js} +1 -1
  117. package/dist/dashboard-web/chunks/{roles-page-O4OUA7ZH.js → roles-page-L7JJ4FFV.js} +1 -1
  118. package/dist/dashboard-web/chunks/{schedules-page-LNIBXJSZ.js → schedules-page-Z3672POL.js} +1 -1
  119. package/dist/dashboard-web/chunks/sessions-page-OKJ5YWUZ.js +3 -0
  120. package/dist/dashboard-web/chunks/settings-page-P6MI2ZUE.js +2 -0
  121. package/dist/dashboard-web/chunks/{skills-page-M5KYNXBA.js → skills-page-FT7BVZ6X.js} +4 -4
  122. package/dist/dashboard-web/chunks/{team-federation-page-GT7UFQMN.js → team-federation-page-DEYCPUFB.js} +1 -1
  123. package/dist/dashboard-web/chunks/{v3-page-UYZIRJ2C.js → v3-page-4IOWREZG.js} +1 -1
  124. package/dist/dashboard-web/chunks/{whiteboards-page-WAR77DQE.js → whiteboards-page-2VMBETZH.js} +1 -1
  125. package/dist/dashboard-web/style.css +191 -0
  126. package/dist/dashboard.js +33 -6
  127. package/dist/dashboard.js.map +1 -1
  128. package/dist/global-config.d.ts +7 -0
  129. package/dist/global-config.d.ts.map +1 -1
  130. package/dist/global-config.js +2 -0
  131. package/dist/global-config.js.map +1 -1
  132. package/dist/im/lark/ask-card.d.ts.map +1 -1
  133. package/dist/im/lark/ask-card.js +52 -4
  134. package/dist/im/lark/ask-card.js.map +1 -1
  135. package/dist/im/lark/card-handler.d.ts +2 -0
  136. package/dist/im/lark/card-handler.d.ts.map +1 -1
  137. package/dist/im/lark/card-handler.js +19 -2
  138. package/dist/im/lark/card-handler.js.map +1 -1
  139. package/dist/im/lark/event-dispatcher.d.ts +3 -0
  140. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  141. package/dist/im/lark/event-dispatcher.js +706 -693
  142. package/dist/im/lark/event-dispatcher.js.map +1 -1
  143. package/dist/im/lark/grant-pending.d.ts +8 -4
  144. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  145. package/dist/im/lark/grant-pending.js +13 -6
  146. package/dist/im/lark/grant-pending.js.map +1 -1
  147. package/dist/services/connector-store.d.ts +8 -0
  148. package/dist/services/connector-store.d.ts.map +1 -1
  149. package/dist/services/connector-store.js.map +1 -1
  150. package/dist/services/session-store.d.ts +2 -1
  151. package/dist/services/session-store.d.ts.map +1 -1
  152. package/dist/services/session-store.js +60 -2
  153. package/dist/services/session-store.js.map +1 -1
  154. package/dist/services/skill-registry-store.d.ts +16 -0
  155. package/dist/services/skill-registry-store.d.ts.map +1 -1
  156. package/dist/services/skill-registry-store.js +343 -47
  157. package/dist/services/skill-registry-store.js.map +1 -1
  158. package/dist/services/trigger-types.d.ts +5 -0
  159. package/dist/services/trigger-types.d.ts.map +1 -1
  160. package/dist/services/trigger-types.js +12 -0
  161. package/dist/services/trigger-types.js.map +1 -1
  162. package/dist/types.d.ts +13 -2
  163. package/dist/types.d.ts.map +1 -1
  164. package/dist/worker.js +440 -34
  165. package/dist/worker.js.map +1 -1
  166. package/package.json +1 -1
  167. package/dist/dashboard-web/chunks/connectors-page-2MKF4YTN.js +0 -1
  168. package/dist/dashboard-web/chunks/sessions-page-B6U6TCFF.js +0 -3
  169. package/dist/dashboard-web/chunks/settings-page-PXLRFF7I.js +0 -2
@@ -1178,7 +1178,7 @@ export function canOperate(larkAppId, _chatId, senderOpenId, senderUnionId) {
1178
1178
  * 入口 A:无权限者 @bot 时弹授权申请卡(正文 @owner,由 owner 处置)。
1179
1179
  * 受 grant-pending 节流:pending 中 / deny 冷却期内静默不发。开放模式(无 owner)兜底不发。
1180
1180
  */
1181
- async function maybeSendGrantRequestCard(larkAppId, message, chatId, requesterOpenId) {
1181
+ async function maybeSendGrantRequestCard(larkAppId, message, chatId, requesterOpenId, messageData) {
1182
1182
  if (getBot(larkAppId).config.autoGrantRequestCards === false)
1183
1183
  return;
1184
1184
  const owner = getOwnerOpenId(larkAppId);
@@ -1194,7 +1194,8 @@ async function maybeSendGrantRequestCard(larkAppId, message, chatId, requesterOp
1194
1194
  ? undefined
1195
1195
  : listObservedBots(config.session.dataDir, larkAppId, chatId).find(b => b.openId === requesterOpenId)?.name;
1196
1196
  const name = mentionName ?? observedName ?? requesterOpenId;
1197
- const nonce = openPending(larkAppId, chatId, requesterOpenId, getBot(larkAppId).config.messageQuota?.defaultLimit);
1197
+ // 把原始消息事件挂在 pending 上:授权成功后可重放,用户无需再 @ 一遍。
1198
+ const nonce = openPending(larkAppId, chatId, requesterOpenId, getBot(larkAppId).config.messageQuota?.defaultLimit, messageData);
1198
1199
  const card = buildGrantCard({ ownerOpenId: owner, targets: [{ openId: requesterOpenId, name: String(name) }], chatId, nonce, mode: 'request' }, localeForBot(larkAppId));
1199
1200
  await replyMessage(larkAppId, message.message_id, card, 'interactive')
1200
1201
  .catch(err => {
@@ -1763,6 +1764,708 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers, bra
1763
1764
  seedRoutingGates.set(messageId, gate);
1764
1765
  return gate;
1765
1766
  };
1767
+ async function processMessageEvent(data, seedRoutingGate) {
1768
+ try {
1769
+ const message = data.message;
1770
+ const sender = data.sender;
1771
+ if (!message)
1772
+ return;
1773
+ // Close the open_id startup race: probeBotOpenId is fire-and-forget at
1774
+ // startup, so an @ arriving in that window would hit isBotMentioned with
1775
+ // an undefined botOpenId and be silently dropped (the WSClient still ACKs
1776
+ // it, so Lark never redelivers → the @ is lost). Await the deduped probe
1777
+ // so the @ is recognized. Best-effort: on probe failure we degrade to the
1778
+ // prior behavior (the periodic heartbeat retries the probe).
1779
+ await ensureBotOpenId(larkAppId).catch(() => { });
1780
+ // Learn other bots' open_ids from @mentions in this event.
1781
+ // Lark open_id is per-app: these IDs are correct for our app context.
1782
+ if (message.mentions?.length > 0) {
1783
+ updateBotOpenIdCrossRef(config.session.dataDir, larkAppId, message.mentions);
1784
+ // Learn our OWN union_id from any @ of ourselves: Lark stamps every
1785
+ // mention with the target's union_id, and mention-driven delivery
1786
+ // works even where the self-message echo never arrives(无 receive-all
1787
+ // scope 的应用收不到自己发的消息;bot-only 大厅实测完全不推事件)。
1788
+ if (recordBotUnionIdFromMentions(config.session.dataDir, larkAppId, getBot(larkAppId).botOpenId, message.mentions)) {
1789
+ logger.info(`[${larkAppId}] learned own bot union_id from @mention`);
1790
+ }
1791
+ }
1792
+ const chatId = message.chat_id;
1793
+ const chatType = (message.chat_type === 'p2p' ? 'p2p' : 'group');
1794
+ const messageId = message.message_id;
1795
+ // Bot-originated messages — bots historically only post inside threads
1796
+ // (their own thread replies). With chat-scope sessions a bot can also
1797
+ // post top-level (its first reply in a chat-scope group), so we still
1798
+ // route them through `decideRouting` rather than gating on root_id.
1799
+ //
1800
+ // 飞书在跨 bot 卡片消息场景实测会把发送方标成 sender_type='bot'(不是
1801
+ // 文档里写的 'app'),所以这里两个值都接受,否则那条路径会落到下面的
1802
+ // user-message 通用分支,绕开 /close self-message 特判、foreign-bot
1803
+ // chat-scope gate(isKnownPeerBot)和"Bot-to-bot @mention detected"
1804
+ // 日志。
1805
+ const senderType = sender?.sender_type;
1806
+ const isBotSenderType = senderType === 'app' || senderType === 'bot';
1807
+ if (isBotSenderType) {
1808
+ const senderOpenId = sender.sender_id?.open_id;
1809
+ const isSelfMessage = senderOpenId === getBot(larkAppId).botOpenId;
1810
+ // Self messages: learn our OWN union_id from the echo first (the only
1811
+ // reliable source — see bot-union-ids-store; reported to the platform
1812
+ // on heartbeat for the team roster), then only echoed `/close` matters.
1813
+ if (isSelfMessage) {
1814
+ const selfUnionId = sender.sender_id?.union_id;
1815
+ if (selfUnionId && recordBotUnionId(config.session.dataDir, larkAppId, selfUnionId)) {
1816
+ logger.info(`[${larkAppId}] learned own bot union_id from self-message echo`);
1817
+ }
1818
+ try {
1819
+ const body = JSON.parse(message.content ?? '{}');
1820
+ if (body.text?.trim() !== '/close')
1821
+ return;
1822
+ }
1823
+ catch {
1824
+ return;
1825
+ }
1826
+ const ctx = await decideRouting(larkAppId, message);
1827
+ // Serialize per anchor so back-to-back messages to the same thread
1828
+ // (e.g. dispatch's /repo prime + brief kickoff) don't interleave with
1829
+ // the first's async session-spawn. See anchor-serializer.ts.
1830
+ await serializeByAnchor(ctx.anchor, () => handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId }))
1831
+ .catch(err => logger.error(`Error handling message event: ${err}`));
1832
+ return;
1833
+ }
1834
+ // Learn teammate identity from team-assembled groups (the trust root):
1835
+ // any bot talking in a 拉群 group is a vouched teammate, so capture its
1836
+ // tenant-stable union_id — we then honour it as a teammate in ANY chat
1837
+ // (see team-bots-store). Done BEFORE the @mention gate so even a non-@
1838
+ // message in a team group teaches us the teammate. Cheap + idempotent.
1839
+ const senderUnionId = sender.sender_id?.union_id;
1840
+ if (senderUnionId && isTeamGroupChat(config.session.dataDir, chatId)) {
1841
+ recordTeamBot(config.session.dataDir, { unionId: senderUnionId });
1842
+ }
1843
+ // 机器人大厅:bot 消息只用于身份登记(上面已学 sender union / mentions
1844
+ // 自学 / cross-ref),绝不当任务路由——大厅打卡会点名 @ 同伴,不吞掉的话
1845
+ // 接收 bot 会把打卡当任务拉起会话在大厅里回话(实测)。只吞 bot 发送方:
1846
+ // 人类经隐藏入口进大厅后 @ bot 仍正常应答。
1847
+ if (isPlatformHallChat(config.session.dataDir, chatId)) {
1848
+ // 回执互教:打卡者点名了我们且带 #hall-echo(= 它还没学到自己的
1849
+ // union_id)→ @ 回它一次。open_id 直接取事件 sender_id(本 app 视角,
1850
+ // 无需 cross-ref),打卡者从回执的 mentions[] 学到自己。每进程每发送者
1851
+ // 只回一次;回执不带标记,链路必然终止。
1852
+ try {
1853
+ const text = JSON.parse(message.content ?? '{}')?.text;
1854
+ if (typeof text === 'string' && text.includes('#hall-echo') && senderOpenId &&
1855
+ isBotMentioned(larkAppId, message, undefined) &&
1856
+ !hallEchoReplied.has(`${larkAppId}::${senderOpenId}`)) {
1857
+ hallEchoReplied.add(`${larkAppId}::${senderOpenId}`);
1858
+ void sendMessage(larkAppId, chatId, `<at user_id="${senderOpenId}"></at> 已登记`, 'text')
1859
+ .then(() => logger.info(`[${larkAppId}] hall echo reply sent to ${senderOpenId.substring(0, 12)}`))
1860
+ .catch((e) => logger.warn(`[${larkAppId}] hall echo reply failed: ${e.message}`));
1861
+ }
1862
+ }
1863
+ catch { /* content 非 JSON → 忽略 */ }
1864
+ logger.debug(`[${larkAppId}] hall bot message swallowed after learning (chat=${chatId.substring(0, 12)})`);
1865
+ return;
1866
+ }
1867
+ // Foreign bot: only route on @mention of us.
1868
+ if (!isBotMentioned(larkAppId, message, undefined))
1869
+ return;
1870
+ const decision = await decideRoutingWithSource(larkAppId, message);
1871
+ const ctx = { scope: decision.scope, anchor: decision.anchor };
1872
+ // Honor `/t` / `/topic` from bot senders too, aligning with the human
1873
+ // path so an explicit `@bot /t …` handoff seeds a fresh topic instead of
1874
+ // sticking to chat-scope. Applied BEFORE the gate (and the shared-topic
1875
+ // fold) so vetting keys on the FINAL routing: `/t` rewrites ctx to a
1876
+ // brand-new {thread, messageId} anchor. forceTopicApplied also suppresses
1877
+ // the shared-topic fold below — a `/t` seed wins over shared, same
1878
+ // precedence as the human path.
1879
+ const forcedTopic = maybeApplyForceTopicOverride(ctx, message, messageId);
1880
+ if (forcedTopic) {
1881
+ logger.info(`[/t] Force-topic override (bot sender): msg=${messageId.substring(0, 12)} → thread-scope, anchor=msg`);
1882
+ }
1883
+ const ownsThreadSession = ctx.scope === 'thread'
1884
+ ? (handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? false)
1885
+ : false;
1886
+ const canFoldForeignBotThread = findOncallChat(larkAppId, chatId)
1887
+ || isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId)
1888
+ || isTrustedTeamBotSender(config.session.dataDir, chatId, senderUnionId)
1889
+ || hasChatGrant(larkAppId, chatId, senderOpenId)
1890
+ || hasGlobalGrant(larkAppId, senderOpenId);
1891
+ let replyRootId = await maybeFoldMentionedRegularGroupThreadToChat({
1892
+ larkAppId, chatId, chatType, message, routing: ctx, forceTopicApplied: forcedTopic, mentionedThisBot: !!canFoldForeignBotThread, ownsThreadSession,
1893
+ });
1894
+ if (!replyRootId) {
1895
+ replyRootId = await maybeApplySharedTopicSeed({
1896
+ larkAppId, chatId, chatType, message, senderOpenId, messageId, routing: ctx, forceTopicApplied: forcedTopic,
1897
+ });
1898
+ }
1899
+ // Regular-group foreign-bot @mention: gate to vetted botmux peers
1900
+ // (registered in our bot-openids cross-ref). Fires for legacy chat-scope
1901
+ // routing, the new-topic send-shape
1902
+ // (decision.source === 'regular-group-thread'), AND a `/t` force-topic
1903
+ // seed (forcedTopic) — so random Lark bots cannot silently spawn sessions
1904
+ // in 普通群, whether this bot replies in threads or a stranger bot @s us
1905
+ // with `/t`. Known Bot A → Bot B handoffs in 普通群 still work.
1906
+ //
1907
+ // ownsSession is read on ctx.anchor AFTER the `/t` override above. That
1908
+ // exemption means "a foreign bot following up into a session we already
1909
+ // own" (e.g. a chat-scope session at chatId). A `/t` rewrites the anchor
1910
+ // to a fresh messageId where we own nothing, so an unvetted bot can NOT
1911
+ // ride the existing chat-scope session's ownership to skip vetting — it
1912
+ // must independently hit isKnownPeerBot / chatGrants / globalGrants (or
1913
+ // this bot's own oncall chat).
1914
+ //
1915
+ // 注意 isKnownPeerBot 查的是 cross-ref(bot-openids-<appId>.json),它只
1916
+ // 收录 bots-info.json 里有名字的 bot,即本机 daemon 自己配置的 bot
1917
+ // (getAllBots)。"别人的 bot" 永远进不了这个 cross-ref,所以 isKnownPeerBot
1918
+ // 对外部 bot 恒为 false——这跟 /introduce 是两套独立存储:/introduce 写的是
1919
+ // observed-bots-store,只负责让发送方"发现并能 @ 到"对方,过不了这道接收闸。
1920
+ //
1921
+ // Oncall 群是当前接收 bot 显式部署的协作工作区,canTalk 已对任何成员
1922
+ // (含真人)放行;这里对 bot 同等放行,跳过 cross-ref vetting。否则本
1923
+ // bot 已绑定的 oncall 群里外部 bot 互相 @ 会被静默丢弃、只有真人能拉起会话。
1924
+ // 注意 oncall talk access 是 bot-scoped:一个 bot 的 /oncall bind 不会放开
1925
+ // sibling bot 的 talk 权限;如果 sibling bot 也要开放,需要自己绑定同一个 chat。
1926
+ //
1927
+ // owner 还可用 `/grant @bot` 把外部 bot 加进本群 chatGrants(与真人 /grant
1928
+ // 同一存储、同一 per-chat 语义)。命中 chatGrants 的 bot 即便不在 cross-ref,
1929
+ // 也与已注册 peer 同等放行——这是「授权外部 bot 在本群协作」的入口。
1930
+ // 全局授权(globalGrants)同理:命中即在任意群放行,是上面的全局版。
1931
+ //
1932
+ // 团队 bot 免 /grant:isTrustedTeamBotSender 命中(sender 的租户稳定
1933
+ // union_id 已学进 team-bots,或本群就是团队拉群组建的协作群)即放行——
1934
+ // 与「同部署兄弟 bot」(isKnownPeerBot) 对等,但覆盖跨部署的团队 peer。
1935
+ // 这正是「加入团队即免 introduce/grant」的接收闸:身份只认 union_id /
1936
+ // 团队群成员,绝不认自报名字(防同名 bot 冒名蹭权)。
1937
+ //
1938
+ // 开放模式(未配任何 allowlist:allowedUsers / allowedChatGroups /
1939
+ // globalGrants 全空)与人侧 evaluateTalk 的 `reason:'open'`(1011 行)对齐:
1940
+ // 「谁都能触发」本应人、bot 同权。过去这道 gate 漏了这一腿,导致开放模式下
1941
+ // 外部 bot @ 仍被丢弃,必须真人先 @ 一次建 session(ownsSession=true)才救活。
1942
+ // 补上 hasConfiguredAllowlist 短路后两条路径统一:一旦配了任一 allowlist,
1943
+ // 立刻恢复「限制态」设闸,安全边界不变。
1944
+ if ((ctx.scope === 'chat' || decision.source === 'regular-group-thread' || forcedTopic)
1945
+ && !findOncallChat(larkAppId, chatId)
1946
+ && hasConfiguredAllowlist(getBot(larkAppId))) {
1947
+ const ownsSession = handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? false;
1948
+ if (!ownsSession
1949
+ && !isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId)
1950
+ && !isTrustedTeamBotSender(config.session.dataDir, chatId, senderUnionId)
1951
+ && !hasChatGrant(larkAppId, chatId, senderOpenId)
1952
+ && !hasGlobalGrant(larkAppId, senderOpenId)) {
1953
+ await maybeSendGrantRequestCard(larkAppId, message, chatId, senderOpenId, data);
1954
+ return;
1955
+ }
1956
+ }
1957
+ logger.info(`Bot-to-bot @mention detected (scope=${ctx.scope}): routing to handleThreadReply`);
1958
+ // Serialize per anchor — a sub-bot dispatched a /repo prime + kickoff
1959
+ // back-to-back into this thread must be handled in order, not raced.
1960
+ await serializeByAnchor(ctx.anchor, () => handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId, replyRootId }))
1961
+ .catch(err => logger.error(`Error handling bot @mention: ${err}`));
1962
+ return;
1963
+ }
1964
+ const senderOpenId = sender?.sender_id?.open_id;
1965
+ // 人的 union_id:平台团队成员 talk-免grant 腿(isPlatformTeamMemberChat)要用。
1966
+ const humanSenderUnionId = sender?.sender_id?.union_id;
1967
+ // defaultOncall 自动绑定必须在 canTalk 权限判断前完成,否则已开 defaultOncall
1968
+ // 的群首次 @bot 时 oncallChats 中还没有该 chat → evaluateTalk 判无权限 → 误弹
1969
+ // 自助授权申请卡。ensureDefaultOncallBound 本身带 fast-path 短路且 idempotent。
1970
+ await ensureDefaultOncallBound(larkAppId, chatId, chatType).catch(err => logger.warn(`[oncall:${larkAppId}] pre-permission auto-bind failed for ${chatId.substring(0, 12)}: ${err}`));
1971
+ // 人的路径(bot 发送方已在上面的分支 return):union 走 memberUnionId 腿,
1972
+ // 不进 bot-trust 腿——teamBot 只认 bot-locked union。
1973
+ const isAllowed = canTalk(larkAppId, chatId, senderOpenId, undefined, humanSenderUnionId, chatType);
1974
+ // /introduce — collaboration handshake. Intercept before any routing
1975
+ // so the command never reaches a CLI session (each @ed bot's daemon
1976
+ // independently records the mentions[] open_ids + names). 无需授权:
1977
+ // 任何人都能登记花名册(只记 observed,不授予任何权限)。
1978
+ if (await tryHandleIntroduceCommand(larkAppId, message, senderOpenId)) {
1979
+ return;
1980
+ }
1981
+ if (await tryHandleReplyModeCommand(larkAppId, message, senderOpenId, isAllowed)) {
1982
+ return;
1983
+ }
1984
+ if (await tryHandleSubstituteCommand(larkAppId, message, senderOpenId)) {
1985
+ return;
1986
+ }
1987
+ // /grant、/revoke — 群内授权元命令。在路由/spawn 之前拦截(仅 owner,需明确 @ 本 bot),
1988
+ // 否则会被当成 prompt 喂给 CLI 会话。
1989
+ if (await tryHandleGrantCommand(larkAppId, message, senderOpenId)) {
1990
+ return;
1991
+ }
1992
+ logger.debug('Received message:', message);
1993
+ // Diagnostic: record the Lark quote-bubble UI quirk where root_id
1994
+ // appears without thread_id. decideRouting now treats this as
1995
+ // "no thread" (chat-scope / topic / new-topic depending on context),
1996
+ // which is the authoritative behavior. Logging it here so we can spot
1997
+ // any future surprise in the wild.
1998
+ if (message.root_id && !message.thread_id) {
1999
+ logger.info(`[routing] root_id w/o thread_id (Lark UI quirk, treating as top-level): ` +
2000
+ `msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)} ` +
2001
+ `type=${chatType} root=${String(message.root_id).substring(0, 12)} ` +
2002
+ `parent=${String(message.parent_id ?? '').substring(0, 12)}`);
2003
+ }
2004
+ const decision = await decideRoutingWithSource(larkAppId, message);
2005
+ const routing = {
2006
+ scope: decision.scope,
2007
+ anchor: decision.anchor,
2008
+ };
2009
+ let routingSource = decision.source;
2010
+ let replyRootId;
2011
+ const explicitlyMentionedThisBot = isBotMentioned(larkAppId, message, senderOpenId);
2012
+ // Cheap in-memory gate FIRST: skip the getChatMode roundtrip and the
2013
+ // per-chat toggle disk read entirely for bots that never configured a
2014
+ // substitute target (the overwhelming majority on the hot path).
2015
+ const substituteCfg = getBot(larkAppId).config.substituteMode;
2016
+ let substituteChatMode;
2017
+ // chats 白名单在 getChatMode 之前(纯内存判断走在 API roundtrip 前),
2018
+ // 对普通群与话题群统一生效:白名单是「替身可触发的群」清单,与群形态无关。
2019
+ if (substituteCfg?.enabled === true && chatType === 'group' && isSubstituteAllowedChat(substituteCfg, chatId)) {
2020
+ const chatMode = await getChatMode(larkAppId, chatId);
2021
+ const modeSupported = chatMode === 'group'
2022
+ // 话题群支持默认开(缺省=开,normalize 只在显式 false 时关)。
2023
+ || (chatMode === 'topic' && substituteCfg.topicGroups !== false);
2024
+ if (modeSupported && isSubstituteEnabledForChat(larkAppId, chatId)) {
2025
+ substituteChatMode = chatMode;
2026
+ }
2027
+ }
2028
+ let substituteTrigger = substituteChatMode
2029
+ ? resolveSubstituteTrigger(larkAppId, message)
2030
+ : undefined;
2031
+ if (substituteTrigger && !explicitlyMentionedThisBot) {
2032
+ const rawText = extractMessageTextForRouting(message);
2033
+ const stripped = rawText ? stripLeadingMentions(rawText.trim(), message?.mentions ?? []).trim() : '';
2034
+ if (stripped.startsWith('/'))
2035
+ substituteTrigger = undefined;
2036
+ }
2037
+ if (substituteTrigger && substituteChatMode === 'topic'
2038
+ && substituteCfg?.topicActiveSessionTrigger === false
2039
+ && (handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false)) {
2040
+ // 话题里已有本 bot 活跃会话 + 用户关掉了「活跃话题也触发」:
2041
+ // 单独 @替身对象 是明确转交,必须在任何通用免 @ 规则前直接让路;
2042
+ // 只清掉 metadata 不够,1v1 群/mentionMode=never 仍会把消息喂给 bot。
2043
+ substituteTrigger = undefined;
2044
+ if (!explicitlyMentionedThisBot) {
2045
+ logger.debug(`[substitute:${larkAppId}] active-topic trigger disabled; backing off ` +
2046
+ `msg=${messageId.substring(0, 12)} thread=${String(routing.anchor).substring(0, 12)}`);
2047
+ return;
2048
+ }
2049
+ }
2050
+ if (substituteTrigger) {
2051
+ if (substituteChatMode === 'group') {
2052
+ routing.scope = 'chat';
2053
+ routing.anchor = chatId;
2054
+ routingSource = 'regular-group-chat';
2055
+ // Top-level substitute messages need their own reply anchor so that
2056
+ // concurrent triggers from different users in the same chat-scope
2057
+ // session don't collapse or thread under the wrong message. Existing
2058
+ // real threads keep their root_id.
2059
+ replyRootId = (message.root_id && message.thread_id) ? message.root_id : messageId;
2060
+ }
2061
+ // 话题群:保持 decideRouting 的 thread-scope/话题锚点不动——替身回合
2062
+ // 直接搭该话题自己的会话(无会话则由 handleNewTopic 新开),与普通群
2063
+ // 「搭群 chat-scope 会话」同构;回复天然落回本话题,无需 replyRootId。
2064
+ const configuredTargetId = substituteTrigger.target.openId
2065
+ ?? substituteTrigger.target.userId
2066
+ ?? substituteTrigger.target.unionId
2067
+ ?? 'unknown';
2068
+ const configuredTargetForLog = JSON.stringify(configuredTargetId.slice(0, 128));
2069
+ logger.info(`[substitute:${larkAppId}] mention target=${configuredTargetForLog} ` +
2070
+ `msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)} → ${substituteChatMode === 'group' ? 'chat-scope' : `topic thread=${String(routing.anchor).substring(0, 12)}`}`);
2071
+ }
2072
+ // Shared-mode follow-up: a non-@ message inside a Lark thread can belong
2073
+ // to the regular group's chat-scope session when that root was registered
2074
+ // as a shared-topic alias. Whether a 普通群 answers it without an @mention
2075
+ // is governed by the bot-global mention policy: 'always' (default) keeps
2076
+ // "@ required" so this fold-back is skipped (non-@ thread chatter falls
2077
+ // through to the gate below and is ignored — only an explicit @ continues
2078
+ // a shared topic); 'topic', 'never' and 'ambient' enable the seamless
2079
+ // no-@ fold-back. Carve-out: under 'topic' / 'ambient', a non-@ reply
2080
+ // that @mentions another specific member (person/bot) is a redirect to
2081
+ // someone else → back off, don't fold it in (mentionsAnotherMember).
2082
+ // 'never' stays unconditional by design.
2083
+ const mentionModeForAlias = resolveGroupMentionMode(larkAppId);
2084
+ if (!explicitlyMentionedThisBot
2085
+ && mentionModeForAlias !== 'always'
2086
+ && !((mentionModeForAlias === 'topic' || mentionModeForAlias === 'ambient') && mentionsAnotherMember(larkAppId, message))
2087
+ && routing.scope === 'thread' && message.root_id && message.thread_id && chatType === 'group') {
2088
+ const alias = handlers.resolveReplyThreadAlias?.(message.root_id, chatId, larkAppId) ?? null;
2089
+ if (alias) {
2090
+ const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
2091
+ if (freshMode === 'group') {
2092
+ routing.scope = 'chat';
2093
+ routing.anchor = alias.chatId;
2094
+ replyRootId = message.root_id;
2095
+ logger.info(`[reply-mode] alias root=${message.root_id.substring(0, 12)} → chat=${alias.chatId.substring(0, 12)} session=${alias.sessionId.substring(0, 8)}`);
2096
+ }
2097
+ }
2098
+ }
2099
+ // 话题群 → 普通群 (reverse conversion). Symmetric to the forward check
2100
+ // below: when decideRouting lands on thread-scope purely because the
2101
+ // *cached* chat_mode said 'topic' (no real thread_id on the message
2102
+ // either — i.e. this would seed a brand-new thread), our 5-min cache
2103
+ // may be stale from before a flip-back to 普通群. Re-verify with
2104
+ // forceRefresh; if Lark now reports 'group', flatten to chat-scope so
2105
+ // the bot doesn't keep wrapping every top-level reply in a fresh
2106
+ // Lark topic via reply_in_thread.
2107
+ //
2108
+ // Skip when there's a real thread_id (authoritative thread signal,
2109
+ // can't be cache-stale) or when chatType is p2p (DMs always thread).
2110
+ // Runs BEFORE /t override so a `@bot /t …` in a now-flat 普通群 still
2111
+ // gets the explicit topic seed it asked for.
2112
+ if (routing.scope === 'thread' &&
2113
+ routing.anchor === messageId &&
2114
+ !message.thread_id &&
2115
+ chatType === 'group') {
2116
+ const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
2117
+ if (freshMode === 'group') {
2118
+ const rerouted = regularGroupRouting(larkAppId, messageId, chatId);
2119
+ logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'topic' → 'group'; ` +
2120
+ `rerouting msg=${messageId.substring(0, 12)} as ${rerouted.scope}-scope`);
2121
+ routing.scope = rerouted.scope;
2122
+ routing.anchor = rerouted.anchor;
2123
+ routingSource = rerouted.source;
2124
+ }
2125
+ }
2126
+ // 主动开工 — 场景②: capture only genuine topic-group seeds NOW, before
2127
+ // `/t` or the regular-group new-topic mode can create the same
2128
+ // {thread, anchor=messageId} shape in a regular group. autoStartOnNewTopic
2129
+ // is deliberately limited to 话题群.
2130
+ const autoTopicSeedScope = routingSource === 'topic-chat' ? routing.scope : 'chat';
2131
+ const autoTopicSeedAnchor = routingSource === 'topic-chat' ? routing.anchor : chatId;
2132
+ // /t / /topic in 普通群: flip routing to thread-scope so the bot's
2133
+ // first reply seeds a fresh Lark thread, even if a chat-scope session
2134
+ // is currently active in this chat.
2135
+ const forceTopicApplied = substituteTrigger ? false : maybeApplyForceTopicOverride(routing, message, messageId);
2136
+ if (forceTopicApplied) {
2137
+ logger.info(`[/t] Force-topic override: msg=${messageId.substring(0, 12)} → thread-scope, anchor=msg`);
2138
+ }
2139
+ let ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
2140
+ const ownsThreadSessionBeforeFold = routing.scope === 'thread'
2141
+ ? (handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false)
2142
+ : false;
2143
+ const foldedReplyRootId = await maybeFoldMentionedRegularGroupThreadToChat({
2144
+ larkAppId, chatId, chatType, message, routing, forceTopicApplied, mentionedThisBot: explicitlyMentionedThisBot, ownsThreadSession: ownsThreadSessionBeforeFold,
2145
+ });
2146
+ if (foldedReplyRootId) {
2147
+ replyRootId = foldedReplyRootId;
2148
+ ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
2149
+ }
2150
+ else {
2151
+ const seedReplyRootId = await maybeApplySharedTopicSeed({
2152
+ larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, forceTopicApplied,
2153
+ });
2154
+ if (seedReplyRootId) {
2155
+ replyRootId = seedReplyRootId;
2156
+ ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
2157
+ }
2158
+ }
2159
+ // 普通群 → 话题群 conversion detection. Lark group admins can flip
2160
+ // chat_mode at any time; our 30/5-min cache lags. If routing landed on
2161
+ // chat-scope AND we own a session at this chat, the chat-scope session
2162
+ // may be stale from before a conversion. Re-fetch chat_mode with
2163
+ // forceRefresh to confirm. If it's now 'topic', the session is dead:
2164
+ // sendMessage(chatId) at dispatch time would wrap each reply in a new
2165
+ // Lark topic (the user-reported bug). Evict the stale session, then
2166
+ // route this message as if it were a brand-new thread seed so
2167
+ // handleNewTopic spawns a thread-scope session anchored at messageId.
2168
+ // Gate on ownsSession to avoid an API roundtrip on every fresh inbound.
2169
+ // Skip p2p: a DM is always 'p2p' and can never be a topic group, so the
2170
+ // check can only waste a forceRefresh roundtrip (relevant now that
2171
+ // p2pMode==='chat' makes DMs land on chat-scope).
2172
+ if (routing.scope === 'chat' && ownsSession && chatType !== 'p2p') {
2173
+ const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
2174
+ if (freshMode === 'topic') {
2175
+ logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'group' → 'topic'; ` +
2176
+ `evicting stale chat-scope session and rerouting msg=${messageId.substring(0, 12)} as thread seed`);
2177
+ try {
2178
+ handlers.onChatModeConverted?.(chatId, larkAppId);
2179
+ }
2180
+ catch (err) {
2181
+ logger.warn(`onChatModeConverted handler threw: ${err}`);
2182
+ }
2183
+ routing.scope = 'thread';
2184
+ routing.anchor = messageId;
2185
+ routingSource = 'topic-chat';
2186
+ // ownsSession was true on the stale chatId anchor; the new anchor
2187
+ // (messageId) is brand-new, so no current session owns it.
2188
+ ownsSession = false;
2189
+ }
2190
+ }
2191
+ const summaryCommandMatch = await resolveSummaryCommandMatch({
2192
+ larkAppId,
2193
+ chatId,
2194
+ chatType,
2195
+ routingSource,
2196
+ message,
2197
+ senderOpenId,
2198
+ });
2199
+ const summaryCommandTriggered = !!summaryCommandMatch && isAllowed;
2200
+ const routingText = extractMessageTextForRouting(message);
2201
+ const strippedRoutingText = routingText
2202
+ ? stripLeadingMentions(routingText.trim(), message?.mentions ?? []).trim()
2203
+ : '';
2204
+ const isControlCommand = strippedRoutingText.startsWith('/');
2205
+ let pairedForwardSeed;
2206
+ // Require isAllowed before pairing: a root-linked clarification from a
2207
+ // sender who was /revoked within the grace window must not consume the
2208
+ // seed from the buffer or overwrite the durable paired record. The seed
2209
+ // stays in the buffer and flushes on its original timer. Paired seeds
2210
+ // only arise under never/ambient modes, where a legitimate merge always
2211
+ // requires isAllowed anyway.
2212
+ if (senderOpenId && isAllowed && message.root_id && !isControlCommand) {
2213
+ await seedRoutingGates.get(message.root_id)?.ready;
2214
+ const pairingInput = {
2215
+ larkAppId,
2216
+ chatId,
2217
+ senderOpenId,
2218
+ rootId: message.root_id,
2219
+ };
2220
+ const pairingMentionMode = resolveGroupMentionMode(larkAppId);
2221
+ const ambientRedirect = pairingMentionMode === 'ambient'
2222
+ && !explicitlyMentionedThisBot
2223
+ && mentionsAnotherMember(larkAppId, message);
2224
+ const pairingDelayEnabled = usesForwardFollowupDelay(pairingMentionMode);
2225
+ if (pairingDelayEnabled && !ambientRedirect) {
2226
+ pairedForwardSeed = forwardFollowups.take(pairingInput);
2227
+ }
2228
+ else if (!pairingDelayEnabled) {
2229
+ const stalePendingSeed = forwardFollowups.take(pairingInput);
2230
+ if (stalePendingSeed) {
2231
+ try {
2232
+ await dispatchPersistedForwardFollowup(stalePendingSeed.messageId, stalePendingSeed.payload);
2233
+ }
2234
+ catch (err) {
2235
+ logger.warn(`[forward-followup] failed to flush stale seed=${stalePendingSeed.messageId.substring(0, 12)}; ` +
2236
+ `continuing current msg=${messageId.substring(0, 12)}: ${err}`);
2237
+ }
2238
+ }
2239
+ }
2240
+ }
2241
+ if (pairedForwardSeed) {
2242
+ // The clarification becomes the visible Lark topic root. The earlier
2243
+ // forwarded seed is retained only as prompt context, so the bot never
2244
+ // emits a reply under the forwarding bubble itself.
2245
+ routing.scope = 'thread';
2246
+ routing.anchor = messageId;
2247
+ routingSource = 'topic-chat';
2248
+ replyRootId = undefined;
2249
+ ownsSession = false;
2250
+ try {
2251
+ putForwardFollowup(larkAppId, {
2252
+ messageId: pairedForwardSeed.messageId,
2253
+ dueAt: Date.now(),
2254
+ payload: {
2255
+ data,
2256
+ ctx: {
2257
+ ...pairedForwardSeed.payload.ctx,
2258
+ chatId,
2259
+ messageId,
2260
+ chatType,
2261
+ larkAppId,
2262
+ scope: 'thread',
2263
+ anchor: messageId,
2264
+ replyRootId: undefined,
2265
+ forwardSeedData: pairedForwardSeed.payload.data,
2266
+ },
2267
+ ownsSession: false,
2268
+ },
2269
+ });
2270
+ }
2271
+ catch (err) {
2272
+ logger.warn(`[forward-followup] failed to persist provisional paired payload: ${err}`);
2273
+ }
2274
+ logger.info(`[forward-followup] merged seed=${pairedForwardSeed.messageId.substring(0, 12)} ` +
2275
+ `into msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)}`);
2276
+ }
2277
+ // Permission gating — same shape as before, just keyed on
2278
+ // `ownsSession` (anchor-aware) instead of "rootId presence":
2279
+ //
2280
+ // ownsSession + 1v1 group → relax (no @mention required)
2281
+ // ownsSession + multi → require @mention
2282
+ // !ownsSession (group) → require @mention + allowlist
2283
+ // p2p → allowlist only
2284
+ if (chatType === 'group') {
2285
+ let stats = null;
2286
+ if (ownsSession && !replyRootId)
2287
+ stats = await getGroupStats(larkAppId, chatId);
2288
+ // replyRootId means this turn has already been explicitly addressed
2289
+ // to the bot by shared-topic logic (possibly from inside an existing
2290
+ // Lark thread). Do not re-run the generic group @ gate, which would
2291
+ // reject multi-bot thread replies simply because `routing.scope` was
2292
+ // folded back to chat-scope.
2293
+ //
2294
+ // The bot-global mention policy drops the @ requirement:
2295
+ // • 'never' — answer EVERY un-@ message from talk-allowed senders
2296
+ // (incl. brand-new non-@ top-level → spawns/continues a session),
2297
+ // unconditionally. Intended for dedicated / on-call groups.
2298
+ // • 'ambient' — like 'never' (answer un-@ messages), EXCEPT when the
2299
+ // message @mentions another specific member (person/bot) without
2300
+ // @ing us — that is a redirect to someone else, so we back off and
2301
+ // stay quiet (mentionsAnotherMember). @all does not count as a
2302
+ // redirect. Best for multi-bot / multi-person groups that want a
2303
+ // default responder which yields the moment you address someone else.
2304
+ // • 'topic' — only inside a topic the bot already owns: a non-@ reply
2305
+ // INSIDE such a thread (new-topic / 话题群 thread the bot owns, or a
2306
+ // shared-topic alias via replyRootId) continues without @, while a
2307
+ // brand-new top-level conversation still requires @. If the user
2308
+ // explicitly @mentions another member/bot without @ing this bot,
2309
+ // treat it as a hand-off and stay quiet.
2310
+ // Both gated on isAllowed so restricted groups still only react to
2311
+ // permitted senders. (The shared fold-back's replyRootId is already
2312
+ // handled by the first clause.)
2313
+ const mentionMode = resolveGroupMentionMode(larkAppId);
2314
+ // 话题群 owned-topic 免@续话不再无条件放行(#336 引入的默认行为回归:
2315
+ // 多人群里旁人不 @ 也会触发 bot)。现在与普通群共用同一套「群聊 @ 策略」:
2316
+ // 默认 'always' 在多人群里必须 @,想要话题内免@续话就把 mentionMode 配成
2317
+ // 'topic'(下方条款已同时覆盖话题群 thread 与普通群 shared topic),
2318
+ // 'never'/'ambient' 亦按各自语义生效。1人1bot 的 solo 群仍走末条放行。
2319
+ // 注:pairedForwardSeed 仅在 never/ambient 模式下产生,且 ambient redirect
2320
+ // 已在配对前排除,故 isAllowed=true 时下方 never/ambient 条款必然放行;
2321
+ // 不在此单独加 clause,以免 isAllowed=false 时绕过权限检查。
2322
+ const relax = (!!replyRootId && isAllowed)
2323
+ || (!!substituteTrigger && isAllowed)
2324
+ || (isAllowed && mentionMode === 'never')
2325
+ || (isAllowed && mentionMode === 'ambient' && !mentionsAnotherMember(larkAppId, message))
2326
+ || (isAllowed && mentionMode === 'topic' && ownsSession && !!message.thread_id && !mentionsAnotherMember(larkAppId, message))
2327
+ || (ownsSession && isAllowed && !!stats && stats.userCount <= 1 && stats.botCount <= 1);
2328
+ if (!relax) {
2329
+ const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId, humanSenderUnionId);
2330
+ if (access === 'not_allowed') {
2331
+ // 入口 A:无权限者 @bot → 弹授权申请卡(@owner),代替「无操作权限」。
2332
+ // 覆盖 ownsSession 真假两种情况,但绝不把该消息喂进已有 session。
2333
+ await maybeSendGrantRequestCard(larkAppId, message, chatId, senderOpenId, data);
2334
+ logger.debug(`Ignoring group message from non-allowed user: ${senderOpenId} (grant request card path)`);
2335
+ return;
2336
+ }
2337
+ if (access === 'ignore') {
2338
+ // 主动开工 — 场景②: a non-@ message that seeds a brand-new topic in
2339
+ // a 话题群 auto-starts a session when the bot opted in. Everything
2340
+ // else (regular-group chatter, thread replies, disabled bots) keeps
2341
+ // the original ignore. Sender is intentionally not gated (D4).
2342
+ const autoTopic = shouldAutoStartOnNewTopic({
2343
+ enabled: getBot(larkAppId).config.autoStartOnNewTopic === true,
2344
+ scope: autoTopicSeedScope,
2345
+ anchor: autoTopicSeedAnchor,
2346
+ messageId,
2347
+ chatType,
2348
+ ownsSession,
2349
+ });
2350
+ if (!autoTopic) {
2351
+ logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
2352
+ return;
2353
+ }
2354
+ logger.info(`[auto-start:新话题] ${chatId.substring(0, 12)} 新话题免@自动开工 msg=${messageId.substring(0, 12)}`);
2355
+ }
2356
+ }
2357
+ }
2358
+ else if (!isAllowed) {
2359
+ // 私聊被挡目前是静默丢弃:owner 不在这个 p2p 会话里,把授权申请卡 reply 回来只会
2360
+ // 发给陌生人自己(卡上的按钮又是 owner 专属),既不可用又泄露 owner —— 所以不发。
2361
+ // 真正的修法是把申请发到 owner 自己的 DM 并加 owner 维度节流,单独一个 PR 做。
2362
+ logger.debug(`Ignoring p2p message from non-allowed user: ${senderOpenId}`);
2363
+ return;
2364
+ }
2365
+ const promptOverride = summaryCommandTriggered && summaryCommandMatch
2366
+ ? await buildSummaryCommandPrompt({ larkAppId, chatId, message, match: summaryCommandMatch })
2367
+ : undefined;
2368
+ if (promptOverride && summaryCommandMatch) {
2369
+ logger.info(`[summary-command] matched msg=${messageId.substring(0, 12)} ` +
2370
+ `chat=${chatId.substring(0, 12)} kind=${summaryCommandMatch.chatKind}`);
2371
+ }
2372
+ const ctx = {
2373
+ chatId,
2374
+ messageId,
2375
+ chatType,
2376
+ larkAppId,
2377
+ ...routing,
2378
+ replyRootId,
2379
+ promptOverride,
2380
+ summaryCommand: summaryCommandTriggered && summaryCommandMatch
2381
+ ? { name: 'summary-command', chatKind: summaryCommandMatch.chatKind }
2382
+ : undefined,
2383
+ substituteTrigger,
2384
+ forwardSeedData: pairedForwardSeed?.payload.data,
2385
+ };
2386
+ if (explicitlyMentionedThisBot) {
2387
+ const before = await handlers.beforeSessionTurn?.(data, ctx, { senderOpenId, explicitlyMentionedThisBot });
2388
+ if (before?.block)
2389
+ return;
2390
+ if (before?.anchorOverride)
2391
+ ctx.anchor = before.anchorOverride;
2392
+ ownsSession = handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? ownsSession;
2393
+ }
2394
+ const payload = { data, ctx, ownsSession };
2395
+ const groupMentionMode = resolveGroupMentionMode(larkAppId);
2396
+ const shouldDelayTopicSeed = usesForwardFollowupDelay(groupMentionMode)
2397
+ && !pairedForwardSeed
2398
+ && !isControlCommand
2399
+ && !!senderOpenId
2400
+ && routingSource === 'topic-chat'
2401
+ && ctx.scope === 'thread'
2402
+ && ctx.anchor === messageId
2403
+ && !ownsSession;
2404
+ if (shouldDelayTopicSeed) {
2405
+ try {
2406
+ putForwardFollowup(larkAppId, {
2407
+ messageId,
2408
+ dueAt: Date.now() + config.daemon.forwardFollowupWaitMs,
2409
+ payload,
2410
+ });
2411
+ if (forwardFollowups.hold({
2412
+ larkAppId,
2413
+ chatId,
2414
+ senderOpenId,
2415
+ messageId,
2416
+ payload,
2417
+ flush: delayedPayload => dispatchPersistedForwardFollowup(messageId, delayedPayload),
2418
+ })) {
2419
+ logger.debug(`[forward-followup] holding topic seed msg=${messageId.substring(0, 12)} ` +
2420
+ `for ${config.daemon.forwardFollowupWaitMs}ms`);
2421
+ return;
2422
+ }
2423
+ removeForwardFollowup(larkAppId, messageId);
2424
+ }
2425
+ catch (err) {
2426
+ logger.warn(`[forward-followup] persistence unavailable, dispatching immediately: ${err}`);
2427
+ }
2428
+ }
2429
+ if (pairedForwardSeed) {
2430
+ try {
2431
+ putForwardFollowup(larkAppId, {
2432
+ messageId: pairedForwardSeed.messageId,
2433
+ dueAt: Date.now(),
2434
+ payload,
2435
+ });
2436
+ }
2437
+ catch (err) {
2438
+ logger.warn(`[forward-followup] failed to persist paired payload before dispatch: ${err}`);
2439
+ }
2440
+ await dispatchPersistedForwardFollowup(pairedForwardSeed.messageId, payload)
2441
+ .catch(err => logger.error(`Error handling paired message event: ${err}`));
2442
+ return;
2443
+ }
2444
+ // Serialize per anchor so two messages to the same thread/chat are
2445
+ // processed in arrival order — never concurrently. Without this a fast
2446
+ // second message interleaves with the first's async session-spawn and is
2447
+ // dropped (worker-not-ready → re-fork branch). See anchor-serializer.ts.
2448
+ await dispatchHumanMessage(payload)
2449
+ .catch(err => logger.error(`Error handling message event: ${err}`));
2450
+ }
2451
+ catch (err) {
2452
+ logger.error(`Error handling message event: ${err}`);
2453
+ }
2454
+ finally {
2455
+ seedRoutingGate?.complete();
2456
+ }
2457
+ }
2458
+ /** 授权成功后重放之前被拦截的消息,让用户无需再 @ 一遍。
2459
+ * 绕过消息去重(原消息在拦截时已 claim 过),直接重新走消息处理流程。
2460
+ * 用 setImmediate 异步执行,不阻塞卡片回调。 */
2461
+ function replayMessageEvent(data) {
2462
+ setImmediate(() => {
2463
+ void processMessageEvent(data)
2464
+ .catch(err => logger.error(`Error replaying message event after grant: ${err}`));
2465
+ });
2466
+ }
2467
+ // 暴露给 daemon 的 cardDeps.replayGrantedMessage 调用。
2468
+ handlers.replayMessageEvent = replayMessageEvent;
1766
2469
  const eventDispatcher = new Lark.EventDispatcher({}).register({
1767
2470
  // 主动开工 — 场景①: the bot was added to a chat. Hand off to the daemon,
1768
2471
  // which gates on the autoStartOnGroupJoin toggle + allowedUser membership.
@@ -1811,697 +2514,7 @@ export function startLarkEventDispatcher(larkAppId, larkAppSecret, handlers, bra
1811
2514
  ? () => claimMessageOnce(larkAppId, messageIdForKey)
1812
2515
  : () => claimEventOnce(eventKey);
1813
2516
  let seedRoutingGate;
1814
- const scheduled = scheduleAckSafeEvent(eventKey, async () => {
1815
- try {
1816
- const message = data.message;
1817
- const sender = data.sender;
1818
- if (!message)
1819
- return;
1820
- // Close the open_id startup race: probeBotOpenId is fire-and-forget at
1821
- // startup, so an @ arriving in that window would hit isBotMentioned with
1822
- // an undefined botOpenId and be silently dropped (the WSClient still ACKs
1823
- // it, so Lark never redelivers → the @ is lost). Await the deduped probe
1824
- // so the @ is recognized. Best-effort: on probe failure we degrade to the
1825
- // prior behavior (the periodic heartbeat retries the probe).
1826
- await ensureBotOpenId(larkAppId).catch(() => { });
1827
- // Learn other bots' open_ids from @mentions in this event.
1828
- // Lark open_id is per-app: these IDs are correct for our app context.
1829
- if (message.mentions?.length > 0) {
1830
- updateBotOpenIdCrossRef(config.session.dataDir, larkAppId, message.mentions);
1831
- // Learn our OWN union_id from any @ of ourselves: Lark stamps every
1832
- // mention with the target's union_id, and mention-driven delivery
1833
- // works even where the self-message echo never arrives(无 receive-all
1834
- // scope 的应用收不到自己发的消息;bot-only 大厅实测完全不推事件)。
1835
- if (recordBotUnionIdFromMentions(config.session.dataDir, larkAppId, getBot(larkAppId).botOpenId, message.mentions)) {
1836
- logger.info(`[${larkAppId}] learned own bot union_id from @mention`);
1837
- }
1838
- }
1839
- const chatId = message.chat_id;
1840
- const chatType = (message.chat_type === 'p2p' ? 'p2p' : 'group');
1841
- const messageId = message.message_id;
1842
- // Bot-originated messages — bots historically only post inside threads
1843
- // (their own thread replies). With chat-scope sessions a bot can also
1844
- // post top-level (its first reply in a chat-scope group), so we still
1845
- // route them through `decideRouting` rather than gating on root_id.
1846
- //
1847
- // 飞书在跨 bot 卡片消息场景实测会把发送方标成 sender_type='bot'(不是
1848
- // 文档里写的 'app'),所以这里两个值都接受,否则那条路径会落到下面的
1849
- // user-message 通用分支,绕开 /close self-message 特判、foreign-bot
1850
- // chat-scope gate(isKnownPeerBot)和"Bot-to-bot @mention detected"
1851
- // 日志。
1852
- const senderType = sender?.sender_type;
1853
- const isBotSenderType = senderType === 'app' || senderType === 'bot';
1854
- if (isBotSenderType) {
1855
- const senderOpenId = sender.sender_id?.open_id;
1856
- const isSelfMessage = senderOpenId === getBot(larkAppId).botOpenId;
1857
- // Self messages: learn our OWN union_id from the echo first (the only
1858
- // reliable source — see bot-union-ids-store; reported to the platform
1859
- // on heartbeat for the team roster), then only echoed `/close` matters.
1860
- if (isSelfMessage) {
1861
- const selfUnionId = sender.sender_id?.union_id;
1862
- if (selfUnionId && recordBotUnionId(config.session.dataDir, larkAppId, selfUnionId)) {
1863
- logger.info(`[${larkAppId}] learned own bot union_id from self-message echo`);
1864
- }
1865
- try {
1866
- const body = JSON.parse(message.content ?? '{}');
1867
- if (body.text?.trim() !== '/close')
1868
- return;
1869
- }
1870
- catch {
1871
- return;
1872
- }
1873
- const ctx = await decideRouting(larkAppId, message);
1874
- // Serialize per anchor so back-to-back messages to the same thread
1875
- // (e.g. dispatch's /repo prime + brief kickoff) don't interleave with
1876
- // the first's async session-spawn. See anchor-serializer.ts.
1877
- await serializeByAnchor(ctx.anchor, () => handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId }))
1878
- .catch(err => logger.error(`Error handling message event: ${err}`));
1879
- return;
1880
- }
1881
- // Learn teammate identity from team-assembled groups (the trust root):
1882
- // any bot talking in a 拉群 group is a vouched teammate, so capture its
1883
- // tenant-stable union_id — we then honour it as a teammate in ANY chat
1884
- // (see team-bots-store). Done BEFORE the @mention gate so even a non-@
1885
- // message in a team group teaches us the teammate. Cheap + idempotent.
1886
- const senderUnionId = sender.sender_id?.union_id;
1887
- if (senderUnionId && isTeamGroupChat(config.session.dataDir, chatId)) {
1888
- recordTeamBot(config.session.dataDir, { unionId: senderUnionId });
1889
- }
1890
- // 机器人大厅:bot 消息只用于身份登记(上面已学 sender union / mentions
1891
- // 自学 / cross-ref),绝不当任务路由——大厅打卡会点名 @ 同伴,不吞掉的话
1892
- // 接收 bot 会把打卡当任务拉起会话在大厅里回话(实测)。只吞 bot 发送方:
1893
- // 人类经隐藏入口进大厅后 @ bot 仍正常应答。
1894
- if (isPlatformHallChat(config.session.dataDir, chatId)) {
1895
- // 回执互教:打卡者点名了我们且带 #hall-echo(= 它还没学到自己的
1896
- // union_id)→ @ 回它一次。open_id 直接取事件 sender_id(本 app 视角,
1897
- // 无需 cross-ref),打卡者从回执的 mentions[] 学到自己。每进程每发送者
1898
- // 只回一次;回执不带标记,链路必然终止。
1899
- try {
1900
- const text = JSON.parse(message.content ?? '{}')?.text;
1901
- if (typeof text === 'string' && text.includes('#hall-echo') && senderOpenId &&
1902
- isBotMentioned(larkAppId, message, undefined) &&
1903
- !hallEchoReplied.has(`${larkAppId}::${senderOpenId}`)) {
1904
- hallEchoReplied.add(`${larkAppId}::${senderOpenId}`);
1905
- void sendMessage(larkAppId, chatId, `<at user_id="${senderOpenId}"></at> 已登记`, 'text')
1906
- .then(() => logger.info(`[${larkAppId}] hall echo reply sent to ${senderOpenId.substring(0, 12)}`))
1907
- .catch((e) => logger.warn(`[${larkAppId}] hall echo reply failed: ${e.message}`));
1908
- }
1909
- }
1910
- catch { /* content 非 JSON → 忽略 */ }
1911
- logger.debug(`[${larkAppId}] hall bot message swallowed after learning (chat=${chatId.substring(0, 12)})`);
1912
- return;
1913
- }
1914
- // Foreign bot: only route on @mention of us.
1915
- if (!isBotMentioned(larkAppId, message, undefined))
1916
- return;
1917
- const decision = await decideRoutingWithSource(larkAppId, message);
1918
- const ctx = { scope: decision.scope, anchor: decision.anchor };
1919
- // Honor `/t` / `/topic` from bot senders too, aligning with the human
1920
- // path so an explicit `@bot /t …` handoff seeds a fresh topic instead of
1921
- // sticking to chat-scope. Applied BEFORE the gate (and the shared-topic
1922
- // fold) so vetting keys on the FINAL routing: `/t` rewrites ctx to a
1923
- // brand-new {thread, messageId} anchor. forceTopicApplied also suppresses
1924
- // the shared-topic fold below — a `/t` seed wins over shared, same
1925
- // precedence as the human path.
1926
- const forcedTopic = maybeApplyForceTopicOverride(ctx, message, messageId);
1927
- if (forcedTopic) {
1928
- logger.info(`[/t] Force-topic override (bot sender): msg=${messageId.substring(0, 12)} → thread-scope, anchor=msg`);
1929
- }
1930
- const ownsThreadSession = ctx.scope === 'thread'
1931
- ? (handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? false)
1932
- : false;
1933
- const canFoldForeignBotThread = findOncallChat(larkAppId, chatId)
1934
- || isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId)
1935
- || isTrustedTeamBotSender(config.session.dataDir, chatId, senderUnionId)
1936
- || hasChatGrant(larkAppId, chatId, senderOpenId)
1937
- || hasGlobalGrant(larkAppId, senderOpenId);
1938
- let replyRootId = await maybeFoldMentionedRegularGroupThreadToChat({
1939
- larkAppId, chatId, chatType, message, routing: ctx, forceTopicApplied: forcedTopic, mentionedThisBot: !!canFoldForeignBotThread, ownsThreadSession,
1940
- });
1941
- if (!replyRootId) {
1942
- replyRootId = await maybeApplySharedTopicSeed({
1943
- larkAppId, chatId, chatType, message, senderOpenId, messageId, routing: ctx, forceTopicApplied: forcedTopic,
1944
- });
1945
- }
1946
- // Regular-group foreign-bot @mention: gate to vetted botmux peers
1947
- // (registered in our bot-openids cross-ref). Fires for legacy chat-scope
1948
- // routing, the new-topic send-shape
1949
- // (decision.source === 'regular-group-thread'), AND a `/t` force-topic
1950
- // seed (forcedTopic) — so random Lark bots cannot silently spawn sessions
1951
- // in 普通群, whether this bot replies in threads or a stranger bot @s us
1952
- // with `/t`. Known Bot A → Bot B handoffs in 普通群 still work.
1953
- //
1954
- // ownsSession is read on ctx.anchor AFTER the `/t` override above. That
1955
- // exemption means "a foreign bot following up into a session we already
1956
- // own" (e.g. a chat-scope session at chatId). A `/t` rewrites the anchor
1957
- // to a fresh messageId where we own nothing, so an unvetted bot can NOT
1958
- // ride the existing chat-scope session's ownership to skip vetting — it
1959
- // must independently hit isKnownPeerBot / chatGrants / globalGrants (or
1960
- // this bot's own oncall chat).
1961
- //
1962
- // 注意 isKnownPeerBot 查的是 cross-ref(bot-openids-<appId>.json),它只
1963
- // 收录 bots-info.json 里有名字的 bot,即本机 daemon 自己配置的 bot
1964
- // (getAllBots)。"别人的 bot" 永远进不了这个 cross-ref,所以 isKnownPeerBot
1965
- // 对外部 bot 恒为 false——这跟 /introduce 是两套独立存储:/introduce 写的是
1966
- // observed-bots-store,只负责让发送方"发现并能 @ 到"对方,过不了这道接收闸。
1967
- //
1968
- // Oncall 群是当前接收 bot 显式部署的协作工作区,canTalk 已对任何成员
1969
- // (含真人)放行;这里对 bot 同等放行,跳过 cross-ref vetting。否则本
1970
- // bot 已绑定的 oncall 群里外部 bot 互相 @ 会被静默丢弃、只有真人能拉起会话。
1971
- // 注意 oncall talk access 是 bot-scoped:一个 bot 的 /oncall bind 不会放开
1972
- // sibling bot 的 talk 权限;如果 sibling bot 也要开放,需要自己绑定同一个 chat。
1973
- //
1974
- // owner 还可用 `/grant @bot` 把外部 bot 加进本群 chatGrants(与真人 /grant
1975
- // 同一存储、同一 per-chat 语义)。命中 chatGrants 的 bot 即便不在 cross-ref,
1976
- // 也与已注册 peer 同等放行——这是「授权外部 bot 在本群协作」的入口。
1977
- // 全局授权(globalGrants)同理:命中即在任意群放行,是上面的全局版。
1978
- //
1979
- // 团队 bot 免 /grant:isTrustedTeamBotSender 命中(sender 的租户稳定
1980
- // union_id 已学进 team-bots,或本群就是团队拉群组建的协作群)即放行——
1981
- // 与「同部署兄弟 bot」(isKnownPeerBot) 对等,但覆盖跨部署的团队 peer。
1982
- // 这正是「加入团队即免 introduce/grant」的接收闸:身份只认 union_id /
1983
- // 团队群成员,绝不认自报名字(防同名 bot 冒名蹭权)。
1984
- //
1985
- // 开放模式(未配任何 allowlist:allowedUsers / allowedChatGroups /
1986
- // globalGrants 全空)与人侧 evaluateTalk 的 `reason:'open'`(1011 行)对齐:
1987
- // 「谁都能触发」本应人、bot 同权。过去这道 gate 漏了这一腿,导致开放模式下
1988
- // 外部 bot @ 仍被丢弃,必须真人先 @ 一次建 session(ownsSession=true)才救活。
1989
- // 补上 hasConfiguredAllowlist 短路后两条路径统一:一旦配了任一 allowlist,
1990
- // 立刻恢复「限制态」设闸,安全边界不变。
1991
- if ((ctx.scope === 'chat' || decision.source === 'regular-group-thread' || forcedTopic)
1992
- && !findOncallChat(larkAppId, chatId)
1993
- && hasConfiguredAllowlist(getBot(larkAppId))) {
1994
- const ownsSession = handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? false;
1995
- if (!ownsSession
1996
- && !isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId)
1997
- && !isTrustedTeamBotSender(config.session.dataDir, chatId, senderUnionId)
1998
- && !hasChatGrant(larkAppId, chatId, senderOpenId)
1999
- && !hasGlobalGrant(larkAppId, senderOpenId)) {
2000
- await maybeSendGrantRequestCard(larkAppId, message, chatId, senderOpenId);
2001
- return;
2002
- }
2003
- }
2004
- logger.info(`Bot-to-bot @mention detected (scope=${ctx.scope}): routing to handleThreadReply`);
2005
- // Serialize per anchor — a sub-bot dispatched a /repo prime + kickoff
2006
- // back-to-back into this thread must be handled in order, not raced.
2007
- await serializeByAnchor(ctx.anchor, () => handlers.handleThreadReply(data, { ...ctx, chatId, messageId, chatType, larkAppId, replyRootId }))
2008
- .catch(err => logger.error(`Error handling bot @mention: ${err}`));
2009
- return;
2010
- }
2011
- const senderOpenId = sender?.sender_id?.open_id;
2012
- // 人的 union_id:平台团队成员 talk-免grant 腿(isPlatformTeamMemberChat)要用。
2013
- const humanSenderUnionId = sender?.sender_id?.union_id;
2014
- // defaultOncall 自动绑定必须在 canTalk 权限判断前完成,否则已开 defaultOncall
2015
- // 的群首次 @bot 时 oncallChats 中还没有该 chat → evaluateTalk 判无权限 → 误弹
2016
- // 自助授权申请卡。ensureDefaultOncallBound 本身带 fast-path 短路且 idempotent。
2017
- await ensureDefaultOncallBound(larkAppId, chatId, chatType).catch(err => logger.warn(`[oncall:${larkAppId}] pre-permission auto-bind failed for ${chatId.substring(0, 12)}: ${err}`));
2018
- // 人的路径(bot 发送方已在上面的分支 return):union 走 memberUnionId 腿,
2019
- // 不进 bot-trust 腿——teamBot 只认 bot-locked union。
2020
- const isAllowed = canTalk(larkAppId, chatId, senderOpenId, undefined, humanSenderUnionId, chatType);
2021
- // /introduce — collaboration handshake. Intercept before any routing
2022
- // so the command never reaches a CLI session (each @ed bot's daemon
2023
- // independently records the mentions[] open_ids + names). 无需授权:
2024
- // 任何人都能登记花名册(只记 observed,不授予任何权限)。
2025
- if (await tryHandleIntroduceCommand(larkAppId, message, senderOpenId)) {
2026
- return;
2027
- }
2028
- if (await tryHandleReplyModeCommand(larkAppId, message, senderOpenId, isAllowed)) {
2029
- return;
2030
- }
2031
- if (await tryHandleSubstituteCommand(larkAppId, message, senderOpenId)) {
2032
- return;
2033
- }
2034
- // /grant、/revoke — 群内授权元命令。在路由/spawn 之前拦截(仅 owner,需明确 @ 本 bot),
2035
- // 否则会被当成 prompt 喂给 CLI 会话。
2036
- if (await tryHandleGrantCommand(larkAppId, message, senderOpenId)) {
2037
- return;
2038
- }
2039
- logger.debug('Received message:', message);
2040
- // Diagnostic: record the Lark quote-bubble UI quirk where root_id
2041
- // appears without thread_id. decideRouting now treats this as
2042
- // "no thread" (chat-scope / topic / new-topic depending on context),
2043
- // which is the authoritative behavior. Logging it here so we can spot
2044
- // any future surprise in the wild.
2045
- if (message.root_id && !message.thread_id) {
2046
- logger.info(`[routing] root_id w/o thread_id (Lark UI quirk, treating as top-level): ` +
2047
- `msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)} ` +
2048
- `type=${chatType} root=${String(message.root_id).substring(0, 12)} ` +
2049
- `parent=${String(message.parent_id ?? '').substring(0, 12)}`);
2050
- }
2051
- const decision = await decideRoutingWithSource(larkAppId, message);
2052
- const routing = {
2053
- scope: decision.scope,
2054
- anchor: decision.anchor,
2055
- };
2056
- let routingSource = decision.source;
2057
- let replyRootId;
2058
- const explicitlyMentionedThisBot = isBotMentioned(larkAppId, message, senderOpenId);
2059
- // Cheap in-memory gate FIRST: skip the getChatMode roundtrip and the
2060
- // per-chat toggle disk read entirely for bots that never configured a
2061
- // substitute target (the overwhelming majority on the hot path).
2062
- const substituteCfg = getBot(larkAppId).config.substituteMode;
2063
- let substituteChatMode;
2064
- // chats 白名单在 getChatMode 之前(纯内存判断走在 API roundtrip 前),
2065
- // 对普通群与话题群统一生效:白名单是「替身可触发的群」清单,与群形态无关。
2066
- if (substituteCfg?.enabled === true && chatType === 'group' && isSubstituteAllowedChat(substituteCfg, chatId)) {
2067
- const chatMode = await getChatMode(larkAppId, chatId);
2068
- const modeSupported = chatMode === 'group'
2069
- // 话题群支持默认开(缺省=开,normalize 只在显式 false 时关)。
2070
- || (chatMode === 'topic' && substituteCfg.topicGroups !== false);
2071
- if (modeSupported && isSubstituteEnabledForChat(larkAppId, chatId)) {
2072
- substituteChatMode = chatMode;
2073
- }
2074
- }
2075
- let substituteTrigger = substituteChatMode
2076
- ? resolveSubstituteTrigger(larkAppId, message)
2077
- : undefined;
2078
- if (substituteTrigger && !explicitlyMentionedThisBot) {
2079
- const rawText = extractMessageTextForRouting(message);
2080
- const stripped = rawText ? stripLeadingMentions(rawText.trim(), message?.mentions ?? []).trim() : '';
2081
- if (stripped.startsWith('/'))
2082
- substituteTrigger = undefined;
2083
- }
2084
- if (substituteTrigger && substituteChatMode === 'topic'
2085
- && substituteCfg?.topicActiveSessionTrigger === false
2086
- && (handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false)) {
2087
- // 话题里已有本 bot 活跃会话 + 用户关掉了「活跃话题也触发」:
2088
- // 单独 @替身对象 是明确转交,必须在任何通用免 @ 规则前直接让路;
2089
- // 只清掉 metadata 不够,1v1 群/mentionMode=never 仍会把消息喂给 bot。
2090
- substituteTrigger = undefined;
2091
- if (!explicitlyMentionedThisBot) {
2092
- logger.debug(`[substitute:${larkAppId}] active-topic trigger disabled; backing off ` +
2093
- `msg=${messageId.substring(0, 12)} thread=${String(routing.anchor).substring(0, 12)}`);
2094
- return;
2095
- }
2096
- }
2097
- if (substituteTrigger) {
2098
- if (substituteChatMode === 'group') {
2099
- routing.scope = 'chat';
2100
- routing.anchor = chatId;
2101
- routingSource = 'regular-group-chat';
2102
- // Top-level substitute messages need their own reply anchor so that
2103
- // concurrent triggers from different users in the same chat-scope
2104
- // session don't collapse or thread under the wrong message. Existing
2105
- // real threads keep their root_id.
2106
- replyRootId = (message.root_id && message.thread_id) ? message.root_id : messageId;
2107
- }
2108
- // 话题群:保持 decideRouting 的 thread-scope/话题锚点不动——替身回合
2109
- // 直接搭该话题自己的会话(无会话则由 handleNewTopic 新开),与普通群
2110
- // 「搭群 chat-scope 会话」同构;回复天然落回本话题,无需 replyRootId。
2111
- const configuredTargetId = substituteTrigger.target.openId
2112
- ?? substituteTrigger.target.userId
2113
- ?? substituteTrigger.target.unionId
2114
- ?? 'unknown';
2115
- const configuredTargetForLog = JSON.stringify(configuredTargetId.slice(0, 128));
2116
- logger.info(`[substitute:${larkAppId}] mention target=${configuredTargetForLog} ` +
2117
- `msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)} → ${substituteChatMode === 'group' ? 'chat-scope' : `topic thread=${String(routing.anchor).substring(0, 12)}`}`);
2118
- }
2119
- // Shared-mode follow-up: a non-@ message inside a Lark thread can belong
2120
- // to the regular group's chat-scope session when that root was registered
2121
- // as a shared-topic alias. Whether a 普通群 answers it without an @mention
2122
- // is governed by the bot-global mention policy: 'always' (default) keeps
2123
- // "@ required" so this fold-back is skipped (non-@ thread chatter falls
2124
- // through to the gate below and is ignored — only an explicit @ continues
2125
- // a shared topic); 'topic', 'never' and 'ambient' enable the seamless
2126
- // no-@ fold-back. Carve-out: under 'topic' / 'ambient', a non-@ reply
2127
- // that @mentions another specific member (person/bot) is a redirect to
2128
- // someone else → back off, don't fold it in (mentionsAnotherMember).
2129
- // 'never' stays unconditional by design.
2130
- const mentionModeForAlias = resolveGroupMentionMode(larkAppId);
2131
- if (!explicitlyMentionedThisBot
2132
- && mentionModeForAlias !== 'always'
2133
- && !((mentionModeForAlias === 'topic' || mentionModeForAlias === 'ambient') && mentionsAnotherMember(larkAppId, message))
2134
- && routing.scope === 'thread' && message.root_id && message.thread_id && chatType === 'group') {
2135
- const alias = handlers.resolveReplyThreadAlias?.(message.root_id, chatId, larkAppId) ?? null;
2136
- if (alias) {
2137
- const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
2138
- if (freshMode === 'group') {
2139
- routing.scope = 'chat';
2140
- routing.anchor = alias.chatId;
2141
- replyRootId = message.root_id;
2142
- logger.info(`[reply-mode] alias root=${message.root_id.substring(0, 12)} → chat=${alias.chatId.substring(0, 12)} session=${alias.sessionId.substring(0, 8)}`);
2143
- }
2144
- }
2145
- }
2146
- // 话题群 → 普通群 (reverse conversion). Symmetric to the forward check
2147
- // below: when decideRouting lands on thread-scope purely because the
2148
- // *cached* chat_mode said 'topic' (no real thread_id on the message
2149
- // either — i.e. this would seed a brand-new thread), our 5-min cache
2150
- // may be stale from before a flip-back to 普通群. Re-verify with
2151
- // forceRefresh; if Lark now reports 'group', flatten to chat-scope so
2152
- // the bot doesn't keep wrapping every top-level reply in a fresh
2153
- // Lark topic via reply_in_thread.
2154
- //
2155
- // Skip when there's a real thread_id (authoritative thread signal,
2156
- // can't be cache-stale) or when chatType is p2p (DMs always thread).
2157
- // Runs BEFORE /t override so a `@bot /t …` in a now-flat 普通群 still
2158
- // gets the explicit topic seed it asked for.
2159
- if (routing.scope === 'thread' &&
2160
- routing.anchor === messageId &&
2161
- !message.thread_id &&
2162
- chatType === 'group') {
2163
- const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
2164
- if (freshMode === 'group') {
2165
- const rerouted = regularGroupRouting(larkAppId, messageId, chatId);
2166
- logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'topic' → 'group'; ` +
2167
- `rerouting msg=${messageId.substring(0, 12)} as ${rerouted.scope}-scope`);
2168
- routing.scope = rerouted.scope;
2169
- routing.anchor = rerouted.anchor;
2170
- routingSource = rerouted.source;
2171
- }
2172
- }
2173
- // 主动开工 — 场景②: capture only genuine topic-group seeds NOW, before
2174
- // `/t` or the regular-group new-topic mode can create the same
2175
- // {thread, anchor=messageId} shape in a regular group. autoStartOnNewTopic
2176
- // is deliberately limited to 话题群.
2177
- const autoTopicSeedScope = routingSource === 'topic-chat' ? routing.scope : 'chat';
2178
- const autoTopicSeedAnchor = routingSource === 'topic-chat' ? routing.anchor : chatId;
2179
- // /t / /topic in 普通群: flip routing to thread-scope so the bot's
2180
- // first reply seeds a fresh Lark thread, even if a chat-scope session
2181
- // is currently active in this chat.
2182
- const forceTopicApplied = substituteTrigger ? false : maybeApplyForceTopicOverride(routing, message, messageId);
2183
- if (forceTopicApplied) {
2184
- logger.info(`[/t] Force-topic override: msg=${messageId.substring(0, 12)} → thread-scope, anchor=msg`);
2185
- }
2186
- let ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
2187
- const ownsThreadSessionBeforeFold = routing.scope === 'thread'
2188
- ? (handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false)
2189
- : false;
2190
- const foldedReplyRootId = await maybeFoldMentionedRegularGroupThreadToChat({
2191
- larkAppId, chatId, chatType, message, routing, forceTopicApplied, mentionedThisBot: explicitlyMentionedThisBot, ownsThreadSession: ownsThreadSessionBeforeFold,
2192
- });
2193
- if (foldedReplyRootId) {
2194
- replyRootId = foldedReplyRootId;
2195
- ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
2196
- }
2197
- else {
2198
- const seedReplyRootId = await maybeApplySharedTopicSeed({
2199
- larkAppId, chatId, chatType, message, senderOpenId, messageId, routing, forceTopicApplied,
2200
- });
2201
- if (seedReplyRootId) {
2202
- replyRootId = seedReplyRootId;
2203
- ownsSession = handlers.isSessionOwner?.(routing.anchor, larkAppId) ?? false;
2204
- }
2205
- }
2206
- // 普通群 → 话题群 conversion detection. Lark group admins can flip
2207
- // chat_mode at any time; our 30/5-min cache lags. If routing landed on
2208
- // chat-scope AND we own a session at this chat, the chat-scope session
2209
- // may be stale from before a conversion. Re-fetch chat_mode with
2210
- // forceRefresh to confirm. If it's now 'topic', the session is dead:
2211
- // sendMessage(chatId) at dispatch time would wrap each reply in a new
2212
- // Lark topic (the user-reported bug). Evict the stale session, then
2213
- // route this message as if it were a brand-new thread seed so
2214
- // handleNewTopic spawns a thread-scope session anchored at messageId.
2215
- // Gate on ownsSession to avoid an API roundtrip on every fresh inbound.
2216
- // Skip p2p: a DM is always 'p2p' and can never be a topic group, so the
2217
- // check can only waste a forceRefresh roundtrip (relevant now that
2218
- // p2pMode==='chat' makes DMs land on chat-scope).
2219
- if (routing.scope === 'chat' && ownsSession && chatType !== 'p2p') {
2220
- const freshMode = await getChatMode(larkAppId, chatId, { forceRefresh: true });
2221
- if (freshMode === 'topic') {
2222
- logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} chat_mode flipped 'group' → 'topic'; ` +
2223
- `evicting stale chat-scope session and rerouting msg=${messageId.substring(0, 12)} as thread seed`);
2224
- try {
2225
- handlers.onChatModeConverted?.(chatId, larkAppId);
2226
- }
2227
- catch (err) {
2228
- logger.warn(`onChatModeConverted handler threw: ${err}`);
2229
- }
2230
- routing.scope = 'thread';
2231
- routing.anchor = messageId;
2232
- routingSource = 'topic-chat';
2233
- // ownsSession was true on the stale chatId anchor; the new anchor
2234
- // (messageId) is brand-new, so no current session owns it.
2235
- ownsSession = false;
2236
- }
2237
- }
2238
- const summaryCommandMatch = await resolveSummaryCommandMatch({
2239
- larkAppId,
2240
- chatId,
2241
- chatType,
2242
- routingSource,
2243
- message,
2244
- senderOpenId,
2245
- });
2246
- const summaryCommandTriggered = !!summaryCommandMatch && isAllowed;
2247
- const routingText = extractMessageTextForRouting(message);
2248
- const strippedRoutingText = routingText
2249
- ? stripLeadingMentions(routingText.trim(), message?.mentions ?? []).trim()
2250
- : '';
2251
- const isControlCommand = strippedRoutingText.startsWith('/');
2252
- let pairedForwardSeed;
2253
- // Require isAllowed before pairing: a root-linked clarification from a
2254
- // sender who was /revoked within the grace window must not consume the
2255
- // seed from the buffer or overwrite the durable paired record. The seed
2256
- // stays in the buffer and flushes on its original timer. Paired seeds
2257
- // only arise under never/ambient modes, where a legitimate merge always
2258
- // requires isAllowed anyway.
2259
- if (senderOpenId && isAllowed && message.root_id && !isControlCommand) {
2260
- await seedRoutingGates.get(message.root_id)?.ready;
2261
- const pairingInput = {
2262
- larkAppId,
2263
- chatId,
2264
- senderOpenId,
2265
- rootId: message.root_id,
2266
- };
2267
- const pairingMentionMode = resolveGroupMentionMode(larkAppId);
2268
- const ambientRedirect = pairingMentionMode === 'ambient'
2269
- && !explicitlyMentionedThisBot
2270
- && mentionsAnotherMember(larkAppId, message);
2271
- const pairingDelayEnabled = usesForwardFollowupDelay(pairingMentionMode);
2272
- if (pairingDelayEnabled && !ambientRedirect) {
2273
- pairedForwardSeed = forwardFollowups.take(pairingInput);
2274
- }
2275
- else if (!pairingDelayEnabled) {
2276
- const stalePendingSeed = forwardFollowups.take(pairingInput);
2277
- if (stalePendingSeed) {
2278
- try {
2279
- await dispatchPersistedForwardFollowup(stalePendingSeed.messageId, stalePendingSeed.payload);
2280
- }
2281
- catch (err) {
2282
- logger.warn(`[forward-followup] failed to flush stale seed=${stalePendingSeed.messageId.substring(0, 12)}; ` +
2283
- `continuing current msg=${messageId.substring(0, 12)}: ${err}`);
2284
- }
2285
- }
2286
- }
2287
- }
2288
- if (pairedForwardSeed) {
2289
- // The clarification becomes the visible Lark topic root. The earlier
2290
- // forwarded seed is retained only as prompt context, so the bot never
2291
- // emits a reply under the forwarding bubble itself.
2292
- routing.scope = 'thread';
2293
- routing.anchor = messageId;
2294
- routingSource = 'topic-chat';
2295
- replyRootId = undefined;
2296
- ownsSession = false;
2297
- try {
2298
- putForwardFollowup(larkAppId, {
2299
- messageId: pairedForwardSeed.messageId,
2300
- dueAt: Date.now(),
2301
- payload: {
2302
- data,
2303
- ctx: {
2304
- ...pairedForwardSeed.payload.ctx,
2305
- chatId,
2306
- messageId,
2307
- chatType,
2308
- larkAppId,
2309
- scope: 'thread',
2310
- anchor: messageId,
2311
- replyRootId: undefined,
2312
- forwardSeedData: pairedForwardSeed.payload.data,
2313
- },
2314
- ownsSession: false,
2315
- },
2316
- });
2317
- }
2318
- catch (err) {
2319
- logger.warn(`[forward-followup] failed to persist provisional paired payload: ${err}`);
2320
- }
2321
- logger.info(`[forward-followup] merged seed=${pairedForwardSeed.messageId.substring(0, 12)} ` +
2322
- `into msg=${messageId.substring(0, 12)} chat=${chatId.substring(0, 12)}`);
2323
- }
2324
- // Permission gating — same shape as before, just keyed on
2325
- // `ownsSession` (anchor-aware) instead of "rootId presence":
2326
- //
2327
- // ownsSession + 1v1 group → relax (no @mention required)
2328
- // ownsSession + multi → require @mention
2329
- // !ownsSession (group) → require @mention + allowlist
2330
- // p2p → allowlist only
2331
- if (chatType === 'group') {
2332
- let stats = null;
2333
- if (ownsSession && !replyRootId)
2334
- stats = await getGroupStats(larkAppId, chatId);
2335
- // replyRootId means this turn has already been explicitly addressed
2336
- // to the bot by shared-topic logic (possibly from inside an existing
2337
- // Lark thread). Do not re-run the generic group @ gate, which would
2338
- // reject multi-bot thread replies simply because `routing.scope` was
2339
- // folded back to chat-scope.
2340
- //
2341
- // The bot-global mention policy drops the @ requirement:
2342
- // • 'never' — answer EVERY un-@ message from talk-allowed senders
2343
- // (incl. brand-new non-@ top-level → spawns/continues a session),
2344
- // unconditionally. Intended for dedicated / on-call groups.
2345
- // • 'ambient' — like 'never' (answer un-@ messages), EXCEPT when the
2346
- // message @mentions another specific member (person/bot) without
2347
- // @ing us — that is a redirect to someone else, so we back off and
2348
- // stay quiet (mentionsAnotherMember). @all does not count as a
2349
- // redirect. Best for multi-bot / multi-person groups that want a
2350
- // default responder which yields the moment you address someone else.
2351
- // • 'topic' — only inside a topic the bot already owns: a non-@ reply
2352
- // INSIDE such a thread (new-topic / 话题群 thread the bot owns, or a
2353
- // shared-topic alias via replyRootId) continues without @, while a
2354
- // brand-new top-level conversation still requires @. If the user
2355
- // explicitly @mentions another member/bot without @ing this bot,
2356
- // treat it as a hand-off and stay quiet.
2357
- // Both gated on isAllowed so restricted groups still only react to
2358
- // permitted senders. (The shared fold-back's replyRootId is already
2359
- // handled by the first clause.)
2360
- const mentionMode = resolveGroupMentionMode(larkAppId);
2361
- // 话题群 owned-topic 免@续话不再无条件放行(#336 引入的默认行为回归:
2362
- // 多人群里旁人不 @ 也会触发 bot)。现在与普通群共用同一套「群聊 @ 策略」:
2363
- // 默认 'always' 在多人群里必须 @,想要话题内免@续话就把 mentionMode 配成
2364
- // 'topic'(下方条款已同时覆盖话题群 thread 与普通群 shared topic),
2365
- // 'never'/'ambient' 亦按各自语义生效。1人1bot 的 solo 群仍走末条放行。
2366
- // 注:pairedForwardSeed 仅在 never/ambient 模式下产生,且 ambient redirect
2367
- // 已在配对前排除,故 isAllowed=true 时下方 never/ambient 条款必然放行;
2368
- // 不在此单独加 clause,以免 isAllowed=false 时绕过权限检查。
2369
- const relax = (!!replyRootId && isAllowed)
2370
- || (!!substituteTrigger && isAllowed)
2371
- || (isAllowed && mentionMode === 'never')
2372
- || (isAllowed && mentionMode === 'ambient' && !mentionsAnotherMember(larkAppId, message))
2373
- || (isAllowed && mentionMode === 'topic' && ownsSession && !!message.thread_id && !mentionsAnotherMember(larkAppId, message))
2374
- || (ownsSession && isAllowed && !!stats && stats.userCount <= 1 && stats.botCount <= 1);
2375
- if (!relax) {
2376
- const access = await checkGroupMessageAccess(larkAppId, message, chatId, senderOpenId, humanSenderUnionId);
2377
- if (access === 'not_allowed') {
2378
- // 入口 A:无权限者 @bot → 弹授权申请卡(@owner),代替「无操作权限」。
2379
- // 覆盖 ownsSession 真假两种情况,但绝不把该消息喂进已有 session。
2380
- await maybeSendGrantRequestCard(larkAppId, message, chatId, senderOpenId);
2381
- logger.debug(`Ignoring group message from non-allowed user: ${senderOpenId} (grant request card path)`);
2382
- return;
2383
- }
2384
- if (access === 'ignore') {
2385
- // 主动开工 — 场景②: a non-@ message that seeds a brand-new topic in
2386
- // a 话题群 auto-starts a session when the bot opted in. Everything
2387
- // else (regular-group chatter, thread replies, disabled bots) keeps
2388
- // the original ignore. Sender is intentionally not gated (D4).
2389
- const autoTopic = shouldAutoStartOnNewTopic({
2390
- enabled: getBot(larkAppId).config.autoStartOnNewTopic === true,
2391
- scope: autoTopicSeedScope,
2392
- anchor: autoTopicSeedAnchor,
2393
- messageId,
2394
- chatType,
2395
- ownsSession,
2396
- });
2397
- if (!autoTopic) {
2398
- logger.debug(`Ignoring group message not addressed to bot: ${messageId}`);
2399
- return;
2400
- }
2401
- logger.info(`[auto-start:新话题] ${chatId.substring(0, 12)} 新话题免@自动开工 msg=${messageId.substring(0, 12)}`);
2402
- }
2403
- }
2404
- }
2405
- else if (!isAllowed) {
2406
- // 私聊被挡目前是静默丢弃:owner 不在这个 p2p 会话里,把授权申请卡 reply 回来只会
2407
- // 发给陌生人自己(卡上的按钮又是 owner 专属),既不可用又泄露 owner —— 所以不发。
2408
- // 真正的修法是把申请发到 owner 自己的 DM 并加 owner 维度节流,单独一个 PR 做。
2409
- logger.debug(`Ignoring p2p message from non-allowed user: ${senderOpenId}`);
2410
- return;
2411
- }
2412
- const promptOverride = summaryCommandTriggered && summaryCommandMatch
2413
- ? await buildSummaryCommandPrompt({ larkAppId, chatId, message, match: summaryCommandMatch })
2414
- : undefined;
2415
- if (promptOverride && summaryCommandMatch) {
2416
- logger.info(`[summary-command] matched msg=${messageId.substring(0, 12)} ` +
2417
- `chat=${chatId.substring(0, 12)} kind=${summaryCommandMatch.chatKind}`);
2418
- }
2419
- const ctx = {
2420
- chatId,
2421
- messageId,
2422
- chatType,
2423
- larkAppId,
2424
- ...routing,
2425
- replyRootId,
2426
- promptOverride,
2427
- summaryCommand: summaryCommandTriggered && summaryCommandMatch
2428
- ? { name: 'summary-command', chatKind: summaryCommandMatch.chatKind }
2429
- : undefined,
2430
- substituteTrigger,
2431
- forwardSeedData: pairedForwardSeed?.payload.data,
2432
- };
2433
- if (explicitlyMentionedThisBot) {
2434
- const before = await handlers.beforeSessionTurn?.(data, ctx, { senderOpenId, explicitlyMentionedThisBot });
2435
- if (before?.block)
2436
- return;
2437
- if (before?.anchorOverride)
2438
- ctx.anchor = before.anchorOverride;
2439
- ownsSession = handlers.isSessionOwner?.(ctx.anchor, larkAppId) ?? ownsSession;
2440
- }
2441
- const payload = { data, ctx, ownsSession };
2442
- const groupMentionMode = resolveGroupMentionMode(larkAppId);
2443
- const shouldDelayTopicSeed = usesForwardFollowupDelay(groupMentionMode)
2444
- && !pairedForwardSeed
2445
- && !isControlCommand
2446
- && !!senderOpenId
2447
- && routingSource === 'topic-chat'
2448
- && ctx.scope === 'thread'
2449
- && ctx.anchor === messageId
2450
- && !ownsSession;
2451
- if (shouldDelayTopicSeed) {
2452
- try {
2453
- putForwardFollowup(larkAppId, {
2454
- messageId,
2455
- dueAt: Date.now() + config.daemon.forwardFollowupWaitMs,
2456
- payload,
2457
- });
2458
- if (forwardFollowups.hold({
2459
- larkAppId,
2460
- chatId,
2461
- senderOpenId,
2462
- messageId,
2463
- payload,
2464
- flush: delayedPayload => dispatchPersistedForwardFollowup(messageId, delayedPayload),
2465
- })) {
2466
- logger.debug(`[forward-followup] holding topic seed msg=${messageId.substring(0, 12)} ` +
2467
- `for ${config.daemon.forwardFollowupWaitMs}ms`);
2468
- return;
2469
- }
2470
- removeForwardFollowup(larkAppId, messageId);
2471
- }
2472
- catch (err) {
2473
- logger.warn(`[forward-followup] persistence unavailable, dispatching immediately: ${err}`);
2474
- }
2475
- }
2476
- if (pairedForwardSeed) {
2477
- try {
2478
- putForwardFollowup(larkAppId, {
2479
- messageId: pairedForwardSeed.messageId,
2480
- dueAt: Date.now(),
2481
- payload,
2482
- });
2483
- }
2484
- catch (err) {
2485
- logger.warn(`[forward-followup] failed to persist paired payload before dispatch: ${err}`);
2486
- }
2487
- await dispatchPersistedForwardFollowup(pairedForwardSeed.messageId, payload)
2488
- .catch(err => logger.error(`Error handling paired message event: ${err}`));
2489
- return;
2490
- }
2491
- // Serialize per anchor so two messages to the same thread/chat are
2492
- // processed in arrival order — never concurrently. Without this a fast
2493
- // second message interleaves with the first's async session-spawn and is
2494
- // dropped (worker-not-ready → re-fork branch). See anchor-serializer.ts.
2495
- await dispatchHumanMessage(payload)
2496
- .catch(err => logger.error(`Error handling message event: ${err}`));
2497
- }
2498
- catch (err) {
2499
- logger.error(`Error handling message event: ${err}`);
2500
- }
2501
- finally {
2502
- seedRoutingGate?.complete();
2503
- }
2504
- }, 'message event', claim);
2517
+ const scheduled = scheduleAckSafeEvent(eventKey, () => processMessageEvent(data, seedRoutingGate), 'message event', claim);
2505
2518
  const rawMessage = data?.message;
2506
2519
  const rawSenderType = data?.sender?.sender_type;
2507
2520
  if (scheduled