dsh-im-ops/test/channels/qq/bridge.test.mjs

1018 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
function deferred() {
let resolve;
let reject;
const promise = new Promise((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
async function eventually(predicate, messageText = 'condition was not met') {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.fail(messageText);
}
function stateFixture(initialSessions = []) {
const sessions = new Map(initialSessions);
const seen = new Set();
return {
sessions,
seen,
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
},
};
}
function message(overrides = {}) {
return {
kind: 'c2c',
rawEventType: 'C2C_MESSAGE_CREATE',
senderId: 'owner-openid',
senderIsBot: false,
content: '请回答',
messageId: 'msg-1',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'msg-1' },
...overrides,
};
}
test('QQ private messages stream Harness snapshots and finalize once', async () => {
const frames = [];
const sent = [];
const seen = new Set();
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sent.push(text),
openStream: () => ({
update: async (text) => frames.push(text),
complete: async () => frames.push('DONE'),
cancel() {},
}),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
createSession: async () => 'session-new',
ensureRunning: async () => true,
ask: async (_session, _text, { onUpdate }) => {
await onUpdate({ type: 'text', text: '回答中' });
return '最终回答';
},
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
setSession: async () => {},
clearSession: async () => {},
},
});
await bridge.accept(message());
assert.deepEqual(frames, ['回答中', '最终回答', 'DONE']);
assert.deepEqual(sent, []);
assert.equal(seen.has('msg-1'), true);
assert.equal(bridge.status.messagesReplied, 1);
});
test('QQ bridge accepts only the scanner and requires an at-message event in groups', async () => {
let asks = 0;
const state = {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session',
sessionExists: async () => true,
setSession: async () => {},
clearSession: async () => {},
};
const bridge = new QqHarnessBridge({
bot: { sendText: async () => {} },
ownerUserOpenid: 'owner-openid',
harness: { sessionExists: async () => true, ask: async () => { asks += 1; return 'ok'; } },
state,
});
await bridge.accept(message({ messageId: 'other', senderId: 'other-openid' }));
await bridge.accept(message({
messageId: 'group', kind: 'group', groupOpenid: 'group-1', rawEventType: 'GROUP_MESSAGE_CREATE',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group' },
}));
assert.equal(asks, 0);
assert.equal(bridge.status.messagesRejected, 1);
});
test('QQ credential-bound bots accept senders within the platform visibility scope', async () => {
let asks = 0;
const bridge = new QqHarnessBridge({
bot: { sendText: async () => {} },
ownerUserOpenid: '*',
harness: {
sessionExists: async () => true,
ask: async () => { asks += 1; return 'ok'; },
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session',
setSession: async () => {},
clearSession: async () => {},
},
});
await bridge.accept(message({ messageId: 'visible', senderId: 'visible-user' }));
assert.equal(asks, 1);
assert.equal(bridge.status.messagesRejected, 0);
});
test('QQ group questions require the initiating actor to mention the bot and preserve multi-question answers', async () => {
const fixture = stateFixture([['group:group-1', 'session-group']]);
const sent = [];
const asked = [];
const submitted = deferred();
const secondQuestionDelivered = deferred();
const releaseSecondQuestion = deferred();
const bridge = new QqHarnessBridge({
bot: {
sendText: async (target, text) => {
sent.push({ target, text });
if (text.includes('选择交付内容')) {
secondQuestionDelivered.resolve();
await releaseSecondQuestion.promise;
}
},
},
ownerUserOpenid: '*',
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, text, options) => {
asked.push({ sessionId, text });
if (text === '旁观者的普通问题') return '旁观者问题已回答';
await options.onInteraction({
kind: 'question',
interactionId: 'qq-group-multi',
rpcId: 'qq-group-multi',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [
{
id: 'language',
question: '选择回答语言',
options: [{ label: '中文' }, { label: 'English' }],
},
{
id: 'deliverables',
question: '选择交付内容',
multiSelect: true,
options: [{ label: '测试' }, { label: '文档' }],
},
],
},
respond: async (result) => {
submitted.resolve(result);
return { accepted: true };
},
});
await submitted.promise;
return '发起者交互完成';
},
},
state: fixture.state,
});
const first = bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
senderId: 'initiator',
groupOpenid: 'group-1',
content: '启动群聊交互',
messageId: 'group-start',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group-start' },
}));
await eventually(() => sent.some(({ text }) => text.includes('选择回答语言')));
assert.equal(sent.find(({ text }) => text.includes('选择回答语言')).text
.includes('群聊中请 @机器人 后发送答案。'), true);
let submittedResult;
submitted.promise.then((result) => { submittedResult = result; });
const bystander = bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
senderId: 'bystander',
groupOpenid: 'group-1',
content: '旁观者的普通问题',
messageId: 'group-bystander',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group-bystander' },
}));
await bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_MESSAGE_CREATE',
senderId: 'initiator',
groupOpenid: 'group-1',
content: '未提及机器人的答案',
messageId: 'group-unmentioned',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group-unmentioned' },
}));
assert.equal(submittedResult, undefined);
const firstAnswer = bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
senderId: 'initiator',
groupOpenid: 'group-1',
content: '2',
messageId: 'group-language',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group-language' },
}));
await secondQuestionDelivered.promise;
const secondAnswer = bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
senderId: 'initiator',
groupOpenid: 'group-1',
content: '1,文档,发布说明',
messageId: 'group-deliverables',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group-deliverables' },
}));
releaseSecondQuestion.resolve();
await Promise.all([firstAnswer, secondAnswer]);
assert.deepEqual(await submitted.promise, {
ok: true,
value: {
sessionId: 'session-group',
answer: {
answers: [
{ id: 'language', selected: ['English'] },
{ id: 'deliverables', selected: ['测试', '文档'], custom: '发布说明' },
],
},
},
});
await Promise.all([first, bystander]);
assert.deepEqual(asked, [
{ sessionId: 'session-group', text: '启动群聊交互' },
{ sessionId: 'session-group', text: '旁观者的普通问题' },
]);
assert.equal(asked.some(({ text }) => [
'未提及机器人的答案',
'2',
'1,文档,发布说明',
].includes(text)), false);
assert.deepEqual(sent.slice(-2).map(({ text }) => text), ['发起者交互完成', '旁观者问题已回答']);
});
test('QQ pending questions stay isolated between private conversations', async () => {
const fixture = stateFixture([
['c2c:user-a', 'session-a'],
['c2c:user-b', 'session-b'],
]);
const sent = [];
const asked = [];
const answeredA = deferred();
const responseA = deferred();
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: '*',
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing sessions should be reused'),
ask: async (sessionId, text, options) => {
asked.push({ sessionId, text });
if (sessionId === 'session-b') return '乙会话的普通回答';
await options.onInteraction({
kind: 'question',
interactionId: 'qq-isolated-a',
rpcId: 'qq-isolated-a',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'answer-a', question: '甲会话的问题' }],
},
respond: async (result) => {
responseA.resolve(result);
answeredA.resolve();
return { accepted: true };
},
});
await answeredA.promise;
return '甲会话完成';
},
},
state: fixture.state,
});
const firstA = bridge.accept(message({
senderId: 'user-a',
content: '启动甲会话',
messageId: 'private-a-start',
replyTarget: { scope: 'c2c', targetId: 'user-a', msgId: 'private-a-start' },
}));
await eventually(() => sent.some(({ text }) => text.includes('甲会话的问题')));
await bridge.accept(message({
senderId: 'user-b',
content: '乙会话的消息',
messageId: 'private-b',
replyTarget: { scope: 'c2c', targetId: 'user-b', msgId: 'private-b' },
}));
assert.equal(sent.some(({ target, text }) => (
target.targetId === 'user-b' && text === '乙会话的普通回答'
)), true);
await bridge.accept(message({
senderId: 'user-a',
content: '甲的答案',
messageId: 'private-a-answer',
replyTarget: { scope: 'c2c', targetId: 'user-a', msgId: 'private-a-answer' },
}));
await firstA;
assert.deepEqual((await responseA.promise).value.answer.answers, [
{ id: 'answer-a', selected: [], custom: '甲的答案' },
]);
assert.deepEqual(asked, [
{ sessionId: 'session-a', text: '启动甲会话' },
{ sessionId: 'session-b', text: '乙会话的消息' },
]);
});
test('QQ presents concurrent approvals in FIFO order without a code and consumes exact decisions', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-approval']]);
const sent = [];
const asked = [];
const completed = deferred();
const responses = [];
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, text, options) => {
asked.push(text);
for (const [approvalId, reason, command] of [
['qq-approval-one', '允许执行第一步', "printf 'first-step\\n'"],
['qq-approval-two', '允许执行第二步', "printf 'second-step\\n'"],
]) {
await options.onInteraction({
kind: 'approval',
interactionId: approvalId,
rpcId: `${approvalId}-rpc`,
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId,
toolName: 'bash',
callId: `${approvalId}-call`,
reason,
},
toolCall: {
callId: `${approvalId}-call`,
name: 'bash',
arguments: JSON.stringify({ command }),
},
respond: async (result) => {
responses.push(result);
if (responses.length === 2) completed.resolve();
return { accepted: true };
},
});
}
await completed.promise;
return '审批完成';
},
},
state: fixture.state,
});
const prompt = bridge.accept(message({
messageId: 'approval-start',
content: '启动两个审批',
}));
await eventually(() => sent.some(({ text }) => text.includes('允许执行第一步')));
assert.equal(sent.some(({ text }) => text.includes('允许执行第二步')), false);
assert.match(sent.find(({ text }) => text.includes('允许执行第一步')).text, /bash/);
assert.match(sent.find(({ text }) => text.includes('允许执行第一步')).text, /批准.*拒绝/s);
assert.doesNotMatch(sent.find(({ text }) => text.includes('允许执行第一步')).text, /qq-approval-one/);
await bridge.accept(message({
messageId: 'approval-allow',
content: '批准',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'approval-allow' },
}));
await eventually(() => sent.some(({ text }) => text.includes('允许执行第二步')));
assert.doesNotMatch(sent.find(({ text }) => text.includes('允许执行第二步')).text, /qq-approval-two/);
await Promise.all([
bridge.accept(message({
messageId: 'approval-reject',
content: '拒绝',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'approval-reject' },
})),
prompt,
]);
assert.deepEqual(responses, [
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'qq-approval-one',
outcome: 'allowed-once',
},
},
{
ok: true,
value: {
sessionId: 'session-approval',
approvalId: 'qq-approval-two',
outcome: 'rejected',
},
},
]);
assert.deepEqual(asked, ['启动两个审批']);
assert.equal(sent.at(-1).text, '审批完成');
});
test('QQ deduplicates question replays, cancels orphan questions, and keeps approvals fail-closed', async () => {
const fixture = stateFixture();
const sent = [];
let approvalResponse;
let parallelResponse;
let orphanResponse;
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-replay',
ask: async (sessionId, _text, options) => {
const replayedQuestion = {
kind: 'question',
interactionId: 'qq-replayed-question',
rpcId: 'qq-replayed-question',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'choice', question: '只应显示一次' }],
},
respond: async () => ({ accepted: true }),
};
await options.onInteraction(replayedQuestion);
await options.onInteraction(replayedQuestion);
await options.onInteraction({
kind: 'question',
interactionId: 'qq-parallel-question',
rpcId: 'qq-parallel-question',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'parallel', question: '不应展示的并行问题' }],
},
respond: async (result) => {
parallelResponse = result;
return { accepted: true };
},
});
await options.onInteraction({
kind: 'approval',
interactionId: 'qq-approval',
rpcId: 'qq-approval',
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: 'qq-approval',
toolName: 'bash',
},
respond: async (result) => { approvalResponse = result; },
});
await options.onInteractionResolved({
kind: 'question',
interactionId: 'qq-replayed-question',
sessionId,
outcome: 'cancelled',
});
await options.onInteraction({
kind: 'question',
interactionId: 'qq-orphan-question',
rpcId: 'qq-orphan-question',
sessionId,
recovered: true,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'secret', question: '旧会话中的敏感问题内容' }],
},
respond: async (result) => {
orphanResponse = result;
return { accepted: true };
},
});
return '交互恢复完成';
},
},
state: fixture.state,
logger: { warn() {}, error() {} },
});
await bridge.accept(message({ messageId: 'qq-replay', content: '测试交互重放' }));
assert.equal(sent.filter(({ text }) => text.includes('只应显示一次')).length, 1);
assert.deepEqual(parallelResponse, {
ok: false,
error: {
code: 'cancelled',
message: 'QQ is already handling another user interaction.',
details: {},
},
});
assert.deepEqual(approvalResponse, {
ok: true,
value: {
sessionId: 'session-replay',
approvalId: 'qq-approval',
outcome: 'rejected',
},
});
assert.equal(sent.some(({ text }) => text.includes('approval')), false);
assert.deepEqual(orphanResponse, {
ok: false,
error: {
code: 'cancelled',
message: 'QQ safely cancelled an interaction left by an earlier client.',
details: {},
},
});
assert.equal(sent.some(({ text }) => text.includes('旧会话中的敏感问题内容')), false);
assert.equal(sent.some(({ text }) => text.includes('遗留的待回答问题')), true);
assert.equal(sent.at(-1).text, '交互恢复完成');
});
test('QQ keeps a queued prompt separate while a failed interaction answer is retried', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-submit-retry']]);
const sent = [];
const asked = [];
const firstSubmitStarted = deferred();
const releaseFirstSubmit = deferred();
const answered = deferred();
const submittedAnswers = [];
let submitAttempts = 0;
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, text, options) => {
asked.push(text);
if (text === '排队的下一个问题') return '第二轮完成';
await options.onInteraction({
kind: 'question',
interactionId: 'qq-submit-retry',
rpcId: 'qq-submit-retry',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'answer', question: '请回答后再继续' }],
},
respond: async (result) => {
submittedAnswers.push(result.value.answer.answers[0].custom);
submitAttempts += 1;
if (submitAttempts === 1) {
firstSubmitStarted.resolve();
await releaseFirstSubmit.promise;
throw new Error('temporary response failure');
}
answered.resolve();
return { accepted: true };
},
});
await answered.promise;
return '第一轮完成';
},
},
state: fixture.state,
logger: { warn() {}, error() {} },
});
const first = bridge.accept(message({ messageId: 'retry-start', content: '启动可重试交互' }));
await eventually(() => sent.some(({ text }) => text.includes('请回答后再继续')));
const firstAnswer = bridge.accept(message({
messageId: 'retry-first-answer',
content: '第一次答案',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'retry-first-answer' },
}));
await firstSubmitStarted.promise;
let nextSettled = false;
const next = bridge.accept(message({
messageId: 'retry-next',
content: '排队的下一个问题',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'retry-next' },
})).finally(() => { nextSettled = true; });
releaseFirstSubmit.resolve();
await firstAnswer;
await eventually(() => sent.some(({ text }) => text.includes('回答提交失败')));
assert.equal(nextSettled, false);
assert.deepEqual(asked, ['启动可重试交互']);
await Promise.all([
bridge.accept(message({
messageId: 'retry-second-answer',
content: '重试后的答案',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'retry-second-answer' },
})),
first,
next,
]);
assert.deepEqual(submittedAnswers, ['第一次答案', '重试后的答案']);
assert.deepEqual(asked, ['启动可重试交互', '排队的下一个问题']);
assert.deepEqual(sent.slice(-2).map(({ text }) => text), ['第一轮完成', '第二轮完成']);
});
test('QQ discards an already-claimed answer when the interaction resolves elsewhere', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-resolved-race']]);
const originalMarkSeen = fixture.state.markSeen;
const answerMarkStarted = deferred();
const releaseAnswerMark = deferred();
fixture.state.markSeen = async (id) => {
if (id === 'resolved-answer') {
answerMarkStarted.resolve();
await releaseAnswerMark.promise;
}
await originalMarkSeen(id);
};
const sent = [];
const asked = [];
const resolved = deferred();
let resolveInteraction;
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, text, options) => {
asked.push(text);
if (text === '后来的普通问题') return '后来问题的回答';
await options.onInteraction({
kind: 'question',
interactionId: 'qq-resolved-race',
rpcId: 'qq-resolved-race',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'answer', question: '可能在其他客户端回答' }],
},
respond: async () => ({ accepted: true }),
});
resolveInteraction = async () => {
await options.onInteractionResolved({
kind: 'question',
interactionId: 'qq-resolved-race',
sessionId,
outcome: 'answered',
});
resolved.resolve();
};
await resolved.promise;
return '第一轮已由其他客户端完成';
},
},
state: fixture.state,
});
const first = bridge.accept(message({ messageId: 'resolved-start', content: '启动外部解决竞态' }));
await eventually(() => typeof resolveInteraction === 'function');
const answer = bridge.accept(message({
messageId: 'resolved-answer',
content: '原本的问题答案',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'resolved-answer' },
}));
await answerMarkStarted.promise;
const later = bridge.accept(message({
messageId: 'resolved-later',
content: '后来的普通问题',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'resolved-later' },
}));
await resolveInteraction();
releaseAnswerMark.resolve();
await Promise.all([answer, first, later]);
assert.deepEqual(asked, ['启动外部解决竞态', '后来的普通问题']);
assert.equal(asked.includes('原本的问题答案'), false);
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
});
test('QQ keeps an answer that arrives after the first question is delivered but before its send ACK', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-first-delivery']]);
const questionDelivered = deferred();
const releaseQuestionAck = deferred();
const answered = deferred();
const sent = [];
const asked = [];
let submitted;
let questionSends = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (target, text) => {
sent.push({ target, text });
if (text.includes('首问 ACK 窗口')) {
questionSends += 1;
questionDelivered.resolve();
await releaseQuestionAck.promise;
}
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (sessionId, text, options) => {
asked.push(text);
await options.onInteraction({
kind: 'question',
interactionId: 'qq-first-delivery',
rpcId: 'qq-first-delivery',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'answer', question: '首问 ACK 窗口' }],
},
respond: async (result) => {
submitted = result;
answered.resolve();
return { accepted: true };
},
});
await answered.promise;
return '首问已完成';
},
},
state: fixture.state,
});
const first = bridge.accept(message({
messageId: 'first-delivery-start',
content: '启动首问窗口',
}));
await questionDelivered.promise;
const answer = bridge.accept(message({
messageId: 'first-delivery-answer',
content: '窗口内答案',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'first-delivery-answer' },
}));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(submitted, undefined);
releaseQuestionAck.resolve();
await Promise.all([first, answer]);
assert.equal(questionSends, 1);
assert.deepEqual(asked, ['启动首问窗口']);
assert.deepEqual(submitted.value.answer.answers, [{
id: 'answer',
selected: [],
custom: '窗口内答案',
}]);
assert.equal(fixture.seen.has('first-delivery-answer'), true);
});
test('QQ tombstones a q2 answer accepted before its send ACK when the interaction resolves', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-q2-resolved']]);
const secondQuestionDelivered = deferred();
const releaseSecondQuestionAck = deferred();
const turnResolved = deferred();
const sent = [];
const asked = [];
let resolveInteraction;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (target, text) => {
sent.push({ target, text });
if (text.includes('会在 ACK 前 resolved 的第二问')) {
secondQuestionDelivered.resolve();
await releaseSecondQuestionAck.promise;
}
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (sessionId, text, options) => {
asked.push(text);
await options.onInteraction({
kind: 'question',
interactionId: 'qq-q2-resolved',
rpcId: 'qq-q2-resolved',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [
{ id: 'first', question: '先回答第一问' },
{ id: 'second', question: '会在 ACK 前 resolved 的第二问' },
],
},
respond: async () => assert.fail('the externally resolved interaction must not be answered'),
});
resolveInteraction = () => {
options.onInteractionResolved({
kind: 'question',
interactionId: 'qq-q2-resolved',
sessionId,
outcome: 'answered',
});
turnResolved.resolve();
};
await turnResolved.promise;
return '已由其他客户端完成';
},
},
state: fixture.state,
});
const first = bridge.accept(message({
messageId: 'q2-resolved-start',
content: '启动 q2 resolved 窗口',
}));
await eventually(() => typeof resolveInteraction === 'function');
const firstAnswer = bridge.accept(message({
messageId: 'q2-resolved-first',
content: '第一问答案',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'q2-resolved-first' },
}));
await secondQuestionDelivered.promise;
const secondAnswer = bridge.accept(message({
messageId: 'q2-resolved-second',
content: '第二问答案',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'q2-resolved-second' },
}));
resolveInteraction();
releaseSecondQuestionAck.resolve();
await Promise.all([firstAnswer, secondAnswer, first]);
assert.deepEqual(asked, ['启动 q2 resolved 窗口']);
assert.equal(fixture.seen.has('q2-resolved-second'), true);
assert.equal(sent.some(({ text }) => text.includes('已在其他客户端处理')), true);
});
test('QQ reports resolved when an in-flight response becomes not-pending', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-respond-resolved']]);
const responseStarted = deferred();
const releaseResponse = deferred();
const turnResolved = deferred();
const sent = [];
let resolveInteraction;
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (sessionId, _text, options) => {
await options.onInteraction({
kind: 'question',
interactionId: 'qq-respond-resolved',
rpcId: 'qq-respond-resolved',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'answer', question: '提交中会被外部解决' }],
},
respond: async () => {
responseStarted.resolve();
await releaseResponse.promise;
const error = new Error('already resolved');
error.code = 'interaction-not-pending';
throw error;
},
});
resolveInteraction = () => {
options.onInteractionResolved({
kind: 'question',
interactionId: 'qq-respond-resolved',
sessionId,
outcome: 'answered',
});
turnResolved.resolve();
};
await turnResolved.promise;
return '外部处理完成';
},
},
state: fixture.state,
});
const first = bridge.accept(message({
messageId: 'respond-resolved-start',
content: '启动提交竞态',
}));
await eventually(() => typeof resolveInteraction === 'function');
const answer = bridge.accept(message({
messageId: 'respond-resolved-answer',
content: '我的答案',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'respond-resolved-answer' },
}));
await responseStarted.promise;
resolveInteraction();
releaseResponse.resolve();
await Promise.all([answer, first]);
assert.equal(sent.some(({ text, target }) => (
target.msgId === 'respond-resolved-answer'
&& text.includes('已在其他客户端处理')
)), true);
});
test('QQ propagates the stop signal and cancels its pending question on abort', async () => {
const fixture = stateFixture();
fixture.sessions.set('c2c:owner-openid', 'stale-session');
const controller = new AbortController();
const interactionReady = deferred();
let existsSignal;
let createSignal;
let askSignal;
let cancellation;
let cancellationSignal;
const bridge = new QqHarnessBridge({
bot: { sendText: async () => {} },
ownerUserOpenid: 'owner-openid',
signal: controller.signal,
harness: {
sessionExists: async (_sessionId, options) => {
existsSignal = options.signal;
return false;
},
createSession: async (options) => {
createSignal = options.signal;
return 'session-abort';
},
ask: async (sessionId, _text, options) => {
askSignal = options.signal;
await options.onInteraction({
kind: 'question',
interactionId: 'qq-abort-question',
rpcId: 'qq-abort-question',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'answer', question: '等待进程停止' }],
},
respond: async (result, respondOptions) => {
cancellation = result;
cancellationSignal = respondOptions.signal;
return { accepted: true };
},
});
interactionReady.resolve();
await new Promise((resolve, reject) => {
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
});
},
},
state: fixture.state,
logger: { warn() {}, error() {} },
});
const turn = bridge.accept(message({ messageId: 'abort-start', content: '启动后停止' }));
await interactionReady.promise;
controller.abort(new Error('runtime stopped'));
await turn;
assert.equal(existsSignal, controller.signal);
assert.equal(createSignal, controller.signal);
assert.equal(askSignal, controller.signal);
assert.deepEqual(cancellation, {
ok: false,
error: {
code: 'cancelled',
message: 'The QQ interaction ended before the user answered.',
details: {},
},
});
assert.notEqual(cancellationSignal, controller.signal);
assert.equal(cancellationSignal.aborted, false);
});