mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 09:20:52 +08:00
fix: preserve quoted message context across channels
This commit is contained in:
parent
c2be2389b0
commit
94e114b9b3
46 changed files with 4242 additions and 321 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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('最终答案');
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue