From e25befdb65997488b4e9c8015b7fc92b408b25cd Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Wed, 22 Jul 2026 21:04:35 +0800 Subject: [PATCH] fix(dingtalk): preserve non-bot mention context (#7473) * fix(dingtalk): preserve non-bot mention context * test(dingtalk): cover plural mentions, staffId fallback, and edge cases (#7473) --------- Co-authored-by: qwen-code-dev-bot --- .../dingtalk-structured-user-mentions.md | 24 +++ .../channels/base/src/ChannelBase.test.ts | 5 + packages/channels/base/src/ChannelBase.ts | 2 + .../dingtalk/src/DingtalkAdapter.test.ts | 200 ++++++++++++++++++ .../channels/dingtalk/src/DingtalkAdapter.ts | 37 +++- 5 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 .qwen/e2e-tests/dingtalk-structured-user-mentions.md diff --git a/.qwen/e2e-tests/dingtalk-structured-user-mentions.md b/.qwen/e2e-tests/dingtalk-structured-user-mentions.md new file mode 100644 index 0000000000..2bf3c9691a --- /dev/null +++ b/.qwen/e2e-tests/dingtalk-structured-user-mentions.md @@ -0,0 +1,24 @@ +# DingTalk Structured User Mentions + +Date: 2026-07-22 + +## Baseline reproduction + +Using Qwen Code 0.20.1 with a DingTalk Stream channel, send a group message that mentions the bot and one other member. DingTalk delivers two entries in `atUsers`, removes both visible names from `text.content`, and the current adapter forwards only the remaining text to the model. With debug payload logging enabled, the nested `dingtalkId` and `staffId` values are also logged without redaction. + +Use anonymized identifiers in all captured evidence. Do not commit real group, user, staff, message, webhook, or application identifiers. + +## Verification + +1. Send `@Bot please review this @Member` in a test group. +2. Confirm the inbound model text starts with `[Mentioned 1 other group member]` followed by `please review this`. +3. Confirm the bot's own `atUsers` entry is excluded and duplicate member entries are counted once. +4. Send `@Bot hello` and confirm no additional mention context is added. +5. Enable `QWEN_CHANNEL_DEBUG_PAYLOAD` for the test channel and confirm `atUsers[].dingtalkId` and `atUsers[].staffId` appear as `[redacted]` while routing fields needed for diagnostics remain visible. + +## Automated coverage + +- `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts` +- `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts` +- `npm run typecheck` +- `git diff --check` diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index d43b53b4a4..863f27a1f6 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -445,6 +445,7 @@ describe('ChannelBase', () => { senderId: 'sender-123', senderNick: 'Alice', senderName: 'Bob', + atUsers: [{ dingtalkId: 'dingtalk-123', staffId: 'staff-at-123' }], nested: { aeskey: 'media-key' }, }); logged = writeSpy.mock.calls.map((call) => String(call[0])).join(''); @@ -473,6 +474,10 @@ describe('ChannelBase', () => { expect(logged).toContain('"senderId":"[redacted]"'); expect(logged).toContain('"senderNick":"[redacted]"'); expect(logged).toContain('"senderName":"[redacted]"'); + expect(logged).toContain('"dingtalkId":"[redacted]"'); + expect(logged).toContain('"staffId":"[redacted]"'); + expect(logged).not.toContain('dingtalk-123'); + expect(logged).not.toContain('staff-at-123'); expect(logged).toContain('"aeskey":"[redacted]"'); expect(logged).not.toContain('\\n'); expect(logged).not.toContain('secret-token'); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 830c654adb..f077500e06 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -107,6 +107,8 @@ const SENSITIVE_PAYLOAD_KEY_PATTERN = new RegExp( 'media', 'webhook', 'staff_id', + 'staffId', + 'dingtalkId', 'open_id', 'union_id', 'user_?id', diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index dcd72ca93a..ea5a7ec488 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -1188,6 +1188,9 @@ describe('DingtalkChannel parsed-message logging', () => { senderStaffId: 'staff-1', senderId: 'sender-1', isInAtList: true, + atUsers: [ + { dingtalkId: 'private-dingtalk-id', staffId: 'private-staff-id' }, + ], text: { content: '@qwen-code hello' }, }), headers: { messageId: 'debug-m1' }, @@ -1215,6 +1218,10 @@ describe('DingtalkChannel parsed-message logging', () => { expect(logged).toContain('"msgId":"debug-m1"'); expect(logged).toContain('"sessionWebhook":"[redacted]"'); expect(logged).not.toContain('access_token=token'); + expect(logged).toContain('"dingtalkId":"[redacted]"'); + expect(logged).toContain('"staffId":"[redacted]"'); + expect(logged).not.toContain('private-dingtalk-id'); + expect(logged).not.toContain('private-staff-id'); }); it('logs parsed routing and sender fields for routable group messages', () => { @@ -1657,6 +1664,199 @@ describe('DingtalkChannel sender attribution', () => { ); }); + it('preserves non-bot mentions when DingTalk removes names from text', () => { + const channel = createChannel(); + const downstream = { + data: JSON.stringify({ + msgId: 'structured-mentions', + conversationType: '2', + conversationId: 'cid123', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-user', + isInAtList: true, + atUsers: [ + { dingtalkId: 'bot-user' }, + { dingtalkId: 'member-user', staffId: 'member-staff' }, + { dingtalkId: 'member-user', staffId: 'member-staff' }, + ], + text: { content: 'please review this' }, + }), + headers: { messageId: 'structured-mentions' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: '[Mentioned 1 other group member]\nplease review this', + isMentioned: true, + }), + ); + }); + + it('does not add mention context when only the bot was mentioned', () => { + const channel = createChannel(); + const downstream = { + data: JSON.stringify({ + msgId: 'bot-only-mention', + conversationType: '2', + conversationId: 'cid123', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-user', + isInAtList: true, + atUsers: [{ dingtalkId: 'bot-user' }], + text: { content: 'hello' }, + }), + headers: { messageId: 'bot-only-mention' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello', isMentioned: true }), + ); + }); + + it('uses plural label when multiple distinct non-bot members are mentioned', () => { + const channel = createChannel(); + const downstream = { + data: JSON.stringify({ + msgId: 'plural-mentions', + conversationType: '2', + conversationId: 'cid123', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-user', + isInAtList: true, + atUsers: [ + { dingtalkId: 'bot-user' }, + { dingtalkId: 'user-a' }, + { dingtalkId: 'user-b' }, + ], + text: { content: 'please review this' }, + }), + headers: { messageId: 'plural-mentions' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: '[Mentioned 2 other group members]\nplease review this', + isMentioned: true, + }), + ); + }); + + it('falls back to staffId when dingtalkId is absent', () => { + const channel = createChannel(); + const downstream = { + data: JSON.stringify({ + msgId: 'staffid-fallback', + conversationType: '2', + conversationId: 'cid123', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-user', + isInAtList: true, + atUsers: [{ dingtalkId: 'bot-user' }, { staffId: 'only-staff' }], + text: { content: 'hello' }, + }), + headers: { messageId: 'staffid-fallback' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: '[Mentioned 1 other group member]\nhello', + isMentioned: true, + }), + ); + }); + + it('returns text unchanged when chatbotUserId is absent', () => { + const channel = createChannel(); + const downstream = { + data: JSON.stringify({ + msgId: 'no-chatbot-id', + conversationType: '2', + conversationId: 'cid123', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + isInAtList: true, + atUsers: [{ dingtalkId: 'user-a' }], + text: { content: 'hello' }, + }), + headers: { messageId: 'no-chatbot-id' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello', isMentioned: true }), + ); + }); + + it('returns context only when text is empty after mention stripping', () => { + const channel = createChannel(); + const downstream = { + data: JSON.stringify({ + msgId: 'empty-text-mention', + conversationType: '2', + conversationId: 'cid123', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + chatbotUserId: 'bot-user', + isInAtList: true, + atUsers: [{ dingtalkId: 'bot-user' }, { dingtalkId: 'user-a' }], + text: { content: '' }, + }), + headers: { messageId: 'empty-text-mention' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + text: '[Mentioned 1 other group member]', + isMentioned: true, + }), + ); + }); + it('ignores non-string message metadata when logging parsed JSON', () => { const channel = createChannel(); const downstream = { diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 72694a4337..dc8a2bd90a 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -50,6 +50,11 @@ interface DingTalkRepliedMsg { }; } +interface DingTalkAtUser { + dingtalkId?: string; + staffId?: string; +} + interface DingTalkMessageData { msgId?: string; msgtype?: string; @@ -62,6 +67,7 @@ interface DingTalkMessageData { senderNick?: string; chatbotUserId?: string; isInAtList?: boolean; + atUsers?: DingTalkAtUser[]; text?: { content?: string; isReplyMsg?: boolean; @@ -103,6 +109,32 @@ type MentionTargetEnvelope = Envelope & { [mentionTarget]?: string; }; +function withNonBotMentionContext( + data: DingTalkMessageData, + text: string, +): string { + if (!Array.isArray(data.atUsers) || typeof data.chatbotUserId !== 'string') { + return text; + } + + const mentions = new Set(); + for (const user of data.atUsers) { + if (!user) continue; + const dingtalkId = + typeof user.dingtalkId === 'string' ? user.dingtalkId : undefined; + // DingTalk Stream always sets dingtalkId for the bot entry; staffId-only bot entries are not expected. + if (dingtalkId === data.chatbotUserId) continue; + const staffId = typeof user.staffId === 'string' ? user.staffId : undefined; + const stableId = dingtalkId || staffId; + if (stableId) mentions.add(stableId); + } + + if (mentions.size === 0) return text; + const memberLabel = mentions.size === 1 ? 'member' : 'members'; + const context = `[Mentioned ${mentions.size} other group ${memberLabel}]`; + return text ? `${context}\n${text}` : context; +} + interface DingTalkTokenResponse { errcode?: number; errmsg?: string; @@ -1322,7 +1354,10 @@ export class DingtalkChannel extends ChannelBase { // After stripping the bot @mention, cleanText may legitimately be empty // (user pinged the bot with no other text). Don't fall back to the // original text in that case — it would re-introduce the @mention. - const envelopeText = isMentioned ? cleanText : cleanText || content.text; + const messageText = isMentioned ? cleanText : cleanText || content.text; + const envelopeText = isGroup + ? withNonBotMentionContext(data, messageText) + : messageText; const senderId = senderStaffId || senderIdValue || ''; const senderName = senderNick || senderId || 'Unknown';