@@ -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.
218218const 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.
222226fn 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 ) ]
227303struct 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