diff --git a/docs/design/thread-bypass-policy.md b/docs/design/thread-bypass-policy.md new file mode 100644 index 00000000..71541010 --- /dev/null +++ b/docs/design/thread-bypass-policy.md @@ -0,0 +1,109 @@ +--- +summary: "话题内消息是否触发 bot 响应的过滤策略:单人放行 / 多人强制 @mention。单 bot 与多 bot 模式共享 evaluateThreadBypass 判定" +related_paths: + - src/feishu/event-handler.ts + - src/feishu/thread-participants.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. 如果没有 → 单人话题:图片/文档/文字 全部直接放行 +``` + +代码入口:`src/feishu/event-handler.ts` 的 `handleMessageEvent` 在两处分支(单 bot / 多 bot)调用同一个共享判定 `evaluateThreadBypass()`(位于 `src/feishu/thread-participants.ts`)。 + +| 模式 | 进入 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。两个分支共用同一份判定,避免一边漏改导致策略不一致。 + +## 「多人话题」的定义 + +一个话题被视为**多人话题**,当且仅当: +- 拉取话题最近 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 拉历史 | +| `evaluateThreadBypass(deps, params)` | 完整 bypass 判定,串起 session 创建者校验 + 多人话题检测 | + +**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 模式 | +|------|-------------|-------------| +| 触发条件 | session 创建者发消息 | 当前 bot 是话题创建者 + 任何 bot 都没被 @ | +| 单人话题任何类型 | 直接放行 | 直接放行 | +| 多人话题任何类型 | 不响应 | 不响应 | +| API 调用 | fetchRecentMessages 一次 | fetchRecentMessages 一次 | + +## 设计决策 + +| 决策 | 理由 | +|------|------| +| 多人话题强制 @bot | 历史教训(话题 `omt_197a3379c4cf1bb7`):session 创建者发图给第三人看,bot 自作多情冲上去回复。多人讨论里 bot 没法可靠判断"这条消息是给我还是给别人",干脆要求显式信号 | +| 单人话题直接放行(不再用 Qwen 二次判断) | 话题本身就是 user vs bot 的对话载体,单人话题里 session 创建者发的消息基本就是冲 bot 来的。早期"自言自语保留 Qwen 文字判断"的设计反而引入误判(话题 `omt_197a5d56cc92dbe9`:「不用新分支,但是文档需要更新」被 Qwen 判为"不是跟 bot 说话",因为 Qwen 拿不到上下文) | +| fetchRecentMessages 失败默认 true | 保守优先。API 抖动时宁可让用户多 @ 一次,也不要在多人话题里乱接 | +| 多人判定基于"最近 N 条窗口"(默认 N=10) | 工程取舍:`fetchRecentMessages` 一次拉最近 N 条即可判定,无需翻完整话题历史。代价是第三人若在 N 条之前发言、之后只剩创建者与 bot,新消息会重新判成 solo 放行 —— 实际话题里这种"沉寂多轮再单人续聊"的场景概率低,且 bot 自作多情的代价可控(只是少 @ 一次) | +| 不在 thread_session 表加持久标志 | 实时拉历史已经足够轻量(一次 API 调用),加 schema 字段反而需要 migration + 数据回填 | +| `app` 类型消息不算第三方 | 同群其他 bot 的发言不应触发 multi-user 状态 | + +## 测试覆盖 + +- `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 行为 + +## 历史演进 + +1. **v1**:话题内 bypass 只有"session 创建者 + Qwen 语义判断(文字)"。 +2. **v2**:发现纯图片/文档消息没有文字可送进 Qwen → 退化成"直接放行" → 多人话题里被滥用。 +3. **v3**:引入"多人话题保守策略" —— 多人话题里干掉所有 bypass。Qwen 仍然保留在单人话题文字消息上。 +4. **v4**:发现 Qwen 在单人话题里也是累赘 —— 它拿不到对话上下文,把延续上文的简短反馈(如「不用新分支,但是文档需要更新」)误判成"不是跟 bot 说话"。话题本就是 user vs bot 的对话载体,单人话题里没必要二次判断,直接放行。 +5. **v5(当前)**:把单 bot 和多 bot 模式的 bypass 判定抽到 `evaluateThreadBypass()` 共享,event-handler 里两个分支只剩"判 @mention 状态 + 调共享函数",再不会出现一边改一边漏的策略漂移。 diff --git a/src/__tests__/mention-gate.test.ts b/src/__tests__/mention-gate.test.ts index 56556c1b..c0a300b8 100644 --- a/src/__tests__/mention-gate.test.ts +++ b/src/__tests__/mention-gate.test.ts @@ -182,35 +182,36 @@ describe('resolveMentionGate', () => { expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBeUndefined(); }); - it('allows media from thread session creator', async () => { + it('allows media from thread session creator (solo thread)', async () => { + // 单人话题图/文/字一视同仁直接放行 —— 不再有 thread_session_media 特殊待遇 mockGetThreadSession.mockReturnValue({ userId: 'ou_user_1', createdAt: new Date().toISOString() }); expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123', images: [{ key: 'img_key' }], - })).toBe('thread_session_media'); + })).toBe('thread_session_owner'); }); it('allows message from thread session creator when only two participants', async () => { mockGetThreadSession.mockReturnValue({ userId: 'ou_user_1', createdAt: new Date().toISOString() }); - // fetchRecentMessages returns [] → humanSenders.size=0 → dual-person bypass + // fetchRecentMessages returns [] → 无第三人 → 放行 expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBe('thread_session_owner'); }); - it('uses Qwen when third person present in thread', async () => { + it('blocks thread bypass when third person present in thread (no Qwen fallback)', async () => { + // 多人话题保守:不管文字怎么写都不放行,必须 @bot mockGetThreadSession.mockReturnValue({ userId: 'ou_user_1', createdAt: new Date().toISOString() }); const { feishuClient: fc } = await import('../feishu/client.js'); vi.mocked(fc.fetchRecentMessages).mockResolvedValue([ { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hello', msgType: 'text' }, { messageId: 'm2', senderId: 'ou_user_2', senderType: 'user', content: 'hi', msgType: 'text' }, ] as any); - mockCheckThreadRelevance.mockResolvedValue(false); expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBeUndefined(); vi.mocked(fc.fetchRecentMessages).mockResolvedValue([]); }); - it('allows owner in thread even if not session creator', async () => { + it('allows owner in thread even if not session creator (solo)', async () => { + // owner 在单人话题里和 session 创建者一样直接放行;不再过 Qwen mockGetThreadSession.mockReturnValue({ userId: 'ou_other_user', createdAt: new Date().toISOString() }); mockIsOwner.mockReturnValue(true); - mockCheckThreadRelevance.mockResolvedValue(true); expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_123' })).toBe('thread_session_owner'); }); @@ -270,36 +271,18 @@ describe('resolveMentionGate', () => { })).toBeUndefined(); }); - it('allows thread_bypass_exclusive when only creator and bot', async () => { + it('allows thread_bypass when only creator and bot (solo thread)', async () => { mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; return undefined; }); - // fetchRecentMessages returns [] → only creator + bot → exclusive bypass - expect(await resolveMentionGate({ - ...baseInput, threadId: 'omt_existing_topic', - })).toBe('thread_bypass_exclusive'); - }); - - it('allows thread_bypass when third person present and Qwen says yes', async () => { - mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { - if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; - return undefined; - }); - const { feishuClient: fc } = await import('../feishu/client.js'); - vi.mocked(fc.fetchRecentMessages).mockResolvedValue([ - { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hi', msgType: 'text' }, - { messageId: 'm2', senderId: 'ou_user_other', senderType: 'user', content: 'yo', msgType: 'text' }, - ] as any); - mockCheckThreadRelevance.mockResolvedValue(true); - + // fetchRecentMessages returns [] → only creator + bot → solo bypass expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_existing_topic', })).toBe('thread_bypass'); - vi.mocked(fc.fetchRecentMessages).mockResolvedValue([]); }); - it('blocks thread_bypass when third person present and Qwen says no', async () => { + it('blocks thread bypass when third person present (multi-user conservative, no Qwen fallback)', async () => { mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; return undefined; @@ -309,7 +292,6 @@ describe('resolveMentionGate', () => { { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hi', msgType: 'text' }, { messageId: 'm2', senderId: 'ou_user_other', senderType: 'user', content: 'yo', msgType: 'text' }, ] as any); - mockCheckThreadRelevance.mockResolvedValue(false); expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_existing_topic', @@ -322,7 +304,6 @@ describe('resolveMentionGate', () => { if (agentId === 'dev') return { userId: 'ou_different_user', createdAt: '2026-01-01T00:00:00Z' }; return undefined; }); - mockCheckThreadRelevance.mockResolvedValue(true); expect(await resolveMentionGate({ ...baseInput, threadId: 'omt_existing_topic', @@ -410,7 +391,6 @@ describe('resolveMentionGate', () => { { messageId: 'm1', senderId: 'ou_user_1', senderType: 'user', content: 'hi', msgType: 'text' }, { messageId: 'm2', senderId: 'ou_relic_product_xz', senderType: 'user', content: 'ok', msgType: 'text' }, ] as any); - mockCheckThreadRelevance.mockResolvedValue(false); const humanMention = { id: { open_id: 'ou_relic_product_xz' } }; const result = await resolveMentionGate({ diff --git a/src/feishu/__tests__/chat-history-fork.test.ts b/src/feishu/__tests__/chat-history-fork.test.ts index 83f8ac62..9f3e9484 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(), buildCombinedProgressCard: 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 c280d368..1e8b2cc6 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', () => ({ @@ -168,7 +169,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(), @@ -942,17 +942,21 @@ 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' 需语义判断 + * @returns 'allow' 放行 | 'block' 拦截 */ function singleBotGroupFilter(params: { chatType: string; @@ -962,8 +966,13 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( isSessionCreatorOrOwner: boolean; hasImages: boolean; hasDocuments: boolean; - }): 'allow' | 'block' | 'semantic_check' { - const { chatType, mentionedBot, threadId, hasThreadSession, isSessionCreatorOrOwner, hasImages, hasDocuments } = params; + /** 话题里是否已有非 session 创建者的人类用户参与过 */ + threadHasOtherParticipants?: boolean; + }): 'allow' | 'block' { + const { + chatType, mentionedBot, threadId, hasThreadSession, isSessionCreatorOrOwner, + threadHasOtherParticipants = false, + } = params; // 非群聊 或 已 @bot → 直接放行 if (chatType !== 'group' || mentionedBot) return 'allow'; @@ -972,9 +981,12 @@ 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'; + + // 单人话题:session 创建者发什么都直接放行(话题就是 user vs bot 的对话载体) + return 'allow'; } // --- 主聊天区(无话题)--- @@ -1003,30 +1015,85 @@ 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', () => { 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'); + }); + + 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('allow'); + }); + + // --- 话题内 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 require SEMANTIC_CHECK for text in thread from session creator', () => { + 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, hasImages: false, hasDocuments: false, - })).toBe('semantic_check'); + 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 创建者 --- @@ -1050,6 +1117,105 @@ describe('single-bot group image/doc @mention filtering (regression PR #220)', ( }); }); +// ============================================================ +// 多 bot 模式 thread creator 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 生效(响应)| 'skip' 不响应 + */ + function multiBotThreadBypass(params: { + isThreadCreatorAgent: boolean; + anyBotMentioned: boolean; + hasThreadSession: boolean; + isOwnerOrCreator: boolean; + threadHasOtherParticipants: boolean; + }): 'bypass' | 'skip' { + const { + isThreadCreatorAgent, anyBotMentioned, hasThreadSession, isOwnerOrCreator, + threadHasOtherParticipants, + } = params; + + // 没匹配 bypass 前提 → 不进入 bypass 分支 + if (anyBotMentioned || !isThreadCreatorAgent) return 'skip'; + if (!hasThreadSession || !isOwnerOrCreator) return 'skip'; + + // 多人话题:直接 skip,不响应 + if (threadHasOtherParticipants) return 'skip'; + + // 单人话题:直接 bypass + return 'bypass'; + } + + it('should BYPASS when solo thread + creator agent + session creator', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: false, + })).toBe('bypass'); + }); + + it('should SKIP when multi-user thread (avoid butting in)', () => { + // 黎叔的场景:话题里出现过第三人,即使消息看起来在跟 bot 说话,也不响应 + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: true, + })).toBe('skip'); + }); + + it('should SKIP when not thread creator agent', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: false, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: true, + threadHasOtherParticipants: false, + })).toBe('skip'); + }); + + it('should SKIP when no thread session', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: false, + isOwnerOrCreator: true, + threadHasOtherParticipants: false, + })).toBe('skip'); + }); + + it('should SKIP when sender is not session creator nor owner', () => { + expect(multiBotThreadBypass({ + isThreadCreatorAgent: true, + anyBotMentioned: false, + hasThreadSession: true, + isOwnerOrCreator: false, + threadHasOtherParticipants: false, + })).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, + })).toBe('skip'); + }); +}); + // ============================================================ // makeQueueKey + 并行执行策略测试 // diff --git a/src/feishu/__tests__/restart-history.test.ts b/src/feishu/__tests__/restart-history.test.ts index cedee150..bb284567 100644 --- a/src/feishu/__tests__/restart-history.test.ts +++ b/src/feishu/__tests__/restart-history.test.ts @@ -61,7 +61,7 @@ vi.mock('../message-builder.js', () => ({ buildProgressCard: vi.fn(), buildCombinedProgressCard: 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__/thread-participants.test.ts b/src/feishu/__tests__/thread-participants.test.ts new file mode 100644 index 00000000..cd8bacff --- /dev/null +++ b/src/feishu/__tests__/thread-participants.test.ts @@ -0,0 +1,274 @@ +// @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, + evaluateThreadBypass, + type ParticipantMessage, + type ThreadBypassDeps, +} 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); + }); + + // ============================================================ + // 动态切换:单人话题 → 加入第三人 → 后续消息变成多人话题 + // + // 重要边界:不能缓存"这个话题是单人/多人"的判定,必须每条消息都 + // 实时拉历史。否则第三人加入后,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); + }); +}); + +// ============================================================ +// 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 1faa2e83..9c816c21 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -42,7 +42,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 { checkThreadRelevance } from '../utils/thread-relevance.js'; +import { evaluateThreadBypass, type ThreadBypassDeps } from './thread-participants.js'; import { compressImage, compressImageForHistory } from '../utils/image-compress.js'; // 注册审批通过后的消息重新入队回调(避免 approval.ts → event-handler.ts 循环依赖) @@ -187,6 +187,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 等待超时(毫秒)。0 表示永不超时(默认)。 */ @@ -543,7 +550,7 @@ interface MentionGateInput { } async function resolveMentionGate(input: MentionGateInput): Promise { - const { chatType, mentionedBot, mentions, threadId, messageId, text, userId, chatId, agentId, accountId, images, documents } = input; + const { chatType, mentionedBot, mentions, threadId, messageId, text, userId, chatId, agentId, accountId } = input; // 私聊始终放行 if (chatType === 'p2p') return 'p2p'; @@ -562,24 +569,21 @@ async function resolveMentionGate(input: MentionGateInput): Promise knownBotIds.has(m.id.open_id ?? '')); // 话题内 thread bypass:话题创建者 bot 无需 @mention + // 走共享 evaluateThreadBypass —— session 创建者 + 单人话题直接放行;多人话题保守要求 @ if (threadId && !anyBotMentioned && isThreadCreatorAgent(threadId, agentId)) { - const ts = sessionManager.getThreadSession(threadId, agentId); - if (ts && (isOwner(userId) || ts.userId === userId)) { - const recentMsgs = await feishuClient.fetchRecentMessages(threadId, 'thread', 10); - const humanSenders = new Set(recentMsgs.filter(m => m.senderType === 'user').map(m => m.senderId)); - - if (humanSenders.size <= 1) { - return 'thread_bypass_exclusive'; - } - - const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const context = await formatThreadContext(recentMsgs, botDisplayName, chatId, accountId); - const relevant = await checkThreadRelevance(text, botDisplayName, context); - if (relevant) { - return 'thread_bypass'; - } - logger.info({ threadId, agentId, text: text.slice(0, 100), humanCount: humanSenders.size }, 'Thread bypass skipped — message not directed at bot'); + const result = await evaluateThreadBypass(threadBypassDeps, { + threadId, chatId, agentId, senderUserId: userId, messageId, + }); + if (result.allow) { + return 'thread_bypass'; + } + if (result.reason === 'multi_user') { + logger.info( + { threadId, agentId, sessionUserId: result.sessionUserId, text: text?.slice(0, 100) }, + 'Thread bypass skipped — multi-user thread without @mention', + ); } + // no_session / not_creator:静默落回 @mention 路由 } // @mention / commander 路由 @@ -593,74 +597,23 @@ async function resolveMentionGate(input: MentionGateInput): Promise m.senderType === 'user').map(m => m.senderId)); - - if (humanSenders.size <= 1) { - return 'thread_session_owner'; - } - - const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; - const context = await formatThreadContext(recentMsgs, botDisplayName, chatId, accountId); - const relevant = await checkThreadRelevance(text, botDisplayName, context); - if (!relevant) { - logger.info({ messageId, threadId, text: text?.slice(0, 100), humanCount: humanSenders.size }, 'Thread session owner: semantically not directed at bot'); + // 群聊未 @mention:仅话题内 session 创建者可放行(共享判定逻辑,与多 bot 一致) + if (!threadId) return undefined; + 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, text: text?.slice(0, 100) }, + 'Single-bot thread bypass skipped — multi-user thread without @mention', + ); + } return undefined; } - - logger.info({ messageId, threadId }, 'Thread session owner: semantically relevant'); return 'thread_session_owner'; } -/** - * 将话题最近消息格式化为 Qwen 可读的对话记录。 - * 用于多人话题场景的语义判断,让 Qwen 看清谁在跟谁说话。 - */ -async function formatThreadContext( - messages: Array<{ senderId: string; senderType: string; content: string }>, - botName: string, - chatId: string, - accountId: string, -): Promise { - const userIds = [...new Set(messages.filter(m => m.senderType === 'user').map(m => m.senderId))]; - await resolveUserNames(userIds, chatId); - - const selfBotOpenId = accountManager.getBotOpenId(accountId) ?? ''; - const botNameMap = new Map(); - for (const acc of accountManager.allAccounts()) { - if (acc.botOpenId) botNameMap.set(acc.botOpenId, acc.botName); - } - const lines: string[] = []; - let totalLen = 0; - for (const m of messages) { - let name: string; - if (m.senderType === 'app') { - name = m.senderId === selfBotOpenId - ? `${botName}(bot)` - : `${botNameMap.get(m.senderId) ?? chatBotRegistry.getBots(chatId).find(b => b.openId === m.senderId)?.name ?? '其他bot'}(bot)`; - } else { - name = _userNameCache.get(m.senderId) ?? '用户'; - } - const content = m.senderType === 'app' ? m.content.slice(0, 100) : m.content; - const line = `[${name}]: ${content}`; - if (totalLen + line.length > 1500) break; - lines.push(line); - totalLen += line.length; - } - return lines.join('\n'); -} - // ============================================================ // 话题创建者判定 // ============================================================ diff --git a/src/feishu/thread-participants.ts b/src/feishu/thread-participants.ts new file mode 100644 index 00000000..98728cd0 --- /dev/null +++ b/src/feishu/thread-participants.ts @@ -0,0 +1,145 @@ +// ============================================================ +// Thread Participants — 飞书话题"是否要响应"的统一判定 +// +// 两层 API: +// - threadHasOtherHumanParticipant(): 拉历史 + 判定是否多人话题 +// - evaluateThreadBypass(): 完整的 bypass 判定(session 创建者 + 多人话题保守) +// +// 单 bot 和多 bot 模式的外层"@ 路由"分支不同,但内层 bypass 判定一致, +// 都通过 evaluateThreadBypass 共享,避免一边漏改导致策略不一致。 +// ============================================================ + +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; + } +} + +// ============================================================ +// 完整 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 }; +}