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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/__tests__/mention-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 模式 ──
Expand Down Expand Up @@ -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();
});
});

// ── 边界情况 ──
Expand Down
26 changes: 22 additions & 4 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,11 +566,20 @@ async function resolveMentionGate(input: MentionGateInput): Promise<string | und
// 补充 chatBotRegistry 中跨 app bot open_id
const registryBotIds = chatBotRegistry.getBots(chatId).map(b => 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,
});
Expand Down Expand Up @@ -599,6 +608,15 @@ async function resolveMentionGate(input: MentionGateInput): Promise<string | und

// 群聊未 @mention:仅话题内 session 创建者可放行(共享判定逻辑,与多 bot 一致)
if (!threadId) return undefined;

// 若消息 @ 了"除本 bot 以外的任何对象"(哪怕是另一个独立服务的 bot),说明在叫别人,不 bypass。
// 走到这里说明本 bot 未被 @(@自己已在上方 mentionedBot 分支返回),故任一带 open_id 的 mention 都是「@别人」。
const selfOpenId = feishuClient.botOpenId ?? '';
const mentionsOtherParty = mentions.some(
(m) => !!m.id.open_id && m.id.open_id !== selfOpenId,
);
if (mentionsOtherParty) return undefined;

const result = await evaluateThreadBypass(threadBypassDeps, {
threadId, chatId, senderUserId: userId, messageId,
});
Expand Down
Loading