From deb97d4d8ff4bf4cd60a1667043f4a68bf639658 Mon Sep 17 00:00:00 2001 From: flag Date: Wed, 3 Jun 2026 15:52:33 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=E5=A4=9A=E4=BA=BA=E8=AF=9D=E9=A2=98?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E6=89=80=E6=9C=89=E6=97=A0=20@mention=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史教训(话题 omt_197a3379c4cf1bb7):session 创建者在多人话题 里发图,bot 自作多情冲上去回。多人讨论里 bot 无法可靠判断"这条 是给我还是给别人"——所以干脆要求显式 @bot 才回。 - 抽出 thread-participants 模块,统一拉历史 + 判断"是否有非 session 创建者的人类参与者",失败时保守返回 true - 单 bot 模式:图片/文档/文字 三条 bypass 路径全部加多人话题 前置过滤 - 多 bot 模式:thread creator bypass 同样加多人话题过滤 - 单人话题保留原 Qwen 文字判断 + 图片/文档直放 - 新增 thread-participants 单测 + event-handler 多人话题场景测试 - 新增 docs/design/thread-bypass-policy.md 记录策略 Co-Authored-By: Claude Opus 4.7 --- docs/design/thread-bypass-policy.md | 89 ++++++++ src/feishu/__tests__/event-handler.test.ts | 192 +++++++++++++++++- .../__tests__/thread-participants.test.ts | 122 +++++++++++ src/feishu/event-handler.ts | 57 +++++- src/feishu/thread-participants.ts | 72 +++++++ 5 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 docs/design/thread-bypass-policy.md create mode 100644 src/feishu/__tests__/thread-participants.test.ts create mode 100644 src/feishu/thread-participants.ts diff --git a/docs/design/thread-bypass-policy.md b/docs/design/thread-bypass-policy.md new file mode 100644 index 00000000..ff479186 --- /dev/null +++ b/docs/design/thread-bypass-policy.md @@ -0,0 +1,89 @@ +--- +summary: "话题内消息是否触发 bot 响应的过滤策略:保守优先,多人话题强制 @mention" +related_paths: + - src/feishu/event-handler.ts + - src/feishu/thread-participants.ts + - src/utils/thread-relevance.ts +last_updated: "2026-06-03" +--- + +# Thread Bypass Policy + +群聊里 bot **默认**只对显式 `@bot` 的消息响应。但在飞书话题(thread)场景下,频繁要求用户每条都 @ 会很烦——所以加了若干"无 @ bypass"口子。 + +本文档描述:哪些消息可以走 bypass、哪些必须 @;以及"多人话题保守策略"的兜底逻辑。 + +## 触发响应的判断顺序 + +每条群聊消息按以下顺序判断(命中任何 return 就停止): + +``` +1. 私聊或显式 @ 任意已知 bot → 放行 +2. 群聊主聊天区(无 threadId) → 必须 @bot,否则 return +3. 话题内消息: + 3.1. 发送者不是 session 创建者 / owner → return + 3.2. 拉取话题最近 10 条历史,判断是否有"非 session 创建者的人类用户" + 3.3. 如果有 → 多人话题,return(必须 @bot 才回,不论消息类型) + 3.4. 如果没有 → 单人话题: + - 图片/文档 → 直接放行 + - 文字 → 走 Qwen 语义判断(checkThreadRelevance) +``` + +代码入口:`src/feishu/event-handler.ts` 的 `handleMessageEvent`: + +| 行号范围 | 模式 | 职责 | +|----------|------|------| +| 756-780 | 多 bot 模式 thread creator bypass | 话题创建者 bot 在话题里无需 @;多人话题里跳过 | +| 779-820 | 单 bot 模式 | 话题内 session 创建者 bypass;多人话题里跳过 | + +## 「多人话题」的定义 + +一个话题被视为**多人话题**,当且仅当: +- 拉取话题最近 10 条消息(不含当前消息),其中存在 senderType === `'user'` 且 senderId 不等于 session 创建者的消息。 +- 拉取失败时**保守默认为 true**(要求 @bot 才回)。 + +实现:`src/feishu/thread-participants.ts`: + +| 函数 | 职责 | +|------|------| +| `hasOtherHumanInMessages(messages, sessionUserId, currentMessageId)` | 纯函数,便于单测 | +| `threadHasOtherHumanParticipant(client, threadId, chatId, sessionUserId, currentMessageId, limit=10)` | 异步 wrapper,调用飞书 API 拉历史 | + +**bot 自己的历史消息不算第三方**(senderType === `'app'` 一律忽略)。 + +## 单 bot vs 多 bot 模式差异 + +| 维度 | 单 bot 模式 | 多 bot 模式 | +|------|-------------|-------------| +| 触发条件 | session 创建者发消息 | 当前 bot 是话题创建者 + 任何 bot 都没被 @ | +| 单人话题文字消息 | Qwen 语义判断 | Qwen 语义判断 | +| 单人话题图片/文档 | 直接放行 | 走 `shouldRespond`(实际要求 @bot) | +| 多人话题任何类型 | 不响应 | 不响应 | +| API 调用 | fetchRecentMessages 一次 | fetchRecentMessages 一次 | + +## 设计决策 + +| 决策 | 理由 | +|------|------| +| 多人话题强制 @bot | 历史教训(话题 `omt_197a3379c4cf1bb7`):session 创建者发图给第三人看,bot 自作多情冲上去回复。多人讨论里 bot 没法可靠判断"这条消息是给我还是给别人",干脆要求显式信号 | +| fetchRecentMessages 失败默认 true | 保守优先。API 抖动时宁可让用户多 @ 一次,也不要在多人话题里乱接 | +| 多人判定基于"曾经有过"而非"最近 N 条" | 简单且语义清晰;话题一旦多人化就一直保持保守 | +| 不在 thread_session 表加持久标志 | 实时拉历史已经足够轻量(一次 API 调用),加 schema 字段反而需要 migration + 数据回填 | +| 单人话题保留 Qwen 文字判断 | session 创建者自言自语(如"翻车了气死")不应触发 bot | +| `app` 类型消息不算第三方 | 同群其他 bot 的发言不应触发 multi-user 状态 | + +## 测试覆盖 + +- `src/feishu/__tests__/thread-participants.test.ts`:纯函数 + 异步 wrapper + 失败 fallback +- `src/feishu/__tests__/event-handler.test.ts`: + - `single-bot group image/doc @mention filtering` —— 单 bot 模式所有组合(图/文档/文字 × 多人/单人 × @/无 @) + - `multi-bot thread creator bypass — multi-user filtering` —— 多 bot 模式 bypass 行为 + +## 历史背景 + +最初话题内 bypass 只有"session 创建者 + Qwen 语义判断"。后来发现: + +1. 纯图片/文档消息没有文字可送进 Qwen → 退化成"直接放行" → 多人话题里被滥用 +2. Qwen 判断在多人讨论里准确率下降(无法区分"是问我还是问另一个人") + +修复策略从"修补单点" 升级到"多人话题里干掉所有 bypass"。Qwen 仍然在**单人话题**里有用——例如 session 创建者发一条自言自语的消息("翻车了气死"),Qwen 能判断这不是在跟 bot 说话。 diff --git a/src/feishu/__tests__/event-handler.test.ts b/src/feishu/__tests__/event-handler.test.ts index 61692b35..0d83aec8 100644 --- a/src/feishu/__tests__/event-handler.test.ts +++ b/src/feishu/__tests__/event-handler.test.ts @@ -904,15 +904,19 @@ describe('parseMessage empty @mention handling', () => { // 单 bot 模式群聊图片/文档消息 @mention 过滤回归测试 // // 回归 PR #220 的 BUG:群聊中纯图片/文档消息不应绕过 @mention 检查 +// 后续修复(多人话题图片插嘴):话题里若已有非 session 创建者参与过, +// 即使是 session 创建者发图,也要求显式 @ bot 才响应。 +// // 正确行为: // - 主聊天区:没 @bot 的图片消息 → 不响应(无论有没有图片) -// - 话题内 session 创建者:图片消息 → 放行(无需语义判断) +// - 话题内 session 创建者 + 无第三方参与:图片/文档消息 → 放行(无需语义判断) +// - 话题内 session 创建者 + 已有第三方参与:图片/文档消息 → 不响应(避免插嘴) // - 话题内非 session 创建者:图片消息 → 不响应 // ============================================================ describe('single-bot group image/doc @mention filtering (regression PR #220)', () => { /** - * 模拟 event-handler.ts 第 778-796 行的单 bot 群聊过滤逻辑。 + * 模拟 event-handler.ts 第 778-820 行的单 bot 群聊过滤逻辑。 * * @returns 'allow' 放行 | 'block' 拦截 | 'semantic_check' 需语义判断 */ @@ -924,8 +928,13 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( isSessionCreatorOrOwner: boolean; hasImages: boolean; hasDocuments: boolean; + /** 话题里是否已有非 session 创建者的人类用户参与过 */ + threadHasOtherParticipants?: boolean; }): 'allow' | 'block' | 'semantic_check' { - const { chatType, mentionedBot, threadId, hasThreadSession, isSessionCreatorOrOwner, hasImages, hasDocuments } = params; + const { + chatType, mentionedBot, threadId, hasThreadSession, isSessionCreatorOrOwner, + hasImages, hasDocuments, threadHasOtherParticipants = false, + } = params; // 非群聊 或 已 @bot → 直接放行 if (chatType !== 'group' || mentionedBot) return 'allow'; @@ -934,9 +943,13 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( const ts = threadId && hasThreadSession; if (!ts || !isSessionCreatorOrOwner) return 'block'; - // 话题内 session 创建者 - if (hasImages || hasDocuments) return 'allow'; // 图片/文档直接放行 - return 'semantic_check'; // 文本消息需语义判断 + // 多人话题保守策略:只要话题里有非 session 创建者的人类参与过, + // 任何未 @ bot 的消息(图/文档/文字)都不放行,避免插嘴。 + if (threadHasOtherParticipants) return 'block'; + + // 单人话题:图片/文档直接放行;文字走 Qwen 语义判断 + if (hasImages || hasDocuments) return 'allow'; + return 'semantic_check'; } // --- 主聊天区(无话题)--- @@ -965,21 +978,23 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( })).toBe('allow'); }); - // --- 话题内 session 创建者 --- + // --- 话题内 session 创建者(无第三方参与)--- - it('should ALLOW image in thread from session creator (skip semantic check)', () => { + it('should ALLOW image in solo thread from session creator (skip semantic check)', () => { expect(singleBotGroupFilter({ chatType: 'group', mentionedBot: false, threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, hasImages: true, hasDocuments: false, + threadHasOtherParticipants: false, })).toBe('allow'); }); - it('should ALLOW document in thread from session creator', () => { + it('should ALLOW document in solo thread from session creator', () => { expect(singleBotGroupFilter({ chatType: 'group', mentionedBot: false, threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, hasImages: false, hasDocuments: true, + threadHasOtherParticipants: false, })).toBe('allow'); }); @@ -991,6 +1006,58 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( })).toBe('semantic_check'); }); + // --- 话题内 session 创建者(有第三方参与,方案 1)--- + + it('should BLOCK image in multi-user thread from session creator (avoid butting in)', () => { + // 场景:话题里有另一个人参与过讨论,session 创建者发图给那个人看 + // 期望:bot 不响应,必须显式 @ bot + expect(singleBotGroupFilter({ + chatType: 'group', mentionedBot: false, + threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, + hasImages: true, hasDocuments: false, + threadHasOtherParticipants: true, + })).toBe('block'); + }); + + it('should BLOCK document in multi-user thread from session creator', () => { + expect(singleBotGroupFilter({ + chatType: 'group', mentionedBot: false, + threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, + hasImages: false, hasDocuments: true, + threadHasOtherParticipants: true, + })).toBe('block'); + }); + + it('should ALLOW image in multi-user thread from session creator WITH @mention', () => { + // 显式 @ bot 时,无论是否多人话题都应放行 + expect(singleBotGroupFilter({ + chatType: 'group', mentionedBot: true, + threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, + hasImages: true, hasDocuments: false, + threadHasOtherParticipants: true, + })).toBe('allow'); + }); + + it('should BLOCK text in multi-user thread from session creator (skip Qwen semantic check)', () => { + // 场景:话题里有第三人参与过,session 创建者发文字,即使语义上像在跟 bot 说话也不放行 + // 期望:跳过 Qwen 语义判断,直接 block,要求显式 @ bot + expect(singleBotGroupFilter({ + chatType: 'group', mentionedBot: false, + threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, + hasImages: false, hasDocuments: false, + threadHasOtherParticipants: true, + })).toBe('block'); + }); + + it('should ALLOW text in multi-user thread from session creator WITH @mention', () => { + expect(singleBotGroupFilter({ + chatType: 'group', mentionedBot: true, + threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, + hasImages: false, hasDocuments: false, + threadHasOtherParticipants: true, + })).toBe('allow'); + }); + // --- 话题内非 session 创建者 --- it('should BLOCK image in thread from non-session-creator', () => { @@ -1012,6 +1079,113 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( }); }); +// ============================================================ +// 多 bot 模式 thread creator bypass 多人话题过滤 +// +// 对应 event-handler.ts 756-780 行的多 bot 模式 bypass 逻辑: +// - 话题创建者 bot 在话题内无需 @ 即可响应 +// - 但若话题里已有非 session 创建者的人类参与过 → 跳过 bypass,要求显式 @ +// ============================================================ + +describe('multi-bot thread creator bypass — multi-user filtering', () => { + /** + * 模拟 event-handler.ts 多 bot 模式 thread creator bypass 流程。 + * + * @returns 'bypass' bypass 生效(响应)| 'qwen' 走语义判断 | 'skip' 不响应 + */ + function multiBotThreadBypass(params: { + isThreadCreatorAgent: boolean; + anyBotMentioned: boolean; + hasThreadSession: boolean; + isOwnerOrCreator: boolean; + threadHasOtherParticipants: boolean; + qwenJudgesRelevant: boolean; + }): 'bypass' | 'qwen' | 'skip' { + const { + isThreadCreatorAgent, anyBotMentioned, hasThreadSession, isOwnerOrCreator, + threadHasOtherParticipants, qwenJudgesRelevant, + } = params; + + // 没匹配 bypass 前提 → 不进入 bypass 分支 + if (anyBotMentioned || !isThreadCreatorAgent) return 'skip'; + if (!hasThreadSession || !isOwnerOrCreator) return 'skip'; + + // 多人话题:直接 skip,不走 Qwen + if (threadHasOtherParticipants) return 'skip'; + + // 单人话题:走 Qwen 语义判断 + if (qwenJudgesRelevant) return 'bypass'; + return 'qwen'; + } + + it('should BYPASS when solo thread + creator agent + session creator + Qwen relevant', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: false, + qwenJudgesRelevant: true, + })).toBe('bypass'); + }); + + it('should SKIP when multi-user thread (avoid butting in even if Qwen says relevant)', () => { + // 黎叔的场景:话题里出现过第三人,即使消息看起来在跟 bot 说话,也不响应 + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: true, + qwenJudgesRelevant: true, + })).toBe('skip'); + }); + + it('should SKIP when multi-user thread + Qwen would say irrelevant', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: true, + qwenJudgesRelevant: false, + })).toBe('skip'); + }); + + it('should fall through to qwen when solo thread + Qwen says irrelevant', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: false, + qwenJudgesRelevant: false, + })).toBe('qwen'); + }); + + it('should SKIP when not thread creator agent (regardless of participants)', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: false, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: false, + qwenJudgesRelevant: true, + })).toBe('skip'); + }); + + it('should SKIP when any bot was @mentioned (defer to normal shouldRespond)', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: true, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: false, + qwenJudgesRelevant: true, + })).toBe('skip'); + }); +}); + // ============================================================ // makeQueueKey + 并行执行策略测试 // diff --git a/src/feishu/__tests__/thread-participants.test.ts b/src/feishu/__tests__/thread-participants.test.ts new file mode 100644 index 00000000..a1a64576 --- /dev/null +++ b/src/feishu/__tests__/thread-participants.test.ts @@ -0,0 +1,122 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { + hasOtherHumanInMessages, + threadHasOtherHumanParticipant, + type ParticipantMessage, +} from '../thread-participants.js'; + +// ============================================================ +// hasOtherHumanInMessages — 纯函数过滤逻辑 +// ============================================================ +describe('hasOtherHumanInMessages', () => { + const SESSION_USER = 'ou_session_creator'; + const CURRENT_MSG = 'om_current'; + + it('returns false for empty list', () => { + expect(hasOtherHumanInMessages([], SESSION_USER, CURRENT_MSG)).toBe(false); + }); + + it('returns false when only session creator messages exist', () => { + const msgs: ParticipantMessage[] = [ + { messageId: 'om_1', senderId: SESSION_USER, senderType: 'user' }, + { messageId: 'om_2', senderId: SESSION_USER, senderType: 'user' }, + ]; + expect(hasOtherHumanInMessages(msgs, SESSION_USER, CURRENT_MSG)).toBe(false); + }); + + it('returns false when only bot (app) messages besides session creator', () => { + // bot 自己历史发的消息不算第三方 + const msgs: ParticipantMessage[] = [ + { messageId: 'om_1', senderId: SESSION_USER, senderType: 'user' }, + { messageId: 'om_2', senderId: 'ou_bot_a', senderType: 'app' }, + { messageId: 'om_3', senderId: 'ou_bot_b', senderType: 'app' }, + ]; + expect(hasOtherHumanInMessages(msgs, SESSION_USER, CURRENT_MSG)).toBe(false); + }); + + it('returns true when another human user has spoken in thread', () => { + // 黎叔的场景:话题里出现过 ou_other 这个人类用户 + const msgs: ParticipantMessage[] = [ + { messageId: 'om_1', senderId: SESSION_USER, senderType: 'user' }, + { messageId: 'om_2', senderId: 'ou_bot', senderType: 'app' }, + { messageId: 'om_3', senderId: 'ou_other', senderType: 'user' }, + { messageId: 'om_4', senderId: SESSION_USER, senderType: 'user' }, + ]; + expect(hasOtherHumanInMessages(msgs, SESSION_USER, CURRENT_MSG)).toBe(true); + }); + + it('excludes the current message itself from the check', () => { + // 当前消息混入历史时不应误判(即使它的 senderId 是 session creator 也别让它影响判断) + const msgs: ParticipantMessage[] = [ + { messageId: CURRENT_MSG, senderId: 'ou_unrelated', senderType: 'user' }, + { messageId: 'om_2', senderId: SESSION_USER, senderType: 'user' }, + ]; + expect(hasOtherHumanInMessages(msgs, SESSION_USER, CURRENT_MSG)).toBe(false); + }); + + it('ignores messages with empty senderId', () => { + const msgs: ParticipantMessage[] = [ + { messageId: 'om_1', senderId: '', senderType: 'user' }, + { messageId: 'om_2', senderId: SESSION_USER, senderType: 'user' }, + ]; + expect(hasOtherHumanInMessages(msgs, SESSION_USER, CURRENT_MSG)).toBe(false); + }); +}); + +// ============================================================ +// threadHasOtherHumanParticipant — 集成 feishuClient 的包装 +// ============================================================ +describe('threadHasOtherHumanParticipant', () => { + const SESSION_USER = 'ou_session_creator'; + const CURRENT_MSG = 'om_current'; + + it('returns false when fetch returns only session creator', async () => { + const client = { + fetchRecentMessages: vi.fn().mockResolvedValue([ + { messageId: 'om_1', senderId: SESSION_USER, senderType: 'user' as const }, + ]), + }; + await expect( + threadHasOtherHumanParticipant(client, 'thread-1', 'chat-1', SESSION_USER, CURRENT_MSG), + ).resolves.toBe(false); + expect(client.fetchRecentMessages).toHaveBeenCalledWith('thread-1', 'thread', 10, 'chat-1'); + }); + + it('returns true when another human present in history', async () => { + const client = { + fetchRecentMessages: vi.fn().mockResolvedValue([ + { messageId: 'om_1', senderId: 'ou_other', senderType: 'user' as const }, + { messageId: 'om_2', senderId: SESSION_USER, senderType: 'user' as const }, + ]), + }; + await expect( + threadHasOtherHumanParticipant(client, 'thread-1', 'chat-1', SESSION_USER, CURRENT_MSG), + ).resolves.toBe(true); + }); + + it('respects custom limit', async () => { + const client = { + fetchRecentMessages: vi.fn().mockResolvedValue([]), + }; + await threadHasOtherHumanParticipant( + client, 'thread-1', 'chat-1', SESSION_USER, CURRENT_MSG, 25, + ); + expect(client.fetchRecentMessages).toHaveBeenCalledWith('thread-1', 'thread', 25, 'chat-1'); + }); + + it('returns true on fetch failure (conservative — require @mention)', async () => { + const client = { + fetchRecentMessages: vi.fn().mockRejectedValue(new Error('network broken')), + }; + // 失败时保守默认有第三方,让用户必须 @ bot 才响应 + await expect( + threadHasOtherHumanParticipant(client, 'thread-1', 'chat-1', SESSION_USER, CURRENT_MSG), + ).resolves.toBe(true); + }); +}); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 49579f02..d1425b78 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -39,6 +39,7 @@ import { getRepoIdentity } from '../workspace/identity.js'; import { parseRepoNameFromWorkspaceDir } from '../workspace/manager.js'; import { generateQuickAck } from '../utils/quick-ack.js'; import { checkThreadRelevance } from '../utils/thread-relevance.js'; +import { threadHasOtherHumanParticipant } from './thread-participants.js'; import { compressImage, compressImageForHistory } from '../utils/image-compress.js'; // 注册审批通过后的消息重新入队回调(避免 approval.ts → event-handler.ts 循环依赖) @@ -757,14 +758,26 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd if (threadId && !anyBotMentioned && isThreadCreatorAgent(threadId, agentId)) { const ts = sessionManager.getThreadSession(threadId, agentId); if (ts && (isOwner(userId) || ts.userId === userId)) { - // 语义判断:用 Qwen 小模型判断无 @mention 的消息是否在跟 bot 对话 - const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const relevant = await checkThreadRelevance(text, botDisplayName); - if (relevant) { - threadBypass = true; - logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention'); + // 多人话题保守策略:先看话题里是否有非 session 创建者的人类参与过; + // 有的话直接关掉 bypass,要求显式 @ bot 才回,避免插嘴。 + const otherHumanInThread = await threadHasOtherHumanParticipant( + feishuClient, threadId, chatId, ts.userId, messageId, + ); + if (otherHumanInThread) { + logger.info( + { threadId, agentId, sessionUserId: ts.userId, text: text?.slice(0, 100) }, + 'Thread creator bypass skipped — multi-user thread without @mention', + ); } else { - logger.info({ threadId, agentId, text: text.slice(0, 100) }, 'Thread bypass skipped — message not directed at bot'); + // 单人话题:用 Qwen 小模型判断无 @mention 的消息是否在跟 bot 对话 + const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; + const relevant = await checkThreadRelevance(text, botDisplayName); + if (relevant) { + threadBypass = true; + logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention'); + } else { + logger.info({ threadId, agentId, text: text.slice(0, 100) }, 'Thread bypass skipped — message not directed at bot'); + } } } } @@ -780,11 +793,35 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd if (!ts || (!isOwner(userId) && ts.userId !== userId)) { return; } - // 纯图片/文档消息没有文本可做语义判断,话题内 session 创建者直接放行 + // 多人话题保守策略:若话题里已经有非 session 创建者的人类参与过讨论, + // 任何未显式 @ bot 的消息(图片/文档/文字)都不响应——避免在多人讨论里自作多情插嘴。 + // 拉历史失败时 threadHasOtherHumanParticipant 默认返回 true(保守)。 + const otherHumanInThread = threadId + ? await threadHasOtherHumanParticipant(feishuClient, threadId, chatId, ts.userId, messageId) + : false; + if (otherHumanInThread) { + logger.info( + { + messageId, + threadId, + sessionUserId: ts.userId, + hasImages: !!images?.length, + hasDocuments: !!documents?.length, + text: text?.slice(0, 100), + }, + 'Single-bot thread bypass skipped — multi-user thread without @mention', + ); + return; + } + if (images?.length || documents?.length) { - logger.debug({ messageId, threadId }, 'Message allowed in group thread: image/doc from session creator'); + // 单人话题里的图片/文档:直接放行 + logger.debug( + { messageId, threadId }, + 'Message allowed in group thread: image/doc from session creator (no other human participants)', + ); } else { - // 语义判断:与多 bot 模式对齐,用 Qwen 小模型判断消息是否在跟 bot 对话 + // 单人话题里的文字:仍走 Qwen 语义判断,避免 session 创建者自言自语也被回复 const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; const relevant = await checkThreadRelevance(text, botDisplayName); if (!relevant) { diff --git a/src/feishu/thread-participants.ts b/src/feishu/thread-participants.ts new file mode 100644 index 00000000..b57178b4 --- /dev/null +++ b/src/feishu/thread-participants.ts @@ -0,0 +1,72 @@ +// ============================================================ +// Thread Participants — 判断飞书话题里是否有非会话创建者的人类参与 +// +// 用于单 bot 模式下的"图片/文档防插嘴"过滤:session 创建者在话题里发图, +// 如果近期已有其他人类用户加入讨论,那张图很可能是发给其他人看的, +// bot 不应自作多情响应,要求显式 @ bot 才接。 +// ============================================================ + +import { logger } from '../utils/logger.js'; +import type { feishuClient as FeishuClient } from './client.js'; + +/** 历史消息中用于判断的最小字段集(与 feishuClient.fetchRecentMessages 返回结构兼容) */ +export interface ParticipantMessage { + messageId: string; + senderId: string; + senderType: 'user' | 'app'; +} + +/** + * 纯函数:判断给定的历史消息列表里是否有"非 session 创建者的人类用户"。 + * + * - 排除掉 currentMessageId 本身(避免当前消息混入历史导致误判) + * - 只看 senderType === 'user' 的消息(bot 发的消息不算第三方) + * - 空 senderId 也跳过 + * + * 抽成纯函数主要为了好单测,不需要拉真实历史。 + */ +export function hasOtherHumanInMessages( + messages: ReadonlyArray, + sessionUserId: string, + currentMessageId: string, +): boolean { + return messages.some(m => + m.messageId !== currentMessageId && + m.senderType === 'user' && + !!m.senderId && + m.senderId !== sessionUserId + ); +} + +/** + * 拉取话题最近消息并判断是否存在非 session 创建者的人类参与者。 + * + * @param client feishuClient 实例(依赖注入便于测试) + * @param threadId 飞书话题 id + * @param chatId 所属群 chat_id(fetchRecentMessages 在 thread 模式下用于 bot 被动收集) + * @param sessionUserId 当前 session 创建者的 open_id + * @param currentMessageId 当前正在处理的消息 id(从历史里排除掉) + * @param limit 拉取条数,默认 10 + * @returns true = 话题里有第三方人类;false = 没有 / 拉取为空 + * + * 注意:拉取失败时保守返回 true,等用户显式 @ bot 再响应,避免误插嘴。 + */ +export async function threadHasOtherHumanParticipant( + client: Pick, + threadId: string, + chatId: string, + sessionUserId: string, + currentMessageId: string, + limit: number = 10, +): Promise { + try { + const recent = await client.fetchRecentMessages(threadId, 'thread', limit, chatId); + return hasOtherHumanInMessages(recent, sessionUserId, currentMessageId); + } catch (err) { + logger.warn( + { err, threadId, chatId }, + 'threadHasOtherHumanParticipant: fetch failed, defaulting to true (require @mention)', + ); + return true; + } +} From eaa87b88197f018612ef6abe9b80869401288cf4 Mon Sep 17 00:00:00 2001 From: flag Date: Wed, 3 Jun 2026 16:02:46 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E5=8D=95=E4=BA=BA=E8=AF=9D=E9=A2=98?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20Qwen=20=E4=BA=8C=E6=AC=A1=E5=88=A4?= =?UTF-8?q?=E6=96=AD=EF=BC=8Csession=20=E5=88=9B=E5=BB=BA=E8=80=85?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=94=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 被黎叔在话题 omt_197a5d56cc92dbe9 抓现行:发「不用新分支,但是 文档需要更新」没响应。日志显示 Qwen 判 {respond: false} —— 因 为 Qwen prompt 里只塞了消息文本本身,没上下文,看到一句像"安排 开发干活"的话就误判成"不是跟 bot 说话"。 反思:话题本就是 user vs bot 的对话载体,单人话题里 session 创 建者发的消息基本就是冲 bot 来的。多人话题已经在前一版强制 @ 了,单人话题再用 Qwen 二次判断纯属画蛇添足——既增加延迟,又 引入误判。直接放行。 - 单 bot 模式:单人话题图片/文档/文字 全部直接 allow,砍掉 checkThreadRelevance 调用 - 多 bot 模式:thread creator bypass 在单人话题直接 bypass=true - 移除 thread-relevance import;event-handler.test 移除 Qwen mock,单测用例从 'semantic_check' 收敛到 'allow' / 'block' - 更新 design doc:单人/多人差异表 + 历史演进 v4 注明取消 Qwen 的原因 Co-Authored-By: Claude Opus 4.7 --- docs/design/thread-bypass-policy.md | 36 +++++------ src/feishu/__tests__/event-handler.test.ts | 73 ++++++++++------------ src/feishu/event-handler.ts | 35 +++-------- 3 files changed, 56 insertions(+), 88 deletions(-) diff --git a/docs/design/thread-bypass-policy.md b/docs/design/thread-bypass-policy.md index ff479186..a3697908 100644 --- a/docs/design/thread-bypass-policy.md +++ b/docs/design/thread-bypass-policy.md @@ -1,15 +1,14 @@ --- -summary: "话题内消息是否触发 bot 响应的过滤策略:保守优先,多人话题强制 @mention" +summary: "话题内消息是否触发 bot 响应的过滤策略:单人放行 / 多人强制 @mention" related_paths: - src/feishu/event-handler.ts - src/feishu/thread-participants.ts - - src/utils/thread-relevance.ts last_updated: "2026-06-03" --- # Thread Bypass Policy -群聊里 bot **默认**只对显式 `@bot` 的消息响应。但在飞书话题(thread)场景下,频繁要求用户每条都 @ 会很烦——所以加了若干"无 @ bypass"口子。 +群聊里 bot **默认**只对显式 `@bot` 的消息响应。但在飞书话题(thread)场景下,频繁要求用户每条都 @ 会很烦——所以加了"无 @ bypass"口子。 本文档描述:哪些消息可以走 bypass、哪些必须 @;以及"多人话题保守策略"的兜底逻辑。 @@ -24,17 +23,15 @@ last_updated: "2026-06-03" 3.1. 发送者不是 session 创建者 / owner → return 3.2. 拉取话题最近 10 条历史,判断是否有"非 session 创建者的人类用户" 3.3. 如果有 → 多人话题,return(必须 @bot 才回,不论消息类型) - 3.4. 如果没有 → 单人话题: - - 图片/文档 → 直接放行 - - 文字 → 走 Qwen 语义判断(checkThreadRelevance) + 3.4. 如果没有 → 单人话题:图片/文档/文字 全部直接放行 ``` -代码入口:`src/feishu/event-handler.ts` 的 `handleMessageEvent`: +代码入口:`src/feishu/event-handler.ts` 的 `handleMessageEvent`。 -| 行号范围 | 模式 | 职责 | -|----------|------|------| -| 756-780 | 多 bot 模式 thread creator bypass | 话题创建者 bot 在话题里无需 @;多人话题里跳过 | -| 779-820 | 单 bot 模式 | 话题内 session 创建者 bypass;多人话题里跳过 | +| 模式 | 职责 | +|------|------| +| 多 bot 模式 thread creator bypass | 话题创建者 bot 在单人话题里无需 @;多人话题里跳过 | +| 单 bot 模式 | 话题内 session 创建者在单人话题里无需 @;多人话题里跳过 | ## 「多人话题」的定义 @@ -56,8 +53,7 @@ last_updated: "2026-06-03" | 维度 | 单 bot 模式 | 多 bot 模式 | |------|-------------|-------------| | 触发条件 | session 创建者发消息 | 当前 bot 是话题创建者 + 任何 bot 都没被 @ | -| 单人话题文字消息 | Qwen 语义判断 | Qwen 语义判断 | -| 单人话题图片/文档 | 直接放行 | 走 `shouldRespond`(实际要求 @bot) | +| 单人话题任何类型 | 直接放行 | 直接放行 | | 多人话题任何类型 | 不响应 | 不响应 | | API 调用 | fetchRecentMessages 一次 | fetchRecentMessages 一次 | @@ -66,10 +62,10 @@ last_updated: "2026-06-03" | 决策 | 理由 | |------|------| | 多人话题强制 @bot | 历史教训(话题 `omt_197a3379c4cf1bb7`):session 创建者发图给第三人看,bot 自作多情冲上去回复。多人讨论里 bot 没法可靠判断"这条消息是给我还是给别人",干脆要求显式信号 | +| 单人话题直接放行(不再用 Qwen 二次判断) | 话题本身就是 user vs bot 的对话载体,单人话题里 session 创建者发的消息基本就是冲 bot 来的。早期"自言自语保留 Qwen 文字判断"的设计反而引入误判(话题 `omt_197a5d56cc92dbe9`:「不用新分支,但是文档需要更新」被 Qwen 判为"不是跟 bot 说话",因为 Qwen 拿不到上下文) | | fetchRecentMessages 失败默认 true | 保守优先。API 抖动时宁可让用户多 @ 一次,也不要在多人话题里乱接 | | 多人判定基于"曾经有过"而非"最近 N 条" | 简单且语义清晰;话题一旦多人化就一直保持保守 | | 不在 thread_session 表加持久标志 | 实时拉历史已经足够轻量(一次 API 调用),加 schema 字段反而需要 migration + 数据回填 | -| 单人话题保留 Qwen 文字判断 | session 创建者自言自语(如"翻车了气死")不应触发 bot | | `app` 类型消息不算第三方 | 同群其他 bot 的发言不应触发 multi-user 状态 | ## 测试覆盖 @@ -79,11 +75,9 @@ last_updated: "2026-06-03" - `single-bot group image/doc @mention filtering` —— 单 bot 模式所有组合(图/文档/文字 × 多人/单人 × @/无 @) - `multi-bot thread creator bypass — multi-user filtering` —— 多 bot 模式 bypass 行为 -## 历史背景 - -最初话题内 bypass 只有"session 创建者 + Qwen 语义判断"。后来发现: - -1. 纯图片/文档消息没有文字可送进 Qwen → 退化成"直接放行" → 多人话题里被滥用 -2. Qwen 判断在多人讨论里准确率下降(无法区分"是问我还是问另一个人") +## 历史演进 -修复策略从"修补单点" 升级到"多人话题里干掉所有 bypass"。Qwen 仍然在**单人话题**里有用——例如 session 创建者发一条自言自语的消息("翻车了气死"),Qwen 能判断这不是在跟 bot 说话。 +1. **v1**:话题内 bypass 只有"session 创建者 + Qwen 语义判断(文字)"。 +2. **v2**:发现纯图片/文档消息没有文字可送进 Qwen → 退化成"直接放行" → 多人话题里被滥用。 +3. **v3**:引入"多人话题保守策略" —— 多人话题里干掉所有 bypass。Qwen 仍然保留在单人话题文字消息上。 +4. **v4(当前)**:发现 Qwen 在单人话题里也是累赘 —— 它拿不到对话上下文,把延续上文的简短反馈(如「不用新分支,但是文档需要更新」)误判成"不是跟 bot 说话"。话题本就是 user vs bot 的对话载体,单人话题里没必要二次判断,直接放行。 diff --git a/src/feishu/__tests__/event-handler.test.ts b/src/feishu/__tests__/event-handler.test.ts index 0d83aec8..3aaddc6f 100644 --- a/src/feishu/__tests__/event-handler.test.ts +++ b/src/feishu/__tests__/event-handler.test.ts @@ -168,7 +168,6 @@ vi.mock('../../memory/commands.js', () => ({ })); vi.mock('../../workspace/identity.js', () => ({ getRepoIdentity: vi.fn((p: string) => p) })); vi.mock('../../utils/quick-ack.js', () => ({ generateQuickAck: vi.fn() })); -vi.mock('../../utils/thread-relevance.js', () => ({ checkThreadRelevance: vi.fn() })); vi.mock('../../workspace/manager.js', () => ({ setupWorkspace: vi.fn(), @@ -918,7 +917,7 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( /** * 模拟 event-handler.ts 第 778-820 行的单 bot 群聊过滤逻辑。 * - * @returns 'allow' 放行 | 'block' 拦截 | 'semantic_check' 需语义判断 + * @returns 'allow' 放行 | 'block' 拦截 */ function singleBotGroupFilter(params: { chatType: string; @@ -930,10 +929,10 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( hasDocuments: boolean; /** 话题里是否已有非 session 创建者的人类用户参与过 */ threadHasOtherParticipants?: boolean; - }): 'allow' | 'block' | 'semantic_check' { + }): 'allow' | 'block' { const { chatType, mentionedBot, threadId, hasThreadSession, isSessionCreatorOrOwner, - hasImages, hasDocuments, threadHasOtherParticipants = false, + threadHasOtherParticipants = false, } = params; // 非群聊 或 已 @bot → 直接放行 @@ -947,9 +946,8 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( // 任何未 @ bot 的消息(图/文档/文字)都不放行,避免插嘴。 if (threadHasOtherParticipants) return 'block'; - // 单人话题:图片/文档直接放行;文字走 Qwen 语义判断 - if (hasImages || hasDocuments) return 'allow'; - return 'semantic_check'; + // 单人话题:session 创建者发什么都直接放行(话题就是 user vs bot 的对话载体) + return 'allow'; } // --- 主聊天区(无话题)--- @@ -980,7 +978,7 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( // --- 话题内 session 创建者(无第三方参与)--- - it('should ALLOW image in solo thread from session creator (skip semantic check)', () => { + it('should ALLOW image in solo thread from session creator', () => { expect(singleBotGroupFilter({ chatType: 'group', mentionedBot: false, threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, @@ -998,12 +996,13 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( })).toBe('allow'); }); - it('should require SEMANTIC_CHECK for text in thread from session creator', () => { + it('should ALLOW text in solo thread from session creator (no semantic check needed)', () => { + // 单人话题里 session 创建者发的消息基本就是冲 bot 来的,没必要再用 Qwen 二次判断 expect(singleBotGroupFilter({ chatType: 'group', mentionedBot: false, threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, hasImages: false, hasDocuments: false, - })).toBe('semantic_check'); + })).toBe('allow'); }); // --- 话题内 session 创建者(有第三方参与,方案 1)--- @@ -1038,9 +1037,9 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( })).toBe('allow'); }); - it('should BLOCK text in multi-user thread from session creator (skip Qwen semantic check)', () => { - // 场景:话题里有第三人参与过,session 创建者发文字,即使语义上像在跟 bot 说话也不放行 - // 期望:跳过 Qwen 语义判断,直接 block,要求显式 @ bot + it('should BLOCK text in multi-user thread from session creator', () => { + // 场景:话题里有第三人参与过,session 创建者发文字也不放行(避免插嘴) + // 期望:直接 block,要求显式 @ bot expect(singleBotGroupFilter({ chatType: 'group', mentionedBot: false, threadId: 'thread-1', hasThreadSession: true, isSessionCreatorOrOwner: true, @@ -1082,16 +1081,16 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( // ============================================================ // 多 bot 模式 thread creator bypass 多人话题过滤 // -// 对应 event-handler.ts 756-780 行的多 bot 模式 bypass 逻辑: -// - 话题创建者 bot 在话题内无需 @ 即可响应 -// - 但若话题里已有非 session 创建者的人类参与过 → 跳过 bypass,要求显式 @ +// 对应 event-handler.ts 多 bot 模式 bypass 逻辑: +// - 话题创建者 bot 在单人话题内无需 @ 即可响应 +// - 多人话题里跳过 bypass,要求显式 @ // ============================================================ describe('multi-bot thread creator bypass — multi-user filtering', () => { /** * 模拟 event-handler.ts 多 bot 模式 thread creator bypass 流程。 * - * @returns 'bypass' bypass 生效(响应)| 'qwen' 走语义判断 | 'skip' 不响应 + * @returns 'bypass' bypass 生效(响应)| 'skip' 不响应 */ function multiBotThreadBypass(params: { isThreadCreatorAgent: boolean; @@ -1099,37 +1098,34 @@ describe('multi-bot thread creator bypass — multi-user filtering', () => { hasThreadSession: boolean; isOwnerOrCreator: boolean; threadHasOtherParticipants: boolean; - qwenJudgesRelevant: boolean; - }): 'bypass' | 'qwen' | 'skip' { + }): 'bypass' | 'skip' { const { isThreadCreatorAgent, anyBotMentioned, hasThreadSession, isOwnerOrCreator, - threadHasOtherParticipants, qwenJudgesRelevant, + threadHasOtherParticipants, } = params; // 没匹配 bypass 前提 → 不进入 bypass 分支 if (anyBotMentioned || !isThreadCreatorAgent) return 'skip'; if (!hasThreadSession || !isOwnerOrCreator) return 'skip'; - // 多人话题:直接 skip,不走 Qwen + // 多人话题:直接 skip,不响应 if (threadHasOtherParticipants) return 'skip'; - // 单人话题:走 Qwen 语义判断 - if (qwenJudgesRelevant) return 'bypass'; - return 'qwen'; + // 单人话题:直接 bypass + return 'bypass'; } - it('should BYPASS when solo thread + creator agent + session creator + Qwen relevant', () => { + it('should BYPASS when solo thread + creator agent + session creator', () => { expect(multiBotThreadBypass({ isThreadCreatorAgent: true, anyBotMentioned: false, hasThreadSession: true, isOwnerOrCreator: true, threadHasOtherParticipants: false, - qwenJudgesRelevant: true, })).toBe('bypass'); }); - it('should SKIP when multi-user thread (avoid butting in even if Qwen says relevant)', () => { + it('should SKIP when multi-user thread (avoid butting in)', () => { // 黎叔的场景:话题里出现过第三人,即使消息看起来在跟 bot 说话,也不响应 expect(multiBotThreadBypass({ isThreadCreatorAgent: true, @@ -1137,40 +1133,36 @@ describe('multi-bot thread creator bypass — multi-user filtering', () => { hasThreadSession: true, isOwnerOrCreator: true, threadHasOtherParticipants: true, - qwenJudgesRelevant: true, })).toBe('skip'); }); - it('should SKIP when multi-user thread + Qwen would say irrelevant', () => { + it('should SKIP when not thread creator agent', () => { expect(multiBotThreadBypass({ - isThreadCreatorAgent: true, + isThreadCreatorAgent: false, anyBotMentioned: false, hasThreadSession: true, isOwnerOrCreator: true, - threadHasOtherParticipants: true, - qwenJudgesRelevant: false, + threadHasOtherParticipants: false, })).toBe('skip'); }); - it('should fall through to qwen when solo thread + Qwen says irrelevant', () => { + it('should SKIP when no thread session', () => { expect(multiBotThreadBypass({ isThreadCreatorAgent: true, anyBotMentioned: false, - hasThreadSession: true, + hasThreadSession: false, isOwnerOrCreator: true, threadHasOtherParticipants: false, - qwenJudgesRelevant: false, - })).toBe('qwen'); + })).toBe('skip'); }); - it('should SKIP when not thread creator agent (regardless of participants)', () => { + it('should SKIP when sender is not session creator nor owner', () => { expect(multiBotThreadBypass({ - isThreadCreatorAgent: false, + isThreadCreatorAgent: true, anyBotMentioned: false, hasThreadSession: true, - isOwnerOrCreator: true, + isOwnerOrCreator: false, threadHasOtherParticipants: false, - qwenJudgesRelevant: true, })).toBe('skip'); }); @@ -1181,7 +1173,6 @@ describe('multi-bot thread creator bypass — multi-user filtering', () => { hasThreadSession: true, isOwnerOrCreator: true, threadHasOtherParticipants: false, - qwenJudgesRelevant: true, })).toBe('skip'); }); }); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index d1425b78..cdd60e21 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -38,7 +38,6 @@ import { handleMemoryCommand, handleMemoryCardAction } from '../memory/commands. import { getRepoIdentity } from '../workspace/identity.js'; import { parseRepoNameFromWorkspaceDir } from '../workspace/manager.js'; import { generateQuickAck } from '../utils/quick-ack.js'; -import { checkThreadRelevance } from '../utils/thread-relevance.js'; import { threadHasOtherHumanParticipant } from './thread-participants.js'; import { compressImage, compressImageForHistory } from '../utils/image-compress.js'; @@ -769,15 +768,9 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd 'Thread creator bypass skipped — multi-user thread without @mention', ); } else { - // 单人话题:用 Qwen 小模型判断无 @mention 的消息是否在跟 bot 对话 - const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const relevant = await checkThreadRelevance(text, botDisplayName); - if (relevant) { - threadBypass = true; - logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention'); - } else { - logger.info({ threadId, agentId, text: text.slice(0, 100) }, 'Thread bypass skipped — message not directed at bot'); - } + // 单人话题:session 创建者发消息基本就是冲 bot 来的(话题本身就是对话载体),直接 bypass + threadBypass = true; + logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: solo thread, responding without @mention'); } } } @@ -814,22 +807,12 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd return; } - if (images?.length || documents?.length) { - // 单人话题里的图片/文档:直接放行 - logger.debug( - { messageId, threadId }, - 'Message allowed in group thread: image/doc from session creator (no other human participants)', - ); - } else { - // 单人话题里的文字:仍走 Qwen 语义判断,避免 session 创建者自言自语也被回复 - const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const relevant = await checkThreadRelevance(text, botDisplayName); - if (!relevant) { - logger.info({ messageId, threadId, text: text?.slice(0, 100) }, 'Single-bot thread bypass skipped — message not directed at bot'); - return; - } - logger.debug({ messageId, threadId }, 'Message allowed in group thread: sender is session creator + semantically relevant'); - } + // 单人话题里的图片/文档/文字:直接放行 + // session 创建者在专属话题里发的消息基本就是冲 bot 来的,没必要再用 Qwen 二次判断 + logger.debug( + { messageId, threadId }, + 'Message allowed in group thread: session creator in solo thread', + ); } } From 82594e3779520fcb8c1dddd568266884526c1ff2 Mon Sep 17 00:00:00 2001 From: flag Date: Wed, 3 Jun 2026 16:08:27 +0800 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=E6=98=BE=E5=BC=8F=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=8D=95=E4=BA=BA=E8=AF=9D=E9=A2=98=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E7=AC=AC=E4=B8=89=E4=BA=BA=E7=9A=84=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 黎叔提醒的边界:单人话题虽然总放行,但一旦动态加入第三人, 就要立即切换到多人保守模式。 实现层面早就是对的——threadHasOtherHumanParticipant 每次都 现拉 fetchRecentMessages,没缓存。但语义没有被显式断言, 所以补一个 stateful mock 测试 + 在 design doc 里加"动态切换" 小节固化下来。 - thread-participants.test.ts 新增 transitions from solo to multi-user as third party joins - design doc「多人话题的定义」下加「动态切换」小节,说明 每条消息都实时拉 + 第三人插话的处理流程 Co-Authored-By: Claude Opus 4.7 --- docs/design/thread-bypass-policy.md | 10 ++++++ .../__tests__/thread-participants.test.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/design/thread-bypass-policy.md b/docs/design/thread-bypass-policy.md index a3697908..05a4885c 100644 --- a/docs/design/thread-bypass-policy.md +++ b/docs/design/thread-bypass-policy.md @@ -48,6 +48,16 @@ last_updated: "2026-06-03" **bot 自己的历史消息不算第三方**(senderType === `'app'` 一律忽略)。 +### 动态切换:单人 → 多人 + +判定不缓存。每条消息进入 `handleMessageEvent` 时都会**实时**拉一次 `fetchRecentMessages`,所以: + +- 单人话题里 session 创建者发消息 → bypass 放行 +- 第三人插话(这条消息因不是 session 创建者,被 isSessionCreatorOrOwner 检查挡掉,bot 不响应,但消息进入飞书话题历史) +- session 创建者**下一条**消息处理时,拉历史能看到第三人 → 切换到多人模式 → 要求 @bot + +代价是每条消息一次 API 调用,但 fetchRecentMessages 本身已在多处复用,开销可接受。回归测试覆盖:`thread-participants.test.ts > transitions from solo to multi-user as third party joins`。 + ## 单 bot vs 多 bot 模式差异 | 维度 | 单 bot 模式 | 多 bot 模式 | diff --git a/src/feishu/__tests__/thread-participants.test.ts b/src/feishu/__tests__/thread-participants.test.ts index a1a64576..3f84a490 100644 --- a/src/feishu/__tests__/thread-participants.test.ts +++ b/src/feishu/__tests__/thread-participants.test.ts @@ -119,4 +119,37 @@ describe('threadHasOtherHumanParticipant', () => { threadHasOtherHumanParticipant(client, 'thread-1', 'chat-1', SESSION_USER, CURRENT_MSG), ).resolves.toBe(true); }); + + // ============================================================ + // 动态切换:单人话题 → 加入第三人 → 后续消息变成多人话题 + // + // 重要边界:不能缓存"这个话题是单人/多人"的判定,必须每条消息都 + // 实时拉历史。否则第三人加入后,session 创建者再发的消息仍会被 + // 当作单人话题放行,导致插嘴。 + // ============================================================ + it('transitions from solo to multi-user as third party joins', async () => { + // 模拟话题历史随时间累积:先只有 session creator,然后第三人加入 + const history: Array<{ messageId: string; senderId: string; senderType: 'user' | 'app' }> = [ + { messageId: 'om_1', senderId: SESSION_USER, senderType: 'user' }, + ]; + const client = { + fetchRecentMessages: vi.fn().mockImplementation(async () => [...history]), + }; + + // T1: 单人话题,session creator 发消息 → 判定为单人,可 bypass + await expect( + threadHasOtherHumanParticipant(client, 'thread-1', 'chat-1', SESSION_USER, 'om_t1'), + ).resolves.toBe(false); + + // T2: 第三人插话(这条消息本身不会触发 bot,但会进入飞书话题历史) + history.push({ messageId: 'om_2', senderId: 'ou_third_party', senderType: 'user' }); + + // T3: session creator 再发消息 → 实时拉历史能看到第三人 → 切换到保守模式 + await expect( + threadHasOtherHumanParticipant(client, 'thread-1', 'chat-1', SESSION_USER, 'om_t3'), + ).resolves.toBe(true); + + // 每次都现拉,没缓存 + expect(client.fetchRecentMessages).toHaveBeenCalledTimes(2); + }); }); From 9cca5b5f8e4a2f7770ec2fb7b56d7ca34ce13891 Mon Sep 17 00:00:00 2001 From: flag Date: Wed, 3 Jun 2026 16:33:41 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=87=BA=20evaluateTh?= =?UTF-8?q?readBypass=20=E8=AE=A9=E5=8D=95/=E5=A4=9A=20bot=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E8=AF=9D=E9=A2=98=20bypass=20=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit event-handler.ts 里单 bot 和多 bot 模式各有一套 thread bypass 逻辑(session 创建者校验 + 多人话题保守),代码近乎完全重复。之前 deb97d4 加多人话题保守 时改了多 bot 分支,差点漏改单 bot;后续每加一条规则都得改两份,迟早飘。 把 bypass 判定抽到 thread-participants.ts 的 evaluateThreadBypass(),event-handler 里两个分支只剩 "判 @mention 状态 + 调共享函数"。返回带 reason 的结果对象 (no_session / not_creator / multi_user / solo),日志保持各分支独立。 - 新增 8 条 evaluateThreadBypass 单测覆盖所有 reason 分支 + agentId 透传 - 补 chat-history-fork.test.ts 和 event-handler.test.ts 里 security mock 漏导出的 isOwner(event-handler.ts 现在模块级常量直接引用 isOwner) - 更新 docs/design/thread-bypass-policy.md,标注共享判定与 v5 演进 Co-Authored-By: Claude Opus 4.7 --- docs/design/thread-bypass-policy.md | 32 +++-- .../__tests__/chat-history-fork.test.ts | 2 +- src/feishu/__tests__/event-handler.test.ts | 1 + .../__tests__/thread-participants.test.ts | 119 ++++++++++++++++++ src/feishu/event-handler.ts | 82 ++++++------ src/feishu/thread-participants.ts | 81 +++++++++++- 6 files changed, 261 insertions(+), 56 deletions(-) diff --git a/docs/design/thread-bypass-policy.md b/docs/design/thread-bypass-policy.md index 05a4885c..07ee1d89 100644 --- a/docs/design/thread-bypass-policy.md +++ b/docs/design/thread-bypass-policy.md @@ -1,5 +1,5 @@ --- -summary: "话题内消息是否触发 bot 响应的过滤策略:单人放行 / 多人强制 @mention" +summary: "话题内消息是否触发 bot 响应的过滤策略:单人放行 / 多人强制 @mention。单 bot 与多 bot 模式共享 evaluateThreadBypass 判定" related_paths: - src/feishu/event-handler.ts - src/feishu/thread-participants.ts @@ -26,12 +26,23 @@ last_updated: "2026-06-03" 3.4. 如果没有 → 单人话题:图片/文档/文字 全部直接放行 ``` -代码入口:`src/feishu/event-handler.ts` 的 `handleMessageEvent`。 +代码入口:`src/feishu/event-handler.ts` 的 `handleMessageEvent` 在两处分支(单 bot / 多 bot)调用同一个共享判定 `evaluateThreadBypass()`(位于 `src/feishu/thread-participants.ts`)。 -| 模式 | 职责 | -|------|------| -| 多 bot 模式 thread creator bypass | 话题创建者 bot 在单人话题里无需 @;多人话题里跳过 | -| 单 bot 模式 | 话题内 session 创建者在单人话题里无需 @;多人话题里跳过 | +| 模式 | 进入 bypass 的前置条件 | bypass 判定本体 | +|------|----------------------|----------------| +| 多 bot | 当前 bot 是话题创建者 + 所有 bot 都没被 @ | `evaluateThreadBypass(deps, { ..., agentId })` | +| 单 bot | 无 bot 被 @ | `evaluateThreadBypass(deps, { ... })`(不传 agentId) | + +`evaluateThreadBypass` 返回 `{ allow, reason }`,reason 取值: + +| reason | 含义 | +|--------|------| +| `no_session` | 话题没有 session 记录(陌生话题 / 已清理) | +| `not_creator` | 发送者不是 session 创建者,也不是 owner | +| `multi_user` | 话题里已有非 session 创建者的人类参与过 → 保守要求 @ | +| `solo` | 单人话题 + session 创建者 → 放行 | + +只有 `solo` 时 `allow = true`,其他三种都返回 `allow = false` 让外层 return。两个分支共用同一份判定,避免一边漏改导致策略不一致。 ## 「多人话题」的定义 @@ -45,6 +56,7 @@ last_updated: "2026-06-03" |------|------| | `hasOtherHumanInMessages(messages, sessionUserId, currentMessageId)` | 纯函数,便于单测 | | `threadHasOtherHumanParticipant(client, threadId, chatId, sessionUserId, currentMessageId, limit=10)` | 异步 wrapper,调用飞书 API 拉历史 | +| `evaluateThreadBypass(deps, params)` | 完整 bypass 判定,串起 session 创建者校验 + 多人话题检测 | **bot 自己的历史消息不算第三方**(senderType === `'app'` 一律忽略)。 @@ -80,7 +92,10 @@ last_updated: "2026-06-03" ## 测试覆盖 -- `src/feishu/__tests__/thread-participants.test.ts`:纯函数 + 异步 wrapper + 失败 fallback +- `src/feishu/__tests__/thread-participants.test.ts`: + - `hasOtherHumanInMessages` 纯函数边界 + - `threadHasOtherHumanParticipant` 异步 wrapper + 失败 fallback + 动态切换 + - `evaluateThreadBypass` 完整判定(no_session / not_creator / owner bypass / multi_user / solo / fetch 失败 / agentId 透传) - `src/feishu/__tests__/event-handler.test.ts`: - `single-bot group image/doc @mention filtering` —— 单 bot 模式所有组合(图/文档/文字 × 多人/单人 × @/无 @) - `multi-bot thread creator bypass — multi-user filtering` —— 多 bot 模式 bypass 行为 @@ -90,4 +105,5 @@ last_updated: "2026-06-03" 1. **v1**:话题内 bypass 只有"session 创建者 + Qwen 语义判断(文字)"。 2. **v2**:发现纯图片/文档消息没有文字可送进 Qwen → 退化成"直接放行" → 多人话题里被滥用。 3. **v3**:引入"多人话题保守策略" —— 多人话题里干掉所有 bypass。Qwen 仍然保留在单人话题文字消息上。 -4. **v4(当前)**:发现 Qwen 在单人话题里也是累赘 —— 它拿不到对话上下文,把延续上文的简短反馈(如「不用新分支,但是文档需要更新」)误判成"不是跟 bot 说话"。话题本就是 user vs bot 的对话载体,单人话题里没必要二次判断,直接放行。 +4. **v4**:发现 Qwen 在单人话题里也是累赘 —— 它拿不到对话上下文,把延续上文的简短反馈(如「不用新分支,但是文档需要更新」)误判成"不是跟 bot 说话"。话题本就是 user vs bot 的对话载体,单人话题里没必要二次判断,直接放行。 +5. **v5(当前)**:把单 bot 和多 bot 模式的 bypass 判定抽到 `evaluateThreadBypass()` 共享,event-handler 里两个分支只剩"判 @mention 状态 + 调共享函数",再不会出现一边改一边漏的策略漂移。 diff --git a/src/feishu/__tests__/chat-history-fork.test.ts b/src/feishu/__tests__/chat-history-fork.test.ts index e87a8e2e..e448c573 100644 --- a/src/feishu/__tests__/chat-history-fork.test.ts +++ b/src/feishu/__tests__/chat-history-fork.test.ts @@ -73,7 +73,7 @@ vi.mock('../message-builder.js', () => ({ buildProgressCard: vi.fn(), buildResultCard: vi.fn(), buildStatusCard: vi.fn(), })); vi.mock('../../utils/security.js', () => ({ - isUserAllowed: vi.fn(() => true), containsDangerousCommand: vi.fn(() => false), + isUserAllowed: vi.fn(() => true), containsDangerousCommand: vi.fn(() => false), isOwner: vi.fn(() => false), })); vi.mock('../../pipeline/store.js', () => ({ pipelineStore: { get: vi.fn(), findPendingByChat: vi.fn(), tryStart: vi.fn() }, diff --git a/src/feishu/__tests__/event-handler.test.ts b/src/feishu/__tests__/event-handler.test.ts index 3aaddc6f..2bfd66e6 100644 --- a/src/feishu/__tests__/event-handler.test.ts +++ b/src/feishu/__tests__/event-handler.test.ts @@ -107,6 +107,7 @@ vi.mock('../../utils/logger.js', () => ({ vi.mock('../../utils/security.js', () => ({ isUserAllowed: vi.fn(() => true), containsDangerousCommand: vi.fn(() => false), + isOwner: vi.fn(() => false), })); vi.mock('../../config.js', () => ({ diff --git a/src/feishu/__tests__/thread-participants.test.ts b/src/feishu/__tests__/thread-participants.test.ts index 3f84a490..cd8bacff 100644 --- a/src/feishu/__tests__/thread-participants.test.ts +++ b/src/feishu/__tests__/thread-participants.test.ts @@ -8,7 +8,9 @@ vi.mock('../../utils/logger.js', () => ({ import { hasOtherHumanInMessages, threadHasOtherHumanParticipant, + evaluateThreadBypass, type ParticipantMessage, + type ThreadBypassDeps, } from '../thread-participants.js'; // ============================================================ @@ -153,3 +155,120 @@ describe('threadHasOtherHumanParticipant', () => { expect(client.fetchRecentMessages).toHaveBeenCalledTimes(2); }); }); + +// ============================================================ +// evaluateThreadBypass — 单 bot / 多 bot 模式共享的完整 bypass 判定 +// ============================================================ +describe('evaluateThreadBypass', () => { + const SESSION_USER = 'ou_session_creator'; + const CURRENT_MSG = 'om_current'; + + /** 构造一份可复用的 deps,按需覆盖单字段 */ + function makeDeps(overrides: Partial = {}): ThreadBypassDeps { + return { + client: { fetchRecentMessages: vi.fn().mockResolvedValue([]) }, + getThreadSession: vi.fn().mockReturnValue({ userId: SESSION_USER }), + isOwner: vi.fn().mockReturnValue(false), + ...overrides, + }; + } + + const baseParams = { + threadId: 'thread-1', + chatId: 'chat-1', + senderUserId: SESSION_USER, + messageId: CURRENT_MSG, + }; + + it('returns no_session when thread has no session record', async () => { + const deps = makeDeps({ getThreadSession: vi.fn().mockReturnValue(undefined) }); + const result = await evaluateThreadBypass(deps, baseParams); + expect(result).toEqual({ allow: false, reason: 'no_session' }); + }); + + it('returns not_creator when sender is neither owner nor session creator', async () => { + const deps = makeDeps(); + const result = await evaluateThreadBypass(deps, { + ...baseParams, + senderUserId: 'ou_outsider', + }); + expect(result).toEqual({ + allow: false, + reason: 'not_creator', + sessionUserId: SESSION_USER, + }); + // 不应走到拉历史 + expect((deps.client.fetchRecentMessages as ReturnType)).not.toHaveBeenCalled(); + }); + + it('allows owner to bypass even if not session creator', async () => { + // owner 是平台管理员,话题里发啥都按 session 创建者待遇处理 + const deps = makeDeps({ isOwner: vi.fn().mockReturnValue(true) }); + const result = await evaluateThreadBypass(deps, { + ...baseParams, + senderUserId: 'ou_admin', + }); + expect(result).toEqual({ + allow: true, + reason: 'solo', + sessionUserId: SESSION_USER, + }); + }); + + it('returns multi_user when other human present in history', async () => { + const deps = makeDeps({ + client: { + fetchRecentMessages: vi.fn().mockResolvedValue([ + { messageId: 'om_old', senderId: 'ou_third_party', senderType: 'user' as const }, + ]), + }, + }); + const result = await evaluateThreadBypass(deps, baseParams); + expect(result).toEqual({ + allow: false, + reason: 'multi_user', + sessionUserId: SESSION_USER, + }); + }); + + it('returns solo when session creator alone in thread', async () => { + const deps = makeDeps({ + client: { + fetchRecentMessages: vi.fn().mockResolvedValue([ + { messageId: 'om_1', senderId: SESSION_USER, senderType: 'user' as const }, + ]), + }, + }); + const result = await evaluateThreadBypass(deps, baseParams); + expect(result).toEqual({ + allow: true, + reason: 'solo', + sessionUserId: SESSION_USER, + }); + }); + + it('treats fetch failure as multi_user (conservative)', async () => { + const deps = makeDeps({ + client: { + fetchRecentMessages: vi.fn().mockRejectedValue(new Error('boom')), + }, + }); + const result = await evaluateThreadBypass(deps, baseParams); + expect(result.allow).toBe(false); + expect(result.reason).toBe('multi_user'); + }); + + it('passes agentId through to getThreadSession (multi-bot mode)', async () => { + const getThreadSession = vi.fn().mockReturnValue({ userId: SESSION_USER }); + const deps = makeDeps({ getThreadSession }); + await evaluateThreadBypass(deps, { ...baseParams, agentId: 'pm' }); + expect(getThreadSession).toHaveBeenCalledWith('thread-1', 'pm'); + }); + + it('calls getThreadSession with undefined agentId in single-bot mode', async () => { + const getThreadSession = vi.fn().mockReturnValue({ userId: SESSION_USER }); + const deps = makeDeps({ getThreadSession }); + await evaluateThreadBypass(deps, baseParams); + expect(getThreadSession).toHaveBeenCalledWith('thread-1', undefined); + }); +}); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index cdd60e21..d34d854f 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -38,7 +38,7 @@ import { handleMemoryCommand, handleMemoryCardAction } from '../memory/commands. import { getRepoIdentity } from '../workspace/identity.js'; import { parseRepoNameFromWorkspaceDir } from '../workspace/manager.js'; import { generateQuickAck } from '../utils/quick-ack.js'; -import { threadHasOtherHumanParticipant } from './thread-participants.js'; +import { evaluateThreadBypass, type ThreadBypassDeps } from './thread-participants.js'; import { compressImage, compressImageForHistory } from '../utils/image-compress.js'; // 注册审批通过后的消息重新入队回调(避免 approval.ts → event-handler.ts 循环依赖) @@ -113,6 +113,13 @@ interface PendingQuestion { } /** questionId → PendingQuestion */ +// 共享 bypass 判定的依赖注入:所有都是 module-level singleton,构造一次复用 +const threadBypassDeps: ThreadBypassDeps = { + client: feishuClient, + getThreadSession: (threadId, agentId) => sessionManager.getThreadSession(threadId, agentId), + isOwner, +}; + const pendingQuestions = new Map(); /** AskUserQuestion 等待超时(5 分钟) */ @@ -755,24 +762,23 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd const anyBotMentioned = mentions.some(m => knownBotIds.has(m.id.open_id ?? '')); let threadBypass = false; if (threadId && !anyBotMentioned && isThreadCreatorAgent(threadId, agentId)) { - const ts = sessionManager.getThreadSession(threadId, agentId); - if (ts && (isOwner(userId) || ts.userId === userId)) { - // 多人话题保守策略:先看话题里是否有非 session 创建者的人类参与过; - // 有的话直接关掉 bypass,要求显式 @ bot 才回,避免插嘴。 - const otherHumanInThread = await threadHasOtherHumanParticipant( - feishuClient, threadId, chatId, ts.userId, messageId, + // 共享 bypass 判定(session 创建者 + 多人话题保守策略);外层 @ 路由各模式仍分别处理 + const result = await evaluateThreadBypass(threadBypassDeps, { + threadId, chatId, agentId, senderUserId: userId, messageId, + }); + if (result.allow) { + threadBypass = true; + logger.debug( + { threadId, agentId, accountId }, + 'Thread creator bypass: solo thread, responding without @mention', + ); + } else if (result.reason === 'multi_user') { + logger.info( + { threadId, agentId, sessionUserId: result.sessionUserId, text: text?.slice(0, 100) }, + 'Thread creator bypass skipped — multi-user thread without @mention', ); - if (otherHumanInThread) { - logger.info( - { threadId, agentId, sessionUserId: ts.userId, text: text?.slice(0, 100) }, - 'Thread creator bypass skipped — multi-user thread without @mention', - ); - } else { - // 单人话题:session 创建者发消息基本就是冲 bot 来的(话题本身就是对话载体),直接 bypass - threadBypass = true; - logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: solo thread, responding without @mention'); - } } + // no_session / not_creator:静默落回 shouldRespond } if (!threadBypass && !shouldRespond(chatType, mentions, botOpenId, knownBotIds, commanderOpenId)) { @@ -780,35 +786,25 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd } } else { // 单 bot 模式:群聊中需要 @机器人 才响应 - // 例外:话题内后续消息,需同时满足:① 发送者是 session 创建者 ② 语义判断消息在跟 bot 对话 + // 例外:话题内 session 创建者发的消息——通过 evaluateThreadBypass 走和多 bot 一致的 bypass 判定 if (chatType === 'group' && !mentionedBot) { - const ts = threadId ? sessionManager.getThreadSession(threadId) : undefined; - if (!ts || (!isOwner(userId) && ts.userId !== userId)) { - return; - } - // 多人话题保守策略:若话题里已经有非 session 创建者的人类参与过讨论, - // 任何未显式 @ bot 的消息(图片/文档/文字)都不响应——避免在多人讨论里自作多情插嘴。 - // 拉历史失败时 threadHasOtherHumanParticipant 默认返回 true(保守)。 - const otherHumanInThread = threadId - ? await threadHasOtherHumanParticipant(feishuClient, threadId, chatId, ts.userId, messageId) - : false; - if (otherHumanInThread) { - logger.info( - { - messageId, - threadId, - sessionUserId: ts.userId, - hasImages: !!images?.length, - hasDocuments: !!documents?.length, - text: text?.slice(0, 100), - }, - 'Single-bot thread bypass skipped — multi-user thread without @mention', - ); + if (!threadId) return; + const result = await evaluateThreadBypass(threadBypassDeps, { + threadId, chatId, senderUserId: userId, messageId, + }); + if (!result.allow) { + if (result.reason === 'multi_user') { + logger.info( + { + messageId, threadId, sessionUserId: result.sessionUserId, + hasImages: !!images?.length, hasDocuments: !!documents?.length, + text: text?.slice(0, 100), + }, + 'Single-bot thread bypass skipped — multi-user thread without @mention', + ); + } return; } - - // 单人话题里的图片/文档/文字:直接放行 - // session 创建者在专属话题里发的消息基本就是冲 bot 来的,没必要再用 Qwen 二次判断 logger.debug( { messageId, threadId }, 'Message allowed in group thread: session creator in solo thread', diff --git a/src/feishu/thread-participants.ts b/src/feishu/thread-participants.ts index b57178b4..98728cd0 100644 --- a/src/feishu/thread-participants.ts +++ b/src/feishu/thread-participants.ts @@ -1,9 +1,12 @@ // ============================================================ -// Thread Participants — 判断飞书话题里是否有非会话创建者的人类参与 +// Thread Participants — 飞书话题"是否要响应"的统一判定 // -// 用于单 bot 模式下的"图片/文档防插嘴"过滤:session 创建者在话题里发图, -// 如果近期已有其他人类用户加入讨论,那张图很可能是发给其他人看的, -// bot 不应自作多情响应,要求显式 @ bot 才接。 +// 两层 API: +// - threadHasOtherHumanParticipant(): 拉历史 + 判定是否多人话题 +// - evaluateThreadBypass(): 完整的 bypass 判定(session 创建者 + 多人话题保守) +// +// 单 bot 和多 bot 模式的外层"@ 路由"分支不同,但内层 bypass 判定一致, +// 都通过 evaluateThreadBypass 共享,避免一边漏改导致策略不一致。 // ============================================================ import { logger } from '../utils/logger.js'; @@ -70,3 +73,73 @@ export async function threadHasOtherHumanParticipant( return true; } } + +// ============================================================ +// 完整 bypass 判定 +// ============================================================ + +/** thread session 接口的最小子集(依赖注入便于测试) */ +export interface ThreadSessionLike { + userId: string; +} + +/** evaluateThreadBypass 依赖的外部能力,全部通过参数注入 */ +export interface ThreadBypassDeps { + client: Pick; + getThreadSession: (threadId: string, agentId?: string) => ThreadSessionLike | undefined; + isOwner: (userId: string) => boolean; +} + +export type ThreadBypassReason = + | 'no_session' // 话题没有 session 记录(陌生话题 / 已清理) + | 'not_creator' // 发送者不是 session 创建者也不是 owner + | 'multi_user' // 话题里已有非 session 创建者的人类参与过 → 保守要求 @ + | 'solo'; // 单人话题 + session 创建者 → 放行 + +export interface ThreadBypassResult { + allow: boolean; + reason: ThreadBypassReason; + /** session 创建者的 user id;no_session 时为 undefined */ + sessionUserId?: string; +} + +/** + * 判断当前话题消息是否应该 bypass @mention 要求。 + * + * 适用于"无 @bot 的话题内消息要不要响应"——单 bot/多 bot 模式共享这套判定。 + * 注意:本函数不关心 @mention 状态,调用方自己决定什么时候才进入 bypass 流程。 + * + * 决策顺序: + * 1. 拿不到 thread session → not_session + * 2. 发送者不是 session 创建者也不是 owner → not_creator + * 3. 拉话题历史看有没有第三人 → multi_user / solo + */ +export async function evaluateThreadBypass( + deps: ThreadBypassDeps, + params: { + threadId: string; + chatId: string; + /** 多 bot 模式传 agentId 隔离 session;单 bot 模式可不传,走默认 */ + agentId?: string; + senderUserId: string; + messageId: string; + }, +): Promise { + const { threadId, chatId, agentId, senderUserId, messageId } = params; + + const ts = deps.getThreadSession(threadId, agentId); + if (!ts) return { allow: false, reason: 'no_session' }; + + if (!deps.isOwner(senderUserId) && ts.userId !== senderUserId) { + return { allow: false, reason: 'not_creator', sessionUserId: ts.userId }; + } + + const otherHuman = await threadHasOtherHumanParticipant( + deps.client, threadId, chatId, ts.userId, messageId, + ); + if (otherHuman) { + return { allow: false, reason: 'multi_user', sessionUserId: ts.userId }; + } + + return { allow: true, reason: 'solo', sessionUserId: ts.userId }; +} From c385f053fd7f3f12d18091781a8e16da512d91d7 Mon Sep 17 00:00:00 2001 From: flag Date: Wed, 3 Jun 2026 17:29:18 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=20thread-bypass-?= =?UTF-8?q?policy=20=E5=A4=9A=E4=BA=BA=E5=88=A4=E5=AE=9A=E7=9A=84=E6=8E=AA?= =?UTF-8?q?=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reviewer 指出 doc/impl 语义差:文档写"曾经有过"+"话题一旦多人化就一直保持保守", 但实现是 fetchRecentMessages 默认 limit=10,只看最近 N 条窗口。 把决策表里的措辞改成"最近 N 条窗口(默认 N=10)",并把工程取舍和已知边界 (沉寂多轮再单人续聊会被重判 solo)写明,与代码现状对齐。 Co-Authored-By: Claude Opus 4.7 --- docs/design/thread-bypass-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/thread-bypass-policy.md b/docs/design/thread-bypass-policy.md index 07ee1d89..71541010 100644 --- a/docs/design/thread-bypass-policy.md +++ b/docs/design/thread-bypass-policy.md @@ -86,7 +86,7 @@ last_updated: "2026-06-03" | 多人话题强制 @bot | 历史教训(话题 `omt_197a3379c4cf1bb7`):session 创建者发图给第三人看,bot 自作多情冲上去回复。多人讨论里 bot 没法可靠判断"这条消息是给我还是给别人",干脆要求显式信号 | | 单人话题直接放行(不再用 Qwen 二次判断) | 话题本身就是 user vs bot 的对话载体,单人话题里 session 创建者发的消息基本就是冲 bot 来的。早期"自言自语保留 Qwen 文字判断"的设计反而引入误判(话题 `omt_197a5d56cc92dbe9`:「不用新分支,但是文档需要更新」被 Qwen 判为"不是跟 bot 说话",因为 Qwen 拿不到上下文) | | fetchRecentMessages 失败默认 true | 保守优先。API 抖动时宁可让用户多 @ 一次,也不要在多人话题里乱接 | -| 多人判定基于"曾经有过"而非"最近 N 条" | 简单且语义清晰;话题一旦多人化就一直保持保守 | +| 多人判定基于"最近 N 条窗口"(默认 N=10) | 工程取舍:`fetchRecentMessages` 一次拉最近 N 条即可判定,无需翻完整话题历史。代价是第三人若在 N 条之前发言、之后只剩创建者与 bot,新消息会重新判成 solo 放行 —— 实际话题里这种"沉寂多轮再单人续聊"的场景概率低,且 bot 自作多情的代价可控(只是少 @ 一次) | | 不在 thread_session 表加持久标志 | 实时拉历史已经足够轻量(一次 API 调用),加 schema 字段反而需要 migration + 数据回填 | | `app` 类型消息不算第三方 | 同群其他 bot 的发言不应触发 multi-user 状态 |