@@ -322,6 +322,378 @@ describe('FeishuGateway - Message Parsing', () => {
322322 expect ( receivedMsg . pendingImages [ 0 ] . imageKey ) . toBe ( 'img_v2_123' ) ;
323323 expect ( receivedMsg . pendingImages [ 0 ] . placeholder ) . toBe ( '[图片_1]' ) ;
324324 } ) ;
325+
326+ it ( 'correctly parses merge_forward message and extracts nested sub-messages' , async ( ) => {
327+ await gateway . connect ( ) ;
328+
329+ const mockFetchOk = ( body : any ) => ( {
330+ ok : true ,
331+ json : async ( ) => body ,
332+ } ) ;
333+
334+ const fetchMock = vi . fn ( ) . mockImplementation ( async ( url : string ) => {
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。
344+ return mockFetchOk ( {
345+ code : 0 ,
346+ msg : 'success' ,
347+ data : {
348+ 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+ } ,
356+ {
357+ message_id : 'om_sub_1' ,
358+ upper_message_id : 'om_merge_123' ,
359+ msg_type : 'text' ,
360+ body : { content : JSON . stringify ( { text : 'hello from sub1' } ) } ,
361+ create_time : '1615367851000' ,
362+ sender : {
363+ id : 'ou_user_1' ,
364+ id_type : 'open_id' ,
365+ sender_type : 'user' ,
366+ } ,
367+ } ,
368+ {
369+ message_id : 'om_sub_2' ,
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' } ) } ,
373+ create_time : '1615367852000' ,
374+ sender : {
375+ id : 'ou_user_2' ,
376+ id_type : 'open_id' ,
377+ sender_type : 'user' ,
378+ } ,
379+ } ,
380+ ] ,
381+ } ,
382+ } ) ;
383+ }
384+ return mockFetchOk ( { code : 0 } ) ;
385+ } ) ;
386+
387+ vi . stubGlobal ( 'fetch' , fetchMock ) ;
388+
389+ const mockEvent = {
390+ event : {
391+ message : {
392+ message_id : 'om_merge_123' ,
393+ message_type : 'merge_forward' ,
394+ content : 'Merged and Forwarded Message' ,
395+ chat_id : 'oc_456' ,
396+ chat_type : 'p2p' ,
397+ } ,
398+ sender : {
399+ sender_id : {
400+ open_id : 'ou_789' ,
401+ } ,
402+ } ,
403+ } ,
404+ } ;
405+
406+ let receivedMsg : any = null ;
407+ gateway . onMessage = async ( msg ) => {
408+ receivedMsg = msg ;
409+ return null ;
410+ } ;
411+
412+ await messageCallback ( mockEvent ) ;
413+
414+ expect ( receivedMsg ) . not . toBeNull ( ) ;
415+ expect ( receivedMsg . messageType ) . toBe ( 'merge_forward' ) ;
416+ expect ( receivedMsg . text ) . toContain ( '[合并转发的消息记录]' ) ;
417+ expect ( receivedMsg . text ) . toContain ( 'ou_user_1' ) ;
418+ expect ( receivedMsg . text ) . toContain ( 'hello from sub1' ) ;
419+ expect ( receivedMsg . text ) . toContain ( 'ou_user_2' ) ;
420+ expect ( receivedMsg . text ) . toContain ( '[文件消息: nested.zip]' ) ;
421+ expect ( receivedMsg . pendingFiles ) . toBeDefined ( ) ;
422+ expect ( receivedMsg . pendingFiles ) . toHaveLength ( 1 ) ;
423+ expect ( receivedMsg . pendingFiles [ 0 ] . fileKey ) . toBe ( 'file_sub_2' ) ;
424+ expect ( receivedMsg . pendingFiles [ 0 ] . fileName ) . toBe ( 'nested.zip' ) ;
425+ } ) ;
426+
427+ it ( 'correctly generates unique placeholders for multiple rich-text images across sub-messages within merge_forward' , async ( ) => {
428+ await gateway . connect ( ) ;
429+
430+ const mockFetchOk = ( body : any ) => ( {
431+ ok : true ,
432+ json : async ( ) => body ,
433+ } ) ;
434+
435+ const fetchMock = vi . fn ( ) . mockImplementation ( async ( url : string ) => {
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/' ) ) {
443+ return mockFetchOk ( {
444+ code : 0 ,
445+ msg : 'success' ,
446+ data : {
447+ 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+ } ,
455+ {
456+ message_id : 'om_sub_post_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+ ] ,
468+ ] ,
469+ } ,
470+ } ) ,
471+ } ,
472+ create_time : '1615367851000' ,
473+ sender : {
474+ id : 'ou_user_1' ,
475+ id_type : 'open_id' ,
476+ sender_type : 'user' ,
477+ } ,
478+ } ,
479+ {
480+ message_id : 'om_sub_post_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+ ] ,
492+ ] ,
493+ } ,
494+ } ) ,
495+ } ,
496+ create_time : '1615367852000' ,
497+ sender : {
498+ id : 'ou_user_2' ,
499+ id_type : 'open_id' ,
500+ sender_type : 'user' ,
501+ } ,
502+ } ,
503+ ] ,
504+ } ,
505+ } ) ;
506+ }
507+ return mockFetchOk ( { code : 0 } ) ;
508+ } ) ;
509+
510+ vi . stubGlobal ( 'fetch' , fetchMock ) ;
511+
512+ const mockEvent = {
513+ event : {
514+ message : {
515+ message_id : 'om_merge_125' ,
516+ message_type : 'merge_forward' ,
517+ content : 'Merged posts with images' ,
518+ chat_id : 'oc_456' ,
519+ chat_type : 'p2p' ,
520+ } ,
521+ sender : {
522+ sender_id : {
523+ open_id : 'ou_789' ,
524+ } ,
525+ } ,
526+ } ,
527+ } ;
528+
529+ let receivedMsg : any = null ;
530+ gateway . onMessage = async ( msg ) => {
531+ receivedMsg = msg ;
532+ return null ;
533+ } ;
534+
535+ await messageCallback ( mockEvent ) ;
536+
537+ expect ( receivedMsg ) . not . toBeNull ( ) ;
538+ expect ( receivedMsg . messageType ) . toBe ( 'merge_forward' ) ;
539+
540+ expect ( receivedMsg . pendingImages ) . toBeDefined ( ) ;
541+ expect ( receivedMsg . pendingImages ) . toHaveLength ( 2 ) ;
542+ expect ( receivedMsg . pendingImages [ 0 ] . imageKey ) . toBe ( 'img_key_1' ) ;
543+ expect ( receivedMsg . pendingImages [ 0 ] . placeholder ) . toBe ( '[图片_1]' ) ;
544+ expect ( receivedMsg . pendingImages [ 1 ] . imageKey ) . toBe ( 'img_key_2' ) ;
545+ expect ( receivedMsg . pendingImages [ 1 ] . placeholder ) . toBe ( '[图片_2]' ) ;
546+
547+ expect ( receivedMsg . text ) . toContain ( '[图片_1]' ) ;
548+ expect ( receivedMsg . text ) . toContain ( '[图片_2]' ) ;
549+ } ) ;
550+
551+ it ( 'correctly reports error when fetching merge_forward sub-messages fails' , async ( ) => {
552+ await gateway . connect ( ) ;
553+
554+ const mockFetchError = ( body : any ) => ( {
555+ ok : true ,
556+ json : async ( ) => body ,
557+ } ) ;
558+
559+ const fetchMock = vi . fn ( ) . mockImplementation ( async ( url : string ) => {
560+ if ( url . includes ( '/tenant_access_token' ) ) {
561+ return mockFetchError ( {
562+ tenant_access_token : 't-mock-token' ,
563+ expire : 7200 ,
564+ } ) ;
565+ }
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+ }
572+ return mockFetchError ( { code : 0 } ) ;
573+ } ) ;
574+
575+ vi . stubGlobal ( 'fetch' , fetchMock ) ;
576+
577+ const mockEvent = {
578+ event : {
579+ message : {
580+ message_id : 'om_merge_error' ,
581+ message_type : 'merge_forward' ,
582+ content : 'Merged with error' ,
583+ chat_id : 'oc_456' ,
584+ chat_type : 'p2p' ,
585+ } ,
586+ sender : {
587+ sender_id : {
588+ open_id : 'ou_789' ,
589+ } ,
590+ } ,
591+ } ,
592+ } ;
593+
594+ let receivedMsg : any = null ;
595+ gateway . onMessage = async ( msg ) => {
596+ receivedMsg = msg ;
597+ return null ;
598+ } ;
599+
600+ await messageCallback ( mockEvent ) ;
601+
602+ expect ( receivedMsg ) . not . toBeNull ( ) ;
603+ expect ( receivedMsg . messageType ) . toBe ( 'merge_forward' ) ;
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 ) ;
696+ } ) ;
325697} ) ;
326698
327699// ---------------------------------------------------------------------------
0 commit comments