fix: preserve quoted message context across channels

This commit is contained in:
xmanrui 2026-09-02 01:27:35 +08:00
parent c2be2389b0
commit 94e114b9b3
46 changed files with 4242 additions and 321 deletions

View file

@ -59,17 +59,186 @@ function message(id, text, overrides = {}) {
};
}
test('DingTalk maps undocumented repliedMsg runtime fields into a reply snapshot', () => {
const inbound = dingtalkInboundMessage(message('dingtalk-quote-normalize', '继续分析', {
text: {
content: '继续分析',
isReplyMsg: true,
repliedMsg: {
msgType: 'richText',
msgId: 'quoted-ding-message',
senderId: 'quoted-staff',
senderNick: '引用用户',
content: {
richText: [
{ type: 'text', text: '被引用的说明' },
{ type: 'picture', downloadCode: 'quoted-picture' },
],
},
},
},
}));
assert.equal(inbound.content, '继续分析');
assert.deepEqual(inbound.replyTo, {
messageId: 'quoted-ding-message',
authorId: 'quoted-staff',
authorName: '引用用户',
content: '被引用的说明',
attachments: [{ kind: 'image' }],
});
});
test('DingTalk sends quote context to Harness but does not execute quoted commands', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-quote');
let clears = 0;
let prompt;
fixture.state.clearSession = async () => { clears += 1; };
const bridge = new DingtalkHarnessBridge({
api: { sendText: async () => ({ messageId: 'ding-quote-answer' }) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, content) => { prompt = content; return '已处理'; },
},
state: fixture.state,
});
await bridge.accept(message('dingtalk-quote-prompt', '这条指令是什么意思?', {
text: {
content: '这条指令是什么意思?',
isReplyMsg: true,
repliedMsg: { msgType: 'text', msgId: 'quoted-command', content: { text: '/new' } },
},
}));
assert.equal(clears, 0);
assert.equal(Array.isArray(prompt), true);
assert.match(prompt[0].text, /<dsh_im_reply_to>/);
assert.match(prompt[0].text, /"content":"\/new"/);
assert.deepEqual(prompt.at(-1), { type: 'text', text: '这条指令是什么意思?' });
});
test('DingTalk resolves an Interactive Card reply by originalProcessQueryKey', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-card-quote');
await fixture.state.rememberOutboundMessage({
conversationKey: 'p2p:staff-approved',
text: '钉钉卡片里的完整回答',
sentAt: Date.now() - 2_000,
completedAt: Date.now() - 1_000,
providerMessageIds: ['dsh-card-answer'],
});
let prompt;
const bridge = new DingtalkHarnessBridge({
api: { sendText: async () => ({ messageId: 'ding-card-followup' }) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, content) => { prompt = content; return '已识别卡片'; },
},
state: fixture.state,
});
await bridge.accept(message('dingtalk-card-quote', '卡片说了什么?', {
chatbotUserId: 'ding-client',
originalProcessQueryKey: 'dsh-card-answer',
text: {
content: '卡片说了什么?',
isReplyMsg: true,
repliedMsg: {
msgType: 'interactiveCard',
msgId: 'quoted-interactive-card',
senderId: 'ding-client',
createdAt: Date.now() - 2_000,
content: { text: '[Interactive Card Message]' },
},
},
}));
assert.match(prompt[0].text, /"content":"钉钉卡片里的完整回答"/);
assert.doesNotMatch(prompt[0].text, /unavailableReason/);
});
test('DingTalk recovers a pre-index Interactive Card reply from bounded Session history', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-old-card');
const quotedAt = Date.now() - 5_000;
let prompt;
const session = {
sessionExists: async () => true,
readHistory: async () => ({
events: [
{ event: { type: 'turn/start', seq: 1, time: quotedAt, data: { turn: 2 } } },
{ event: {
type: 'assistant/message',
seq: 2,
time: quotedAt + 2_000,
data: {
turn: 2,
message: { content: [{ type: 'text', text: '旧钉钉卡片正文' }] },
},
} },
{ event: {
type: 'turn/end',
seq: 3,
time: quotedAt + 2_001,
data: { turn: 2, reason: { kind: 'completed' } },
} },
],
hasMore: false,
}),
ask: async (content) => { prompt = content; return '已恢复旧卡片'; },
};
const bridge = new DingtalkHarnessBridge({
api: { sendText: async () => ({ messageId: 'ding-old-card-answer' }) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: { workspaceSession: () => session },
state: fixture.state,
});
await bridge.accept(message('dingtalk-old-card-quote', '旧卡片说了什么?', {
chatbotUserId: 'ding-client',
text: {
content: '旧卡片说了什么?',
isReplyMsg: true,
repliedMsg: {
msgType: 'interactiveCard',
msgId: 'old-interactive-card',
senderId: 'ding-client',
createdAt: quotedAt,
},
},
}));
assert.match(prompt[0].text, /"content":"旧钉钉卡片正文"/);
assert.doesNotMatch(prompt[0].text, /unavailableReason/);
});
function stateFixture() {
const sessions = new Map();
const seen = new Set();
const pending = new Map();
const outbound = [];
return {
sessions,
seen,
pending,
outbound,
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
rememberOutboundMessage: async (entry) => outbound.push(structuredClone(entry)),
recentOutboundTextFor: ({ conversationKey, processQueryKey, messageId }) => {
const ids = [processQueryKey, messageId].filter(Boolean);
const matches = outbound.filter((entry) => entry.conversationKey === conversationKey
&& ids.some((id) => entry.providerMessageIds?.includes(id)));
return matches.length === 1 ? matches[0].text : null;
},
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
@ -1411,6 +1580,8 @@ test('bridge streams one AI Card and mentions only the group sender without an e
assert.equal(calls.finish.length, 1);
assert.equal(calls.finish[0].text, '最终完整回答');
assert.equal(calls.text.length, 0);
assert.deepEqual(fixture.outbound.at(-1).providerMessageIds, ['card-one']);
assert.equal(fixture.outbound.at(-1).text, '最终完整回答');
assert.equal(bridge.status.messagesReplied, 1);
});
}
@ -2443,15 +2614,24 @@ test('DingTalk private batch input submits once, cancels cleanly, and restores n
});
await bridge.accept(message('batch-start', '/batch'));
await bridge.accept(message('batch-quote', '钉钉引用不能收录', {
text: {
content: '钉钉引用不能收录',
isReplyMsg: true,
repliedMsg: { msgType: 'text', content: { text: '被引用内容' } },
},
}));
await bridge.accept(message('batch-one', '第一条'));
await bridge.accept(message('batch-two', '第二条'));
assert.deepEqual(asked, []);
assert.equal(sent.length, 1, 'only /batch acknowledges before submission');
assert.equal(sent.length, 2, '/batch and the rejected quote acknowledge before submission');
assert.match(sent[1], /引用消息.*未收录/s);
await bridge.accept(message('batch-send', '/send'));
assert.equal(asked.length, 1);
assert.match(asked[0], /\[消息 1\]\n第一条/);
assert.match(asked[0], /\[消息 2\]\n第二条/);
assert.doesNotMatch(asked[0], /钉钉引用不能收录/);
assert.equal(sent.at(-1), '批量完成');
await bridge.accept(message('cancel-start', '/batch'));

View file

@ -88,7 +88,9 @@ test('finish waits for an in-flight update and discards stale pending progress',
});
const stream = createStream(fixtureValue.api, { logger: { error() {} } });
assert.deepEqual(stream.providerMessageIds, []);
assert.equal(await stream.start('正在处理'), true);
assert.deepEqual(stream.providerMessageIds, ['card-one']);
stream.push('第一段');
stream.push('应丢弃的旧进度');
const finishing = stream.finish('最终答案');

View file

@ -18,6 +18,14 @@ test('state store persists sessions, dedupe IDs, and host-only pending sender re
await store.setSession('p2p:staff-one', 'session-one');
await store.markSeen('message-one');
const outboundAt = Date.now() - 1_000;
await store.rememberOutboundMessage({
conversationKey: 'p2p:staff-one',
text: '可恢复的 AI Card 正文',
sentAt: outboundAt,
completedAt: outboundAt + 500,
providerMessageIds: ['dsh-card-one'],
});
const first = await store.recordPendingSender({
staffId: 'staff-one',
displayName: '小明',
@ -40,6 +48,18 @@ test('state store persists sessions, dedupe IDs, and host-only pending sender re
const reloaded = await new DingtalkStateStore(path).load();
assert.deepEqual(reloaded.pendingSenders(), [updated]);
assert.equal(reloaded.recentOutboundTextFor({
conversationKey: 'p2p:staff-one',
processQueryKey: 'dsh-card-one',
}), '可恢复的 AI Card 正文');
assert.equal(reloaded.recentOutboundTextFor({
conversationKey: 'p2p:staff-one',
createdAt: outboundAt,
}), '可恢复的 AI Card 正文');
assert.equal(reloaded.recentOutboundTextFor({
conversationKey: 'p2p:other',
processQueryKey: 'dsh-card-one',
}), null);
assert.equal(await reloaded.removePendingSenderByStaffId('staff-one'), true);
assert.deepEqual(reloaded.pendingSenders(), []);
});
@ -79,5 +99,6 @@ test('state store keeps only normalized fields from an existing pending-sender d
lastSeenAt: '2026-08-15T01:02:00.000Z',
},
},
recentOutboundMessages: [],
});
});