@@ -332,16 +332,32 @@ describe('FeishuGateway - Message Parsing', () => {
332332 } ) ;
333333
334334 const fetchMock = vi . fn ( ) . mockImplementation ( async ( url : string ) => {
335- if ( url . includes ( '/merged_forward' ) ) {
335+ if ( url . includes ( '/tenant_access_token' ) ) {
336+ return mockFetchOk ( {
337+ tenant_access_token : 't-mock-token' ,
338+ expire : 7200 ,
339+ } ) ;
340+ }
341+ if ( url . includes ( '/im/v1/messages/' ) ) {
342+ // 飞书「获取指定消息内容」对 merge_forward 返回扁平化消息树:
343+ // 第一条是父消息(无 upper_message_id),其后子消息带 upper_message_id。
336344 return mockFetchOk ( {
337345 code : 0 ,
338346 msg : 'success' ,
339347 data : {
340348 items : [
349+ {
350+ message_id : 'om_merge_123' ,
351+ msg_type : 'merge_forward' ,
352+ body : { content : '{}' } ,
353+ create_time : '1615367850000' ,
354+ sender : { id : 'ou_root' , id_type : 'open_id' , sender_type : 'user' } ,
355+ } ,
341356 {
342357 message_id : 'om_sub_1' ,
343- message_type : 'text' ,
344- content : JSON . stringify ( { text : 'hello from sub1' } ) ,
358+ upper_message_id : 'om_merge_123' ,
359+ msg_type : 'text' ,
360+ body : { content : JSON . stringify ( { text : 'hello from sub1' } ) } ,
345361 create_time : '1615367851000' ,
346362 sender : {
347363 id : 'ou_user_1' ,
@@ -351,8 +367,9 @@ describe('FeishuGateway - Message Parsing', () => {
351367 } ,
352368 {
353369 message_id : 'om_sub_2' ,
354- message_type : 'file' ,
355- content : JSON . stringify ( { file_key : 'file_sub_2' , file_name : 'nested.zip' } ) ,
370+ upper_message_id : 'om_merge_123' ,
371+ msg_type : 'file' ,
372+ body : { content : JSON . stringify ( { file_key : 'file_sub_2' , file_name : 'nested.zip' } ) } ,
356373 create_time : '1615367852000' ,
357374 sender : {
358375 id : 'ou_user_2' ,
@@ -364,12 +381,6 @@ describe('FeishuGateway - Message Parsing', () => {
364381 } ,
365382 } ) ;
366383 }
367- if ( url . includes ( '/tenant_access_token' ) ) {
368- return mockFetchOk ( {
369- tenant_access_token : 't-mock-token' ,
370- expire : 7200 ,
371- } ) ;
372- }
373384 return mockFetchOk ( { code : 0 } ) ;
374385 } ) ;
375386
@@ -422,26 +433,42 @@ describe('FeishuGateway - Message Parsing', () => {
422433 } ) ;
423434
424435 const fetchMock = vi . fn ( ) . mockImplementation ( async ( url : string ) => {
425- if ( url . includes ( '/merged_forward' ) ) {
436+ if ( url . includes ( '/tenant_access_token' ) ) {
437+ return mockFetchOk ( {
438+ tenant_access_token : 't-mock-token' ,
439+ expire : 7200 ,
440+ } ) ;
441+ }
442+ if ( url . includes ( '/im/v1/messages/' ) ) {
426443 return mockFetchOk ( {
427444 code : 0 ,
428445 msg : 'success' ,
429446 data : {
430447 items : [
448+ {
449+ message_id : 'om_merge_125' ,
450+ msg_type : 'merge_forward' ,
451+ body : { content : '{}' } ,
452+ create_time : '1615367850000' ,
453+ sender : { id : 'ou_root' , id_type : 'open_id' , sender_type : 'user' } ,
454+ } ,
431455 {
432456 message_id : 'om_sub_post_1' ,
433- message_type : 'post' ,
434- content : JSON . stringify ( {
435- zh_cn : {
436- title : 'First post' ,
437- content : [
438- [
439- { tag : 'text' , text : 'Check first: ' } ,
440- { tag : 'img' , image_key : 'img_key_1' } ,
457+ upper_message_id : 'om_merge_125' ,
458+ msg_type : 'post' ,
459+ body : {
460+ content : JSON . stringify ( {
461+ zh_cn : {
462+ title : 'First post' ,
463+ content : [
464+ [
465+ { tag : 'text' , text : 'Check first: ' } ,
466+ { tag : 'img' , image_key : 'img_key_1' } ,
467+ ] ,
441468 ] ,
442- ] ,
443- } ,
444- } ) ,
469+ } ,
470+ } ) ,
471+ } ,
445472 create_time : '1615367851000' ,
446473 sender : {
447474 id : 'ou_user_1' ,
@@ -451,18 +478,21 @@ describe('FeishuGateway - Message Parsing', () => {
451478 } ,
452479 {
453480 message_id : 'om_sub_post_2' ,
454- message_type : 'post' ,
455- content : JSON . stringify ( {
456- zh_cn : {
457- title : 'Second post' ,
458- content : [
459- [
460- { tag : 'text' , text : 'Check second: ' } ,
461- { tag : 'img' , image_key : 'img_key_2' } ,
481+ upper_message_id : 'om_merge_125' ,
482+ msg_type : 'post' ,
483+ body : {
484+ content : JSON . stringify ( {
485+ zh_cn : {
486+ title : 'Second post' ,
487+ content : [
488+ [
489+ { tag : 'text' , text : 'Check second: ' } ,
490+ { tag : 'img' , image_key : 'img_key_2' } ,
491+ ] ,
462492 ] ,
463- ] ,
464- } ,
465- } ) ,
493+ } ,
494+ } ) ,
495+ } ,
466496 create_time : '1615367852000' ,
467497 sender : {
468498 id : 'ou_user_2' ,
@@ -474,12 +504,6 @@ describe('FeishuGateway - Message Parsing', () => {
474504 } ,
475505 } ) ;
476506 }
477- if ( url . includes ( '/tenant_access_token' ) ) {
478- return mockFetchOk ( {
479- tenant_access_token : 't-mock-token' ,
480- expire : 7200 ,
481- } ) ;
482- }
483507 return mockFetchOk ( { code : 0 } ) ;
484508 } ) ;
485509
@@ -524,7 +548,7 @@ describe('FeishuGateway - Message Parsing', () => {
524548 expect ( receivedMsg . text ) . toContain ( '[图片_2]' ) ;
525549 } ) ;
526550
527- it ( 'correctly reports error when fetching merged_forward fails' , async ( ) => {
551+ it ( 'correctly reports error when fetching merge_forward sub-messages fails' , async ( ) => {
528552 await gateway . connect ( ) ;
529553
530554 const mockFetchError = ( body : any ) => ( {
@@ -533,18 +557,18 @@ describe('FeishuGateway - Message Parsing', () => {
533557 } ) ;
534558
535559 const fetchMock = vi . fn ( ) . mockImplementation ( async ( url : string ) => {
536- if ( url . includes ( '/merged_forward' ) ) {
537- return mockFetchError ( {
538- code : 99991403 ,
539- msg : 'No permission for merged_forward API' ,
540- } ) ;
541- }
542560 if ( url . includes ( '/tenant_access_token' ) ) {
543561 return mockFetchError ( {
544562 tenant_access_token : 't-mock-token' ,
545563 expire : 7200 ,
546564 } ) ;
547565 }
566+ if ( url . includes ( '/im/v1/messages/' ) ) {
567+ return mockFetchError ( {
568+ code : 230002 ,
569+ msg : 'Bot has no permission to access the message' ,
570+ } ) ;
571+ }
548572 return mockFetchError ( { code : 0 } ) ;
549573 } ) ;
550574
@@ -577,7 +601,98 @@ describe('FeishuGateway - Message Parsing', () => {
577601
578602 expect ( receivedMsg ) . not . toBeNull ( ) ;
579603 expect ( receivedMsg . messageType ) . toBe ( 'merge_forward' ) ;
580- expect ( receivedMsg . text ) . toContain ( '原因: 飞书接口返回错误 (code: 99991403): No permission for merged_forward API' ) ;
604+ expect ( receivedMsg . text ) . toContain ( '原因: 飞书接口返回错误 (code: 230002): Bot has no permission to access the message' ) ;
605+ } ) ;
606+
607+ it ( 'recursively expands nested merge_forward sub-messages using upper_message_id tree' , async ( ) => {
608+ await gateway . connect ( ) ;
609+
610+ const mockFetchOk = ( body : any ) => ( {
611+ ok : true ,
612+ json : async ( ) => body ,
613+ } ) ;
614+
615+ // 扁平树:root -> (text A) 和 (nested merge_forward) -> (text B 挂在 nested 下)
616+ const fetchMock = vi . fn ( ) . mockImplementation ( async ( url : string ) => {
617+ if ( url . includes ( '/tenant_access_token' ) ) {
618+ return mockFetchOk ( { tenant_access_token : 't-mock-token' , expire : 7200 } ) ;
619+ }
620+ if ( url . includes ( '/im/v1/messages/' ) ) {
621+ return mockFetchOk ( {
622+ code : 0 ,
623+ msg : 'success' ,
624+ data : {
625+ items : [
626+ {
627+ message_id : 'om_root' ,
628+ msg_type : 'merge_forward' ,
629+ body : { content : '{}' } ,
630+ create_time : '1615367850000' ,
631+ sender : { id : 'ou_root' , id_type : 'open_id' , sender_type : 'user' } ,
632+ } ,
633+ {
634+ message_id : 'om_textA' ,
635+ upper_message_id : 'om_root' ,
636+ msg_type : 'text' ,
637+ body : { content : JSON . stringify ( { text : 'top level A' } ) } ,
638+ create_time : '1615367851000' ,
639+ sender : { id : 'ou_a' , id_type : 'open_id' , sender_type : 'user' } ,
640+ } ,
641+ {
642+ message_id : 'om_nested' ,
643+ upper_message_id : 'om_root' ,
644+ msg_type : 'merge_forward' ,
645+ body : { content : '{}' } ,
646+ create_time : '1615367852000' ,
647+ sender : { id : 'ou_nest' , id_type : 'open_id' , sender_type : 'user' } ,
648+ } ,
649+ {
650+ message_id : 'om_textB' ,
651+ upper_message_id : 'om_nested' ,
652+ msg_type : 'text' ,
653+ body : { content : JSON . stringify ( { text : 'deep nested B' } ) } ,
654+ create_time : '1615367853000' ,
655+ sender : { id : 'ou_b' , id_type : 'open_id' , sender_type : 'user' } ,
656+ } ,
657+ ] ,
658+ } ,
659+ } ) ;
660+ }
661+ return mockFetchOk ( { code : 0 } ) ;
662+ } ) ;
663+
664+ vi . stubGlobal ( 'fetch' , fetchMock ) ;
665+
666+ const mockEvent = {
667+ event : {
668+ message : {
669+ message_id : 'om_root' ,
670+ message_type : 'merge_forward' ,
671+ content : 'Nested merged' ,
672+ chat_id : 'oc_456' ,
673+ chat_type : 'p2p' ,
674+ } ,
675+ sender : { sender_id : { open_id : 'ou_789' } } ,
676+ } ,
677+ } ;
678+
679+ let receivedMsg : any = null ;
680+ gateway . onMessage = async ( msg ) => {
681+ receivedMsg = msg ;
682+ return null ;
683+ } ;
684+
685+ await messageCallback ( mockEvent ) ;
686+
687+ expect ( receivedMsg ) . not . toBeNull ( ) ;
688+ expect ( receivedMsg . messageType ) . toBe ( 'merge_forward' ) ;
689+ // 顶层文本与深层嵌套文本都应被展开
690+ expect ( receivedMsg . text ) . toContain ( 'top level A' ) ;
691+ expect ( receivedMsg . text ) . toContain ( 'deep nested B' ) ;
692+ // 深层文本应有更深的缩进(嵌套渲染)
693+ const lines = receivedMsg . text . split ( '\n' ) ;
694+ const deepLine = lines . find ( ( l : string ) => l . includes ( 'deep nested B' ) ) ;
695+ expect ( deepLine . startsWith ( ' ' ) ) . toBe ( true ) ;
581696 } ) ;
582697} ) ;
583698
0 commit comments