Skip to content

Commit 2db550a

Browse files
committed
Merge branch 'ls-dev' into 'master'
feat(feishu): support merge_forward message parsing See merge request ai_native/DeepVCode/DeepVcodeClient!491
2 parents 9c67dcc + 43d0192 commit 2db550a

4 files changed

Lines changed: 659 additions & 117 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "easycode-ai",
3-
"version": "1.1.3",
3+
"version": "1.1.5",
44
"engines": {
55
"node": ">=20.0.0"
66
},

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "easycode-cli",
3-
"version": "1.1.3",
3+
"version": "1.1.5",
44
"description": "DeepV Code - AI-powered coding assistant with enhanced capabilities",
55
"repository": {
66
"type": "git",

packages/cli/src/services/feishu/gateway.test.ts

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)