新增: df-im-gateway 网关骨架

This commit is contained in:
lxy
2026-10-05 00:35:00 +08:00
parent 91773da602
commit e492f03191
37 changed files with 4211 additions and 12 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
$queries = @('feishu', 'lark', 'larksuite')
foreach ($q in $queries) {
Write-Output "===== query: $q ====="
$r = Invoke-RestMethod -Uri "https://crates.io/api/v1/crates?q=$q&per_page=20" -Headers @{ 'User-Agent' = 'devflow-research' }
$r.crates | ForEach-Object {
"{0,-22} v{1,-10} dl={2,-8} upd={3} repo={4}" -f $_.id, $_.max_version, $_.downloads, ($_.updated_at -split 'T')[0], $_.repository
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,168 @@
# DingTalk Stream SDK for Rust
[![Crates.io](https://img.shields.io/crates/v/dingtalk-stream.svg)](https://crates.io/crates/dingtalk-stream)
[![docs.rs](https://docs.rs/dingtalk-stream/badge.svg)](https://docs.rs/dingtalk-stream)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-%3E%3D1.85.0-orange.svg)](https://www.rust-lang.org)
钉钉 Stream 模式 SDK 的 Rust 实现,完整对齐 Python 版 [dingtalk-stream-sdk-python](https://github.com/open-dingtalk/dingtalk-stream-sdk-python)。
> 对齐的 Python SDK 版本: commit [`6381a7b`](https://github.com/open-dingtalk/dingtalk-stream-sdk-python/commit/6381a7bacb0353e1e0cd425688a02ff785fda44c)
## 功能特性
- 基于 WebSocket 的钉钉 Stream 协议连接(自动重连、60s Keepalive)
- 聊天机器人消息收发(文本、Markdown、卡片)
- 交互式卡片(Markdown / AI 流式 / 按钮 / 轮播 / RPA 插件)
- Graph API 支持
- 事件订阅
- OAuth2 Token 自动管理(带缓存与 5 分钟提前刷新)
- 统一 async/await(基于 tokio)
## 快速开始
将依赖添加到 `Cargo.toml`:
```toml
[dependencies]
dingtalk-stream = "0.1"
```
最小示例:
```rust
use async_trait::async_trait;
use dingtalk_stream::*;
struct MyHandler;
#[async_trait]
impl CallbackHandler for MyHandler {
async fn process(&self, message: &messages::frames::MessageBody) -> (u16, String) {
println!("Received: {}", message.data);
(AckMessage::STATUS_OK, "OK".to_owned())
}
}
fn main() {
let credential = Credential::new("your_client_id", "your_client_secret");
let handler = MyHandler;
let mut client = DingTalkStreamClient::builder(credential)
.register_callback_handler(ChatbotMessage::TOPIC, handler)
.build();
client.start_forever().unwrap();
}
```
更多示例见 [`examples/`](examples/) 目录。
## 项目结构
```
src/
├── lib.rs # 公共 API 导出
├── client.rs # DingTalkStreamClient + Builder
├── credential.rs # Credential(Debug 遮蔽 secret)
├── error.rs # 统一错误类型
├── handlers/ # Handler traits (Callback/Event/System/Chatbot/Graph)
├── messages/ # 消息类型 (Frames/Chatbot/CardCallback/Graph)
├── card/ # 卡片系统 (Replier/Instances/Templates)
└── transport/ # 传输层 (HTTP/Token/WebSocket)
```
## 文档
- [整体架构图](docs/architecture.md)
- [连接生命周期时序图](docs/connection-lifecycle.md)
- [卡片操作时序图](docs/card-operations.md)
## 端到端测试
1. 复制配置模板并填入凭证:
```bash
cp examples/secret.example.toml examples/secret.toml
# 编辑 examples/secret.toml,填入 client_id 和 client_secret
```
2. 运行测试机器人:
```bash
cargo run --example e2e_test
```
3. 带媒体下载测试:
```bash
cargo run --example e2e_test -- --download-dir /tmp/dingtalk_downloads
```
4. 通过钉钉向机器人发送消息测试:
- `ping` → 回复 pong
- `echo <text>` → 回声
- `card` → 发送 Markdown 卡片
- `info` → 显示消息详情
## 与 Python SDK 的设计差异
| Python | Rust | 说明 |
|---|---|---|
| sync + async 双版本 | 统一 async | 消除代码重复 |
| 类继承 | Trait + 组合 | Rust 惯用模式 |
| ThreadPoolExecutor | tokio::spawn | 更高效的异步调度 |
| dict 动态类型 | 强类型 struct + serde | 编译期类型安全 |
## Rust SDK 独有功能
以下功能为 Rust SDK 独有,Python SDK 中不存在。同步 Python SDK 时请勿删除(代码中均有 `Rust SDK exclusive` 标注)。
### 扩展消息类型
Rust SDK 支持解析钉钉机器人可接收的全部 6 种消息类型,Python SDK 仅支持 text / richText / picture:
| 消息类型 | 结构体 | 场景限制 |
|---------|--------|---------|
| audio 语音 | `AudioContent` | 仅单聊 |
| file 文件 | `FileContent` | 仅单聊 |
| video 视频 | `VideoContent` | 仅单聊 |
```rust
let msg = ChatbotMessage::from_value(&json)?;
if let Some(fc) = &msg.file_content {
println!("文件名: {:?}, 下载码: {:?}", fc.file_name, fc.download_code);
}
```
### 统一下载码提取
`get_all_download_codes()` 一次性获取消息中所有媒体的下载码,覆盖 picture / richText 图片 / audio / file / video:
```rust
for (media_type, download_code) in msg.get_all_download_codes() {
let url = replier.get_image_download_url(&download_code).await?;
let bytes = replier.download_bytes(&url).await?;
}
```
### 文件下载
| 方法 | 说明 |
|------|------|
| `download_bytes(url)` | 下载文件字节内容 |
| `download_bytes_with_limit(url, max_size)` | 带大小限制的流式下载(Content-Length 预检 + 累计检查) |
### 主动单聊消息
`send_oto_message()` 通过 OpenAPI 向指定用户发送单聊消息(对应 `POST /v1.0/robot/oToMessages/batchSend`):
```rust
replier.send_oto_message(user_id, "sampleText", r#"{"content":"hello"}"#).await?;
```
## 环境要求
- Rust ≥ 1.85.0(edition 2024)
- tokio 异步运行时
## License
MIT
@@ -0,0 +1,285 @@
# Feishu SDK Rust
[![Crates.io](https://img.shields.io/crates/v/feishu-sdk.svg)](https://crates.io/crates/feishu-sdk)
[![Documentation](https://docs.rs/feishu-sdk/badge.svg)](https://docs.rs/feishu-sdk)
[![License](https://img.shields.io/crates/l/feishu-sdk.svg)](https://github.com/your-repo/feishu-sdk-rust)
> Notice: This is a community-maintained crate and is not an official Feishu/Lark SDK.
Rust SDK for Feishu/Lark Open Platform API. Provides a complete, type-safe interface to interact with Feishu APIs.
## Features
- **Complete API Coverage**: 1448+ operations from Feishu Open Platform
- **Token Management**: Automatic app_access_token and tenant_access_token handling
- **Event Processing**: Event subscription and card callback handling
- **WebSocket Support**: Long connection for real-time messaging
- **HTTP Server**: Built-in Salvo server for webhook handling
- **Type Safety**: Generated operation constants and typed APIs
- **Async/Await**: Full async support with tokio runtime
- **Pluggable Components**: Customizable cache, logger, serializer, and HTTP client
- **Code Generation**: Operation wrappers + typed model stubs from endpoint catalog
## Installation
Add to your `Cargo.toml`:
```toml
[dependencies]
feishu-sdk = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
### Optional Features
```toml
[dependencies]
feishu-sdk = { version = "0.1", features = ["server", "websocket"] }
```
- `server`: Enable HTTP server for event handling (Salvo)
- `websocket`: Enable WebSocket client for long connections
## Quick Start
### Basic Usage
```rust
use feishu_sdk::core::{Config, FEISHU_BASE_URL};
use feishu_sdk::Client;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::builder("app_id", "app_secret")
.base_url(FEISHU_BASE_URL)
.build();
let client = Client::new(config)?;
// Get chat list
let resp = client
.operation("im.v1.chat.list")
.query_param("page_size", "20")
.send()
.await?;
println!("Status: {}", resp.status);
println!("Body: {}", String::from_utf8_lossy(&resp.body));
Ok(())
}
```
### Send Message
```rust
use feishu_sdk::core::Config;
use feishu_sdk::Client;
async fn send_message(client: &Client, chat_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let message = serde_json::json!({
"receive_id": chat_id,
"msg_type": "text",
"content": r#"{"text":"Hello!"}"#
});
let resp = client
.operation("im.v1.messages.create")
.path_param("receive_id_type", "chat_id")
.body_json(&message)?
.send()
.await?;
println!("Message sent: {:?}", resp.json_value()?);
Ok(())
}
```
### Event Handling Server
```rust
use feishu_sdk::core::{noop_logger, Config};
use feishu_sdk::event::{EventDispatcher, EventDispatcherConfig};
use feishu_sdk::server::FeishuServer;
#[tokio::main]
async fn main() {
let event_config = EventDispatcherConfig::new()
.verification_token("your_token");
let dispatcher = EventDispatcher::new(event_config, noop_logger());
// Register event handlers
dispatcher.on_event("im.message.receive_v1", |event| {
Box::pin(async move {
println!("Received message: {:?}", event);
Ok(None)
})
});
// Start server
let server = FeishuServer::new(dispatcher).port(8080);
server.run().await;
}
```
### WebSocket Client
```rust
use feishu_sdk::core::noop_logger;
use feishu_sdk::ws::{WebSocketClient, WsConfig};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = WsConfig::new("wss://ws.feishu.cn/...")
.heartbeat_interval(Duration::from_secs(30))
.reconnect_delay(Duration::from_secs(5));
let client = WebSocketClient::new(config, noop_logger()).await?;
// Check connection status
if client.is_connected().await {
println!("Connected to WebSocket");
}
Ok(())
}
```
## Configuration
### Basic Configuration
```rust
use feishu_sdk::core::{Config, LogLevel, FEISHU_BASE_URL, LARK_BASE_URL};
use std::time::Duration;
let config = Config::builder("app_id", "app_secret")
.base_url(FEISHU_BASE_URL) // or LARK_BASE_URL for international
.log_level(LogLevel::Debug) // Set log level
.request_timeout(Duration::from_secs(30)) // Request timeout
.enable_token_cache(true) // Enable token caching
.build();
```
### Marketplace App Configuration
```rust
let config = Config::builder("app_id", "app_secret")
.marketplace_app() // Enable marketplace mode
.app_ticket("your_app_ticket") // Required for marketplace apps
.build();
```
### Helpdesk Configuration
```rust
let config = Config::builder("app_id", "app_secret")
.helpdesk("helpdesk_id", "helpdesk_token")
.build();
```
### Client Options
```rust
use feishu_sdk::client::{ClientBuilder, with_http_client, with_log_level, with_request_timeout, with_serializer};
use feishu_sdk::core::{Config, LogLevel};
let client = ClientBuilder::new(Config::builder("app_id", "app_secret").build())
.with(with_log_level(LogLevel::Debug))
.with(with_request_timeout(Duration::from_secs(60)))
.with(with_http_client(custom_http_client))
.with(with_serializer(custom_serializer))
.build()?;
```
## API Reference
### Operation IDs
Operations use the format: `{service}.{version}.{resource}.{method}`
Examples:
- `im.v1.messages.create` - Send message
- `im.v1.chat.list` - List chats
- `contact.v3.user.get` - Get user info
- `auth.v3.tenant_access_token.internal.post` - Get tenant token
### Typed API Wrappers
```rust
use feishu_sdk::api::all_services;
// Get chat info
let builder = all_services::im::v1::chat::get(&client, "oc_xxx")
.query_param("user_id_type", "open_id");
// List users
let builder = all_services::contact::v3::user::list(&client)
.query_param("department_id", "0")
.query_param("page_size", "50");
```
## Examples
See the `examples/` directory:
- `get_token.rs` - Get tenant access token
- `send_message.rs` - Send a message
- `get_user.rs` - Get user information
- `im_v1_message.rs` - IM message operations
- `drive_v1_file.rs` - Drive file operations
- `calendar_v4.rs` - Calendar operations
- `docx_v1.rs` - Docx document operations
- `sheets_v3.rs` - Sheets operations
- `bitable_v1.rs` - Bitable operations
- `approval_v4.rs` - Approval operations
- `event_server.rs` - Event handling server
- `card_handler.rs` - Card callback server
- `websocket_client.rs` - WebSocket client
- `full_app.rs` - End-to-end app example (client + event + card)
Run examples:
```bash
# Set environment variables
export FEISHU_APP_ID=your_app_id
export FEISHU_APP_SECRET=your_app_secret
# Run example
cargo run --example get_token
# Run server example (requires server feature)
cargo run --features server --example event_server
# Run websocket example (requires websocket feature)
cargo run --features websocket --example websocket_client
```
## Documentation
- Usage guide: [`docs/USAGE.md`](docs/USAGE.md)
- FAQ: [`docs/FAQ.md`](docs/FAQ.md)
- Migration guide: [`docs/MIGRATION.md`](docs/MIGRATION.md)
- Best practices: [`docs/BEST_PRACTICES.md`](docs/BEST_PRACTICES.md)
## Error Handling
```rust
use feishu_sdk::core::Error;
match client.operation("im.v1.messages.create").send().await {
Ok(resp) => println!("Success: {:?}", resp.json_value()?),
Err(Error::Api(api_error)) => {
println!("API Error: code={}, msg={}", api_error.code, api_error.msg);
}
Err(Error::RequestTimeout(_)) => println!("Request timed out"),
Err(e) => println!("Error: {}", e),
}
```
## License
MIT License
@@ -0,0 +1,10 @@
$ErrorActionPreference = 'Stop'
$zip = "$env:TEMP\larksdk.zip"
$dir = "$env:TEMP\larksdk"
curl.exe -sL "https://goproxy.cn/github.com/larksuite/oapi-sdk-go/v3/@v/v3.12.0.zip" -o $zip
if (Test-Path $dir) { Remove-Item -Recurse -Force $dir }
Expand-Archive -Path $zip -DestinationPath $dir -Force
Write-Host "=== ws 相关文件 ==="
Get-ChildItem -Recurse $dir -Filter *.go | Where-Object { $_.FullName -match '\\ws\\|ws\\.go|longconn' } | ForEach-Object { $_.FullName.Replace($dir, '') }
Write-Host "`n=== 顶层目录 ==="
Get-ChildItem $dir | ForEach-Object { Get-ChildItem $_.FullName -Directory | Select-Object -ExpandProperty Name } | Select-Object -Unique
@@ -0,0 +1,3 @@
# 从 larkrs-tree.json 提取关键源文件路径(ws/card/event/channel)
$j = Get-Content 'E:\wk-lab\devflow\docs\02-架构设计\专项设计\.research\larkrs-tree.json' -Raw | ConvertFrom-Json
$j.tree | Where-Object { $_.path -match '(ws|card|event|channel)' -and $_.path -match '\.rs$' } | ForEach-Object { $_.path }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
=== maemual @ 2024-03-22T06:42:29Z ===
近期卡片新特性较多,人力比较紧张,还在推进中。
可以继续使用旧版的卡片回传交互使用。
=== Martin2877 @ 2024-06-21T02:13:35Z ===
卡片回调一直报这个错误 `json: cannot unmarshal string into Go struct field CardAction.Header of type []string]`
=== sbaby1997 @ 2024-07-31T13:45:20Z ===
这个问题非常影响使用呀!
=== maemual @ 2024-07-31T13:53:03Z ===
@sbaby1997 升级最新版 v3.3.0,然后卡片回传交互选“卡片回传交互(旧)”
=== Martin2877 @ 2024-08-02T01:54:18Z ===
All right. 鉴于官方还没修,我这里提出一个临时解决方法,大家可以参考:
在 `package larkcard` 文件中
1. 修改 `model.go` 中的 `CardAction`
```go
type CardAction struct {
*larkevent.EventReq
Schema string `json:"schema"`
Header struct {
EventID string `json:"event_id"`
Token string `json:"token"`
CreateTime string `json:"create_time"`
EventType string `json:"event_type"`
TenantKey string `json:"tenant_key"`
AppID string `json:"app_id"`
} `json:"header"`
Challenge string `json:"challenge"`
Token string `json:"token"`
Type string `json:"type"`
Event struct {
Operator struct {
TenantKey string `json:"tenant_key"`
UserID string `json:"user_id"`
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
} `json:"operator"`
Token string `json:"token"`
Action struct {
Tag string `json:"tag"`
Timezone string `json:"timezone"`
FormValue map[string]string `json:"form_value"`
Name string `json:"name"`
} `json:"action"`
Host string `json:"host"`
Context struct {
OpenMessageID string `json:"open_message_id"`
OpenChatID string `json:"open_chat_id"`
} `json:"context"`
} `json:"event"`
}
```
2. 修改`card.go`中的 `VerifySign`方法,使用 eventEncryptKey
```go
if h.eventEncryptKey == "" {
return nil
}
...
targetSign := Signature(requestTimestamp, requestNonce,h.eventEncryptKey,string(req.Body))
```
3. 修改`card.go`中的 `Signature` 方法,使用 sha256加密
```go
h := sha256.New()
```
=== crazykun @ 2024-09-14T02:43:54Z ===
新的改成event事件承载了
[v3代码示例](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/sample/event/event.go#L171)
```
.OnP2CardActionTrigger(func(ctx context.Context, event *callback.CardActionTriggerEvent) (*callback.CardActionTriggerReponse, error) {
fmt.Println("receive card action")
fmt.Println(larkcore.Prettify(event))
return nil, nil
})
```
=== kj648 @ 2025-01-23T11:52:59Z ===
对于卡片消息回调的 Value 应该定义成interface{} . 至少目前的卡片消息回调, 回调事件的值 支持 字符串,如果用字符串传进来 就直接cannot unmarshal string into Go struct XXXXXXX 。为了不出发这个error 只能强制使用 对象结构作为value
event *callback.CardActionTriggerEvent
```go
type CallBackAction struct {
Value map[string]interface{} `json:"value"`
Tag string `json:"tag"`
Option string `json:"option"`
Timezone string `json:"timezone"`
Name string `json:"name"`
FormValue map[string]interface{} `json:"form_value"`
InputValue string `json:"input_value"`
Options []string `json:"options"`
Checked bool `json:"checked"`
}
```
=== xiaozhi747 @ 2025-03-14T09:53:22Z ===
还没修复么?
=== maemual @ 2025-03-14T10:12:24Z ===
@xiaozhi747 早就支持了新版卡片回调,建议按照 README 文档使用。
=== eyotang @ 2025-11-24T04:13:19Z ===
> 卡片回调一直报这个错误 `json: cannot unmarshal string into Go struct field CardAction.Header of type []string]`
v3.4.3也有这个问题,请问怎么解决的?header是map[string]string,但是sdk里面定义的是map[string][]string
@@ -0,0 +1 @@
{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130","repository_url":"https://api.github.com/repos/larksuite/oapi-sdk-go","labels_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/labels{/name}","comments_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/comments","events_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/events","html_url":"https://github.com/larksuite/oapi-sdk-go/issues/130","id":2201794740,"node_id":"I_kwDOEiHOJs6DPLi0","number":130,"title":"使用新版卡片回调消息格式不匹配,反序列化错误","user":{"login":"lxitgto","id":11627541,"node_id":"MDQ6VXNlcjExNjI3NTQx","avatar_url":"https://avatars.githubusercontent.com/u/11627541?v=4","gravatar_id":"","url":"https://api.github.com/users/lxitgto","html_url":"https://github.com/lxitgto","followers_url":"https://api.github.com/users/lxitgto/followers","following_url":"https://api.github.com/users/lxitgto/following{/other_user}","gists_url":"https://api.github.com/users/lxitgto/gists{/gist_id}","starred_url":"https://api.github.com/users/lxitgto/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lxitgto/subscriptions","organizations_url":"https://api.github.com/users/lxitgto/orgs","repos_url":"https://api.github.com/users/lxitgto/repos","events_url":"https://api.github.com/users/lxitgto/events{/privacy}","received_events_url":"https://api.github.com/users/lxitgto/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"closed","locked":false,"assignees":[],"milestone":null,"comments":10,"created_at":"2024-03-22T06:30:32Z","updated_at":"2025-11-24T04:13:19Z","closed_at":"2025-03-14T10:12:28Z","assignee":null,"author_association":"NONE","issue_field_values":[],"type":null,"active_lock_reason":null,"sub_issues_summary":{"total":0,"completed":0,"percent_completed":0},"issue_dependencies_summary":{"blocked_by":0,"total_blocked_by":0,"blocking":0,"total_blocking":0},"body":"使用新版卡片回调消息格式不匹配,反序列化错误。\r\n\r\n`CardAction struct`格式为v1格式,请问什么时候支持新版消息格式?","closed_by":{"login":"maemual","id":2352843,"node_id":"MDQ6VXNlcjIzNTI4NDM=","avatar_url":"https://avatars.githubusercontent.com/u/2352843?v=4","gravatar_id":"","url":"https://api.github.com/users/maemual","html_url":"https://github.com/maemual","followers_url":"https://api.github.com/users/maemual/followers","following_url":"https://api.github.com/users/maemual/following{/other_user}","gists_url":"https://api.github.com/users/maemual/gists{/gist_id}","starred_url":"https://api.github.com/users/maemual/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/maemual/subscriptions","organizations_url":"https://api.github.com/users/maemual/orgs","repos_url":"https://api.github.com/users/maemual/repos","events_url":"https://api.github.com/users/maemual/events{/privacy}","received_events_url":"https://api.github.com/users/maemual/received_events","type":"User","user_view_type":"public","site_admin":false},"reactions":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/timeline","performed_via_github_app":null,"state_reason":"completed","pinned_comment":null}
@@ -0,0 +1 @@
{"total_count":1,"incomplete_results":false,"items":[{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130","repository_url":"https://api.github.com/repos/larksuite/oapi-sdk-go","labels_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/labels{/name}","comments_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/comments","events_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/events","html_url":"https://github.com/larksuite/oapi-sdk-go/issues/130","id":2201794740,"node_id":"I_kwDOEiHOJs6DPLi0","number":130,"title":"使用新版卡片回调消息格式不匹配,反序列化错误","user":{"login":"lxitgto","id":11627541,"node_id":"MDQ6VXNlcjExNjI3NTQx","avatar_url":"https://avatars.githubusercontent.com/u/11627541?v=4","gravatar_id":"","url":"https://api.github.com/users/lxitgto","html_url":"https://github.com/lxitgto","followers_url":"https://api.github.com/users/lxitgto/followers","following_url":"https://api.github.com/users/lxitgto/following{/other_user}","gists_url":"https://api.github.com/users/lxitgto/gists{/gist_id}","starred_url":"https://api.github.com/users/lxitgto/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lxitgto/subscriptions","organizations_url":"https://api.github.com/users/lxitgto/orgs","repos_url":"https://api.github.com/users/lxitgto/repos","events_url":"https://api.github.com/users/lxitgto/events{/privacy}","received_events_url":"https://api.github.com/users/lxitgto/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"closed","locked":false,"assignees":[],"milestone":null,"comments":10,"created_at":"2024-03-22T06:30:32Z","updated_at":"2025-11-24T04:13:19Z","closed_at":"2025-03-14T10:12:28Z","assignee":null,"author_association":"NONE","issue_field_values":[],"type":null,"active_lock_reason":null,"sub_issues_summary":{"total":0,"completed":0,"percent_completed":0},"issue_dependencies_summary":{"blocked_by":0,"total_blocked_by":0,"blocking":0,"total_blocking":0},"body":"使用新版卡片回调消息格式不匹配,反序列化错误。\r\n\r\n`CardAction struct`格式为v1格式,请问什么时候支持新版消息格式?","reactions":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/130/timeline","performed_via_github_app":null,"state_reason":"completed","pinned_comment":null,"score":1.0}],"search_type":"lexical"}
@@ -0,0 +1 @@
{"total_count":1,"incomplete_results":false,"items":[{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244","repository_url":"https://api.github.com/repos/larksuite/oapi-sdk-go","labels_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/labels{/name}","comments_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/comments","events_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/events","html_url":"https://github.com/larksuite/oapi-sdk-go/pull/244","id":5270680313,"node_id":"PR_kwDOEiHOJs8AAAABBPl61Q","number":244,"title":"fix(ws): make WebSocket client lifecycle cancellable","user":{"login":"SadMemories","id":44433408,"node_id":"MDQ6VXNlcjQ0NDMzNDA4","avatar_url":"https://avatars.githubusercontent.com/u/44433408?v=4","gravatar_id":"","url":"https://api.github.com/users/SadMemories","html_url":"https://github.com/SadMemories","followers_url":"https://api.github.com/users/SadMemories/followers","following_url":"https://api.github.com/users/SadMemories/following{/other_user}","gists_url":"https://api.github.com/users/SadMemories/gists{/gist_id}","starred_url":"https://api.github.com/users/SadMemories/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/SadMemories/subscriptions","organizations_url":"https://api.github.com/users/SadMemories/orgs","repos_url":"https://api.github.com/users/SadMemories/repos","events_url":"https://api.github.com/users/SadMemories/events{/privacy}","received_events_url":"https://api.github.com/users/SadMemories/received_events","type":"User","user_view_type":"public","site_admin":false},"labels":[],"state":"closed","locked":false,"assignees":[],"milestone":null,"comments":0,"created_at":"2026-08-27T18:07:43Z","updated_at":"2026-08-28T09:41:33Z","closed_at":"2026-08-28T09:41:33Z","assignee":null,"author_association":"COLLABORATOR","issue_field_values":[],"type":null,"active_lock_reason":null,"draft":false,"pull_request":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/pulls/244","html_url":"https://github.com/larksuite/oapi-sdk-go/pull/244","diff_url":"https://github.com/larksuite/oapi-sdk-go/pull/244.diff","patch_url":"https://github.com/larksuite/oapi-sdk-go/pull/244.patch","merged_at":"2026-08-28T09:41:33Z"},"body":"## Summary\n- make `Client.Start` stop deterministically for caller cancellation, explicit `Close`, and terminal connection errors\n- preserve public bootstrap error types and synchronous lifecycle callback delivery\n- detect half-open connections with read deadlines, and bound WebSocket writes so read or write failures enter the reconnect flow\n- remove the bootstrap dependency on the global HTTP client: add `WithHttpClient` and `WithWebSocketDialer`, with a bounded default bootstrap HTTP client\n- keep `Close` callback-safe and non-blocking; add `CloseAndWait(ctx)` for callers that must wait for connection teardown, `OnDisconnected`, and SDK workers\n- restore legacy lifecycle log wording and per-connection `conn_id` output\n\n## Fixed issues\n\nFixes #189\nFixes #201\nFixes #204\nFixes #224\n\n## Notes\n- `CloseAndWait(ctx)` must be called outside lifecycle callbacks; callbacks should call `Close()` to avoid waiting for the coordinator that is currently executing them.\n- Ping may start before `OnReady` / `OnReconnected` returns, consistent with the accepted behavior for this PR.\n\n## Validation\n- `go test -count=1 -race ./ws`\n- `go test ./...`\n- IM sample: Code 0\n- event and card mocks: HTTP 200\n- WebSocket sample: connected, ping success, receive pong","reactions":{"url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/larksuite/oapi-sdk-go/issues/244/timeline","performed_via_github_app":null,"state_reason":null,"score":1.0}],"search_type":"lexical"}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"message":"Not Found","documentation_url":"https://docs.github.com/rest/git/trees#get-a-tree","status":"404"}
@@ -0,0 +1,46 @@
use std::collections::HashMap;
use larksuite_oapi_sdk_rs::{CardAction, CardActionHandler, EventReq};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler =
CardActionHandler::new("verification_token", "", |action: CardAction| async move {
println!("card clicked by open_id={}", action.open_id);
println!("action value: {:?}", action.action.value);
Ok(serde_json::json!({
"toast": {
"type": "info",
"content": "Handled by Rust"
}
}))
})
.skip_sign_verify();
let payload = serde_json::json!({
"open_id": "ou_xxx",
"open_message_id": "om_xxx",
"open_chat_id": "oc_xxx",
"tenant_key": "tenant_xxx",
"token": "verification_token",
"action": {
"tag": "button",
"value": {
"action": "acknowledge",
"ticket_id": "INC-1001"
}
}
});
let req = EventReq {
headers: HashMap::new(),
body: serde_json::to_vec(&payload)?,
request_uri: "/webhook/card".to_string(),
};
let resp = handler.handle(req).await;
println!("response status: {}", resp.status_code);
println!("response body: {}", String::from_utf8_lossy(&resp.body));
Ok(())
}
@@ -0,0 +1,132 @@
# Card protocol alignment
Message cards are an embedded JSON protocol: IM and CardKit APIs transport a
card as opaque content, so REST route parity cannot establish card-schema
coverage. This guide defines the required reference and verification workflow
for every change to the SDK's card support.
## Source roles
Use each reference for the thing it can prove:
1. **Official Lark card documentation** is the wire-contract authority. It
defines Card JSON 1.0 and 2.0 roots, component tags, field names, value
domains, constraints, client-version requirements, and deprecations.
2. **The Lark CLI card references and fixtures** are an operational
cross-check. They supply representative valid payloads and callback/update
lifecycle details, including `card.action.trigger`.
3. **The official Go SDK** is an implementation cross-check and compatibility
reference. Its card model may deliberately represent a different layer or
expose a different surface from a particular documentation page; an apparent
gap is a discrepancy to investigate, not evidence that the Go SDK is
obsolete or incomplete.
4. **The existing Rust SDK** is a regression baseline only. An existing
serializer or a test that repeats its output is not evidence of protocol
correctness.
When sources disagree, record the discrepancy in the protocol inventory and
investigate its version, schema, and transport context. The official
documentation defines the wire contract, while current Go SDK and CLI behavior
are required implementation evidence; do not assume that either implementation
is stale, or silently preserve a conflicting spelling.
## Required workflow
Before implementing or accepting an embedded-protocol change:
1. Classify it. REST resources and typed events use the Go compatibility
workflow; message cards use this workflow. A change touching both must pass
both sets of checks.
2. Refresh the pinned Go SDK and Lark CLI references, and record the source
revision plus official-documentation URLs and access date in the protocol
inventory.
3. Update the version-specific inventory before implementation. Card JSON 1.0
and 2.0 have separate roots and must have separate component, field,
enumeration, validation, and client-version entries.
4. Add or update exact JSON fixtures for every changed wire shape. Fixtures
must cover the tag, required fields, optional fields, enum spelling, and at
least one invalid cross-field combination where the protocol defines one.
5. Cross-check each changed card component against the CLI and Go references.
Record an absence from either reference as an absence, not as permission to
omit a documented capability.
6. Run the aggregate reference-alignment check and the focused Rust tests. A
new card feature cannot merge with only a builder unit test that asserts the
implementation's own JSON.
## Callback and update coverage
For callback-bearing Card JSON 1.0 components, the inventory and fixtures must
also cover the corresponding `card.action.trigger` fields, callback value or
form-value shape, and the three-second acknowledgement/update response. The
SDK may expose outbound card construction and inbound event types independently,
but their protocol relationship must be tested together.
Card JSON 2.0 and CardKit are separate tracks. CardKit's typed Card JSON 2.0
document transport and ordered text-content streaming have their own manifest
and transport fixtures; they are not optional extensions of a Card JSON 1.0
fixture. CardKit settings, element mutation, and batch-action helpers remain
separate protocol surfaces with their own exact transport fixtures.
## Gate status
The Card JSON 1.0 inventory and fixture corpus extend the deterministic
`reference-alignment-check` command with card fixtures and validation. The
aggregate command receives pinned Go and Lark CLI checkout paths, verifies
their revisions, and checks recorded source artifacts before it runs the Rust
fixture tests. Card checks remain one part of this aggregate gate, alongside
REST, event, and future protocol alignment; they do not get a separate command
or CI job.
The Card JSON 2.0 inventory is separate from the 1.0 corpus. It records the
incompatible root, every documented handwritten component, the root and
cross-field constraints that the typed AST must enforce, and exact valid and
invalid wire fixtures. `partial` means that a component has a typed wire shape
but is not yet eligible to be marked complete: its optional fields, validation,
and exact fixtures must still be covered. The documented Card-Builder-only
recycling container is recorded as a known divergence because it has no
handwritten Card JSON tag.
Card Builder is not a Card JSON AST. The SDK does not replicate the visual
editor or represent builder-only components. It supports delivery of a published
template through `card::template::TemplateMessage<T>`. `T` is a caller-defined
`Serialize` variable object, validated before delivery; `TemplateMessage` uses
`JsonValue` by default as the dynamic JSON escape hatch. The template-delivery
manifest keeps that envelope, its IM transport, and the builder-only
recycling-container example in the same aggregate alignment check.
`implemented` is reserved for a component whose field-level inventory, valid
fixture, invalid constraint fixture where applicable, and direct serde
round-trip coverage are all present. A complete component group does not imply
that the incompatible Card JSON 2.0 root or unrelated interaction and update
surfaces are complete.
## CardKit document, mutation, and instance coverage
`card::cardkit::CardDocument` accepts only a locally validated Card JSON 2.0
document and emits CardKit's escaped `type: "card_json"` payload. The typed
`LarkClient::cardkit_cards()` helper covers entity creation and creates an
ordered update session for full replacement and full-content updates on one
text or Markdown element.
Creation returns a CardKit entity ID, not an IM message. Send it through an IM
`interactive` request with `card::cardkit::CardEntityMessage`, which emits the
separate `{"type":"card","data":{"card_id":"..."}}` content envelope.
Do not reuse the CardKit `card_json` REST envelope for this step.
Each CardKit update carries a caller-provided idempotency key and a positive
sequence. One `CardKitUpdateSession` owns the sequence across document and
content, settings, element, and batch updates, advancing it only after a
successful request; a failed update can therefore be retried with the same
sequence and key. Content updates intentionally send the complete current
value: Lark renders a prefix extension as a typewriter update and otherwise
replaces the element content.
The typed mutation surface retains Card JSON 2.0 settings and full elements,
validates partial-element and insertion constraints, and encodes the nested
objects required by CardKit batch actions exactly once. It also covers template
instance creation and variable updates (a name-keyed object with open JSON
values). The pinned Go CardKit source does not expose Card Instance resources,
so those endpoints are normatively tracked against the official documentation
rather than unrelated Go template models. The raw generated resource methods
remain available for any future CardKit endpoint not yet given a typed
composition layer.
@@ -0,0 +1,105 @@
# Cards
The SDK keeps Card JSON 1.0, Card JSON 2.0, CardKit, and published Card Builder
templates as separate protocols. Choose the smallest surface that matches the
message lifecycle you need.
## Send a Card JSON document
Build a versioned Card JSON value, then create a `CardDocument` before passing
it to IM or channel message helpers. `CardDocument::new` validates the document;
outbound interactive-card helpers deliberately reject arbitrary serializable
values.
[`examples/card_send.rs`](../examples/card_send.rs) sends a validated Card JSON
1.0 document through the IM create-message API:
```sh
APP_ID=... APP_SECRET=... CHAT_ID=oc_... cargo run --example card_send
```
Use `card::v1` for Card JSON 1.0. Use `card::v2` for Card JSON 2.0; its root,
shared-card requirements, and element-identifier rules are incompatible with
1.0.
## Author Card JSON 2.0 layouts safely
`CardDocument::new` remains the final strict protocol gate. For dynamic
authoring, `CardDocument::new_with_diagnostic` preserves the original
`ValidationError` and also returns a stable code, a JSON Pointer, the violated
constraint, and relevant legal values. Do not parse its display string.
`ColumnSet` has constructors for the three legal width branches. They emit the
wire shape required by Card JSON 2.0 and leave document validation enabled:
```rust
use larksuite_oapi_sdk_rs::card::v2::{
AutoColumn, Body, Card, CardDocument, ColumnSet, ColumnWeight, Config,
Element, FixedColumn, FixedColumnWidth, Markdown, WeightedColumn,
};
let automatic = Element::ColumnSet(ColumnSet::automatic([
AutoColumn::new().element(Element::Markdown(Markdown::new("auto"))),
]));
let fixed = Element::ColumnSet(ColumnSet::fixed([
FixedColumn::new(FixedColumnWidth::pixels(160)?)
.element(Element::Markdown(Markdown::new("fixed"))),
]));
let weighted = Element::ColumnSet(ColumnSet::weighted([
WeightedColumn::new(ColumnWeight::new(3)?)
.element(Element::Markdown(Markdown::new("weighted"))),
]));
let card = Card::new()
.config(Config::new().update_multi())
.body(Body::new().element(automatic).element(fixed).element(weighted));
let _document = CardDocument::new(card)?;
# Ok::<(), larksuite_oapi_sdk_rs::card::v2::ValidationError>(())
```
The `Padding`, `Margin`, `Spacing::pixels`, `FixedColumnWidth`, and
`ColumnWeight` constructors encode the documented finite pixel/range grammar.
Existing string-oriented builders remain supported for compatibility and are
still validated by `CardDocument`.
## Stream a CardKit document
CardKit accepts a validated Card JSON 2.0 `card::cardkit::CardDocument`.
`LarkClient::cardkit_cards()` creates the entity; send that entity once with a
`card::cardkit::CardEntityMessage` through IM before beginning ordered updates.
Keep one update session for full-document, settings, element, and content
updates; each successful mutation advances its sequence. CardKit entities have
the documented 14-day lifetime and may be sent only once.
[`examples/cardkit_stream.rs`](../examples/cardkit_stream.rs) creates a
streaming CardKit card and replaces a Markdown element's complete content:
```sh
APP_ID=... APP_SECRET=... CHAT_ID=oc_... cargo run --example cardkit_stream
```
## Send a published Card Builder template
Card Builder's editor and builder-only components are not a Card JSON AST.
Send a published template with `TemplateMessage<T>`, where `T: Serialize` is
your application's template-variable object. The SDK requires that it serialize
to a JSON object; `TemplateMessage<JsonValue>` remains available for dynamic
bindings.
[`examples/card_template_send.rs`](../examples/card_template_send.rs) sends a
typed published template:
```sh
APP_ID=... APP_SECRET=... CHAT_ID=oc_... CARD_TEMPLATE_ID=... \
cargo run --example card_template_send
```
## Handle callbacks
Card callbacks are inbound events. Use
[`examples/card_action_handler.rs`](../examples/card_action_handler.rs) for a
typed `CardActionHandler` that returns a callback response. Application-level
authorization, routing, deduplication, and state management remain the
application's responsibility.
@@ -0,0 +1,30 @@
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy};
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app_id = std::env::var("APP_ID").expect("APP_ID env var required");
let app_secret = std::env::var("APP_SECRET").expect("APP_SECRET env var required");
let verification_token = std::env::var("VERIFICATION_TOKEN").unwrap_or_default();
let encrypt_key = std::env::var("ENCRYPT_KEY").unwrap_or_default();
let client = LarkClient::builder(app_id, app_secret).build()?;
let dispatcher = EventDispatcher::new(verification_token, encrypt_key);
Channel::builder(&client, dispatcher)
.policy(ChannelPolicy::default().require_mention(false))
.on_message(|message| async move {
println!("message_id: {}", message.message_id);
println!("raw content: {}", message.content);
println!("normalized text: {}", message.text.unwrap_or_default());
for resource in message.resources {
println!("resource: {} {}", resource.resource_type, resource.file_key);
}
Ok::<(), LarkError>(())
})
.build()
.start()
.await?;
Ok(())
}
@@ -0,0 +1,158 @@
# Events, WebSockets, and channels
Webhook handlers use `EventDispatcher` for typed event callbacks, signature
verification, and encrypted payload processing. The optional `ws` feature adds
long connections for event delivery; see
[`examples/ws_client.rs`](../examples/ws_client.rs).
Trusted user channels match the upstream Go SDK behavior. Configure the channel
before obtaining its control handle, start the client, then bind a user token
after the connection becomes ready. See
[`examples/ws_client.rs`](../examples/ws_client.rs) for the connection lifecycle
and [`examples/channel_send.rs`](../examples/channel_send.rs) for higher-level
messages.
```rust,no_run
use std::collections::HashMap;
use std::time::Duration;
# use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient};
# #[tokio::main]
# async fn main() -> Result<(), Box<dyn std::error::Error>> {
# let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
# let dispatcher = EventDispatcher::new("VERIFICATION_TOKEN", "ENCRYPT_KEY");
let ws_client = client
.ws_client(dispatcher)
.channel_tag("trusted_channel")
.websocket_query_params(HashMap::from([("env".into(), "staging".into())]));
let control = ws_client.control();
let task = tokio::spawn(ws_client.start());
while control.connection_id().is_none() {
tokio::time::sleep(Duration::from_millis(50)).await;
}
control.attach_user("USER_ACCESS_TOKEN").await?;
// Later, before dropping the user session:
// control.detach_user("USER_ACCESS_TOKEN").await?;
control.close_and_wait().await;
task.await??;
# Ok(())
# }
```
`WsClientControl::close` requests shutdown without waiting, which is safe from
lifecycle callbacks. `close_and_wait` is for external teardown and waits until
the running client has stopped. The client honors a finite reconnect count from
the gateway bootstrap configuration, treats an inbound-silent connection as
failed after two ping intervals plus five seconds, and reconnects after either
that liveness timeout or a timed-out connection write. It bounds each
connection write to ten seconds by default and accepts `write_timeout` for a
different bound. Event callbacks run independently of the receive loop, so a
slow callback does not mask a silent gateway. Use
`websocket_connector` when a custom proxy, TLS, or test dial transport is
needed; it affects only the WebSocket gateway dial, not the bootstrap HTTP
client.
## Calendar share-token joins
Calendar invitations provide an opaque share token through their link, QR
code, or RSVP card. Join with that token; the endpoint deliberately has no
event-ID alternative.
```rust,no_run
use larksuite_oapi_sdk_rs::service::calendar::v4::JoinCalendarEventReqBody;
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
# async fn example() -> Result<(), larksuite_oapi_sdk_rs::LarkError> {
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
client
.calendar()
.calendar
.join_event(
&JoinCalendarEventReqBody::new("CALENDAR_SHARE_TOKEN"),
&RequestOption::default(),
)
.await?;
# Ok(())
# }
```
Use the optional `axum` feature for Axum HTTP adapters. The optional `messaging`
feature provides outbound send, reply, update, upload, and download operations
without WebSocket dependencies. The optional `channel` feature adds inbound
message normalization and runtime policy controls and enables both `messaging`
and `ws`.
## Replies
`ChannelMessaging::send` preserves convenience behavior: a reply whose target has
disappeared may retry as a top-level send. For workflows that must never escape
a thread, use `ChannelMessaging::reply` or `ChannelMessaging::reply_in_thread`.
Both methods only call the reply endpoint and return its error unchanged. Set
`uuid` to opt into Lark's one-hour idempotency window; automatic split messages
derive distinct UUIDs, so the supplied UUID must leave room for a `-N` suffix.
```rust,no_run
use larksuite_oapi_sdk_rs::channel::SendInput;
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
let messaging = client.channel_messaging();
messaging
.reply_in_thread(
"om_parent_message",
&SendInput {
markdown: Some("A reply that stays in the topic".into()),
uuid: Some("order-42-status".into()),
..Default::default()
},
&RequestOption::default(),
)
.await?;
# Ok(())
# }
```
## Message updates
Use `ChannelMessaging::edit_text` to update a text message. Use
`ChannelMessaging::edit_card` only for validated interactive-card documents; it
uses the separate card patch operation. See
[`examples/card_send.rs`](../examples/card_send.rs) for Card JSON delivery and
[`examples/card_action_handler.rs`](../examples/card_action_handler.rs) for
interactive callback responses.
```rust,no_run
use larksuite_oapi_sdk_rs::card::v1::{Card, CardDocument, Div, Element, Header, Text};
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
let messaging = client.channel_messaging();
let card = CardDocument::new(
Card::new()
.header(Header::new("Done"))
.element(Element::Div(Div::new(Text::lark_md("Complete")))),
)?;
messaging
.edit_text("om_text_message", "Updated status", &RequestOption::default())
.await?;
messaging
.edit_card(
"om_card_message",
&card,
&RequestOption::default(),
)
.await?;
# Ok(())
# }
```
## Cards
Use `card::v1` or `card::v2` to build versioned Card JSON, and validate it with
their `CardDocument` types before sending. `CardActionHandler` handles callback
payloads. See the [Cards guide](cards.md) for CardKit and published Card Builder
templates.
@@ -0,0 +1,219 @@
# larksuite-oapi-sdk-rs
[![Crates.io](https://img.shields.io/crates/v/larksuite-oapi-sdk-rs)](https://crates.io/crates/larksuite-oapi-sdk-rs)
[![Docs](https://docs.rs/larksuite-oapi-sdk-rs/badge.svg)](https://docs.rs/larksuite-oapi-sdk-rs)
[![CI](https://github.com/adamcavendish/larksuite-oapi-sdk-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/adamcavendish/larksuite-oapi-sdk-rs/actions/workflows/ci.yml)
[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
Non-official, Tokio-native Rust SDK for Lark / Feishu (飞书) Open Platform
APIs, bots, events, WebSockets, and interactive cards. It follows the official
[Go SDK](https://github.com/larksuite/oapi-sdk-go) closely while providing
idiomatic Rust configuration, typed REST resources, event handling, and optional
WebSocket, Axum, and channel integration.
## Build with Lark / Feishu
- **Build a bot:** receive typed message events and reply through the IM API.
- **Receive events:** use WebSocket long connections without a public callback
URL, or verify and dispatch HTTP webhooks.
- **Automate Open Platform workflows:** use typed REST resources for messages,
calendars, documents, Base, approvals, and more.
New to the platform? Start with the
[build a Lark / Feishu bot tutorial](docs/tutorial-build-a-feishu-bot.md). It
creates a bot that replies to received messages over a WebSocket long
connection.
## Highlights
- Tenant, user, app, and marketplace token acquisition with configurable
caching and retry behavior.
- Typed REST resources with query inputs, upload/download helpers, and lazy
iterators for supported paginated endpoints.
- Raw requests and a Go-compatibility bridge for endpoints not yet promoted to
dedicated Rust resources.
- Webhook event dispatch with signature verification and encrypted payload
decryption.
- Optional WebSocket long connections, Axum handlers, and higher-level channel
message helpers.
## Install
```toml
[dependencies]
larksuite-oapi-sdk-rs = "0.3.12"
```
The minimum supported Rust version is 1.95.0.
## Quick Start
Build a client once, then call a typed resource. The SDK handles tenant-token
acquisition and request authentication for this endpoint.
```rust,no_run
use larksuite_oapi_sdk_rs::service::im::v1::CreateMessageReqBody;
use larksuite_oapi_sdk_rs::{LarkClient, RequestOption};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = LarkClient::builder("APP_ID", "APP_SECRET").build()?;
let body = CreateMessageReqBody {
receive_id: Some("CHAT_ID".to_string()),
msg_type: Some("text".to_string()),
content: Some(r#"{"text":"Hello from Rust"}"#.to_string()),
uuid: None,
};
let response = client
.im()
.message
.create("chat_id", &body, &RequestOption::default())
.await?;
println!("success: {}", response.success());
Ok(())
}
```
Use `LarkClient::builder` to set a base URL, timeout, retry limit, token cache,
custom headers, marketplace mode, helpdesk credentials, or a JWT client
assertion provider. See [`examples/client_config.rs`](examples/client_config.rs)
for the complete setup.
## Common Flows
| Need | Start here |
| --- | --- |
| Build a first Lark / Feishu bot | [Tutorial: build a Lark / Feishu bot](docs/tutorial-build-a-feishu-bot.md) |
| Call a typed REST endpoint | [`examples/send_message.rs`](examples/send_message.rs) or [generated examples](examples/README.md) |
| Call an uncovered endpoint | [`examples/raw_api.rs`](examples/raw_api.rs) |
| Control VC meeting bots | [VC meeting bots](docs/vc-bots.md) |
| Export Spark app source | [Spark app export](docs/spark-app-export.md) |
| Preview or apply Spark database schema changes | [Spark database migration](docs/spark-db-migration.md) |
| Read or modify Mail conversations | [Mail user-mailbox threads](docs/mail-v1-threads.md) |
| Resolve Drive object metadata by token | [Drive token lookup](docs/drive-token-lookup.md) |
| Manage Unified KMS autonomous keys | [Unified KMS](docs/unified-kms.md) |
| Discover or manage BaseApps | [`examples/base_v3_app_read.rs`](examples/base_v3_app_read.rs) and [Base v3 application mode](docs/base-v3-apps.md) |
| Manage Base dashboards or dashboard blocks | [Base v3 dashboards](docs/base-v3-dashboards.md) |
| Read or update Base dashboard or form sharing | [Base v3 sharing](docs/base-v3-sharing.md) |
| Manage Base v3 forms and questions | [Base v3 forms](docs/base-v3-forms.md) |
| Manage Base workflows or visible fields | [Base workflows](docs/base-v3-workflows.md) and [Base visible fields](docs/base-visible-fields.md) |
| Configure a Base field extension or share records | [Base v3 field extensions and record share links](docs/base-v3-field-extensions.md) |
| Read or update modern Docx content | [`examples/docs_ai_fetch.rs`](examples/docs_ai_fetch.rs) and [Docs AI document content](docs/docs-ai.md) |
| Read, edit, or render Slides AI presentations | [`examples/slides_ai_read.rs`](examples/slides_ai_read.rs), [`examples/slides_ai_render.rs`](examples/slides_ai_render.rs), and [Slides AI presentation content](docs/slides-ai.md) |
| Exchange or refresh OAuth tokens | [`examples/authen_oauth.rs`](examples/authen_oauth.rs) and [OAuth SSO guide](docs/oauth-sso.md) |
| Join a shared calendar event | [Events, WebSockets, and channels](docs/events-and-channels.md#calendar-share-token-joins) |
| Receive webhook events | [`examples/event_handler.rs`](examples/event_handler.rs) |
| Send a validated interactive card | [`examples/card_send.rs`](examples/card_send.rs) and [Cards guide](docs/cards.md) |
| Stream a CardKit document | [`examples/cardkit_stream.rs`](examples/cardkit_stream.rs) and [Cards guide](docs/cards.md) |
| Send a published Card Builder template | [`examples/card_template_send.rs`](examples/card_template_send.rs) and [Cards guide](docs/cards.md) |
| Handle interactive card callbacks | [`examples/card_action_handler.rs`](examples/card_action_handler.rs) |
| Run a WebSocket event client | [`examples/ws_client.rs`](examples/ws_client.rs) with `ws` enabled |
| Send channel messages | [`examples/channel_send.rs`](examples/channel_send.rs) with `messaging` enabled |
| Normalize channel messages | [`examples/channel_normalize.rs`](examples/channel_normalize.rs) with `channel` enabled |
| Search bots or work with OKRs | [`examples/bot_search.rs`](examples/bot_search.rs) and [`examples/okr_v2.rs`](examples/okr_v2.rs) |
## Guides
- [Tutorial: build a Lark / Feishu bot](docs/tutorial-build-a-feishu-bot.md):
create a WebSocket-connected bot that receives and replies to messages.
- [Go service and event compatibility](docs/go-compatibility.md): raw
requests, the compatibility bridge, and deterministic Go-to-Rust checks.
- [Cards](docs/cards.md): versioned Card JSON, CardKit streaming, published
Card Builder templates, and callbacks.
- [Card protocol alignment](docs/card-protocol.md): source hierarchy and
verification workflow for Card JSON and card callbacks.
- [Events, WebSockets, and channels](docs/events-and-channels.md): webhook
dispatch, long connections, trusted user channels, replies, and updates.
- [OAuth SSO](docs/oauth-sso.md): Feishu and Lark browser login, PKCE, token
refresh, and application-owned session handling.
- [VC meeting bots](docs/vc-bots.md): event queries and outbound meeting join,
leave, message, countdown, and active-meeting operations.
- [Spark app export](docs/spark-app-export.md): streaming source archives,
response validation, and app-type-dependent source snapshots.
- [Spark database migration](docs/spark-db-migration.md): explicit preview,
irreversible apply, and caller-owned asynchronous status polling.
- [Mail user-mailbox threads](docs/mail-v1-threads.md): conversation reads
and batch label, folder, and Trash operations.
- [Drive token lookup](docs/drive-token-lookup.md): underlying object metadata
resolution without implicit download or export.
- [Unified KMS](docs/unified-kms.md): tenant-scoped autonomous-key lifecycle,
deletion plans, recovery, and import material.
- [Base v3 application mode](docs/base-v3-apps.md): user-token workspace,
BaseApp, page, and block operations.
- [Base v3 dashboards](docs/base-v3-dashboards.md): dashboard and dashboard
block lifecycle, chart data, and layout operations.
- [Base v3 sharing](docs/base-v3-sharing.md): dashboard and form sharing
reads and partial updates with user or tenant credentials.
- [Base v3 forms](docs/base-v3-forms.md): form lifecycle and form-question
operations, including explicit destructive-delete semantics.
- [Base v3 workflows](docs/base-v3-workflows.md): workflow lifecycle with
caller-owned definition bodies.
- [Base visible fields](docs/base-visible-fields.md): full-list view and form
field visibility and ordering.
- [Base v3 field extensions and record share links](docs/base-v3-field-extensions.md):
typed extension configuration, cell updates, and batch record links.
- [Docs AI document content](docs/docs-ai.md): modern Docx content fetches,
updates, asynchronous-task reads, and version-history operations.
- [Slides AI presentation content](docs/slides-ai.md): XML presentation and
slide operations with version-history support.
- [Documentation index](docs/README.md): detailed guides and runnable examples.
## Migration from 0.2
Version 0.3 makes API-breaking corrections so the public Rust surface matches
the upstream Lark contracts more closely.
- Replace `Client` with `LarkClient` and `ClientBuilder` with
`LarkClientBuilder`.
- Replace `client.wiki()` with `client.wiki_v1()` or `client.wiki_v2()`.
- Replace `do_req`, `do_req_typed`, and verb helpers with `raw_request*` and an
`ApiReq` when a dedicated typed resource is unavailable.
- Treat Hire v1 `IdNameObject.name` as `Option<I18n>` with `zh_cn` and `en_us`
fields.
- Handle JSON construction failures as `LarkError::Json`; legacy card element
helpers are fallible for the same reason.
- Use `JsonValue` for intentionally dynamic API data instead of public
`serde_json::Value` model fields.
Typed REST responses and incoming event payloads are `#[non_exhaustive]`.
Deserialize them and read their public fields as before, but do not use struct
literals to create server-owned values.
## Cargo Features
| Feature | Description |
| --- | --- |
| `ws` | WebSocket long-connection event client with protobuf framing. |
| `messaging` | High-level outbound messaging and resource operations without WebSocket dependencies. |
| `channel` | Inbound normalization and runtime policy helpers; enables `messaging` and `ws`. |
| `axum` | Axum adapters for webhook and card-action handlers. |
Cargo unifies enabled features across the dependency graph. Enabling `channel`
therefore enables both `messaging` and `ws`; enabling `messaging` and `ws`
separately does not enable the `channel` runtime API.
```toml
[dependencies]
larksuite-oapi-sdk-rs = { version = "0.3.12", features = ["ws", "axum"] }
```
## API Coverage
The SDK provides dedicated resources across IM, Calendar, Drive, Docs, Sheets,
Bitable, Contacts, Approval, VC, Wiki, Hire, and the broader Lark/Feishu
OpenAPI surface. Generated smoke tests verify request paths, query inputs,
bodies, uploads, downloads, and response handling. See the
[changelog](CHANGELOG.md) for release detail and the
[examples index](examples/README.md) for runnable calls.
## Requirements
- Rust 1.95.0 or newer
- Rust edition 2024
## License
Licensed under [MIT](LICENSE).
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
use larksuite_oapi_sdk_rs::event::EventDispatcher;
use larksuite_oapi_sdk_rs::events::im::P2MessageReceiveV1;
use larksuite_oapi_sdk_rs::service::im::v1::CreateMessageReqBody;
use larksuite_oapi_sdk_rs::{LarkClient, LarkError, RequestOption};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let app_id = std::env::var("APP_ID").expect("APP_ID env var required");
let app_secret = std::env::var("APP_SECRET").expect("APP_SECRET env var required");
let client = LarkClient::builder(&app_id, &app_secret).build()?;
// Keep the SDK event handler short: the WebSocket client can acknowledge
// delivery as soon as the event is queued. A worker performs outbound I/O.
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<P2MessageReceiveV1>(128);
let worker_client = client.clone();
let worker = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if event.message.message_type != "text" {
continue;
}
let message_id = event.message.message_id;
let chat_id = event.message.chat_id;
println!(
"[worker] message: id={message_id} content={}",
event.message.content
);
let body = CreateMessageReqBody {
receive_id: Some(chat_id),
msg_type: Some("text".to_string()),
content: Some(r#"{"text":"Hello from the queued worker!"}"#.to_string()),
// Keep retries for the same inbound message idempotent.
uuid: Some(format!("reply-{message_id}")),
};
match worker_client
.im()
.message
.create("chat_id", &body, &RequestOption::default())
.await
{
Ok(response) if response.success() => {
println!("[worker] reply sent");
}
Ok(response) => {
eprintln!("[worker] reply rejected: {:?}", response.code_error);
}
Err(error) => {
eprintln!("[worker] reply failed: {error}");
}
}
}
});
let dispatcher = EventDispatcher::new("", "").on_p2_im_message_receive_v1(
move |event: P2MessageReceiveV1| {
let event_tx = event_tx.clone();
async move {
event_tx.try_send(event).map_err(|error| {
LarkError::Event(format!("event queue is full or closed: {error}"))
})
}
},
);
let mut ws_client = client
.ws_client(dispatcher)
.on_ready(|| println!("websocket connected"))
.on_disconnected(|| eprintln!("websocket disconnected"))
.on_error(|error| eprintln!("websocket error: {error}"));
if let Ok(channel_tag) = std::env::var("WS_CHANNEL_TAG") {
ws_client = ws_client.channel_tag(channel_tag);
}
let user_access_token = std::env::var("USER_ACCESS_TOKEN").ok();
let control = user_access_token.as_ref().map(|_| ws_client.control());
println!("starting ws client (Ctrl+C to stop)...");
let ws_task = tokio::spawn(ws_client.start());
if let (Some(control), Some(user_access_token)) = (control, user_access_token) {
while control.connection_id().is_none() {
tokio::time::sleep(Duration::from_millis(100)).await;
}
control.attach_user(&user_access_token).await?;
println!("trusted user channel attached");
}
ws_task.await??;
worker.await?;
Ok(())
}
@@ -0,0 +1,8 @@
$c = Get-Content 'E:\wk-lab\devflow\docs\02-架构设计\专项设计\.research\issue-130-comments.json' -Raw | ConvertFrom-Json
$out = foreach ($x in $c) {
"=== $($x.user.login) @ $($x.created_at) ==="
$x.body
""
}
$out | Set-Content 'E:\wk-lab\devflow\docs\02-架构设计\专项设计\.research\issue-130-comments.txt' -Encoding UTF8
Write-Output "written"
@@ -0,0 +1,546 @@
# wecom-aibot-rust-sdk (Rust)
企业微信智能机器人 Rust SDK —— 基于 WebSocket 长连接通道,提供消息收发、流式回复、模板卡片、事件回调、文件下载解密等核心能力。
> 本项目是 Python 版本 [@wecom/aibot-python-sdk](https://pypi.org/project/wecom-aibot-python-sdk/) 的 Rust 改写版本。
## ✨ 特性
- 🔗 **WebSocket 长连接** — 基于 `wss://openws.work.weixin.qq.com` 内置默认地址,开箱即用
- 🔐 **自动认证** — 连接建立后自动发送认证帧(bot_id + secret)
- 💓 **心跳保活** — 自动维护心跳,连续未收到 ack 时自动判定连接异常
- 🔄 **断线重连** — 指数退避重连策略(1s → 2s → 4s → ... → 30s 上限),支持自定义最大重连次数
- 📨 **消息分发** — 自动解析消息类型并触发对应事件(text / image / mixed / voice / file)
- 🌊 **流式回复** — 内置流式回复方法,支持 Markdown 和图文混排
- 🃏 **模板卡片** — 支持回复模板卡片消息、流式 + 卡片组合回复、更新卡片
- 📤 **主动推送** — 支持向指定会话主动发送 Markdown 或模板卡片消息,无需依赖回调帧
- 📡 **事件回调** — 支持进入会话、模板卡片按钮点击、用户反馈等事件
- ⏩ **串行回复队列** — 同一 req_id 的回复消息串行发送,自动等待回执
- 🔑 **文件下载解密** — 内置 AES-256-CBC 文件解密,每个图片/文件消息自带独立的 aeskey
- 🪵 **可插拔日志** — 支持自定义 Logger,内置带时间戳的 DefaultLogger
- 🦀 **tokio 原生** — 基于 Rust tokio 异步运行时,支持 async/await
## 📦 安装
在 `Cargo.toml` 中添加依赖:
```toml
[dependencies]
wecom-aibot-rust-sdk = "1.0.2"
tokio = { version = "1.35", features = ["full"] }
```
**依赖:**
- Rust >= 1.70
- tokio >= 1.35
- tokio-tungstenite >= 0.21
- tokio-stream >= 0.1
- reqwest >= 0.11
- serde >= 1.0
- serde_json >= 1.0
- aes >= 0.8
- cbc >= 0.1
- base64 >= 0.21
- cpufeatures >= 0.2
- thiserror >= 1.0
- futures >= 0.3
- parking_lot >= 0.12
- rand >= 0.9
- hex >= 0.4
- chrono >= 0.4
- regex >= 1.10
- urlencoding >= 2.1
## ⚙️ 配置
```bash
# 复制示例配置文件
cp .env.example .env
# 编辑 .env 文件,填入真实配置
WECHAT_BOT_ID=your-bot-id
WECHAT_BOT_SECRET=your-bot-secret
# (可选)配置下载文件存放目录,不设置则使用系统 temp 目录
WECHAT_BOT_DOWNLOAD_DIR=/path/to/download/dir
```
## 🚀 快速开始
```rust
use dotenv::dotenv;
use std::env;
use tokio::time::{self, Duration};
use wecom_aibot_rust_sdk::{generate_req_id, WSClient, WSClientOptions};
#[tokio::main]
async fn main() {
// 加载 .env 文件
dotenv().ok();
// 1. 创建客户端实例
let client = WSClient::new(WSClientOptions::new(
env::var("WECHAT_BOT_ID").unwrap(),
env::var("WECHAT_BOT_SECRET").unwrap(),
));
// 2. 监听认证成功
client.on_authenticated(|| {
println!("🔐 认证成功");
});
// 3. 监听文本消息并进行流式回复
let client_text = client.clone();
client.on_message_text(move |frame| {
let client_text = client_text.clone();
let frame = frame.clone();
tokio::spawn(async move {
let content = frame
.body
.as_ref()
.and_then(|v| v.as_object())
.and_then(|b| b.get("text"))
.and_then(|v| v.as_object())
.and_then(|t| t.get("content"))
.and_then(|v| v.as_str())
.unwrap_or("");
println!("收到文本:{}", content);
let stream_id = generate_req_id("stream");
// 发送流式中间内容
let _ = client_text
.reply_stream(&frame, &stream_id, "正在思考中...", false, None, None)
.await;
// 发送最终结果
time::sleep(Duration::from_secs(1)).await;
let _ = client_text
.reply_stream(
&frame,
&stream_id,
&format!("你好!你说的是:\"{}\"", content),
true,
None,
None,
)
.await;
});
});
// 4. 监听进入会话事件(发送欢迎语)
let client_enter = client.clone();
client.on_event_enter_chat(move |frame| {
let client_enter = client_enter.clone();
let frame = frame.clone();
tokio::spawn(async move {
use serde_json::json;
let _ = client_enter
.reply_welcome(
&frame,
json!({
"msgtype": "text",
"text": {"content": "您好!我是智能助手,有什么可以帮您的吗?"}
}),
)
.await;
});
});
// 5. 启动客户端
println!("正在启动机器人...");
if let Err(e) = client.connect().await {
eprintln!("启动失败:{}", e);
return;
}
println!("机器人启动成功,按 Ctrl+C 退出");
// 等待退出信号
tokio::signal::ctrl_c().await.ok();
println!("\n正在停止机器人...");
client.disconnect();
}
```
## 📚 示例说明
SDK 提供了多个示例代码,位于 `examples/` 目录下:
### 1. basic.rs - 基础使用示例
演示基本的连接、消息监听和回复功能。
```bash
cargo run --example basic
```
### 2. echo_test.rs - Echo 测试机器人
收到消息后自动原样回复,适合测试基本功能。
```bash
cargo run --example echo_test
```
### 3. test_files.rs - 文件/图片下载测试
演示如何下载和解密企业微信发送的图片和文件,支持:
- 下载加密图片并保存
- 下载加密文件并保存
- 自动处理未加密的文件(如截屏图片)
```bash
cargo run --example test_files
```
### 4. sender_info.rs - 消息发送人信息示例
演示如何获取消息发送人的详细信息,包括:
- 发送人 userid
- 发送人 username(如果可用)
- 发送人 gender(如果可用)
```bash
cargo run --example sender_info
```
### 5. upload_media.rs - 上传临时素材示例
演示如何通过 WebSocket 分片上传临时素材到企业微信,支持:
- 图片(image):≤10MB, JPG/PNG 格式
- 语音(voice):≤2MB, AMR 格式
- 视频(video):≤10MB, MP4 格式
- 文件(file):≤10MB
```bash
# 上传文件
cargo run --example upload_media <文件路径>
# 示例:上传图片
cargo run --example upload_media test.jpg
```
上传成功后返回 `media_id`,可用于发送消息时引用该素材。
## 📖 API 文档
### `WSClient`
核心客户端类,提供连接管理、消息收发等功能。
#### 构造函数
```rust
let client = WSClient::new(WSClientOptions::new(bot_id, secret));
```
#### 配置选项
`WSClientOptions` 完整配置:
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `bot_id` | `String` | ✅ | — | 机器人 ID(企业微信后台获取) |
| `secret` | `String` | ✅ | — | 机器人 Secret(企业微信后台获取) |
| `reconnect_interval` | `u64` | — | `1000` | 重连基础延迟(毫秒),实际延迟按指数退避递增 |
| `max_reconnect_attempts` | `i32` | — | `10` | 最大重连次数(`-1` 表示无限重连) |
| `heartbeat_interval` | `u64` | — | `30000` | 心跳间隔(毫秒) |
| `request_timeout` | `u64` | — | `10000` | HTTP 请求超时时间(毫秒) |
| `ws_url` | `Option<String>` | — | `None` | 自定义 WebSocket 连接地址 |
| `logger` | `Option<Box<dyn Logger>>` | — | `None` | 自定义日志实例 |
```rust
let options = WSClientOptions {
bot_id: "your-bot-id".to_string(),
secret: "your-bot-secret".to_string(),
reconnect_interval: 1000,
max_reconnect_attempts: 10,
heartbeat_interval: 30000,
request_timeout: 10000,
ws_url: None,
logger: None,
};
```
#### 方法
| 方法 | 说明 | 返回值 |
| --- | --- | --- |
| `async connect()` | 建立 WebSocket 连接,连接后自动认证 | `Result<(), SdkError>` |
| `disconnect()` | 主动断开连接 | `()` |
| `async reply(frame, body, cmd)` | 通过 WebSocket 通道发送回复消息(通用方法) | `Result<WsFrame, SdkError>` |
| `async reply_stream(frame, stream_id, content, finish, msg_item, feedback)` | 发送流式文本回复(便捷方法,支持 Markdown) | `Result<WsFrame, SdkError>` |
| `async reply_welcome(frame, body)` | 发送欢迎语回复(支持文本或模板卡片格式),需在收到事件 5s 内调用 | `Result<WsFrame, SdkError>` |
| `async reply_template_card(frame, template_card, feedback)` | 回复模板卡片消息 | `Result<WsFrame, SdkError>` |
| `async reply_stream_with_card(frame, stream_id, content, finish, ...)` | 发送流式消息 + 模板卡片组合回复 | `Result<WsFrame, SdkError>` |
| `async update_template_card(frame, template_card, userids)` | 更新模板卡片,需在收到事件 5s 内调用 | `Result<WsFrame, SdkError>` |
| `async send_message(chatid, body)` | 主动发送消息(支持 Markdown 或模板卡片),无需依赖回调帧 | `Result<WsFrame, SdkError>` |
| `async download_file(url, aes_key)` | 下载文件并使用 AES 密钥解密 | `Result<(Vec<u8>, Option<String>), SdkError>` |
| `async upload_media(media_type, file_data, filename)` | 上传临时素材(通过 WebSocket 分片上传) | `Result<serde_json::Value, SdkError>` |
#### 属性
| 方法 | 说明 | 返回类型 |
| --- | --- | --- |
| `is_connected()` | 当前 WebSocket 连接状态 | `bool` |
| `api()` | 内部 API 客户端实例(高级用途) | `Arc<WeComApiClient>` |
### 事件监听
所有事件均通过 `on_*` 方法监听:
```rust
// 连接事件
client.on_connected(|| { println!("WebSocket 已连接"); });
client.on_authenticated(|| { println!("认证成功"); });
client.on_disconnected(|reason| { println!("连接已断开:{}", reason); });
client.on_reconnecting(|attempt| { println!("正在进行第 {} 次重连...", attempt); });
client.on_error(|error| { eprintln!("发生错误:{}", error); });
// 消息事件
client.on_message(|frame| { println!("收到消息:{:?}", frame); });
client.on_message_text(|frame| { println!("收到文本消息"); });
client.on_message_image(|frame| { println!("收到图片消息"); });
client.on_message_mixed(|frame| { println!("收到图文混排消息"); });
client.on_message_voice(|frame| { println!("收到语音消息"); });
client.on_message_file(|frame| { println!("收到文件消息"); });
// 事件回调
client.on_event(|frame| { println!("收到事件回调"); });
client.on_event_enter_chat(|frame| { println!("用户进入会话"); });
client.on_event_template_card(|frame| { println!("模板卡片事件"); });
client.on_event_feedback(|frame| { println!("用户反馈事件"); });
```
### `reply_stream` 详细说明
```rust
await client.reply_stream(
&frame, // 收到的原始 WebSocket 帧(透传 req_id)
&stream_id, // 流式消息 ID(使用 generate_req_id("stream") 生成)
"回复内容", // 回复内容(支持 Markdown)
false, // 是否结束流式消息
None, // 图文混排项(仅 finish=true 时有效)
None, // 反馈信息(仅首次回复时设置)
);
```
### `reply_welcome` 详细说明
发送欢迎语回复,需在收到 `event.enter_chat` 事件 5 秒内调用。
```rust
// 文本欢迎语
use serde_json::json;
await client.reply_welcome(&frame, json!({
"msgtype": "text",
"text": {"content": "欢迎!"},
}));
// 模板卡片欢迎语
await client.reply_welcome(&frame, json!({
"msgtype": "template_card",
"template_card": {"card_type": "text_notice", "main_title": {"title": "欢迎"}},
}));
```
### `send_message` 详细说明
主动向指定会话推送消息,无需依赖收到的回调帧。
```rust
// 发送 Markdown 消息
await client.send_message("userid_or_chatid", json!({
"msgtype": "markdown",
"markdown": {"content": "这是一条**主动推送**的消息"},
}));
// 发送模板卡片消息
await client.send_message("userid_or_chatid", json!({
"msgtype": "template_card",
"template_card": {"card_type": "text_notice", "main_title": {"title": "通知"}},
}));
```
### `upload_media` 上传临时素材
通过 WebSocket 分片上传临时素材到企业微信,支持图片、语音、视频和文件。
```rust
use std::fs;
// 读取文件
let file_data = fs::read("test.pdf").unwrap();
// 上传文件(media_type: "image", "voice", "video", "file")
match client.upload_media("file", &file_data, "test.pdf").await {
Ok(result) => {
let media_id = result.get("media_id").and_then(|v| v.as_str()).unwrap();
println!("上传成功,media_id: {}", media_id);
}
Err(e) => eprintln!("上传失败:{}", e),
}
```
**文件大小限制:**
| 类型 | 大小限制 | 格式 |
| --- | --- | --- |
| image | ≤10MB | JPG, PNG |
| voice | ≤2MB | AMR (≤60s) |
| video | ≤10MB | MP4 |
| file | ≤10MB | - |
**注意:** 临时素材 `media_id` 有效期为 3 天。
### `download_file` 使用示例
```rust
// aes_key 取自消息体中的 image.aeskey 或 file.aeskey
client.on_message_image(|frame| {
let client = client.clone();
let frame = frame.clone();
tokio::spawn(async move {
let aes_key = frame
.body
.as_ref()
.and_then(|v| v.as_object())
.and_then(|b| b.get("image"))
.and_then(|v| v.as_object())
.and_then(|i| i.get("aeskey"))
.and_then(|v| v.as_str())
.map(String::from);
let url = frame
.body
.as_ref()
.and_then(|v| v.as_object())
.and_then(|b| b.get("image"))
.and_then(|v| v.as_object())
.and_then(|i| i.get("url"))
.and_then(|v| v.as_str())
.map(String::from);
if let (Some(url), aes_key) = (url, aes_key) {
let (buffer, filename) = client.download_file(&url, aes_key.as_deref()).await.unwrap();
println!("文件名:{:?}, 大小:{} bytes", filename, buffer.len());
}
});
});
```
## 📋 消息类型
SDK 支持以下消息类型(`MessageType` 枚举):
| 类型 | 值 | 说明 |
| --- | --- | --- |
| `MessageType::Text` | `"text"` | 文本消息 |
| `MessageType::Image` | `"image"` | 图片消息 |
| `MessageType::Mixed` | `"mixed"` | 图文混排消息 |
| `MessageType::Voice` | `"voice"` | 语音消息 |
| `MessageType::File` | `"file"` | 文件消息 |
SDK 支持以下事件类型(`EventType` 枚举):
| 类型 | 值 | 说明 |
| --- | --- | --- |
| `EventType::EnterChat` | `"enter_chat"` | 进入会话事件 |
| `EventType::TemplateCardEvent` | `"template_card_event"` | 模板卡片事件 |
| `EventType::FeedbackEvent` | `"feedback_event"` | 用户反馈事件 |
## 🪵 自定义日志
实现 `Logger` Trait 接口即可自定义日志输出:
```rust
use wecom_aibot_rust_sdk::types::Logger;
struct MyLogger;
impl Logger for MyLogger {
fn debug(&self, message: &str) {
// 静默 debug 日志
}
fn info(&self, message: &str) {
println!("[INFO] {}", message);
}
fn warn(&self, message: &str) {
println!("[WARN] {}", message);
}
fn error(&self, message: &str) {
println!("[ERROR] {}", message);
}
}
let options = WSClientOptions {
bot_id: "your-bot-id".to_string(),
secret: "your-bot-secret".to_string(),
logger: Some(Box::new(MyLogger)),
..Default::default()
};
let client = WSClient::new(options);
```
## 📂 项目结构
```
rust_wecom_bot_rust_sdk/
├── src/
│ ├── lib.rs # 库入口文件,统一导出
│ ├── client.rs # WSClient 核心客户端
│ ├── ws.rs # WebSocket 长连接管理器
│ ├── message_handler.rs # 消息解析与事件分发
│ ├── api.rs # HTTP API 客户端(文件下载)
│ ├── crypto_utils.rs # AES-256-CBC 文件解密
│ ├── logger.rs # 默认日志实现
│ ├── utils.rs # 工具方法(generate_req_id 等)
│ └── types.rs # 类型定义(枚举、结构体、常量)
├── examples/
│ ├── basic.rs # 基础使用示例
│ ├── send_test_message.rs # 发送测试消息示例
│ ├── echo_test.rs # Echo 测试机器人
│ ├── test_files.rs # 文件/图片下载测试
│ ├── sender_info.rs # 消息发送人信息示例
│ └── upload_media.rs # 上传临时素材示例
├── Cargo.toml # 项目配置
├── README.md # 本文件
└── .env.example # 环境变量示例
```
## 🏗️ 架构说明
本 SDK 采用异步事件驱动架构,主要组件:
```
┌─────────────────────────────────────────────────────┐
│ WSClient │
│ (核心客户端,提供事件注册和消息发送接口) │
└─────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ WsConnection │ │ MessageHandler │ │ WeComApiClient │
│ Manager │ │ (消息分发) │ │ (文件下载) │
│ (连接/心跳/重连) │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ WebSocket LWS │ │ Event Types │
│ (长连接) │ │ (文本/图片/事件)│
└─────────────────┘ └─────────────────┘
```
## 📄 License
MIT
@@ -152,3 +152,194 @@ IM 用户 @机器人 "把登录任务推进到 in_review"
- **df-mcp**:对外 stdio 暴露数据层(本地进程级);本规划是远程人类入口(IM 级),互补不重叠
- **u-relay**:零改动(除非 D5 触发多 token 需求)
- **df-tunnel / AI 引擎**:零改动
## 附录A、PostgreSQL 多租户 × DevFlow 结合分析(§十 S3 展开论证)
### A.1 概念:多租户三模式
| 模式 | 结构 | 优点 | 缺点 | 适合 |
|---|---|---|---|---|
| **行级隔离** | 同库同表,每行 `tenant_id` 列,查询强制 WHERE | 成本最低、迁移跑一份、跨租户统计易 | 隔离最弱(漏写 WHERE 即泄漏)、noisy neighbor | 租户多单租户数据小,SaaS 主流 |
| **独立 Schema** | 一实例每租户一 schema | 隔离较强,按 schema 备份 | 租户多时爆炸;DDL 迁移跑 N 遍 | 租户几百内 |
| **独立 Database** | 每租户一库 | 最强隔离 | 资源贵、连接池复杂、跨租户查询难 | 租户少而大、合规严 |
行级隔离通常配合 **PG RLS(Row Level Security)**:租户过滤下沉数据库层——session 设 `SET app.tenant_id`,策略自动过滤,应用层漏写 WHERE 也不泄漏。这是 PG 多租户的核心王牌。
### A.2 激活时机
DevFlow 当前是本地优先单机应用(SQLite/单用户/零运维),「租户」不存在。多租户仅在 **S3 AI 引擎上云**时被激活:SQLite 单写者模型扛不住云端多用户并发写,且「本地优先」范式失效——隔离必须显式设计。**多租户是需求,PostgreSQL 是载体**(PG 因并发写 + RLS + 生态成为默认答案)。
### A.3 租户模型与选型
- 租户模型:`tenant`=团队(付费主体),`user`=成员;现有全部表(projects/tasks/ideas/workflows/ai_messages/knowledge/audit…)挂 `tenant_id`
- 选型:**行级隔离 + RLS**(非 schema/database 隔离)。决定性理由是迁移演进节奏:V1-V16+ 已 20+ 表且快速迭代,per-tenant schema 下每次迁移要循环跑 N 遍并处理失败重试,直接拖死迭代速度;行级隔离迁移只跑一份
### A.4 落点三抓手
1. **df-storage trait 层**(对齐 F-07 下沉思路):存储访问收敛 Repository trait,多租户化 = trait 实现层注入租户上下文(依赖 PG session 变量),上层 commands 无感——现有分层红利
2. **迁移体系**:SQLite 手写迁移换 sqlx/refinery 版本化迁移,一套脚本跑一份
3. **RLS 策略**:每表 `USING (tenant_id = current_setting('app.tenant_id')::uuid)`,连接池(pgbouncer)按事务设租户变量
### A.5 配套改造清单(多租户 ≠ 换数据库驱动)
缓存键带租户(防 Redis 串数据)/ 对象存储按租户前缀隔离 / 日志审计按租户切分 / 备份恢复按租户粒度。**全链路改造**——这正是 §十 把 PG 多租户列入「显式不做」的原因:现在做 = 为不存在的需求付全链路成本。
### A.6 与本期规划的交集
唯一交集 = P0 已预留的会话映射表 `owner_user_id` 列(S2 时是「用户归属」,S3 升级语义为「租户内用户」),成本≈0。时序红线重申:S2 各人数据仍在各自桌面端 SQLite,云端只有路由表,**不需要 PG**;S3 才引入 PG+RLS,与引擎多租户化统一做。
## 附录B、引入云端后的多人架构模式(C4 视图)
> 按 C4 模型整理:C1 上下文(现状基线 → S2 多人 → S3 引擎上云)+ C2 容器(网关内部)+ 部署视图 + 数据归属演进表。
> 贯穿不变量:**TunnelCommand/TunnelEvent 是网关↔执行端唯一契约**,执行端可从「桌面端 df-tunnel」无缝换成「云端 df-agent」,网关与 IM 端零改动。
### B.1 C1 上下文 —— 现状基线(S0:单机 + 小程序遥控)
```mermaid
flowchart TB
subgraph local["💻 用户本地(数据主权 100% 在个人)"]
desktop["DevFlow 桌面端 (Tauri v2)<br/>AI Chat 引擎(Agentic Loop+审批) · SQLite · df-tunnel(Agent)"]
end
subgraph cloud["☁️ 云端 u-relay.1216.top(nginx TLS 终止)"]
relay["u-relay :9180<br/>channel_id 配对 · payload 透传零业务耦合"]
end
mini["📱 df-miniapp 微信小程序<br/>(Commander)"]
mini <-->|"WSS"| relay
relay <-.->|"WS Agent 出站穿NAT<br/>单 channel 配对"| desktop
```
### B.2 C1 上下文 —— S2 团队多人(云端路由化,数据仍归各人)
本期 IM 网关落地后的直接形态:**云端只有「路由与审计」,业务数据仍在各桌面端**。
```mermaid
flowchart TB
subgraph users["👥 多人入口"]
direction LR
im["飞书 / 钉钉 / 企微<br/>企业机器人"]
web["Web Chat<br/>浏览器/手机"]
mini["df-miniapp<br/>微信小程序"]
end
subgraph cloud["☁️ 云端(nginx TLS 终止)"]
direction TB
gw["df-im-gateway :9190<br/>平台适配器 · 会话映射 · 白名单<br/>节流聚合 · 审批分级 · 审计"]
relay["u-relay :9180<br/>多 channel 配对转发"]
gw <-->|"WS Commander(多channel)<br/>TunnelCommand / TunnelEvent"| relay
end
subgraph deskA["用户A 本地"]
dA["DevFlow 桌面端<br/>AI引擎 + SQLite(A) · df-tunnel"]
end
subgraph deskB["用户B 本地"]
dB["DevFlow 桌面端<br/>AI引擎 + SQLite(B) · df-tunnel"]
end
im --> gw
web <-->|"HTTP/WS"| gw
mini <-->|"WSS Commander"| relay
relay <-.->|"WS Agent · chan-userA"| dA
relay <-.->|"WS Agent · chan-userB"| dB
```
**路由链路**:IM 消息 → 网关查映射表 `chat_id → (owner_user_id, channel_id, conv_id)` → 发 `TunnelCommand::Send` 到对应 channel → 该用户桌面端执行。**用户隔离靠 channel 隔离**(一人一 channel,per-channel token 为 u-relay 演进观察项)。
### B.3 C2 容器 —— df-im-gateway 内部(适配器 trait 分层,S4 扩展点)
```mermaid
flowchart TB
subgraph adapters["平台适配器层(各平台差异收敛于此)"]
feishu["feishu_adapter<br/>长连接+卡片流式"]
dingtalk["dingtalk_adapter<br/>Stream Mode"]
wecom["wecom_adapter<br/>回调验签+AES"]
webui["web_adapter<br/>WS 直连+静态页"]
end
subgraph core["核心(平台无关,S4 加端零改动)"]
unify["统一模型<br/>InboundEvent / OutboundAction"]
sess["会话映射表 (SQLite)<br/>chat_id | conv_id | owner_user_id | platform | channel_id"]
throttle["节流聚合器<br/>TextDelta 时间窗"]
acl["白名单 + 审批分级"]
audit["审计日志<br/>IM消息 ↔ TunnelCommand"]
unify --> sess
sess --> throttle
acl --> sess
sess --> audit
end
relay["u-relay WS Commander"]
feishu --> unify
dingtalk --> unify
wecom --> unify
webui <--> unify
core <-->|"TunnelCommand / TunnelEvent"| relay
```
### B.4 C1 上下文 —— S3 引擎上云(云端多人完整形态,PG 多租户)
执行端从「各人桌面端」切换为「云端 df-agent 服务」,**同一协议**,网关/IM 端零改动;桌面端退化为瘦客户端/管理台,本地优先形态仍可用(不废弃)。
```mermaid
flowchart TB
subgraph users["👥 多人入口"]
direction LR
im["飞书/钉钉/企微"]
web["Web Chat"]
end
subgraph cloud["☁️ 云端"]
direction TB
gw["df-im-gateway<br/>(零改动)"]
relay["u-relay<br/>(零改动)"]
agent["df-agent 云端执行服务<br/>AI 引擎(Agentic Loop) · 工具沙箱<br/>多会话/多租户调度"]
gw <--> relay
relay <-.->|"同协议<br/>TunnelCommand / TunnelEvent"| agent
end
subgraph data["数据层(附录A 落点)"]
pg[("PostgreSQL<br/>行级 tenant_id + RLS<br/>projects/tasks/messages/audit")]
redis[("Redis<br/>租户前缀缓存键")]
oss[("对象存储<br/>租户前缀隔离")]
end
thin["💻 桌面端=瘦客户端/管理台<br/>本地 SQLite 留个人配置"]
im --> gw
web <--> gw
agent --> pg
agent --> redis
agent --> oss
thin <-.-> relay
```
### B.5 部署视图(S2 落地形态,单机双服务 + nginx)
```mermaid
flowchart LR
subgraph inet["互联网"]
u["用户 IM / 浏览器 / 小程序"]
end
subgraph server["云端主机(u-relay 同机,复用 nginx+systemd 模式)"]
nginx["nginx :443 TLS 终止<br/>/ws/* → 9180<br/>/im/* /chat/* → 9190"]
ur["u-relay :9180<br/>systemd · musl 单二进制 ~3.6MB"]
gig["df-im-gateway :9190<br/>systemd · musl 单二进制<br/>静态 Web Chat 页托管"]
nginx --> ur
nginx --> gig
end
subgraph homes["各用户本地"]
dt["DevFlow 桌面端<br/>出站 WSS → /ws/agent"]
end
u -->|"wss://u-relay.1216.top"| nginx
dt <-.->|"出站穿NAT"| nginx
```
### B.6 数据归属与信任边界演进表
| 维度 | S0 现状 | S2 团队多人 | S3 引擎上云 |
|---|---|---|---|
| 身份/会话映射 | 桌面端 Settings | **网关 SQLite**(owner/channel 列已预留) | 网关 → 并入 PG |
| 项目/任务/工作流 | 桌面端 SQLite | **各桌面端 SQLite**(主权在个人) | PG(tenant_id+RLS) |
| AI 消息/溯源 | 桌面端 ai_messages | 各桌面端 | PG |
| IM↔指令审计 | — | 网关审计日志 | PG(租户维度) |
| 信任边界 | 个人设备 | 云端只见「指令与脱敏结果」 | 云端见全部业务数据(需租户隔离+合规) |
| 桌面端角色 | 全栈主体 | 执行主体 | 瘦客户端/管理台(可选保留本地优先) |
### B.7 演进的三条设计不变量
1. **协议不变性**:网关↔执行端唯一契约是 TunnelCommand/TunnelEvent(df-tunnel crate 类型),S3 换执行端零成本——这是全方案最重要的远期红利
2. **u-relay 纯转发零耦合**不破例:业务逻辑只进 df-im-gateway / df-agent,中继永远轻
3. **数据主权渐进上移**:S0/S2 数据在个人(本地优先不被废弃),S3 才集中上云并配套 RLS 隔离——每一步都由真实需求触发,不过度设计
@@ -0,0 +1,109 @@
# 飞书/企微/钉钉 Rust 接入与 SDK 调研
> 创建: 2026-10-04 | 状态: ✅ 调研完成
> 服务于: [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) P2/P3/P4
> 原始材料: [.research/](./.research/)(larkrs 全套 + 本次新增 dingtalk/wecom README 与 crates 数据)
## 一、总览:三家选型结论
| 维度 | 飞书 | 钉钉 | 企微 |
|---|---|---|---|
| **选型** | ⭐ larksuite-oapi-sdk-rs 0.3.12 | ⭐ dingtalk-stream 0.1.7 | ⭐ wecom-aibot-rust-sdk 1.0.2 |
| 作者 | adamcavendish(非官方,显式对齐 Go 官方 SDK) | spensercai(非官方,对齐官方 Python SDK commit 6381a7b) | camark(非官方,Python 版 wecom/aibot 的 Rust 改写) |
| 仓库 | github.com/adamcavendish/larksuite-oapi-sdk-rs | github.com/spensercai/dingtalk-stream-sdk-rs | github.com/camark/wecom-aibot-rust-sdk |
| 最近发版/提交 | 2026-10-03(活跃) | 2026-03-26 | 2026-04-07 |
| crates 下载(近期) | 4276(openlark-auth 关联) | 3459 | 106 |
| 连接方式 | **WS 长连接**(features=["ws"]) | **WS Stream Mode** | **WS 长连接**(wss://openws.work.weixin.qq.com)✅ |
| 免公网回调 | ✅ | ✅ | ✅(智能机器人形态,**非**传统自建应用回调模式) |
| 流式回复 | ✅ CardKit(CardEntity + sequence 递增) | ✅ 卡片 AI 流式 | ✅ reply_stream(stream_id + finish 标志,支持 Markdown) |
| 审批按钮回调 | ⚠️ card.action.trigger 新版事件(编译冒烟验证) | ✅ 卡片按钮回调 | ✅ template_card_event(5s 内 update_template_card) |
| Rust 版本要求 | 1.95+/edition 2024 | 1.85+/edition 2024 | 1.70+(tokio 1.35+) |
**核心结论**:三家全部有可用的 Rust 原生 SDK,且三家全部支持 WS 长连接——**df-im-gateway 三平台均不需要公网回调/可信 IP/AES 回调解密**。原规划中「企微接入复杂度最高(回调验签+AES+可信域名)」的判断基于传统自建应用形态,改用**智能机器人**形态后三平台接入复杂度拉平,P4 工作量从 3-4 天降到 2-3 天。
## 二、飞书:larksuite-oapi-sdk-rs(已有源码级勘察)
详见 [.research/larkrs-readme.md](./.research/larkrs-readme.md) / [larkrs-events-and-channels.md](./.research/larkrs-events-and-channels.md) / [larkrs-cards.md](./.research/larkrs-cards.md) 及任务 P2 描述中的勘察补充。要点:
- `features=["channel"]` 一站式:`channel_messaging()` 高层封装(send/reply/reply_in_thread/edit_text/edit_card),uuid 1h 幂等窗口
- ws feature:bootstrap 握手(POST /callback/ws/endpoint 换 wss URL)+ protobuf framing + 有限重连 + liveness(入站静默 2×ping+5s 判失败)+ write 超时 10s + 回调与收包循环解耦(慢回调不掩盖网关静默)
- 卡片三套:card::v1(JSON 1.0)/ card::v2(JSON 2.0)/ CardKit 流式(CardEntity 一次发送 + sequence 递增更新,14 天生命周期);CardDocument 带 JSON Pointer 级严格校验
- 事件:EventDispatcher typed handler(如 on_p2_im_message_receive_v1),mpsc 队列 + worker 出站 I/O 模式可直接套用([.research/larkrs-ws-client-example.rs](./.research/larkrs-ws-client-example.rs))
- ⚠️ 遗留验证项:CardActionHandler 官方示例是 HTTP webhook 形态;长连接下 card.action.trigger 应经事件通道分发,typed 注册口需编译冒烟确认(docs/card-protocol.md + examples/card_action_handler.rs 已存档)
- ⚠️ 坑(源码级实证,知识库已沉淀):卡片回调必须用**新版** card.action.trigger(EventDispatcher 注册);旧版 card 帧被 ws 客户端直接丢弃(Go SDK v3.12.0 中 WithCardHandler 被注释)
## 三、钉钉:dingtalk-stream(本次新调研)
完整对齐官方 Python SDK(open-dingtalk/dingtalk-stream-sdk-python @ 6381a7b),结构清晰(client/credential/handlers/messages/card/transport 分层)。
**能力覆盖**([.research/dingtalk-stream-readme.md](./.research/dingtalk-stream-readme.md)):
- Stream 协议连接:自动重连 + 60s Keepalive
- 消息收发:文本/Markdown/卡片;Rust 独有扩展——机器人可收全部 6 种消息类型(audio/file/video 仅单聊)
- 交互卡片:Markdown / **AI 流式** / 按钮 / 轮播 / RPA 插件——审批按钮与流式都覆盖
- 事件订阅 + Graph API
- OAuth2 Token 自动管理(缓存 + 5 分钟提前刷新)
- 主动单聊:send_oto_message(POST /v1.0/robot/oToMessages/batchSend)
- 媒体下载:get_all_download_codes 统一提取 + download_bytes_with_limit 流式限速下载
**接入姿势**:
```rust
let credential = Credential::new(client_id, client_secret);
let mut client = DingTalkStreamClient::builder(credential)
.register_callback_handler(ChatbotMessage::TOPIC, handler)
.build();
client.start_forever()?;
```
Handler trait 回 `(u16, String)` ACK——**注意 ACK 语义与飞书不同**(飞书是 Response 帧回执,钉钉是 handler 返回值)。
**评估**:能力全覆盖、架构对齐官方、文档含时序图、有 e2e_test 示例可直接冒烟。社区量小(3459 下载)但对齐官方 Python SDK 降低了漂移风险。备选 dingtalk-stream-sdk 0.2.5(wenhaozhao,更早但更新至 2026-04)可作 fallback。
## 四、企微:wecom-aibot-rust-sdk(本次新调研,重大发现)
**关键发现:企业微信「智能机器人」走 WS 长连接**(wss://openws.work.weixin.qq.com,bot_id+secret 认证帧),**完全不需要**传统自建应用的公网回调 URL / GET 验签 / POST AES 解密 / 可信 IP 白名单 / 可信域名。
**能力覆盖**([.research/wecom-aibot-readme.md](./.research/wecom-aibot-readme.md),546 行完整 API 文档):
- 连接管理:自动认证 + 心跳保活(30s)+ 指数退避重连(1s→30s 上限,可设无限)
- 消息分发:text / image / mixed / voice / file 五类 on_message_* 事件
- **流式回复**:reply_stream(frame, stream_id, content, finish, ...)——stream_id + finish 标志,天然贴合 TunnelEvent::TextDelta 聚合刷新模式
- 模板卡片:reply_template_card / **reply_stream_with_card**(流式+卡片组合)/ update_template_card(需事件 5s 内调用)
- 主动推送:send_message(chatid, body)——无需依赖回调帧,Markdown/卡片均可
- 事件回调:enter_chat(进会话,5s 内 reply_welcome)/ **template_card_event(卡片按钮点击)** / feedback
- 文件:download_file(AES-256-CBC 自动解密,媒体自带独立 aeskey)+ upload_media(WS 分片上传,media_id 3 天有效)
- 串行回复队列:同 req_id 回复串行发送自动等回执;可插拔 Logger;tokio 原生
**API 形态**:回调式(on_* 闭包注册)+ client 可 clone(Arc 内部)——与 df-im-gateway 的 adapter 模式兼容性极好,直接把 on_message_text 桥到 TunnelCommand::Send。
**限制与验证项**:
1. **形态确认**:智能机器人 vs 自建应用的能力边界需在管理后台确认(智能机器人是否支持群聊场景、可见范围配置)。README 场景以单聊(userid)+ chatid 为主,群聊支持度落地时验证
2. 模板卡片更新有 **5s 时窗限制**(enter_chat/template_card_event 后 5s 内)——审批卡片的「按钮点击后更新卡片状态」必须用时窗内 update_template_card 或直接发新卡片
3. 社区量小(346 下载)且为个人项目——锁版本 + 冒烟测试对冲;协议本身简单(WS+JSON 帧),最坏情况自实现成本可控(远低于原 AES 回调模式)
4. 备选:WecomTeam/wecom-cli 官方系(wecomx-* crate 族,2026-08 活跃)但为 CLI 形态服务化集成不便;传统自建应用路线(wecom-crypto + wecom-rs)仅在智能机器人不满足时启用
## 五、对 df-im-gateway 实施的影响
### 5.1 规划修正
1. **§四 四端对比表**:企微「事件接收」从「HTTP 回调(GET 验签+POST AES,需公网 URL)」修正为「WS 长连接(智能机器人形态,免公网回调)」;接入复杂度从「高」降为「中」
2. **P4 任务描述**:接入路线改为智能机器人(wecom-aibot-rust-sdk),传统回调模式降级为 fallback
3. **三平台统一形态**:三家全是「WS 长连接出站 + JSON/protobuf 帧 + 卡片流式 + 按钮回调」——df-im-gateway 的 adapter trait 可以高度统一(InboundEvent/OutboundAction 映射逻辑三平台趋同),印证 P0 的 ④ 预留项价值
### 5.2 统一技术底座
| 关注点 | 三家公共形态 | 网关侧统一处理 |
|---|---|---|
| 连接 | WS 出站长连接 + 心跳 + 自动重连 | adapter trait 的 connect/health_check 统一签名 |
| 收消息 | 事件/消息帧 → text content | InboundEvent::Message{chat_id, user, content} |
| 流式回复 | stream_id + finish 标志(企微)/ CardKit sequence(飞书)/ AI 卡片流式(钉钉) | OutboundAction::StreamChunk{conv_id, delta, finished} → 各 adapter 自映射 |
| 审批按钮 | 卡片按钮点击事件 | InboundEvent::CardAction{chat_id, action_id, value} → TunnelCommand::Approve |
| 媒体 | 下载码/aeskey(企微钉钉)、message resource(飞书) | 二期再做,一期纯文本 |
### 5.3 风险清单(落地时冒烟)
| # | 风险 | 对冲 |
|---|---|---|
| R1 | 飞书 card.action.trigger 长连接下 typed 注册口未编译验证 | P2 开工首个冒烟项(examples/card_action_handler.rs 已存档) |
| R2 | 企微智能机器人群聊支持度/可见范围未确认 | P4 开工前管理后台确认;不满足则回退传统自建应用回调路线 |
| R3 | 三家 SDK 均非官方(个人维护) | 锁精确版本 + e2e 冒烟进 CI + 协议简单最坏自实现 |
| R4 | 钉钉 SDK handler ACK 语义与飞书 Response 帧不同 | adapter 层各自封装,不追求网关核心层统一 |
| R5 | 企微卡片 5s 更新时窗 | 审批结果用新卡片回复而非更新原卡片,规避时窗 |
## 六、结论
三平台 Rust 接入**全部可行且路线统一**(WS 长连接 + SDK 现成 + 卡片流式 + 按钮审批),无阻塞性技术风险。规划文档的 P2/P3/P4 工作量估算维持 3-4 天/平台不变(复杂度下降但均摊了锁版本冒烟与群聊验证成本);企微从「最繁琐」降为与钉钉同级,**实施顺序可按 Web → 飞书 → 企微 → 钉钉 或按企业实际使用优先级自由排**。