Skill: radium0028/official-feishu-toolkit (version 1.0.0)

--- id: official/feishu-toolkit owner_id: openclaw-cn name: 飞书办公套件 description: 飞书开放平台全面集成工具包。支持日历与会议室预约、消息发送、审批流程、多维表格操作、通讯录查询和考勤管理六大核心办公模块。 version: 1.0.1 icon: "\U0001F3E2" author: OpenClaw中文社区 metadata: clawdbot: emoji: "\U0001F3E2" requires: bins: - uv env: - FEISHU_APP_ID - FEISHU_APP_SECRET primaryEnv: FEISHU_APP_ID install: - id: brew kind: brew formula: uv bins: - uv label: Install uv via Homebrew --- # 🏢 飞书办公套件 *Feishu/Lark Office Toolkit — 让 Agent 成为你的飞书办公助手* 基于飞书开放平台 API 的全面集成工具包，覆盖日常办公六大核心场景。安装本技能后，你的 Agent 就能帮你预约会议室、发送消息、发起审批、操作多维表格、查询通讯录和管理考勤。 ## ✨ 功能亮点 | 模块 | 能力 | 示例指令 | |------|------|----------| | 📅 日历 | 日程 CRUD、会议室预约、忙闲查询 | "帮我预约明天下午的8楼大会议室" | | 💬 消息 | 文本/富文本/卡片消息发送、回复 | "给产品组群发版本发布通知" | | ✅ 审批 | 发起/查询/同意/拒绝/转交审批 | "帮我发起一个出差审批" | | 📊 多维表格 | 表格创建、记录增删改查 | "在项目跟踪表中新增一条任务" | | 👥 通讯录 | 用户/部门查询、组织架构浏览 | "查一下市场部有哪些成员" | | ⏰ 考勤 | 打卡记录、补卡查询、考勤组管理 | "查看我这周的打卡记录" | ## 📦 安装 ```bash claw skill install official/feishu-toolkit ``` 或在 AI IDE（Cursor / Copilot / Windsurf / Trae 等）中： ```bash curl -sL "https://backend.clawd.org.cn/api/skills/official%2Ffeishu-toolkit/install.sh" | sh ``` ## ⚙️ 配置 ### 1. 创建飞书应用 前往 [飞书开发者后台](https://open.feishu.cn/app) 创建自建应用，开启**机器人能力**，并根据所需模块申请对应 API 权限。 ### 2. 设置环境变量 ```bash export FEISHU_APP_ID="your-app-id" export FEISHU_APP_SECRET="your-app-secret" ``` | 变量 | 必填 | 说明 | |------|------|------| | `FEISHU_APP_ID` | ✅ | 飞书应用 App ID | | `FEISHU_APP_SECRET` | ✅ | 飞书应用 App Secret | | `FEISHU_APPROVAL_CODES` | 否 | 常用审批类型映射（JSON） | ### 3. 启动服务 ```bash cd server/ uv venv && uv pip install -e ".[dev]" uv run --env-file .env uvicorn feishu_toolkit.main:app --host 127.0.0.1 --port 8002 ``` ### 4. 验证 ```bash curl http://127.0.0.1:8002/ping # {"message": "pong"} ``` ## 🔐 权限清单 | 模块 | 权限标识 | 说明 | |------|----------|------| | 日历 | `calendar:calendar` | 读写日历及日程 | | 日历 | `vc:room:readonly` | 查询会议室 | | 消息 | `im:message:send_as_bot` | 发送消息 | | 审批 | `approval:approval` | 读写审批信息 | | 审批 | `approval:task` | 审批人操作 | | 多维表格 | `bitable:app` | 读写多维表格 | | 多维表格 | `drive:drive` | 访问云空间 | | 通讯录 | `contact:contact.base:readonly` | 读取通讯录 | | 考勤 | `attendance:task:readonly` | 导出打卡数据 | > 💡 在飞书开发者后台 → 权限管理中，将 **通讯录权限范围** 设为「全部成员」或指定部门，否则无法查询到用户信息。 ## 📖 详细文档 - [日历与会议室](references/calendar.md) - [消息](references/messaging.md) - [审批](references/approval.md) - [多维表格](references/bitable.md) - [通讯录](references/contacts.md) - [考勤](references/attendance.md) ## 🔗 相关资源 - [飞书开放平台](https://open.feishu.cn/) - [飞书开发者文档](https://open.feishu.cn/document/) - [API 调试台](https://open.feishu.cn/api-explorer)

path: README.md
content: # 🏢 飞书办公套件

*Feishu/Lark Office Toolkit — 让 Agent 成为你的飞书办公助手*

基于飞书开放平台 API 的全面集成工具包，覆盖日常办公六大核心场景。安装本技能后，你的 Agent 就能帮你预约会议室、发送消息、发起审批、操作多维表格、查询通讯录和管理考勤。

## ✨ 功能亮点

| 模块 | 能力 | 示例指令 |
|------|------|----------|
| 📅 日历 | 日程 CRUD、会议室预约、忙闲查询 | "帮我预约明天下午的8楼大会议室" |
| 💬 消息 | 文本/富文本/卡片消息发送、回复 | "给产品组群发版本发布通知" |
| ✅ 审批 | 发起/查询/同意/拒绝/转交审批 | "帮我发起一个出差审批" |
| 📊 多维表格 | 表格创建、记录增删改查 | "在项目跟踪表中新增一条任务" |
| 👥 通讯录 | 用户/部门查询、组织架构浏览 | "查一下市场部有哪些成员" |
| ⏰ 考勤 | 打卡记录、补卡查询、考勤组管理 | "查看我这周的打卡记录" |

## 📦 安装

```bash
claw skill install official/feishu-toolkit
```

或在 AI IDE（Cursor / Copilot / Windsurf / Trae 等）中：

```bash
curl -sL "https://backend.clawd.org.cn/api/skills/official%2Ffeishu-toolkit/install.sh" | sh
```

## ⚙️ 配置

### 1. 创建飞书应用

前往 [飞书开发者后台](https://open.feishu.cn/app) 创建自建应用，开启**机器人能力**，并根据所需模块申请对应 API 权限。

### 2. 设置环境变量

```bash
export FEISHU_APP_ID="your-app-id"
export FEISHU_APP_SECRET="your-app-secret"
```

| 变量 | 必填 | 说明 |
|------|------|------|
| `FEISHU_APP_ID` | ✅ | 飞书应用 App ID |
| `FEISHU_APP_SECRET` | ✅ | 飞书应用 App Secret |
| `FEISHU_APPROVAL_CODES` | 否 | 常用审批类型映射（JSON） |

### 3. 启动服务

```bash
cd server/
uv venv && uv pip install -e ".[dev]"
uv run --env-file .env uvicorn feishu_toolkit.main:app --host 127.0.0.1 --port 8002
```

### 4. 验证

```bash
curl http://127.0.0.1:8002/ping
# {"message": "pong"}
```

## 🔐 权限清单

| 模块 | 权限标识 | 说明 |
|------|----------|------|
| 日历 | `calendar:calendar` | 读写日历及日程 |
| 日历 | `vc:room:readonly` | 查询会议室 |
| 消息 | `im:message:send_as_bot` | 发送消息 |
| 审批 | `approval:approval` | 读写审批信息 |
| 审批 | `approval:task` | 审批人操作 |
| 多维表格 | `bitable:app` | 读写多维表格 |
| 多维表格 | `drive:drive` | 访问云空间 |
| 通讯录 | `contact:contact.base:readonly` | 读取通讯录 |
| 考勤 | `attendance:task:readonly` | 导出打卡数据 |

> 💡 在飞书开发者后台 → 权限管理中，将 **通讯录权限范围** 设为「全部成员」或指定部门，否则无法查询到用户信息。

## 📖 详细文档

- [日历与会议室](references/calendar.md)
- [消息](references/messaging.md)
- [审批](references/approval.md)
- [多维表格](references/bitable.md)
- [通讯录](references/contacts.md)
- [考勤](references/attendance.md)

## 🔗 相关资源

- [飞书开放平台](https://open.feishu.cn/)
- [飞书开发者文档](https://open.feishu.cn/document/)
- [API 调试台](https://open.feishu.cn/api-explorer)


path: references/approval.md
content: # 审批

本模块提供飞书审批流程操作能力，支持查看审批定义、列出可用审批类型、创建审批实例、查询审批状态和撤回审批。

## 所需权限

- `approval:approval` — 读写审批信息
- `approval:approval.list:readonly` — 查询审批列表（`GET /approval/list` 需要）
- `approval:task` — 执行审批人操作（同意/拒绝/转交、查询任务）

## 如何获取 Approval Code（审批定义 Code）

飞书不提供"列出所有审批定义"的 API，需要手动从管理后台获取：

1. 打开 [飞书审批管理后台（开发者模式）](https://www.feishu.cn/approval/admin/approvalList?devMode=on)
2. 找到目标审批，点击 **编辑** 按钮
3. 在浏览器地址栏中复制 `definitionCode=` 后面的值，例如：
   `https://www.feishu.cn/approval/admin/edit?definitionCode=48D49517-C979-447E-AD93-4BAE0FBC57EA`
4. 将获取到的 Code 配置在 `.env` 的 `FEISHU_APPROVAL_CODES` 中，方便 Agent 自动查询

## API 端点

### 1. 列出可用审批类型

列出已配置的审批类型，或从用户历史审批中自动发现。

```
GET /approval/types
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `user_id` | string | 否 | 用户 open_id，传入后会从该用户的历史审批中发现审批类型 |

**数据来源：**
- 环境变量 `FEISHU_APPROVAL_CODES` 中预配置的审批类型（`source: config`）
- 用户历史审批实例中发现的审批类型（`source: discovered`）

**响应示例：**

```json
{
  "approval_types": [
    {"name": "请假", "approval_code": "CODE1", "source": "config"},
    {"name": "补卡", "approval_code": "48D49517-...", "source": "discovered"}
  ],
  "hint": ""
}
```

### 2. 查看审批定义

获取审批定义详情，包括表单控件结构和审批节点。Agent 可通过此接口了解需要填写哪些字段。

```
GET /approval/definitions/{approval_code}
```

**响应示例：**

```json
{
  "approval_code": "48D49517-C979-447E-AD93-4BAE0FBC57EA",
  "approval_name": "补卡",
  "form": "[{\"id\":\"widgetRemedyGroupV2\",\"type\":\"remedyGroupV2\",\"name\":\"补卡\"}]",
  "node_list": [{"node_id": "6dbe...", "node_type": "AND", "name": "审批"}]
}
```

### 3. 创建审批实例

发起一个新的审批申请。

```
POST /approval/create
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `approval_code` | string | ✅ | 审批定义 Code |
| `open_id` | string | ✅ | 发起人的 open_id（`ou_` 开头） |
| `form` | string | ✅ | 审批表单内容（JSON 字符串，控件结构可通过查看审批定义获取） |
| `department_id` | string | 否 | 发起人所属部门 ID（多部门用户需填写） |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/approval/create \
  -H "Content-Type: application/json" \
  -d '{
    "approval_code": "48D49517-C979-447E-AD93-4BAE0FBC57EA",
    "open_id": "ou_xxx",
    "form": "[{\"id\":\"widgetRemedyGroupV2\",\"type\":\"remedyGroupV2\",\"value\":[{\"date\":\"2026-02-12\",\"remedy_time\":\"2026-02-12T09:00:00+08:00\",\"reason\":\"忘记打卡\"}]}]"
  }'
```

**响应示例：**

```json
{
  "instance_code": "619697E7-DCB5-431A-B7BC-33724C4BB1E9"
}
```

### 4. 查询审批实例列表

根据条件查询审批实例。

```
GET /approval/list
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `approval_code` | string | 否 | 审批定义 Code |
| `user_id` | string | 否 | 发起人 open_id |
| `instance_status` | string | 否 | 状态：`PENDING`/`APPROVED`/`REJECT`/`RECALL`/`ALL` |
| `start_time` | string | 否 | 起始时间（Unix 毫秒时间戳） |
| `end_time` | string | 否 | 结束时间（Unix 毫秒时间戳） |
| `page_size` | int | 否 | 每页数量，默认 10 |

**请求示例：**

```bash
# 查询所有补卡审批实例
curl "http://127.0.0.1:8002/approval/list?approval_code=48D49517-...&instance_status=ALL"
```

### 5. 获取审批详情

获取单个审批实例的详细信息，包括状态、表单数据、任务列表和审批动态。

```
GET /approval/{instance_code}
```

**请求示例：**

```bash
curl "http://127.0.0.1:8002/approval/619697E7-DCB5-431A-B7BC-33724C4BB1E9"
```

### 6. 撤回审批

撤回审批中或已通过的审批实例。

```
POST /approval/cancel
```

### 7. 查询用户待办/已办任务

查询某个审批人的任务列表，常用于“帮我同意张三的请假申请”这类需求的任务定位。

```
GET /approval/tasks
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `user_id` | string | ✅ | 审批人 open_id |
| `approval_code` | string | 否 | 审批定义 Code |
| `instance_code` | string | 否 | 审批实例 Code |
| `task_status` | string | 否 | 任务状态（默认 `PENDING`） |
| `page_size` | int | 否 | 每页数量（默认 10） |
| `page_token` | string | 否 | 翻页令牌 |

**响应示例：**

```json
{
  "has_more": false,
  "page_token": "",
  "tasks": [
    {
      "task": {"id": "7605931414537653476", "status": "PENDING"},
      "instance": {"code": "619697E7-...", "title": "张三 · 病假"},
      "approval": {"code": "48D49517-...", "name": "请假"}
    }
  ]
}
```

### 8. 同意审批任务

```
POST /approval/tasks/approve
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `approval_code` | string | ✅ | 审批定义 Code |
| `instance_code` | string | ✅ | 审批实例 Code |
| `task_id` | string | ✅ | 任务 ID |
| `open_id` | string | ✅ | 审批人 open_id |
| `comment` | string | 否 | 同意意见 |
| `form` | string | 否 | 表单补充（部分审批可能需要） |

### 9. 拒绝审批任务

```
POST /approval/tasks/reject
```

参数同上，`comment` 为拒绝理由。

### 10. 转交审批任务

```
POST /approval/tasks/transfer
```

在请求体中额外提供 `transfer_open_id`（目标审批人 open_id）。

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `approval_code` | string | ✅ | 审批定义 Code |
| `instance_code` | string | ✅ | 审批实例 Code |
| `user_id` | string | ✅ | 审批提交人 open_id |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/approval/cancel \
  -H "Content-Type: application/json" \
  -d '{
    "approval_code": "48D49517-C979-447E-AD93-4BAE0FBC57EA",
    "instance_code": "619697E7-DCB5-431A-B7BC-33724C4BB1E9",
    "user_id": "ou_xxx"
  }'
```

> **注意**：撤回需要在审批后台对应审批定义中勾选"允许撤销审批中的申请"或"允许撤销 x 天内通过的审批"。

## 典型工作流

### 发起审批（发起人视角）

1. 调用 `GET /approval/types` 查看可用审批类型，获取 `approval_code`
2. 调用 `GET /approval/definitions/{approval_code}` 查看表单控件结构
3. 根据控件结构组装 `form` JSON 字符串
4. 调用 `POST /approval/create` 发起审批
5. 调用 `GET /approval/{instance_code}` 查询审批状态

### 处理审批（审批人视角）

1. 调用 `GET /approval/tasks?user_id=ou_xxx&task_status=PENDING` 获取待办
2. 根据标题/申请人匹配到目标任务，拿到 `task_id` 和 `instance_code`
3. 同意：`POST /approval/tasks/approve`（或拒绝/转交）

### 常见表单控件类型

| 控件类型 | 说明 | value 格式 |
|----------|------|------------|
| `input` | 单行文本 | `"文本内容"` |
| `textarea` | 多行文本 | `"文本内容"` |
| `number` | 数字 | `123.45` |
| `date` | 日期 | `"2026-02-12T09:00:00+08:00"` (RFC3339) |
| `leaveGroup` | 请假控件组 | `{"name":"年假","start":"...","end":"...","interval":2.0}` |
| `remedyGroupV2` | 补卡控件组 | `[{"date":"2026-02-12","remedy_time":"...","reason":"..."}]` |
| `tripGroup` | 出差控件组 | `{"schedule":[...],"interval":2.0,"reason":"..."}` |

## 飞书 API 参考

| 本地端点 | 飞书 API |
|----------|----------|
| `GET /approval/types` | 环境变量 + `POST /open-apis/approval/v4/instances/query` |
| `GET /approval/definitions/{code}` | `GET /open-apis/approval/v4/approvals/:approval_code` |
| `POST /approval/create` | `POST /open-apis/approval/v4/instances` |
| `GET /approval/list` | `POST /open-apis/approval/v4/instances/query` |
| `GET /approval/{instance_code}` | `GET /open-apis/approval/v4/instances/:instance_code` |
| `POST /approval/cancel` | `POST /open-apis/approval/v4/instances/cancel` |
| `GET /approval/tasks` | `POST /open-apis/approval/v4/tasks/search` |
| `POST /approval/tasks/approve` | `POST /open-apis/approval/v4/tasks/approve` |
| `POST /approval/tasks/reject` | `POST /open-apis/approval/v4/tasks/reject` |
| `POST /approval/tasks/transfer` | `POST /open-apis/approval/v4/tasks/transfer` |


path: references/attendance.md
content: # 考勤

本模块提供飞书考勤打卡查询能力，支持查询打卡结果、补卡记录和考勤组信息。

## 所需权限

- `attendance:task:readonly` — 导出打卡数据
- `attendance:rule:readonly` — 导出打卡管理规则（查询考勤组时需要）
- `contact:user.employee_id:readonly` — 获取用户 ID（传入 open_id 时自动转换为 employee_id）

## API 端点

### 1. 查询打卡结果

获取员工在指定日期范围内的实际打卡结果。

```
POST /attendance/tasks
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `user_ids` | list[string] | ✅ | 员工 ID 列表，最多 50 个 |
| `check_date_from` | int | ✅ | 起始日期，格式 yyyyMMdd，如 `20260213` |
| `check_date_to` | int | ✅ | 结束日期，格式 yyyyMMdd |
| `employee_type` | string | 否 | ID 类型：`employee_id`(默认)/`employee_no` |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/attendance/tasks \
  -H "Content-Type: application/json" \
  -d '{
    "user_ids": ["abd754f7"],
    "check_date_from": 20260209,
    "check_date_to": 20260213
  }'
```

**响应示例：**

```json
{
  "tasks": [
    {
      "user_id": "abd754f7",
      "employee_name": "张三",
      "day": 20260213,
      "group_id": "6737202939523236110",
      "records": [
        {
          "check_in_result": "Normal",
          "check_out_result": "Normal",
          "check_in_time": "1739422800",
          "check_out_time": "1739455200"
        }
      ]
    }
  ]
}
```

**打卡结果说明：**

| 值 | 含义 |
|----|------|
| `Normal` | 正常 |
| `Late` | 迟到 |
| `Early` | 早退 |
| `Lack` | 缺卡 |
| `Todo` | 未打卡 |
| `NoNeedCheck` | 无需打卡 |

### 2. 获取补卡记录

获取员工的补卡申请记录。

```
POST /attendance/remedys
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `user_ids` | list[string] | ✅ | 员工 ID 列表 |
| `check_time_from` | string | ✅ | 起始时间（Unix 秒时间戳） |
| `check_time_to` | string | ✅ | 结束时间（Unix 秒时间戳） |
| `status` | int | 否 | 状态：0=待审批, 1=未通过, 2=已通过(默认), 3=已取消, 4=已撤回 |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/attendance/remedys \
  -H "Content-Type: application/json" \
  -d '{
    "user_ids": ["abd754f7"],
    "check_time_from": "1738800000",
    "check_time_to": "1739404800",
    "status": 2
  }'
```

**响应示例：**

```json
{
  "remedys": [
    {
      "user_id": "abd754f7",
      "remedy_date": 20260210,
      "remedy_time": "2026-02-10 09:00",
      "status": 2,
      "reason": "忘记打卡"
    }
  ]
}
```

### 3. 查询考勤组

获取考勤组的详细配置信息。

```
GET /attendance/group/{group_id}
```

**路径参数：**

| 参数 | 类型 | 说明 |
|------|------|------|
| `group_id` | string | 考勤组 ID（从打卡结果中获取） |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/attendance/group/6737202939523236110"
```

**响应示例：**

```json
{
  "group_id": "6737202939523236110",
  "group_name": "产品部考勤",
  "time_zone": "Asia/Shanghai",
  "group_type": 0,
  "locations": [
    {
      "location_name": "杭州总部",
      "latitude": 30.28994,
      "longitude": 120.04509,
      "gps_range": 300
    }
  ],
  "allow_out_punch": true,
  "allow_remedy": true
}
```

**考勤组类型说明：**

| 值 | 含义 |
|----|------|
| 0 | 固定班制 |
| 2 | 排班制 |
| 3 | 自由班制 |

## 典型工作流

### 查看本周考勤

1. **查询打卡结果** → `POST /attendance/tasks` (本周日期范围)
2. 汇总每天的上下班打卡状态
3. 标记异常（迟到/早退/缺卡）

### 查看考勤规则

1. **查询打卡结果** → 获取 `group_id`
2. **查询考勤组** → `GET /attendance/group/{group_id}`
3. 查看考勤地点、打卡方式等配置

## 飞书 API 参考

| 本地端点 | 飞书 API |
|----------|----------|
| `POST /attendance/tasks` | `POST /open-apis/attendance/v1/user_tasks/query` |
| `POST /attendance/remedys` | `POST /open-apis/attendance/v1/user_task_remedys/query` |
| `GET /attendance/group/{group_id}` | `GET /open-apis/attendance/v1/groups/:group_id` |


path: references/bitable.md
content: # 多维表格

本模块提供飞书多维表格（Bitable）的完整生命周期管理能力，包括创建多维表格、列出已有多维表格、查看表结构、查询、新增和更新记录。Agent 无需手动获取 app_token，可直接通过 API 创建或列出多维表格。

## 所需权限

- `bitable:app` — 读写多维表格
- `drive:drive` — 访问云空间（创建/列出多维表格时需要）
- 对于已有的多维表格，需确保应用已被添加为协作者（云文档右上角「...」→「更多」→「添加文档应用」）

## API 端点

### 1. 创建多维表格

创建一个新的多维表格，返回 app_token 和默认数据表 table_id。

```
POST /bitable/apps
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | ✅ | 多维表格名称 |
| `folder_token` | string | 否 | 目标文件夹 token，省略时创建在应用根目录 |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/bitable/apps \
  -H "Content-Type: application/json" \
  -d '{"name": "项目跟踪表"}'
```

**响应示例：**

```json
{
  "app_token": "NQRxbRkBMa6OnZsjtERcxhNWnNh",
  "name": "项目跟踪表",
  "url": "https://xxx.feishu.cn/base/NQRxbRkBMa6OnZsjtERcxhNWnNh",
  "default_table_id": "tbl0xe5g8PP3U3cS",
  "folder_token": "fldcnxxxxxx"
}
```

### 2. 列出多维表格

列出指定文件夹下的所有多维表格。

```
GET /bitable/apps
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `folder_token` | string | 否 | 文件夹 token，省略时列出应用根目录下的多维表格 |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/bitable/apps"
```

**响应示例：**

```json
{
  "folder_token": "fldcnxxxxxx",
  "bitables": [
    {
      "app_token": "NQRxbRkBMa6OnZsjtERcxhNWnNh",
      "name": "项目跟踪表",
      "url": "https://xxx.feishu.cn/base/NQRxbRkBMa6OnZsjtERcxhNWnNh",
      "created_time": "1708300800",
      "modified_time": "1708387200",
      "owner_id": "ou_xxx"
    }
  ]
}
```

### 3. 列出字段

获取数据表的字段（列）定义，了解表结构后再进行数据操作。

```
GET /bitable/fields
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `app_token` | string | ✅ | 多维表格 App Token |
| `table_id` | string | ✅ | 数据表 ID |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/bitable/fields?app_token=NQRxbRkBMa6OnZsjtERcxhNWnNh&table_id=tbl0xe5g8PP3U3cS"
```

**响应示例：**

```json
{
  "fields": [
    {"field_id": "fld001", "field_name": "任务名称", "type": 1, "is_primary": true, "property": {}},
    {"field_id": "fld002", "field_name": "状态", "type": 3, "is_primary": false, "property": {"options": [{"name": "待开始"}, {"name": "进行中"}, {"name": "已完成"}]}},
    {"field_id": "fld003", "field_name": "截止日期", "type": 5, "is_primary": false, "property": {}}
  ]
}
```

### 4. 列出数据表

获取多维表格中的数据表列表。

```
GET /bitable/tables
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `app_token` | string | ✅ | 多维表格 App Token |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/bitable/tables?app_token=NQRxbRkBMa6OnZsjtERcxhNWnNh"
```

**响应示例：**

```json
{
  "tables": [
    {"table_id": "tbl0xe5g8PP3U3cS", "name": "项目任务"},
    {"table_id": "tblABC123", "name": "成员列表"}
  ]
}
```

### 5. 查询记录

根据条件查询多维表格中的记录。

```
POST /bitable/records/search
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `app_token` | string | ✅ | 多维表格 App Token |
| `table_id` | string | ✅ | 数据表 ID |
| `field_names` | list[string] | 否 | 指定返回的字段名称 |
| `filter` | object | 否 | 筛选条件 |
| `sort` | list[object] | 否 | 排序条件 |
| `page_size` | int | 否 | 每页数量，默认 20，最大 500 |

**请求示例：**

```bash
# 查询所有记录
curl -X POST http://127.0.0.1:8002/bitable/records/search \
  -H "Content-Type: application/json" \
  -d '{
    "app_token": "NQRxbRkBMa6OnZsjtERcxhNWnNh",
    "table_id": "tbl0xe5g8PP3U3cS",
    "field_names": ["任务名称", "负责人", "状态"],
    "filter": {
      "conjunction": "and",
      "conditions": [
        {"field_name": "状态", "operator": "is", "value": ["进行中"]}
      ]
    }
  }'
```

**响应示例：**

```json
{
  "total": 3,
  "items": [
    {
      "record_id": "recyOaMB2F",
      "fields": {
        "任务名称": [{"text": "完成 API 对接", "type": "text"}],
        "负责人": [{"id": "ou_xxx", "name": "张三"}],
        "状态": "进行中"
      }
    }
  ]
}
```

### 6. 新增记录

向数据表中新增一条记录。

```
POST /bitable/records
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `app_token` | string | ✅ | 多维表格 App Token |
| `table_id` | string | ✅ | 数据表 ID |
| `fields` | object | ✅ | 字段数据（键为字段名，值为对应数据） |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/bitable/records \
  -H "Content-Type: application/json" \
  -d '{
    "app_token": "NQRxbRkBMa6OnZsjtERcxhNWnNh",
    "table_id": "tbl0xe5g8PP3U3cS",
    "fields": {
      "任务名称": "设计数据库表结构",
      "状态": "待开始",
      "优先级": "高",
      "截止日期": 1708300800000
    }
  }'
```

### 7. 更新记录

更新数据表中的一条记录。

```
PUT /bitable/records/{record_id}
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `app_token` | string | ✅ | 多维表格 App Token |
| `table_id` | string | ✅ | 数据表 ID |
| `fields` | object | ✅ | 要更新的字段数据 |

**请求示例：**

```bash
curl -X PUT http://127.0.0.1:8002/bitable/records/recyOaMB2F \
  -H "Content-Type: application/json" \
  -d '{
    "app_token": "NQRxbRkBMa6OnZsjtERcxhNWnNh",
    "table_id": "tbl0xe5g8PP3U3cS",
    "fields": {
      "状态": "已完成",
      "完成日期": 1708387200000
    }
  }'
```

## 字段类型说明

| 字段类型 | 写入格式 | 示例 |
|----------|----------|------|
| 多行文本 | string 或 text 对象数组 | `"Hello"` 或 `[{"text":"Hello","type":"text"}]` |
| 数字 | number | `2323.23` |
| 单选 | string | `"选项1"` |
| 多选 | list[string] | `["选项1", "选项2"]` |
| 日期 | int (毫秒时间戳) | `1690992000000` |
| 复选框 | boolean | `true` |
| 人员 | list[object] | `[{"id": "ou_xxx"}]` |
| 超链接 | object | `{"text": "链接", "link": "https://..."}` |

## 获取 App Token

多维表格的 `app_token` 有三种获取方式：
1. **通过 API 创建**: 调用 `POST /bitable/apps` 创建新多维表格，响应中包含 app_token
2. **通过 API 列出**: 调用 `GET /bitable/apps` 列出已有多维表格，获取 app_token
3. **从 URL 提取**: URL 格式 `https://xxx.feishu.cn/base/{app_token}`

## 飞书 API 参考

| 本地端点 | 飞书 API |
|----------|----------|
| `POST /bitable/apps` | `POST /open-apis/bitable/v1/apps` |
| `GET /bitable/apps` | `GET /open-apis/drive/v1/files` (筛选 type=bitable) |
| `GET /bitable/fields` | `GET /open-apis/bitable/v1/apps/:app_token/tables/:table_id/fields` |
| `GET /bitable/tables` | `GET /open-apis/bitable/v1/apps/:app_token/tables` |
| `POST /bitable/records/search` | `POST /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/search` |
| `POST /bitable/records` | `POST /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records` |
| `PUT /bitable/records/{record_id}` | `PUT /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/:record_id` |


path: references/calendar.md
content: # 日历与会议室

本模块提供飞书日历和会议室相关操作能力，支持创建日程、预约会议室、查询忙闲状态等核心办公场景。

## 所需权限

- `calendar:calendar` — 读写日历及日程信息
- `vc:room:readonly` — 获取会议室信息（查询/搜索会议室时需要）
- `contact:user.employee_id:readonly` — 获取用户 ID（可选）

## API 端点

### 1. 创建日程

创建一个日程，可同时添加参与人和会议室。

```
POST /calendar/events
```

### 1b. 更新日程

按需更新标题、时间、描述等字段。

```
PATCH /calendar/events/{event_id}
```

可选字段：`summary`、`start_time`、`end_time`、`description`、`calendar_id`

示例：
```bash
curl -X PATCH http://127.0.0.1:8002/calendar/events/<event_id> \
  -H "Content-Type: application/json" \
  -d '{
    "summary": "产品评审会议（更新）",
    "end_time": "2026-02-13T15:30:00+08:00"
  }'
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `summary` | string | ✅ | 日程标题 |
| `start_time` | string | ✅ | 开始时间，RFC3339 格式，如 `2026-02-13T14:00:00+08:00` |
| `end_time` | string | ✅ | 结束时间，RFC3339 格式 |
| `description` | string | 否 | 日程描述 |
| `attendee_user_ids` | list[string] | 否 | 参与人 open_id 列表 |
| `room_id` | string | 否 | 会议室 ID（预约会议室） |
| `calendar_id` | string | 否 | 日历 ID，默认使用主日历 |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/calendar/events \
  -H "Content-Type: application/json" \
  -d '{
    "summary": "产品评审会议",
    "start_time": "2026-02-13T14:00:00+08:00",
    "end_time": "2026-02-13T15:00:00+08:00",
    "description": "Q1 产品路线图评审",
    "attendee_user_ids": ["ou_xxx1", "ou_xxx2"],
    "room_id": "omm_xxx"
  }'
```

**响应示例：**

```json
{
  "event_id": "xxxxxxxxx_0",
  "summary": "产品评审会议",
  "start_time": "2026-02-13T14:00:00+08:00",
  "end_time": "2026-02-13T15:00:00+08:00",
  "organizer": "ou_xxx"
}
```

> **提示**：添加会议室后，会议室进入异步预约流程。请求成功不代表会议室预约成功，需后续查询会议室的预约状态（通过日程参与人的 `rsvp_status` 判断）。

### 2. 获取日程列表

获取指定时间范围内的日程。

```
GET /calendar/events
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `start_time` | string | ✅ | 起始时间，RFC3339 格式 |
| `end_time` | string | ✅ | 结束时间，RFC3339 格式 |
| `calendar_id` | string | 否 | 日历 ID，默认主日历 |
| `page_size` | int | 否 | 每页数量，默认 50 |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/calendar/events?start_time=2026-02-13T00:00:00%2B08:00&end_time=2026-02-14T00:00:00%2B08:00"
```

> 提示：已删除的事件在其时间窗口内仍可能出现在列表中，状态为 `cancelled`。

### 2b. 获取单个日程详情

获取指定 event_id 的详情。

```
GET /calendar/events/{event_id}
```

可选查询参数：`calendar_id`（不传默认主日历）

示例：
```bash
curl "http://127.0.0.1:8002/calendar/events/<event_id>"
```

### 3. 查询忙闲状态

查询用户或会议室在指定时间段内的忙闲情况。

```
POST /calendar/freebusy
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `time_min` | string | ✅ | 查询起始时间，RFC3339 格式 |
| `time_max` | string | ✅ | 查询结束时间，RFC3339 格式 |
| `user_id` | string | 否 | 用户 open_id（与 room_id 二选一） |
| `room_id` | string | 否 | 会议室 ID（与 user_id 二选一） |

**请求示例：**

```bash
# 查询会议室忙闲
curl -X POST http://127.0.0.1:8002/calendar/freebusy \
  -H "Content-Type: application/json" \
  -d '{
    "time_min": "2026-02-13T09:00:00+08:00",
    "time_max": "2026-02-13T18:00:00+08:00",
    "room_id": "omm_xxx"
  }'
```

**响应示例：**

```json
{
  "busy_times": [
    {
      "start_time": "2026-02-13T10:00:00+08:00",
      "end_time": "2026-02-13T11:30:00+08:00"
    },
    {
      "start_time": "2026-02-13T14:00:00+08:00",
      "end_time": "2026-02-13T15:00:00+08:00"
    }
  ]
}
```

### 4. 查询会议室列表

查询某个层级下的会议室列表。

```
GET /calendar/rooms
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `room_level_id` | string | 否 | 层级 ID，为空时返回租户所有会议室 |
| `page_size` | int | 否 | 每页数量，默认 20 |
| `page_token` | string | 否 | 分页标记 |

**请求示例：**

```bash
# 查询所有会议室
curl "http://127.0.0.1:8002/calendar/rooms"
```

### 4b. 搜索会议室

通过关键词搜索会议室。

```
POST /calendar/rooms/search
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `keyword` | string | 否 | 搜索关键词（会议室名称） |
| `room_level_id` | string | 否 | 在该层级下搜索 |
| `page_size` | int | 否 | 每页数量，默认 10 |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/calendar/rooms/search \
  -H "Content-Type: application/json" \
  -d '{"keyword": "大会议室"}'  
```

### 5. 添加日程参与人

为已有日程添加参与人或预约会议室。

```
POST /calendar/events/{event_id}/attendees
```

### 6. 删除日程

删除指定的日程事件。

```
DELETE /calendar/events/{event_id}
```

可选查询参数：
- `calendar_id`（string）— 日历 ID，默认主日历

示例：
```bash
curl -X DELETE "http://127.0.0.1:8002/calendar/events/381e074d-c843-4f01-b3bb-1d9105751df9_0"
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `attendees` | list | ✅ | 参与人列表 |
| `attendees[].type` | string | ✅ | 参与人类型：`user`/`chat`/`resource` |
| `attendees[].user_id` | string | 否 | 用户 open_id（type=user 时） |
| `attendees[].chat_id` | string | 否 | 群组 ID（type=chat 时） |
| `attendees[].room_id` | string | 否 | 会议室 ID（type=resource 时） |
| `need_notification` | boolean | 否 | 是否发送通知，默认 true |

## 典型工作流

### 清理测试数据

- 通过 `DELETE /calendar/events/{event_id}` 删除测试产生的日程

### 预约会议室

1. **查询会议室** → `GET /calendar/rooms` 或 `POST /calendar/rooms/search`
2. **查询会议室忙闲** → `POST /calendar/freebusy` (room_id)
3. **创建日程并预约** → `POST /calendar/events` (含 room_id)
4. **确认预约状态** → 查看日程参与人中会议室的 `rsvp_status`

### 安排团队会议

1. **查询团队成员忙闲** → 分别对每位成员调用 `POST /calendar/freebusy`
2. **找到共同空闲时间段**
3. **查询可用会议室** → `GET /calendar/rooms`
4. **创建日程** → `POST /calendar/events` (含参与人和会议室)

## 飞书 API 参考

| 本地端点 | 飞书 API |
|----------|----------|
| `POST /calendar/events` | `POST /open-apis/calendar/v4/calendars/:calendar_id/events` |
| `PATCH /calendar/events/{event_id}` | `PATCH /open-apis/calendar/v4/calendars/:calendar_id/events/:event_id` |
| `GET /calendar/events` | `GET /open-apis/calendar/v4/calendars/:calendar_id/events` |
| `GET /calendar/events/{event_id}` | `GET /open-apis/calendar/v4/calendars/:calendar_id/events/:event_id` |
| `POST /calendar/freebusy` | `POST /open-apis/calendar/v4/freebusy/list` |
| `GET /calendar/rooms` | `GET /open-apis/vc/v1/rooms` |
| `POST /calendar/rooms/search` | `POST /open-apis/vc/v1/rooms/search` |
| `POST /calendar/events/{event_id}/attendees` | `POST /open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/attendees` |
| `DELETE /calendar/events/{event_id}` | `DELETE /open-apis/calendar/v4/calendars/:calendar_id/events/:event_id` |


path: references/contacts.md
content: # 通讯录

本模块提供飞书通讯录查询能力，支持查询用户信息、搜索部门和浏览组织架构。全部基于 `tenant_access_token`，Agent 可直接使用。

## 所需权限

- `contact:contact.base:readonly` — 读取通讯录基本信息
- `contact:department.base:readonly` — 获取部门基础信息
- `contact:user.employee_id:readonly` — 获取用户 ID（可选）

## API 端点

### 1. 获取用户信息

根据用户 ID 获取用户详细信息。

```
GET /contacts/user/{user_id}
```

**路径参数：**

| 参数 | 类型 | 说明 |
|------|------|------|
| `user_id` | string | 用户 ID（open_id 或 user_id） |

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `user_id_type` | string | 否 | ID 类型：`open_id`(默认)/`user_id`/`union_id` |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/contacts/user/ou_xxx"
```

**响应示例：**

```json
{
  "user_id": "ou_xxx",
  "name": "张三",
  "en_name": "Zhang San",
  "email": "[REDACTED_SECRET]",
  "mobile": "+86 138xxxx0000",
  "department_ids": ["od-xxx"],
  "status": {"is_activated": true, "is_frozen": false}
}
```

### 2. 搜索用户

通过关键词搜索用户。

```
POST /contacts/users/search
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | string | ✅ | 搜索关键词（姓名） |
| `page_size` | int | 否 | 每页数量，默认 20 |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/contacts/users/search \
  -H "Content-Type: application/json" \
  -d '{"query": "张三"}'
```

> **实现方式**：基于部门成员遍历 + 姓名模糊匹配，使用 `tenant_access_token`，无需用户授权。

### 3. 获取部门信息

获取单个部门的详细信息。

```
GET /contacts/department/{department_id}
```

**路径参数：**

| 参数 | 类型 | 说明 |
|------|------|------|
| `department_id` | string | 部门 ID（open_department_id 或 department_id） |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/contacts/department/od-xxx"
```

**响应示例：**

```json
{
  "department_id": "D096",
  "open_department_id": "od-xxx",
  "name": "产品部",
  "parent_department_id": "D067",
  "leader_user_id": "ou_xxx",
  "member_count": 25
}
```

### 4. 搜索部门

通过部门名称搜索部门。

```
POST /contacts/departments/search
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | string | ✅ | 搜索关键词（部门名称） |
| `page_size` | int | 否 | 每页数量，默认 20 |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/contacts/departments/search \
  -H "Content-Type: application/json" \
  -d '{"query": "产品"}'
```

### 5. 获取部门成员

获取指定部门的直属成员列表。

```
GET /contacts/department/{department_id}/users
```

**查询参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `page_size` | int | 否 | 每页数量，默认 50 |
| `page_token` | string | 否 | 分页标记 |

**请求示例：**

```bash
curl "http://127.0.0.1:8002/contacts/department/od-xxx/users"
```

**响应示例：**

```json
{
  "items": [
    {"user_id": "ou_xxx1", "name": "张三", "email": "[REDACTED_SECRET]"},
    {"user_id": "ou_xxx2", "name": "李四", "email": "[REDACTED_SECRET]"}
  ],
  "has_more": false
}
```

## 典型工作流

### 查找某部门所有成员

1. **搜索部门** → `POST /contacts/departments/search` (query="市场部")
2. **获取部门成员** → `GET /contacts/department/{dept_id}/users`

### 查找用户所在部门

1. **获取用户信息** → `GET /contacts/user/{user_id}`
2. 从返回的 `department_ids` 获取部门 ID
3. **获取部门详情** → `GET /contacts/department/{dept_id}`

## 飞书 API 参考

| 本地端点 | 飞书 API |
|----------|----------|
| `GET /contacts/user/{user_id}` | `GET /open-apis/contact/v3/users/:user_id` |
| `POST /contacts/users/search` | `POST /open-apis/contact/v3/users/search` |
| `GET /contacts/department/{dept_id}` | `GET /open-apis/contact/v3/departments/:department_id` |
| `POST /contacts/departments/search` | `POST /open-apis/contact/v3/departments/search` |
| `GET /contacts/department/{dept_id}/users` | `GET /open-apis/contact/v3/users/find_by_department` |


path: references/messaging.md
content: # 消息

本模块提供飞书消息发送能力，支持向个人或群聊发送文本、富文本和卡片等多种类型的消息。

## 所需权限

- `im:message:send_as_bot` — 以应用身份发送消息

## API 端点

### 1. 发送消息

向指定用户或群聊发送消息。

```
POST /messaging/send
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `receive_id` | string | ✅ | 接收者 ID |
| `receive_id_type` | string | ✅ | ID 类型：`open_id`/`user_id`/`email`/`chat_id` |
| `msg_type` | string | ✅ | 消息类型：`text`/`post`/`interactive`/`image`/`file` |
| `content` | string | ✅ | 消息内容（JSON 字符串） |

**发送文本消息：**

```bash
curl -X POST http://127.0.0.1:8002/messaging/send \
  -H "Content-Type: application/json" \
  -d '{
    "receive_id": "ou_xxx",
    "receive_id_type": "open_id",
    "msg_type": "text",
    "content": "{\"text\": \"你好，这是一条测试消息\"}"
  }'
```

**发送富文本消息：**

```bash
curl -X POST http://127.0.0.1:8002/messaging/send \
  -H "Content-Type: application/json" \
  -d '{
    "receive_id": "oc_xxx",
    "receive_id_type": "chat_id",
    "msg_type": "post",
    "content": "{\"zh_cn\":{\"title\":\"项目更新\",\"content\":[[{\"tag\":\"text\",\"text\":\"版本 2.0 已发布，主要更新：\"},{\"tag\":\"a\",\"text\":\"查看详情\",\"href\":\"https://example.com\"}]]}}"
  }'
```

**发送卡片消息：**

```bash
curl -X POST http://127.0.0.1:8002/messaging/send \
  -H "Content-Type: application/json" \
  -d '{
    "receive_id": "oc_xxx",
    "receive_id_type": "chat_id",
    "msg_type": "interactive",
    "content": "{\"elements\":[{\"tag\":\"markdown\",\"content\":\"**项目进度更新**\\n- 前端开发：80%\\n- 后端开发：60%\\n- 测试：30%\"}]}"
  }'
```

**响应示例：**

```json
{
  "message_id": "om_dc13264520392913993dd051dba21dcf",
  "msg_type": "text",
  "create_time": "1615380573411",
  "chat_id": "oc_5ad11d72b830411d72b836c20"
}
```

### 2. 回复消息

回复指定消息。

```
POST /messaging/reply
```

**请求参数：**

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `message_id` | string | ✅ | 要回复的消息 ID |
| `msg_type` | string | ✅ | 消息类型 |
| `content` | string | ✅ | 消息内容（JSON 字符串） |

**请求示例：**

```bash
curl -X POST http://127.0.0.1:8002/messaging/reply \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "om_xxx",
    "msg_type": "text",
    "content": "{\"text\": \"收到，我马上处理\"}"
  }'
```

## 消息内容格式

### 文本消息 (text)

```json
{"text": "你好，这是一条消息"}
```

支持 @ 用户：`{"text": "<at user_id=\"ou_xxx\">张三</at> 请查看"}`

### 富文本消息 (post)

```json
{
  "zh_cn": {
    "title": "标题",
    "content": [
      [
        {"tag": "text", "text": "普通文本"},
        {"tag": "a", "text": "链接文字", "href": "https://example.com"},
        {"tag": "at", "user_id": "ou_xxx"}
      ]
    ]
  }
}
```

### 卡片消息 (interactive)

```json
{
  "elements": [
    {"tag": "markdown", "content": "**标题**\n内容文本"},
    {
      "tag": "action",
      "actions": [
        {"tag": "button", "text": {"tag": "plain_text", "content": "确认"}, "type": "primary"}
      ]
    }
  ]
}
```

## 使用限制

- 向同一用户发送消息限频：**5 QPS**
- 向同一群组发送消息限频：群内机器人共享 **5 QPS**
- 文本消息最大 **150 KB**
- 卡片/富文本消息最大 **30 KB**

## 飞书 API 参考

| 本地端点 | 飞书 API |
|----------|----------|
| `POST /messaging/send` | `POST /open-apis/im/v1/messages` |
| `POST /messaging/reply` | `POST /open-apis/im/v1/messages/:message_id/reply` |


path: server/feishu_toolkit/approval_api.py
content: """审批模块

提供查看审批定义、创建审批实例、查询审批状态和撤回审批的能力。
支持通过环境变量预配置常用审批类型的 approval_code，方便 Agent 自动发起审批。
"""

from __future__ import annotations

import json
import logging
import os

from fastapi import APIRouter, Query
from pydantic import BaseModel, Field

from feishu_toolkit.auth import feishu_request

router = APIRouter()
logger = logging.getLogger("feishu_toolkit.approval")


def _load_approval_code_map() -> dict[str, str]:
    """从环境变量加载审批类型映射。

    格式: FEISHU_APPROVAL_CODES='{"请假":"CODE1","加班":"CODE2","出差":"CODE3"}'
    每个 key 为审批类型名称，value 为对应的 approval_code。
    """
    raw = os.getenv("FEISHU_APPROVAL_CODES", "")
    if not raw:
        return {}
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        logger.warning("FEISHU_APPROVAL_CODES 解析失败，请检查 JSON 格式")
        return {}


# ── 请求模型 ──────────────────────────────────────────────

class CreateApprovalRequest(BaseModel):
    approval_code: str = Field(..., description="审批定义 Code")
    open_id: str = Field(..., description="发起人 open_id（ou_ 开头）")
    form: str = Field(..., description="审批表单内容（JSON 字符串）")
    department_id: str | None = Field(None, description="发起人所属部门 ID")


class CancelApprovalRequest(BaseModel):
    approval_code: str = Field(..., description="审批定义 Code")
    instance_code: str = Field(..., description="审批实例 Code")
    user_id: str = Field(..., description="审批提交人 open_id")


class ApproveTaskRequest(BaseModel):
    approval_code: str = Field(..., description="审批定义 Code")
    instance_code: str = Field(..., description="审批实例 Code")
    task_id: str = Field(..., description="任务 ID")
    open_id: str = Field(..., description="操作人 open_id（审批人）")
    comment: str | None = Field(None, description="同意意见")
    form: str | None = Field(None, description="表单补充（可选）")


class RejectTaskRequest(BaseModel):
    approval_code: str = Field(..., description="审批定义 Code")
    instance_code: str = Field(..., description="审批实例 Code")
    task_id: str = Field(..., description="任务 ID")
    open_id: str = Field(..., description="操作人 open_id（审批人）")
    comment: str | None = Field(None, description="拒绝理由")


class TransferTaskRequest(BaseModel):
    approval_code: str = Field(..., description="审批定义 Code")
    instance_code: str = Field(..., description="审批实例 Code")
    task_id: str = Field(..., description="任务 ID")
    open_id: str = Field(..., description="当前审批人 open_id")
    transfer_open_id: str = Field(..., description="转交目标审批人 open_id")
    comment: str | None = Field(None, description="转交说明")


# ── 路由 ──────────────────────────────────────────────────

@router.get("/types", summary="列出可用审批类型")
def list_approval_types(
    user_id: str | None = Query(
        None,
        description="用户 open_id，传入后会从该用户的历史审批中发现审批类型",
    ),
) -> dict:
    """列出可用的审批类型。

    数据来源：
    1. 环境变量 FEISHU_APPROVAL_CODES 中预配置的审批类型
    2. 如果传入 user_id，还会从该用户的历史审批实例中发现审批类型

    飞书不提供"列出所有审批定义"的 API，因此推荐管理员在 .env 中预配置常用审批 code。
    """
    # 1. 从环境变量加载
    configured = _load_approval_code_map()
    types: dict[str, dict] = {
        name: {"approval_code": code, "source": "config"}
        for name, code in configured.items()
    }

    # 2. 从用户历史实例中发现
    if user_id:
        try:
            data = feishu_request(
                "POST",
                "/open-apis/approval/v4/instances/query",
                params={"user_id_type": "open_id"},
                json_body={"user_id": user_id, "page_size": 50},
            )
            for item in data.get("instance_list", []):
                approval = item.get("approval", {})
                name = approval.get("name", "")
                code = approval.get("code", "")
                if name and code and name not in types:
                    types[name] = {"approval_code": code, "source": "discovered"}
        except Exception:
            logger.warning("从用户历史实例发现审批类型失败", exc_info=True)

    return {
        "approval_types": [
            {"name": name, **info} for name, info in types.items()
        ],
        "hint": "如需更多审批类型，请在 .env 中配置 FEISHU_APPROVAL_CODES" if not types else "",
    }


@router.get("/definitions/{approval_code}", summary="查看审批定义")
def get_definition(approval_code: str) -> dict:
    """获取审批定义详情，包括表单控件结构。

    Agent 可通过此接口了解审批表单需要填写哪些字段，然后构建 form 参数。
    """
    data = feishu_request(
        "GET",
        f"/open-apis/approval/v4/approvals/{approval_code}",
    )
    return {
        "approval_code": data.get("approval_code", approval_code),
        "approval_name": data.get("approval_name", ""),
        "form": data.get("form", []),
        "node_list": data.get("node_list", []),
    }


@router.post("/create", summary="创建审批实例")
def create_instance(req: CreateApprovalRequest) -> dict:
    """发起一个新的审批申请。

    注意：创建审批实例 API 使用独立的 open_id / user_id 字段，
    不支持 user_id_type 查询参数。
    """
    body: dict = {
        "approval_code": req.approval_code,
        "open_id": req.open_id,
        "form": req.form,
    }
    if req.department_id:
        body["department_id"] = req.department_id

    data = feishu_request(
        "POST",
        "/open-apis/approval/v4/instances",
        json_body=body,
    )
    return {"instance_code": data.get("instance_code", "")}


@router.get("/list", summary="查询审批实例列表")
def list_instances(
    approval_code: str | None = Query(None, description="审批定义 Code"),
    user_id: str | None = Query(None, description="发起人 open_id"),
    instance_status: str | None = Query(None, description="状态：PENDING/APPROVED/REJECT/RECALL/ALL"),
    start_time: str | None = Query(None, description="起始时间（Unix 毫秒时间戳）"),
    end_time: str | None = Query(None, description="结束时间（Unix 毫秒时间戳）"),
    page_size: int = Query(10, description="每页数量"),
) -> dict:
    """根据条件查询审批实例。"""
    body: dict = {"page_size": page_size}
    if approval_code:
        body["approval_code"] = approval_code
    if user_id:
        body["user_id"] = user_id
    if instance_status:
        body["instance_status"] = instance_status
    if start_time:
        body["start_time"] = start_time
    if end_time:
        body["end_time"] = end_time

    data = feishu_request(
        "POST",
        "/open-apis/approval/v4/instances/query",
        params={"user_id_type": "open_id"},
        json_body=body,
    )
    return {
        "count": data.get("count", 0),
        "instances": data.get("instance_list", []),
    }


@router.get("/{instance_code}", summary="获取审批详情")
def get_instance(instance_code: str) -> dict:
    """获取单个审批实例的详细信息。"""
    return feishu_request(
        "GET",
        f"/open-apis/approval/v4/instances/{instance_code}",
    )


# ── 审批人任务操作 ──────────────────────────────────────────

@router.get("/tasks", summary="查询用户待办/已办任务")
def search_tasks(
    user_id: str = Query(..., description="审批人 open_id"),
    approval_code: str | None = Query(None, description="审批定义 Code"),
    instance_code: str | None = Query(None, description="审批实例 Code"),
    task_status: str | None = Query("PENDING", description="任务状态：PENDING/APPROVED/REJECT/DELEGATE/ALL"),
    page_size: int = Query(10, description="每页数量"),
    page_token: str | None = Query(None, description="翻页令牌"),
) -> dict:
    """查询指定用户的审批任务。

    底层调用 Feishu: POST /open-apis/approval/v4/tasks/search
    """
    params: dict = {"user_id_type": "open_id", "page_size": page_size}
    if page_token:
        params["page_token"] = page_token

    body: dict = {"user_id": user_id}
    if approval_code:
        body["approval_code"] = approval_code
    if instance_code:
        body["instance_code"] = instance_code
    if task_status:
        body["task_status"] = task_status

    data = feishu_request(
        "POST",
        "/open-apis/approval/v4/tasks/search",
        params=params,
        json_body=body,
    )
    return {
        "has_more": data.get("has_more", False),
        "page_token": data.get("page_token", ""),
        "tasks": data.get("task_list", data.get("data", [])),
    }


@router.post("/tasks/approve", summary="同意审批任务")
def approve_task(req: ApproveTaskRequest) -> dict:
    payload = {
        "approval_code": req.approval_code,
        "instance_code": req.instance_code,
        "task_id": req.task_id,
        "user_id": req.open_id,  # 结合 user_id_type=open_id
    }
    if req.comment:
        payload["comment"] = req.comment
    if req.form:
        payload["form"] = req.form

    feishu_request(
        "POST",
        "/open-apis/approval/v4/tasks/approve",
        params={"user_id_type": "open_id"},
        json_body=payload,
    )
    return {"success": True, "task_id": req.task_id, "instance_code": req.instance_code}


@router.post("/tasks/reject", summary="拒绝审批任务")
def reject_task(req: RejectTaskRequest) -> dict:
    payload = {
        "approval_code": req.approval_code,
        "instance_code": req.instance_code,
        "task_id": req.task_id,
        "user_id": req.open_id,  # 结合 user_id_type=open_id
    }
    if req.comment:
        payload["comment"] = req.comment

    feishu_request(
        "POST",
        "/open-apis/approval/v4/tasks/reject",
        params={"user_id_type": "open_id"},
        json_body=payload,
    )
    return {"success": True, "task_id": req.task_id, "instance_code": req.instance_code}


@router.post("/tasks/transfer", summary="转交审批任务")
def transfer_task(req: TransferTaskRequest) -> dict:
    payload = {
        "approval_code": req.approval_code,
        "instance_code": req.instance_code,
        "task_id": req.task_id,
        "user_id": req.open_id,
        "transfer_user_id": req.transfer_open_id,
    }
    if req.comment:
        payload["comment"] = req.comment

    feishu_request(
        "POST",
        "/open-apis/approval/v4/tasks/transfer",
        params={"user_id_type": "open_id"},
        json_body=payload,
    )
    return {"success": True, "task_id": req.task_id, "instance_code": req.instance_code}


@router.post("/cancel", summary="撤回审批")
def cancel_instance(req: CancelApprovalRequest) -> dict:
    """撤回审批中或已通过的审批实例。"""
    feishu_request(
        "POST",
        "/open-apis/approval/v4/instances/cancel",
        params={"user_id_type": "open_id"},
        json_body={
            "approval_code": req.approval_code,
            "instance_code": req.instance_code,
            "user_id": req.user_id,
        },
    )
    return {"success": True, "instance_code": req.instance_code}


path: server/feishu_toolkit/attendance_api.py
content: """考勤模块

提供打卡结果查询、补卡记录查询和考勤组信息获取能力。
支持传入 open_id，自动转换为 employee_id。
"""

from __future__ import annotations

import logging

from fastapi import APIRouter
from pydantic import BaseModel, Field

from feishu_toolkit.auth import feishu_request

router = APIRouter()
logger = logging.getLogger("feishu_toolkit.attendance")


def _resolve_employee_ids(user_ids: list[str]) -> list[str]:
    """将 open_id (ou_ 开头) 转换为 employee_id。

    飞书考勤 API 只接受 employee_id/employee_no，
    此函数自动将 open_id 转换为 employee_id。
    """
    open_ids = [uid for uid in user_ids if uid.startswith("ou_")]
    plain_ids = [uid for uid in user_ids if not uid.startswith("ou_")]

    if not open_ids:
        return user_ids

    resolved: list[str] = list(plain_ids)
    for oid in open_ids:
        try:
            data = feishu_request(
                "GET",
                f"/open-apis/contact/v3/users/{oid}",
                params={"user_id_type": "open_id"},
            )
            user = data.get("user", data)
            eid = user.get("user_id", "")
            if eid:
                resolved.append(eid)
                logger.info("Resolved open_id %s → employee_id %s", oid, eid)
            else:
                logger.warning("No employee_id found for open_id %s, using as-is", oid)
                resolved.append(oid)
        except Exception:
            logger.warning("Failed to resolve open_id %s, using as-is", oid)
            resolved.append(oid)
    return resolved


# ── 请求模型 ──────────────────────────────────────────────

class QueryTasksRequest(BaseModel):
    user_ids: list[str] = Field(..., description="员工 ID 列表（支持 open_id 或 employee_id），最多 50 个")
    check_date_from: int = Field(..., description="起始日期，格式 yyyyMMdd")
    check_date_to: int = Field(..., description="结束日期，格式 yyyyMMdd")


class QueryRemedysRequest(BaseModel):
    user_ids: list[str] = Field(..., description="员工 ID 列表（支持 open_id 或 employee_id）")
    check_time_from: str = Field(..., description="起始时间（Unix 秒时间戳）")
    check_time_to: str = Field(..., description="结束时间（Unix 秒时间戳）")
    status: int | None = Field(None, description="状态：0=待审批, 1=未通过, 2=已通过, 3=已取消, 4=已撤回")


# ── 路由 ──────────────────────────────────────────────────

@router.post("/tasks", summary="查询打卡结果")
def query_tasks(req: QueryTasksRequest) -> dict:
    """获取员工在指定日期范围内的打卡结果。支持传入 open_id，自动转换。"""
    resolved_ids = _resolve_employee_ids(req.user_ids)
    data = feishu_request(
        "POST",
        "/open-apis/attendance/v1/user_tasks/query",
        params={"employee_type": "employee_id"},
        json_body={
            "user_ids": resolved_ids,
            "check_date_from": req.check_date_from,
            "check_date_to": req.check_date_to,
        },
    )
    return {"tasks": data.get("user_task_results", [])}


@router.post("/remedys", summary="获取补卡记录")
def query_remedys(req: QueryRemedysRequest) -> dict:
    """获取员工的补卡申请记录。支持传入 open_id，自动转换。"""
    resolved_ids = _resolve_employee_ids(req.user_ids)
    body: dict = {
        "user_ids": resolved_ids,
        "check_time_from": req.check_time_from,
        "check_time_to": req.check_time_to,
    }
    if req.status is not None:
        body["status"] = req.status

    data = feishu_request(
        "POST",
        "/open-apis/attendance/v1/user_task_remedys/query",
        json_body=body,
    )
    return {"remedys": data.get("user_remedys", [])}


@router.get("/group/{group_id}", summary="查询考勤组")
def get_group(group_id: str) -> dict:
    """获取考勤组的详细配置信息。"""
    data = feishu_request(
        "GET",
        f"/open-apis/attendance/v1/groups/{group_id}",
    )
    return data.get("group", data)


path: server/feishu_toolkit/auth.py
content: """飞书 tenant_access_token 管理

Token 有效期 2 小时，提前 5 分钟刷新。
"""

from __future__ import annotations

import logging
import os
import time
from dataclasses import dataclass

import ssl

import httpx
import truststore
from fastapi import HTTPException

# 使用系统证书存储，解决 macOS 上 SSL 验证问题
truststore.inject_into_ssl()
_ssl_ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)

FEISHU_HOST = "https://open.feishu.cn"
TOKEN_URL = f"{FEISHU_HOST}/open-apis/auth/v3/tenant_access_token/internal"

logger = logging.getLogger("feishu_toolkit.auth")


@dataclass
class _TokenCache:
    token: str = ""
    expires_at: float = 0.0


_cache = _TokenCache()

# 提前 5 分钟刷新
_REFRESH_MARGIN = 300


def _get_credentials() -> tuple[str, str]:
    """读取飞书应用凭证"""
    app_id = os.getenv("FEISHU_APP_ID", "")
    app_secret = os.getenv("FEISHU_APP_SECRET", "")
    if not app_id or not app_secret:
        raise HTTPException(
            status_code=500,
            detail="FEISHU_APP_ID / FEISHU_APP_SECRET is not set.",
        )
    return app_id, app_secret


def get_tenant_token() -> str:
    """获取 tenant_access_token，自动刷新过期 token"""
    now = time.time()
    if _cache.token and now < _cache.expires_at:
        return _cache.token

    app_id, app_secret = _get_credentials()
    body = {"app_id": app_id, "app_secret": app_secret}

    try:
        with httpx.Client(timeout=10.0, verify=_ssl_ctx) as client:
            resp = client.post(TOKEN_URL, json=body)
    except httpx.HTTPError as exc:
        logger.error("Failed to fetch tenant_access_token: %s", exc)
        raise HTTPException(status_code=502, detail="Feishu auth unavailable.") from exc

    data = resp.json()
    if data.get("code") != 0:
        logger.error("Feishu auth error: %s", data)
        raise HTTPException(
            status_code=502,
            detail=f"Feishu auth error: {data.get('msg', 'unknown')}",
        )

    _cache.token = data["tenant_access_token"]
    _cache.expires_at = now + data.get("expire", 7200) - _REFRESH_MARGIN
    logger.info("tenant_access_token refreshed, expires in %ds", data.get("expire", 7200))
    return _cache.token


def feishu_headers() -> dict[str, str]:
    """返回带有认证信息的请求头"""
    return {
        "Content-Type": "application/json; charset=utf-8",
        "Authorization": f"Bearer {get_tenant_token()}",
    }


def feishu_request(
    method: str,
    path: str,
    *,
    params: dict | None = None,
    json_body: dict | None = None,
) -> dict:
    """通用飞书 API 请求封装

    自动处理认证、错误和重试。
    """
    url = f"{FEISHU_HOST}{path}"
    headers = feishu_headers()

    try:
        with httpx.Client(timeout=30.0, verify=_ssl_ctx) as client:
            resp = client.request(method, url, headers=headers, params=params, json=json_body)
    except httpx.HTTPError as exc:
        logger.error("Feishu API request failed: %s %s – %s", method, path, exc)
        raise HTTPException(status_code=502, detail="Feishu API unavailable.") from exc

    if resp.status_code != 200:
        logger.error("Feishu API HTTP error: status=%d, body=%s", resp.status_code, resp.text)
        raise HTTPException(status_code=resp.status_code, detail=resp.text)

    data = resp.json()
    code = data.get("code", -1)
    if code != 0:
        logger.error("Feishu API biz error: code=%d, msg=%s", code, data.get("msg"))
        raise HTTPException(
            status_code=400,
            detail=f"Feishu API error (code={code}): {data.get('msg', 'unknown')}",
        )

    return data.get("data", data)


path: server/feishu_toolkit/bitable_api.py
content: """多维表格模块

提供飞书多维表格（Bitable）的创建、查询、新增和更新记录能力。
支持通过 API 创建新的多维表格和列出已有多维表格，无需手动获取 app_token。
"""

from __future__ import annotations

import logging
from typing import Any

from fastapi import APIRouter, Query
from pydantic import BaseModel, Field

from feishu_toolkit.auth import feishu_request

router = APIRouter()
logger = logging.getLogger("feishu_toolkit.bitable")


# ── 请求模型 ──────────────────────────────────────────────

class CreateBitableRequest(BaseModel):
    name: str = Field(..., description="多维表格名称")
    folder_token: str | None = Field(
        None,
        description="目标文件夹 token，为空时创建在应用根目录",
    )


class SearchRecordsRequest(BaseModel):
    app_token: str = Field(..., description="多维表格 App Token")
    table_id: str = Field(..., description="数据表 ID")
    field_names: list[str] | None = Field(None, description="指定返回的字段名称")
    filter: dict | None = Field(None, description="筛选条件")
    sort: list[dict] | None = Field(None, description="排序条件")
    page_size: int = Field(20, description="每页数量，最大 500")


class CreateRecordRequest(BaseModel):
    app_token: str = Field(..., description="多维表格 App Token")
    table_id: str = Field(..., description="数据表 ID")
    fields: dict[str, Any] = Field(..., description="字段数据")


class UpdateRecordRequest(BaseModel):
    app_token: str = Field(..., description="多维表格 App Token")
    table_id: str = Field(..., description="数据表 ID")
    fields: dict[str, Any] = Field(..., description="要更新的字段数据")


# ── 辅助函数 ──────────────────────────────────────────────

def _get_root_folder_token() -> str:
    """获取应用根目录的 folder_token。"""
    data = feishu_request("GET", "/open-apis/drive/explorer/v2/root_folder/meta")
    return data.get("token", "")


# ── 路由: 多维表格管理 ────────────────────────────────────

@router.post("/apps", summary="创建多维表格")
def create_bitable(req: CreateBitableRequest) -> dict:
    """创建一个新的多维表格。

    如果不提供 folder_token，将创建在应用根目录下。
    返回新多维表格的 app_token 和默认数据表 table_id。
    """
    folder = req.folder_token or _get_root_folder_token()
    body: dict[str, Any] = {"name": req.name, "folder_token": folder}

    data = feishu_request(
        "POST",
        "/open-apis/bitable/v1/apps",
        json_body=body,
    )
    app_info = data.get("app", {})
    return {
        "app_token": app_info.get("app_token", ""),
        "name": app_info.get("name", req.name),
        "url": app_info.get("url", ""),
        "default_table_id": app_info.get("default_table_id", ""),
        "folder_token": folder,
    }


@router.get("/apps", summary="列出多维表格")
def list_bitables(
    folder_token: str | None = Query(
        None,
        description="文件夹 token，为空时列出应用根目录下的多维表格",
    ),
) -> dict:
    """列出指定文件夹下的多维表格。

    通过云空间 Drive API 列出文件并筛选 bitable 类型。
    """
    folder = folder_token or _get_root_folder_token()
    items: list[dict] = []
    page_token: str | None = None

    # 分页获取所有文件
    for _ in range(20):  # 防止无限循环
        params: dict[str, Any] = {
            "folder_token": folder,
            "page_size": 50,
        }
        if page_token:
            params["page_token"] = page_token

        data = feishu_request(
            "GET",
            "/open-apis/drive/v1/files",
            params=params,
        )
        for f in data.get("files", []):
            if f.get("type") == "bitable":
                items.append({
                    "app_token": f.get("token", ""),
                    "name": f.get("name", ""),
                    "url": f.get("url", ""),
                    "created_time": f.get("created_time", ""),
                    "modified_time": f.get("modified_time", ""),
                    "owner_id": f.get("owner_id", ""),
                })

        if not data.get("has_more"):
            break
        page_token = data.get("page_token")

    return {"folder_token": folder, "bitables": items}


@router.get("/fields", summary="列出字段")
def list_fields(
    app_token: str = Query(..., description="多维表格 App Token"),
    table_id: str = Query(..., description="数据表 ID"),
) -> dict:
    """获取数据表的字段（列）定义。

    返回字段名、类型等信息，方便了解表结构后进行数据操作。
    """
    data = feishu_request(
        "GET",
        f"/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields",
    )
    items = data.get("items", [])
    return {
        "fields": [
            {
                "field_id": f.get("field_id", ""),
                "field_name": f.get("field_name", ""),
                "type": f.get("type", 0),
                "is_primary": f.get("is_primary", False),
                "property": f.get("property", {}),
            }
            for f in items
        ]
    }


# ── 路由: 数据表与记录 ────────────────────────────────────

@router.get("/tables", summary="列出数据表")
def list_tables(
    app_token: str = Query(..., description="多维表格 App Token"),
) -> dict:
    """获取多维表格中的数据表列表。"""
    data = feishu_request(
        "GET",
        f"/open-apis/bitable/v1/apps/{app_token}/tables",
    )
    return {"tables": data.get("items", [])}


@router.post("/records/search", summary="查询记录")
def search_records(req: SearchRecordsRequest) -> dict:
    """根据条件查询多维表格中的记录。"""
    body: dict[str, Any] = {"page_size": req.page_size}
    if req.field_names:
        body["field_names"] = req.field_names
    if req.filter:
        body["filter"] = req.filter
    if req.sort:
        body["sort"] = req.sort

    data = feishu_request(
        "POST",
        f"/open-apis/bitable/v1/apps/{req.app_token}/tables/{req.table_id}/records/search",
        json_body=body,
    )
    return {
        "total": data.get("total", 0),
        "items": data.get("items", []),
    }


@router.post("/records", summary="新增记录")
def create_record(req: CreateRecordRequest) -> dict:
    """向数据表中新增一条记录。"""
    data = feishu_request(
        "POST",
        f"/open-apis/bitable/v1/apps/{req.app_token}/tables/{req.table_id}/records",
        json_body={"fields": req.fields},
    )
    record = data.get("record", data)
    return {
        "record_id": record.get("record_id", ""),
        "fields": record.get("fields", {}),
    }


@router.put("/records/{record_id}", summary="更新记录")
def update_record(record_id: str, req: UpdateRecordRequest) -> dict:
    """更新数据表中的一条记录。"""
    data = feishu_request(
        "PUT",
        f"/open-apis/bitable/v1/apps/{req.app_token}/tables/{req.table_id}/records/{record_id}",
        json_body={"fields": req.fields},
    )
    record = data.get("record", data)
    return {
        "record_id": record.get("record_id", record_id),
        "fields": record.get("fields", {}),
    }


path: server/feishu_toolkit/calendar_api.py
content: """日历与会议室模块

提供日程创建、忙闲查询、会议室搜索和预约等功能。
"""

from __future__ import annotations

from datetime import datetime
from typing import Any

from fastapi import APIRouter, Query
from pydantic import BaseModel, Field

from feishu_toolkit.auth import feishu_request


def _to_unix(time_str: str) -> str:
    """将 RFC3339 或 Unix 时间戳字符串统一转为 Unix 秒字符串"""
    # 如果已经是纯数字，直接返回
    if time_str.isdigit():
        return time_str
    try:
        dt = datetime.fromisoformat(time_str)
        return str(int(dt.timestamp()))
    except ValueError:
        return time_str

router = APIRouter()

_primary_calendar_id: str = ""


def _get_primary_calendar() -> str:
    """获取机器人的主日历 ID（自动缓存）"""
    global _primary_calendar_id
    if _primary_calendar_id:
        return _primary_calendar_id

    data = feishu_request("GET", "/open-apis/calendar/v4/calendars")
    for cal in data.get("calendar_list", []):
        if cal.get("role") == "owner":
            _primary_calendar_id = cal["calendar_id"]
            return _primary_calendar_id

    # fallback: 取第一个日历
    calendars = data.get("calendar_list", [])
    if calendars:
        _primary_calendar_id = calendars[0]["calendar_id"]
        return _primary_calendar_id

    raise Exception("No calendar found for this app")


# ── 请求/响应模型 ─────────────────────────────────────────

class CreateEventRequest(BaseModel):
    summary: str = Field(..., description="日程标题")
    start_time: str = Field(..., description="开始时间，RFC3339 格式")
    end_time: str = Field(..., description="结束时间，RFC3339 格式")
    description: str | None = Field(None, description="日程描述")
    attendee_user_ids: list[str] | None = Field(None, description="参与人 open_id 列表")
    room_id: str | None = Field(None, description="会议室 ID")
    calendar_id: str | None = Field(None, description="日历 ID，默认主日历")


class UpdateEventRequest(BaseModel):
    summary: str | None = Field(None, description="日程标题")
    start_time: str | None = Field(None, description="开始时间，RFC3339 格式")
    end_time: str | None = Field(None, description="结束时间，RFC3339 格式")
    description: str | None = Field(None, description="日程描述")
    calendar_id: str | None = Field(None, description="日历 ID，默认主日历")


class FreebusyRequest(BaseModel):
    time_min: str = Field(..., description="查询起始时间，RFC3339 格式")
    time_max: str = Field(..., description="查询结束时间，RFC3339 格式")
    user_id: str | None = Field(None, description="用户 open_id")
    room_id: str | None = Field(None, description="会议室 ID")


class AttendeeItem(BaseModel):
    type: str = Field(..., description="参与人类型：user/chat/resource")
    user_id: str | None = Field(None, description="用户 open_id")
    chat_id: str | None = Field(None, description="群组 ID")
    room_id: str | None = Field(None, description="会议室 ID")


class AddAttendeesRequest(BaseModel):
    attendees: list[AttendeeItem]
    need_notification: bool = True


# ── 路由 ──────────────────────────────────────────────────

@router.post("/events", summary="创建日程")
def create_event(req: CreateEventRequest) -> dict:
    """创建日程，可同时添加参与人和会议室。"""
    cal_id = req.calendar_id or _get_primary_calendar()

    event_body: dict[str, Any] = {
        "summary": req.summary,
        "start_time": {"timestamp": _to_unix(req.start_time)},
        "end_time": {"timestamp": _to_unix(req.end_time)},
    }
    if req.description:
        event_body["description"] = req.description

    data = feishu_request(
        "POST",
        f"/open-apis/calendar/v4/calendars/{cal_id}/events",
        json_body=event_body,
    )

    event = data.get("event", data)
    event_id = event.get("event_id", "")

    # 添加参与人和会议室
    attendees: list[dict[str, Any]] = []
    for uid in req.attendee_user_ids or []:
        attendees.append({"type": "user", "user_id": uid})
    if req.room_id:
        attendees.append({"type": "resource", "room_id": req.room_id})

    if attendees:
        feishu_request(
            "POST",
            f"/open-apis/calendar/v4/calendars/{cal_id}/events/{event_id}/attendees",
            json_body={"attendees": attendees},
        )

    return {
        "event_id": event_id,
        "summary": req.summary,
        "start_time": req.start_time,
        "end_time": req.end_time,
    }


@router.get("/events", summary="获取日程列表")
def list_events(
    start_time: str = Query(..., description="起始时间，RFC3339 格式"),
    end_time: str = Query(..., description="结束时间，RFC3339 格式"),
    calendar_id: str | None = Query(None, description="日历 ID"),
    page_size: int = Query(50, description="每页数量"),
) -> dict:
    """获取指定时间范围内的日程。"""
    cal_id = calendar_id or _get_primary_calendar()
    data = feishu_request(
        "GET",
        f"/open-apis/calendar/v4/calendars/{cal_id}/events",
        params={
            "start_time": _to_unix(start_time),
            "end_time": _to_unix(end_time),
            "page_size": page_size,
        },
    )
    return {"events": data.get("items", [])}


@router.post("/freebusy", summary="查询忙闲状态")
def query_freebusy(req: FreebusyRequest) -> dict:
    """查询用户或会议室在指定时间段内的忙闲情况。"""
    body: dict[str, Any] = {
        "time_min": req.time_min,
        "time_max": req.time_max,
    }
    if req.user_id:
        body["user_id"] = req.user_id
    if req.room_id:
        body["room_id"] = req.room_id

    data = feishu_request(
        "POST",
        "/open-apis/calendar/v4/freebusy/list",
        json_body=body,
    )
    return {"busy_times": data.get("freebusy_list", [])}


@router.get("/rooms", summary="查询会议室列表")
def list_rooms(
    room_level_id: str | None = Query(None, description="层级 ID，为空时返回租户所有会议室"),
    page_size: int = Query(20, description="每页数量"),
    page_token: str | None = Query(None, description="分页标记"),
) -> dict:
    """查询某个会议室层级下的会议室列表。"""
    params: dict[str, Any] = {"page_size": page_size}
    if room_level_id:
        params["room_level_id"] = room_level_id
    if page_token:
        params["page_token"] = page_token

    data = feishu_request(
        "GET",
        "/open-apis/vc/v1/rooms",
        params=params,
    )
    return {
        "rooms": data.get("rooms", []),
        "has_more": data.get("has_more", False),
        "page_token": data.get("page_token", ""),
    }


@router.post("/rooms/search", summary="搜索会议室")
def search_rooms(req: dict) -> dict:
    """通过关键词搜索会议室。"""
    body: dict[str, Any] = {}
    if req.get("keyword"):
        body["keyword"] = req["keyword"]
    if req.get("room_level_id"):
        body["room_level_id"] = req["room_level_id"]
    if req.get("page_size"):
        body["page_size"] = req["page_size"]

    data = feishu_request(
        "POST",
        "/open-apis/vc/v1/rooms/search",
        json_body=body,
    )
    return {
        "rooms": data.get("rooms", []),
        "has_more": data.get("has_more", False),
    }


@router.patch("/events/{event_id}", summary="更新日程")
def update_event(event_id: str, req: UpdateEventRequest) -> dict:
    """更新日程的标题/时间/描述等字段。"""
    cal_id = req.calendar_id or _get_primary_calendar()
    body: dict[str, Any] = {}
    if req.summary is not None:
        body["summary"] = req.summary
    if req.start_time is not None:
        body["start_time"] = {"timestamp": _to_unix(req.start_time)}
    if req.end_time is not None:
        body["end_time"] = {"timestamp": _to_unix(req.end_time)}
    if req.description is not None:
        body["description"] = req.description

    data = feishu_request(
        "PATCH",
        f"/open-apis/calendar/v4/calendars/{cal_id}/events/{event_id}",
        json_body=body,
    )
    event = data.get("event", data)
    return {
        "event_id": event.get("event_id", event_id),
        "summary": event.get("summary", req.summary),
    }


@router.get("/events/{event_id}", summary="获取日程详情")
def get_event(event_id: str, calendar_id: str | None = Query(None, description="日历 ID，默认主日历")) -> dict:
    """获取单个日程的详细信息。"""
    cal_id = calendar_id or _get_primary_calendar()
    data = feishu_request(
        "GET",
        f"/open-apis/calendar/v4/calendars/{cal_id}/events/{event_id}",
    )
    return data.get("event", data)


@router.post("/events/{event_id}/attendees", summary="添加日程参与人")
def add_attendees(event_id: str, req: AddAttendeesRequest) -> dict:
    """为已有日程添加参与人或预约会议室。"""
    cal_id = _get_primary_calendar()
    attendees = []
    for att in req.attendees:
        item: dict[str, Any] = {"type": att.type}
        if att.user_id:
            item["user_id"] = att.user_id
        if att.chat_id:
            item["chat_id"] = att.chat_id
        if att.room_id:
            item["room_id"] = att.room_id
        attendees.append(item)

    data = feishu_request(
        "POST",
        f"/open-apis/calendar/v4/calendars/{cal_id}/events/{event_id}/attendees",
        json_body={
            "attendees": attendees,
            "need_notification": req.need_notification,
        },
    )
    return {"attendees": data.get("attendees", [])}


@router.delete("/events/{event_id}", summary="删除日程")
def delete_event(event_id: str, calendar_id: str | None = Query(None, description="日历 ID，默认主日历")) -> dict:
    """删除指定日历中的一个事件。"""
    cal_id = calendar_id or _get_primary_calendar()
    feishu_request(
        "DELETE",
        f"/open-apis/calendar/v4/calendars/{cal_id}/events/{event_id}",
    )
    return {"success": True, "event_id": event_id}


path: server/feishu_toolkit/contacts_api.py
content: """通讯录模块

提供用户信息查询、部门信息查询和搜索能力。
全部基于 tenant_access_token，Agent 可直接使用。
"""

from __future__ import annotations

import logging

from fastapi import APIRouter, Query
from pydantic import BaseModel, Field

from feishu_toolkit.auth import feishu_request

router = APIRouter()
logger = logging.getLogger("feishu_toolkit.contacts")


# ── 请求模型 ──────────────────────────────────────────────

class SearchUsersRequest(BaseModel):
    query: str = Field(..., description="搜索关键词（姓名）")
    department_id: str = Field("0", description="从哪个部门开始搜索，默认根部门")
    recursive: bool = Field(True, description="是否递归搜索子部门")


class SearchDepartmentsRequest(BaseModel):
    query: str = Field(..., description="搜索关键词（部门名称）")
    parent_department_id: str = Field("0", description="父部门 ID，默认根部门")


# ── 内部工具 ──────────────────────────────────────────────

def _list_sub_departments(parent_id: str = "0") -> list[dict]:
    """列出指定部门的直属子部门（tenant_access_token）"""
    all_depts: list[dict] = []
    page_token = ""
    while True:
        params: dict = {"parent_department_id": parent_id, "page_size": 50}
        if page_token:
            params["page_token"] = page_token
        data = feishu_request(
            "GET",
            "/open-apis/contact/v3/departments",
            params=params,
        )
        all_depts.extend(data.get("items", []))
        if not data.get("has_more"):
            break
        page_token = data.get("page_token", "")
    return all_depts


def _list_department_users_internal(department_id: str) -> list[dict]:
    """列出部门直属成员（tenant_access_token）"""
    all_users: list[dict] = []
    page_token = ""
    while True:
        params: dict = {"department_id": department_id, "page_size": 50}
        if page_token:
            params["page_token"] = page_token
        data = feishu_request(
            "GET",
            "/open-apis/contact/v3/users/find_by_department",
            params=params,
        )
        all_users.extend(data.get("items", []))
        if not data.get("has_more"):
            break
        page_token = data.get("page_token", "")
    return all_users


def _collect_departments_recursive(parent_id: str = "0", max_depth: int = 5) -> list[dict]:
    """递归收集部门树（限制深度防止过深遍历）"""
    if max_depth <= 0:
        return []
    depts = _list_sub_departments(parent_id)
    result = list(depts)
    for dept in depts:
        dept_id = dept.get("open_department_id", "")
        if dept_id:
            result.extend(_collect_departments_recursive(dept_id, max_depth - 1))
    return result


# ── 路由 ──────────────────────────────────────────────────

@router.get("/user/{user_id}", summary="获取用户信息")
def get_user(
    user_id: str,
    user_id_type: str = Query("open_id", description="ID 类型：open_id/user_id/union_id"),
) -> dict:
    """根据用户 ID 获取用户详细信息。"""
    data = feishu_request(
        "GET",
        f"/open-apis/contact/v3/users/{user_id}",
        params={"user_id_type": user_id_type},
    )
    return data.get("user", data)


@router.post("/users/search", summary="搜索用户")
def search_users(req: SearchUsersRequest) -> dict:
    """通过关键词搜索用户（基于部门遍历 + 姓名匹配）。

    使用 tenant_access_token，遍历部门成员并按姓名模糊匹配。
    """
    query_lower = req.query.lower()

    # 收集要搜索的部门 ID
    dept_ids = [req.department_id]
    if req.recursive:
        sub_depts = _collect_departments_recursive(req.department_id)
        dept_ids.extend(d.get("open_department_id", "") for d in sub_depts if d.get("open_department_id"))

    matched: list[dict] = []
    for dept_id in dept_ids:
        try:
            users = _list_department_users_internal(dept_id)
        except Exception:
            logger.debug("Skipping dept %s", dept_id)
            continue
        for user in users:
            name = user.get("name", "")
            en_name = user.get("en_name", "")
            if query_lower in name.lower() or query_lower in en_name.lower():
                matched.append(user)
    return {"items": matched}


@router.get("/department/{department_id}", summary="获取部门信息")
def get_department(department_id: str) -> dict:
    """获取单个部门的详细信息。"""
    data = feishu_request(
        "GET",
        f"/open-apis/contact/v3/departments/{department_id}",
    )
    return data.get("department", data)


@router.post("/departments/search", summary="搜索部门")
def search_departments(req: SearchDepartmentsRequest) -> dict:
    """通过部门名称搜索部门（基于部门树遍历 + 名称匹配）。

    使用 tenant_access_token，递归遍历部门树并按名称模糊匹配。
    """
    query_lower = req.query.lower()
    all_depts = _collect_departments_recursive(req.parent_department_id)
    matched = [
        dept for dept in all_depts
        if query_lower in dept.get("name", "").lower()
    ]
    return {"items": matched}


@router.get("/department/{department_id}/users", summary="获取部门成员")
def list_department_users(
    department_id: str,
    page_size: int = Query(50, description="每页数量"),
    page_token: str | None = Query(None, description="分页标记"),
) -> dict:
    """获取指定部门的直属成员列表。"""
    params: dict = {
        "department_id": department_id,
        "page_size": page_size,
    }
    if page_token:
        params["page_token"] = page_token

    data = feishu_request(
        "GET",
        "/open-apis/contact/v3/users/find_by_department",
        params=params,
    )
    return {
        "items": data.get("items", []),
        "has_more": data.get("has_more", False),
        "page_token": data.get("page_token", ""),
    }


path: server/feishu_toolkit/main.py
content: import logging
import os

from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

from feishu_toolkit.calendar_api import router as calendar_router
from feishu_toolkit.messaging_api import router as messaging_router
from feishu_toolkit.approval_api import router as approval_router
from feishu_toolkit.bitable_api import router as bitable_router
from feishu_toolkit.contacts_api import router as contacts_router
from feishu_toolkit.attendance_api import router as attendance_router

app = FastAPI(
    title="飞书办公套件 API",
    description="飞书办公套件代理服务 — 日历/消息/审批/多维表格/通讯录/考勤",
    version="0.1.0",
    servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://127.0.0.1:8002")}],
)
logger = logging.getLogger("feishu_toolkit.main")

# ── 注册子路由 ────────────────────────────────────────────
app.include_router(calendar_router, prefix="/calendar", tags=["日历与会议室"])
app.include_router(messaging_router, prefix="/messaging", tags=["消息"])
app.include_router(approval_router, prefix="/approval", tags=["审批"])
app.include_router(bitable_router, prefix="/bitable", tags=["多维表格"])
app.include_router(contacts_router, prefix="/contacts", tags=["通讯录"])
app.include_router(attendance_router, prefix="/attendance", tags=["考勤"])


# ── 健康检查 ──────────────────────────────────────────────
@app.get("/ping")
def ping() -> dict[str, str]:
    """健康检查"""
    return {"message": "pong"}


# ── 全局异常处理 ──────────────────────────────────────────
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request, exc: RequestValidationError
) -> JSONResponse:
    logger.error(
        "Validation error on %s %s. body=%s errors=%s",
        request.method,
        request.url.path,
        exc.body,
        exc.errors(),
    )
    return JSONResponse(
        status_code=422,
        content=jsonable_encoder({"detail": exc.errors()}),
    )


if __name__ == "__main__":
    import uvicorn

    port = int(os.getenv("PORT", "8002"))
    uvicorn.run("feishu_toolkit.main:app", host="0.0.0.0", port=port)


path: server/feishu_toolkit/messaging_api.py
content: """消息模块

提供向个人或群聊发送文本、富文本和卡片消息的能力。
"""

from __future__ import annotations

from fastapi import APIRouter
from pydantic import BaseModel, Field

from feishu_toolkit.auth import feishu_request

router = APIRouter()


# ── 请求模型 ──────────────────────────────────────────────

class SendMessageRequest(BaseModel):
    receive_id: str = Field(..., description="接收者 ID")
    receive_id_type: str = Field(..., description="ID 类型：open_id/user_id/email/chat_id")
    msg_type: str = Field(..., description="消息类型：text/post/interactive/image/file")
    content: str = Field(..., description="消息内容（JSON 字符串）")


class ReplyMessageRequest(BaseModel):
    message_id: str = Field(..., description="要回复的消息 ID")
    msg_type: str = Field(..., description="消息类型")
    content: str = Field(..., description="消息内容（JSON 字符串）")


# ── 路由 ──────────────────────────────────────────────────

@router.post("/send", summary="发送消息")
def send_message(req: SendMessageRequest) -> dict:
    """向指定用户或群聊发送消息。"""
    data = feishu_request(
        "POST",
        "/open-apis/im/v1/messages",
        params={"receive_id_type": req.receive_id_type},
        json_body={
            "receive_id": req.receive_id,
            "msg_type": req.msg_type,
            "content": req.content,
        },
    )
    return {
        "message_id": data.get("message_id", ""),
        "msg_type": req.msg_type,
    }


@router.post("/reply", summary="回复消息")
def reply_message(req: ReplyMessageRequest) -> dict:
    """回复指定消息。"""
    data = feishu_request(
        "POST",
        f"/open-apis/im/v1/messages/{req.message_id}/reply",
        json_body={
            "msg_type": req.msg_type,
            "content": req.content,
        },
    )
    return {
        "message_id": data.get("message_id", ""),
        "msg_type": req.msg_type,
    }


path: server/feishu_toolkit/__init__.py
content: """飞书办公套件 API 代理服务"""


path: server/pyproject.toml
content: [project]
name = "feishu-toolkit"
version = "0.1.0"
description = "飞书办公套件 API 代理服务"
readme = "../SKILL.md"
requires-python = ">=3.11"
dependencies = [
  "fastapi>=0.110.0",
  "httpx>=0.27.0",
  "uvicorn[standard]>=0.29.0",
  "truststore>=0.9.0",
]

[project.optional-dependencies]
dev = [
  "pytest>=8.0.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["feishu_toolkit"]

[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)