新增: 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