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 <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
qwen-code-dev-bot 2026-07-22 21:04:35 +08:00 committed by GitHub
parent 2714974176
commit e25befdb65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 267 additions and 1 deletions

View file

@ -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`

View file

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

View file

@ -107,6 +107,8 @@ const SENSITIVE_PAYLOAD_KEY_PATTERN = new RegExp(
'media',
'webhook',
'staff_id',
'staffId',
'dingtalkId',
'open_id',
'union_id',
'user_?id',

View file

@ -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 = {

View file

@ -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<string>();
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';