use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::card::{Card, CardId, CardSettings, validate_card_element_content, validate_element_id};
use crate::message::MessageId;
use crate::{Error, Result};
use super::message::validate_message_id;
use super::{HttpMethod, HttpRequest, OpenApiClient, OpenApiTransport, parse_openapi_response};
const CARD_ENTITY_PATH: &str = "/open-apis/cardkit/v1/cards";
const CALLBACK_CARD_UPDATE_PATH: &str = "/open-apis/interactive/v1/card/update";
const MESSAGE_PATH: &str = "/open-apis/im/v1/messages";
const MAX_CARD_SETTINGS_CHARS: usize = 100_000;
const MAX_CARD_UPDATE_UUID_CHARS: usize = 64;
const MAX_CARD_UPDATE_SEQUENCE: u32 = i32::MAX as u32;
impl<T> OpenApiClient<T>
where
T: OpenApiTransport,
{
pub async fn update_message_card(&self, message_id: &MessageId, card: &Card) -> Result<()> {
validate_message_id(message_id)?;
card.validate_for_message_update()?;
let path = format!("{MESSAGE_PATH}/{}", message_id.0);
let request = UpdateMessageCardRequest {
content: serde_json::to_string(card)?,
};
let _: Value = self.patch_tenant_json(&path, &request).await?;
Ok(())
}
pub async fn update_message_card_with_callback_token(
&self,
callback_token: &str,
card: &Card,
) -> Result<()> {
validate_callback_token(callback_token)?;
card.validate()?;
let request = CallbackCardUpdateRequest {
token: callback_token,
card,
};
let _: Value = self
.post_tenant_json(CALLBACK_CARD_UPDATE_PATH, &request)
.await?;
Ok(())
}
pub async fn create_card_entity(&self, card: &Card) -> Result<CardId> {
let request = self.prepare_card_entity_create(card)?;
let token = self.tenant_access_token().await?;
self.send_prepared_card_entity_create(request.with_bearer_auth(token))
.await
}
pub(crate) fn prepare_card_entity_create(&self, card: &Card) -> Result<HttpRequest> {
card.validate()?;
let payload = CardEntityPayload::from_card(card)?;
self.json_request(HttpMethod::Post, CARD_ENTITY_PATH, &payload)
}
pub(crate) async fn send_prepared_card_entity_create(
&self,
request: HttpRequest,
) -> Result<CardId> {
let response = self.transport.send_json(request).await?;
let response: CreateCardEntityResponse = parse_openapi_response(response)?;
CardId::new(response.data.card_id)
}
pub async fn update_card_entity(
&self,
card_id: &CardId,
card: &Card,
options: CardUpdateOptions,
) -> Result<()> {
card_id.validate()?;
card.validate()?;
options.validate()?;
let path = format!("{CARD_ENTITY_PATH}/{}", card_id.as_str());
let request = UpdateCardEntityRequest {
card: CardEntityPayload::from_card(card)?,
sequence: options.sequence,
uuid: options.uuid,
};
let _: Value = self.put_tenant_json(&path, &request).await?;
Ok(())
}
pub async fn update_card_settings(
&self,
card_id: &CardId,
settings: &CardSettings,
options: CardUpdateOptions,
) -> Result<()> {
card_id.validate()?;
settings.validate()?;
options.validate()?;
let settings = serde_json::to_string(settings)?;
if settings.chars().count() > MAX_CARD_SETTINGS_CHARS {
return Err(Error::Validation(format!(
"serialized card settings must be at most {MAX_CARD_SETTINGS_CHARS} characters"
)));
}
let path = format!("{CARD_ENTITY_PATH}/{}/settings", card_id.as_str());
let request = UpdateCardSettingsRequest {
settings,
sequence: options.sequence,
uuid: options.uuid,
};
let _: Value = self.patch_tenant_json(&path, &request).await?;
Ok(())
}
pub async fn update_card_element_content(
&self,
card_id: &CardId,
element_id: &str,
content: &str,
options: CardUpdateOptions,
) -> Result<()> {
card_id.validate()?;
validate_element_id(element_id)?;
validate_card_element_content(content)?;
options.validate()?;
let path = format!(
"{CARD_ENTITY_PATH}/{}/elements/{element_id}/content",
card_id.as_str()
);
let request = UpdateCardElementContentRequest {
content,
sequence: options.sequence,
uuid: options.uuid,
};
let _: Value = self.put_tenant_json(&path, &request).await?;
Ok(())
}
}
fn validate_callback_token(callback_token: &str) -> Result<()> {
if callback_token.trim().is_empty() {
return Err(Error::Validation(
"card callback token must not be empty".to_owned(),
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CardUpdateOptions {
pub sequence: u32,
pub uuid: Option<String>,
}
impl CardUpdateOptions {
pub fn new(sequence: u32) -> Self {
Self {
sequence,
uuid: None,
}
}
pub fn uuid(mut self, uuid: impl Into<String>) -> Self {
self.uuid = Some(uuid.into());
self
}
fn validate(&self) -> Result<()> {
if !(1..=MAX_CARD_UPDATE_SEQUENCE).contains(&self.sequence) {
return Err(Error::Validation(format!(
"card update sequence must be between 1 and {MAX_CARD_UPDATE_SEQUENCE}"
)));
}
if let Some(uuid) = &self.uuid {
if uuid.is_empty() {
return Err(Error::Validation(
"card update uuid must not be empty".to_owned(),
));
}
if uuid.chars().count() > MAX_CARD_UPDATE_UUID_CHARS {
return Err(Error::Validation(format!(
"card update uuid must be at most {MAX_CARD_UPDATE_UUID_CHARS} characters"
)));
}
}
Ok(())
}
}
#[derive(Debug, Serialize)]
struct UpdateMessageCardRequest {
content: String,
}
#[derive(Debug, Serialize)]
struct CallbackCardUpdateRequest<'a> {
token: &'a str,
card: &'a Card,
}
#[derive(Debug, Serialize)]
struct CardEntityPayload {
r#type: &'static str,
data: String,
}
impl CardEntityPayload {
fn from_card(card: &Card) -> Result<Self> {
Ok(Self {
r#type: "card_json",
data: serde_json::to_string(card)?,
})
}
}
#[derive(Debug, Serialize)]
struct UpdateCardEntityRequest {
card: CardEntityPayload,
sequence: u32,
#[serde(skip_serializing_if = "Option::is_none")]
uuid: Option<String>,
}
#[derive(Debug, Serialize)]
struct UpdateCardSettingsRequest {
settings: String,
sequence: u32,
#[serde(skip_serializing_if = "Option::is_none")]
uuid: Option<String>,
}
#[derive(Debug, Serialize)]
struct UpdateCardElementContentRequest<'a> {
content: &'a str,
sequence: u32,
#[serde(skip_serializing_if = "Option::is_none")]
uuid: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateCardEntityResponse {
data: CreateCardEntityData,
}
#[derive(Debug, Deserialize)]
struct CreateCardEntityData {
card_id: String,
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
use crate::card::MAX_CARD_ELEMENT_CONTENT_CHARS;
use crate::lark_openapi::test_support::{FakeTransport, block_on};
use crate::lark_openapi::{HttpMethod, HttpResponse};
use crate::message::{MessageContent, MessageId, Recipient};
use crate::{Card, CardId, ChannelConfig};
#[test]
fn update_message_card_patches_serialized_shared_card() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(200, json!({ "code": 0, "msg": "ok", "data": {} })),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().markdown("updated").build().expect("card");
block_on(client.update_message_card(&MessageId("om_123".to_owned()), &card))
.expect("updated message card");
let calls = transport.calls();
assert_eq!(calls[1].method, HttpMethod::Patch);
assert_eq!(
calls[1].url.as_str(),
"https://open.feishu.cn/open-apis/im/v1/messages/om_123"
);
let content: Value =
serde_json::from_str(calls[1].body["content"].as_str().expect("content string"))
.expect("card json");
assert_eq!(content["schema"], "2.0");
assert_eq!(content["config"]["update_multi"], true);
assert_eq!(content["body"]["elements"][0]["content"], "updated");
}
#[test]
fn update_message_card_rejects_url_path_delimiters_before_authentication() {
let transport = FakeTransport::new(vec![]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().text("hello").build().expect("card");
for message_id in ["om_1/extra", "om_1?x=1", "om_1#fragment", "om_1%2Fextra"] {
let error =
block_on(client.update_message_card(&MessageId(message_id.to_owned()), &card))
.expect_err("unsafe message_id must fail");
assert!(matches!(error, Error::Validation(_)));
}
assert!(transport.calls().is_empty());
}
#[test]
fn update_message_card_with_callback_token_posts_card_json() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(200, json!({ "code": 0, "msg": "ok", "data": {} })),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder()
.markdown("Delayed update")
.build()
.expect("card");
block_on(client.update_message_card_with_callback_token("c-callback-1", &card))
.expect("updated callback card");
let calls = transport.calls();
assert_eq!(calls[1].method, HttpMethod::Post);
assert_eq!(
calls[1].url.as_str(),
"https://open.feishu.cn/open-apis/interactive/v1/card/update"
);
assert_eq!(calls[1].body["token"], "c-callback-1");
assert_eq!(calls[1].body["card"]["schema"], "2.0");
assert_eq!(
calls[1].body["card"]["body"]["elements"][0]["content"],
"Delayed update"
);
}
#[test]
fn update_message_card_with_callback_token_rejects_empty_token_before_authentication() {
let transport = FakeTransport::new(vec![]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().text("hello").build().expect("card");
for callback_token in ["", " "] {
let error =
block_on(client.update_message_card_with_callback_token(callback_token, &card))
.expect_err("empty callback token must fail");
assert!(matches!(error, Error::Validation(_)));
}
assert!(transport.calls().is_empty());
}
#[test]
fn create_card_entity_posts_card_json_and_validates_response_id() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"data": { "card_id": "7355372766134157313" }
}),
),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().text("hello").build().expect("card");
let card_id = block_on(client.create_card_entity(&card)).expect("card entity");
assert_eq!(card_id, CardId("7355372766134157313".to_owned()));
let calls = transport.calls();
assert_eq!(calls[1].method, HttpMethod::Post);
assert_eq!(
calls[1].url.as_str(),
"https://open.feishu.cn/open-apis/cardkit/v1/cards"
);
assert_eq!(calls[1].body["type"], "card_json");
let data: Value =
serde_json::from_str(calls[1].body["data"].as_str().expect("data string"))
.expect("card json");
assert_eq!(data["schema"], "2.0");
assert_eq!(data["body"]["elements"][0]["text"]["content"], "hello");
}
#[test]
fn create_card_entity_rejects_invalid_response_card_id() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"data": { "card_id": "card/unsafe" }
}),
),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().text("hello").build().expect("card");
let error = block_on(client.create_card_entity(&card))
.expect_err("unsafe response card_id must fail");
assert!(matches!(error, Error::Validation(_)));
assert_eq!(transport.calls().len(), 2);
}
#[test]
fn update_card_entity_puts_sequence_uuid_and_card_json() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(200, json!({ "code": 0, "msg": "ok", "data": {} })),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().markdown("done").build().expect("card");
let card_id = CardId::new("7355372766134157313").expect("card id");
block_on(client.update_card_entity(
&card_id,
&card,
CardUpdateOptions::new(2).uuid("card-update-2"),
))
.expect("updated card entity");
let calls = transport.calls();
assert_eq!(calls[1].method, HttpMethod::Put);
assert_eq!(
calls[1].url.as_str(),
"https://open.feishu.cn/open-apis/cardkit/v1/cards/7355372766134157313"
);
assert_eq!(calls[1].body["sequence"], 2);
assert_eq!(calls[1].body["uuid"], "card-update-2");
assert_eq!(calls[1].body["card"]["type"], "card_json");
let data: Value =
serde_json::from_str(calls[1].body["card"]["data"].as_str().expect("data string"))
.expect("card json");
assert_eq!(data["body"]["elements"][0]["content"], "done");
}
#[test]
fn update_card_entity_rejects_invalid_sequence_before_authentication() {
let transport = FakeTransport::new(vec![]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().text("hello").build().expect("card");
let card_id = CardId::new("7355372766134157313").expect("card id");
let error = block_on(client.update_card_entity(&card_id, &card, CardUpdateOptions::new(0)))
.expect_err("zero sequence must fail");
assert!(matches!(
error,
Error::Validation(message) if message.contains("card update sequence")
));
assert!(transport.calls().is_empty());
}
#[test]
fn update_card_entity_rejects_invalid_uuid_before_authentication() {
let transport = FakeTransport::new(vec![]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card = Card::builder().text("hello").build().expect("card");
let card_id = CardId::new("7355372766134157313").expect("card id");
for uuid in [String::new(), "x".repeat(65)] {
let error = block_on(client.update_card_entity(
&card_id,
&card,
CardUpdateOptions::new(1).uuid(uuid),
))
.expect_err("invalid card update uuid must fail");
assert!(matches!(error, Error::Validation(_)));
}
assert!(transport.calls().is_empty());
}
#[test]
fn update_card_settings_patches_serialized_settings_and_options() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(200, json!({ "code": 0, "msg": "ok", "data": {} })),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card_id = CardId::new("7355372766134157313").expect("card id");
let settings = CardSettings::new()
.streaming_mode(false)
.summary("Finished");
block_on(client.update_card_settings(
&card_id,
&settings,
CardUpdateOptions::new(3).uuid("settings-3"),
))
.expect("updated card settings");
let calls = transport.calls();
assert_eq!(calls[1].method, HttpMethod::Patch);
assert_eq!(
calls[1].url.as_str(),
"https://open.feishu.cn/open-apis/cardkit/v1/cards/7355372766134157313/settings"
);
assert_eq!(calls[1].body["sequence"], 3);
assert_eq!(calls[1].body["uuid"], "settings-3");
let settings: Value =
serde_json::from_str(calls[1].body["settings"].as_str().expect("settings string"))
.expect("settings json");
assert_eq!(settings["config"]["streaming_mode"], false);
assert_eq!(settings["config"]["summary"]["content"], "Finished");
}
#[test]
fn update_card_element_content_puts_full_text_and_options() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(200, json!({ "code": 0, "msg": "ok", "data": {} })),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card_id = CardId::new("7355372766134157313").expect("card id");
block_on(client.update_card_element_content(
&card_id,
"stream_md",
"Full accumulated Markdown",
CardUpdateOptions::new(2).uuid("content-2"),
))
.expect("updated card element content");
let calls = transport.calls();
assert_eq!(calls[1].method, HttpMethod::Put);
assert_eq!(
calls[1].url.as_str(),
"https://open.feishu.cn/open-apis/cardkit/v1/cards/7355372766134157313/elements/stream_md/content"
);
assert_eq!(calls[1].body["content"], "Full accumulated Markdown");
assert_eq!(calls[1].body["sequence"], 2);
assert_eq!(calls[1].body["uuid"], "content-2");
}
#[test]
fn update_card_settings_rejects_empty_or_oversized_settings_before_authentication() {
let transport = FakeTransport::new(vec![]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card_id = CardId::new("7355372766134157313").expect("card id");
for settings in [
CardSettings::new(),
CardSettings::new().summary("x".repeat(MAX_CARD_SETTINGS_CHARS + 1)),
] {
let error = block_on(client.update_card_settings(
&card_id,
&settings,
CardUpdateOptions::new(1),
))
.expect_err("invalid settings must fail");
assert!(matches!(error, Error::Validation(_)));
}
assert!(transport.calls().is_empty());
}
#[test]
fn update_card_element_content_rejects_invalid_inputs_before_authentication() {
let transport = FakeTransport::new(vec![]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card_id = CardId::new("7355372766134157313").expect("card id");
for (element_id, content) in [
("1-invalid", "content".to_owned()),
("stream_md", String::new()),
("stream_md", "界".repeat(MAX_CARD_ELEMENT_CONTENT_CHARS + 1)),
] {
let error = block_on(client.update_card_element_content(
&card_id,
element_id,
&content,
CardUpdateOptions::new(1),
))
.expect_err("invalid element update must fail");
assert!(matches!(error, Error::Validation(_)));
}
assert!(transport.calls().is_empty());
}
#[test]
fn streaming_card_updates_accept_unicode_and_exact_documented_limits() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(200, json!({ "code": 0, "msg": "ok", "data": {} })),
HttpResponse::json(200, json!({ "code": 0, "msg": "ok", "data": {} })),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card_id = CardId::new("7355372766134157313").expect("card id");
let empty_settings = serde_json::to_string(&CardSettings::new().summary(""))
.expect("empty summary settings");
let settings = CardSettings::new()
.summary("界".repeat(MAX_CARD_SETTINGS_CHARS - empty_settings.chars().count()));
let serialized_settings = serde_json::to_string(&settings).expect("settings");
assert_eq!(serialized_settings.chars().count(), MAX_CARD_SETTINGS_CHARS);
let unicode_uuid = "界".repeat(MAX_CARD_UPDATE_UUID_CHARS);
block_on(client.update_card_settings(
&card_id,
&settings,
CardUpdateOptions::new(1).uuid(unicode_uuid.clone()),
))
.expect("exact-limit settings update");
block_on(client.update_card_element_content(
&card_id,
"stream_md",
&"界".repeat(MAX_CARD_ELEMENT_CONTENT_CHARS),
CardUpdateOptions::new(MAX_CARD_UPDATE_SEQUENCE),
))
.expect("exact-limit content update");
let calls = transport.calls();
assert_eq!(calls[1].body["uuid"], unicode_uuid);
assert_eq!(calls[1].body["sequence"], 1);
assert_eq!(calls[2].body["sequence"], MAX_CARD_UPDATE_SEQUENCE);
assert_eq!(
calls[2].body["content"]
.as_str()
.expect("content")
.chars()
.count(),
MAX_CARD_ELEMENT_CONTENT_CHARS
);
}
#[test]
fn streaming_card_updates_reject_invalid_options_before_authentication() {
let transport = FakeTransport::new(vec![]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let card_id = CardId::new("7355372766134157313").expect("card id");
let settings = CardSettings::new().streaming_mode(true);
let settings_error =
block_on(client.update_card_settings(&card_id, &settings, CardUpdateOptions::new(0)))
.expect_err("zero sequence must fail");
assert!(matches!(settings_error, Error::Validation(_)));
let content_error = block_on(client.update_card_element_content(
&card_id,
"stream_md",
"content",
CardUpdateOptions::new(1).uuid("界".repeat(MAX_CARD_UPDATE_UUID_CHARS + 1)),
))
.expect_err("oversized uuid must fail");
assert!(matches!(content_error, Error::Validation(_)));
assert!(transport.calls().is_empty());
}
#[test]
fn create_message_serializes_card_entity_reference() {
let transport = FakeTransport::new(vec![
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"tenant_access_token": "tenant-token-1",
"expire": 7200
}),
),
HttpResponse::json(
200,
json!({
"code": 0,
"msg": "ok",
"data": { "message_id": "om_card" }
}),
),
]);
let client = OpenApiClient::new(ChannelConfig::new("cli_a", "secret"), transport.clone());
let message_id = block_on(client.create_message(
Recipient::Chat("oc_123".to_owned()),
MessageContent::CardReference {
card_id: CardId::new("7355372766134157313").expect("card id"),
},
))
.expect("card reference message");
assert_eq!(message_id, MessageId("om_card".to_owned()));
let body = &transport.calls()[1].body;
assert_eq!(body["msg_type"], "interactive");
let content: Value =
serde_json::from_str(body["content"].as_str().expect("content string"))
.expect("card reference json");
assert_eq!(
content,
json!({ "type": "card", "data": { "card_id": "7355372766134157313" } })
);
}
}