# dsh-feishu-gateway

[![npm version](https://img.shields.io/npm/v/@kriskwok/dsh-feishu-gateway)](https://www.npmjs.com/package/@kriskwok/dsh-feishu-gateway)
[![License: MIT](https://img.shields.io/npm/l/@kriskwok/dsh-feishu-gateway)](LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/kriskwok/dsh-feishu-gateway)](https://github.com/kriskwok/dsh-feishu-gateway)

[English](README.md) | 中文

在**飞书（Feishu/Lark）**里与你的 **DeepSeek Harness（DSH）** agent 对话。

这是一个 DSH 插件 bundle：挂载飞书长连接监听器，每条飞书消息路由到**稳定的 DSH 会话**
（通过 `agents` 服务的 resume 恢复，多轮对话保持在同一个会话），agent 的答复以
Markdown 富文本（post 消息的 md 标签）回复。支持 `/new` 开启全新会话、
原生 **Typing 表情**处理中指示、长任务的**流式进度卡片**、权限审批与
`ask_user_question` 的**点击即答卡片**、以及主动推送。

## 功能

- 💬 **完整对话** — 飞书私聊 / 群聊 @机器人 → DSH agent → 回复。群聊中每个话题（thread）都是独立的 DSH 会话，该话题下的所有对话都保持在同一会话里，直到发 `/new`。
- 🔁 **会话保持** — 每个飞书会话（或群聊话题）对应一个 DSH 会话（`agents.resume` /
  `agents.create`）；发 `/new`（或"另起会话 / 新会话 / 重新开始 / 换个话题"）开启全新会话，
  群聊话题里发 `/new` 同样新开一个会话（该话题绑定新的 DSH 会话）。
- ⌨️ **原生 Typing 指示** — 处理期间机器人在你的消息上加一个 `Typing` 表情回复
  （同 [hermes-agent 的飞书网关](https://github.com/NousResearch/hermes-agent)），
  回答未结束就一直显示，失败时换成 `CrossMark`。默认不再发"思考中…"提示语。
- 🎞 **流式汇报** — 长任务持续汇报：一张实时交互卡片流式显示 agent 的
  **思考、工具调用、回答草稿**（`reporting.mode: 'stream'`，默认开启）；
  设为 `reporting.mode: 'final'` 则只显示最终结果。
- 🃏 **点击即答卡片** — 权限审批（`approval/request`，如沙箱提权）与模型的
  `ask_user_question` 工具会渲染成飞书交互卡片：点 **✅ 允许一次 / 🚫 拒绝**
  或选项按钮即可作答。点击后回调响应会瞬间把卡片替换为已处理态
  （按钮移除、显示结果）并弹出 toast 确认。
- ✍️ **Markdown 回复** — 用普通富文本（post）消息的 `md` 标签：粗体、行内代码、
  列表、链接原生渲染，无需卡片
- 🧩 **Web-only 交互围栏降级** — 模型输出的 `dsh-ui` 交互组件围栏（如 dsh-genui）
  只在 Web UI 渲染；飞书渠道会自动降级成一行可读提示（提取标题，注明"请在 Web UI
  查看"），不会出现裸 JSON 代码块
- 🤖 **完整 agent 能力** — DSH agent 自带模型与工具（bash、文件、子代理…），完全自主
- 📨 **主动推送** — 可选管理 HTTP API（`/api/push`），随时向用户/群推送文本、Markdown、卡片
- 🔌 **无需公网** — 飞书长连接，不需要回调地址
- 🗂 **持久化** — 飞书↔DSH 会话映射重启不丢

## 环境要求

- 已安装并构建的 DeepSeek Harness（`dsh` CLI），并配置好 `DEEPSEEK_API_KEY`
  （agent 直接使用 DSH 当前模型，无需另配）
- 一个飞书开放平台**企业自建应用**，已开启机器人能力（见下）

## 飞书应用配置

1. [飞书开放平台](https://open.feishu.cn/app) → 创建**企业自建应用**。
2. 开启**机器人**能力。
3. 开通权限：`im:message`、`im:message:send_as_bot`（如需读取消息内容再加
   `im:message:send_as_bot:readonly`），然后创建版本并发布。
4. 事件与回调 → 选择**使用长连接接收事件**，订阅 **`im.message.receive_v1`**（无需公网）。
   审批/问答卡片的**按钮点击**（`card.action.trigger`）也走同一条长连接，无需回调地址。
5. 在飞书客户端搜索应用名，添加机器人为联系人。

> `Typing` 表情与卡片按钮依赖机器人在会话内有消息交互权限（`im:message`）。
> 若表情接口被拒，网关会自动退回发送 `hintText` 提示语。

## 安装（作为 DSH 插件）

前提：本包已发布到 npm，且 `dsh` 命令可用。

推荐把网关**挂载到 web profile**：与 DSH Web UI 同进程运行，启动 Web UI
即同时启动飞书网关，两者共用同一个 DSH agent。也可以用独立 profile 运行
（见文末「备选」）。

方式一（推荐）：挂载到 web profile

web profile 是 DSH 的默认图形界面 profile（`dsh --profile web`）。

1. 编辑 `~/.dsh/profiles/web/package.json`，加入依赖与 bundle：

```json
{
  "name": "dsh-profile-web",
  "private": true,
  "dependencies": {
    "@kriskwok/dsh-feishu-gateway": "^0.2.0"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app",
        "@kriskwok/dsh-feishu-gateway"
      ]
    }
  }
}
```

2. 在 web profile 目录安装依赖：

```bash
cd ~/.dsh/profiles/web && pnpm install
```

3. 编辑 `~/.dsh/profiles/web/cordis.patch.yml`，填入飞书应用凭据：

```yaml
- id: feishu-gateway
  config:
    feishu:
      appId: cli_xxxxxxxxxxxxxxxx
      appSecret: xxxxxxxxxxxxxxxxxxxxxxxx
    http:
      port: 3100      # 可选管理 API
      token: your-token
```

4. 启动（或重启）web profile：

```bash
dsh --profile web
```

> 也可以直接运行本仓库的一键脚本：`./scripts/create-profile.sh`
> （默认挂载到 web profile；`--standalone` 则创建独立 feishu profile）。

### 备选：独立 feishu profile

若不想通过 Web UI 使用，可让网关在独立 profile 中运行：

```bash
mkdir -p ~/.dsh/profiles/feishu && cd ~/.dsh/profiles/feishu

cat > package.json <<'EOF'
{
  "name": "dsh-profile-feishu",
  "private": true,
  "dependencies": {
    "@kriskwok/dsh-feishu-gateway": "^0.2.0"
  },
  "dsh": {
    "profile": {
      "bundles": ["@deepseek-ai/dsh-base", "@kriskwok/dsh-feishu-gateway"]
    }
  }
}
EOF

cat > pnpm-workspace.yaml <<'EOF'
packages:
  - .
nodeLinker: hoisted
autoInstallPeers: false
EOF

pnpm install
# 再创建 ~/.dsh/profiles/feishu/cordis.patch.yml 填入应用凭据
dsh --profile feishu
```

## 配置项

所有配置都在 `feishu-gateway` 命名空间下（profile patch 行或 `~/.dsh/settings.yaml`）：

| 字段 | 默认 | 说明 |
|---|---|---|
| `feishu.appId` | — | 飞书应用 App ID（必填） |
| `feishu.appSecret` | — | 飞书应用 App Secret（必填） |
| `feishu.domain` | `feishu` | `feishu`（国内）/ `lark`（海外） |
| `feishu.botOpenId` | 空 | 可选；@ 识别可自动完成 |
| `feishu.replyMode` | `at` | 群聊策略：`at` 仅被 @ 回复 / `all` 全部回复。每个群聊话题（thread）是独立的 DSH 会话。 |
| `workspace` | `/root/Documents/DSH-Workspace` | agent 工作目录（会话也会自动挂到对应的 DSH 工作区，在 Web UI 里归入该工作区而非"未分组"）。私聊与群聊话题会话都固定在此工作区。 |
| `hintText` | `爸爸，我正在努力处理中……` | 兜底"处理中"文案（仅当 Typing 表情被禁用/不可用时） |
| `reporting.mode` | `stream` | `stream`=流式进度卡片；`final`=只显示最终结果 |
| `reporting.typingReaction` | `true` | 处理中显示原生 Typing 表情 |
| `reporting.showReasoning` | `true` | 卡片中流式显示模型思考 |
| `reporting.showToolCalls` | `true` | 卡片中流式显示工具调用 |
| `reporting.patchIntervalMs` | `1100` | 卡片刷新最小间隔（毫秒）；飞书单条消息更新约限 1 次/秒（错误 230020），失败会自动退避 |
| `reporting.maxBodyChars` | `900` | 卡片正文最大渲染长度 |
| `reporting.failureReaction` | `CrossMark` | 失败时（移除 Typing 后）追加的表情 |
| `reporting.cardTitleStreaming` | `🤖 DSH 处理中…` | 处理中卡片标题（黄色头）可自定义 |
| `reporting.cardTitleDone` | `🤖 DSH 处理完成` | 完成卡片标题（绿色头）可自定义 |
| `interactions.approvalCards` | `true` | 权限审批用可点击卡片回答 |
| `interactions.userQuestionsCards` | `true` | `ask_user_question` 用可点击卡片回答 |
| `interactions.approvalCardDispose` | `update` | 审批卡片点击后：`update`=回调响应瞬间替换为已处理态（按钮移除+结果+toast）；`recall`=撤回卡片消息（失败自动回退 update；注意飞书会在原位显示"撤回了一条消息"占位） |
| `newSessionPatterns` | `/new` 及中文短语 | 触发另起会话的正则列表 |
| `sessionsFile` | `data/dsh-feishu-sessions.json` | 会话映射持久化文件 |
| `http.port` | `0` | 管理 API 端口（`0`=禁用） |
| `http.token` | 空 | 管理 API Bearer Token |

> **web profile 下的问答卡片**：`ask_user_question` 的作答走唯一的
> `ctx.userQuestions` provider 槽位。网关**从不抢占**该槽位（否则 Web UI 的
> apiProxy 宿主会因 `DUPLICATE_PROVIDER` 启动失败）——它在服务边界包裹
> `service.ask` 做桥接：**飞书会话的提问用飞书卡片作答**，其余会话继续走
> Web UI provider。**权限审批卡片在任何部署下都从飞书作答**。独立 feishu
> profile 下，`ask_user_question` 与审批都在飞书卡片中作答。

## 会话共存与自愈

- **预设编排（有工具！）** — 在 preset-roster 部署（如 web profile）下，飞书
  agent 会从部署的 agent preset 编排（`meta.agentPreset` + preset `mount`），
  模型因此拿到工具，而不会把工具调用当成纯文本。
- **与 Web UI 共存** — 会话单 owner。当 Web UI 打开某会话后，飞书侧通过
  `agents.get()` 接管正在运行的 agent 并驱动同一会话，不再报
  "while it is live" / "already exists"；两个界面共享同一段对话。
- **wedged 会话自愈** — 进程中途死亡留下永久冲突的会话（"already exists"）时，
  网关自动换一个新 session id，并把飞书会话映射重指向它，继续对话。

## 管理 HTTP API（可选）

设置 `http.port` 启用。端点：

- `GET /health` — 状态
- `POST /api/push` — 主动推送
  `{ "receive_id": "ou_xxx", "receive_id_type": "open_id", "msg_type": "text", "content": "{\"text\":\"hi\"}" }`
- `GET /api/sessions` — 飞书↔DSH 会话映射概览

## 开发

```bash
pnpm install
pnpm build     # tsc → lib/
pnpm test      # 离线自测
```

> 说明：`@deepseek-ai/*` 运行时由 DSH 宿主提供；本地类型检查从你的
> deepseek-harness 检出目录 symlink（见发布检查清单）。

## 许可

MIT
