新增: df-im-gateway 网关骨架
This commit is contained in:
@@ -91,6 +91,7 @@
|
||||
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
|
||||
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
|
||||
| [devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md](./专项设计/devflow集成三方IM-飞书企微钉钉Web-规划设计-2026-10-04.md) | 📐 规划(待评审) | 飞书/企微/钉钉/自定义Web 远程接入 AI Chat:新增 df-im-gateway(WS Commander)复用 u-relay+df-tunnel,桌面端零改动。含必要性分析(§三A)、破坏性分析(§三B)、演进预留(§十 S1-S4:多人/引擎上云/新端)。P0网关→P1 Web→P2飞书→P3钉钉→P4企微 |
|
||||
| [三方IM-Rust接入与SDK调研-2026-10-04.md](./专项设计/三方IM-Rust接入与SDK调研-2026-10-04.md) | ✅ 调研完成 | 三平台 Rust SDK 选型:飞书 larksuite-oapi-sdk-rs / 钉钉 dingtalk-stream / 企微 wecom-aibot-rust-sdk。**重大发现:企微智能机器人走 WS 长连接免公网回调,三平台统一形态**。含统一 adapter 映射表与 5 项落地风险清单 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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
|
||||
|
||||
[](https://crates.io/crates/dingtalk-stream)
|
||||
[](https://docs.rs/dingtalk-stream)
|
||||
[](LICENSE)
|
||||
[](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
|
||||
|
||||
[](https://crates.io/crates/feishu-sdk)
|
||||
[](https://docs.rs/feishu-sdk)
|
||||
[](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
|
||||
|
||||
[](https://crates.io/crates/larksuite-oapi-sdk-rs)
|
||||
[](https://docs.rs/larksuite-oapi-sdk-rs)
|
||||
[](https://github.com/adamcavendish/larksuite-oapi-sdk-rs/actions/workflows/ci.yml)
|
||||
[](./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 → 飞书 → 企微 → 钉钉 或按企业实际使用优先级自由排**。
|
||||
Reference in New Issue
Block a user