@clawhub-lens-lzy-1fd9181428
教飞书agent如何发送语音气泡消息。需要先将文字转语音(MP3),再转为opus格式,最后通过飞书消息工具发送。
---
name: feishu-voice-bubble
description: 教飞书agent如何发送语音气泡消息。需要先将文字转语音(MP3),再转为opus格式,最后通过飞书消息工具发送。
user-invocable: true
metadata:
trigger_words:
- 发语音气泡
- 发送语音消息
- 语音气泡
- 发送语音
- voice bubble
- 语音
---
# Skill: 飞书语音气泡发送
**Trigger Words (触发词):**
- 发语音气泡
- 发送语音消息
- 语音气泡
- 发送语音
- voice bubble
- 语音
---
## 前置条件
### 环境要求
此技能依赖 `edge-tts` 和 `ffmpeg`,技能目录下已提供 `setup.sh` 自动安装脚本。
**OpenClaw 安装此技能时,请执行以下命令完成依赖安装:**
```bash
bash setup.sh
```
脚本会自动完成:
- 通过 `pip` 安装 `edge-tts`
- 通过系统包管理器(apt-get / brew / yum)安装 `ffmpeg`(版本 4.4.2+)
> 如果自动安装失败,请手动安装:
> - edge-tts:`pip3 install edge-tts`
> - ffmpeg:参考 https://ffmpeg.org/download.html
### 需要的工具
1. `tts` - 文字转语音(生成MP3)
2. `exec` - 用ffmpeg转换格式
3. `feishu_im_bot_image` 或 `message` - 发送.opus文件
---
## 初始化引导(首次安装后执行)
`setup.sh` 执行完毕后,**立即**向用户发送以下欢迎消息,并引导完成音色初始设置:
> 🎙️ feishu-voice-bubble 已安装完成!
> 我支持 14 种中文音色,现在为你逐一播放试音,听完告诉我你喜欢哪个就好。
然后按照下方「音色切换流程」逐一发送试音气泡。
---
## 音色管理
### 读取当前音色
每次发送语音前,先读取当前音色设置:
```python
voice = ctx.state.get("tts_voice", "zh-CN-XiaoxiaoNeural") # 默认音色
```
### 音色切换触发词
当用户说出以下类似内容时,进入音色切换流程:
- "换个声音"、"切换音色"、"换个音色"、"我想换声音"
- "有哪些声音可以选"、"给我听听其他声音"
- "change voice"、"switch voice"
### 音色切换流程
**第一步**:发送文字说明当前音色,并告知即将逐一试音:
> 当前音色:**{当前音色名}**
> 共有 14 种中文音色,我来逐一播放,听完告诉我你想选哪个 👇
**第二步**:按顺序逐一发送每个音色的试音语音气泡,每条气泡前先发一条文字标注编号和音色信息:
| # | 音色名 | 性别 | 风格 | 特点 | 试音文本 |
|---|---|---|---|---|---|
| 1 | `zh-CN-XiaoxiaoNeural` | 女 | 新闻/小说 | 温暖 | 你好,我是晓晓,很高兴认识你。 |
| 2 | `zh-CN-XiaoyiNeural` | 女 | 动画/小说 | 活泼 | 你好,我是晓伊,超级开心见到你! |
| 3 | `zh-CN-YunjianNeural` | 男 | 体育/小说 | 激情 | 你好,我是云健,让我们一起加油! |
| 4 | `zh-CN-YunxiNeural` | 男 | 小说 | 阳光活泼 | 你好,我是云希,今天也是元气满满的一天! |
| 5 | `zh-CN-YunxiaNeural` | 男 | 动画/小说 | 可爱 | 你好,我是云夏,嘿嘿,好高兴认识你哦。 |
| 6 | `zh-CN-YunyangNeural` | 男 | 新闻 | 专业可靠 | 您好,我是云扬,为您提供专业播报服务。 |
| 7 | `zh-CN-liaoning-XiaobeiNeural` | 女 | 方言 | 东北话 | 你好,我是晓北,老铁没毛病,双击666! |
| 8 | `zh-CN-shaanxi-XiaoniNeural` | 女 | 方言 | 陕西话 | 你好,我是晓妮,额是陕西人,可高兴了! |
| 9 | `zh-HK-HiuGaaiNeural` | 女 | 粤语 | 友好 | 你好,我係曉佳,好高興認識你! |
| 10 | `zh-HK-HiuMaanNeural` | 女 | 粤语 | 友好 | 你好,我係曉曼,希望我哋可以做朋友! |
| 11 | `zh-HK-WanLungNeural` | 男 | 粤语 | 友好 | 你好,我係雲龍,隨時為你服務! |
| 12 | `zh-TW-HsiaoChenNeural` | 女 | 台湾普通话 | 友好 | 你好,我是曉臻,很開心認識你喔! |
| 13 | `zh-TW-HsiaoYuNeural` | 女 | 台湾普通话 | 友好 | 你好,我是曉雨,希望我們可以成為好朋友! |
| 14 | `zh-TW-YunJheNeural` | 男 | 台湾普通话 | 友好 | 你好,我是雲哲,很榮幸為你服務! |
每条试音的发送格式(文字 + 语音气泡):
```
[文字消息] 🔊 #3 云健 · 男声 · 激情风格
[语音气泡] 用 zh-CN-YunjianNeural 生成的 opus 文件
```
**第三步**:全部播放完毕后,发送:
> 14 种音色已全部播放完毕 🎉
> 请告诉我你想选哪个?可以说编号(如"选3")或音色名(如"云希")。
**第四步**:用户确认后,写入 `ctx.state` 并回复确认:
```python
ctx.state.set("tts_voice", "zh-CN-YunxiNeural") # 替换为用户选择的音色
```
> ✅ 已切换为 **云希**(zh-CN-YunxiNeural),后续语音气泡将使用此音色。
---
## ⚠️ 重要行为约束
**禁止在生成 MP3 后直接将 MP3 文件发送到飞书聊天窗口。**
MP3 是中间产物,仅用于 ffmpeg 转换,不应作为最终消息发出。正确流程必须是:
```
生成 MP3 → 转换为 opus → 发送 opus(语音气泡)
```
**唯一例外**:用户明确要求发送 MP3 文件(如"把这个音频文件发给我")时,才可直接发送 MP3。
---
## 完整操作步骤
### Step 1: 文字转语音 (MP3)
先读取用户设置的音色(若未设置则用默认值):
```python
voice = ctx.state.get("tts_voice", "zh-CN-XiaoxiaoNeural")
```
使用 `tts` 工具生成MP3文件:
```json
{
"channel": "feishu",
"text": "要转成语音的文字内容",
"voice": "<voice>"
}
```
> ⚠️ 注意:tts工具会返回音频文件路径,保存到 `/tmp/openclaw/` 目录下
### Step 2: 转换为 Opus 格式
使用 `exec` 运行 ffmpeg 命令转换格式:
```bash
ffmpeg -y -i /tmp/openclaw/xxx.mp3 -c:a libopus -b:a 32k -ar 48000 -ac 1 /tmp/openclaw/xxx.opus
```
参数说明:
- `-y` - 覆盖已存在的文件
- `-i input.mp3` - 输入文件
- `-c:a libopus` - 使用opus编码
- `-b:a 32k` - 音频比特率
- `-ar 48000` - 采样率
- `-ac 1` - 单声道
- `output.opus` - 输出文件
### Step 3: 发送语音气泡
使用 `feishu_im_bot_image` 工具发送 .opus 文件:
```json
{
"message_id": "om_xxx",
"file_key": "file_xxx",
"type": "file"
}
```
或者使用 `message` 工具:
```json
{
"action": "send",
"filePath": "/tmp/openclaw/xxx.opus"
}
```
---
## 完整示例流程
```python
# 1. 调用tts生成语音
tts(text="你好,这是我的第一条语音消息!")
# 2. 假设返回的文件是 /tmp/openclaw/voice_123.mp3
# 用ffmpeg转换
exec(command="ffmpeg -y -i /tmp/openclaw/voice_123.mp3 -c:a libopus -b:a 32k -ar 48000 -ac 1 /tmp/openclaw/voice_123.opus")
# 3. 发送 opus 文件
feishu_im_bot_image(message_id="om_xxx", file_key="file_xxx", type="file")
```
---
## 常见问题
### Q1: 为什么不能直接发MP3?
A: 飞书语音气泡只支持opus格式,MP3发出去只是附件而非语音消息。
### Q2: ffmpeg找不到怎么办?
A: 确保ffmpeg已安装且在系统PATH中。如果是在OpenClaw环境中,应该已经预装了。
### Q3: 语音没有声音怎么办?
A: 检查opus文件是否正确生成,可以用 `ffprobe` 查看音频信息确认。
### Q4: 发送失败怎么办?
A: 确保message_id和file_key正确,且bot有发送文件的权限。
---
## 进阶技巧
1. **批量处理**: 如果需要发多条语音,可以循环执行tts+ffmpeg转换
2. **音色切换**: 用户可随时通过自然语言要求切换音色,参见「音色管理」章节
3. **文件名随机**: 使用时间戳或UUID避免文件名冲突
---
## 相关文件
- TOOLS.md - 基础环境配置
- 此技能依赖 tts, exec, feishu_im_bot_image 工具
FILE:setup.sh
#!/bin/bash
# setup.sh - 安装 feishu-voice-bubble 技能所需的依赖
# OpenClaw 在安装此技能时会自动执行此脚本
set -e
echo "=== feishu-voice-bubble 依赖安装 ==="
# 安装 edge-tts
echo "[1/2] 安装 edge-tts..."
if command -v pip3 &>/dev/null; then
pip3 install --quiet edge-tts
elif command -v pip &>/dev/null; then
pip install --quiet edge-tts
else
echo "错误:未找到 pip,请手动安装 Python 和 pip 后重试。"
exit 1
fi
echo "✓ edge-tts 安装完成"
# 安装 ffmpeg
echo "[2/2] 安装 ffmpeg..."
if command -v ffmpeg &>/dev/null; then
echo "✓ ffmpeg 已安装,跳过"
else
if command -v apt-get &>/dev/null; then
apt-get install -y -q ffmpeg
elif command -v brew &>/dev/null; then
brew install ffmpeg
elif command -v yum &>/dev/null; then
yum install -y ffmpeg
else
echo "错误:无法自动安装 ffmpeg,请手动安装(版本 4.4.2+)。"
exit 1
fi
echo "✓ ffmpeg 安装完成"
fi
echo ""
echo "=== 所有依赖安装完成,feishu-voice-bubble 技能已就绪 ==="
处理飞书消息中正确 @ 人的问题。用于解释为什么直接写 @xxx 不会生效、指导使用 post 富文本消息进行 @ 提及,以及说明如何获取 user_id 或 open_id。
---
name: feishu-at
description: 处理飞书消息中正确 @ 人的问题。用于解释为什么直接写 @xxx 不会生效、指导使用 post 富文本消息进行 @ 提及,以及说明如何获取 user_id 或 open_id。
---
# 飞书 @ 人
当用户想在飞书里真正 `@` 某个人,或遇到“写了 `@xxx` 但没有高亮提醒”的情况时,使用这个技能。
## 核心结论
- 直接写 `@xxx` 只是普通文本,不会触发飞书的提醒和高亮。
- 默认推荐使用 `msg_type: "post"` 的富文本消息来实现真正的 `@`。
- 构造 `@` 节点前,必须先拿到对方的 `user_id` 或 `open_id`。
## 默认处理方式
如果用户只是问“怎么在飞书里 @ 某人”,优先按下面的思路回答:
1. 先明确问题根因:直接写 `@xxx` 不会生效,只是普通文本。
2. 给出推荐方案:改用富文本 `post` 消息。
3. 提醒必须准备对方的 `user_id/open_id`。
4. 再提供一个可以直接套用的 JSON 示例。
除非用户明确要求别的消息格式,否则不要优先展开卡片消息或其他格式。
## 飞书 @ 人的正确方式
### 问题
直接写 `@xxx` 不会高亮提醒,只是普通文本。
### 解决方案
使用富文本 `post` 消息格式,并在内容中放入 `tag: "at"` 节点。
### 获取 `user_id` 的方法
1. 从群成员列表获取:使用 `feishu_chat_members`
- 适合已经知道对方真实姓名的场景
- 注意:通常只能拿到真实姓名,不能保证拿到群昵称
2. 从消息记录获取:查看已经包含 `@xxx` 的消息
- 从消息里的 `mentions` 数组中读取 `id` 字段
- 这是定位某个实际被提及用户最稳妥的方法之一
### 发送富文本消息示例
```json
{
"msg_type": "post",
"content": "{\"zh_cn\":{\"title\":\"标题\",\"content\":[[{\"tag\":\"at\",\"user_id\":\"ou_xxx\",\"text\":\"@用户名\"},{\"tag\":\"text\",\"text\":\" 消息内容\"}]]}}"
}
```
### 关键点
- `tag: "at"` 表示这是一个真正的 @ 提及节点
- `user_id: "ou_xxx"` 是被提及人的用户标识
- `text: "@用户名"` 是消息里展示给人看的文本
## 回答时要强调的注意事项
- 不能只把 `@用户名` 当普通字符串拼进去,否则不会触发提醒。
- 没有 `user_id/open_id` 时,不能保证真正 @ 到指定用户。
- 如果用户只提供了昵称,先提示其补充可映射到账号的标识,或建议从历史消息的 `mentions` 中反查。
## 推荐回答模板
需要真正 `@` 某人的话,不能直接写 `@xxx`,那样只是普通文本。飞书里应当使用 `post` 富文本消息,并在内容里放一个 `tag: "at"` 节点;同时需要先拿到对方的 `user_id/open_id`。如果你还没有这个 ID,可以先从 `feishu_chat_members` 查群成员,或者从历史消息的 `mentions` 数组里取 `id`。
```json
{
"msg_type": "post",
"content": "{\"zh_cn\":{\"title\":\"标题\",\"content\":[[{\"tag\":\"at\",\"user_id\":\"ou_xxx\",\"text\":\"@用户名\"},{\"tag\":\"text\",\"text\":\" 消息内容\"}]]}}"
}
```
将需求文档、表单字段规则、审批流程定义和 UI 说明转化为结构化测试用例,适用于表单校验、流程审批、状态流转、权限隔离和业务分支测试场景。
--- name: feishu-pro-testcase-generation description: 将需求文档、表单字段规则、审批流程定义和 UI 说明转化为结构化测试用例,适用于表单校验、流程审批、状态流转、权限隔离和业务分支测试场景。 user-invocable: true license: MIT --- # 资深测试用例设计专家 你是一名资深测试用例设计专家,负责将用户提供的需求材料转化为专业、可执行、可评审的测试用例。 ## 适用范围 当用户提供以下任一材料,并希望产出测试用例、测试点、测试清单或测试设计时,使用本技能: - 需求文档(PRD)、业务背景、功能说明、业务规则 - 表单字段定义、必填选填规则、格式校验规则 - 审批流、流程图、状态机、节点参与者配置 - UI/UX 蓝图、页面说明、交互说明、按钮行为 - 截图描述、原型说明、模块级功能描述 ## 开场欢迎语 当用户刚开始使用本技能,或只表达了“帮我生成测试用例”但尚未提供完整材料时,先发送以下欢迎语: 🎯 您好!我是您的专属资深测试用例设计师。 请将您的以下材料发送给我: - 📄 需求文档(PRD) - 📝 表单字段规则 - 🎨 UI/UX 蓝图说明 您提供的信息越详细,我生成的用例覆盖度就越高! 我们可以先从一个特定的模块开始,请发送您的需求吧~ ## 核心原则 1. 完全忠于输入,只基于用户明确提供的材料生成测试用例,不自行扩展未说明的业务功能。 2. 自动补足常规测试设计维度,例如边界值、空值、非法值、重复值、长度、格式、权限和状态流转,但不要虚构业务规则。 3. 每条用例都必须可执行,步骤要具体到字段名、按钮名、角色、操作动作和输入值。 4. 预期结果必须具体描述界面提示、数据变化、状态变化、流转去向和可见性变化,禁止使用“操作成功”这类空泛表述。 5. 如果需求存在歧义、缺失或冲突,在测试用例后单独输出“需求确认建议(疑问点)”。 ## 工作流程 ### Step 1: 需求拆解 收到材料后,先识别并整理以下信息: - 核心业务目标 - 涉及的用户角色与权限 - 表单字段、控件类型、输入限制、默认值、必填规则 - 页面动作和交互行为,例如提交、保存、返回、驳回、撤销、转办 - 流程生命周期与状态流转 - 审批节点、参与者设置、分支条件、回写逻辑 - 明确写出的业务规则、限制条件和异常处理方式 ### Step 2: 测试策略覆盖 设计测试场景时,优先覆盖以下维度: 1. 正向场景(Happy Path):主流程完整闭环。 2. 异常/逆向场景:必填缺失、非法输入、极值、重复提交、错误格式。 3. 状态流转测试:提交、同意、驳回、退回、撤销、转办、挂起,以及状态回写。 4. UI 与交互覆盖:弹窗、按钮可用性、提示语、加载态、禁用态、重复点击保护。 5. 权限场景:不同角色的可见范围、可操作范围、数据隔离。 6. 数据校验:长度、类型、格式、唯一性、枚举值、默认值、联动校验。 7. 业务场景:不同人员、组织、金额、条件分支导致的不同流程路径。 8. 审批节点参与者设置测试:参与方式设置、多角色单一出口、无参与者跳过规则。 9. 驳回设置逻辑测试:驳回方式设置、被驳回节点重新提交后的执行逻辑。 ### Step 3: 输出结果 - 默认输出为 Markdown 表格。 - 如果用户明确要求生成飞书文档、上传云盘或写本地文件,且当前环境确实提供相关工具,再使用工具。 - 如果工具不可用或用户未指定输出方式,直接在对话中输出 Markdown 表格。 ## 输出要求 ### 测试场景与测试步骤的区别 **测试场景**是对测试目标的概括性描述,回答”验证什么”: - ✅ 正确示例:验证姓名字段必填校验 - ✅ 正确示例:验证审批驳回后状态回退 - ✅ 正确示例:验证金额超限时自动升级审批流程 **测试步骤**是具体的、可执行的原子操作,回答”怎么做”: - ❌ 错误示例:验证必填校验(这是场景,不是步骤) - ❌ 错误示例:输入有效数据(太抽象) - ✅ 正确示例:在[姓名]字段输入”张三” - ❌ 错误示例:点击提交(不够具体) - ✅ 正确示例:点击页面右下角”提交审批”按钮 - ❌ 错误示例:验证提示信息(这是预期结果,不是步骤) - ✅ 正确示例:观察页面顶部提示区域(如果需要明确观察动作) ### 测试步骤写法规范 每个测试步骤必须包含: 1. 明确的操作对象(字段名、按钮名、区域名) 2. 明确的操作动作(输入、点击、选择、切换、滚动等) 3. 具体的操作内容(输入值、选择项等) 禁止在测试步骤中出现: - 场景级描述(”验证XXX”、”检查XXX”) - 抽象表述(”输入有效数据”、”填写表单”) - 预期结果(”系统提示XXX”应该写在预期结果列) ### 预期结果写法 - 不要写:操作成功 - 要写:系统弹出 Toast 提示“保存成功”,列表首行显示新建数据,状态列显示“待审批” ### 优先级定义 | 优先级 | 说明 | | :--- | :--- | | P0 | 核心主流程,必须通过 | | P1 | 重要功能与高频异常 | | P2 | 一般功能与低频异常 | | P3 | UI 展示与极少触发的边界场景 | ## 标准输出格式 ### 输出结构说明 输出分为两层:先按模块输出"测试场景总览表",再输出每个场景下的"测试用例明细表"。 - 测试场景:描述"验证什么",是对一类测试目标的概括,例如"验证姓名字段必填校验"、"验证审批驳回后状态回退"。 - 测试用例:描述"怎么验证",是场景下具体的、可执行的操作步骤和预期结果。 ### 第一层:测试场景总览表 按模块分组,每个模块先输出场景总览: | 场景编号 | 模块/功能点 | 测试场景 | 场景类型 | 优先级 | | :--- | :--- | :--- | :--- | :--- | | TS-001 | 基本信息表单 | 验证姓名字段必填校验 | 数据校验 | P0 | | TS-002 | 基本信息表单 | 验证姓名字段最大长度边界 | 边界 | P1 | 其中: - 场景编号:按模块递增,例如 `TS-001` - 模块/功能点:写清模块名和具体功能点 - 测试场景:用一句话概括该场景要验证的目标,格式为"验证 + 对象 + 行为/规则" - 场景类型:例如 正向、逆向、边界、权限、流程、UI、数据校验 - 优先级:只能使用 `P0`、`P1`、`P2`、`P3` ### 第二层:测试用例明细表 在场景总览表之后,输出每个场景对应的用例明细: | 用例编号 | 所属场景 | 前置条件 | 测试步骤 | 预期结果 | | :--- | :--- | :--- | :--- | :--- | 其中: - 用例编号:按场景递增,例如 `TC-001-01`(属于 TS-001 的第 1 条用例) - 所属场景:关联的场景编号,例如 `TS-001` - 前置条件:只写执行该用例前必须满足的条件 - 测试步骤:逐步描述具体的原子操作,必要时分 1、2、3。每一步必须是一个明确的用户动作(点击、输入、选择、切换等),禁止在步骤中写场景级描述 - 预期结果:逐条对应步骤结果,重点写页面提示、数据变化、状态变化、流转结果 ## 需求确认建议 如果材料中存在不明确、互相冲突或无法判断的规则,在测试用例下方追加以下章节: ## 📋 需求确认建议(疑问点) | 序号 | 疑问点 | 建议确认内容 | | :--- | :--- | :--- | | 1 | XXX字段取值 | 是否支持中文?最大长度是多少? | | 2 | 审批节点 | 驳回后数据如何处理? | 疑问点只针对用户输入中缺失或冲突的信息,不要为了凑数量而强行补充。 ## 工具使用规则 仅在当前环境已提供对应工具时,才可调用: - `feishu_create_doc`:将测试用例创建为飞书文档 - `feishu_drive_file`:将生成内容上传到指定云盘位置 - `write`:将测试用例保存为本地文件 使用工具前遵循以下规则: 1. 先完成测试用例内容本身,再决定是否落地为文档或文件。 2. 用户未指定输出方式时,默认直接展示 Markdown 表格。 3. 用户要求输出到飞书或云盘但缺少必要信息时,仅补问最关键的信息,例如目标文件夹或文档标题。 4. 若工具不可用,不要伪造执行结果,改为直接输出内容并说明原因。 ## 生成时的行为要求 - 优先按模块分组输出,保证结构清晰。 - 当字段存在明确长度限制时,自动包含命中边界值的测试,例如 50 字符和 51 字符。 - 当流程存在状态机时,尽量覆盖完整生命周期,而不是只覆盖提交成功。 - 当角色、组织层级、审批人来源会影响流程分支时,必须体现差异化用例。 - 当用户要求“先从某个模块开始”时,仅输出该模块相关内容,不擅自展开到其他模块。 - 如果输入信息很少,先基于现有信息输出可落地的最小测试集,再列出缺失信息和确认建议。
飞书在线电子表格(Sheets)操作,包括创建、读取、写入、追加数据、管理工作表。 当用户提到飞书电子表格、在线表格、电子表格时使用(不是多维表格 Bitable)。 支持:创建表格、读写单元格、追加行、插入/删除行列、管理工作表。
---
name: skill-feishu-sheets
description: |
飞书在线电子表格(Sheets)操作,包括创建、读取、写入、追加数据、管理工作表。
当用户提到飞书电子表格、在线表格、电子表格时使用(不是多维表格 Bitable)。
支持:创建表格、读写单元格、追加行、插入/删除行列、管理工作表。
---
# 飞书电子表格工具
统一使用 `feishu_sheets` 工具,通过 action 参数区分不同的表格操作。
## Token 提取
从 URL `https://xxx.feishu.cn/sheets/shtABC123` → `spreadsheet_token` = `shtABC123`
## 操作列表
### 创建电子表格
```json
{ "action": "create", "title": "新建表格" }
```
可选指定文件夹:
```json
{ "action": "create", "title": "新建表格", "folder_token": "fldcnXXX" }
```
返回:spreadsheet_token、url、title
### 写入数据
```json
{
"action": "write",
"spreadsheet_token": "shtABC123",
"sheet_id": "0bxxxx",
"range": "A1:C3",
"values": [["姓名", "年龄", "城市"], ["Alice", 25, "北京"], ["Bob", 30, "上海"]]
}
```
### 读取数据
```json
{
"action": "read",
"spreadsheet_token": "shtABC123",
"sheet_id": "0bxxxx",
"range": "A1:C10"
}
```
### 追加数据
```json
{
"action": "append",
"spreadsheet_token": "shtABC123",
"sheet_id": "0bxxxx",
"values": [["Charlie", 28, "深圳"]]
}
```
### 插入行/列
```json
{
"action": "insert_dimension",
"spreadsheet_token": "shtABC123",
"sheet_id": "0bxxxx",
"dimension": "ROWS",
"start_index": 5,
"end_index": 7
}
```
### 删除行/列
```json
{
"action": "delete_dimension",
"spreadsheet_token": "shtABC123",
"sheet_id": "0bxxxx",
"dimension": "ROWS",
"start_index": 5,
"end_index": 7
}
```
### 获取表格信息
```json
{ "action": "get_info", "spreadsheet_token": "shtABC123" }
```
返回:表格元数据,包含所有工作表的 sheet_id 和标题
### 新增工作表
```json
{
"action": "add_sheet",
"spreadsheet_token": "shtABC123",
"title": "Sheet2"
}
```
### 删除工作表
```json
{
"action": "delete_sheet",
"spreadsheet_token": "shtABC123",
"sheet_id": "0bxxxx"
}
```
## 范围格式
- 单个单元格:`A1`、`B5`
- 范围:`A1:C10`、`B2:D5`
- 整列:`A:A`、`B:D`
- 整行:`1:1`、`3:5`
- 带 sheet_id:`0bxxxx!A1:C10`
## 工作表 ID
- 从 URL 获取:`https://xxx.feishu.cn/sheets/shtABC123?sheet=0bxxxx`
- 通过 get_info 操作获取
- 默认第一个工作表的 ID 通常类似 `0bxxxx`
## 数据类型
支持的值类型:
- 字符串:`"你好"`
- 数字:`123`、`45.67`
- 公式:`{"type": "formula", "text": "=SUM(A1:A10)"}`
- 链接:`{"type": "url", "text": "点击这里", "link": "https://..."}`
## 配置
```yaml
channels:
feishu:
tools:
sheets: true # 默认:true
```
## 所需权限
- `sheets:spreadsheet` - 创建和管理电子表格
- `sheets:spreadsheet:readonly` - 读取电子表格数据
- `drive:drive` - 访问云空间
## API 参考
基础 URL:`https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/`
详细 API 文档请参阅 references/api-reference.md。
FILE:scripts/feishu_sheets.py
#!/usr/bin/env python3
"""
Feishu Sheets API Client
Supports: create, read, write, append, insert/delete rows/columns
"""
import os
import sys
import json
import requests
from typing import List, Dict, Any, Optional
# API Base URLs
BASE_URL = "https://open.feishu.cn/open-apis"
SHEETS_API = f"{BASE_URL}/sheets/v2/spreadsheets"
AUTH_API = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
class FeishuSheetsClient:
def __init__(self):
self.app_id = os.getenv("FEISHU_APP_ID")
self.app_secret = os.getenv("FEISHU_APP_SECRET")
self._token = None
def _get_token(self) -> str:
"""Get tenant access token"""
if self._token:
return self._token
resp = requests.post(AUTH_API, json={
"app_id": self.app_id,
"app_secret": self.app_secret
})
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise Exception(f"Auth failed: {data}")
self._token = data["tenant_access_token"]
return self._token
def _headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/json"
}
def create_spreadsheet(self, title: str, folder_token: Optional[str] = None) -> Dict:
"""Create a new spreadsheet"""
url = f"{BASE_URL}/sheets/v3/spreadsheets"
payload = {"title": title}
if folder_token:
payload["folder_token"] = folder_token
resp = requests.post(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
def get_spreadsheet_info(self, spreadsheet_token: str) -> Dict:
"""Get spreadsheet metadata including sheets"""
url = f"{SHEETS_API}/{spreadsheet_token}/metainfo"
resp = requests.get(url, headers=self._headers())
resp.raise_for_status()
return resp.json()
def read_values(self, spreadsheet_token: str, sheet_id: str, range_str: str) -> Dict:
"""Read values from a range"""
range_param = f"{sheet_id}!{range_str}" if sheet_id else range_str
url = f"{SHEETS_API}/{spreadsheet_token}/values/{range_param}"
resp = requests.get(url, headers=self._headers())
resp.raise_for_status()
return resp.json()
def write_values(self, spreadsheet_token: str, sheet_id: str,
range_str: str, values: List[List[Any]]) -> Dict:
"""Write values to a range"""
url = f"{SHEETS_API}/{spreadsheet_token}/values"
range_param = f"{sheet_id}!{range_str}" if sheet_id else range_str
payload = {
"valueRange": {
"range": range_param,
"values": values
}
}
resp = requests.put(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
def append_values(self, spreadsheet_token: str, sheet_id: str,
values: List[List[Any]]) -> Dict:
"""Append values to the end of sheet"""
url = f"{SHEETS_API}/{spreadsheet_token}/values_append"
payload = {
"valueRange": {
"range": sheet_id,
"values": values
}
}
resp = requests.post(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
def insert_dimension(self, spreadsheet_token: str, sheet_id: str,
dimension: str, start_index: int, end_index: int) -> Dict:
"""Insert rows or columns"""
url = f"{SHEETS_API}/{spreadsheet_token}/insert_dimension_range"
payload = {
"dimension": {
"sheetId": sheet_id,
"majorDimension": dimension, # ROWS or COLUMNS
"startIndex": start_index,
"endIndex": end_index
}
}
resp = requests.post(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
def delete_dimension(self, spreadsheet_token: str, sheet_id: str,
dimension: str, start_index: int, end_index: int) -> Dict:
"""Delete rows or columns"""
url = f"{SHEETS_API}/{spreadsheet_token}/dimension_range"
payload = {
"dimension": {
"sheetId": sheet_id,
"majorDimension": dimension,
"startIndex": start_index,
"endIndex": end_index
}
}
resp = requests.delete(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
def add_sheet(self, spreadsheet_token: str, title: str) -> Dict:
"""Add a new worksheet"""
url = f"{SHEETS_API}/{spreadsheet_token}/sheets_batch_update"
payload = {
"requests": [{
"addSheet": {
"properties": {"title": title}
}
}]
}
resp = requests.post(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
def delete_sheet(self, spreadsheet_token: str, sheet_id: str) -> Dict:
"""Delete a worksheet"""
url = f"{SHEETS_API}/{spreadsheet_token}/sheets_batch_update"
payload = {
"requests": [{
"deleteSheet": {"sheetId": sheet_id}
}]
}
resp = requests.post(url, headers=self._headers(), json=payload)
resp.raise_for_status()
return resp.json()
def main():
"""CLI entry point for tool calls"""
if len(sys.argv) < 2:
print(json.dumps({"error": "No action specified"}))
sys.exit(1)
action = sys.argv[1]
client = FeishuSheetsClient()
try:
if action == "create":
title = sys.argv[2]
folder = sys.argv[3] if len(sys.argv) > 3 else None
result = client.create_spreadsheet(title, folder)
elif action == "get_info":
token = sys.argv[2]
result = client.get_spreadsheet_info(token)
elif action == "read":
token = sys.argv[2]
sheet_id = sys.argv[3]
range_str = sys.argv[4]
result = client.read_values(token, sheet_id, range_str)
elif action == "write":
token = sys.argv[2]
sheet_id = sys.argv[3]
range_str = sys.argv[4]
values = json.loads(sys.argv[5])
result = client.write_values(token, sheet_id, range_str, values)
elif action == "append":
token = sys.argv[2]
sheet_id = sys.argv[3]
values = json.loads(sys.argv[4])
result = client.append_values(token, sheet_id, values)
elif action == "insert_dimension":
token = sys.argv[2]
sheet_id = sys.argv[3]
dimension = sys.argv[4]
start = int(sys.argv[5])
end = int(sys.argv[6])
result = client.insert_dimension(token, sheet_id, dimension, start, end)
elif action == "delete_dimension":
token = sys.argv[2]
sheet_id = sys.argv[3]
dimension = sys.argv[4]
start = int(sys.argv[5])
end = int(sys.argv[6])
result = client.delete_dimension(token, sheet_id, dimension, start, end)
elif action == "add_sheet":
token = sys.argv[2]
title = sys.argv[3]
result = client.add_sheet(token, title)
elif action == "delete_sheet":
token = sys.argv[2]
sheet_id = sys.argv[3]
result = client.delete_sheet(token, sheet_id)
else:
result = {"error": f"Unknown action: {action}"}
print(json.dumps(result, ensure_ascii=False, indent=2))
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
sys.exit(1)
if __name__ == "__main__":
main()
FILE:references/api-reference.md
# Feishu Sheets API Reference
## Base URLs
- Sheets API v2: `https://open.feishu.cn/open-apis/sheets/v2/spreadsheets`
- Sheets API v3: `https://open.feishu.cn/open-apis/sheets/v3/spreadsheets`
- Auth API: `https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal`
## Authentication
All API calls require a tenant access token in the Authorization header:
```
Authorization: Bearer {tenant_access_token}
```
Get token by calling auth API with app_id and app_secret.
## Core APIs
### 1. Create Spreadsheet
**Endpoint:** `POST /sheets/v3/spreadsheets`
**Request Body:**
```json
{
"title": "Spreadsheet Name",
"folder_token": "fldcnXXX" // optional
}
```
**Response:**
```json
{
"code": 0,
"msg": "success",
"data": {
"spreadsheet": {
"title": "Spreadsheet Name",
"spreadsheet_token": "shtcnXXX",
"url": "https://xxx.feishu.cn/sheets/shtcnXXX"
}
}
}
```
### 2. Get Spreadsheet Info
**Endpoint:** `GET /sheets/v2/spreadsheets/{spreadsheet_token}/metainfo`
**Response:**
```json
{
"code": 0,
"data": {
"properties": {
"title": "Spreadsheet Name"
},
"sheets": [
{
"sheet_id": "0bxxxx",
"title": "Sheet1",
"grid_properties": {
"row_count": 1000,
"column_count": 20
}
}
]
}
}
```
### 3. Read Values
**Endpoint:** `GET /sheets/v2/spreadsheets/{spreadsheet_token}/values/{range}`
**Range Formats:**
- `A1:C10` - Cell range
- `0bxxxx!A1:C10` - With sheet_id
- `Sheet1!A1:C10` - With sheet title
**Response:**
```json
{
"code": 0,
"data": {
"range": "0bxxxx!A1:C3",
"values": [
["Name", "Age", "City"],
["Alice", 25, "Beijing"],
["Bob", 30, "Shanghai"]
]
}
}
```
### 4. Write Values
**Endpoint:** `PUT /sheets/v2/spreadsheets/{spreadsheet_token}/values`
**Request Body:**
```json
{
"valueRange": {
"range": "0bxxxx!A1:C3",
"values": [
["Name", "Age", "City"],
["Alice", 25, "Beijing"],
["Bob", 30, "Shanghai"]
]
}
}
```
### 5. Append Values
**Endpoint:** `POST /sheets/v2/spreadsheets/{spreadsheet_token}/values_append`
**Request Body:**
```json
{
"valueRange": {
"range": "0bxxxx",
"values": [
["Charlie", 28, "Shenzhen"]
]
}
}
```
### 6. Insert Rows/Columns
**Endpoint:** `POST /sheets/v2/spreadsheets/{spreadsheet_token}/insert_dimension_range`
**Request Body:**
```json
{
"dimension": {
"sheetId": "0bxxxx",
"majorDimension": "ROWS", // or "COLUMNS"
"startIndex": 5,
"endIndex": 7
}
}
```
### 7. Delete Rows/Columns
**Endpoint:** `DELETE /sheets/v2/spreadsheets/{spreadsheet_token}/dimension_range`
**Request Body:** Same as insert
### 8. Add Worksheet
**Endpoint:** `POST /sheets/v2/spreadsheets/{spreadsheet_token}/sheets_batch_update`
**Request Body:**
```json
{
"requests": [{
"addSheet": {
"properties": {
"title": "New Sheet"
}
}
}]
}
```
### 9. Delete Worksheet
**Endpoint:** `POST /sheets/v2/spreadsheets/{spreadsheet_token}/sheets_batch_update`
**Request Body:**
```json
{
"requests": [{
"deleteSheet": {
"sheetId": "0bxxxx"
}
}]
}
```
## Data Types
### Cell Values
**String:**
```json
"Hello World"
```
**Number:**
```json
123
45.67
```
**Formula:**
```json
{
"type": "formula",
"text": "=SUM(A1:A10)"
}
```
**Hyperlink:**
```json
{
"type": "url",
"text": "Click here",
"link": "https://example.com"
}
```
## Error Codes
| Code | Message | Description |
|------|---------|-------------|
| 0 | success | Request successful |
| 10001 | bad request | Invalid parameters |
| 10002 | unauthorized | Invalid or expired token |
| 10003 | forbidden | Insufficient permissions |
| 10004 | not found | Spreadsheet or sheet not found |
| 10005 | internal error | Server error |
## Rate Limits
- Default: 100 requests per minute per app
- Bulk operations (batch_update): 20 requests per minute
## Best Practices
1. **Reuse token:** Cache tenant_access_token for up to 2 hours
2. **Batch operations:** Use batch_update for multiple changes
3. **Range selection:** Be specific with ranges to reduce data transfer
4. **Error handling:** Always check response code before processing data
5. **Sheet ID:** Use sheet_id (not title) for reliable operations
将需求文档、表单字段规则、审批流程定义和 UI 说明转化为结构化测试用例,适用于表单校验、流程审批、状态流转、权限隔离和业务分支测试场景。
--- name: skill-qa-testcase-generator description: 将需求文档、表单字段规则、审批流程定义和 UI 说明转化为结构化测试用例,适用于表单校验、流程审批、状态流转、权限隔离和业务分支测试场景。 user-invocable: true license: MIT --- # 资深测试用例设计专家 你是一名资深测试用例设计专家,负责将用户提供的需求材料转化为专业、可执行、可评审的测试用例。 ## 适用范围 当用户提供以下任一材料,并希望产出测试用例、测试点、测试清单或测试设计时,使用本技能: - 需求文档(PRD)、业务背景、功能说明、业务规则 - 表单字段定义、必填选填规则、格式校验规则 - 审批流、流程图、状态机、节点参与者配置 - UI/UX 蓝图、页面说明、交互说明、按钮行为 - 截图描述、原型说明、模块级功能描述 ## 开场欢迎语 当用户刚开始使用本技能,或只表达了“帮我生成测试用例”但尚未提供完整材料时,先发送以下欢迎语: 🎯 您好!我是您的专属资深测试用例设计师。 请将您的以下材料发送给我: - 📄 需求文档(PRD) - 📝 表单字段规则 - 🎨 UI/UX 蓝图说明 您提供的信息越详细,我生成的用例覆盖度就越高! 我们可以先从一个特定的模块开始,请发送您的需求吧~ ## 核心原则 1. 完全忠于输入,只基于用户明确提供的材料生成测试用例,不自行扩展未说明的业务功能。 2. 自动补足常规测试设计维度,例如边界值、空值、非法值、重复值、长度、格式、权限和状态流转,但不要虚构业务规则。 3. 每条用例都必须可执行,步骤要具体到字段名、按钮名、角色、操作动作和输入值。 4. 预期结果必须具体描述界面提示、数据变化、状态变化、流转去向和可见性变化,禁止使用“操作成功”这类空泛表述。 5. 如果需求存在歧义、缺失或冲突,在测试用例后单独输出“需求确认建议(疑问点)”。 ## 工作流程 ### Step 1: 需求拆解 收到材料后,先识别并整理以下信息: - 核心业务目标 - 涉及的用户角色与权限 - 表单字段、控件类型、输入限制、默认值、必填规则 - 页面动作和交互行为,例如提交、保存、返回、驳回、撤销、转办 - 流程生命周期与状态流转 - 审批节点、参与者设置、分支条件、回写逻辑 - 明确写出的业务规则、限制条件和异常处理方式 ### Step 2: 测试策略覆盖 设计测试场景时,优先覆盖以下维度: 1. 正向场景(Happy Path):主流程完整闭环。 2. 异常/逆向场景:必填缺失、非法输入、极值、重复提交、错误格式。 3. 状态流转测试:提交、同意、驳回、退回、撤销、转办、挂起,以及状态回写。 4. UI 与交互覆盖:弹窗、按钮可用性、提示语、加载态、禁用态、重复点击保护。 5. 权限场景:不同角色的可见范围、可操作范围、数据隔离。 6. 数据校验:长度、类型、格式、唯一性、枚举值、默认值、联动校验。 7. 业务场景:不同人员、组织、金额、条件分支导致的不同流程路径。 8. 审批节点参与者设置测试:参与方式设置、多角色单一出口、无参与者跳过规则。 9. 驳回设置逻辑测试:驳回方式设置、被驳回节点重新提交后的执行逻辑。 ### Step 3: 输出结果 - 默认输出为 Markdown 表格。 - 如果用户明确要求生成飞书文档、上传云盘或写本地文件,且当前环境确实提供相关工具,再使用工具。 - 如果工具不可用或用户未指定输出方式,直接在对话中输出 Markdown 表格。 ## 输出要求 ### 测试步骤写法 - 不要写:输入有效数据 - 要写:在[姓名]字段输入“张三” - 不要写:点击提交 - 要写:点击页面右下角“提交审批”按钮 ### 预期结果写法 - 不要写:操作成功 - 要写:系统弹出 Toast 提示“保存成功”,列表首行显示新建数据,状态列显示“待审批” ### 优先级定义 | 优先级 | 说明 | | :--- | :--- | | P0 | 核心主流程,必须通过 | | P1 | 重要功能与高频异常 | | P2 | 一般功能与低频异常 | | P3 | UI 展示与极少触发的边界场景 | ## 标准输出格式 优先输出以下表格: | 用例编号 | 模块/功能点 | 用例类型 | 前置条件 | 测试步骤 | 预期结果 | 优先级 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | 其中: - 用例编号:按模块递增,例如 `TC-001` - 模块/功能点:写清模块名和具体功能点 - 用例类型:例如 正向、逆向、边界、权限、流程、UI、数据校验 - 前置条件:只写执行该用例前必须满足的条件 - 测试步骤:逐步描述,必要时分 1、2、3 - 预期结果:逐条对应步骤结果,重点写页面提示、数据变化、状态变化、流转结果 - 优先级:只能使用 `P0`、`P1`、`P2`、`P3` ## 需求确认建议 如果材料中存在不明确、互相冲突或无法判断的规则,在测试用例下方追加以下章节: ## 📋 需求确认建议(疑问点) | 序号 | 疑问点 | 建议确认内容 | | :--- | :--- | :--- | | 1 | XXX字段取值 | 是否支持中文?最大长度是多少? | | 2 | 审批节点 | 驳回后数据如何处理? | 疑问点只针对用户输入中缺失或冲突的信息,不要为了凑数量而强行补充。 ## 工具使用规则 仅在当前环境已提供对应工具时,才可调用: - `feishu_create_doc`:将测试用例创建为飞书文档 - `feishu_drive_file`:将生成内容上传到指定云盘位置 - `write`:将测试用例保存为本地文件 使用工具前遵循以下规则: 1. 先完成测试用例内容本身,再决定是否落地为文档或文件。 2. 用户未指定输出方式时,默认直接展示 Markdown 表格。 3. 用户要求输出到飞书或云盘但缺少必要信息时,仅补问最关键的信息,例如目标文件夹或文档标题。 4. 若工具不可用,不要伪造执行结果,改为直接输出内容并说明原因。 ## 生成时的行为要求 - 优先按模块分组输出,保证结构清晰。 - 当字段存在明确长度限制时,自动包含命中边界值的测试,例如 50 字符和 51 字符。 - 当流程存在状态机时,尽量覆盖完整生命周期,而不是只覆盖提交成功。 - 当角色、组织层级、审批人来源会影响流程分支时,必须体现差异化用例。 - 当用户要求“先从某个模块开始”时,仅输出该模块相关内容,不擅自展开到其他模块。 - 如果输入信息很少,先基于现有信息输出可落地的最小测试集,再列出缺失信息和确认建议。
飞书一句话智能排期与日程协调助手,基于事件驱动架构
---
name: feishu-scheduler
description: "飞书一句话智能排期与日程协调助手,基于事件驱动架构"
---
# 📅 飞书智能排期助手 (Feishu Scheduler)
## 技能描述 (Skill Description)
这是专门用于 OpenClaw Agent 下发的飞书环境专属 AI 工具(Skill)。
它使大语言模型具备处理**“长程协调”、“涉及多位群内飞书用户”**复杂会议预约场景的能力。
主要能力有:计算多人的日历闲忙交集、发送附带时间选项的飞书交互式卡片、以及在成员意见不合时,作为 Webhook 中控台提供实时冲突情报通知 Agent 进行多轮自动斡旋处理。
## 🤖 运行时指令:Agent 执行策略 (Execution Strategy)
作为挂载此 Skill 的 Agent,处理用户的排期请求时,需要严格遵循以下工作流机制(请阅读你的系统提示词):
### 1. 意图解析与信息补足
- 对话用户输入:“帮我约刘总开个需求会”。
- Agent 必须检查是否满足:【参会人群(包含用户本人或其上级)】、【期望的时间范围(几号到几号,如没有则默认下周)】、【预计会议时长(单位分钟,如半小时或一小时)】。
- 如果信息不全,Agent 要利用大模型的自然语言主动**拒绝工具调用**,改口礼貌询问用户要求的内容。
### 2. 日历交集计算器 (`POST /api/claw/calc-free-time`)
- 向端点传入所需属性:`userIds`, `startTimeIso`, `endTimeIso`, `durationMinutes`。
- 获取返回结果中提供的最近 3-5 个**完全交集长短的空闲时间段 (Free Time)**。
- 只有成功拿到选项列表(也就是时间不冲突)时,才可进入第三步。如果拿到的数组为空(没有任何共同闲时),将原因转化成友好长句推还给请求用户。
### 3. 主动发包:排期卡片派发 (`POST /api/claw/dispatch-cards`)
- Agent 根据上一步拿出的可选时间,加上排期会议的话题,组合成一段 **“富有情商和商务礼仪”** 的导语作为发包请求内容(如:“诸位大佬,刚跟李总沟通过看啥时间定方案比较好,礼拜四十点这个点您看行吗?”)。
- 将该 `agentMessage` 传入卡片下发 API。
- API 响应 `SessionID` 给 Agent。**注意:收到 SessionID 意味着此任务挂起,当前 Agent 不需要再主动采取任何行动,请结束当前进程不要死等。**
### 4. 冲突判定与反弹接力 (Webhook 触发机制)
- 会话对象可能在这张“确认时间选项卡片”中出现意见不合或全点“此时均冲突”。
- Webhook 服务器 (`/api/feishu/card-webhook`) 会持续跟踪和监控所有人的投递票选比例,若出现异常分歧,Webhook 会把当前进展(包括具体的谁没投、谁选择了什么时间)组织成结构体,并带上同一 `SessionID` 去**主动反向触发 (Wake up)** 给我们的 Agent。
- Agent 在被异常唤醒之时,能够读取并判断:“噢,刘总说周三有事,周四行”,这要求 Agent 再一次使用大模型的推算能力和对话补全能力执行,带着全新计算好的时间槽二次调用**第三步 (步骤3 发派卡片)**,继续向各方斡旋,直至全部投票意见统一。
---
## 📂 微服务结构说明 (Architecture Info)
> 目录已经过符合 Miaoda 规范和 OpenClawHub 打包分发处理:
- **`index.js`**: 提供给 Agent 声明内主动发起交互动作的三组 API,以及直接供飞书开发者后台端点消费的 Webhook `/api/feishu/card-webhook` 入口。
- **`feishuService.js`**: 包含所有包含 Feishu Open API 网关交互的核心认证 (TenantAccessToken Cache)、区间合并算法、卡片 Message Card DOM 构建和真实创建日历日程模块 (`createMeeting`)。
- **`dbStore.js`**: 依赖 Node.js 文件系统读写的轻量级事务/会话记录模块,实现各端异步状态管理的防丢落地与挂起机制追踪。
- **`openapi.yaml`**: OpenClaw 一键导入时的标准大模型能力边界规范元数据定义文件。
- **`_meta.json`**: Clawhub 及第三方 Agent UI 解析插件包属性的专用格式声明包。
FILE:_meta.json
{
"name": "feishu-scheduler-skill",
"version": "1.0.0",
"displayName": "飞书智能排期助手",
"description": "基于 OpenClaw 架构并在飞书上原生调用的多回合交互排期与协同日程管理插件,支持一键部署到 Clawhub 并在 Miaoda 运行。",
"author": "Developer",
"runtime": "node",
"main": "index.js",
"tags": [
"feishu",
"scheduler",
"calendar",
"bot"
],
"dependencies": {
"express": "^4.18.2",
"dotenv": "^16.3.1",
"js-yaml": "^4.1.1",
"yaml": "^2.8.2"
},
"env": {
"FEISHU_APP_ID": {
"description": "飞书应用的 App ID",
"required": true
},
"FEISHU_APP_SECRET": {
"description": "飞书应用的 App Secret",
"required": true
},
"FEISHU_VERIFICATION_TOKEN": {
"description": "飞书事件订阅的 Verification Token",
"required": true
},
"FEISHU_ENCRYPT_KEY": {
"description": "飞书事件订阅的 Encrypt Key (用于签名校验)",
"required": false
},
"OPENCLAW_WAKE_ENDPOINT": {
"description": "用于唤醒 OpenClaw Agent 的 Webhook 地址",
"required": true
}
},
"config": {
"store.json": {
"description": "本地持久化会话存储文件",
"type": "file"
}
},
"required_envs": [
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_VERIFICATION_TOKEN",
"OPENCLAW_WAKE_ENDPOINT"
],
"optional_envs": [
"FEISHU_ENCRYPT_KEY",
"FEISHU_CALENDAR_ID",
"ALLOWED_WAKE_DOMAINS"
],
"required_config_paths": [
"store.json"
]
}
FILE:codepreview1.md
现在我已经全面审查了整个项目,发现了多个需要修复的问题。让我为你详细分析:
* 🔍 代码审查发现的主要问题
1. 安全漏洞问题
A. Webhook签名验证不完整
javascript
// index.js 第87-98行
function verifyFeishuSignature(req) {
const timestamp = req.headers['x-lark-request-timestamp'];
const nonce = req.headers['x-lark-request-nonce'];
const signature = req.headers['x-lark-signature'];
const encryptKey = process.env.FEISHU_ENCRYPT_KEY;
if (!encryptKey) return null; // ⚠️ 严重问题:密钥不存在时返回null而非false
const body = JSON.stringify(req.body);
const content = timestamp + nonce + encryptKey + body;
const hash = crypto.createHash('sha256').update(content).digest('hex');
return hash === signature;
}
问题:当FEISHU_ENCRYPT_KEY未配置时返回null,但在调用处将其视为false处理,逻辑不够清晰。
B. URL安全验证过于宽松
javascript
// index.js 第104-112行
function isSafeUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol); // ⚠️ 缺少域名白名单验证
} catch (e) {
return false;
}
}
2. 功能实现问题
A. 日历API调用硬编码问题
javascript
// feishuService.js 第174行
const calRes = await this._request('POST', 'https://open.feishu.cn/open-apis/calendar/v4/calendars/feishu.cn_0000/events', {
body: eventBody
});
// ⚠️ 硬编码了日历ID,实际部署时会失败
B. 数据库存储机制不适用于生产环境
javascript
// dbStore.js 使用文件系统存储,在容器化部署或多实例环境下会有问题
class SimpleStore {
constructor(dbName = 'store.json') {
this.dbPath = path.join(__dirname, dbName); // ⚠️ 文件路径在不同环境中可能不可写
}
}
3. 代码质量问题
A. 错误处理不完善
javascript
// index.js 第186-199行
try {
console.log(`[Wakeup] 正在主动唤醒 Agent 端点: wakeEndpoint`);
const response = await fetch(wakeEndpoint, { /* ... */ });
const wakeData = await response.json();
console.log(`[Wakeup] Agent 响应接收:`, JSON.stringify(wakeData));
} catch (wakeErr) {
console.warn(`[Wakeup Warning] 唤醒请求失败(非致命):`, wakeErr.message);
// ⚠️ 缺少重试机制和详细的错误分类处理
}
B. 时间处理缺乏时区考虑
javascript
// feishuService.js 多处使用 new Date() 但未明确处理时区
const text = `new Date(ts.start_time).toLocaleDateString('zh-CN') '2-digit', minute:'2-digit')}`;
// ⚠️ 可能在不同时区的服务器上显示错误时间
🛠️ 修复方案
让我为你提供具体的修复代码:
Skill create-skill
让我先创建修复后的文件版本:
已查看
index.js
现在让我创建修复后的版本。首先创建改进的index.js:
已探索
1工具调用
让我创建修复建议文档:
已查看
feishuService.js
现在让我创建完整的修复报告:
已探索
1工具调用
让我创建一个新的修复建议文件:
已查看
package.json
现在让我总结审查结果并提供具体修复建议:
📋 飞书排期Skill代码审查报告
🔍 主要发现问题
🚨 高危安全问题
Webhook签名验证不严谨
当前:密钥缺失时返回null,逻辑模糊
建议:改为直接返回false并记录安全日志
URL安全验证不足
当前:只检查协议,无域名白名单
建议:添加ALLOWED_DOMAINS环境变量控制
日历API硬编码
当前:使用测试日历ID feishu.cn_0000
建议:从环境变量获取或动态创建日历
⚠️ 功能性缺陷
存储机制不适合生产
当前:文件系统存储 store.json
建议:使用Redis或飞书多维表格
缺少错误处理机制
缺少重试逻辑、超时控制
建议:添加指数退避重试机制
📊 代码质量问题
缺少参数验证
API端点参数验证不充分
建议:使用express-validator加强验证
缺少监控和健康检查
无健康检查端点
建议:添加/health端点和APM监控
🛠️ 具体修复建议
1. 安全修复
javascript
// 改进的签名验证
function verifyFeishuSignature(req) {
const encryptKey = process.env.FEISHU_ENCRYPT_KEY;
if (!encryptKey) {
console.error('[SECURITY] FEISHU_ENCRYPT_KEY未配置');
return false;
}
// 完整验证逻辑...
}
// URL白名单控制
function isSafeUrl(url) {
const allowedDomains = process.env.ALLOWED_DOMAINS?.split(',') ||
['localhost', '127.0.0.1'];
// 域名验证逻辑...
}
2. 存储方案升级
javascript
// Redis存储替代方案
const redis = require('redis');
class RedisSessionStore {
async createSession(id, data) {
await this.client.setex(`session:id`, 86400, JSON.stringify(data));
}
}
3. 错误处理增强
javascript
// 重试机制
async _requestWithRetry(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
// 请求逻辑...
} catch (error) {
if (i < maxRetries - 1) {
await this.sleep(1000 * (i + 1)); // 指数退避
}
}
}
}
📦 Clawhub提交准备
环境变量更新
json
{
"required_envs": [
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_ENCRYPT_KEY",
"OPENCLAW_WAKE_ENDPOINT",
"REDIS_URL"
],
"optional_envs": [
"ALLOWED_DOMAINS",
"APM_SERVER_URL"
]
}
依赖包建议
json
{
"dependencies": {
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"redis": "^4.6.7",
"elastic-apm-node": "^3.49.0"
}
}
🎯 修复优先级
🔴 立即修复 (影响上线):
Webhook签名验证
URL安全控制
存储机制替换
🟡 重要优化 (提升稳定性):
参数验证
错误处理
健康检查
🟢 增强功能 (优化体验):
监控集成
性能优化
测试覆盖
这个Skill整体架构设计合理,但需要在安全性、稳定性和生产就绪性方面进行重点改进才能顺利提交到Clawhub。
FILE:intro.md
FILE:index.js
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const FeishuService = require('./feishuService');
const BitableStore = require('./bitableStore');
const app = express();
const port = process.env.PORT || 3000;
app.use(express.json());
const feishu = new FeishuService(
process.env.FEISHU_APP_ID,
process.env.FEISHU_APP_SECRET
);
// 使用飞书多维表格存储(传入 feishuService 实例共享 token)
const dbStore = new BitableStore(feishu);
// 启动时清理过期的孤儿表格
dbStore.cleanupExpiredSessions().catch(err =>
console.error('[Startup] Failed to cleanup expired sessions:', err)
);
/**
* =========================================================
* 供 OpenClaw Agent 调用的三组原子化 Skill 接口
* =========================================================
*/
/**
* 技能一:日历排期计算器
* Agent 提取出人名单和期望时间段后,调用此接口索要 3 个左右空闲时间解
*/
app.post('/api/claw/calc-free-time', async (req, res) => {
const { userIds, startTimeIso, endTimeIso, durationMinutes } = req.body;
// 参数验证
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
return res.status(400).json({ error: 'userIds 必须是非空数组' });
}
if (!startTimeIso || !endTimeIso || !durationMinutes) {
return res.status(400).json({ error: '缺少 startTimeIso / endTimeIso / durationMinutes' });
}
if (typeof durationMinutes !== 'number' || durationMinutes <= 0) {
return res.status(400).json({ error: 'durationMinutes 必须是正整数' });
}
try {
const timeOptions = await feishu.getCommonFreeTime(userIds, startTimeIso, endTimeIso, durationMinutes);
res.json({
success: true,
message: timeOptions.length > 0 ? '找到了以下空闲时段' : '在给定时间内所有人无符合要求的共同空闲时段',
data: { options: timeOptions }
});
} catch (error) {
console.error('[API Error] calc-free-time:', error);
res.status(500).json({ error: error.message });
}
});
/**
* 技能二:状态发包与意向收集单
* Agent 利用第一步的结果自己生成了拟人化的话术,再调用此接口下发确认卡片,并开启一个 Session 挂起任务
*/
app.post('/api/claw/dispatch-cards', async (req, res) => {
const { meetingTopic, userIds, timeSlots, agentMessage } = req.body;
if (!meetingTopic || typeof meetingTopic !== 'string') {
return res.status(400).json({ error: 'meetingTopic 不能为空' });
}
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
return res.status(400).json({ error: 'userIds 必须是非空数组' });
}
if (!timeSlots || !Array.isArray(timeSlots) || timeSlots.length === 0) {
return res.status(400).json({ error: 'timeSlots 必须是非空数组' });
}
if (!agentMessage || typeof agentMessage !== 'string') {
return res.status(400).json({ error: 'agentMessage 不能为空' });
}
try {
// 1. 使用 crypto 生成安全的唯一事务 ID
const sessionId = 'ses_' + crypto.randomBytes(12).toString('hex');
// 2. 多维表格存储事务状态(必须 await,确保建表完成后再发卡片)
await dbStore.createSession(sessionId, {
meetingTopic,
userIds,
timeSlots,
agentMessage
});
// 3. 将包含 session_id 的独立带按钮卡片派给这几个人
await feishu.sendPollCards(userIds, sessionId, meetingTopic, agentMessage, timeSlots);
res.json({
success: true,
message: `已向 userIds.length 人的飞书发送了排期卡片。此任务已建档挂起等待用户响应。`,
data: { sessionId }
});
} catch (error) {
console.error('[API Error] dispatch-cards:', error);
res.status(500).json({ error: error.message });
}
});
/**
* 校验飞书 Webhook 签名的工具函数
* @returns {boolean} true: 校验通过; false: 校验失败
*/
function verifyFeishuSignature(req) {
const timestamp = req.headers['x-lark-request-timestamp'];
const nonce = req.headers['x-lark-request-nonce'];
const signature = req.headers['x-lark-signature'];
const encryptKey = process.env.FEISHU_ENCRYPT_KEY;
// 如果未配置加密密钥,记录警告并返回 false
if (!encryptKey) {
console.warn('[Security] FEISHU_ENCRYPT_KEY 未配置,无法进行签名校验');
return false;
}
if (!timestamp || !nonce || !signature) {
console.warn('[Security] 缺少签名相关 headers');
return false;
}
const body = JSON.stringify(req.body);
const content = timestamp + nonce + encryptKey + body;
const hash = crypto.createHash('sha256').update(content).digest('hex');
return hash === signature;
}
/**
* 安全校验:校验 URL 是否合法,防止 SSRF
*/
function isSafeUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
// 检查协议
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false;
}
// 检查域名白名单(如果配置了)
const allowedDomains = process.env.ALLOWED_WAKE_DOMAINS;
if (allowedDomains) {
const domains = allowedDomains.split(',').map(d => d.trim());
const isAllowed = domains.some(domain =>
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain)
);
if (!isAllowed) {
console.warn(`[Security] 域名 parsed.hostname 不在白名单中`);
return false;
}
}
// 防止内网地址
const hostname = parsed.hostname.toLowerCase();
const privatePatterns = [
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^::1$/,
/^fe80:/i
];
if (privatePatterns.some(pattern => pattern.test(hostname))) {
console.warn(`[Security] 检测到内网地址: hostname`);
return false;
}
return true;
} catch (e) {
console.error('[Security] URL 解析失败:', e.message);
return false;
}
}
/**
* =========================================================
* 飞书卡片 Webhook,用于接管用户点击与后续决策
* =========================================================
*/
app.post('/api/feishu/card-webhook', async (req, res) => {
// 飞书后台 URL 验证请求,必须在签名校验之前处理(此请求不带签名)
if (req.body.type === 'url_verification') {
const token = process.env.FEISHU_VERIFICATION_TOKEN;
if (token && req.body.token !== token) {
return res.status(403).send('Forbidden: Token Mismatch');
}
return res.json({ challenge: req.body.challenge });
}
// 安全校验:优先使用 HMAC 签名,未配置时降级到 Verification Token
const encryptKey = process.env.FEISHU_ENCRYPT_KEY;
if (encryptKey) {
if (!verifyFeishuSignature(req)) {
console.error('[Security] HMAC Signature Match Failed');
return res.status(403).send('Forbidden: Invalid Signature');
}
} else {
// 降级回 Verification Token
console.warn('[Security Warning] FEISHU_ENCRYPT_KEY 未配置,降级为 Token 校验,强烈建议配置加密密钥。');
if (!process.env.FEISHU_VERIFICATION_TOKEN || req.body.token !== process.env.FEISHU_VERIFICATION_TOKEN) {
console.error('[Security] Verification Token Missing or Mismatch');
return res.status(403).send('Forbidden: Security Token Mismatch');
}
}
const actionObj = req.body.action;
// 处理投递按钮点击逻辑
if (actionObj && actionObj.value && actionObj.value.action === 'vote') {
const { session_id, choice } = actionObj.value;
const operatorId = req.body.open_id;
console.log(`[Webhook] Session session_id - User operatorId clicked: choice`);
// 1. 更新此人的意向
await dbStore.updateParticipantState(session_id, operatorId, choice);
// 2. 检查会话当前的共识状态
const status = await dbStore.checkSessionConsensus(session_id);
// 回复给点击用户的局部视图 Toast 提示
let toastMsg = '收到意向!请等待其他人反馈~';
if (status.done) {
if (status.result === 'agreed') {
// ========== 全员同意,执行最终定档 ==========
toastMsg = '你是最后一个确认者!全员意见统一,我正在执行系统排期...';
const agreedSlot = JSON.parse(status.time);
const sessionStore = await dbStore.getSession(session_id);
// 异步执行:创会、向所有人发日程邀请,全部完成后再删除多维表格
feishu.createMeeting(
sessionStore.userIds,
sessionStore.meetingTopic,
agreedSlot.start_time,
agreedSlot.end_time
).then(success => {
if (success) {
// 日程邀请已全部发出,此时才清理多维表格
console.log(`[Cleanup] 日程邀请已发送完毕,删除 session session_id 的多维表格`);
return dbStore.deleteSession(session_id);
} else {
console.warn(`[Cleanup] createMeeting 未完全成功,保留多维表格以便排查`);
}
}).catch(err => console.error('[Meeting Error]', err));
} else if (status.result === 'conflict') {
// ========== 唤醒唤醒端点:排期冲突,需 LLM 决策 ==========
toastMsg = '由于存在冲突或有人没空,排期进程暂停。助手已记录并将寻求下一套方案!';
console.log(`[ALERT] 排期冲突触发:会话 session_id 需要回复人工或重议。`);
// 关键唤醒逻辑:明确执行外部调用
const wakeEndpoint = process.env.OPENCLAW_WAKE_ENDPOINT;
if (isSafeUrl(wakeEndpoint)) {
try {
console.log(`[Wakeup] 正在主动唤醒 Agent 端点: wakeEndpoint`);
const response = await fetch(wakeEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: session_id,
event: 'schedule_conflict',
message: '大家排期时间无法协调,或者均选择了暂无时间。请你分析冲突情况,主动跟用户生成安抚话术或重新提出另外一周的时间方案进行斡旋。'
})
});
const wakeData = await response.json();
console.log(`[Wakeup] Agent 响应接收:`, JSON.stringify(wakeData));
} catch (wakeErr) {
console.warn(`[Wakeup Warning] 唤醒请求失败(非致命):`, wakeErr.message);
}
} else {
console.warn('[Security/Config] OPENCLAW_WAKE_ENDPOINT 未配置或不合法,跳过唤醒。');
}
}
}
// 给飞书立刻回包
return res.json({ toast: { type: 'success', content: toastMsg } });
}
res.status(200).send('ok');
});
app.listen(port, () => {
console.log(`[OpenClaw Agent] 排期原子服务启动, 监听端口: port`);
});
FILE:README.md
# Feishu Scheduler Skill for OpenClaw
这是一个针对 OpenClaw 平台设计的飞书 (Feishu) 日程排期与卡片交互的后台服务。
它可以被 OpenClaw Agent 调用来拉取指定一群人的“共同空闲时间”,并通过飞书发送“交互式卡片”供群负责人点击确认时间。
---
## 🚀 项目结构
- \`index.js\` - Express 服务端入口,暴露 OpenClaw 的调用 API,以及飞书的卡片回调 Webhook。
- \`feishuService.js\` - 核心飞书 API 封装层,负责缓存 Token、查询 FreeBusy、发送交互式卡片 (Interactive Message Card)。
- \`.env.example\` - 配置飞书核心凭证的环境变量模板。
- \`package.json\` - 依赖定义。
---
## 🛠️ 安装与配置
### 1. 飞书开放平台准备
1. 访问并登录 [飞书开放平台](https://open.feishu.cn/app/)。
2. **创建自建应用**。
3. 从“凭证与基础信息”中提取 \`App ID\` 和 \`App Secret\`。
4. 在“权限管理”中,申请以下权限并发布新版本使其生效:
- 获取日历及日程信息 (获取空闲状态必须)
- 以应用身份发送消息 (发送群卡片必须)
### 2. 本地初始化
复制环境变量模板,并填入你自己飞书应用的 ID 和 Secret:
\`\`\`bash
cp .env.example .env
\`\`\`
然后在项目根目录执行:
\`\`\`bash
npm install
npm run dev
\`\`\`
服务默认会运行在本地 \`http://localhost:3000\`。
---
## 📡 接口说明与集成 (OpenClaw)
该服务暴露了两个核心路由接口:
### 1. 接受 Agent/OpenClaw 的排期请求
**POST** \`/api/claw/schedule\`
当 Agent/OpenClaw 通过 Workflow 决定为几个骨干安排会议前,它会发起此 HTTP POST 请求。
**Body 参数要求 (JSON):**
\`\`\`json
{
"managerUserId": "ou_3333333333", // 点击定夺按钮的负责人飞书 user_id
"userIds": ["ou_1111111111", "ou_2222222"], // 需要查询闲忙的所有参会者飞书 user_id 数组
"startTimeIso": "2023-10-31T09:00:00+08:00",// 约束下限时间 (ISO 8601格式)
"endTimeIso": "2023-10-31T18:00:00+08:00" // 约束上限时间
}
\`\`\`
### 2. 飞书卡片行为回调 (Webhook)
**POST** \`/api/feishu/card-callback\`
这个是你需要反向配置到 **“飞书开发者后台 -> 事件订阅 / 接收消息”** 中的公网 URL 地址。
如果要在本地测试,你需要用内网穿透工具(例如 ngrok 或者 cpolar)。
当负责人在飞书内点击了:\`[10:00 - 10:30]\` 的按钮,飞书会把信息 POST 传递回这个接口,从而让服务实现真正的建日程功能。
---
## 📚 开发注意与补充
1. **多时区问题**: \`feishuService.js\` 中全部依据带有时区 (\`+08:00\`) 的 ISO8601 长字符串处理。
2. **错误处理**: 如果参会者满负荷没有空隙,服务会响应 \`success: false\` 和 \`未找到空闲时段\`。
3. **真实落盘/预定**: 当前 \`index.js\` 的 \`card-callback\` 只打印了用户选择了哪个时间,没有实质去调用建日程接口 (Create Calendar Event)。你可以基于此结构在回调里接入进一步的数据库落盘或触发子 Agent 去真实预定会议室。
FILE:package.json
{
"name": "feishu-scheduler-skill",
"version": "1.0.0",
"description": "OpenClaw 技能:飞书日历排期与交互卡片确认",
"main": "index.js",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"js-yaml": "^4.1.1",
"yaml": "^2.8.2"
},
"keywords": [
"openclaw",
"feishu",
"scheduler"
],
"author": "",
"license": "ISC",
"openclaw": {
"required_envs": [
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_VERIFICATION_TOKEN",
"OPENCLAW_WAKE_ENDPOINT"
],
"optional_envs": [
"FEISHU_ENCRYPT_KEY",
"FEISHU_CALENDAR_ID",
"ALLOWED_WAKE_DOMAINS"
],
"env": {
"FEISHU_APP_ID": "飞书应用 App ID",
"FEISHU_APP_SECRET": "飞书应用 App Secret",
"FEISHU_VERIFICATION_TOKEN": "飞书 Verification Token",
"FEISHU_ENCRYPT_KEY": "飞书加密密钥,用于 Webhook 签名校验(强烈建议配置)",
"FEISHU_CALENDAR_ID": "飞书日历 ID,用于创建日程(留空则使用 primary)",
"ALLOWED_WAKE_DOMAINS": "允许唤醒的域名白名单,逗号分隔(可选,留空则不限制)",
"OPENCLAW_WAKE_ENDPOINT": "Agent 唤醒地址"
}
}
}
FILE:dbStore.js
const fs = require('fs');
const path = require('path');
/**
* 极简版基于文件的持久化数据库(仅作示例,生产环境请使用 Redis/MySQL 或飞书多维表格/Miaoda 数据表)
*/
class SimpleStore {
constructor(dbName = 'store.json') {
this.dbPath = path.join(__dirname, dbName);
this.data = this._load();
}
_load() {
if (fs.existsSync(this.dbPath)) {
return JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
}
return { sessions: {} };
}
_save() {
fs.writeFileSync(this.dbPath, JSON.stringify(this.data, null, 2));
}
/**
* 初始化一场会议的排期发包会话
*/
createSession(id, details) {
this.data.sessions[id] = {
id,
...details,
status: 'WAITING',
participantsState: details.userIds.reduce((acc, uid) => {
acc[uid] = { status: 'PENDING', selectedTime: null };
return acc;
}, {}),
createdAt: Date.now()
};
this._save();
return this.data.sessions[id];
}
getSession(id) {
return this.data.sessions[id];
}
/**
* 更新参会人的意向
*/
updateParticipantState(sessionId, userId, selectedTime) {
const session = this.data.sessions[sessionId];
if (!session) return null;
if (session.participantsState[userId]) {
session.participantsState[userId] = {
status: selectedTime === 'none' ? 'REJECTED' : 'CONFIRMED',
selectedTime
};
this._save();
}
return session;
}
/**
* 检查是否所有人都在该会话中做了决定且统一了意见
*/
checkSessionConsensus(sessionId) {
const session = this.data.sessions[sessionId];
if (!session) return { done: false, result: null };
const states = Object.values(session.participantsState);
const pendingCount = states.filter(s => s.status === 'PENDING').length;
// 还没全部回复
if (pendingCount > 0) {
return { done: false, result: 'pending' };
}
// 检查是否有拒绝
const rejects = states.filter(s => s.status === 'REJECTED');
if (rejects.length > 0) {
return { done: true, result: 'conflict' };
}
// 检查是否选的时间一致
const selectedTimes = states.map(s => s.selectedTime);
const allSame = selectedTimes.every(t => t === selectedTimes[0]);
if (allSame) {
return { done: true, result: 'agreed', time: selectedTimes[0] };
} else {
return { done: true, result: 'conflict' }; // 时间挑的不一样
}
}
}
module.exports = new SimpleStore();
FILE:feishuService.js
/**
* 飞书 API 交互服务 - 完整进阶版
* 用于 OpenClaw Skill 中的日历排期、多人交互式卡片分发、日程创建等能力
*/
const logger = {
info: (ctx, msg) => console.log(`[INFO]`, JSON.stringify({ ...ctx, msg })),
error: (ctx, msg) => console.error(`[ERR]`, JSON.stringify({ ...ctx, msg }))
};
class FeishuService {
constructor(appId, appSecret) {
this.appId = appId;
this.appSecret = appSecret;
this.tenantAccessToken = null;
this.tokenExpireAt = 0;
}
async getTenantAccessToken() {
const now = Date.now();
if (this.tenantAccessToken && now < this.tokenExpireAt - 5 * 60 * 1000) return this.tenantAccessToken;
const url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret })
});
const data = await response.json();
if (data.code !== 0) throw new Error(`Fetch token error [data.code]`);
this.tenantAccessToken = data.tenant_access_token;
this.tokenExpireAt = now + data.expire * 1000;
return this.tenantAccessToken;
}
async _request(method, url, options = {}, retries = 3) {
const token = await this.getTenantAccessToken();
for (let attempt = 1; attempt <= retries; attempt++) {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer token`,
'Content-Type': 'application/json; charset=utf-8',
...options.headers
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const data = await response.json();
// 飞书限流错误码 99991400 / 99991429,指数退避重试
if ((data.code === 99991400 || data.code === 99991429) && attempt < retries) {
const delay = Math.pow(2, attempt) * 500; // 1s, 2s, 4s
logger.info({ attempt, delay, url }, 'Rate limited, retrying...');
await new Promise(r => setTimeout(r, delay));
continue;
}
if (data.code !== 0) throw new Error(`API Error [data.code]: data.msg`);
return data.data;
}
}
/**
* 能力1:计算一组用户的闲暇聚合时间块
*/
async getCommonFreeTime(userIds, startTimeIso, endTimeIso, durationMinutes = 30) {
const startTs = new Date(startTimeIso).getTime();
const endTs = new Date(endTimeIso).getTime();
const busyIntervals = [];
for (const userId of userIds) {
const url = 'https://open.feishu.cn/open-apis/calendar/v4/freebusy/list?user_id_type=user_id';
const data = await this._request('POST', url, {
body: { time_min: startTimeIso, time_max: endTimeIso, user_id: userId }
});
const list = data.freebusy_list || [];
for (const item of list) {
busyIntervals.push([new Date(item.start_time).getTime(), new Date(item.end_time).getTime()]);
}
}
busyIntervals.sort((a, b) => a[0] - b[0]);
const mergedBusy = [];
if (busyIntervals.length > 0) {
mergedBusy.push([...busyIntervals[0]]);
for (let i = 1; i < busyIntervals.length; i++) {
const current = busyIntervals[i];
const last = mergedBusy[mergedBusy.length - 1];
if (current[0] <= last[1]) last[1] = Math.max(last[1], current[1]);
else mergedBusy.push([...current]);
}
}
const freeIntervals = [];
let currentStart = startTs;
for (const busy of mergedBusy) {
if (busy[0] > currentStart && (busy[0] - currentStart) >= durationMinutes * 60000) {
freeIntervals.push([currentStart, busy[0]]);
}
currentStart = Math.max(currentStart, busy[1]);
}
if (endTs > currentStart && (endTs - currentStart) >= durationMinutes * 60000) {
freeIntervals.push([currentStart, endTs]);
}
// 将连续的长空闲块依照 durationMinutes 切分成合理的可选时间段(供参考)
const timeOptions = [];
for (const slot of freeIntervals) {
let tStart = slot[0];
while (tStart + durationMinutes * 60000 <= slot[1] && timeOptions.length < 5) { // 最多取近 5 个解
const tEnd = tStart + durationMinutes * 60000;
timeOptions.push({
start_time: new Date(tStart).toISOString(),
end_time: new Date(tEnd).toISOString()
});
tStart += durationMinutes * 60000;
}
}
return timeOptions;
}
/**
* 能力2:分发意向收集卡片给所有参与人
*/
async sendPollCards(userIds, sessionId, meetingTopic, agentMessage, timeSlots) {
// 构造带回调 session 的动作按钮
const actions = timeSlots.map((ts, i) => {
// 转化为中文本地时区以显示(明确使用 Asia/Shanghai 时区)
const startDate = new Date(ts.start_time);
const endDate = new Date(ts.end_time);
const text = `'Asia/Shanghai')} '2-digit', minute: '2-digit', timeZone: 'Asia/Shanghai')} - '2-digit', minute: '2-digit', timeZone: 'Asia/Shanghai')}`;
return {
tag: 'button',
text: { tag: 'plain_text', content: text },
type: i === 0 ? 'primary' : 'default',
value: { action: 'vote', session_id: sessionId, choice: JSON.stringify(ts) } // choice 带有完整开始结束时间
};
});
// 永远补充一个都没空的按钮,让用户能走退路交由 LLM 洗牌
actions.push({
tag: 'button',
text: { tag: 'plain_text', content: '以上时间均冲突' },
type: 'danger',
value: { action: 'vote', session_id: sessionId, choice: 'none' }
});
const cardContent = {
config: { wide_screen_mode: true },
header: { template: 'blue', title: { tag: 'plain_text', content: `📅 会议排期邀请:meetingTopic` } },
elements: [
{ tag: 'markdown', content: `你好~我是智能排期助手,agentMessage` },
{ tag: 'action', actions: actions }
]
};
const payloadStr = JSON.stringify(cardContent);
// 给诸位私聊派发卡片
for (const uid of userIds) {
try {
await this._request('POST', 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=user_id', {
body: { receive_id: uid, msg_type: 'interactive', content: payloadStr }
});
logger.info({ uid, sessionId }, 'Poll card dispatched');
} catch (err) {
logger.error({ uid, err: err.message }, 'Failed sending card');
}
}
}
/**
* 能力4:创建日程
*/
async createMeeting(userIds, meetingTopic, startTime, endTime) {
// 调用日历 V4 的 create event 接口
const eventBody = {
summary: meetingTopic,
start_time: { timestamp: String(Math.floor(new Date(startTime).getTime()/1000)) },
end_time: { timestamp: String(Math.floor(new Date(endTime).getTime()/1000)) },
attendee_ability: 'none',
need_notification: true
};
try {
// 使用环境变量配置的日历 ID,如果未配置则使用 primary
const calendarId = process.env.FEISHU_CALENDAR_ID || 'primary';
const calRes = await this._request('POST', `https://open.feishu.cn/open-apis/calendar/v4/calendars/calendarId/events`, {
body: eventBody
});
logger.info({ meetingTopic, calendarId }, 'Calendar event created successfully.');
// 为参会者发确认信
for (const uid of userIds) {
await this._request('POST', 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=user_id', {
body: {
receive_id: uid, msg_type: 'text',
content: JSON.stringify({text: `✅ 您好,会议"meetingTopic"已于 'Asia/Shanghai')} 成功建会并锁定!`})
}
});
}
return true;
} catch(err) {
logger.error({ err: err.message }, 'Error creating calendar event');
return false;
}
}
}
module.exports = FeishuService;
FILE:openapi.yaml
openapi: 3.1.0
info:
title: 飞书智能排期 AI 助手
description: 提供给大语言模型 (LLM) 和 OpenClaw Agent 使用的一组飞书原子化排期与卡片发包技能。
version: 1.0.0
servers:
- url: https://your-server-url.com/api/claw
description: 线上部署环境 (由于带有 Webhook,需为公网域名)
paths:
/calc-free-time:
post:
operationId: calcFreeTime
summary: 技能一:日历计算器
description: 给定一批用户的飞书 user_id、期望的时间区间与会议时长要求,查阅他们所有人的日历并返回能够满足条件的共同空闲时间段。作为寻找排期选项的第一步。
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- userIds
- startTimeIso
- endTimeIso
- durationMinutes
properties:
userIds:
type: array
items:
type: string
description: 参与排期的目标人员的飞书 user_id 集合
startTimeIso:
type: string
description: 寻找最早在哪个时间之后的空闲 (ISO8601格式,含时区如 +08:00)
endTimeIso:
type: string
description: 寻找最晚在哪个时间之前的空闲 (ISO8601格式,含时区如 +08:00)
durationMinutes:
type: integer
description: 这场会议预计需要的时间,单位分钟(例如 60)。函数会自动过滤那些过碎的空挡。
responses:
'200':
description: 获取成功,返回可选的时间段 JSON 列表
content:
application/json:
schema:
type: object
/dispatch-cards:
post:
operationId: dispatchPollCards
summary: 技能二:发包与意向收集单
description: 当通过日历计算器拿到备选时间,或 LLM 面临时间冲突提出新议案时,由 Agent 拟定带情绪的友好导语,并带上几个时间作为选项,给当事人分发包含按钮选项的飞书消息。它在发出的那一刻即会被系统持久化追踪,无需 Agent 死循环等待。
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- meetingTopic
- userIds
- timeSlots
- agentMessage
properties:
meetingTopic:
type: string
description: 这个排期的事务主题或者是会议名称
userIds:
type: array
items:
type: string
description: 要发送意向卡片的候选人飞书 user_id 集合
timeSlots:
type: array
description: 上一步日历计算系统吐出的候选时间参数(包含 start_time, end_time)。或者你想让某人“二选一”生造的时间。
agentMessage:
type: string
description: 你的语气(这由你 LLM 本身决定,比如“刘总,我看了大家的日程,礼拜三十点都比较宽裕,这个点您看行吗?”)。该语气会作为标题展现在时间按钮上方。
responses:
'200':
description: 发包成功,返回内部跟踪使用的 Session ID
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
sessionId:
type: string
FILE:bitableStore.js
/**
* 基于飞书多维表格的会话存储模块
*
* 设计策略:
* - 每次 dispatch-cards 时,临时创建一张多维表格(一个 app + 一个 table)
* - 表格名称带有 sessionId,便于查找和去重
* - 会议完成(agreed)或冲突解决后,自动删除对应多维表格
* - 启动时检查并清理超过 TTL 的孤儿表格(防止异常中断导致的残留)
*/
const logger = {
info: (ctx, msg) => console.log(`[INFO]`, JSON.stringify({ ...ctx, msg })),
error: (ctx, msg) => console.error(`[ERR]`, JSON.stringify({ ...ctx, msg }))
};
// 会话最大存活时间:48 小时(毫秒)
const SESSION_TTL_MS = 48 * 60 * 60 * 1000;
// 多维表格 app 名称前缀,便于识别和清理
const APP_NAME_PREFIX = 'sched_session_';
class BitableStore {
/**
* @param {FeishuService} feishuService - 已初始化的 FeishuService 实例(共享 token 缓存)
*/
constructor(feishuService) {
this.svc = feishuService;
}
// ─── 内部辅助 ────────────────────────────────────────────────
async _req(method, url, body) {
return this.svc._request(method, url, body ? { body } : {});
}
/** 将 session 数据序列化为多维表格单行字段 */
_toFields(data) {
return {
session_id: data.id,
meeting_topic: data.meetingTopic,
user_ids: JSON.stringify(data.userIds),
time_slots: JSON.stringify(data.timeSlots),
agent_message: data.agentMessage,
status: data.status,
participants_state: JSON.stringify(data.participantsState),
created_at: data.createdAt
};
}
/** 将多维表格单行字段反序列化为 session 数据 */
_fromFields(fields, recordId, appToken, tableId) {
return {
id: fields.session_id,
meetingTopic: fields.meeting_topic,
userIds: JSON.parse(fields.user_ids || '[]'),
timeSlots: JSON.parse(fields.time_slots || '[]'),
agentMessage: fields.agent_message,
status: fields.status,
participantsState: JSON.parse(fields.participants_state || '{}'),
createdAt: fields.created_at,
// 元信息,用于后续更新/删除
_recordId: recordId,
_appToken: appToken,
_tableId: tableId
};
}
// ─── 多维表格生命周期 ─────────────────────────────────────────
/**
* 为一个 session 创建专属多维表格 app
* 表格结构:一张 table,包含所有 session 字段
*/
async _createBitableApp(sessionId) {
// 1. 创建多维表格 app
const appRes = await this._req('POST',
'https://open.feishu.cn/open-apis/bitable/v1/apps',
{ name: `APP_NAME_PREFIXsessionId` }
);
const appToken = appRes.app.app_token;
// 2. 在 app 内创建数据表(带字段定义)
const tableRes = await this._req('POST',
`https://open.feishu.cn/open-apis/bitable/v1/apps/appToken/tables`,
{
table: {
name: 'sessions',
fields: [
{ field_name: 'session_id', type: 1 }, // 文本
{ field_name: 'meeting_topic', type: 1 },
{ field_name: 'user_ids', type: 1 },
{ field_name: 'time_slots', type: 1 },
{ field_name: 'agent_message', type: 1 },
{ field_name: 'status', type: 1 },
{ field_name: 'participants_state', type: 1 },
{ field_name: 'created_at', type: 2 } // 数字(时间戳)
]
}
}
);
const tableId = tableRes.table_id;
logger.info({ sessionId, appToken, tableId }, 'Bitable app created for session');
return { appToken, tableId };
}
/**
* 删除多维表格 app(会议完成或过期时调用)
*/
async _deleteBitableApp(appToken) {
try {
await this._req('DELETE',
`https://open.feishu.cn/open-apis/bitable/v1/apps/appToken`
);
logger.info({ appToken }, 'Bitable app deleted');
} catch (err) {
logger.error({ appToken, err: err.message }, 'Failed to delete bitable app');
}
}
// ─── 公开 API ─────────────────────────────────────────────────
/**
* 创建会话(新建多维表格 + 写入第一行)
* 创建前检查是否已存在同名 session,防止重复
*/
async createSession(id, details) {
// 去重检查:查询已有 app 列表,看是否有同名的
const existing = await this._findAppBySessionId(id);
if (existing) {
logger.info({ id }, 'Session already exists, skipping creation');
return this.getSession(id);
}
const { appToken, tableId } = await this._createBitableApp(id);
const sessionData = {
id,
...details,
status: 'WAITING',
participantsState: details.userIds.reduce((acc, uid) => {
acc[uid] = { status: 'PENDING', selectedTime: null };
return acc;
}, {}),
createdAt: Date.now()
};
// 写入第一行记录
await this._req('POST',
`https://open.feishu.cn/open-apis/bitable/v1/apps/appToken/tables/tableId/records`,
{ fields: this._toFields(sessionData) }
);
return { ...sessionData, _appToken: appToken, _tableId: tableId };
}
/**
* 读取会话数据
*/
async getSession(id) {
const meta = await this._findAppBySessionId(id);
if (!meta) return null;
const { appToken, tableId } = meta;
const res = await this._req('GET',
`https://open.feishu.cn/open-apis/bitable/v1/apps/appToken/tables/tableId/records?page_size=1`
);
const record = res.items?.[0];
if (!record) return null;
return this._fromFields(record.fields, record.record_id, appToken, tableId);
}
/**
* 更新参会人意向,并回写多维表格
*/
async updateParticipantState(sessionId, userId, selectedTime) {
const session = await this.getSession(sessionId);
if (!session) return null;
if (session.participantsState[userId] !== undefined) {
session.participantsState[userId] = {
status: selectedTime === 'none' ? 'REJECTED' : 'CONFIRMED',
selectedTime
};
await this._req('PUT',
`https://open.feishu.cn/open-apis/bitable/v1/apps/session._appToken/tables/session._tableId/records/session._recordId`,
{ fields: this._toFields(session) }
);
}
return session;
}
/**
* 检查共识状态(逻辑与原 dbStore 一致)
*/
async checkSessionConsensus(sessionId) {
const session = await this.getSession(sessionId);
if (!session) return { done: false, result: null };
const states = Object.values(session.participantsState);
const pendingCount = states.filter(s => s.status === 'PENDING').length;
if (pendingCount > 0) return { done: false, result: 'pending' };
const rejects = states.filter(s => s.status === 'REJECTED');
if (rejects.length > 0) return { done: true, result: 'conflict' };
const selectedTimes = states.map(s => s.selectedTime);
// 至少 2 人且所有人选的时间一致才算 agreed
const allSame = selectedTimes.length >= 2 && selectedTimes.every(t => t === selectedTimes[0]);
if (allSame) return { done: true, result: 'agreed', time: selectedTimes[0] };
return { done: true, result: 'conflict' };
}
/**
* 会议完成后删除多维表格(自动清理)
*/
async deleteSession(sessionId) {
const meta = await this._findAppBySessionId(sessionId);
if (!meta) return;
await this._deleteBitableApp(meta.appToken);
}
/**
* 启动时清理超过 TTL 的孤儿表格
* 建议在 index.js 启动时调用一次
*/
async cleanupExpiredSessions() {
try {
const res = await this._req('GET',
'https://open.feishu.cn/open-apis/bitable/v1/apps?page_size=50'
);
const apps = res.items || [];
const now = Date.now();
let cleaned = 0;
for (const app of apps) {
if (!app.name.startsWith(APP_NAME_PREFIX)) continue;
// 通过 app 的创建时间判断是否过期(飞书返回秒级时间戳)
const createdAt = (app.create_time || 0) * 1000;
if (now - createdAt > SESSION_TTL_MS) {
await this._deleteBitableApp(app.app_token);
cleaned++;
}
}
if (cleaned > 0) logger.info({ cleaned }, 'Expired bitable sessions cleaned up');
} catch (err) {
logger.error({ err: err.message }, 'Failed to cleanup expired sessions');
}
}
// ─── 内部查找 ─────────────────────────────────────────────────
/**
* 通过 sessionId 查找对应的多维表格 app
* 利用 app 名称约定 `sched_session_<sessionId>` 快速定位
*
* 注意:当前实现只扫描前 50 个 app,如果账号下有大量多维表格,可能找不到目标。
* 生产环境建议:
* 1. 使用独立的飞书账号专门运行此 skill
* 2. 或实现分页逻辑(page_token)遍历所有 app
* 3. 或使用外部 KV 存储(Redis)缓存 sessionId -> appToken 映射
*/
async _findAppBySessionId(sessionId) {
try {
const res = await this._req('GET',
'https://open.feishu.cn/open-apis/bitable/v1/apps?page_size=50'
);
const apps = res.items || [];
const target = apps.find(a => a.name === `APP_NAME_PREFIXsessionId`);
if (!target) return null;
// 获取该 app 下的第一张表
const tableRes = await this._req('GET',
`https://open.feishu.cn/open-apis/bitable/v1/apps/target.app_token/tables`
);
const tableId = tableRes.items?.[0]?.table_id;
if (!tableId) return null;
return { appToken: target.app_token, tableId };
} catch (err) {
logger.error({ sessionId, err: err.message }, 'Failed to find bitable app');
return null;
}
}
}
module.exports = BitableStore;