@@ -356,6 +356,217 @@ describe("feishuPlugin actions", () => {
356356 expect ( details . chatId ) . toBe ( "oc_group_1" ) ;
357357 } ) ;
358358
359+ it ( "sends plain message card JSON as a native Feishu card" , async ( ) => {
360+ sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
361+
362+ const result = await feishuPlugin . actions ?. handleAction ?.( {
363+ action : "send" ,
364+ params : {
365+ to : "chat:oc_group_1" ,
366+ message : JSON . stringify ( {
367+ schema : "2.0" ,
368+ header : {
369+ title : { tag : "plain_text" , content : "Plain JSON card" } ,
370+ template : "green" ,
371+ } ,
372+ body : {
373+ elements : [ { tag : "markdown" , content : "Card body" } ] ,
374+ } ,
375+ } ) ,
376+ } ,
377+ cfg,
378+ accountId : undefined ,
379+ toolContext : { } ,
380+ } as never ) ;
381+
382+ const sendCardArgs = requireRecord (
383+ mockCallArg ( sendCardFeishuMock , 0 , 0 , "sendCardFeishu" ) ,
384+ "send card args" ,
385+ ) ;
386+ expect ( sendCardArgs . cfg ) . toBe ( cfg ) ;
387+ expect ( sendCardArgs . to ) . toBe ( "chat:oc_group_1" ) ;
388+ expect ( sendCardArgs . accountId ) . toBeUndefined ( ) ;
389+ expect ( sendCardArgs . replyToMessageId ) . toBeUndefined ( ) ;
390+ expect ( sendCardArgs . replyInThread ) . toBe ( false ) ;
391+ const card = requireRecord ( sendCardArgs . card , "card" ) ;
392+ expect ( card . header ) . toEqual ( {
393+ title : { tag : "plain_text" , content : "Plain JSON card" } ,
394+ template : "green" ,
395+ } ) ;
396+ expect ( card . body ) . toEqual ( {
397+ elements : [ { tag : "markdown" , content : "Card body" } ] ,
398+ } ) ;
399+ expect ( sendMessageFeishuMock ) . not . toHaveBeenCalled ( ) ;
400+ const details = resultDetails ( result ) ;
401+ expect ( details . ok ) . toBe ( true ) ;
402+ expect ( details . messageId ) . toBe ( "om_card" ) ;
403+ expect ( details . chatId ) . toBe ( "oc_group_1" ) ;
404+ } ) ;
405+
406+ it ( "sends legacy top-level elements card JSON as a native Feishu card" , async ( ) => {
407+ sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
408+
409+ await feishuPlugin . actions ?. handleAction ?.( {
410+ action : "send" ,
411+ params : {
412+ to : "chat:oc_group_1" ,
413+ message : JSON . stringify ( {
414+ header : {
415+ title : { tag : "plain_text" , content : "Legacy JSON card" } ,
416+ template : "green" ,
417+ } ,
418+ elements : [
419+ {
420+ tag : "div" ,
421+ text : { tag : "lark_md" , content : '**Legacy** <at id="ou_1">body</at>' } ,
422+ } ,
423+ {
424+ tag : "div" ,
425+ text : { tag : "plain_text" , content : "Literal *text*" } ,
426+ } ,
427+ ] ,
428+ } ) ,
429+ } ,
430+ cfg,
431+ accountId : undefined ,
432+ toolContext : { } ,
433+ } as never ) ;
434+
435+ const sendCardArgs = requireRecord (
436+ mockCallArg ( sendCardFeishuMock , 0 , 0 , "sendCardFeishu" ) ,
437+ "send card args" ,
438+ ) ;
439+ const card = requireRecord ( sendCardArgs . card , "card" ) ;
440+ expect ( card . header ) . toEqual ( {
441+ title : { tag : "plain_text" , content : "Legacy JSON card" } ,
442+ template : "green" ,
443+ } ) ;
444+ expect ( card . body ) . toEqual ( {
445+ elements : [
446+ {
447+ tag : "markdown" ,
448+ content : '**Legacy** <at id="ou_1">body</at>' ,
449+ } ,
450+ { tag : "markdown" , content : "Literal \\*text\\*" } ,
451+ ] ,
452+ } ) ;
453+ expect ( sendMessageFeishuMock ) . not . toHaveBeenCalled ( ) ;
454+ } ) ;
455+
456+ it ( "detects message card JSON after the configured response prefix" , async ( ) => {
457+ sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
458+ const cardJson = JSON . stringify ( {
459+ body : {
460+ elements : [ { tag : "markdown" , content : "Prefixed card" } ] ,
461+ } ,
462+ } ) ;
463+
464+ await feishuPlugin . actions ?. handleAction ?.( {
465+ action : "send" ,
466+ params : {
467+ to : "chat:oc_group_1" ,
468+ message : `[Nexus] ${ cardJson } ` ,
469+ } ,
470+ cfg : {
471+ ...cfg ,
472+ messages : { responsePrefix : "[Nexus]" } ,
473+ } ,
474+ accountId : undefined ,
475+ toolContext : { } ,
476+ } as never ) ;
477+
478+ const sendCardArgs = requireRecord (
479+ mockCallArg ( sendCardFeishuMock , 0 , 0 , "sendCardFeishu" ) ,
480+ "send card args" ,
481+ ) ;
482+ const card = requireRecord ( sendCardArgs . card , "card" ) ;
483+ expect ( card . body ) . toEqual ( {
484+ elements : [ { tag : "markdown" , content : "Prefixed card" } ] ,
485+ } ) ;
486+ expect ( sendMessageFeishuMock ) . not . toHaveBeenCalled ( ) ;
487+ } ) ;
488+
489+ it ( "sends wrapped interactive card JSON as a Feishu thread reply card" , async ( ) => {
490+ sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
491+
492+ await feishuPlugin . actions ?. handleAction ?.( {
493+ action : "thread-reply" ,
494+ params : {
495+ to : "chat:oc_group_1" ,
496+ messageId : "om_parent" ,
497+ text : JSON . stringify ( {
498+ type : "interactive" ,
499+ card : {
500+ body : {
501+ elements : [ { tag : "markdown" , content : "Reply card" } ] ,
502+ } ,
503+ } ,
504+ } ) ,
505+ } ,
506+ cfg,
507+ accountId : undefined ,
508+ toolContext : { } ,
509+ } as never ) ;
510+
511+ const sendCardArgs = requireRecord (
512+ mockCallArg ( sendCardFeishuMock , 0 , 0 , "sendCardFeishu" ) ,
513+ "send card args" ,
514+ ) ;
515+ expect ( sendCardArgs . replyToMessageId ) . toBe ( "om_parent" ) ;
516+ expect ( sendCardArgs . replyInThread ) . toBe ( true ) ;
517+ const card = requireRecord ( sendCardArgs . card , "card" ) ;
518+ expect ( card . body ) . toEqual ( {
519+ elements : [ { tag : "markdown" , content : "Reply card" } ] ,
520+ } ) ;
521+ expect ( sendMessageFeishuMock ) . not . toHaveBeenCalled ( ) ;
522+ } ) ;
523+
524+ it ( "keeps ordinary JSON messages on the text path" , async ( ) => {
525+ sendMessageFeishuMock . mockResolvedValueOnce ( { messageId : "om_sent" , chatId : "oc_group_1" } ) ;
526+ const message = JSON . stringify ( { ok : true , elements : "not-a-card" } ) ;
527+
528+ await feishuPlugin . actions ?. handleAction ?.( {
529+ action : "send" ,
530+ params : { to : "chat:oc_group_1" , message } ,
531+ cfg,
532+ accountId : undefined ,
533+ toolContext : { } ,
534+ } as never ) ;
535+
536+ expect ( sendCardFeishuMock ) . not . toHaveBeenCalled ( ) ;
537+ expect ( sendMessageFeishuMock ) . toHaveBeenCalledWith ( {
538+ cfg,
539+ to : "chat:oc_group_1" ,
540+ text : message ,
541+ accountId : undefined ,
542+ replyToMessageId : undefined ,
543+ replyInThread : false ,
544+ } ) ;
545+ } ) ;
546+
547+ it ( "rejects card JSON sent with media" , async ( ) => {
548+ await expect (
549+ feishuPlugin . actions ?. handleAction ?.( {
550+ action : "send" ,
551+ params : {
552+ to : "chat:oc_group_1" ,
553+ message : JSON . stringify ( {
554+ elements : [ { tag : "markdown" , content : "Card body" } ] ,
555+ } ) ,
556+ media : "/tmp/image.png" ,
557+ } ,
558+ cfg,
559+ accountId : undefined ,
560+ toolContext : { } ,
561+ mediaLocalRoots : [ "/tmp" ] ,
562+ } as never ) ,
563+ ) . rejects . toThrow ( "Feishu send does not support card with media." ) ;
564+
565+ expect ( sendCardFeishuMock ) . not . toHaveBeenCalled ( ) ;
566+ expect ( feishuOutboundSendMediaMock ) . not . toHaveBeenCalled ( ) ;
567+ expect ( sendMessageFeishuMock ) . not . toHaveBeenCalled ( ) ;
568+ } ) ;
569+
359570 it ( "renders presentation messages as cards" , async ( ) => {
360571 sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
361572
@@ -402,6 +613,72 @@ describe("feishuPlugin actions", () => {
402613 expect ( details . chatId ) . toBe ( "oc_group_1" ) ;
403614 } ) ;
404615
616+ it ( "prefers structured presentation over raw card JSON text" , async ( ) => {
617+ sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
618+
619+ await feishuPlugin . actions ?. handleAction ?.( {
620+ action : "send" ,
621+ params : {
622+ to : "chat:oc_group_1" ,
623+ message : JSON . stringify ( {
624+ header : { title : { tag : "plain_text" , content : "Raw card" } } ,
625+ elements : [ { tag : "markdown" , content : "Raw body" } ] ,
626+ } ) ,
627+ presentation : {
628+ title : "Structured card" ,
629+ blocks : [ { type : "text" , text : "Structured body" } ] ,
630+ } ,
631+ } ,
632+ cfg,
633+ accountId : undefined ,
634+ toolContext : { } ,
635+ } as never ) ;
636+
637+ const sendCardArgs = requireRecord (
638+ mockCallArg ( sendCardFeishuMock , 0 , 0 , "sendCardFeishu" ) ,
639+ "send card args" ,
640+ ) ;
641+ const card = requireRecord ( sendCardArgs . card , "card" ) ;
642+ expect ( card . header ) . toEqual ( {
643+ title : { tag : "plain_text" , content : "Structured card" } ,
644+ template : "blue" ,
645+ } ) ;
646+ expect ( card . body ) . toEqual ( {
647+ elements : [ { tag : "markdown" , content : "Structured body" } ] ,
648+ } ) ;
649+ } ) ;
650+
651+ it ( "prefers structured interactive input over raw card JSON text" , async ( ) => {
652+ sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
653+
654+ await feishuPlugin . actions ?. handleAction ?.( {
655+ action : "send" ,
656+ params : {
657+ to : "chat:oc_group_1" ,
658+ message : JSON . stringify ( {
659+ header : { title : { tag : "plain_text" , content : "Raw card" } } ,
660+ elements : [ { tag : "markdown" , content : "Raw body" } ] ,
661+ } ) ,
662+ interactive : {
663+ blocks : [ { type : "text" , text : "Interactive body" } ] ,
664+ } ,
665+ } ,
666+ cfg,
667+ accountId : undefined ,
668+ toolContext : { } ,
669+ } as never ) ;
670+
671+ const sendCardArgs = requireRecord (
672+ mockCallArg ( sendCardFeishuMock , 0 , 0 , "sendCardFeishu" ) ,
673+ "send card args" ,
674+ ) ;
675+ const card = requireRecord ( sendCardArgs . card , "card" ) ;
676+ expect ( card . header ) . toBeUndefined ( ) ;
677+ expect ( card . body ) . toEqual ( {
678+ elements : [ { tag : "markdown" , content : "Interactive body" } ] ,
679+ } ) ;
680+ } ) ;
681+
405682 it ( "renders presentation buttons as native Feishu card buttons" , async ( ) => {
406683 sendCardFeishuMock . mockResolvedValueOnce ( { messageId : "om_card" , chatId : "oc_group_1" } ) ;
407684
0 commit comments