mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
fix(dingtalk): mention senders in streamed group replies
Reuse sender metadata and the existing AI Card lifecycle to include native mentions in ordinary group answers. Preserve mentions across progress, completion, and cleanup without sending a separate reminder. Verified with npm run check (1671 tests) and a real ordinary Q&A in the existing DingTalk test group.
This commit is contained in:
parent
092d2cbb9c
commit
5928b33057
5 changed files with 438 additions and 251 deletions
|
|
@ -802,6 +802,121 @@ test('AI Card replies create, deliver, stream full snapshots, and finalize on fi
|
|||
assert.equal(cardCalls[6].body.cardData.cardParamMap.flowStatus, '3');
|
||||
});
|
||||
|
||||
test('AI Cards keep native group mentions through every frame and leave private replies unchanged', async (t) => {
|
||||
const unsafeUserId = "staff&<>\"'";
|
||||
const unsafeName = "提问者&<>\"'";
|
||||
for (const scenario of [
|
||||
{
|
||||
name: 'group with sender mention',
|
||||
target: {
|
||||
type: 'group',
|
||||
openConversationId: 'group-one',
|
||||
atUserIds: { 'staff-one': '提问者' },
|
||||
},
|
||||
delivery: {
|
||||
openSpaceId: 'dtv1.card//IM_GROUP.group-one',
|
||||
imGroupOpenDeliverModel: {
|
||||
robotCode: 'ding-client',
|
||||
atUserIds: { 'staff-one': '提问者' },
|
||||
},
|
||||
},
|
||||
cardAtUserIds: ['staff-one'],
|
||||
mentionPrefix: '<a atId="staff-one">提问者</a>\n\n',
|
||||
},
|
||||
{
|
||||
name: 'mention IDs and names cannot inject HTML',
|
||||
target: {
|
||||
type: 'group',
|
||||
openConversationId: 'group-one',
|
||||
atUserIds: { [unsafeUserId]: unsafeName },
|
||||
},
|
||||
delivery: {
|
||||
openSpaceId: 'dtv1.card//IM_GROUP.group-one',
|
||||
imGroupOpenDeliverModel: {
|
||||
robotCode: 'ding-client',
|
||||
atUserIds: { [unsafeUserId]: unsafeName },
|
||||
},
|
||||
},
|
||||
cardAtUserIds: [unsafeUserId],
|
||||
mentionPrefix: '<a atId="staff&<>"'">提问者&<>"'</a>\n\n',
|
||||
},
|
||||
{
|
||||
name: 'group without mentions',
|
||||
target: { type: 'group', openConversationId: 'group-one' },
|
||||
delivery: {
|
||||
openSpaceId: 'dtv1.card//IM_GROUP.group-one',
|
||||
imGroupOpenDeliverModel: { robotCode: 'ding-client' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'private reply ignores mentions',
|
||||
target: { type: 'user', userId: 'staff-one', atUserIds: { 'staff-one': '提问者' } },
|
||||
delivery: {
|
||||
openSpaceId: 'dtv1.card//IM_ROBOT.staff-one',
|
||||
imRobotOpenDeliverModel: {
|
||||
spaceType: 'IM_ROBOT',
|
||||
robotCode: 'ding-client',
|
||||
extension: { dynamicSummary: 'true' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const calls = [];
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
|
||||
}
|
||||
calls.push({ path: url.pathname, method: options.method, body: JSON.parse(options.body) });
|
||||
return jsonResponse({});
|
||||
},
|
||||
cardMinIntervalMs: 0,
|
||||
cardBackoffMs: 0,
|
||||
});
|
||||
|
||||
const request = {
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: scenario.target,
|
||||
};
|
||||
const card = await api.createAiCard({ ...request, initialText: '正在处理…' });
|
||||
await api.updateAiCard({ ...request, ...card, text: '第一行\n第二行' });
|
||||
await api.finishAiCard({ ...request, ...card, text: '最终回答' });
|
||||
await api.failAiCard({ ...request, ...card, text: '处理失败' });
|
||||
|
||||
assert.deepEqual(calls[0].body.cardAtUserIds, scenario.cardAtUserIds);
|
||||
if (!scenario.cardAtUserIds) {
|
||||
assert.equal(Object.hasOwn(calls[0].body, 'cardAtUserIds'), false);
|
||||
}
|
||||
const deliveries = calls
|
||||
.filter(({ path }) => path.endsWith('/instances/deliver'))
|
||||
.map(({ body }) => body);
|
||||
assert.deepEqual(deliveries, [{
|
||||
outTrackId: card.cardInstanceId,
|
||||
userIdType: 1,
|
||||
...scenario.delivery,
|
||||
}]);
|
||||
const mention = scenario.mentionPrefix ?? '';
|
||||
const frames = calls.filter(({ path }) => path.endsWith('/streaming')).map(({ body }) => body);
|
||||
assert.deepEqual(frames.map(({ content, isFinalize, isError }) => ({ content, isFinalize, isError })), [
|
||||
{ content: mention + '正在处理…', isFinalize: false, isError: false },
|
||||
{ content: mention + '第一行<br>第二行', isFinalize: false, isError: false },
|
||||
{ content: mention + '最终回答', isFinalize: true, isError: false },
|
||||
{ content: mention + '处理失败', isFinalize: false, isError: true },
|
||||
]);
|
||||
const states = calls
|
||||
.filter(({ path, method }) => path.endsWith('/instances') && method === 'PUT')
|
||||
.map(({ body }) => body.cardData.cardParamMap);
|
||||
assert.deepEqual(states.map(({ flowStatus, msgContent }) => ({ flowStatus, msgContent })), [
|
||||
{ flowStatus: '2', msgContent: mention + '正在处理…' },
|
||||
{ flowStatus: '3', msgContent: mention + '最终回答' },
|
||||
{ flowStatus: '5', msgContent: mention + '处理失败' },
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('AI Card markdown preserves fenced code while rendering ordinary line breaks', () => {
|
||||
assert.equal(normalizeDingtalkCardMarkdown('一\n二'), '一<br>二');
|
||||
assert.equal(
|
||||
|
|
@ -847,6 +962,9 @@ test('AI Card start slots preserve 20 QPS without blocking cleanup behind slow H
|
|||
|
||||
assert.equal(bodies.some((body) => body.isError === true), true);
|
||||
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
|
||||
assert.equal(bodies.find((body) => body.isError === true).content, '及时收口');
|
||||
const failedState = bodies.find((body) => body.cardData?.cardParamMap?.flowStatus === '5');
|
||||
assert.equal(failedState.cardData.cardParamMap.msgContent, '及时收口');
|
||||
assert.deepEqual(waits, [50, 50]);
|
||||
firstUpdate.resolve();
|
||||
await slowUpdate;
|
||||
|
|
@ -918,7 +1036,7 @@ test('AI Card cleanup marks failure while a completed final frame never falls ba
|
|||
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
|
||||
});
|
||||
|
||||
test('AI Card creation closes a delivered card with an independent signal after abort', async () => {
|
||||
test('AI Card creation cleanup preserves group mentions with an independent signal after abort', async () => {
|
||||
const controller = new AbortController();
|
||||
const bodies = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
|
|
@ -938,7 +1056,11 @@ test('AI Card creation closes a delivered card with an independent signal after
|
|||
await assert.rejects(api.createAiCard({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: { type: 'user', userId: 'staff-one' },
|
||||
target: {
|
||||
type: 'group',
|
||||
openConversationId: 'group-one',
|
||||
atUserIds: { 'staff-one': '提问者' },
|
||||
},
|
||||
initialText: '正在处理',
|
||||
signal: controller.signal,
|
||||
}), { name: 'AbortError' });
|
||||
|
|
@ -947,8 +1069,11 @@ test('AI Card creation closes a delivered card with an independent signal after
|
|||
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
|
||||
assert.equal(bodies.some((body) => (
|
||||
body.isError === true
|
||||
&& body.content === '卡片已结束,请查看后续消息。'
|
||||
&& body.content === '<a atId="staff-one">提问者</a>\n\n卡片已结束,请查看后续消息。'
|
||||
)), true);
|
||||
const failedState = bodies.find((body) => body.cardData?.cardParamMap?.flowStatus === '5');
|
||||
assert.equal(failedState.cardData.cardParamMap.msgContent,
|
||||
'<a atId="staff-one">提问者</a>\n\n卡片已结束,请查看后续消息。');
|
||||
assert.equal(JSON.stringify(bodies).includes('消息处理失败,请稍后重试。'), false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1274,77 +1274,115 @@ test('group messages require an explicit bot mention before Harness work', async
|
|||
assert.equal(fixture.sessions.get('group:group-one'), 'session-group');
|
||||
});
|
||||
|
||||
test('bridge streams Harness snapshots into one DingTalk AI Card and finalizes it', async () => {
|
||||
const fixture = stateFixture();
|
||||
const calls = { create: [], update: [], finish: [], text: [] };
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async (request) => calls.text.push(request),
|
||||
createAiCard: async (request) => {
|
||||
calls.create.push(request);
|
||||
return { cardInstanceId: 'card-one' };
|
||||
},
|
||||
updateAiCard: async (request) => calls.update.push(request),
|
||||
finishAiCard: async (request) => {
|
||||
calls.finish.push(request);
|
||||
return { delivered: true, completed: false };
|
||||
test('bridge streams one AI Card and mentions only the group sender without an extra text reply', async (t) => {
|
||||
for (const scenario of [
|
||||
{
|
||||
name: 'private reply',
|
||||
overrides: {},
|
||||
target: { type: 'user', userId: 'staff-approved' },
|
||||
},
|
||||
{
|
||||
name: 'group reply mentions the sender by name',
|
||||
overrides: { conversationType: '2', isInAtList: true },
|
||||
target: {
|
||||
type: 'group',
|
||||
openConversationId: 'conversation-stream',
|
||||
atUserIds: { 'staff-approved': '钉钉用户' },
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-stream',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
options.onUpdate({ type: 'text', text: '生成中的完整快照' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 510));
|
||||
return '最终完整回答';
|
||||
{
|
||||
name: 'group reply without a nickname falls back to the sender ID',
|
||||
overrides: { conversationType: 2, isInAtList: true, senderNick: undefined },
|
||||
target: {
|
||||
type: 'group',
|
||||
openConversationId: 'conversation-stream',
|
||||
atUserIds: { 'staff-approved': 'staff-approved' },
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const fixture = stateFixture();
|
||||
const calls = { create: [], update: [], finish: [], text: [] };
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async (request) => calls.text.push(request),
|
||||
createAiCard: async (request) => {
|
||||
calls.create.push(request);
|
||||
return { cardInstanceId: 'card-one' };
|
||||
},
|
||||
updateAiCard: async (request) => calls.update.push(request),
|
||||
finishAiCard: async (request) => {
|
||||
calls.finish.push(request);
|
||||
return { delivered: true, completed: false };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-stream',
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
options.onUpdate({ type: 'text', text: '生成中的完整快照' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 510));
|
||||
return '最终完整回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('stream', '请流式回答'));
|
||||
await bridge.accept(message('stream', '请流式回答', scenario.overrides));
|
||||
|
||||
assert.equal(calls.create.length, 1);
|
||||
assert.deepEqual(calls.create[0].target, { type: 'user', userId: 'staff-approved' });
|
||||
assert.equal(calls.update.at(-1).text, '生成中的完整快照');
|
||||
assert.equal(calls.finish.length, 1);
|
||||
assert.equal(calls.finish[0].text, '最终完整回答');
|
||||
assert.equal(calls.text.length, 0);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
assert.equal(calls.create.length, 1);
|
||||
assert.deepEqual(calls.create[0].target, scenario.target);
|
||||
assert.equal(calls.update.at(-1).text, '生成中的完整快照');
|
||||
assert.equal(calls.finish.length, 1);
|
||||
assert.equal(calls.finish[0].text, '最终完整回答');
|
||||
assert.equal(calls.text.length, 0);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('bridge asks Harness once and falls back to final text when AI Card creation fails', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async (request) => sent.push(request.text),
|
||||
createAiCard: async () => { throw new Error('card unavailable'); },
|
||||
updateAiCard: async () => undefined,
|
||||
finishAiCard: async () => undefined,
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-fallback',
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
return '文本降级回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
test('bridge falls back to final text with group sender mentions when AI Card creation fails', async (t) => {
|
||||
for (const conversationType of ['1', '2']) {
|
||||
await t.test(`conversationType=${conversationType}`, async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async (request) => sent.push(request),
|
||||
createAiCard: async () => { throw new Error('card unavailable'); },
|
||||
updateAiCard: async () => undefined,
|
||||
finishAiCard: async () => undefined,
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-fallback',
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
return '文本降级回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('fallback', '卡片失败也要回答'));
|
||||
await bridge.accept(message('fallback', '卡片失败也要回答', {
|
||||
conversationType,
|
||||
isInAtList: true,
|
||||
}));
|
||||
|
||||
assert.equal(asks, 1);
|
||||
assert.deepEqual(sent, ['文本降级回答']);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
assert.equal(asks, 1);
|
||||
assert.deepEqual(sent.map(({ text }) => text), ['文本降级回答']);
|
||||
assert.deepEqual(sent[0].at, conversationType === '2'
|
||||
? { atUserIds: ['staff-approved'] }
|
||||
: undefined);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('commands stay local and unsafe session webhooks are rejected before Harness', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue