diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 196c58a7..e0802227 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -531,19 +531,28 @@ export interface BotConfig { regularGroupReplyMode?: ChatReplyMode; /** * Per-bot (bot-global) policy for when an @mention is required to get a reply - * in regular Lark groups — a 3-tier ladder: + * in regular Lark groups — a 4-tier ladder: * • 'always' (or undefined) — @ required everywhere, including inside the * bot's own shared topics (the safe default). * • 'topic' — @ required to start / at top level, but NOT * inside the bot's shared topics (non-@ replies * there continue the session). - * • 'never' — @ never required: non-@ messages in groups - * where the bot has talk access are answered too. + * • 'never' — @ never required: every non-@ message in groups + * where the bot has talk access is answered too, + * unconditionally. For dedicated / on-call groups. + * • 'ambient' — like 'never' (non-@ messages answered), EXCEPT + * when the message @mentions another specific + * member (person/bot) without @ing this bot — + * that is a redirect to someone else, so the bot + * stays quiet (@all is not a redirect). Best for + * multi-bot / multi-person groups: a default + * responder that yields when you address someone + * else. * Governs the shared-topic fold-back + the top-level @ gate. `new-topic` / * 话题群 topics own their own thread and continue without @ regardless (that * is the mode's defining behavior, not affected by this policy). */ - regularGroupMentionMode?: 'always' | 'topic' | 'never'; + regularGroupMentionMode?: 'always' | 'topic' | 'never' | 'ambient'; /** * 飞书文档订阅入口(/subscribe-lark-doc)新订阅的默认评论触发范围: * • 'mention-only'(或 undefined)— 仅评论里 @bot 才触发(默认,防噪声) @@ -1147,9 +1156,12 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { const mode = normalizeChatReplyModeConfig(entry.regularGroupReplyMode); return mode === 'new-topic' || mode === 'shared' || mode === 'chat-topic' ? mode : undefined; })(), - // 3-tier @ policy. Only 'topic' | 'never' are meaningful; 'always' (the - // default) and anything else normalize to undefined so bots.json stays clean. - regularGroupMentionMode: entry.regularGroupMentionMode === 'topic' || entry.regularGroupMentionMode === 'never' + // 4-tier @ policy. Only 'topic' | 'never' | 'ambient' are meaningful; + // 'always' (the default) and anything else normalize to undefined so + // bots.json stays clean. + regularGroupMentionMode: entry.regularGroupMentionMode === 'topic' + || entry.regularGroupMentionMode === 'never' + || entry.regularGroupMentionMode === 'ambient' ? entry.regularGroupMentionMode : undefined, // 文档订阅默认触发范围。只 'all' 有意义;'mention-only'(默认)归一化为 diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 77d56161..f1efb17e 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -1268,7 +1268,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { disableStreamingCard?: boolean; silentTurnReactions?: boolean; writableTerminalLinkInCard?: boolean; privateCard?: boolean; botToBotSameDir?: boolean; autoStartOnGroupJoin?: boolean; autoStartOnGroupJoinPrompt?: string; autoStartOnNewTopic?: boolean; - regularGroupReplyMode?: ChatReplyMode; regularGroupMentionMode?: 'always' | 'topic' | 'never'; + regularGroupReplyMode?: ChatReplyMode; regularGroupMentionMode?: 'always' | 'topic' | 'never' | 'ambient'; docSubscribeDefaultMode?: 'mention-only' | 'all'; } = {}; if (typeof body.disableStreamingCard === 'boolean') patch.disableStreamingCard = body.disableStreamingCard; @@ -1283,7 +1283,7 @@ ipcRoute('PUT', '/api/bot-card-prefs', async (req, res) => { const m = normalizeChatReplyMode(body.regularGroupReplyMode); if (m) patch.regularGroupReplyMode = m; } - if (body.regularGroupMentionMode === 'always' || body.regularGroupMentionMode === 'topic' || body.regularGroupMentionMode === 'never') { + if (body.regularGroupMentionMode === 'always' || body.regularGroupMentionMode === 'topic' || body.regularGroupMentionMode === 'never' || body.regularGroupMentionMode === 'ambient') { patch.regularGroupMentionMode = body.regularGroupMentionMode; } if (body.docSubscribeDefaultMode === 'mention-only' || body.docSubscribeDefaultMode === 'all') { diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index e83d8ee0..d6d65b7e 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -45,7 +45,7 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: regularGroupReplyMode: (j?.regularGroupReplyMode === 'new-topic' || j?.regularGroupReplyMode === 'shared' || j?.regularGroupReplyMode === 'chat-topic') ? j.regularGroupReplyMode : 'chat', - regularGroupMentionMode: (j?.regularGroupMentionMode === 'topic' || j?.regularGroupMentionMode === 'never') + regularGroupMentionMode: (j?.regularGroupMentionMode === 'topic' || j?.regularGroupMentionMode === 'never' || j?.regularGroupMentionMode === 'ambient') ? j.regularGroupMentionMode : 'always', restrictGrantCommands: j?.restrictGrantCommands === true, diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index 8e5d90aa..d9ad899b 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -419,7 +419,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { const p2p: string = b.p2pMode === 'chat' ? 'chat' : 'thread'; const regular: string = (b.regularGroupReplyMode === 'new-topic' || b.regularGroupReplyMode === 'shared' || b.regularGroupReplyMode === 'chat-topic') ? b.regularGroupReplyMode : 'chat'; - const mention: string = (b.regularGroupMentionMode === 'topic' || b.regularGroupMentionMode === 'never') + const mention: string = (b.regularGroupMentionMode === 'topic' || b.regularGroupMentionMode === 'never' || b.regularGroupMentionMode === 'ambient') ? b.regularGroupMentionMode : 'always'; const docMode: string = b.docSubscribeDefaultMode === 'all' ? 'all' : 'mention-only'; const opt = (v: string, label: string) => @@ -465,6 +465,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { ${mopt('always', t('botDefaults.mentionModeAlways'))} ${mopt('topic', t('botDefaults.mentionModeTopic'))} ${mopt('never', t('botDefaults.mentionModeNever'))} + ${mopt('ambient', t('botDefaults.mentionModeAmbient'))} ${t('botDefaults.mentionModeHelp')} diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index db8a7ce7..d3406916 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1084,7 +1084,8 @@ const zh: DashboardMessages = { 'botDefaults.mentionModeAlways': '都需要 @(默认)', 'botDefaults.mentionModeTopic': '仅话题内不需要 @', 'botDefaults.mentionModeNever': '所有消息都不需要 @', - 'botDefaults.mentionModeHelp': '该 bot 全局生效,决定群里要不要 @ 才回:「都需要 @」= 任何消息都得 @(默认,最安全;多人群里话题内也要 @);「仅话题内不需要 @」= 起新对话 / 顶层仍要 @,但在该 bot 已开的任何话题里(new-topic / shared / 话题群)后续消息免 @ 续聊;「所有消息都不需要 @」= 该 bot 有对话权限的群里非 @ 消息也回(含全新消息冷启动,仅适合专用 / 值班小群,多人群里会见消息就回)。注:群里只有你和该 bot(1 对 1)时一直免 @,与本设置无关。', + 'botDefaults.mentionModeAmbient': '都不需要 @(但你 @ 别人时让位)', + 'botDefaults.mentionModeHelp': '该 bot 全局生效,决定群里要不要 @ 才回:「都需要 @」= 任何消息都得 @(默认,最安全;多人群里话题内也要 @);「仅话题内不需要 @」= 起新对话 / 顶层仍要 @,但在该 bot 已开的任何话题里(new-topic / shared / 话题群)后续消息免 @ 续聊;「所有消息都不需要 @」= 该 bot 有对话权限的群里非 @ 消息也无条件回(含全新消息冷启动,适合专用 / 值班小群);「都不需要 @(但你 @ 别人时让位)」= 同样免 @ 也回,但当这条消息 @ 了别的人或别的 bot(而没 @ 它)时它识趣不回,相当于你把这条交给别人了(@所有人 不算交给别人,仍会回),适合多人 / 多 bot 群里想要一个默认响应者、但你点名别人时它就让位。注:群里只有你和该 bot(1 对 1)时一直免 @,与本设置无关。', 'botDefaults.docSubscribeMode': '文档订阅触发范围(默认)', 'botDefaults.docSubscribeModeMention': '仅评论 @ 我才触发', 'botDefaults.docSubscribeModeAll': '所有新评论都触发', @@ -2435,7 +2436,8 @@ const en: DashboardMessages = { 'botDefaults.mentionModeAlways': 'Always require @ (default)', 'botDefaults.mentionModeTopic': 'No @ needed inside topics', 'botDefaults.mentionModeNever': 'Never require @', - 'botDefaults.mentionModeHelp': 'Bot-global: controls when an @ is required to get a reply in groups. "Always require @" = every message needs an @ (default, safest; inside topics too, in multi-person groups); "No @ needed inside topics" = starting a new conversation / top-level still needs @, but follow-ups inside ANY topic this bot already drives (new-topic / shared / topic-group) continue without @; "Never require @" = non-@ messages are answered too wherever the bot has talk access (including cold-starting on a brand-new message — only suitable for dedicated / on-call small groups; in busy multi-person groups it replies to everything). Note: when you are alone with this bot (1:1) replies never need an @, independent of this setting.', + 'botDefaults.mentionModeAmbient': 'No @ needed, but yields when you @ someone else', + 'botDefaults.mentionModeHelp': 'Bot-global: controls when an @ is required to get a reply in groups. "Always require @" = every message needs an @ (default, safest; inside topics too, in multi-person groups); "No @ needed inside topics" = starting a new conversation / top-level still needs @, but follow-ups inside ANY topic this bot already drives (new-topic / shared / topic-group) continue without @; "Never require @" = non-@ messages are always answered wherever the bot has talk access (including cold-starting on a brand-new message) — for dedicated / on-call small groups; "No @ needed, but yields when you @ someone else" = same no-@ answering, EXCEPT when your message @mentions another person or bot (and not this one), in which case it backs off, treating it as handed to someone else (@all does not count, so it still replies) — best for multi-bot / multi-person groups that want a default responder which yields the moment you address someone else. Note: when you are alone with this bot (1:1) replies never need an @, independent of this setting.', 'botDefaults.docSubscribeMode': 'Doc subscription trigger (default)', 'botDefaults.docSubscribeModeMention': 'Only when a comment @s me', 'botDefaults.docSubscribeModeAll': 'Every new comment', diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 1b89c051..a3b6c77e 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -787,6 +787,50 @@ export function isBotMentioned(larkAppId: string, message: any, _senderOpenId: s return false; } +/** Does this message @mention a *specific other member* (a person or bot that + * is NOT this bot)? Used by the 'ambient' mention policy to decide whether to + * back off: under 'ambient' the bot answers un-@ messages, but if the user + * explicitly addresses SOMEONE ELSE it stays quiet (the redirect carve-out). + * `@all` addresses everyone including this bot, so it is NOT an "other member" + * and does NOT trigger backoff. Mirrors isBotMentioned's two shapes: + * message.mentions[] (user text) and inline `at` nodes in post content. */ +export function mentionsAnotherMember(larkAppId: string, message: any): boolean { + const botOpenId = getBot(larkAppId).botOpenId; + + // 1. message.mentions array (populated for user-sent text messages) + const mentions: any[] = message.mentions ?? []; + for (const m of mentions) { + // mentionOpenId() tolerates both the WS event object shape ({ open_id }) and + // the REST bare-string shape (a bot @ is a "cli_…" string). A naked + // m.id.open_id silently misses the string form → the redirect carve-out + // breaks and the ambient bot keeps answering instead of backing off. + const oid = mentionOpenId(m); + if (!oid) continue; + if (oid === botOpenId) continue; // that's me + if (oid === 'all') continue; // @all → everyone incl. me + return true; // a specific other member + } + + // 2. inline `at` nodes in post content (bot-sent / rich messages) + try { + const content = JSON.parse(message.content ?? '{}'); + const inner = content.zh_cn ?? content.en_us ?? content; + if (Array.isArray(inner?.content)) { + for (const paragraph of inner.content) { + if (!Array.isArray(paragraph)) continue; + for (const node of paragraph) { + if (node.tag !== 'at') continue; + const uid: string | undefined = node.user_id; + if (!uid || uid === botOpenId || uid === 'all') continue; + return true; + } + } + } + } catch { /* ignore parse errors */ } + + return false; +} + // ─── Permission gates ──────────────────────────────────────────────────── // // Two gates: @@ -1142,11 +1186,17 @@ async function maybeApplySharedTopicSeed(input: { if (forceTopicApplied) return undefined; if (chatType !== 'group') return undefined; if (resolveRegularGroupMode(larkAppId, chatId) !== 'shared') return undefined; - // Seeding a shared topic normally needs an @mention. But under the 'never' - // mention policy a non-@ message is also answered — and in shared mode it must - // still OPEN a topic (reply in a thread reusing the chat session), not fall - // back to a flat top-level reply. So allow non-@ seeding only when never. - if (!isBotMentioned(larkAppId, message, senderOpenId) && resolveGroupMentionMode(larkAppId) !== 'never') return undefined; + // Seeding a shared topic normally needs an @mention. But the 'never' and + // 'ambient' mention policies answer non-@ messages too — and in shared mode it + // must still OPEN a topic (reply in a thread reusing the chat session), not + // fall back to a flat top-level reply. So allow non-@ seeding under 'never' + // (unconditional) or 'ambient' — but for 'ambient' NOT when the message + // @mentions another specific member (person/bot) without @ing us: that is a + // redirect to someone else, so we back off (mentionsAnotherMember). + const seedMentionMode = resolveGroupMentionMode(larkAppId); + if (!isBotMentioned(larkAppId, message, senderOpenId) + && !(seedMentionMode === 'never' + || (seedMentionMode === 'ambient' && !mentionsAnotherMember(larkAppId, message)))) return undefined; const freshMode = routing.scope === 'thread' ? await getChatMode(larkAppId, chatId, { forceRefresh: true }) : (getCachedChatMode(larkAppId, chatId) ?? 'group'); @@ -1672,9 +1722,14 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin // is governed by the bot-global mention policy: 'always' (default) keeps // "@ required" so this fold-back is skipped (non-@ thread chatter falls // through to the gate below and is ignored — only an explicit @ continues - // a shared topic); 'topic' and 'never' enable the seamless no-@ fold-back. + // a shared topic); 'topic', 'never' and 'ambient' enable the seamless + // no-@ fold-back. Carve-out: under 'ambient', a non-@ reply that @mentions + // another specific member (person/bot) is a redirect to someone else → + // back off, don't fold it in (mentionsAnotherMember). 'never' + // (unconditional) and 'topic' are unaffected. if (!explicitlyMentionedThisBot && resolveGroupMentionMode(larkAppId) !== 'always' + && !(resolveGroupMentionMode(larkAppId) === 'ambient' && mentionsAnotherMember(larkAppId, message)) && routing.scope === 'thread' && message.root_id && message.thread_id && chatType === 'group') { const alias = handlers.resolveReplyThreadAlias?.(message.root_id, chatId, larkAppId) ?? null; if (alias) { @@ -1815,9 +1870,15 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin // folded back to chat-scope. // // The bot-global mention policy drops the @ requirement: - // • 'never' — entirely: any message from a talk-allowed sender is - // answered (incl. brand-new non-@ top-level → spawns/continues a - // session). Intended for dedicated / on-call groups, not busy chats. + // • 'never' — answer EVERY un-@ message from talk-allowed senders + // (incl. brand-new non-@ top-level → spawns/continues a session), + // unconditionally. Intended for dedicated / on-call groups. + // • 'ambient' — like 'never' (answer un-@ messages), EXCEPT when the + // message @mentions another specific member (person/bot) without + // @ing us — that is a redirect to someone else, so we back off and + // stay quiet (mentionsAnotherMember). @all does not count as a + // redirect. Best for multi-bot / multi-person groups that want a + // default responder which yields the moment you address someone else. // • 'topic' — only inside a topic the bot already owns: a non-@ reply // INSIDE such a thread (new-topic / 话题群 thread the bot owns, or a // shared-topic alias via replyRootId) continues without @, while a @@ -1828,6 +1889,7 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin const mentionMode = resolveGroupMentionMode(larkAppId); const relax = (!!replyRootId && isAllowed) || (isAllowed && mentionMode === 'never') + || (isAllowed && mentionMode === 'ambient' && !mentionsAnotherMember(larkAppId, message)) || (isAllowed && mentionMode === 'topic' && ownsSession && !!message.thread_id) || (ownsSession && isAllowed && !!stats && stats.userCount <= 1 && stats.botCount <= 1); if (!relax) { diff --git a/src/services/card-prefs-store.ts b/src/services/card-prefs-store.ts index 61c8222f..b6a6e3a7 100644 --- a/src/services/card-prefs-store.ts +++ b/src/services/card-prefs-store.ts @@ -39,8 +39,8 @@ export interface BotCardPrefs { autoStartOnNewTopic: boolean; /** Per-bot DEFAULT regular-group session mode (chat | chat-topic | new-topic | shared). */ regularGroupReplyMode: ChatReplyMode; - /** Per-bot 3-tier @-requirement policy for regular groups (default 'always'). */ - regularGroupMentionMode: 'always' | 'topic' | 'never'; + /** Per-bot 4-tier @-requirement policy for regular groups (default 'always'). */ + regularGroupMentionMode: 'always' | 'topic' | 'never' | 'ambient'; /** 文档订阅新订阅默认评论触发范围(default 'mention-only')。 */ docSubscribeDefaultMode: 'mention-only' | 'all'; } @@ -59,7 +59,7 @@ export function getBotCardPrefs(larkAppId: string): BotCardPrefs { autoStartOnGroupJoinPrompt: typeof c.autoStartOnGroupJoinPrompt === 'string' ? c.autoStartOnGroupJoinPrompt : '', autoStartOnNewTopic: c.autoStartOnNewTopic === true, regularGroupReplyMode: c.regularGroupReplyMode ?? 'chat', - regularGroupMentionMode: c.regularGroupMentionMode === 'topic' || c.regularGroupMentionMode === 'never' + regularGroupMentionMode: c.regularGroupMentionMode === 'topic' || c.regularGroupMentionMode === 'never' || c.regularGroupMentionMode === 'ambient' ? c.regularGroupMentionMode : 'always', docSubscribeDefaultMode: c.docSubscribeDefaultMode === 'all' ? 'all' : 'mention-only', }; @@ -118,11 +118,11 @@ export async function updateBotCardPrefs( if (val === 'new-topic' || val === 'shared' || val === 'chat-topic') entry[key] = val; else delete entry[key]; }; - // 3-tier @ policy: store only the non-default tiers; 'always' (default) drops + // 4-tier @ policy: store only the non-default tiers; 'always' (default) drops // the key so bots.json stays tidy (absent === 'always'). - const applyMention = (entry: any, key: keyof BotCardPrefs, val: 'always' | 'topic' | 'never' | undefined) => { + const applyMention = (entry: any, key: keyof BotCardPrefs, val: 'always' | 'topic' | 'never' | 'ambient' | undefined) => { if (val === undefined) return; - if (val === 'topic' || val === 'never') entry[key] = val; + if (val === 'topic' || val === 'never' || val === 'ambient') entry[key] = val; else delete entry[key]; }; // 文档订阅默认触发范围:只存 'all';'mention-only'(默认)删键保持 bots.json 干净。 @@ -158,7 +158,7 @@ export async function updateBotCardPrefs( regularGroupReplyMode: (entry.regularGroupReplyMode === 'new-topic' || entry.regularGroupReplyMode === 'shared' || entry.regularGroupReplyMode === 'chat-topic') ? entry.regularGroupReplyMode : 'chat', - regularGroupMentionMode: (entry.regularGroupMentionMode === 'topic' || entry.regularGroupMentionMode === 'never') + regularGroupMentionMode: (entry.regularGroupMentionMode === 'topic' || entry.regularGroupMentionMode === 'never' || entry.regularGroupMentionMode === 'ambient') ? entry.regularGroupMentionMode : 'always', docSubscribeDefaultMode: entry.docSubscribeDefaultMode === 'all' ? 'all' : 'mention-only', @@ -199,7 +199,7 @@ export async function updateBotCardPrefs( : undefined; } if (patch.regularGroupMentionMode !== undefined) { - bot.config.regularGroupMentionMode = (patch.regularGroupMentionMode === 'topic' || patch.regularGroupMentionMode === 'never') + bot.config.regularGroupMentionMode = (patch.regularGroupMentionMode === 'topic' || patch.regularGroupMentionMode === 'never' || patch.regularGroupMentionMode === 'ambient') ? patch.regularGroupMentionMode : undefined; } diff --git a/src/services/chat-reply-mode-store.ts b/src/services/chat-reply-mode-store.ts index 3581e534..8a7d9f5e 100644 --- a/src/services/chat-reply-mode-store.ts +++ b/src/services/chat-reply-mode-store.ts @@ -54,18 +54,20 @@ function regularGroupDefaultMode(larkAppId: string): ChatReplyMode { } } -export type GroupMentionMode = 'always' | 'topic' | 'never'; +export type GroupMentionMode = 'always' | 'topic' | 'never' | 'ambient'; /** * Per-bot (bot-global) @-requirement policy for regular groups, default 'always'. - * • always — @ required everywhere (incl. inside shared topics). - * • topic — @ required at top level, but non-@ continues inside shared topics. - * • never — non-@ messages are answered too (where the bot has talk access). + * • always — @ required everywhere (incl. inside shared topics). + * • topic — @ required at top level, but non-@ continues inside shared topics. + * • never — non-@ messages are always answered (where the bot has talk access). + * • ambient — like never, but stays quiet when the message @mentions another + * specific member (person/bot) without @ing this bot (redirect). */ export function resolveGroupMentionMode(larkAppId: string): GroupMentionMode { try { const m = getBot(larkAppId).config.regularGroupMentionMode; - return m === 'topic' || m === 'never' ? m : 'always'; + return m === 'topic' || m === 'never' || m === 'ambient' ? m : 'always'; } catch { return 'always'; } diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index a5d36a3d..a0e60a20 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -110,7 +110,7 @@ vi.mock('@larksuiteoapi/node-sdk', () => { // ─── Imports (must be after mocks) ────────────────────────────────────────── import { __resetAnchorQueues } from '../src/utils/anchor-serializer.js'; -import { __resetEventClaimsForTest, canOperate, canTalk, decideRouting, ensureBotOpenId, isBotMentioned, startLarkEventDispatcher, writeBotInfoFile, type EventHandlers } from '../src/im/lark/event-dispatcher.js'; +import { __resetEventClaimsForTest, canOperate, canTalk, decideRouting, ensureBotOpenId, isBotMentioned, mentionsAnotherMember, startLarkEventDispatcher, writeBotInfoFile, type EventHandlers } from '../src/im/lark/event-dispatcher.js'; // grant-pending is a real (unmocked) module-level table; reset it per test so the // grant-card throttle state never leaks across cases (it backs the @blocked card path). import { _resetForTest as _resetGrantPending } from '../src/im/lark/grant-pending.js'; @@ -375,6 +375,95 @@ describe('isBotMentioned', () => { }); }); +// The 'ambient' mention policy answers un-@ messages but backs off the moment +// the user @mentions a *different* member (person/bot) — the redirect carve-out. +// mentionsAnotherMember is that predicate. 'never' ignores it (unconditional). +describe('mentionsAnotherMember (ambient redirect carve-out)', () => { + beforeEach(() => { + setupBotState(); + }); + + it('returns true when the message @mentions another member via mentions array', () => { + const message = { + mentions: [{ key: '@_other', name: 'Other', id: { open_id: 'ou_other' } }], + content: JSON.stringify({ text: '@Other 你看下' }), + }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(true); + }); + + it('returns true when another BOT is @mentioned via REST string-form id (regression — naked m.id.open_id misses this)', () => { + // The headline scenario for the carve-out: "@another bot → back off". On the + // REST shape mention.id arrives as a bare string, and a bot @ is a "cli_…" + // string. mentionOpenId() must absorb it, otherwise the ambient bot keeps + // answering instead of yielding to the bot the user actually summoned. + const message = { + mentions: [{ key: '@_other', name: 'OtherBot', id: 'cli_other_bot', id_type: 'open_id' }], + content: JSON.stringify({ text: '@OtherBot 你来答' }), + }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(true); + }); + + it('returns true when another member is @mentioned via string-form id with no id_type (defaults to open_id)', () => { + const message = { + mentions: [{ key: '@_other', name: 'Other', id: 'ou_other' }], + content: JSON.stringify({ text: '@Other 你看下' }), + }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(true); + }); + + it('returns false when only THIS bot is @mentioned via string-form id (no false redirect)', () => { + const message = { + mentions: [{ key: '@_bot', name: 'BotA', id: MY_OPEN_ID, id_type: 'open_id' }], + content: JSON.stringify({ text: '@BotA hello' }), + }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(false); + }); + + it('returns true when another member is @mentioned via inline at node (post content)', () => { + const postContent = JSON.stringify({ + zh_cn: { + content: [[ + { tag: 'at', user_id: 'ou_other' }, + { tag: 'text', text: ' 帮我看下' }, + ]], + }, + }); + expect(mentionsAnotherMember(MY_APP_ID, { content: postContent, mentions: [] })).toBe(true); + }); + + it('returns false when only THIS bot is @mentioned', () => { + const message = { + mentions: [{ key: '@_bot', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + content: JSON.stringify({ text: '@BotA hello' }), + }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(false); + }); + + it('returns false for @all (everyone incl. me — not a redirect to someone else)', () => { + const message = { + mentions: [{ key: '@_all_', name: 'all', id: { open_id: 'all' } }], + content: JSON.stringify({ text: '@all 通知' }), + }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(false); + }); + + it('returns false when no one is @mentioned (plain ambient message)', () => { + const message = { mentions: [], content: JSON.stringify({ text: '随便说一句' }) }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(false); + }); + + it('returns true when both this bot AND another member are @mentioned (still a hand-off signal)', () => { + const message = { + mentions: [ + { key: '@_bot', name: 'BotA', id: { open_id: MY_OPEN_ID } }, + { key: '@_other', name: 'Other', id: { open_id: 'ou_other' } }, + ], + content: JSON.stringify({ text: '@BotA @Other' }), + }; + expect(mentionsAnotherMember(MY_APP_ID, message)).toBe(true); + }); +}); + describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { let handlers: ReturnType; @@ -1804,6 +1893,179 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleNewTopic).not.toHaveBeenCalled(); expect(handlers.handleThreadReply).not.toHaveBeenCalled(); }); + + // ── ambient tier — end-to-end gating across the three no-@ decision points ── + // mentionsAnotherMember is unit-tested above; these drive the FULL dispatch + // path to prove the redirect carve-out is wired into every gate that drops the + // @ requirement: the top-level gate, shared-topic seeding, and alias fold-back. + // Each gate gets a positive (ambient answers) + the carve-out (@ someone else + // → yields). @all is never a redirect, so it still answers. + + it('ambient: a non-@ top-level message from an allowed user is answered (like never)', async () => { + setupBotState({ allowedUsers: [USER_OPEN_ID], regularGroupMentionMode: 'ambient' }); + mockGetChatMode.mockResolvedValue('group'); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: 'no @ at all — ambient default responder answers' }), + messageId: 'msg-ambient-toplevel', + chatId: 'chat-ambient', + chatType: 'group', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-ambient', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('ambient: a top-level message that @mentions ANOTHER member (not this bot) is ignored — yields the turn', async () => { + setupBotState({ allowedUsers: [USER_OPEN_ID], regularGroupMentionMode: 'ambient' }); + mockGetChatMode.mockResolvedValue('group'); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@Someone 你来看看这个' }), + messageId: 'msg-ambient-redirect', + chatId: 'chat-ambient-redirect', + chatType: 'group', + mentions: [{ key: '@_other', name: 'Someone', id: { open_id: 'ou_someone_else' } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + // The redirect carve-out: @ing someone else hands the turn away → stay quiet. + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('ambient: a top-level @all message is still answered (@all is not a redirect to someone else)', async () => { + setupBotState({ allowedUsers: [USER_OPEN_ID], regularGroupMentionMode: 'ambient' }); + mockGetChatMode.mockResolvedValue('group'); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@all 大家注意' }), + messageId: 'msg-ambient-atall', + chatId: 'chat-ambient-atall', + chatType: 'group', + mentions: [{ key: '@_all', name: 'all', id: { open_id: 'all' } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-ambient-atall', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('shared + ambient: a non-@ top-level message OPENS a topic (seeds replyRootId), like never', async () => { + setupBotState({ allowedUsers: [USER_OPEN_ID], regularGroupReplyMode: 'shared', regularGroupMentionMode: 'ambient' }); + mockGetChatMode.mockResolvedValue('group'); + mockGetCachedChatMode.mockReturnValue('group'); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: 'no @ but should open a shared topic' }), + messageId: 'msg-shared-ambient-seed', + chatId: 'chat-shared-ambient', + chatType: 'group', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleNewTopic).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-shared-ambient', + replyRootId: 'msg-shared-ambient-seed', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('shared + ambient: a non-@ top-level message that @mentions another member does NOT seed a topic (yields)', async () => { + setupBotState({ allowedUsers: [USER_OPEN_ID], regularGroupReplyMode: 'shared', regularGroupMentionMode: 'ambient' }); + mockGetChatMode.mockResolvedValue('group'); + mockGetCachedChatMode.mockReturnValue('group'); + handlers.isSessionOwner.mockReturnValue(false); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@Someone 这个交给你' }), + messageId: 'msg-shared-ambient-redirect', + chatId: 'chat-shared-ambient-redirect', + chatType: 'group', + mentions: [{ key: '@_other', name: 'Someone', id: { open_id: 'ou_someone_else' } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + // Seeding gate backs off → no topic opened, and the top-level gate also yields. + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + }); + + it('ambient: a non-@ follow-up inside a shared-topic alias thread folds back into the chat session (like topic/never)', async () => { + setupBotState({ allowedUsers: [USER_OPEN_ID], regularGroupMentionMode: 'ambient' }); + mockGetChatMode.mockResolvedValue('group'); + handlers.resolveReplyThreadAlias.mockReturnValue({ chatId: 'chat-ambient-alias', sessionId: 'sess-chat' }); + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-ambient-alias'); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: 'follow up in alias topic, no @' }), + rootId: 'msg-ambient-alias-1', + messageId: 'msg-ambient-alias-2', + chatId: 'chat-ambient-alias', + chatType: 'group', + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + expect(handlers.handleThreadReply).toHaveBeenCalledWith(event, expect.objectContaining({ + scope: 'chat', + anchor: 'chat-ambient-alias', + replyRootId: 'msg-ambient-alias-1', + larkAppId: MY_APP_ID, + })); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + }); + + it('ambient: a follow-up inside a shared-topic alias thread that @mentions another member does NOT fold back (yields)', async () => { + setupBotState({ allowedUsers: [USER_OPEN_ID], regularGroupMentionMode: 'ambient' }); + mockGetChatMode.mockResolvedValue('group'); + handlers.resolveReplyThreadAlias.mockReturnValue({ chatId: 'chat-ambient-alias', sessionId: 'sess-chat' }); + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-ambient-alias'); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@Someone 你接着看' }), + rootId: 'msg-ambient-alias-1', + messageId: 'msg-ambient-alias-redirect', + chatId: 'chat-ambient-alias', + chatType: 'group', + mentions: [{ key: '@_other', name: 'Someone', id: { open_id: 'ou_someone_else' } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + // The fold-back is skipped (redirect) → the alias resolver is never consulted + // and the message is not pulled into the shared chat session. + expect(handlers.resolveReplyThreadAlias).not.toHaveBeenCalled(); + expect(handlers.handleThreadReply).not.toHaveBeenCalled(); + expect(handlers.handleNewTopic).not.toHaveBeenCalled(); + }); }); describe('im.message.receive_v1 — regular group thread replies preference', () => {