Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions packages/channels/dingtalk/src/DingtalkAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6088,6 +6088,106 @@ describe('DingtalkChannel outbound image delivery', () => {
});
});

describe('DingtalkChannel quoted message context', () => {
function buildReplyDownstream(repliedMsg: Record<string, unknown>) {
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<string, unknown>): 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();
Expand Down
59 changes: 49 additions & 10 deletions packages/channels/dingtalk/src/DingtalkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -89,6 +98,7 @@ interface DingTalkMessageContent {
chatRecord?: unknown;
records?: unknown;
messages?: unknown;
cardContent?: DingTalkCardElement[];
}

interface DingTalkRepliedMsg {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The richText loop still supports the legacy {type, text} part shape via the part.type || fallback, but no test pins that backward-compatible branch — the new quoted-richText tests use only the {msgType, content} shape. Dropping part.type || (mutating to (part.msgType || 'text')) leaves the full package suite green (verified: 320/320 pass after the mutation), so a future refactor could silently break legacy-shape quotes — a {type:'picture'} part losing its [image] placeholder, {type:'text'} text skipped — while the suite stays green.

Consider adding one legacy-shaped fixture to the new quoted message context block, e.g.

content: {
  richText: [
    { type: 'text', text: 'legacy body' },
    { type: 'picture', downloadCode: 'c' },
  ],
},

and asserting referencedText === 'legacy body[image]'.

中文说明

richText 循环目前仍通过 part.type || 回退兼容旧的 {type, text} 段形态,但没有任何测试固定这条向后兼容分支——新增的引用 richText 测试只用了 {msgType, content} 形态。删掉 part.type ||(突变为 (part.msgType || 'text'))后整个包的测试套件依然全绿(已验证:突变后 320/320 通过),因此未来的重构可能在测试全绿时悄悄破坏旧形态的引用——{type:'picture'} 段丢失 [image] 占位符、{type:'text'} 文本被跳过。

建议在新的 quoted message context 用例组补一个旧形态用例:

content: {
  richText: [
    { type: 'text', text: 'legacy body' },
    { type: 'picture', downloadCode: 'c' },
  ],
},

并断言 referencedText === 'legacy body[image]'

— qwen3.8-max via Qwen Code /review (v0.21.14)

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) {
Expand All @@ -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
Expand All @@ -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);
}
Comment on lines +2225 to +2227

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The empty/absent cardContent path of this new branch is untested, even though the comment this diff deletes explicitly stated DingTalk card quotes can arrive without content. Today collectCardText(undefined) degrades gracefully (the Array.isArray guard returns '', so referencedText becomes undefined). But remove that guard and walk(undefined) throws a TypeError inside onMessage, dropping the user's entire message — and no test distinguishes the guard being present from absent (verified: the suite stays green with the mutant).

Add one test: an interactiveCard quote with content: {}, asserting envelope.referencedText is undefined (and isReplyToBot per senderId).

中文说明

这个新分支的空/缺失 cardContent 路径没有测试覆盖,尽管本次 diff 删除的注释恰好明确说过钉钉卡片引用可能不带内容。目前 collectCardText(undefined) 会优雅降级(Array.isArray 守卫返回 ''referencedText 变为 undefined)。但删掉该守卫后,walk(undefined) 会在 onMessage 内抛出 TypeError,导致整条用户消息被丢弃——而且没有任何测试能区分守卫存在与否(已验证:突变后套件依然全绿)。

补一个测试:interactiveCard 引用且 content: {},断言 envelope.referencedTextundefined(且 isReplyToBot 按 senderId 判定)。

— qwen3.8-max via Qwen Code /review (v0.21.14)


// 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');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] collectCardText joins multiple TEXT segments with '\n', but the new interactiveCard test contains exactly one TEXT node, so this separator is unpinned — mutating segments.join('\n') to segments.join('') leaves the whole suite green (verified). Bot reply cards routinely contain several TEXT nodes; with the mutant they would merge into the quoted context as HelloHow can I help you today? — word boundaries across segments lost. Downstream sanitizeQuotedText folds '\n' to a space, so '\n' vs '' is the difference between a space and glued words.

Add a card fixture with two TEXT nodes and assert the joined output, e.g. referencedText === 'first line\nsecond line'.

中文说明

collectCardText'\n' 连接多个 TEXT 段,但新的 interactiveCard 测试只有一个 TEXT 节点,因此该分隔符未被测试固定——把 segments.join('\n') 突变为 segments.join('') 后整个套件依然全绿(已验证)。bot 的卡片回复通常包含多个 TEXT 节点,突变后它们会在引用上下文中连成 HelloHow can I help you today?——段间词边界丢失。下游 sanitizeQuotedText 会把 '\n' 折成空格,所以 '\n''' 的差别就是有空格与词粘在一起。

补一个含两个 TEXT 节点的卡片用例,断言拼接结果,例如 referencedText === 'first line\nsecond line'

— qwen3.8-max via Qwen Code /review (v0.21.14)

}

/**
* Map a DingTalk message type to the media type used for downloads. Shared
* by the direct-media (`extractContent`) and quoted-media
Expand Down
Loading