From 5e6f213aa5119048f636f0478445776f69b9a8f4 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 15 Jun 2026 01:01:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E8=AF=9D=E9=A2=98=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E8=80=85=20bot=20=E5=9C=A8=20@=20=E4=BB=96=E4=BA=BA=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=A2=E7=AD=94(open=5Fid=20=E8=B7=A8=20ap?= =?UTF-8?q?p=20=E9=9A=94=E7=A6=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多 bot 共处一个话题时,用户 @ 某个 bot,其他 bot 也会响应。 根因:飞书 open_id 按 app 隔离,同一 bot 在不同 app 下 open_id 不同。 thread_bypass 的闸用 `!anyBotMentioned`(是否 @ 了已知 bot)判定,而 getAllBotOpenIds() 只存各 bot 用自己 app 拉到的「自视 open_id」。当用户 @ 了另一个 bot 时,本 bot 的 app 收到的那条 mention 是「对方在本 app 视角 下的 open_id」,不在 knownBotIds 里 → anyBotMentioned 漏判 → 话题创建者 bot 误以为没人被 @ 而走 bypass 抢答。 修复:改用「是否 @ 了非自己」作为不 bypass 的判据。每个 bot 唯一能可靠 识别的就是自己的 open_id —— 只要消息 @ 了除本 bot 外的任何对象,就说明在 叫别人,不 bypass。同时给单 bot 路径补上此前缺失的同款 mention 闸。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/feishu/event-handler.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) 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, }); From fedca0276586d83b772bdef7bc34867843e07ff1 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 15 Jun 2026 01:01:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=E8=A6=86=E7=9B=96=E8=AF=9D?= =?UTF-8?q?=E9=A2=98=E5=88=9B=E5=BB=BA=E8=80=85=20bot=20=E5=9C=A8=20@=20?= =?UTF-8?q?=E4=BB=96=E4=BA=BA=E6=97=B6=E4=B8=8D=20bypass=20=E6=8A=A2?= =?UTF-8?q?=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 多 bot: 创建者 bot 看到 @ 了跨 app open_id 未识别的对象 → 不 bypass - 单 bot: 创建者 bot 看到 @ 了另一独立服务 bot → 不 bypass 两个用例在修复前均会失败(走 thread_bypass 抢答),修复后通过。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/mention-gate.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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(); + }); }); // ── 边界情况 ──