PhoneWork/docs/feishu/development_guide.md
Yuyao Huang (Sam) ed70588d95 docs: 添加飞书 Bot 开发指南文档
添加详细的飞书 Bot 开发指南文档,涵盖 SDK 初始化、消息收发、文件上传、WebSocket 长连接等实战经验
2026-03-29 19:59:31 +08:00

331 lines
8.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 飞书 Bot 开发经验总结
基于 `lark-oapi` Python SDK 的实战经验涵盖接收消息、发送消息、文件上传、WebSocket 长连接等。
---
## 1. SDK 初始化
```python
import lark_oapi as lark
client = (
lark.Client.builder()
.app_id(APP_ID)
.app_secret(APP_SECRET)
.log_level(lark.LogLevel.WARNING)
.build()
)
```
`client` 是全局单例,线程安全,可在模块级别创建。
---
## 2. 接收消息WebSocket 长连接模式
飞书推荐使用 WebSocket 长连接(而非 HTTP 回调),省去公网暴露和回调验证。
### 2.1 事件注册
```python
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
handler = (
lark.EventDispatcherHandler.builder("", "") # 加密 key 和验证 token长连接模式留空
.register_p2_im_message_receive_v1(on_message) # 注册消息回调
.build()
)
```
### 2.2 启动 WebSocket 客户端
```python
ws_client = lark.ws.Client(
APP_ID,
APP_SECRET,
event_handler=handler,
log_level=lark.LogLevel.INFO,
)
ws_client.start() # 阻塞调用
```
### 2.3 关键坑点:事件循环
`lark_oapi.ws.client` 在 import 时捕获当前事件循环。如果你的主程序用了 `asyncio`(如 uvicorn
必须在 WebSocket 线程中创建新的事件循环并替换:
```python
import lark_oapi.ws.client as _lark_ws_client
thread_loop = asyncio.new_event_loop()
asyncio.set_event_loop(thread_loop)
_lark_ws_client.loop = thread_loop # 关键:替换 SDK 内部持有的 loop
```
### 2.4 断线重连
`ws_client.start()` 在连接断开时会直接返回(不抛异常),需要自己包裹重连循环:
```python
backoff = 1.0
while True:
try:
ws_client.start()
except Exception:
pass
time.sleep(backoff)
backoff = min(backoff * 2, 60.0)
```
### 2.5 解析收到的消息
```python
def on_message(data: P2ImMessageReceiveV1):
event = data.event
message = event.message
sender = event.sender
chat_id = message.chat_id # 会话 ID群/私聊)
msg_type = message.message_type # "text", "image", "file", ...
content = json.loads(message.content) # 消息内容是 JSON 字符串
text = content.get("text", "")
# 发送者信息
open_id = sender.sender_id.open_id # 用户唯一标识
```
**注意**
- `message.content` 是 JSON 字符串,不是纯文本,需要 `json.loads`
- 群聊中 @bot 的消息会包含 `@xxx` 前缀,需要用正则清除:`re.sub(r"@\S+\s*", "", text)`
- 飞书遇到网络问题会在 ~60s 内重发相同消息,需要做**去重**(按 `(user_id, content)` + 时间窗口)
---
## 3. 发送消息
所有发送都通过 `client.im.v1.message.create`,通过 `msg_type``content` 区分消息类型。
### 3.1 `receive_id_type` 的选择
| 值 | 含义 | 用途 |
|---|---|---|
| `"chat_id"` | 会话 ID | 回复到当前会话(群聊或私聊) |
| `"open_id"` | 用户 open_id | 主动私聊某个用户(如通知) |
| `"user_id"` | 用户 user_id | 同上,另一种 ID 体系 |
**经验**:回复消息用 `chat_id`,主动推送通知用 `open_id`
### 3.2 发送纯文本
```python
content = json.dumps({"text": "Hello"}, ensure_ascii=False)
request = (
CreateMessageRequest.builder()
.receive_id_type("chat_id")
.request_body(
CreateMessageRequestBody.builder()
.receive_id(chat_id)
.msg_type("text")
.content(content)
.build()
)
.build()
)
response = client.im.v1.message.create(request)
```
### 3.3 发送卡片消息Markdown
卡片消息是飞书中展示富文本的主要方式。`msg_type``"interactive"`content 为卡片 JSON。
#### JSON 2.0 Markdown 卡片(推荐)
```python
card = {
"schema": "2.0",
"body": {
"elements": [
{
"tag": "markdown",
"content": "**粗体** *斜体* `code`\n- 列表项\n```python\nprint('hello')\n```",
}
]
}
}
content = json.dumps(card, ensure_ascii=False)
# msg_type = "interactive"
```
#### JSON 1.0 卡片(旧版,带标题栏)
```python
card = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": "标题"},
"template": "turquoise", # 标题栏颜色
},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": "**markdown** 内容"}},
],
}
```
**JSON 1.0 vs 2.0 的区别**
- 1.0 用 `lark_md` tag + `div` 容器2.0 直接用 `markdown` tag
- 2.0 支持标准 CommonMark1.0 有较多语法限制
- 2.0 支持表格、标题(`# ## ###`1.0 不支持
- 需要标题栏时用 1.0 的 `header`;纯内容展示用 2.0 更简洁
### 3.4 发送文件
两步流程:先上传文件获取 `file_key`,再发送文件消息。
```python
from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody
# Step 1: 上传
with open(path, "rb") as f:
req = (
CreateFileRequest.builder()
.request_body(
CreateFileRequestBody.builder()
.file_type("stream") # "stream", "opus", "mp4", "pdf", "doc", "xls", "ppt"
.file_name(file_name)
.file(f)
.build()
)
.build()
)
resp = client.im.v1.file.create(req)
file_key = resp.data.file_key
# Step 2: 发送
content = json.dumps({"file_key": file_key}, ensure_ascii=False)
# msg_type = "file"
```
---
## 4. Markdown 语法速查JSON 2.0 卡片)
JSON 2.0 支持标准 CommonMark除 HTMLBlock外加飞书扩展语法。
### 标准语法
| 效果 | 语法 |
|---|---|
| 粗体 | `**text**` |
| 斜体 | `*text*` |
| 删除线 | `~~text~~` |
| 行内代码 | `` `code` `` |
| 代码块 | ` ```python\ncode\n``` ` |
| 链接 | `[text](url)` |
| 图片 | `![alt](image_key)` |
| 有序列表 | `1. item` (4 空格缩进为子项) |
| 无序列表 | `- item` |
| 引用 | `> text` |
| 标题 | `# ~ ######` (1-6 级) |
| 分割线 | `---``<hr>` |
| 表格 | 标准 Markdown 表格语法 |
### 飞书扩展语法
| 效果 | 语法 |
|---|---|
| @某人 | `<at id=open_id></at>` |
| @所有人 | `<at id=all></at>` |
| 彩色文本 | `<font color='red'>红色</font>` |
| 标签 | `<text_tag color='blue'>标签</text_tag>` |
| 飞书表情 | `:DONE:` `:THUMBSUP:` |
| 带图标链接 | `<link icon='chat_outlined' url='...'>文本</link>` |
| 电话链接 | `[显示文本](tel://号码)` (仅移动端) |
### 特殊字符转义
Markdown 特殊字符(`* ~ > < [ ] ( ) # : + " '` 等)需要 HTML 实体转义才能原样显示:
| 字符 | 转义 |
|---|---|
| `*` | `&#42;` |
| `<` | `&#60;` |
| `>` | `&#62;` |
| `~` | `&sim;` |
| `` ` `` | `&#96;` |
完整对照参考 [HTML 转义标准](https://www.w3school.com.cn/charsets/ref_html_8859.asp)。
---
## 5. 长消息处理
飞书单条文本消息有长度限制(约 4000 字符),卡片消息整体 JSON 约 28KB。超长内容需要拆分
```python
MAX_TEXT_LEN = 3900
def split_message(text: str) -> list[str]:
"""在换行符处分割,避免截断代码块或段落中间。"""
if len(text) <= MAX_TEXT_LEN:
return [text]
parts = []
remaining = text
while remaining:
if len(remaining) <= MAX_TEXT_LEN:
parts.append(remaining)
break
chunk = remaining[:MAX_TEXT_LEN]
last_newline = chunk.rfind("\n")
if last_newline > MAX_TEXT_LEN // 2:
chunk = remaining[:last_newline + 1]
parts.append(chunk)
remaining = remaining[len(chunk):]
return parts
```
多段发送时加 `await asyncio.sleep(0.3)` 避免飞书限流。
---
## 6. 异步兼容
`lark-oapi` 的 SDK 方法全是同步的(阻塞 I/O在 asyncio 环境中需要用 `run_in_executor`
```python
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None,
lambda: client.im.v1.message.create(request),
)
```
---
## 7. 错误处理
所有 API 调用检查 `response.success()`
```python
if not response.success():
logger.error("code=%s msg=%s", response.code, response.msg)
```
常见错误码:
- **权限不足**:机器人未添加到群聊,或未开通对应 API 权限
- **receive_id 无效**ID 类型与 `receive_id_type` 不匹配
- **内容过长**:消息体超出限制
---
## 8. 实用模式总结
| 场景 | msg_type | 发送函数 |
|---|---|---|
| 系统通知、短消息 | `text` | `send_text` |
| LLM/AI 回复(含代码、列表) | `interactive` | `send_markdown` (JSON 2.0 卡片) |
| 结构化信息(带标题栏) | `interactive` | `send_card` (JSON 1.0 卡片) |
| 文件传输 | `file` | `send_file` (先上传再发送) |
| 主动推送给用户 | 同上 | `receive_id_type="open_id"` |
| 回复当前会话 | 同上 | `receive_id_type="chat_id"` |