# pi-lark-notify

pi 主对话 ⇄ 飞书 双向桥。人在外面，用手机飞书就能远程指挥 pi 连续干活。

```
pi 完成任务 → 飞书收到通知（含完整回复原文）
           → 你长按通知回复"顺便把测试也跑了"
           → 该 pi 会话自动收到这句话并继续执行
           → 完成后又收到通知 → ……
```

## 功能

- **下行通知**：主对话每次彻底完成（`agent_settled`，自动重试/压缩不算）时，把项目名、完成时间、最后一条回复**完整原文**推送到飞书私聊或群聊
- **上行回注**：监听飞书 `im.message.receive_v1` 事件（WebSocket 长连接，**无需公网 webhook**），把你的回复通过 `pi.sendUserMessage` 注入对应会话继续执行（会话忙时自动排队）
- **精确路由**：回复某条通知 → 注入发出该通知的会话（按 message_id 匹配，多窗口不串话）；直接发消息（非回复）→ 忽略，不注入任何会话
- **天然排除子 agent**：subagent / workflow 运行在独立进程，不会触发主会话事件
- **安全边界**：只接受指定用户（`userId`）的单聊消息，他人给机器人发消息不会触发任何动作
- **多会话协调**：跨会话共享状态文件（目录锁保护），`/reload` 后旧实例残留的事件消费者自动清理
- **单例消费者**：pi-web 是单进程多 session，事件流是单例资源（一个 bot、一条连接），因此全局只起一个 `lark-cli event consume` 子进程，各 session 通过 `subscribe` 注册回调，事件到达后广播。不再「每 session 一个 consumer」造成进程堆积
- **崩溃自愈**：正常退出时 consumer 随最后一个订阅者优雅停止；仅当 pi 进程崩溃、consumer 变成孤儿时，新进程在 session_start 延迟 10 秒扫描本机所有 `lark-cli event consume` 进程并清理

## 依赖

| 依赖 | 说明 | 安装 |
|---|---|---|
| `@amaster.ai/pi-lark` | 提供 lark-cli 自动安装与凭证初始化 | `pi install npm:@amaster.ai/pi-lark` |
| `@larksuite/cli`（lark-cli） | 飞书官方 CLI，发消息/事件监听都由它执行 | pi-lark 在会话启动时自动安装到 `~/.lark-cli`，无需手动 |

## 新机器落地（完整步骤）

### 1. 安装两个包

```bash
pi install npm:@amaster.ai/pi-lark
pi install npm:pi-lark-notify
```

> **无需手动拷贝 `~/.lark-cli`**：配好 settings.json（下一步）后，首次启动会话时 pi-lark 会自动完成两件事——① 检测不到 lark-cli 时自动 `npm install` 到 `~/.lark-cli`（需联网，约十几秒）；② 将 `pi-lark` 配置写入 `~/.lark-cli/config.json` 凭证文件（0600 权限）。

### 2. 飞书自建应用（可多台机器复用同一个）

在 [飞书开放平台](https://open.feishu.cn/app) 创建企业自建应用，或复用已有的：

1. **开启机器人能力**（应用能力 → 机器人）
2. **开通权限**（权限管理）：
   - `im:message`（获取与发送单聊、群组消息）
   - `im:message:send_as_bot`（以应用的身份发消息）
   - `contact:user.id:readonly`（可选，用于通过手机号/邮箱查 open_id）
3. **创建版本并发布**（版本管理与发布）——权限必须发布后才生效

> 事件接收走 WebSocket 长连接，**不需要**在控制台配置事件订阅，也不需要公网回调地址。

### 3. 配置 `~/.pi/agent/settings.json`

```json
{
  "pi-lark": {
    "appId": "cli_xxx",
    "appSecret": "${LARK_APP_SECRET}",
    "domain": "feishu"
  },
  "lark-notify": {
    "enabled": true,
    "userId": "ou_xxx"
  }
}
```

- `appSecret` 支持 `${ENV_VAR}` 环境变量语法，避免明文
- **复用同一个应用时 appId/appSecret/open_id 全部不变**（open_id 是"应用 × 用户"维度，与机器无关），配置可直接照抄
- 不知道自己的 open_id？配好凭证后执行：

```bash
lark-cli api POST "/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id" \
  --data '{"mobiles":["你的手机号"]}' --as bot
```

### 4. 生效与验证

1. pi 里执行 `/reload`（或重启会话）
2. 随便聊一句 → 对话完成后飞书应收到通知
3. **长按通知 → 回复** → 该会话应自动收到 `【飞书】...` 并继续执行，同时飞书收到"✅ 已转达"回执

## 配置项（`lark-notify` 一节）

| 键 | 默认 | 说明 |
|---|---|---|
| `enabled` | `true` | 总开关（下行 + 上行） |
| `userId` | — | 私聊接收人 open_id（与 `chatId` 二选一，优先） |
| `chatId` | — | 群聊 chat_id（需先把机器人拉进群） |
| `replyEnabled` | `true` | 上行回注开关（关闭则只发通知） |
| `receipt` | `true` | 转达后回执一条"已转达 ✅" |

全局配置在 `~/.pi/agent/settings.json`，项目级可用 `<项目>/.pi/settings.json` 覆盖（例如不同项目发给不同群）。

## 注意事项

### 多机器同时使用

- **回复通知**的路由是精确的（按 message_id 匹配），多机并存也安全
- **直接发消息**（不回复通知）会被忽略，不注入任何会话。多机场景请养成**回复具体通知**的习惯

### 装了 hermes 的机器（可选）

lark-cli 检测到 `HERMES_HOME` 等环境变量会误判运行环境并报 `config bind` 错误。本扩展内部的调用已自动清洗环境变量，不受影响；但**手动或让 agent 使用 lark 技能**时，需要 lark-cli 包装脚本。该脚本在 npm 包中不包含，从 git 仓库获取：

```bash
# 从 git 仓库检出后，拷到 PATH 靠前的目录（如 ~/bin）
cp bin/lark-cli bin/lark-cli.cmd ~/bin/   # Windows git-bash + cmd 双版本
# macOS/Linux 只需 cp bin/lark-cli ~/bin/
```

## 平台支持

- 理论支持 **Windows / macOS / Linux** 三平台（lark-cli 依赖 `@larksuite/cli` 声明 `os: ['darwin', 'linux', 'win32']`）
- 进程枚举跨平台：Windows 走 PowerShell `Get-CimInstance`，macOS/Linux 走 `ps axww`
- **验证状态**：Windows 已充分验证；macOS/Linux 的主流程（发消息、事件监听）应可用，但孤儿 consumer 自愈逻辑未经实测，若 `ps` 不可用或输出格式异常会静默跳过（不影响主功能，仅失去自愈）
- 若你在 macOS/Linux 上遇到问题，欢迎反馈

## 工作原理（简述）

```
session_start ─→ getLarkClient()（进程级单例）
                     │ subscribe(handleEvent)
                     ▼
            唯一 `lark-cli event consume` 子进程（NDJSON 事件流，崩溃自动重启，退避 3s→60s）
                     │ 事件广播给所有订阅者
agent_settled ─→ client.sendMessage() ─→ 记录 通知message_id → 本会话
事件到达 ─→ 过滤(本人/单聊/去重/防过期) ─→ 路由(仅 reply_to 精确匹配，非回复忽略)
           ─→ 跨会话认领(ClaimDedup 状态文件目录锁) ─→ pi.sendUserMessage(followUp)
session_shutdown ─→ unsubscribe()；最后一个订阅者退出时停掉 consumer 进程
```

- 状态拆分（各自独立文件 + 目录锁，15s 死锁自动破除）：
  - `lark-notify-sessions.json` — SessionRegistry：崩溃残留 session 条目清理（单例 consumer 后不再追踪 consumerPid）
  - `lark-notify-router.json` — NotificationRouter：通知 message_id → 会话映射，查回复归属
  - `lark-notify-claims.json` — ClaimDedup：跨会话事件认领去重，先到先得
- 会话身份：每个扩展实例随机 sid，注入前以 sid 认领事件，杜绝多窗口重复注入

## 文件结构

```
pi-lark-notify/
├── package.json            # pi 包清单（extensions 声明）
├── extensions/
│   └── lark-notify.ts      # 扩展本体（单文件，零依赖）
├── bin/                    # 仅 git 仓库包含，npm 包不含
│   ├── lark-cli
│   └── lark-cli.cmd
├── LICENSE
└── README.md
```

## 卸载

```bash
pi remove pi-lark-notify
# 如不再需要 lark 能力：pi remove npm:@amaster.ai/pi-lark
```

删除 settings.json 中的 `pi-lark` / `lark-notify` 两节即可彻底清理。
