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
109 changes: 109 additions & 0 deletions docs/design/thread-bypass-policy.md
Original file line number Diff line number Diff line change
@@ -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 状态 + 调共享函数",再不会出现一边改一边漏的策略漂移。
42 changes: 11 additions & 31 deletions src/__tests__/mention-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=0dual-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');
});

Expand Down Expand Up @@ -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;
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/feishu/__tests__/chat-history-fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
Loading
Loading