diff --git a/src/cli.ts b/src/cli.ts index b1c8fb8c6..1a43cffd9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,7 +33,7 @@ import { validateWorkingDir } from './core/working-dir.js'; import { resolveSessionContext } from './core/session-marker.js'; import { resolveBotmuxDataDir } from './core/data-dir.js'; import { dashboardSecretPath } from './core/dashboard-secret.js'; -import { acceptedDispatchBotAppIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget } from './core/dispatch.js'; +import { acceptedDispatchBotAppIds, activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, foldableChatSessionAppIds, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget, threadRootForReachability } from './core/dispatch.js'; import { pickTurnReplyTarget } from './core/reply-target.js'; import { recordDispatchRegistryEntry } from './core/dispatch-registry.js'; import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js'; @@ -83,7 +83,8 @@ import { expandHomePath, invalidWorkingDirs } from './utils/working-dir.js'; import { firstPositional } from './cli/arg-utils.js'; import { isColdResumeDormant, sessionListDisposition } from './cli/session-list-liveness.js'; import { dispatchPrimaryMessage, findStdinAliasAttachment, normalizeInteractiveCardInput, sendFileAttachments, sendVideoAttachments, shouldSendAsPureVideo, validateVideoAttachments } from './cli/send-dispatch.js'; -import { dispatchDeferredTopicSend, type DeferredScheduleRunData } from './cli/deferred-topic-send.js'; +import { dispatchDeferredTopicSend, reusableDeferredTopicRoot, type DeferredScheduleRunData } from './cli/deferred-topic-send.js'; +import { readDeferredTopicBinding } from './core/deferred-topic-binding.js'; import { resolveDaemonEnv } from './cli/daemon-lifecycle-env.js'; import { buildPm2SpawnCommand } from './cli/pm2-command.js'; import { callDashboard, type DashboardEndpoint, type DashboardResult } from './cli/dashboard-endpoint.js'; @@ -6766,11 +6767,12 @@ async function cmdSend(rest: string[]): Promise { } // Register bots so Lark client works - const { registerBot, loadBotConfigs, findOncallChatForAnyBot } = await import('./bot-registry.js'); + const { registerBot, loadBotConfigs, findOncallChatForAnyBot, getBot } = await import('./bot-registry.js'); + const { resolveRegularGroupMode } = await import('./services/chat-reply-mode-store.js'); try { for (const cfg of loadBotConfigs()) registerBot(cfg); } catch { /* */ } if (envPinnedRiffBot) { try { registerBot(envPinnedRiffBot); } catch { /* */ } } - const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError } = await import('./im/lark/client.js'); + const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError, getChatModeStrict } = await import('./im/lark/client.js'); const appId = s.larkAppId!; // Effective target chat for top-level mode (defaults to session's chat) const targetChatId = overrideChatId ?? s.chatId; @@ -6778,6 +6780,49 @@ async function cmdSend(rest: string[]): Promise { // reply_in_thread, otherwise Lark would force every reply into a fresh // topic — defeating the whole point of chat-scope routing. const isChatScope = s.scope === 'chat'; + // Compute the actual outbound anchor before the advisory guard. A chat-scope + // sender can still reply into a per-turn topic, so sender scope alone does + // not describe which peer sessions are reachable. + const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId }); + const dataDir = resolveDataDir(); + const deferredBinding = !sendInto && (!overrideChatId || overrideChatId === s.chatId) + ? readDeferredTopicBinding(dataDir, s.sessionId) + : undefined; + const deferredRoot = reusableDeferredTopicRoot({ + session: s as SessionData & { larkAppId: string }, + binding: deferredBinding, + explicitTopLevel: sendTopLevel, + reuseBoundRootWhenTopLevel: deferredMaterializedByThisCommand, + }); + const reachabilityTarget = deferredRoot + ? { mode: 'thread' as const, rootMessageId: deferredRoot } + : sendTarget; + + // Load the sender-scoped bot identity map once. Besides prose @Name + // injection below, it lets the sub-bot hint recognize peers that already + // have an active session in THIS conversation. + let botEntries: BotMentionEntry[] = []; + let crossRef: Record = {}; + try { + const botInfoPath = join(dataDir, 'bots-info.json'); + const parsedBotEntries = existsSync(botInfoPath) + ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) + : []; + botEntries = Array.isArray(parsedBotEntries) + ? parsedBotEntries.filter((entry): entry is BotMentionEntry => + !!entry + && typeof entry === 'object' + && typeof entry.larkAppId === 'string' + && (entry.botName === null || typeof entry.botName === 'string')) + : []; + const crossRefPath = join(dataDir, `bot-openids-${appId}.json`); + const parsedCrossRef = existsSync(crossRefPath) + ? JSON.parse(readFileSync(crossRefPath, 'utf-8')) + : {}; + crossRef = parsedCrossRef && typeof parsedCrossRef === 'object' && !Array.isArray(parsedCrossRef) + ? parsedCrossRef + : {}; + } catch { /* best-effort identity map */ } // ── Footgun guard: orchestrator → sub-bot ── // A dispatched sub-bot's session lives in its sub-topic; @-ing it from the main @@ -6787,22 +6832,47 @@ async function cmdSend(rest: string[]): Promise { // `@OtherSubBot` can't slip past after this explicit guard already ran. let dispatchReg: Record = {}; try { - const regPath = join(resolveDataDir(), 'orchestrate-dispatch.json'); + const regPath = join(dataDir, 'orchestrate-dispatch.json'); if (existsSync(regPath)) dispatchReg = JSON.parse(readFileSync(regPath, 'utf-8')); } catch { /* no/!corrupt registry → no guard */ } const dispatchActiveSeeds = new Set(); + let allSessions: SessionData[] = []; if (Object.keys(dispatchReg).length > 0) { - for (const sess of loadSessions().values()) { - if (sess.status === 'active' && sess.scope !== 'chat' && sess.rootMessageId) { + allSessions = [...loadSessions().values()]; + for (const sess of allSessions) { + if (sess.status !== 'active') continue; + if (sess.scope !== 'chat' && sess.rootMessageId) { dispatchActiveSeeds.add(sess.rootMessageId); } } } + // An active chat-scope session can outlive a /reply-mode switch. Verify the + // target bot's current effective mode before assuming mentions still fold + // back into that old session. + const foldableChatAppIds = await foldableChatSessionAppIds({ + sessions: allSessions, + targetChatId, + outboundMode: reachabilityTarget.mode, + resolveMode: (larkAppId, chatId) => { + getBot(larkAppId); // unknown target bot must fail closed + return resolveRegularGroupMode(larkAppId, chatId); + }, + resolveChatMode: chatId => getChatModeStrict(appId, chatId), + }); + const reachableOpenIds = activeConversationBotOpenIds({ + sessions: allSessions, + targetChatId, + outboundRootMessageId: threadRootForReachability(reachabilityTarget), + foldableChatAppIds, + botEntries, + crossRef, + }); // Sub-topic seed if `openId` is a dispatched sub-bot in an active topic that is - // NOT reachable in the current conversation; else null. The bot I'm replying to - // here (quoteTargetSenderOpenId) is reachable, so it's never treated as off-topic. + // NOT reachable in the current conversation; else null. Both the bot I'm + // replying to and any peer with an active session at this conversation anchor + // are reachable, so an unrelated old dispatch topic must not be recommended. const offTopicSubBotSeed = (openId: string): string | null => - offTopicSubBotTopic({ mentionOpenId: openId, quoteTargetSenderOpenId: replyTargetSenderOpenId, chatId: targetChatId, registry: dispatchReg, activeSeeds: dispatchActiveSeeds }); + offTopicSubBotTopic({ mentionOpenId: openId, quoteTargetSenderOpenId: replyTargetSenderOpenId, reachableOpenIds, chatId: targetChatId, registry: dispatchReg, activeSeeds: dispatchActiveSeeds }); // Explicit --mention / --mention-back of an off-topic sub-bot → block + point to // the right command (--anyway overrides). Prose @Name injection is filtered // (dropped, not blocked) at its own site below. @@ -6832,9 +6902,9 @@ async function cmdSend(rest: string[]): Promise { rootMessageId: s.rootMessageId, title: s.title, }; - // Dispatch helper: top-level / chat-scope send vs reply-in-thread, single - // decision point. Used for file attachments (always plain in chat scope). - const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId }); + // Ordinary delivery uses the nominal target. Deferred delivery gets first + // refusal below; the advisory mirrors its existing binding in + // `reachabilityTarget` above so both paths agree about the effective root. const dispatch = async ( content: string, msgType: string, @@ -7060,16 +7130,7 @@ async function cmdSend(rest: string[]): Promise { // "获取群组中其他机器人和用户@当前机器人的消息"权限),不再走任何本地 // 转发——botmux 历史上为绕过 Lark 不投递跨 bot 事件搞过 signal-file, // 那套已经在该权限上线后整体下线。 - let botEntries: BotMentionEntry[] = []; - let crossRef: Record = {}; try { - const dataDir = resolveDataDir(); - const botInfoPath = join(dataDir, 'bots-info.json'); - botEntries = existsSync(botInfoPath) ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) : []; - const crossRefPath = join(dataDir, `bot-openids-${appId}.json`); - crossRef = existsSync(crossRefPath) - ? JSON.parse(readFileSync(crossRefPath, 'utf-8')) - : {}; // --no-mention 显式不 @ 任何人:跳过正文 @BotName 的自动注入,否则正文里 // 出现的 @名字 仍会被注入成 ,破坏 --no-mention 语义、还可能误触发对方 // bot(正是要避免的循环 @)。botEntries/crossRef 仍需加载供 footer 寻址用。 diff --git a/src/cli/deferred-topic-send.ts b/src/cli/deferred-topic-send.ts index b20754aad..0b63c5e76 100644 --- a/src/cli/deferred-topic-send.ts +++ b/src/cli/deferred-topic-send.ts @@ -27,6 +27,34 @@ export interface DeferredTopicSendResult { materializedNow?: boolean; } +export function deferredTopicBindingMatches( + session: DeferredTopicSendSession, + binding: DeferredTopicBinding, +): boolean { + const run = session.deferredScheduleRun; + return !!run + && binding.sessionId === session.sessionId + && binding.turnId === run.turnId + && binding.chatId === session.chatId + && binding.larkAppId === session.larkAppId + && binding.routingAnchor === run.routingAnchor; +} + +/** Existing deferred runs reply into their materialized topic before the + * nominal send target is considered. Mirror that precedence for advisory + * reachability without materializing a new topic. */ +export function reusableDeferredTopicRoot(input: { + session: DeferredTopicSendSession; + binding: DeferredTopicBinding | undefined; + explicitTopLevel: boolean; + reuseBoundRootWhenTopLevel?: boolean; +}): string | undefined { + const { binding } = input; + if (!binding || !deferredTopicBindingMatches(input.session, binding)) return undefined; + if (input.explicitTopLevel && !input.reuseBoundRootWhenTopLevel) return undefined; + return binding.rootMessageId; +} + /** Route one `botmux send` effect for a lazily-materialized schedule topic. * The per-session file lock spans the provider request so two concurrent CLI * processes cannot both create a root. A stable provider UUID (sessionId) @@ -50,12 +78,7 @@ export async function dispatchDeferredTopicSend(opts: { return withDeferredTopicBindingLock(opts.dataDir, opts.session.sessionId, async () => { const existing = readDeferredTopicBinding(opts.dataDir, opts.session.sessionId); if (existing) { - if ( - existing.turnId !== run.turnId - || existing.chatId !== opts.session.chatId - || existing.larkAppId !== opts.session.larkAppId - || existing.routingAnchor !== run.routingAnchor - ) { + if (!deferredTopicBindingMatches(opts.session, existing)) { throw new Error('deferred topic binding identity mismatch'); } if (opts.explicitTopLevel && !opts.reuseBoundRootWhenTopLevel) return { handled: false }; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 0fb1fde29..0f1ce96e9 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -14,6 +14,7 @@ * (cli.ts) performs the actual sendMessage + replyMessage. */ +import type { SessionReplyTarget } from './reply-target.js'; export { resolveSendTarget } from './reply-target.js'; export interface DispatchBot { @@ -224,6 +225,125 @@ export function findSubBotTopic(input: { return null; } +/** A quote reply references a root but does not enter that root's thread. */ +export function threadRootForReachability(target: SessionReplyTarget): string | undefined { + return target.mode === 'thread' ? target.rootMessageId : undefined; +} + +type ReachabilitySession = { + status: 'active' | 'closed'; + scope?: 'thread' | 'chat'; + chatId: string; + rootMessageId: string; + larkAppId?: string; + deferredScheduleRun?: unknown; + vcMeetingReceiver?: unknown; +}; + +/** + * Identify active chat sessions whose bot is still configured to fold a + * mention back into that shared session. Mode lookup failures fail closed. + */ +export async function foldableChatSessionAppIds(input: { + sessions: Iterable; + targetChatId: string; + outboundMode: SessionReplyTarget['mode']; + resolveMode: (larkAppId: string, chatId: string) => 'chat' | 'shared' | 'new-topic' | 'chat-topic' | undefined; + resolveChatMode: (chatId: string) => Promise<'group' | 'topic' | 'p2p' | 'unknown' | undefined>; +}): Promise> { + const candidates = new Set(); + for (const session of input.sessions) { + if (session.status !== 'active' + || session.scope !== 'chat' + || !session.larkAppId + || session.chatId !== input.targetChatId + // These chat-scoped sessions deliberately use isolated routing keys and + // can never be reached through the ordinary (chatId, appId) slot. + || session.deferredScheduleRun + || session.vcMeetingReceiver) continue; + candidates.add(session.larkAppId); + } + + const appIds = new Set(); + if (candidates.size === 0) return appIds; + // Topology belongs to the chat, not to an individual bot. Resolve it once + // through the sending bot's authenticated client. + let chatMode: 'group' | 'topic' | 'p2p' | 'unknown' | undefined; + try { + chatMode = await input.resolveChatMode(input.targetChatId); + } catch { /* lookup failure → retain advisory */ } + if (chatMode !== 'group') return appIds; + + for (const larkAppId of candidates) { + try { + const mode = input.resolveMode(larkAppId, input.targetChatId); + // chat-topic reuses the chat session for top-level/quote delivery, but + // deliberately keeps a real Lark topic isolated. new-topic never folds + // a new mention back into a leftover chat session. + if (mode === 'chat' + || mode === 'shared' + || (mode === 'chat-topic' && input.outboundMode !== 'thread')) { + appIds.add(larkAppId); + } + } catch { /* unknown bot/mode → retain advisory */ } + } + return appIds; +} + +/** + * Resolve sender-scoped open_ids for bots that already have an active session + * at the current conversation anchor. These peers are reachable here, so an + * older dispatch record for the same bot must not be presented as the target. + */ +export function activeConversationBotOpenIds(input: { + sessions: Iterable; + targetChatId: string; + outboundRootMessageId?: string; + foldableChatAppIds?: Set; + botEntries: Array<{ larkAppId: string; botName: string | null }>; + crossRef: Record; +}): Set { + const activeAppIds = new Set(); + for (const session of input.sessions) { + if (session.status !== 'active' + || !session.larkAppId + || session.chatId !== input.targetChatId) continue; + // A chat-scope peer is reachable from any message in the same group: + // mentions inside a topic fold back into that peer's shared chat session. + // A thread-scope peer is reachable only when this send actually lands in + // the same thread root. + const here = session.scope === 'chat' + ? input.foldableChatAppIds?.has(session.larkAppId) === true + : !!input.outboundRootMessageId + && session.rootMessageId === input.outboundRootMessageId; + if (here) activeAppIds.add(session.larkAppId); + } + + const openIds = new Set(); + const entries = Array.isArray(input.botEntries) + ? input.botEntries.filter((entry): entry is { larkAppId: string; botName: string | null } => + !!entry + && typeof entry === 'object' + && typeof entry.larkAppId === 'string' + && (entry.botName === null || typeof entry.botName === 'string')) + : []; + const crossRef = input.crossRef && typeof input.crossRef === 'object' + ? input.crossRef + : {}; + for (const entry of entries) { + if (!entry.botName || !activeAppIds.has(entry.larkAppId)) continue; + // crossRef is keyed by display name. When several apps share that name, + // the value cannot prove which app it represents; fail closed and retain + // the old-topic hint instead of silencing it for the wrong bot. + const sameNameEntries = entries.filter(candidate => + candidate.botName?.toLowerCase() === entry.botName!.toLowerCase()); + if (sameNameEntries.length !== 1) continue; + const openId = crossRef[entry.botName]; + if (typeof openId === 'string' && openId) openIds.add(openId); + } + return openIds; +} + /** * Resolve where a `botmux report` should go + who to @, so report-back works * even when the orchestrator is on a DIFFERENT machine. @@ -418,20 +538,21 @@ export function acceptedDispatchBotAppIds(input: { * a dispatched sub-bot in an active topic that is NOT reachable in the current * conversation (so @-ing it here would spawn a context-less session), else null. * - * The bot I'm replying to (`quoteTargetSenderOpenId`) is reachable right here, so - * it's never treated as off-topic — that's the boundary that stops the guard from - * blocking a normal reply to a bot conversing with me. Callers block (explicit - * --mention) or drop (prose injection) on a non-null result, and skip the whole - * check under `--anyway`. + * The bot I'm replying to (`quoteTargetSenderOpenId`) and bots in + * `reachableOpenIds` are reachable right here, so an unrelated older dispatch + * topic is never recommended for them. */ export function offTopicSubBotTopic(input: { mentionOpenId: string; quoteTargetSenderOpenId?: string; + reachableOpenIds?: Set; chatId: string; registry: Record; activeSeeds: Set; }): string | null { - if (!input.mentionOpenId || input.mentionOpenId === input.quoteTargetSenderOpenId) return null; + if (!input.mentionOpenId + || input.mentionOpenId === input.quoteTargetSenderOpenId + || input.reachableOpenIds?.has(input.mentionOpenId)) return null; return findSubBotTopic({ mentionOpenId: input.mentionOpenId, chatId: input.chatId, diff --git a/test/deferred-topic-send.test.ts b/test/deferred-topic-send.test.ts index 31b074963..9c3e001f2 100644 --- a/test/deferred-topic-send.test.ts +++ b/test/deferred-topic-send.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { dispatchDeferredTopicSend } from '../src/cli/deferred-topic-send.js'; +import { dispatchDeferredTopicSend, reusableDeferredTopicRoot } from '../src/cli/deferred-topic-send.js'; import { deferredTopicBindingPath, readDeferredTopicBinding, @@ -92,6 +92,13 @@ describe('deferred fresh-topic botmux send routing', () => { it('reuses the materialized topic for later turns while explicit top-level remains an escape hatch', async () => { await dispatchDeferredTopicSend(makeOptions()); + const binding = readDeferredTopicBinding(dataDir, SESSION_ID)!; + expect(reusableDeferredTopicRoot({ + session: makeOptions().session, + binding, + explicitTopLevel: false, + })).toBe('om_alert_root'); + const followUp = makeOptions({ currentTurnId: 'human-turn-2', content: 'more detail' }); const result = await dispatchDeferredTopicSend(followUp); @@ -100,6 +107,11 @@ describe('deferred fresh-topic botmux send routing', () => { expect(result).toMatchObject({ handled: true, rootMessageId: 'om_alert_root', materializedNow: false }); const topLevel = makeOptions({ currentTurnId: 'human-turn-3', explicitTopLevel: true }); + expect(reusableDeferredTopicRoot({ + session: topLevel.session, + binding, + explicitTopLevel: true, + })).toBeUndefined(); expect(await dispatchDeferredTopicSend(topLevel)).toEqual({ handled: false }); expect(topLevel.replyRoot).not.toHaveBeenCalled(); }); diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index a6f7c0ea1..f5af8cc69 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { findAncestorSessionContext } from '../src/core/session-marker.js'; import { acceptedDispatchBotAppIds, + activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, @@ -26,9 +27,11 @@ import { findSubBotTopic, eligibleAutoMentionAliases, offTopicSubBotTopic, + foldableChatSessionAppIds, recordDispatchInputCommit, resolveReportTarget, resolveSendTarget, + threadRootForReachability, } from '../src/core/dispatch.js'; describe('parseDispatchBotSpec', () => { @@ -216,6 +219,268 @@ describe('findSubBotTopic', () => { }); }); +describe('activeConversationBotOpenIds', () => { + const botEntries = [ + { larkAppId: 'cli_orchestrator', botName: 'AI Bear' }, + { larkAppId: 'cli_reviewer', botName: 'TraeX' }, + ]; + const crossRef = { + 'AI Bear': 'ou_orchestrator', + 'TraeX': 'ou_reviewer', + }; + + it('finds a peer active in the current topic and excludes the same peer in another topic', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_old', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries, + crossRef, + }); + expect(result).toEqual(new Set(['ou_reviewer'])); + }); + + it('does not trust a leftover chat session after the bot stops using a foldable mode', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + foldableChatAppIds: new Set(), + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + + it('does not treat a peer active only in another topic as reachable here', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_old', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + + it('treats a same-chat chat-scope peer as reachable from a thread sender', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_reviewer' }, + { status: 'active', scope: 'chat', chatId: 'oc_else', rootMessageId: 'oc_else', larkAppId: 'cli_orchestrator' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + foldableChatAppIds: new Set(['cli_reviewer']), + botEntries, + crossRef, + }); + expect(result).toEqual(new Set(['ou_reviewer'])); + }); + + it('treats a same-root thread peer as reachable from a chat-scope reply into that topic', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries, + crossRef, + }); + expect(result).toEqual(new Set(['ou_reviewer'])); + }); + + it('does not treat a thread peer as reachable from a plain chat send', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + + it('does not treat a quoted root as entering a thread peer session', () => { + const quoteTarget = { mode: 'quote' as const, rootMessageId: 'om_current' }; + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: threadRootForReachability(quoteTarget), + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + + it('fails closed when multiple bot apps share the same display name', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_same_1' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries: [ + { larkAppId: 'cli_same_1', botName: 'Same' }, + { larkAppId: 'cli_same_2', botName: 'Same' }, + ], + crossRef: { Same: 'ou_same_2' }, + }); + expect(result).toEqual(new Set()); + }); + + it('treats a non-array bot identity payload as empty instead of throwing', () => { + expect(activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries: {} as any, + crossRef, + })).toEqual(new Set()); + }); + + it('ignores malformed elements inside a bot identity array', () => { + expect(activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries: [null, 1, {}, ...botEntries] as any, + crossRef, + })).toEqual(new Set(['ou_reviewer'])); + }); +}); + +describe('send-target reachability helpers', () => { + it('only exposes a root for a real thread send, not quote/plain delivery', () => { + expect(threadRootForReachability({ mode: 'thread', rootMessageId: 'om_thread' })).toBe('om_thread'); + expect(threadRootForReachability({ mode: 'quote', rootMessageId: 'om_quote' })).toBeUndefined(); + expect(threadRootForReachability({ mode: 'plain', chatId: 'oc_chat' })).toBeUndefined(); + }); + + it('accepts only currently foldable ordinary-group chat sessions in the target chat', async () => { + const sessions = [ + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_chat' }, + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_new_topic' }, + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_else', rootMessageId: 'oc_else', larkAppId: 'cli_elsewhere' }, + ]; + const modes = new Map([ + ['cli_chat', 'chat' as const], + ['cli_new_topic', 'new-topic' as const], + ['cli_elsewhere', 'shared' as const], + ]); + expect(await foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'thread', + resolveMode: appId => modes.get(appId), + resolveChatMode: async () => 'group', + })).toEqual(new Set(['cli_chat'])); + }); + + it('keeps chat-topic chat sessions reachable only for top-level-like delivery', async () => { + const sessions = [ + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_chat_topic' }, + ]; + const resolveMode = () => 'chat-topic' as const; + expect(await foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode, + resolveChatMode: async () => 'group', + })).toEqual(new Set(['cli_chat_topic'])); + expect(await foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'quote', + resolveMode, + resolveChatMode: async () => 'group', + })).toEqual(new Set(['cli_chat_topic'])); + expect(await foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'thread', + resolveMode, + resolveChatMode: async () => 'group', + })).toEqual(new Set()); + }); + + it('excludes isolated deferred and VC chat sessions from the ordinary routing slot', async () => { + expect(await foldableChatSessionAppIds({ + sessions: [ + { + status: 'active', + scope: 'chat', + chatId: 'oc_main', + rootMessageId: 'om_deferred', + larkAppId: 'cli_deferred', + deferredScheduleRun: { routingAnchor: 'schedule-run:1' }, + }, + { + status: 'active', + scope: 'chat', + chatId: 'oc_main', + rootMessageId: 'om_vc', + larkAppId: 'cli_vc', + vcMeetingReceiver: { meetingId: 'meeting-1' }, + }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => 'chat', + resolveChatMode: async () => 'group', + })).toEqual(new Set()); + }); + + it('fails closed after a regular group becomes a topic chat', async () => { + expect(await foldableChatSessionAppIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_stale' }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => 'chat', + resolveChatMode: async () => 'topic', + })).toEqual(new Set()); + }); + + it('fails closed when the target bot reply mode or chat topology cannot be resolved', async () => { + expect(await foldableChatSessionAppIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_unknown' }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => { throw new Error('unknown bot'); }, + resolveChatMode: async () => 'group', + })).toEqual(new Set()); + expect(await foldableChatSessionAppIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_unknown' }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => 'chat', + resolveChatMode: async () => 'unknown', + })).toEqual(new Set()); + }); +}); + describe('eligibleAutoMentionAliases', () => { const selfAliases = new Set(['claude', 'claude-code']); const convo = new Set(['cli_reviewer_in_topic']); @@ -255,6 +520,17 @@ describe('offTopicSubBotTopic', () => { expect(offTopicSubBotTopic({ mentionOpenId: 'ou_subbot', quoteTargetSenderOpenId: 'ou_subbot', chatId: 'oc_main', registry, activeSeeds })).toBeNull(); }); + it('does not recommend an old dispatch topic when the bot is already active here', () => { + expect(offTopicSubBotTopic({ + mentionOpenId: 'ou_subbot', + quoteTargetSenderOpenId: 'ou_human', + reachableOpenIds: new Set(['ou_subbot']), + chatId: 'oc_main', + registry, + activeSeeds, + })).toBeNull(); + }); + it('allows a bot that is not a dispatched sub-bot', () => { expect(offTopicSubBotTopic({ mentionOpenId: 'ou_stranger', quoteTargetSenderOpenId: 'ou_human', chatId: 'oc_main', registry, activeSeeds })).toBeNull(); });