ZeroClaw 接入飞书通道全流程详解

5 阅读4分钟

1. 背景与目标

ZeroClaw 作为通用自动化代理框架,支持多渠道,本篇做深度技术解剖:从代码级角度看 lark.rs,实现如何接入飞书家族(Lark/Feishu),并覆盖文本、帖子、图片、文件、音频、心跳、token管理等关键逻辑。


2. 平台基本差异:Lark vs Feishu

代码核心是 LarkPlatform 枚举:

  • Lark -> base API: https://open.larksuite.com/open-apis
  • Feishu -> 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 :兼容旧 flag use_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_tokenexpire/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::WebSocketlisten_ws()
  • LarkReceiveMode::Webhooklisten_http()

5.2 WebSocket 长连接(推荐)

  • ensure_bot_open_id()/bot/v3/info 取得机器人 open_id,用于群 @ 判定;
  • get_ws_endpoint():POST /callback/ws/endpointwss_url + client_config
  • tokio_tungstenite::connect_async(wss_url)
  • 心跳
    • 默认 ping_interval=120
    • WS_HEARTBEAT_TIMEOUT=300
    • 每次 ping + 监听 pongs 或二进制事件刷新 last_recv
  • 协议帧:PbFramewig
    • 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_target
  • timestampcreate_time(ms) / 1000 或当前

6.2 文本和帖子处理

  • text
    • content JSON 解析后 .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"}}
  • 如果 401 = token 过期,则 invalidate_token(), 再刷新重试一次
  • response code 非0 记录警告但不崩溃

random_lark_ack_reaction(event, text) 依据内容/语言选池:

  • 对中文(日文)/英文/繁体区分
  • 可返回 OK/THUMBSUP/THANKS

8. 消息发送(output)与卡片分片

impl Channel for LarkChannelsend

  • 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. 实践要点:落地接入步骤

  1. 配置 config.toml
    • [channels.lark][channels.feishu]
    • app_id/app_secret, verification_token, receive_mode, allowed_users, mention_only
  2. 初始化 LarkChannel::from_config(...).with_transcription(trans)(如需要语音)
  3. 启动 listen()(preferred)
  4. 打开日志:请求/token/心跳/解析
  5. 回调:
    • WebSocket 通过 listen_ws 循环
    • Webhook 通过 listen_http axum 处理
  6. 测试
    • 文本
    • 帖子
    • 图片/文件/音频
    • 群组 @bot
    • token 过期深拷贝
    • 断线重连