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', () => {