# pi-feishu

一个独立的 [Pi](https://github.com/earendil-works/pi-mono) 飞书扩展。它通过飞书官方 Node SDK 的 WebSocket 长连接，把飞书私聊和已绑定群聊的文本转发到对应的 Pi 会话，并以持续更新的交互卡片展示 Pi 的回复和执行状态。

## 设计边界

当前版本聚焦一条可靠的私聊 + 项目群链路：

- 处理飞书私聊（`p2p`）文本消息，以及已绑定群聊中的文本消息
- 首次启动生成一次性绑定码，只允许一个 Owner；绑定码只能在私聊中使用
- Owner 可以把授权成员加入 allowlist（`/allow @成员`），授权成员在私聊和已绑定群里与 Owner 权限一致
- 收到消息后立刻在原消息上添加“思考中”表情作为已读回执，回复送达后自动移除
- 以 `/` 开头的消息走远程命令快速通道，不排队、不进入 LLM 上下文
- 私聊消息进入主 Pi 会话；每个绑定的群聊拥有独立 Pi session，群之间、群与主对话之间不共享上下文
- 群消息会带上说话人上下文（如 `[飞书群聊] 张三：…`）再发给 Pi；bot 识别 @ 他人、@ 自己
- 消息需要排队时发送“排队中（第 N 位）”提示，轮到处理时自动撤回；队列变化时提示自动改写为新位数
- Owner 在排队中的消息上点 ❌（`CrossMark`）表情即可取消该消息
- 未绑定的群里被 @ 时，bot 会回复绑定引导而不是沉默；非授权成员的普通群消息完全不会触发回复
- Bot 被拉进新群时，会私聊 Owner 发送绑定引导和群聊权限体检结果；启动成功后也会私聊 Owner 发送欢迎语和权限体检
- Assistant 文本通过 Pi 的 `message_update` 增量渲染到同一张飞书卡片
- 工具执行时仅显示安全的状态摘要，不展示参数、命令、文件内容或工具输出
- SDK 以 500ms / 120 字符节流卡片更新；完成、失败、中止都会收尾卡片，失败原因（脱敏后）会写进回复
- 消息串行处理（Pi 是单会话进程，跨群并行会互相打断），按飞书 `message_id` 去重且受理记录落盘（48 小时窗口），重启后飞书重推不会重复执行
- 长连接由跨会话的全局控制器保管：本地 `/new`、远程 `/new` 等会话切换不会断开；`/feishu stop`、`/feishu logout` 或 Pi 进程退出时释放
- 凭据优先从环境变量读取，也可保存到本地凭据文件

支持在飞书私聊中请求创建群聊；扩展会把 Owner 拉入新群、发送欢迎消息，并在绑定群与私聊中协同使用。图片、文件、卡片、语音、多用户并发执行、进程级沙箱仍不支持。Pi 本身仍拥有当前本地进程的权限。

## 环境要求

- Node.js 22.19 或更高版本
- Pi 0.83.0 或兼容版本
- 一个启用了机器人能力的飞书企业自建应用

## 飞书后台配置

在[飞书开放平台](https://open.feishu.cn/app)创建企业自建应用，然后完成以下配置：

1. 在“添加应用能力”中启用机器人。
2. 在“权限管理”中申请：
   - `im:message.p2p_msg:readonly`：接收私聊消息
   - `im:message.group_at_msg:readonly`：接收群内 @机器人 的消息（**群聊必需，缺了群里 @bot 会完全没有响应**）
   - `im:message:send_as_bot`：以机器人身份回复
   - `im:chat`：创建群聊并邀请 Owner、读取群信息
   - `im:message:update`：持续更新机器人发出的交互卡片
   - `application:application:self_manage`：启动/建群后读取应用已授权 scope，用于自动提示缺失权限
   - 表情回复相关权限：在原消息上添加/移除“思考中”已读回执
3. 在“事件与回调”中选择“使用长连接接收事件”。
4. 添加事件 `im.message.receive_v1`；建议同时添加 `im.chat.member.bot.added_v1`（被拉进新群时私聊你发送绑定引导）。
5. 创建并发布一个应用版本，使权限和事件订阅在企业内生效。

这个扩展不需要公网回调地址，也不需要加密密钥或 Verification Token。若未授予 `im:message:update`，扩展仍可工作，但会降级为最终文本一次性回复。若未授予表情回复权限，扩展同样可工作，只是原消息上不会出现已读表情。若希望已绑定项目群里无需 @ 机器人即可触发，需要额外申请敏感权限 `im:message.group_msg`（需人工审核 1-3 天）；未开通时请在群里使用 `@机器人 你的问题`。

## 安装

在项目目录安装依赖：

```powershell
cd D:\ai_study\pi-feishu
npm install --ignore-scripts
```

开发时可以仅为本次启动加载：

```powershell
pi -e D:\ai_study\pi-feishu
```

也可以把本地包注册到 Pi：

```powershell
pi install D:\ai_study\pi-feishu
pi
```

发布版本可直接从 npm 安装：

```powershell
pi install npm:@hwj123weijian/pi-feishu
pi
```

## 配置与首次绑定

推荐用环境变量提供敏感凭据：

```powershell
$env:FEISHU_APP_ID = "cli_xxxxxxxxxxxxx"
$env:FEISHU_APP_SECRET = "xxxxxxxxxxxxxxxx"
pi -e D:\ai_study\pi-feishu
```

进入 Pi 后依次执行：

```text
/feishu setup
/feishu start
```

`start` 会在本地 Pi 中显示六位一次性绑定码。若已配置凭据但启动 Pi 后尚未连接，扩展会在会话开始时提示执行 `/feishu start`；如需 Pi 启动后自动连接，可设置 `PI_FEISHU_AUTO_START=1`（或 `FEISHU_AUTO_START=1`）。使用计划作为 Owner 的飞书账号私聊机器人：

```text
/bind 123456
```

绑定成功后，直接私聊机器人即可驱动当前 Pi 会话。原消息会立刻出现“思考中”表情（已读回执），处理过程中先显示“正在思考”，随后持续更新正文；Pi 调用工具时卡片会显示“正在执行工具”，最终状态变为“已完成”，同时已读表情自动移除。

也支持手动参数：

```text
/feishu setup cli_xxxxxxxxxxxxx xxxxxxxxxxxxxxxx
```

这种方式可能让 App Secret 出现在终端历史中，因此环境变量更合适。验证成功后，凭据默认保存到：

```text
~/.pi/agent/feishu/credentials.json
```

文件通过临时文件原子替换写入；在支持 POSIX 权限的平台上会限制为 `0600`。

## 命令

| 命令 | 作用 |
| --- | --- |
| `/feishu` | 显示帮助和当前状态 |
| `/feishu setup [appId appSecret]` | 验证并保存飞书应用凭据 |
| `/feishu start` | 建立 WebSocket 长连接并显示绑定码 |
| `/feishu stop` | 停止长连接，保留凭据与 Owner |
| `/feishu status` | 查看配置、连接、Owner 和队列状态 |
| `/feishu logout` | 停止连接并清除本地凭据和 Owner |

环境变量优先于凭据文件。如果环境变量中的 App ID 与已保存的 App ID 不同，旧 Owner 绑定不会被继承。

### 飞书远程命令

在飞书私聊或已绑定的群里直接发送以下命令，会走高优先级快速通道：不进入消息队列排队，也不会作为提问发给 Pi。

| 命令 | 作用 |
| --- | --- |
| `/help` | 显示远程命令帮助 |
| `/new` | 新建 Pi 会话；在群里发送时重置该群绑定的会话，不影响主会话 |
| `/stop` | 中止当前任务，并跳过队列中剩余消息 |
| `/status` | 查看连接、队列、模型与上下文占用 |
| `/compact` | 压缩当前会话上下文 |
| `/thinking` | 查看思考档位，或切换：`/thinking off|minimal|low|medium|high|xhigh|max` |
| `/model` | 查看当前模型，或模糊匹配切换：`/model <模型ID或名称>` |
| `/bind` | （群聊）把当前群绑定到 Pi，创建独立会话；仅 Owner |
| `/allow` | （仅 Owner）授权成员：`/allow @成员`；无参数时列出授权列表 |
| `/deny` | （仅 Owner）移除授权：`/deny @成员` |
| `/allowlist` | （仅 Owner）查看当前授权成员 |

在飞书私聊中也可直接说“帮我拉个 XX 群”或“创建飞书群 XX”。机器人会创建群聊、邀请已绑定的 Owner 并发送欢迎消息；建群后会私聊 Owner 做一次权限体检（含免 @ 权限申请链接）。已创建群聊和 session 绑定保存在本地凭据文件中，Pi 重启后仍可继续使用。

### 群聊使用

- **绑定已有群**：把机器人加进现有项目群后，Owner 在群里发送 `/bind` 即可绑定；之后该群获得独立 Pi session，与其他群和主对话互不串上下文。机器人被拉进新群时也会私聊 Owner 提醒绑定。
- **独立群引擎**：群消息由独立的 `pi -p` 子进程处理（不再复用你本地 TUI 的 Pi 会话）——每个群有独立的会话历史（`~/.pi/agent/feishu/group-sessions/<chatId>.jsonl`），互不串上下文，不同群可以同时对话，也不会打断或依赖你本地终端里正在跑的任务。
- **绑定项目目录**（推荐）：发送 `/bind /path/to/project` 把群锚定到项目目录——群子进程直接在该目录下启动，bot 的读写、bash 都在这个项目里执行；Owner 显式绑定的目录会自动加入 Pi 的项目信任。私聊里说“帮我拉个群 /path/to/project”也可以在建群时一并锚定。
- **触发方式**：默认在群里 `@机器人 你的问题`。飞书只向 bot 推送 @bot 的群消息；普通消息直接触发需要敏感权限 `im:message.group_msg`。
- **授权成员**：默认只有 Owner 能使用。Owner 私聊发送 `/allow @张三` 后，张三即可在私聊和已绑定群里与机器人协作；`/deny @张三` 撤销。
- **分寸感**：未绑定的群里被 @ 时回复绑定引导；非授权成员被 @ 时回复一次“未授权”；其余群内消息完全静默，不会刷屏。
- **说话人上下文**：群消息发给 Pi 前会加上 `[飞书群聊] 说话人：` 前缀，@ 其他人会被改写成 `@名字`，模型知道在和谁对话。
- **群内 /new**：删除该群的会话文件，下一条群消息以全新历史开始。

已知限制：私聊仍驱动本地 TUI 所在的 Pi 进程（与本地终端共享引擎，排队提示如实显示位数；本地繁忙时私聊消息会等待，不会打断你）。同一群里任务运行中继续发消息会排队（群子进程按会话文件串行，保证对话顺序）。远程命令 `/model`、`/thinking`、`/compact` 作用于本地 Pi 进程，对群子进程不生效；群内 `/new` 用于重置群会话。

### 与本地使用共存

群聊走独立子进程后与本地使用完全解耦：你在终端 TUI 里跑任何任务都不会被飞书消息打断，也不需要先跑完才能处理群消息。私聊仍在本地 Pi 进程内驱动——本地繁忙时私聊消息会等待（收到提示“本地 Pi 正在执行任务”），超过 15 分钟则取消处理并通知你重发；若在本地 Pi 手动切换过会话，私聊路由的会话控制会过期一次：按提示在本地执行 `/feishu status` 刷新即可恢复。

群子进程从 PATH 中查找 `pi` 可执行文件；如不在 PATH 中，可设置 `PI_BIN` 环境变量指向 pi 的完整路径。

未知命令会返回提示，不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models（未配置时为全部已授权模型）；`/model` 无参数时展示当前模型。注意：任何以 `/` 开头的消息都会先被当作命令解析，想发给 Pi 的提问请勿以 `/` 开头。

### 取消排队中的消息

消息排队期间，Owner 直接在**自己的这条消息**上点 ❌（`CrossMark`）表情即可取消：排队提示与已读表情立即清理，消息不会发给 Pi，后面消息的排队提示自动前移。仅 Owner 的取消表情生效，已开工的消息不能取消（用 `/stop` 中止）。

飞书的“消息撤回”事件未被官方 Node SDK 的 channel 抽象透出（长连接也不允许挂第二条连接分流事件），因此这里以 ❌ 表情作为取消入口，效果等价于撤回后移出队列。

## 常见问题

### `setup` 或 `start` 连接失败

检查 App ID 和 App Secret 是否来自同一个应用，并确认应用版本已经发布。`setup` 会实际建立一次临时 WebSocket 连接来验证凭据，而不只是检查字符串格式。

### 机器人收不到私聊消息

确认已启用机器人能力、订阅 `im.message.receive_v1`、接收方式为长连接，并已发布包含这些配置的应用版本。

### 能收到消息但不能回复

确认已申请并发布 `im:message:send_as_bot` 权限。

### 只能收到最终文本，没有流式卡片

确认已申请并发布 `im:message:update`。卡片初始化或更新失败时，扩展会自动改为最终文本回复，避免用户没有任何反馈。

### 没有已读表情

确认已申请并发布表情回复相关权限。该权限缺失时扩展自动降级：消息照常收发，只是原消息上不会出现“思考中”表情。

### 群里 @机器人 没反应

按顺序排查：

1. 群聊接收权限：确认已申请并发布 `im:message.group_at_msg:readonly`（缺了这个飞书不会推送任何群消息事件）。
2. 应用版本：权限变更后需在权限管理页发布新版本才生效。
3. 群是否绑定：只有扩展创建的群和 `/bind` 绑定过的群会处理消息。在被 @ 而无响应的群里，让 Owner 发送 `/bind`；如果连 @ 后的引导都没出现，通常是第 1、2 步的问题。
4. 被 @ 的是否是机器人本身（@ 错了人不会触发）。
5. 用 `/feishu status` 查看缺失 scope 的自检结果。

### 群里必须 @ 才有响应

飞书默认只向 Bot 推送群内 @bot 消息。若希望绑定群内普通消息也触发，需要额外申请并发布敏感权限 `im:message.group_msg`。本扩展已关闭 SDK 侧的强制 @ 过滤；权限生效后，已绑定群内普通消息会直接进入 Pi。

### 远程 `/new` 提示暂不可用

远程会话切换依赖本地 Pi 提供的命令上下文。先在本地 Pi 执行任意 `/feishu` 子命令（例如 `/feishu status`），再从飞书发送 `/new`。切换完成后长连接自动延续，无需重新执行 `/feishu start`。

### Bot 提示“未授权”

该 Bot 已绑定其他 Owner。若要重新绑定，先在本地 Pi 执行 `/feishu logout`，再重新 `setup`、`start` 和 `/bind`。

### 重复消息

飞书事件可能重投。扩展按 `message_id` 去重，并把受理记录落盘到 `~/.pi/agent/feishu/processed-messages.json`（48 小时滚动窗口、上限 5000 条），Pi 重启后飞书重推的旧消息也不会再次驱动 Pi。

## 开发

```powershell
npm run check
npm test
npm run build
```

测试使用 Fake Gateway 和 Fake Agent，不需要真实飞书凭据，覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、排队提示改位与撤回、❌ 取消、`/stop` 按条跳过、去重（含落盘恢复）、已读表情回执、远程命令快速通道、群绑定（`/bind`）与未托管群引导、allowlist 授权与撤销、群聊说话人上下文注入、mention 元数据透传、botAdded 引导、思考档位与模型切换、Pi 增量文本、工具状态、流式卡片收尾、降级、错误脱敏和清理行为。

## 许可证

MIT
