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.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +13 -1
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/traex.d.ts.map +1 -1
- package/dist/adapters/cli/traex.js +11 -1
- package/dist/adapters/cli/traex.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +6 -0
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +7 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +1 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/daemon-lifecycle-env.d.ts +11 -0
- package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -0
- package/dist/cli/daemon-lifecycle-env.js +25 -0
- package/dist/cli/daemon-lifecycle-env.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -4
- package/dist/cli.js.map +1 -1
- package/dist/codex-rpc-engine.d.ts +110 -0
- package/dist/codex-rpc-engine.d.ts.map +1 -0
- package/dist/codex-rpc-engine.js +496 -0
- package/dist/codex-rpc-engine.js.map +1 -0
- package/dist/codex-rpc-lifecycle.d.ts +157 -0
- package/dist/codex-rpc-lifecycle.d.ts.map +1 -0
- package/dist/codex-rpc-lifecycle.js +210 -0
- package/dist/codex-rpc-lifecycle.js.map +1 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -2
- package/dist/config.js.map +1 -1
- package/dist/core/attachment-path.d.ts +3 -0
- package/dist/core/attachment-path.d.ts.map +1 -0
- package/dist/core/attachment-path.js +8 -0
- package/dist/core/attachment-path.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +6 -1
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-images.d.ts +29 -0
- package/dist/core/dashboard-images.d.ts.map +1 -0
- package/dist/core/dashboard-images.js +113 -0
- package/dist/core/dashboard-images.js.map +1 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +13 -1
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/github-auth.d.ts +5 -0
- package/dist/core/github-auth.d.ts.map +1 -1
- package/dist/core/github-auth.js +38 -0
- package/dist/core/github-auth.js.map +1 -1
- package/dist/core/maintenance.d.ts +1 -0
- package/dist/core/maintenance.d.ts.map +1 -1
- package/dist/core/maintenance.js +9 -1
- package/dist/core/maintenance.js.map +1 -1
- package/dist/core/session-create.d.ts +10 -0
- package/dist/core/session-create.d.ts.map +1 -1
- package/dist/core/session-create.js +21 -0
- package/dist/core/session-create.js.map +1 -1
- package/dist/core/session-manager.d.ts +4 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +25 -13
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/trigger-session.d.ts +3 -0
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +17 -3
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2 -0
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +19 -3
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/connector-api.d.ts.map +1 -1
- package/dist/dashboard/connector-api.js +19 -0
- package/dist/dashboard/connector-api.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +2 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +6 -0
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +23 -2
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/create-session-composer.d.ts +35 -0
- package/dist/dashboard/web/create-session-composer.d.ts.map +1 -0
- package/dist/dashboard/web/create-session-composer.js +70 -0
- package/dist/dashboard/web/create-session-composer.js.map +1 -0
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +65 -5
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +147 -1
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +2 -1
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/skills-page.d.ts.map +1 -1
- package/dist/dashboard/web/skills-page.js +5 -2
- package/dist/dashboard/web/skills-page.js.map +1 -1
- package/dist/dashboard/webhook-routes.d.ts +4 -0
- package/dist/dashboard/webhook-routes.d.ts.map +1 -1
- package/dist/dashboard/webhook-routes.js +19 -0
- package/dist/dashboard/webhook-routes.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/{bot-defaults-page-4PM4T6QQ.js → bot-defaults-page-O37SAQD5.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-RXVA6XW3.js → chunk-6QHMLMIM.js} +6 -6
- package/dist/dashboard-web/chunks/{chunk-IKCZ677K.js → chunk-B42PCL5P.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-3PMN55Z6.js → chunk-FYALHCPG.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-AIDN2WZB.js → chunk-O2DPQL7C.js} +1 -1
- package/dist/dashboard-web/chunks/connectors-page-DZBQ42MH.js +1 -0
- package/dist/dashboard-web/chunks/{groups-page-TAU2UIVS.js → groups-page-3IEGRZKQ.js} +1 -1
- package/dist/dashboard-web/chunks/{insights-page-PJ2XVM6T.js → insights-page-X3ADRON3.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-D6445HJO.js → monitor-room-IV36RGQY.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-GUHMCKSO.js → monitoring-page-ADFKVON4.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-6K6U2MS5.js → office-page-CALAOWNS.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-USDYU7U5.js → overview-page-SLMGLK3M.js} +1 -1
- package/dist/dashboard-web/chunks/{roles-page-O4OUA7ZH.js → roles-page-L7JJ4FFV.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-LNIBXJSZ.js → schedules-page-Z3672POL.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-OKJ5YWUZ.js +3 -0
- package/dist/dashboard-web/chunks/settings-page-P6MI2ZUE.js +2 -0
- package/dist/dashboard-web/chunks/{skills-page-M5KYNXBA.js → skills-page-FT7BVZ6X.js} +4 -4
- package/dist/dashboard-web/chunks/{team-federation-page-GT7UFQMN.js → team-federation-page-DEYCPUFB.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-UYZIRJ2C.js → v3-page-4IOWREZG.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-WAR77DQE.js → whiteboards-page-2VMBETZH.js} +1 -1
- package/dist/dashboard-web/style.css +191 -0
- package/dist/dashboard.js +33 -6
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +7 -0
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +2 -0
- package/dist/global-config.js.map +1 -1
- package/dist/im/lark/ask-card.d.ts.map +1 -1
- package/dist/im/lark/ask-card.js +52 -4
- package/dist/im/lark/ask-card.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +2 -0
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +19 -2
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +3 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +706 -693
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/grant-pending.d.ts +8 -4
- package/dist/im/lark/grant-pending.d.ts.map +1 -1
- package/dist/im/lark/grant-pending.js +13 -6
- package/dist/im/lark/grant-pending.js.map +1 -1
- package/dist/services/connector-store.d.ts +8 -0
- package/dist/services/connector-store.d.ts.map +1 -1
- package/dist/services/connector-store.js.map +1 -1
- package/dist/services/session-store.d.ts +2 -1
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +60 -2
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/skill-registry-store.d.ts +16 -0
- package/dist/services/skill-registry-store.d.ts.map +1 -1
- package/dist/services/skill-registry-store.js +343 -47
- package/dist/services/skill-registry-store.js.map +1 -1
- package/dist/services/trigger-types.d.ts +5 -0
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +12 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/types.d.ts +13 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.js +440 -34
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/connectors-page-2MKF4YTN.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-B6U6TCFF.js +0 -3
- 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
|
-
|
|
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,
|
|
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
|