Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 才触发(默认,防噪声)
Expand Down Expand Up @@ -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'(默认)归一化为
Expand Down
4 changes: 2 additions & 2 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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') {
Expand Down
2 changes: 1 addition & 1 deletion src/dashboard/bot-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/dashboard/web/bot-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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'))}
</select>
</label>
<small class="bd-help">${t('botDefaults.mentionModeHelp')}</small>
Expand Down
6 changes: 4 additions & 2 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '所有新评论都触发',
Expand Down Expand Up @@ -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',
Expand Down
80 changes: 71 additions & 9 deletions src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Loading