diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index e7007b0de5e..440f8defd75 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -6088,6 +6088,106 @@ describe('DingtalkChannel outbound image delivery', () => { }); }); +describe('DingtalkChannel quoted message context', () => { + function buildReplyDownstream(repliedMsg: Record) { + return { + data: JSON.stringify({ + msgId: 'message-reply', + conversationType: '1', + conversationId: 'cid-reply', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + chatbotUserId: 'bot-user', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + text: { + content: 'follow-up question', + isReplyMsg: true, + repliedMsg, + }, + }), + headers: { messageId: 'message-reply' }, + } as unknown as DWClientDownStream; + } + + function deliverReply(repliedMsg: Record): Envelope { + const channel = createChannel(); + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(buildReplyDownstream(repliedMsg)); + const calls = vi.mocked(channel.handleInbound).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[0]![0]; + } + + it('extracts quoted plain-text replies from content.content', () => { + const envelope = deliverReply({ + msgType: 'text', + senderId: 'someone-else', + content: { content: 'the original question' }, + }); + + expect(envelope.referencedText).toBe('the original question'); + expect(envelope.isReplyToBot).toBe(false); + }); + + it('extracts quoted markdown replies from content.text', () => { + const envelope = deliverReply({ + msgType: 'markdown', + senderId: 'someone-else', + content: { text: '## heading body' }, + }); + + expect(envelope.referencedText).toBe('## heading body'); + }); + + it('extracts quoted richText replies that use msgType-shaped parts', () => { + const envelope = deliverReply({ + msgType: 'richText', + senderId: 'someone-else', + content: { + richText: [ + { msgType: 'text', content: 'look at this' }, + { msgType: 'picture', downloadCode: 'opaque-code' }, + { msgType: 'text', content: 'please' }, + ], + }, + }); + + expect(envelope.referencedText).toBe('look at this[image]please'); + }); + + it('extracts quoted interactiveCard text from the cardContent tree', () => { + const envelope = deliverReply({ + msgType: 'interactiveCard', + msgId: 'dt-card', + senderId: 'bot-user', + content: { + cardContent: [ + { + elementType: 'LIST', + children: [ + { + elementType: 'RICHTEXT', + children: [ + { + elementType: 'TEXT', + value: 'Hi! How can I help you today?', + }, + ], + }, + ], + }, + ], + }, + }); + + expect(envelope.referencedText).toBe('Hi! How can I help you today?'); + expect(envelope.isReplyToBot).toBe(true); + }); +}); + describe('DingtalkChannel outbound file projection', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index db1c5c39a4f..2850467e27d 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -73,13 +73,22 @@ import type { interface DingTalkRichTextPart { type?: string; + msgType?: string; text?: string; + content?: string; downloadCode?: string; atName?: string; } +interface DingTalkCardElement { + elementType?: string; + value?: string; + children?: DingTalkCardElement[]; +} + interface DingTalkMessageContent { text?: string; + content?: string; richText?: DingTalkRichTextPart[]; downloadCode?: string; fileName?: string; @@ -89,6 +98,7 @@ interface DingTalkMessageContent { chatRecord?: unknown; records?: unknown; messages?: unknown; + cardContent?: DingTalkCardElement[]; } interface DingTalkRepliedMsg { @@ -2094,8 +2104,6 @@ export class DingtalkChannel extends ChannelBase { const isReplyToBot = !!data.chatbotUserId && replied.senderId === data.chatbotUserId; - // Note: DingTalk doesn't include content for interactiveCard replies - // (bot responses sent via webhook). Only user message quotes have text. const text = this.summarizeRepliedContent(replied); const downloadCode = replied.content?.downloadCode; const mediaType = this.mediaTypeFromMsgType(replied.msgType); @@ -2159,25 +2167,34 @@ export class DingtalkChannel extends ChannelBase { } /** - * Build a text summary from a repliedMsg, handling text, richText, chat - * records, and media message types with placeholders. + * Build a text summary from a repliedMsg, handling text, markdown, + * richText, chat records, interactiveCard, and media message types with + * placeholders. */ private summarizeRepliedContent(replied: DingTalkRepliedMsg): string { - const msgType = replied.msgType; + const msgType = replied.msgType?.toLowerCase(); const content = replied.content; + // Text quotes carry the body in content.content; markdown uses + // content.text, which the generic fallback below handles. + if (msgType === 'text' && content?.content?.trim()) { + return content.content.trim(); + } + // Direct text content if (content?.text?.trim()) { return content.text.trim(); } - // RichText: concatenate text parts, placeholder for images + // RichText: concatenate text parts, placeholder for images. Quoted + // richText segments use {msgType, content} rather than {type, text}. if (content?.richText && Array.isArray(content.richText)) { const parts: string[] = []; for (const part of content.richText) { - const partType = part.type || 'text'; - if (partType === 'text' && part.text) { - parts.push(part.text); + const partType = (part.type || part.msgType || 'text').toLowerCase(); + const partText = part.text ?? part.content; + if (partType === 'text' && partText?.trim()) { + parts.push(partText.trim()); } else if (partType === 'picture') { parts.push('[image]'); } else if (partType === 'at' && part.atName) { @@ -2188,7 +2205,7 @@ export class DingtalkChannel extends ChannelBase { if (summary) return summary; } - if (msgType === 'chatRecord') { + if (msgType === 'chatrecord') { // The quote budget, not the record budget: this text becomes // `envelope.referencedText`, which `ChannelBase` renders through // `sanitizeQuotedText(..., 500)`. Rendered to 4000 the quote arrives cut @@ -2203,11 +2220,33 @@ export class DingtalkChannel extends ChannelBase { return text; } + // Interactive cards (usually quoted bot replies) have no flat text + // field; the body lives in TEXT nodes of the cardContent element tree. + if (msgType === 'interactivecard') { + return this.collectCardText(content?.cardContent); + } + // Media type placeholders. Shared with the chat-record entry formatter so // the same message type is never described two ways to the model. return mediaTypePlaceholder(msgType, content?.fileName) ?? ''; } + private collectCardText(nodes: DingTalkCardElement[] | undefined): string { + const segments: string[] = []; + const walk = (items: DingTalkCardElement[]): void => { + for (const node of items) { + if (!node || typeof node !== 'object') continue; + if (node.elementType === 'TEXT' && typeof node.value === 'string') { + const trimmed = node.value.trim(); + if (trimmed) segments.push(trimmed); + } + if (Array.isArray(node.children)) walk(node.children); + } + }; + if (Array.isArray(nodes)) walk(nodes); + return segments.join('\n'); + } + /** * Map a DingTalk message type to the media type used for downloads. Shared * by the direct-media (`extractContent`) and quoted-media