1. 背景与目标
ZeroClaw 作为通用自动化代理框架,支持多渠道,本篇做深度技术解剖:从代码级角度看 lark.rs,实现如何接入飞书家族(Lark/Feishu),并覆盖文本、帖子、图片、文件、音频、心跳、token管理等关键逻辑。
2. 平台基本差异:Lark vs Feishu
代码核心是 LarkPlatform 枚举:
Lark-> base API:https://open.larksuite.com/open-apisFeishu-> base API:https://open.feishu.cn/open-apis- WS URL 分别
https://open.larksuite.com/https://open.feishu.cn - 语言头:
locale: "en"(Lark) /"zh"(Feishu) - 代理标识:
channel.lark/channel.feishu - 统一接口:避免代码重复,主要差异通过上述方法分发
3. 启动与通道初始化(对象构造)
LarkChannel 核心构造:
new, new_with_platform, from_config, from_lark_config, from_feishu_config:兼容旧 flaguse_feishu, 新配置channels_config.lark。- 重要字段
app_id,app_secret,verification_token:认证凭证receive_mode(WS | webhook)allowed_users(白名单 / 通配*)mention_only(群组强制 @ 才回复)resolved_bot_open_id(群组 @ 判断)tenant_token(缓存 access token)
4. 身份认证、Token 管理
4.1 访问令牌获取
get_tenant_access_token():
- URL:
/auth/v3/tenant_access_token/internal - body:
{ app_id, app_secret } - 成功后拿
tenant_access_token和expire/expires_in - 计算
refresh_after = now + max(ttl - 120s,1s) - 缓存结构:
CachedTenantToken { value, refresh_after }
4.2 token 失效判断
should_refresh_lark_tenant_token(status, body):
- 401 -> 重新获取
- 业务代码
code == 99991663(Lark token 过期/无效)
4.3 失效手工清理
invalidate_token() 将缓存设 None,下一次自行刷新。
5. 消息接收方式:WebSocket + Webhook
5.1 方法选择(1 个入口)
LarkChannel::listen():
LarkReceiveMode::WebSocket→listen_ws()LarkReceiveMode::Webhook→listen_http()
5.2 WebSocket 长连接(推荐)
- 先
ensure_bot_open_id():/bot/v3/info取得机器人open_id,用于群 @ 判定; get_ws_endpoint():POST/callback/ws/endpoint得wss_url + client_config;tokio_tungstenite::connect_async(wss_url);- 心跳
- 默认
ping_interval=120 WS_HEARTBEAT_TIMEOUT=300- 每次 ping + 监听 pongs 或二进制事件刷新
last_recv;
- 默认
- 协议帧:
PbFrame(wig)method=0控制(ping/pong)method=1数据事件
- 事件 ACK(Feishu 要求 3s 内)
- 回写:即刻原帧
code=200+biz_rt=0
- 回写:即刻原帧
- 分片重组(
sum,seq) ws_seen_ids去重(30 min)- 事件
event_type == im.message.receive_v1以后处理
5.3 HTTP Webhook(备选)
listen_http():
- Axum 路由
POST / - 签名校验(
verification_token?) - 事件回调直接进入
parse_event_payload_async,返回标准{"code":0, ...}
6. 消息解析核心:parse_event_payload 和 parse_event_payload_async
parse_event_payload_async优先,支持音频转录;非音频退到parse_event_payload。
6.1 通用判定
- 仅处理:
header.event_type == "im.message.receive_v1" - 提取
sender.open_id;is_user_allowed()过滤 mention_only+ 群组:should_respond_in_group(...)校验chat_id作为sender/reply_targettimestamp由create_time(ms)/ 1000 或当前
6.2 文本和帖子处理
textcontentJSON 解析后.text
post- 调用
parse_post_content_details(content_str)得可读内容 +mentioned_open_ids - 同样参与 @ 响应判定
- 调用
6.3 图片处理
message_type == "image"content解析image_key- 调
download_image_as_marker(key):- 请求
/im/v1/images/{key}+ Bearer Token - 限制
LARK_IMAGE_MAX_BYTES=5MiB - MIME 检查:png/jpg/gif/webp/bmp
- 结果:
[IMAGE:data:{mime};base64,{base64}]
- 失败则退
"[IMAGE:{key} | download failed]"
- 请求
6.4 文件处理
message_type == "file"content解析file_key/file_name- 调
download_file_as_content(message_id, file_key, file_name):- 请求
/im/v1/messages/{message_id}/resources/{file_key}?type=file - 限制
LARK_FILE_MAX_BYTES=512KiB(超限仅摘要) - 如果是图像则转 image-marker
- 否则根据 MIME/文件名做“可读 text”:
- text/json/xml/yaml/javascript/csv 或扩展名为文本
- 取前 50K 字符,超出标记省略
- binary 直接输出:
[ATTACHMENT:... | mime=... | size=... bytes]
- 请求
6.5 音频处理
audio类型(一般含file_key)transcription_manager必须有(with_transcription())- 走
try_transcribe_audio_message(message_id, content, manager):- 先取
content.file_key download_audio_resource(message_id, file_key):- 同
/resources下载并流式读取,限MAX_LARK_AUDIO_BYTES=25MiB
- 同
manager.transcribe(audio_data, filename):可用 Whisper/本地模型- 若转写成功返回文本;失败则丢弃该消息
- 先取
parse_event_payload_async只在audio时执行,上层直接填ChannelMessage.text = transcript
7. 心跳确认与 ACK reaction
try_add_ack_reaction(message_id, emoji_type):
- 发送
/im/v1/messages/{message_id}/reactions:- body 按
{"reaction_type":{"emoji_type":"OK"}}
- body 按
- 如果 401 = token 过期,则
invalidate_token(), 再刷新重试一次 - response code 非0 记录警告但不崩溃
random_lark_ack_reaction(event, text) 依据内容/语言选池:
- 对中文(日文)/英文/繁体区分
- 可返回
OK/THUMBSUP/THANKS等
8. 消息发送(output)与卡片分片
impl Channel for LarkChannel的 send:
- 先
get_tenant_access_token - 目标
/im/v1/messages?receive_id_type=chat_id - 使用
send_text_once(url, token, body)做 HTTP 请求 - 文本超过
LARK_CARD_MARKDOWN_MAX_BYTES=28000分 chunk:split_markdown_chunks按换行切块,避免断开 Markdown- 每块通过
build_interactive_card_body(Card 2.0)发送
ensure_lark_send_success(status, body, context)校验 HTTP+业务 code- 失败就
bail!外抛
9. 边界与安全点补充
- 防重:WS
ws_seen_ids+ 30 分钟 - 并发:
tokio::spawn异步增加 reaction 不阻塞主流 - 群组 @:
mention_only=true时,必须包含 bot open_id - 非白名单:直丢弃
- 空消息:
strip_at_placeholders清空后丢弃 - 异常容忍:获取图片/文件失败、转写失败、反应失败只日志,继续接本条
sequenceDiagram
participant User as 用户
participant Lark as 飞书服务
participant Bot as ZeroClaw LarkChannel
participant Parser as parse_event_payload(_async)
participant Trans as 转录模块(可选)
participant Sender as 发送模块(Channel::send)
Note right of Bot: 初始化 LarkChannel(from_config)
Bot->>Lark: POST /auth/v3/tenant_access_token/internal
Lark-->>Bot: tenant_access_token
Bot->>Lark: POST /callback/ws/endpoint(AppID,AppSecret)
Lark-->>Bot: wss_url + client_config
Bot->>Lark: WS connect + initial ping
Lark-->>Bot: pong + ping_interval
loop WS 长连接心跳
Bot->>Lark: ping
Lark-->>Bot: pong/业务event
Bot->>Bot: 解析 PbFrame(method)
Bot->>Bot: ControlFrame(0)/DataFrame(1)
Bot->>Bot: ACK event (code=200)
opt 断连
Bot-->>Lark: 重连
end
end
User->>Lark: 发送 im.message.receive_v1(text/image/file/audio)
Lark-->>Bot: 事件 payload
Bot->>Parser: parse_event_payload_async(payload)
alt audio
Parser->>Trans: download_audio_resource + transcribe
Trans-->>Parser: 语音文本
else other
Parser-->>Bot: text/post/image/file -> ChannelMessage
end
Parser-->>Bot: ChannelMessage
Bot->>Sender: Channel::send(消息)
Sender->>Lark: POST /im/v1/messages?receive_id_type=chat_id
Lark-->>Sender: 200 + code=0
Sender-->>Bot: 发送成功
10. 实践要点:落地接入步骤
- 配置
config.toml[channels.lark]或[channels.feishu]app_id/app_secret,verification_token,receive_mode,allowed_users,mention_only
- 初始化
LarkChannel::from_config(...).with_transcription(trans)(如需要语音) - 启动
listen()(preferred) - 打开日志:请求/token/心跳/解析
- 回调:
- WebSocket 通过
listen_ws循环 - Webhook 通过
listen_httpaxum处理
- WebSocket 通过
- 测试
- 文本
- 帖子
- 图片/文件/音频
- 群组
@bot - token 过期深拷贝
- 断线重连