diff --git a/src/__tests__/mention-gate.test.ts b/src/__tests__/mention-gate.test.ts index c0a300b..721862e 100644 --- a/src/__tests__/mention-gate.test.ts +++ b/src/__tests__/mention-gate.test.ts @@ -218,6 +218,16 @@ describe('resolveMentionGate', () => { it('allows non-group chat types without @mention', async () => { expect(await resolveMentionGate({ ...baseInput, chatType: 'supergroup' })).toBe('non_group'); }); + + it('blocks thread bypass when another (separate-service) bot is @mentioned', async () => { + // 单 bot 模式:DevBot 是话题创建者,但用户 @ 了群里另一个独立服务的 bot。 + // 旧逻辑单 bot 路径无 mention 闸 → bypass 放行抢答;修复后 @非自己 → 不 bypass。 + mockGetThreadSession.mockReturnValue({ userId: 'ou_user_1', createdAt: new Date().toISOString() }); + const otherBotMention = { id: { open_id: 'ou_separate_service_bot' } }; + expect(await resolveMentionGate({ + ...baseInput, threadId: 'omt_123', mentions: [otherBotMention], + })).toBeUndefined(); + }); }); // ── 多 bot 模式 ── @@ -321,6 +331,24 @@ describe('resolveMentionGate', () => { ...baseInput, mentions: [otherBotMention], threadId: 'omt_existing_topic', })).toBeUndefined(); }); + + it('blocks thread_bypass when @mentioned party is unrecognized as bot (cross-app open_id)', async () => { + // 真实 bug 复现:本 bot(dev) 是话题创建者,用户在话题里 @ 了另一个 bot。 + // 飞书 open_id 按 app 隔离,被 @ 的 bot 在本 app 视角下的 open_id 不在 knownBotIds 里, + // 旧逻辑 anyBotMentioned=false → 命中 thread_bypass 抢答。 + // 修复后:只要 @ 了"非自己",就不 bypass。 + mockGetThreadSession.mockImplementation((_: string, agentId?: string) => { + if (agentId === 'dev') return { userId: 'ou_user_1', createdAt: '2026-01-01T00:00:00Z' }; + return undefined; + }); + const foreignBotMention = { id: { open_id: 'ou_other_bot_foreign_scope' } }; + expect(await resolveMentionGate({ + ...baseInput, + mentions: [foreignBotMention], + threadId: 'omt_existing_topic', + text: '@张全栈 接下来做啥', + })).toBeUndefined(); + }); }); // ── 边界情况 ── diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 9c816c2..5a93ea7 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -566,11 +566,20 @@ async function resolveMentionGate(input: MentionGateInput): Promise b.openId); const knownBotIds = new Set([...allBotOpenIds, ...registryBotIds]); - const anyBotMentioned = mentions.some(m => knownBotIds.has(m.id.open_id ?? '')); - // 话题内 thread bypass:话题创建者 bot 无需 @mention - // 走共享 evaluateThreadBypass —— session 创建者 + 单人话题直接放行;多人话题保守要求 @ - if (threadId && !anyBotMentioned && isThreadCreatorAgent(threadId, agentId)) { + // 话题内 thread bypass:话题创建者 bot 无需 @mention 也可继续对话。 + // 但只要用户 @ 了"除本 bot 以外的任何对象",就说明在叫别人 —— 不 bypass。 + // + // 为什么以"是否 @ 了非自己"为准,而不是"是否 @ 了已知 bot": + // 飞书 open_id 按 app 隔离,同一个 bot 在不同 app 下 open_id 不同。 + // getAllBotOpenIds() 存的是各 bot 用自己 app 拉到的「自视 open_id」, + // 当用户 @ 了另一个 bot 时,本 bot 的 app 收到的那条 mention 是「对方在本 app 视角下的 open_id」, + // 不在 knownBotIds 里 → anyBotMentioned 漏判 → 话题创建者 bot 误以为没人被 @ 而抢答。 + // 每个 bot 唯一能确信的就是自己的 open_id,故改用「@ 了非自己」作为不 bypass 的判据。 + const mentionsOtherParty = mentions.some( + (m) => !!m.id.open_id && m.id.open_id !== botOpenId, + ); + if (threadId && !mentionsOtherParty && isThreadCreatorAgent(threadId, agentId)) { const result = await evaluateThreadBypass(threadBypassDeps, { threadId, chatId, agentId, senderUserId: userId, messageId, }); @@ -599,6 +608,15 @@ async function resolveMentionGate(input: MentionGateInput): Promise !!m.id.open_id && m.id.open_id !== selfOpenId, + ); + if (mentionsOtherParty) return undefined; + const result = await evaluateThreadBypass(threadBypassDeps, { threadId, chatId, senderUserId: userId, messageId, });