Ctrl K
ComunidadGuías…

docs/plan/2026-09-15-feishu-thread-multi-session-design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 飞书话题群多会话(话题=会话)技术方案

## 一、需求背景

### 1.1 现状与痛点

飞书私聊已支持多会话并行(`docs/plan/2026-09-04-feishu-p2p-multi-session-design.md`):`---` 新建会话、引用消息切换会话。但私聊消息全部平铺在同一个聊天窗口中,用户查找历史会话的回复只能逐条翻聊天记录,体验不佳。

群聊方面,当前架构是**一个群 = 一个会话**(`feishu_chat` 表以 `(app_id, chat_id)` 唯一键绑定),所有 @机器人 的消息进入同一个 session,无法并行处理多个独立任务。

### 1.2 目标

利用飞书话题群(topic mode group)的天然结构,实现「**一个群 = 一个工作区,一个话题 = 一个会话**」:

- 用户在群内发起新话题 → 自动创建新 Mao 会话;
- 用户在话题内发送消息 → 自动路由到对应会话执行任务;
- 多个话题(会话)独立并行执行,互不干扰;
- 会话列表在 Mao 桌面端/Web 端自然呈现,无需额外 UI。

### 1.3 飞书话题群关键事实

来源:[飞书开放平台文档](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/thread-introduction)

| 概念 | 说明 |
|---|---|
| `chat_mode = topic` | 话题群:所有消息自动生成话题,回复只能在话题内进行 |
| `chat_mode = group` + `group_message_type = thread` | 话题格式群:普通群切换消息格式为话题,可切回对话模式 |
| `thread_id`(`omt_` 前缀) | 话题唯一 ID;消息事件中带此字段表示该消息属于某话题 |
| 话题内回复的 `parent_id` | **始终指向话题根消息的 `message_id`**(官方明确说明),非用户主动引用 |
| `im.chat.updated_v1` | 群配置变更事件,含 `group_message_type` 字段,可在群模式切换时感知 |

**关键区分**:

- 话题内普通回复:`parent_id === root_id`(均指向话题根消息)
- 话题内显式引用某条回复:`parent_id ≠ root_id`(`parent_id` 指向被引用消息,`root_id` 仍指向话题根)

## 二、需求描述

### 2.1 做(本期范围)

1. **话题 = 会话自动映射**:话题群内,`thread_id` 与 Mao session 一一映射。用户在新话题发消息 → 创建新会话;在已有话题发消息 → 路由到已有会话。
2. **`parent_id` 误判修复**:话题内消息的 `parent_id` 指向话题根,不应被当作「用户引用了某条消息」注入 `【引用的消息】`。仅当 `parent_id ≠ root_id`(显式引用话题内某条回复)时才注入。
3. **话题内消息无需 @机器人**:话题本身已隔离了上下文,用户在话题内发消息即视为触发,无需 @。
4. **群消息日志记录 `thread_id`**:`feishu_group_message_log` 表新增 `thread_id` 列,供话题内上下文按 `thread_id` 过滤。
5. **话题会话标题**:新建话题会话以首条消息前 20 字命名(复用 p2p 的 `awaitingFirstMessageTitle` 机制)。
6. **群模式变更感知**:订阅 `im.chat.updated_v1` 事件,在 `group_message_type` 切换时更新本地缓存。

### 2.2 明确不做的

| 不做项 | 说明 |
|---|---|
| 非话题群的改动 | `chat_mode = group` 且 `group_message_type = chat`(纯对话群)走现有逻辑,零改动 |
| 话题工作区隔离 | 所有话题共享群工作区(同一目录),与私聊多会话共享工作区策略一致 |
| 话题删除/归档联动 | 话题在飞书侧删除后,对应 Mao 会话保留,不自动清理 |
| 话题内进度卡片的 thread 定位 | 进度卡片/排队卡片发送到群 chat_id(reply 触发消息自动落入话题),不做额外 thread 定位 |
| 桌面/Web 端 UI 改动 | 多会话在现有会话列表自然呈现 |
| 安卓原生改动 | 无 |

## 三、技术方案

### 3.1 数据模型

#### 3.1.1 新表 `feishu_thread_session`

映射 `thread_id → session_id`,承载话题与会话的绑定关系。

```sql
-- V114__feishu_thread_session.sql
CREATE TABLE IF NOT EXISTS `feishu_thread_session` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `app_id` VARCHAR(64) NOT NULL COMMENT '飞书 bot id',
  `chat_id` VARCHAR(128) NOT NULL COMMENT '群 ID (oc_xxx)',
  `thread_id` VARCHAR(64) NOT NULL COMMENT '话题 ID (omt_xxx)',
  `root_message_id` VARCHAR(64) NOT NULL COMMENT '话题根消息 message_id(reply API 的目标)',
  `session_id` BIGINT NOT NULL COMMENT '归属的 mao 会话 id',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_feishu_thread_session` (`app_id`, `thread_id`),
  KEY `idx_feishu_thread_session_chat` (`app_id`, `chat_id`),
  KEY `idx_feishu_thread_session_session` (`app_id`, `session_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '飞书话题→会话映射';
```

#### 3.1.2 `feishu_group_message_log` 新增 `thread_id` 列

```sql
ALTER TABLE `feishu_group_message_log` ADD COLUMN `thread_id` VARCHAR(64) NULL COMMENT '话题 ID (omt_xxx)';
ALTER TABLE `feishu_group_message_log` ADD KEY `idx_group_msg_thread` (`app_id`, `chat_id`, `thread_id`);
```

### 3.2 模块改动

| 模块 | 改动 |
|---|---|
| `event-normalizer.ts` | 提取 `thread_id` → `FeishuNormalizedMessage.threadId` |
| `types.ts` | `FeishuNormalizedMessage` 新增 `threadId?: string \| null`;`FeishuInboundContext` 继承自动获得 |
| `message.repository.ts` | 新增 `recordThreadSession`、`findThreadSession`;`appendGroupMessage` 写入 `threadId`;`listGroupMessages` 支持 `threadId` 过滤 |
| `message.service.ts` | 新增 `getOrCreateThreadSession`、`findThreadSession`;`buildGroupContext` 按 `threadId` 过滤 |
| `agent-inbound-handler.ts` | `onGroupMessage` 增加话题分支:`threadId` 存在时走话题路径(查映射→建会话→记录映射→执行) |
| `inbound-processor.ts` | 群聊路径:`threadId` 存在且命中话题会话映射时,跳过 `isBotMentioned` 门禁(话题内消息视为触发);`resolveQuoted` 增加 `parent_id === root_id` 时不注入的守卫 |
| `create-app.ts` | 装配话题会话控制适配器;`sendFeishuText` 群聊路径保持 reply(自动落入话题);`recordGroupMessage` 传入 `threadId` |
| `monitor.service.ts` | 注册 `im.chat.updated_v1` 事件处理器(可选,本期实现基础版) |

### 3.3 入站流程

```
群聊消息到达(chatType === 'group')
  │
  ├─ threadId 为空 → 走现有 onGroupMessage 逻辑(零改动)
  │
  └─ threadId 非空 → 话题路径:
       │
       ├─ 1. 落群消息日志(含 threadId)
       │
       ├─ 2. 查 feishu_thread_session 映射
       │     ├─ 命中 → 取已有 session
       │     └─ 未命中 → 检查是否为话题根消息(parentId 为空)
       │           ├─ 是(用户发起新话题)→ 创建新 session + 记录映射
       │           └─ 否(话题内回复但映射缺失,如上线前消息)→ 降级:按现有群逻辑处理
       │
       ├─ 3. @门禁判定:
       │     └─ threadId 存在 → 跳过 isBotMentioned 检查(话题内消息视为触发)
       │        (未绑定用户仍走未绑定引导逻辑)
       │
       ├─ 4. 构建上下文(groupContext 按 threadId 过滤本话题内消息)
       │
       ├─ 5. 引用注入守卫:
       │     ├─ parentId == rootId 或 parentId == thread 根消息 id → 不注入(话题自动带的 parent_id)
       │     └─ parentId ≠ rootId → 用户显式引用了话题内某条回复 → 注入
       │
       └─ 6. 执行(复用现有 busy/队列/进度卡片逻辑,按 sessionId 粒度互斥)
```

### 3.4 `parent_id` 误判修复

这是最关键的修复点。当前 `resolveQuoted` 只检查 `parentId != null`:

```ts
// inbound-processor.ts 现状(简化)
private async resolveQuoted(accountId: string, event: FeishuNormalizedMessage): Promise<string | undefined> {
  if (event.parentId == null || event.parentId === '') return undefined;
  // ... 直接解析 parentId 对应的消息内容
}
```

**修复**:在话题场景下,增加守卫——`parentId === rootId` 时不注入。

```ts
// 修复后
private async resolveQuoted(accountId: string, event: FeishuNormalizedMessage): Promise<string | undefined> {
  if (event.parentId == null || event.parentId === '') return undefined;
  // 话题内回复:parent_id 指向话题根,非用户主动引用,跳过注入。
  // 仅当 parent_id ≠ root_id 时才是显式引用了话题内某条回复。
  if (event.threadId != null && event.parentId === event.rootId) return undefined;
  // ... 原有解析逻辑
}
```

注意:对于**非话题群**(纯对话群)中的普通引用回复,`rootId` 通常等于 `parentId`(引用的那条消息就是回复树的根)。因此守卫条件必须包含 `event.threadId != null`,避免误伤非话题场景。

### 3.5 上下文注入策略

话题会话的上下文构建与现有群聊不同:

| 维度 | 现有群聊 | 话题会话 |
|---|---|---|
| 上下文来源 | `feishu_group_message_log` 中该群的最近消息 | 同表但按 `thread_id` 过滤,仅取本话题内消息 |
| 增量水位线 | `feishu_chat.last_context_log_id`(群级) | 复用 `feishu_chat.last_context_log_id`(群级)——话题内消息仍走群日志,水位线按群推进 |
| 溢出摘要 | 按群过滤 | 按 `thread_id` 过滤 |
| 消息格式 | `[HH:mm] 发送人:内容` | 同格式,但只含本话题消息 |

**实现**:`listGroupMessages` 增加可选 `threadId` 参数。话题路径传 `threadId`,非话题路径不传(行为不变)。`buildGroupContext` 根据 context 中的 `threadId` 决定是否传入过滤参数。

### 3.6 出站回复策略

| 场景 | 发送方式 | 说明 |
|---|---|---|
| 话题内回复 | `message.reply({ message_id: rootMessageId })` | 回复话题根消息,自动落入话题。**无需 `reply_in_thread` 参数**——在话题群中,回复话题根消息即自动在话题内 |
| 非话题群回复 | `message.reply({ message_id: triggerMessageId })` | 现有逻辑不变 |
| 进度卡片/排队卡片 | `message.create({ receive_id: chatId })` 或 reply | 现有逻辑不变,卡片落在群中;话题群中用户看到卡片在话题外,可接受 |

**关键**:话题群中回复话题根消息时,飞书自动将回复归入该话题,不需要额外的 `thread_id` 参数。`sendFeishuText` 现有的群聊 reply 分支(`message.reply({ path: { message_id: event.messageId } })`)在话题场景下需要改为 reply **根消息**而非触发消息——因为话题内所有回复都挂在根消息下。

具体:话题路径的 `sendFeishuText` 应使用 `rootMessageId`(来自 `feishu_thread_session` 映射)而非 `event.messageId`。

### 3.7 群模式变更感知

注册 `im.chat.updated_v1` 事件(`monitor.service.ts`):

```ts
'im.chat.updated_v1': async (data: unknown) => {
  const event = normalizeFeishuChatUpdatedEvent(data);
  if (event == null) return;
  // 更新本地缓存(群聊模式 / 消息格式)
  chatModeCache.set(event.chatId, {
    chatMode: event.afterChange.chatMode,
    groupMessageType: event.afterChange.groupMessageType,
  });
  // 若从 thread 切回 chat:话题映射保留(会话仍在),后续非话题消息走现有逻辑
  // 若从 chat 切到 thread:后续消息若带 threadId 走话题路径
}
```

本期实现基础版:仅更新缓存 + 日志。缓存用于 `isBotMentioned` 门禁的辅助判断(话题格式群中可放宽门禁),但核心路由逻辑以事件中 `thread_id` 是否存在为准,不依赖缓存。

## 四、详细设计

### 4.1 类型扩展

```ts
// types.ts
export interface FeishuNormalizedMessage {
  // ... 现有字段
  /** 话题 ID (omt_xxx);非话题消息为 null/undefined。 */
  threadId?: string | null;
}
```

### 4.2 事件归一化

```ts
// event-normalizer.ts
parentId: firstString(message.parent_id, event.parent_id) ?? null,
rootId: firstString(message.root_id, event.root_id) ?? null,
threadId: firstString(message.thread_id, event.thread_id) ?? null,  // 新增
```

### 4.3 仓储层

```ts
// message.repository.ts 接口新增
export interface FeishuMessageRepository {
  // ... 现有方法

  /** 记录话题→会话映射(INSERT IGNORE 防重)。 */
  recordThreadSession(params: {
    appId: string; chatId: string; threadId: string;
    rootMessageId: string; sessionId: number;
  }): Promise<void>;

  /** 按话题 ID 查归属会话。 */
  findThreadSession(appId: string, threadId: string): Promise<{
    sessionId: number; rootMessageId: string; chatId: string;
  } | null>;

  /** 群消息日志按话题过滤(threadId 为 null 时不传此条件)。 */
  listGroupMessages(appId: string, chatId: string, limit: number, maxMinutes?: number, threadId?: string | null): Promise<FeishuGroupMessage[]>;
}
```

`appendGroupMessage` 的 `FeishuGroupMessage` 接口新增 `threadId?: string | null` 字段,INSERT 时写入。

### 4.4 服务层

```ts
// message.service.ts

/** 获取或创建话题→会话映射。映射存在返回已有;不存在创建新会话并记录映射。 */
async getOrCreateThreadSession(
  accountId: string, context: FeishuInboundContext,
): Promise<{ sessionId: number; rootMessageId: string; workspace: string | null } | null> {
  if (context.threadId == null || context.chatId == null) return null;
  const existing = await this.repository.findThreadSession(accountId, context.threadId);
  if (existing != null) return { sessionId: existing.sessionId, rootMessageId: existing.rootMessageId, workspace: null };
  // 仅话题根消息触发创建(parentId 为空即根消息,飞书根消息无回复目标)
  // 话题内回复但映射缺失(上线前消息):降级不创建,返回 null 让调用方走现有逻辑
  const isRoot = context.parentId == null || context.parentId === '';
  if (!isRoot) return null;
  const session = await this.sessionFactory.create(accountId, context);
  await this.repository.recordThreadSession({
    appId: accountId, chatId: context.chatId, threadId: context.threadId,
    rootMessageId: context.messageId!, sessionId: session.sessionId,
  });
  return { sessionId: session.sessionId, rootMessageId: context.messageId!, workspace: session.workspace };
}
```

### 4.5 入站处理器

```ts
// agent-inbound-handler.ts

/** 群聊入站:话题分支 + 现有逻辑。 */
private async onGroupMessage(context: FeishuInboundContext): Promise<FeishuReply | null> {
  // 话题路径:threadId 存在且话题会话映射可解析
  if (context.threadId != null && this.options.threadSessionControl != null) {
    const threadSession = await this.options.threadSessionControl.getOrCreateSession(context.accountId, context);
    if (threadSession != null) {
      return this.executeWithSession(threadSession.sessionId, context);
    }
    // 映射未命中且非话题根消息:降级走现有群逻辑
  }
  // 现有逻辑不变
  return this.executeWithSession(
    (await this.options.sessionService.getOrCreateSession(context.accountId, context)).id,
    context,
  );
}
```

`executeWithSession` 为现有 `onGroupMessage` 中「取会话 → buildMessage → busy检查/入队/执行」逻辑的提取,避免重复。

### 4.6 上下文过滤

```ts
// message.service.ts - buildGroupContext
async buildGroupContext(accountId: string, context: FeishuInboundContext): Promise<FeishuGroupContext> {
  const conversation = await this.getOrCreateGroup(accountId, context);
  const messages = await this.repository.listGroupMessages(
    accountId, context.chatId!, this.contextWindow, this.maxMinutes,
    context.threadId ?? null,  // 话题路径传 threadId 过滤
  );
  // ... 后续逻辑不变
}
```

## 五、实现步骤

1. **迁移脚本**:`V114__feishu_thread_session.sql`(新表 + `feishu_group_message_log.thread_id` 列)。
2. **类型与归一化**:`types.ts` 新增 `threadId`;`event-normalizer.ts` 提取 `thread_id`。
3. **仓储层**:`message.repository.ts` 新增 `recordThreadSession` / `findThreadSession`;`appendGroupMessage` 写入 `threadId`;`listGroupMessages` 支持过滤。
4. **服务层**:`message.service.ts` 新增 `getOrCreateThreadSession`;`buildGroupContext` 支持 `threadId` 过滤。
5. **入站处理器**:`agent-inbound-handler.ts` 增加话题分支;提取 `executeWithSession` 公共方法;新增 `FeishuThreadSessionControl` 端口。
6. **引用守卫**:`inbound-processor.ts` 的 `resolveQuoted` 增加 `threadId && parentId === rootId` 守卫。
7. **群消息触发放宽**:`inbound-processor.ts` 群聊路径:`threadId` 存在且话题映射命中时跳过 `isBotMentioned` 检查。
8. **出站回复**:`create-app.ts` 的 `sendFeishuText` 群聊路径:话题场景使用 `rootMessageId` 回复。
9. **装配层**:`create-app.ts` 实现 `FeishuThreadSessionControl` 适配器。
10. **群模式感知(可选)**:`monitor.service.ts` 注册 `im.chat.updated_v1`。
11. **测试**:单测 + 回归(见第六节)。
12. **文档**:CHANGELOG;`skills/mao-cli/SKILL.md` 补充话题群用法。

## 六、测试计划

### 新增单测

- `event-normalizer.spec.ts`:
  - 话题消息提取 `threadId`;
  - 非话题消息 `threadId` 为 null。

- `agent-inbound-handler.spec.ts`:
  - 话题根消息触发:创建新 session、记录映射、正常执行;
  - 话题内回复:命中映射、路由到已有 session;
  - 话题内回复但映射缺失(非根消息):降级走现有群逻辑;
  - 非话题群消息:`threadId` 为空,走现有逻辑(零行为变化)。

- `inbound-processor.spec.ts`:
  - `resolveQuoted` 守卫:`threadId + parentId === rootId` → 不注入引用;
  - `resolveQuoted` 守卫:`threadId + parentId ≠ rootId` → 正常注入引用;
  - `resolveQuoted` 守卫:无 `threadId`(非话题)→ 现有行为不变;
  - 话题消息无 @ 但映射命中 → 跳过 mention 门禁,正常触发;
  - 话题消息无 @ 且映射未命中(非根消息)→ 不触发。

- `message.service.spec.ts`:
  - `getOrCreateThreadSession`:映射存在返回已有;不存在且为根消息创建新会话;不存在且非根消息返回 null;
  - `buildGroupContext` 按 `threadId` 过滤。

### 回归

- `cd backend-ts && npm test`(feishu 全部 spec 全绿);
- 非话题群行为零变化(现有用例不改动断言)。

## 七、落地清单

| # | 交付物 | 文件 |
|---|---|---|
| 1 | 迁移脚本 | `backend-ts/db/migration/V114__feishu_thread_session.sql` |
| 2 | 类型定义 | `backend-ts/src/feishu/types.ts` |
| 3 | 事件归一化 | `backend-ts/src/feishu/event-normalizer.ts` |
| 4 | 映射表仓储 | `backend-ts/src/feishu/message.repository.ts` |
| 5 | 话题会话服务 | `backend-ts/src/feishu/message.service.ts` |
| 6 | 入站话题分支 | `backend-ts/src/feishu/agent-inbound-handler.ts` |
| 7 | 引用守卫 + mention 放宽 | `backend-ts/src/feishu/inbound-processor.ts` |
| 8 | 装配层 | `backend-ts/src/create-app.ts` |
| 9 | 单测 | `agent-inbound-handler.spec.ts`、`inbound-processor.spec.ts`、`event-normalizer.spec.ts`、`message.service.spec.ts` |
| 10 | CHANGELOG | 根 `CHANGELOG.md` |
| 11 | 用户文档 | `skills/mao-cli/SKILL.md` |

## 八、风险与对策

| 风险 | 对策 |
|---|---|
| 话题根消息 `parentId` 为空(飞书根消息无回复目标) | 创建映射条件:`parentId == null \|\| parentId === ''`;以 `threadId` 存在为主要判据 |
| 上线前话题的回复消息(无映射)走现有群逻辑,可能进入群级单会话 | 降级行为可接受:该消息进入群会话而非话题会话,用户下次在话题发消息时话题会话被创建 |
| `feishu_group_message_log` 存量行 `thread_id` 为 null | 话题路径 `listGroupMessages` 过滤 `thread_id = ?` 时存量行自然不命中;新消息从上线起记录 `thread_id` |
| 进度卡片/排队卡片发送到群(非话题内) | 本期接受:卡片在群中可见,不影响功能;后续可通过 reply 话题根消息让卡片落入话题 |
| 话题会话数无上限导致 session 膨胀 | 与私聊多会话同策略:不做上限,依赖用户自行管理 |
| 群模式从 thread 切回 chat 后,已有话题会话的后续消息不再带 `threadId` | 已有话题会话保留(通过 Mao 桌面端继续对话);飞书侧话题内的后续回复仍会带 `threadId`(飞书侧话题结构不因群模式切换而解散) |

## 九、与私聊多会话的关系

| 维度 | 私聊多会话 | 话题群多会话 |
|---|---|---|
| 新建触发 | `---` 指令 | 发起新话题(自动) |
| 切换机制 | 引用消息 → 映射表定位 | 在话题内发消息 → `thread_id` 定位 |
| 活跃指针 | `feishu_chat.session_id` | 不需要(`thread_id` 即指针) |
| 消息映射表 | `feishu_p2p_message` | `feishu_thread_session` |
| 工作区 | 共享(`private-{userId}`) | 共享(`{chatId}`) |
| 上下文注入 | 全量注入 | 按 `thread_id` 过滤 |

两套机制独立运行,互不影响。私聊路径 `chatType === 'p2p'` 走 p2p 分支;话题群路径 `chatType === 'group' && threadId != null` 走话题分支;其余群聊走现有逻辑。