Skip to content

Commit a181230

Browse files
luikorewebhive
authored andcommitted
Make lark / feishu render markdown (zeroclaw-labs#3866)
Co-authored-by: Luikore <masked>
1 parent 011f824 commit a181230

1 file changed

Lines changed: 157 additions & 21 deletions

File tree

src/channels/lark.rs

Lines changed: 157 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,88 @@ const LARK_DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);
217217
/// Feishu/Lark API business code for expired/invalid tenant access token.
218218
const LARK_INVALID_ACCESS_TOKEN_CODE: i64 = 99_991_663;
219219

220+
/// Max byte size for a single interactive card's markdown content.
221+
/// Lark card payloads have a ~30 KB limit; leave margin for JSON envelope.
222+
const LARK_CARD_MARKDOWN_MAX_BYTES: usize = 28_000;
223+
220224
/// Returns true when the WebSocket frame indicates live traffic that should
221225
/// refresh the heartbeat watchdog.
222226
fn should_refresh_last_recv(msg: &WsMsg) -> bool {
223227
matches!(msg, WsMsg::Binary(_) | WsMsg::Ping(_) | WsMsg::Pong(_))
224228
}
225229

230+
/// Build an interactive card JSON string with a single markdown element.
231+
/// Uses Card JSON 2.0 structure so that headings, tables, blockquotes,
232+
/// and inline code render correctly.
233+
fn build_card_content(markdown: &str) -> String {
234+
serde_json::json!({
235+
"schema": "2.0",
236+
"body": {
237+
"elements": [{
238+
"tag": "markdown",
239+
"content": markdown
240+
}]
241+
}
242+
})
243+
.to_string()
244+
}
245+
246+
/// Build the full message body for sending an interactive card message.
247+
fn build_interactive_card_body(recipient: &str, markdown: &str) -> serde_json::Value {
248+
serde_json::json!({
249+
"receive_id": recipient,
250+
"msg_type": "interactive",
251+
"content": build_card_content(markdown),
252+
})
253+
}
254+
255+
/// Split markdown content into chunks that fit within the card size limit.
256+
/// Splits on line boundaries to avoid breaking markdown syntax.
257+
fn split_markdown_chunks(text: &str, max_bytes: usize) -> Vec<&str> {
258+
if text.len() <= max_bytes {
259+
return vec![text];
260+
}
261+
262+
let mut chunks = Vec::new();
263+
let mut start = 0;
264+
265+
while start < text.len() {
266+
if start + max_bytes >= text.len() {
267+
chunks.push(&text[start..]);
268+
break;
269+
}
270+
271+
let end = start + max_bytes;
272+
let search_region = &text[start..end];
273+
let split_at = search_region
274+
.rfind('\n')
275+
.map(|pos| start + pos + 1)
276+
.unwrap_or(end);
277+
278+
let split_at = if text.is_char_boundary(split_at) {
279+
split_at
280+
} else {
281+
(start..split_at)
282+
.rev()
283+
.find(|&i| text.is_char_boundary(i))
284+
.unwrap_or(start)
285+
};
286+
287+
if split_at <= start {
288+
let forced = (end..=text.len())
289+
.find(|&i| text.is_char_boundary(i))
290+
.unwrap_or(text.len());
291+
chunks.push(&text[start..forced]);
292+
start = forced;
293+
} else {
294+
chunks.push(&text[start..split_at]);
295+
start = split_at;
296+
}
297+
}
298+
299+
chunks
300+
}
301+
226302
#[derive(Debug, Clone)]
227303
struct CachedTenantToken {
228304
value: String,
@@ -1138,33 +1214,31 @@ impl Channel for LarkChannel {
11381214
let token = self.get_tenant_access_token().await?;
11391215
let url = self.send_message_url();
11401216

1141-
let content = serde_json::json!({ "text": message.content }).to_string();
1142-
let body = serde_json::json!({
1143-
"receive_id": message.recipient,
1144-
"msg_type": "text",
1145-
"content": content,
1146-
});
1217+
let chunks = split_markdown_chunks(&message.content, LARK_CARD_MARKDOWN_MAX_BYTES);
1218+
for chunk in &chunks {
1219+
let body = build_interactive_card_body(&message.recipient, chunk);
11471220

1148-
let (status, response) = self.send_text_once(&url, &token, &body).await?;
1221+
let (status, response) = self.send_text_once(&url, &token, &body).await?;
11491222

1150-
if should_refresh_lark_tenant_token(status, &response) {
1151-
// Token expired/invalid, invalidate and retry once.
1152-
self.invalidate_token().await;
1153-
let new_token = self.get_tenant_access_token().await?;
1154-
let (retry_status, retry_response) =
1155-
self.send_text_once(&url, &new_token, &body).await?;
1223+
if should_refresh_lark_tenant_token(status, &response) {
1224+
// Token expired/invalid, invalidate and retry once.
1225+
self.invalidate_token().await;
1226+
let new_token = self.get_tenant_access_token().await?;
1227+
let (retry_status, retry_response) =
1228+
self.send_text_once(&url, &new_token, &body).await?;
1229+
1230+
if should_refresh_lark_tenant_token(retry_status, &retry_response) {
1231+
anyhow::bail!(
1232+
"Lark send failed after token refresh: status={retry_status}, body={retry_response}"
1233+
);
1234+
}
11561235

1157-
if should_refresh_lark_tenant_token(retry_status, &retry_response) {
1158-
anyhow::bail!(
1159-
"Lark send failed after token refresh: status={retry_status}, body={retry_response}"
1160-
);
1236+
ensure_lark_send_success(retry_status, &retry_response, "after token refresh")?;
1237+
} else {
1238+
ensure_lark_send_success(status, &response, "without token refresh")?;
11611239
}
1162-
1163-
ensure_lark_send_success(retry_status, &retry_response, "after token refresh")?;
1164-
return Ok(());
11651240
}
11661241

1167-
ensure_lark_send_success(status, &response, "without token refresh")?;
11681242
Ok(())
11691243
}
11701244

@@ -2416,4 +2490,66 @@ mod tests {
24162490
let selected = random_lark_ack_reaction(Some(&payload), "hello");
24172491
assert!(LARK_ACK_REACTIONS_JA.contains(&selected));
24182492
}
2493+
2494+
#[test]
2495+
fn build_interactive_card_body_produces_correct_structure() {
2496+
let body = build_interactive_card_body("oc_chat123", "**Hello** world");
2497+
assert_eq!(body["receive_id"], "oc_chat123");
2498+
assert_eq!(body["msg_type"], "interactive");
2499+
2500+
let content: serde_json::Value =
2501+
serde_json::from_str(body["content"].as_str().unwrap()).unwrap();
2502+
assert_eq!(content["schema"], "2.0");
2503+
let elements = content["body"]["elements"].as_array().unwrap();
2504+
assert_eq!(elements.len(), 1);
2505+
assert_eq!(elements[0]["tag"], "markdown");
2506+
assert_eq!(elements[0]["content"], "**Hello** world");
2507+
}
2508+
2509+
#[test]
2510+
fn build_card_content_produces_valid_json() {
2511+
let content = build_card_content("# Title\n\n**Bold** text");
2512+
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2513+
assert_eq!(parsed["schema"], "2.0");
2514+
assert_eq!(parsed["body"]["elements"][0]["tag"], "markdown");
2515+
assert_eq!(
2516+
parsed["body"]["elements"][0]["content"],
2517+
"# Title\n\n**Bold** text"
2518+
);
2519+
}
2520+
2521+
#[test]
2522+
fn split_markdown_chunks_single_chunk_for_small_content() {
2523+
let text = "Hello world";
2524+
let chunks = split_markdown_chunks(text, LARK_CARD_MARKDOWN_MAX_BYTES);
2525+
assert_eq!(chunks, vec!["Hello world"]);
2526+
}
2527+
2528+
#[test]
2529+
fn split_markdown_chunks_splits_on_newline_boundaries() {
2530+
let line = "abcdefghij\n"; // 11 bytes per line
2531+
let text = line.repeat(10); // 110 bytes total
2532+
let chunks = split_markdown_chunks(&text, 33); // ~3 lines per chunk
2533+
assert_eq!(chunks.len(), 4);
2534+
for chunk in &chunks[..3] {
2535+
assert!(chunk.len() <= 33);
2536+
assert!(chunk.ends_with('\n'));
2537+
}
2538+
}
2539+
2540+
#[test]
2541+
fn split_markdown_chunks_handles_no_newlines() {
2542+
let text = "a".repeat(100);
2543+
let chunks = split_markdown_chunks(&text, 30);
2544+
assert!(chunks.len() > 1);
2545+
let reassembled: String = chunks.concat();
2546+
assert_eq!(reassembled, text);
2547+
}
2548+
2549+
#[test]
2550+
fn split_markdown_chunks_exact_boundary() {
2551+
let text = "abc";
2552+
let chunks = split_markdown_chunks(text, 3);
2553+
assert_eq!(chunks, vec!["abc"]);
2554+
}
24192555
}

0 commit comments

Comments
 (0)