mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-10 17:27:10 +00:00
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:
parent
2714974176
commit
e25befdb65
5 changed files with 267 additions and 1 deletions
24
.qwen/e2e-tests/dingtalk-structured-user-mentions.md
Normal file
24
.qwen/e2e-tests/dingtalk-structured-user-mentions.md
Normal 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`
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ const SENSITIVE_PAYLOAD_KEY_PATTERN = new RegExp(
|
|||
'media',
|
||||
'webhook',
|
||||
'staff_id',
|
||||
'staffId',
|
||||
'dingtalkId',
|
||||
'open_id',
|
||||
'union_id',
|
||||
'user_?id',
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue