最近接手了一个挺有意思的活儿:把本地的 OpenClaw 接入飞书,让团队成员直接在飞书群里喊一句,就能触发本地 AI 代理跑任务、查数据、往多维表格里写东西。折腾下来踩了一圈坑,也理出了一套比较顺的接入路径。写出来分享给正在做类似对接的兄弟,尤其是手里已经有 OpenClaw 这类开源 AI 代理、想借飞书机器人把它暴露给团队用的场景。
先说清楚 OpenClaw 是什么。它本质上是一个开源的 AI 代理运行框架,可以理解为本地版的 Claude Code / Codex 类工具,能让它读文件、调工具、跑脚本,也能暴露 HTTP API 供外部调用。而飞书这边,开放平台提供了机器人、事件订阅、多维表格 API 等等。所谓“接入”,核心就是打通两条链路:飞书消息进来之后交给 OpenClaw 处理,OpenClaw 的结果再通过机器人回复给飞书;同时,如果需要表格能力,再通过飞书开放 API 把结构化数据写进多维表格。适合谁来参考?大概三种人:给自己团队搭 AI 助手的开发者、在飞书里做自动化流程的运维或产品同学、以及纯粹想把开源代理能力接进 IM 的折腾党。下文按实际落地的顺序写,照着做基本能通。
1. 整体方案设计与工具选型
1.1 先想清楚消息链路怎么走
接入飞书,最容易犯的错是一上来就写代码,结果发现方向错了。先把整条链路画在脑子里:飞书群里有人 @机器人 → 飞书开放平台把消息事件推送到我们的服务 → 我们的服务调用 OpenClaw 的接口,把消息作为 prompt 传给它 → OpenClaw 执行工具调用后返回结果 → 我们的服务把结果发回飞书群。这条链路里,OpenClaw 始终是一个被动的执行引擎,飞书机器人则承担入口和出口的角色。
这里有个关键决策:飞书事件回调用长连接还是 Webhook。长连接(WebSocket)模式不需要公网回调地址,飞书开放平台支持通过 SDK 建立长连接接收事件,省去内网穿透的麻烦。Webhook 模式则要求你有公网 HTTPS 地址,并且要能通过飞书的 URL 验证。我在实际项目里选了长连接,因为本地开发调试时不用折腾端口映射,飞书的事件通过 SDK 推过来,服务端只需要保持连接即可。如果你生产环境有公网入口,Webhook 也完全可行,但长连接对中小团队更友好。
另一个需要提前拍板的是:OpenClaw 跑在哪里。如果只是给自己玩,本地 localhost 就够了;如果整个团队要用,最好把 OpenClaw 服务部署到一台内网服务器上,飞书转发服务跟它同网段或同机部署。我这边是 Docker 跑 OpenClaw,监听一个本地端口,飞书转发服务在同一台机器上的另一个进程,彼此通过 HTTP 通信,干净且隔离。
1.2 技术选型与依赖准备
接入方案里,主要涉及三个生态:飞书开放平台、OpenClaw 的 API、中间胶水代码。飞书官方提供 Python SDK,封装了长连接事件订阅、机器人发消息、多维表格读写等操作,强烈建议直接用 SDK,别自己写 HTTP 签名和事件解析。OpenClaw 这边,它本身带一个 HTTP API,可以把对话请求 POST 给它,然后拿到回复。
整个工程我用的 Python 3.10 + fastapi + 飞书 SDK。FastAPI 只用来做 OpenClaw 结果回传给飞书时的一个轻量中间层,实际上如果你只用长连接,连 FastAPI 都可以不要,直接一个 asyncio 脚本配合飞书 SDK 就能跑。不过我还是留了 FastAPI,方便后续暴露一个健康检查接口,也方便扩展成 Webhook 模式。
依赖清单大致如下:
- Python 3.10+
- lark-oapi(飞书官方 SDK)
- fastapi、uvicorn(如果需要 HTTP 接口)
- httpx 或 aiohttp(调用 OpenClaw HTTP API)
- docker(跑 OpenClaw,可选)
这里提醒一句:飞书 SDK 的版本差异比较大,旧版叫feishu,新版叫lark-oapi,接口风格完全不同。本文用的都是lark-oapi的最新写法,如果你搜到老教程,注意区分。
2. 飞书侧配置:从零创建机器人应用
2.1 应用创建与权限配置
去飞书开放平台后台,创建一个企业自建应用。这一步没什么技术含量,但有几个地方容易忽略。应用创建好之后,第一件事是启用机器人能力,在“应用能力”里添加机器人,这样应用才有在群里发消息的权限。
然后是权限配置。很多人在这一步被卡住,因为飞书的权限体系是按 scope 控制的,而且事件订阅和 API 调用分别需要不同的权限。我实际用到的权限大概有这些:
| 权限 scope | 用途 |
|---|---|
| im:message | 读取群聊消息内容,接收用户 @机器人 的消息 |
| im:message.group_at_msg | 接收群里 @机器人 的消息事件 |
| im:message:send_as_bot | 以机器人身份发送消息 |
| bitable:app | 读取多维表格数据 |
| bitable:app:write | 写入多维表格数据 |
其中读写多维表格的权限,如果没有需求可以暂时不开,但后面如果你想让机器人把结果写进表格,就一定要申请。权限申请之后要发布应用版本,并等待管理员审核通过,否则权限不会真正生效。这里有一个坑:在应用开发阶段,你可以用“测试企业”或“自建应用”的调试权限,但如果要在正式群里用,必须走完发布流程。
事件订阅这边,要添加im.message.receive_v1事件。在事件订阅页里,如果你选长连接模式,SDK 会自动建立连接并推送事件,不需要配置回调 URL;如果你选 Webhook,飞书会要求填一个回调地址并做 URL 验证。我推荐长连接,省事。
2.2 长连接模式与事件解析
飞书 SDK 的长连接模式,核心就几行代码:
from lark_oapi.ws import Client as WSClient from lark_oapi.api.im.v1 import P2ImMessageReceiveV1 def on_message(ctx, event: P2ImMessageReceiveV1): # event.event.message.content 是 JSON 字符串 # event.event.message.chat_id 是群 ID print(ctx, event) ws_client = WSClient( "app_id", "app_secret", event_handler=on_message, log_level=logging.INFO, ) ws_client.start()这里要解释一下,长连接模式收到的事件并不是一个简单的文本,message.content是一个 JSON 字符串。当用户发纯文本时,它长这样:
{"text": "@机器人 帮我查一下今天的销售数据"}需要注意的是,消息里如果带了 @,text字段里会包含@_user_1这样的占位符,需要从文本里剥掉,否则直接丢给 OpenClaw 会变成一串乱码。我一般是正则去掉<at>xxx</at>或@_user_1这类标记。更标准的方式是解析mention字段,SDK 的P2ImMessageReceiveV1里有event.event.message.mentions,可以根据mention的key去替换。但简单场景下,正则剥掉制表符和 @ 标记也够用。
另一个细节是,收到事件后要尽快返回。飞书对事件回调的响应时间有限制,长连接模式下如果你在回调函数里同步调用了 OpenClaw 并等它跑完,遇到复杂任务可能超时。所以我的做法是回调里只做消息解析,然后丢进一个 asyncio task 或消息队列里,立刻返回,异步执行完再调飞书 API 发消息。
3. OpenClaw 侧对接实现
3.1 启动 OpenClaw 并暴露本地接口
OpenClaw 的部署方式,官方提供 Docker 镜像,也有一些发行版可以直接跑二进制。我这边是用 Docker 起的,命令行大致是:
docker run -d --name openclaw \ -p 8080:8080 \ -v $PWD/openclaw_workspace:/workspace \ openclaw/openclaw:latest具体镜像名和版本会随项目迭代变动,以你自己拉到的为准。重点是容器要暴露一个 HTTP 端口,/v1/chat或类似路径,接受 JSON 格式的{"message": "..."}请求并返回{"response": "..."}。不同版本的 OpenClaw API 路径会有差异,启动后先curl一下文档或者直接看容器日志里的路由列表,确认再写代码。
这里要特别说明:OpenClaw 的能力在于它可以调用工具,所以接入飞书后它不只是“聊天机器人”,而是能真正干活的代理。比如你可以让它读本地工作区里的 CSV 文件、执行 Python 脚本、调用外部 API。我在群里发一句“帮我统计一下工作区里所有 md 文件的字数”,OpenClaw 就会自己去遍历文件、跑命令、把结论返回给我。这个体验跟普通问答机器人完全不一样。
如果 OpenClaw 没有暴露 HTTP API,也可以退而求其次,直接用 CLI 子进程方式调用,把消息作为参数传进去,然后捕获 stdout。但这种方式对并发不友好,OpenClaw 一次只能跑一个任务,而且调试起来很痛苦。有 API 就优先用 API。
3.2 中转脚本编写实战
接下来是核心的胶水代码。我用lark-oapi接收事件,用httpx调 OpenClaw,再调飞书消息 API 回复。一个最小可运行的示例大概长这样:
import asyncio import json import re import httpx from lark_oapi.ws import Client as WSClient from lark_oapi.api.im.v1 import ( P2ImMessageReceiveV1, CreateMessageRequest, CreateMessageRequestBody, ) OPENCLAW_URL = "http://127.0.0.1:8080/v1/chat" def clean_mention_text(raw: str) -> str: # 去掉 @_user_1 和 <at> 标签 text = re.sub(r"@_user_\d+", "", raw) text = re.sub(r"<at[^>]*>.*?</at>", "", text) return text.strip() async def call_openclaw(prompt: str) -> str: async with httpx.AsyncClient(timeout=300) as client: resp = await client.post(OPENCLAW_URL, json={"message": prompt}) resp.raise_for_status() data = resp.json() return data.get("response", data.get("reply", "")) async def reply_message(client, message_id: str, text: str): req = CreateMessageRequest.builder() \ .receive_id_type("chat_id") \ .request_body( CreateMessageRequestBody.builder() .receive_id(message_id) # 这里实际应传 chat_id,注意区分 .msg_type("text") .content(json.dumps({"text": text})) .build() ) \ .build() await client.im.v1.message.create(req) def on_message(ctx, event: P2ImMessageReceiveV1): msg = event.event.message content = json.loads(msg.content) raw_text = content.get("text", "") user_text = clean_mention_text(raw_text) chat_id = msg.chat_id # 异步执行,避免阻塞事件回调 asyncio.create_task(handle(chat_id, user_text)) async def handle(chat_id: str, user_text: str): # 这里需要初始化一个飞书客户端,放到全局更合适 result = await call_openclaw(user_text) await reply_message(client, chat_id, result) ws_client = WSClient( "app_id", "app_secret", event_handler=on_message, log_level=logging.INFO, ) if __name__ == "__main__": ws_client.start()上面这段代码展示的是核心逻辑,实际用的时候有几个地方必须改:reply_message里receive_id应该传chat_id,而不是message_id,我写的时候为了提醒踩坑故意保留了两种写法,你可以对照飞书 SDK 的文档确认。另外client不能放在handle里重复初始化,应该在主程序里创建一个异步客户端,然后传给各个协程。长连接 SDK 的start是阻塞的,所以事件回调里直接用asyncio.create_task是可行的。
消息超时是个大问题。OpenClaw 跑复杂任务时,十几秒到几十秒都很正常。飞书机器人发消息本身没有强制的超时限制,但用户会以为机器人死了。我的做法是:收到消息后先回复一条“收到,正在处理”,然后等 OpenClaw 返回后再把结果发到群里。这样体验会好很多。还有一种方案是用飞书的“卡片”消息,先发一个带 loading 状态的卡片,处理完再更新卡片,但卡片接口相对复杂,需要申请消息卡片权限,我这里就先不展开了。
3.3 对接多维表格:让机器人直接发表格数据
热词里反复出现“飞书机器人发送表格”和“多维表格”,说明很多人的诉求是这个场景。先说结论:机器人可以直接发文本、富文本、图片、卡片,但“发表格”最实用的方式有两种。第一种是把数据整理成 Markdown 表格塞进文本消息里,飞书会自动渲染成一串字符表格,但阅读体验一般;第二种是直接调多维表格 API,创建记录/更新记录,然后发一条消息附上表格链接。
第二种方案才是正经玩法。OpenClaw 执行完任务后,产生了一批结构化数据,中转脚本负责把数据写入多维表格,再把表格的 URL 发到群里。多维表格 API 的关键点在于需要拿到app_token和table_id。怎么找?打开多维表格的 URL,形如https://xxx.feishu.cn/base/{app_token}?table={table_id},URL 里那两个字段就是。
用 SDK 写多维表格记录大概是这样:
from lark_oapi.api.bitable.v1 import ( CreateRecordRequest, CreateRecordRequestBody, AppTableRecord, ) def write_bitable_record(app_token, table_id, fields): req = CreateRecordRequest.builder() \ .app_token(app_token) \ .table_id(table_id) \ .request_body( CreateRecordRequestBody.builder() .record( AppTableRecord.builder() .fields(fields) .build() ) .build() ) \ .build() resp = client.bitable.v1.app_table_record.create(req) if not resp.success(): raise RuntimeError(f"write bitable failed: {resp.code} {resp.msg}")这里fields是一个 dict,key 是多维表格里的字段名,value 是对应类型的值。注意字段类型必须对得上:数字字段传 int/float,文本字段传 str,日期字段要传毫秒级时间戳。我在实际中经常遇到的一个问题是,多维表格字段名带空格或特殊字符,导致写入报错,建议字段名里别用特殊符号,或者在代码里做一层映射。
数据写入成功后,拿到记录 ID,可以拼接一条 URL 发给用户:https://xxx.feishu.cn/base/{app_token}?table={table_id}&recordId={record_id},点开就能定位到具体记录。如果只是要概览,发表格链接就行。
4. 常见问题与排查技巧实录
4.1 事件订阅回调验证失败
如果你选的是 Webhook 模式,飞书会往你的回调 URL 发一个带challenge的 GET 请求,要求原样返回challenge字段。很多人会在这里挂,因为飞书要求回调地址必须在 3 秒内响应,而且响应体必须是一个 JSON,格式是{"challenge": "..."}。如果你用的是 FastAPI,处理函数还要注意返回 dict 而不是 Response 对象,否则签名格式不对。长连接模式就没有这个问题,所以我个人建议能走长连接就走长连接。
另外一个跟验证相关的问题是:飞书要求回调 URL 的证书是正规 HTTPS 证书,自签名证书直接不认。如果你在内网调试,要么用长连接,要么用内网穿透工具配一个 HTTPS 域名。我不建议为了这个去搞一套复杂的证书体系。
4.2 消息响应超时与并发限制
OpenClaw 通常是单实例的,如果群里同时有多个人 @它,请求会排队,响应越来越慢。飞书 SDK 的长连接模式下,事件回调是可以并发触发的,但 OpenClaw 的 HTTP 接口不一定能扛住并发。我实测下来,超过两个并发请求,OpenClaw 就会开始丢任务或者响应异常。
应对策略有两个:一是给中转服务加一个简单的队列,同一时间只向 OpenClaw 提交一个任务,其余请求排队;二是在飞书群里提示用户“当前任务已排队”。我个人方案是在handle函数里用一个全局asyncio.Semaphore(1),简单粗暴:
semaphore = asyncio.Semaphore(1) async def handle(chat_id, user_text): async with semaphore: result = await call_openclaw(user_text) await reply_message(client, chat_id, result)这样至少保证 OpenClaw 不会被打爆。如果你的团队并发量很高,可以考虑给 OpenClaw 挂多个实例并做负载均衡,但那是另一个话题了。
还有一个很容易被忽视的问题:飞书消息接口有频控。如果 OpenClaw 一次性生成很长的回复,或你的服务频繁发消息,会触发飞书的限流,返回错误码 9499 之类的。遇到这种问题,可以降低发消息频率,或者把长回复拆成多条顺序发送,每条之间延迟几百毫秒。
4.3 权限不足与多维表格写入失败
多维表格写入失败,百分之七八十是权限配置问题。飞书的权限粒度很细,除了应用权限之外,还需要把机器人添加为多维表格的协作者。很多人只在开放平台后台申请了bitable:app:write权限,却在多维表格里没有给应用添加协作权限,结果调用 API 时返回permission denied。这一点非常隐蔽。
正确操作是:打开目标多维表格,点击右上角“分享”,添加你的应用机器人为协作者,并授予“可编辑”权限。这样应用才能写入记录。如果是群里的多维表格,还要注意机器人是不是该群的成员。一个快速验证办法是,在飞书开放平台的“调试”页面里手动调用一次创建记录接口,如果调试成功但代码里失败,说明是代码问题;如果调试也失败,多半是协作者权限没给。
多维表格字段类型不匹配也会报错。比如字段是“单选”类型,你传了一个普通字符串,SDK 虽然接受,但写入会失败,因为单选字段需要传选项的文本或选项 ID。日期字段更特殊,它要求毫秒时间戳,如果传字符串2025-01-01,会报错。我在脚本里专门写了一个字段类型映射函数,把 OpenClaw 返回的数据根据目标表的字段类型做转换,这样才稳定。
4.4 连接稳定性与日志定位
长连接偶尔会断,飞书 SDK 有自动重连机制,但我建议在启动脚本里加一个看门狗,定期检查 WebSocket 状态。更实用的是日志级别调成DEBUG,方便观察事件是否真正到达。OpenClaw 侧的日志也要盯着,尤其是工具调用失败时,它的日志里会直接输出报错堆栈,比飞书侧更容易定位问题。
中转脚本里我习惯加一条前置日志:收到消息、开始调用 OpenClaw、OpenClaw 返回、回复消息。四个阶段各打一条带chat_id的日志,出问题的时候能立刻看出卡在哪一环。这个习惯帮我省了不少排查时间。
最后再分享一个我实际操作的体会:OpenClaw 接入飞书,真正难的不是代码本身,而是消息从“用户 @机器人”到“OpenClaw 执行完任务”之间各种隐性约束。飞书的事件格式、权限体系、多维表格的字段规则,都有各自的坑。如果你照着本文的路子走,先用最简单的文本问答打通链路,再一步步加多维表格和工具调用,就稳得多。我这边目前已经稳定跑了一段时间,群里同事最常用的场景就是让机器人把各种统计结果直接写进多维表格,确实比手动复制粘贴高效太多。后续如果有机会,我还会尝试给 OpenClaw 增加更多飞书侧的工具,比如待办接口、云文档读取,进一步把零碎工作串起来。