Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/users/features/channels/dingtalk.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ DingTalk bots work in both DM and group conversations. To enable group support:

By default, the bot requires an @mention in group chats (`requireMention: true`). Set `"requireMention": false` for a specific group to make it respond to all messages. See [Group Chats](./overview#group-chats) for full details.

Set `"atSender": true` to have the bot @mention the member whose group message triggered its response. It is off by default and only applies to agent replies with a DingTalk staff ID. Mentioned replies use plain text so the @ is visible; replies without a mention use Markdown formatting.
Set `"atSender": true` to have the bot @mention the member whose group message triggered its response. It is off by default and only applies to agent replies with a DingTalk staff ID. Replies are sent as DingTalk markdown whether or not they carry a mention; the mention prefix is included in the first message chunk.

### Finding a Group's Conversation ID

Expand Down
181 changes: 154 additions & 27 deletions packages/channels/dingtalk/src/DingtalkAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1565,19 +1565,21 @@ describe('DingtalkChannel status cards', () => {
).toBeDefined();
});

it('registers only the matching real inbound owner without creating output', () => {
it('starts a status card only for the matching real inbound owner', () => {
const channel = createChannel();
const registerRun = vi.fn();
const startStatusCard = vi.fn();
const appendOutput = vi.fn();
(
channel as unknown as {
interactionPresenter: {
registerRun: typeof registerRun;
startStatusCard: typeof startStatusCard;
appendOutput: typeof appendOutput;
};
inboundCardOwners: Map<string, unknown>;
}
).interactionPresenter = { registerRun, appendOutput };
).interactionPresenter = { registerRun, startStatusCard, appendOutput };
(
channel as unknown as {
inboundCardOwners: Map<string, unknown>;
Expand All @@ -1597,6 +1599,7 @@ describe('DingtalkChannel status cards', () => {
owner: { kind: 'channel_user', id: 'other-owner' },
});
expect(registerRun).not.toHaveBeenCalled();
expect(startStatusCard).not.toHaveBeenCalled();
expect(appendOutput).not.toHaveBeenCalled();

(
Expand All @@ -1606,6 +1609,7 @@ describe('DingtalkChannel status cards', () => {
).inboundCardOwners.set('message-2', {
ownerId: 'owner-1',
target: { chatId: 'cid-1', isGroup: true },
sender: { senderName: 'Alice' },
});
getLifecycleHook(channel)({
type: 'started',
Expand All @@ -1618,10 +1622,21 @@ describe('DingtalkChannel status cards', () => {
});

expect(registerRun).toHaveBeenCalledOnce();
expect(registerRun).toHaveBeenCalledWith('run-2', 'owner-1', {
chatId: 'cid-1',
isGroup: true,
});
expect(registerRun).toHaveBeenCalledWith(
'run-2',
'owner-1',
{
chatId: 'cid-1',
isGroup: true,
},
'session-1',
{ senderName: 'Alice' },
);
expect(startStatusCard).toHaveBeenCalledOnce();
expect(startStatusCard).toHaveBeenCalledWith('run-2');
expect(startStatusCard.mock.invocationCallOrder[0]).toBeGreaterThan(
registerRun.mock.invocationCallOrder[0],
);
expect(appendOutput).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -1653,6 +1668,83 @@ describe('DingtalkChannel status cards', () => {
});
});

it('captures the group sender for card attribution when atSender is enabled', async () => {
const channel = createChannel({ atSender: true });
const downstream = {
data: JSON.stringify({
msgId: 'message-quote',
conversationType: '2',
conversationId: 'cid-quote',
sessionWebhook:
'https://oapi.dingtalk.com/robot/send?access_token=token',
chatbotUserId: 'bot-user',
senderNick: 'Alice',
senderStaffId: 'staff-1',
senderId: 'owner-1',
isInAtList: true,
atUsers: [{ dingtalkId: 'bot-user' }, { dingtalkId: 'other-user' }],
text: { content: '@qwen-code What changed?' },
}),
headers: { messageId: 'message-quote' },
} as unknown as DWClientDownStream;

(
channel as unknown as { onMessage(d: DWClientDownStream): void }
).onMessage(downstream);
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
await DingtalkChannel.prototype.handleInbound.call(channel, envelope);

expect(
(
channel as unknown as {
inboundCardOwners: Map<string, unknown>;
}
).inboundCardOwners.get('message-quote'),
).toEqual({
ownerId: 'staff-1',
target: { chatId: 'cid-quote', isGroup: true },
sender: { senderName: 'Alice' },
});
});

it('omits the group sender from card attribution when atSender is disabled', async () => {
const channel = createChannel({ atSender: false });
const downstream = {
data: JSON.stringify({
msgId: 'message-quote',
conversationType: '2',
conversationId: 'cid-quote',
sessionWebhook:
'https://oapi.dingtalk.com/robot/send?access_token=token',
chatbotUserId: 'bot-user',
senderNick: 'Alice',
senderStaffId: 'staff-1',
senderId: 'owner-1',
isInAtList: true,
atUsers: [{ dingtalkId: 'bot-user' }, { dingtalkId: 'other-user' }],
text: { content: '@qwen-code What changed?' },
}),
headers: { messageId: 'message-quote' },
} as unknown as DWClientDownStream;

(
channel as unknown as { onMessage(d: DWClientDownStream): void }
).onMessage(downstream);
const envelope = vi.mocked(channel.handleInbound).mock.calls[0]![0];
await DingtalkChannel.prototype.handleInbound.call(channel, envelope);

expect(
(
channel as unknown as {
inboundCardOwners: Map<string, unknown>;
}
).inboundCardOwners.get('message-quote'),
).toEqual({
ownerId: 'staff-1',
target: { chatId: 'cid-quote', isGroup: true },
});
});

it('routes the first visible chunk with its exact segment context', () => {
const channel = createChannel();
const appendOutput = vi.fn();
Expand Down Expand Up @@ -2984,8 +3076,8 @@ describe('DingtalkChannel reply mentions', () => {
String((fetchSpy.mock.calls[0]![1] as RequestInit).body),
);
expect(body).toMatchObject({
msgtype: 'text',
text: { content: '@staff-1\n\nhello' },
msgtype: 'markdown',
markdown: { text: '@staff-1\n\nhello' },
at: { atUserIds: ['staff-1'] },
});
} finally {
Expand Down Expand Up @@ -3036,8 +3128,8 @@ describe('DingtalkChannel reply mentions', () => {
expect(
JSON.parse(String((fetchSpy.mock.calls[0]![1] as RequestInit).body)),
).toMatchObject({
msgtype: 'text',
text: { content: '@staff-1\n\nhello' },
msgtype: 'markdown',
markdown: { text: '@staff-1\n\nhello' },
at: { atUserIds: ['staff-1'] },
});
});
Expand Down Expand Up @@ -3103,7 +3195,7 @@ describe('DingtalkChannel reply mentions', () => {
expect(body).not.toHaveProperty('at');
});

it('reserves the mention prefix within the first text chunk limit', async () => {
it('reserves the mention prefix within the first markdown chunk limit', async () => {
const channel = createChannel({ atSender: true });
seedWebhook(channel, 'cid123');
seedMentionTarget(channel, 'm1', 'staff-1');
Expand All @@ -3120,18 +3212,18 @@ describe('DingtalkChannel reply mentions', () => {
JSON.parse(String((init as RequestInit).body)),
);
expect(bodies[0]).toMatchObject({
msgtype: 'text',
msgtype: 'markdown',
at: { atUserIds: ['staff-1'] },
});
expect(bodies[1]).toMatchObject({ msgtype: 'text' });
expect(bodies[1]).toMatchObject({ msgtype: 'markdown' });
expect(bodies[1]).not.toHaveProperty('at');
expect(bodies.map((body) => body.text.content.length)).toEqual([3800, 10]);
expect(bodies.map((body) => body.markdown.text.length)).toEqual([3800, 10]);
expect(
bodies
.map((body, index) =>
index === 0
? body.text.content.slice('@staff-1\n\n'.length)
: body.text.content,
? body.markdown.text.slice('@staff-1\n\n'.length)
: body.markdown.text,
)
.join(''),
).toBe(text);
Expand All @@ -3152,14 +3244,18 @@ describe('DingtalkChannel reply mentions', () => {
const contents = fetchSpy.mock.calls.map(([, init], index) => {
const body = JSON.parse(String((init as RequestInit).body));
return index === 0
? body.text.content.slice('@staff-1\n\n'.length)
: body.text.content;
? body.markdown.text.slice('@staff-1\n\n'.length)
: body.markdown.text;
});
expect(contents.join('')).toBe(text);
expect(contents[0]).toMatch(/^```/u);
expect(contents.at(-1)).toMatch(/```$/u);
expect(contents.join('').replace(/[`\n]/gu, '')).toBe(
text.replace(/[`\n]/gu, ''),
);
expect(
fetchSpy.mock.calls.every(([, init]) => {
const body = JSON.parse(String((init as RequestInit).body));
return body.text.content.length <= 3800;
return body.markdown.text.length <= 3800;
}),
).toBe(true);
});
Expand All @@ -3180,10 +3276,46 @@ describe('DingtalkChannel reply mentions', () => {
JSON.parse(String((init as RequestInit).body)),
);
expect(bodies[0]).toMatchObject({ at: { atUserIds: ['staff-1'] } });
expect(bodies[0].msgtype).toBe('text');
expect(bodies[1]).toMatchObject({ msgtype: 'text' });
expect(bodies[0].msgtype).toBe('markdown');
expect(bodies[1]).toMatchObject({ msgtype: 'markdown' });
expect(bodies[1]).not.toHaveProperty('at');
});

it('keeps the mention available to the final reply after a mid-run fallback', async () => {
const channel = createChannel({ atSender: true });
seedWebhook(channel, 'cid123');
seedMentionTarget(channel, 'm1', 'staff-1');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }));

getPromptHook(channel, 'onPromptStart')('cid123', 'session-1', 'm1');
await (
channel as unknown as {
sendFallbackReply(
chatId: string,
text: string,
sessionId: string,
): Promise<void>;
}
).sendFallbackReply('cid123', 'intermediate result', 'session-1');
await getResponseHook(channel)('cid123', 'final answer', 'session-1');

const bodies = fetchSpy.mock.calls.map(([, init]) =>
JSON.parse(String((init as RequestInit).body)),
);
expect(bodies).toHaveLength(2);
expect(bodies[0]).toMatchObject({
msgtype: 'markdown',
markdown: { text: '@staff-1\n\nintermediate result' },
at: { atUserIds: ['staff-1'] },
});
expect(bodies[1]).toMatchObject({
msgtype: 'markdown',
markdown: { text: '@staff-1\n\nfinal answer' },
at: { atUserIds: ['staff-1'] },
});
});
});

describe('DingtalkChannel mention target lifecycle', () => {
Expand Down Expand Up @@ -3438,7 +3570,6 @@ describe('DingtalkChannel mention target lifecycle', () => {
const internals = channel as unknown as {
mentionTargets: Map<string, string>;
sessionMentionTargets: Map<string, string>;
textReplySessions: Set<string>;
bufferedMentionTargets: Set<string>;
bufferedMentionTargetsBySession: Map<string, Set<string>>;
onPromptBuffered(
Expand All @@ -3455,8 +3586,6 @@ describe('DingtalkChannel mention target lifecycle', () => {
internals.onPromptBuffered('cid-123', 'session-2', 'other-1');
internals.sessionMentionTargets.set('session-1', 'staff-active');
internals.sessionMentionTargets.set('session-2', 'staff-other-active');
internals.textReplySessions.add('session-1');
internals.textReplySessions.add('session-2');

channel.onSessionDied('session-1');

Expand All @@ -3468,7 +3597,6 @@ describe('DingtalkChannel mention target lifecycle', () => {
false,
);
expect(internals.sessionMentionTargets.has('session-1')).toBe(false);
expect(internals.textReplySessions.has('session-1')).toBe(false);
expect(internals.mentionTargets.get('other-1')).toBe('staff-other');
expect(internals.bufferedMentionTargets.has('other-1')).toBe(true);
expect(internals.bufferedMentionTargetsBySession.get('session-2')).toEqual(
Expand All @@ -3477,7 +3605,6 @@ describe('DingtalkChannel mention target lifecycle', () => {
expect(internals.sessionMentionTargets.get('session-2')).toBe(
'staff-other-active',
);
expect(internals.textReplySessions.has('session-2')).toBe(true);
});
});

Expand Down
Loading
Loading