feat: add status reactions across messaging channels

This commit is contained in:
xmanrui 2026-08-25 17:12:28 +08:00
parent aa8fd71b93
commit da01e3acee
30 changed files with 1853 additions and 274 deletions

View file

@ -9,6 +9,11 @@ import {
dingtalkInboundMessage,
DingtalkHarnessBridge,
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
import {
DINGTALK_DONE_REACTION_NAME,
DINGTALK_ERROR_REACTION_NAME,
DINGTALK_THINKING_REACTION_NAME,
} from '../../../src/channels/dingtalk/dingtalk-api.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
@ -945,6 +950,260 @@ test('bridge maps a DingTalk direct conversation to one persistent Harness sessi
assert.equal(status.stats.messagesReplied, 2);
});
test('bridge replaces the native thinking reaction with done after a successful reply', async () => {
const fixture = stateFixture();
const events = [];
const recallPending = deferred();
const bridge = new DingtalkHarnessBridge({
api: {
addReaction: async (request) => {
events.push([
'add',
request.reactionName,
request.messageId,
request.conversationId,
request.robotCode,
]);
},
recallReaction: async (request) => {
events.push(['recall', request.reactionName, request.messageId, request.conversationId]);
return recallPending.promise;
},
sendText: async ({ text }) => events.push(['reply', text]),
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-reaction-success',
ask: async () => '已完成',
},
state: fixture.state,
});
await bridge.accept(message('reaction-success', '请回答', {
robotCode: 'robot-from-callback',
}));
await eventually(() => events.some(([event]) => event === 'recall'));
assert.deepEqual(events, [
[
'add',
DINGTALK_THINKING_REACTION_NAME,
'reaction-success',
'conversation-reaction-success',
'robot-from-callback',
],
['reply', '已完成'],
[
'recall',
DINGTALK_THINKING_REACTION_NAME,
'reaction-success',
'conversation-reaction-success',
],
]);
assert.equal(bridge.status.messagesReplied, 1);
assert.equal(bridge.status.reactionsAdded, 1);
recallPending.resolve();
await eventually(() => bridge.status.reactionsAdded === 2);
assert.deepEqual(events.at(-1), [
'add',
DINGTALK_DONE_REACTION_NAME,
'reaction-success',
'conversation-reaction-success',
'robot-from-callback',
]);
assert.equal(bridge.status.reactionsRemoved, 1);
assert.equal(bridge.status.reactionErrors, 0);
});
test('bridge reacts only to safe, addressed, non-duplicate messages', async () => {
const fixture = stateFixture();
const reactions = [];
const bridge = new DingtalkHarnessBridge({
api: {
addReaction: async ({ messageId, reactionName }) => {
reactions.push(`add:${messageId}:${reactionName}`);
},
recallReaction: async ({ messageId, reactionName }) => {
reactions.push(`recall:${messageId}:${reactionName}`);
},
sendText: async () => true,
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-reaction-validation',
ask: async () => '已回复',
},
state: fixture.state,
});
await bridge.accept(message('reaction-unmentioned', '群聊噪音', {
conversationType: '2',
conversationId: 'reaction-group',
isInAtList: false,
}));
await bridge.accept(message('reaction-unsafe', '不安全路由', {
sessionWebhook: 'https://example.com/reply',
}));
await bridge.accept(message('reaction-valid', '正常问题'));
await bridge.accept(message('reaction-valid', '重复问题'));
await eventually(() => reactions.includes(
`add:reaction-valid:${DINGTALK_DONE_REACTION_NAME}`,
));
assert.deepEqual(reactions, [
`add:reaction-valid:${DINGTALK_THINKING_REACTION_NAME}`,
`recall:reaction-valid:${DINGTALK_THINKING_REACTION_NAME}`,
`add:reaction-valid:${DINGTALK_DONE_REACTION_NAME}`,
]);
});
test('a hanging reaction cannot delay an error reply or the message queue', async () => {
const fixture = stateFixture();
const sent = [];
let thinkingAdds = 0;
let asks = 0;
let recalls = 0;
const terminals = [];
const bridge = new DingtalkHarnessBridge({
api: {
addReaction: async ({ reactionName }) => {
if (reactionName !== DINGTALK_THINKING_REACTION_NAME) {
terminals.push(reactionName);
return true;
}
thinkingAdds += 1;
if (thinkingAdds === 1) throw new Error('reaction unavailable');
return new Promise(() => {});
},
recallReaction: async () => { recalls += 1; },
sendText: async ({ text }) => sent.push(text),
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-reaction-failure',
ask: async () => {
asks += 1;
if (asks === 1) throw new Error('private Harness failure');
return '第二条正常';
},
},
state: fixture.state,
logger: { debug() {}, error() {} },
reactionTimeoutMs: 20,
});
await Promise.race([
bridge.accept(message('reaction-failure', '第一条')),
new Promise((_, reject) => setTimeout(() => reject(new Error('error reply was blocked')), 100)),
]);
await Promise.race([
bridge.accept(message('reaction-next', '第二条')),
new Promise((_, reject) => setTimeout(() => reject(new Error('next message was blocked')), 100)),
]);
assert.equal(asks, 2);
assert.equal(sent.length, 2, 'the safe error reply and next normal reply are both delivered');
assert.equal(sent.some((text) => text.includes('private Harness failure')), false);
assert.equal(sent.some((text) => text.includes('第二条正常')), true);
await eventually(() => recalls === 4 && terminals.length === 2);
assert.deepEqual(terminals, [DINGTALK_ERROR_REACTION_NAME, DINGTALK_DONE_REACTION_NAME]);
assert.equal(bridge.status.reactionsRemoved, 4);
assert.equal(bridge.status.reactionErrors, 2);
});
test('runtime abort recalls the native thinking reaction without delaying stop', async () => {
const fixture = stateFixture();
const controller = new AbortController();
const askStarted = deferred();
let recalls = 0;
const added = [];
const bridge = new DingtalkHarnessBridge({
api: {
addReaction: async ({ reactionName }) => {
added.push(reactionName);
return true;
},
recallReaction: async () => { recalls += 1; },
sendText: async () => true,
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-reaction-abort',
ask: async (_sessionId, _text, options) => {
askStarted.resolve();
await new Promise((resolve, reject) => {
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
once: true,
});
});
},
},
state: fixture.state,
signal: controller.signal,
});
const task = bridge.accept(message('reaction-abort', '等待停止'));
await askStarted.promise;
controller.abort(new DOMException('runtime stopped', 'AbortError'));
await Promise.race([
task,
new Promise((_, reject) => setTimeout(() => reject(new Error('abort was blocked')), 100)),
]);
await eventually(() => recalls === 1);
assert.deepEqual(added, [DINGTALK_THINKING_REACTION_NAME]);
});
test('a failed DingTalk reaction cleanup is retried once without delaying the reply', async () => {
const fixture = stateFixture();
let recalls = 0;
const events = [];
const bridge = new DingtalkHarnessBridge({
api: {
addReaction: async ({ reactionName }) => {
events.push(`add:${reactionName}`);
return true;
},
recallReaction: async ({ reactionName }) => {
recalls += 1;
events.push(`recall:${reactionName}:${recalls}`);
if (recalls === 1) throw new Error('temporary cleanup failure');
return true;
},
sendText: async () => true,
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-reaction-retry',
ask: async () => '已完成',
},
state: fixture.state,
logger: { debug() {}, error() {} },
reactionTimeoutMs: 20,
});
await bridge.accept(message('reaction-retry', '请回答'));
await eventually(() => events.includes(`add:${DINGTALK_DONE_REACTION_NAME}`));
assert.deepEqual(events, [
`add:${DINGTALK_THINKING_REACTION_NAME}`,
`recall:${DINGTALK_THINKING_REACTION_NAME}:1`,
`recall:${DINGTALK_THINKING_REACTION_NAME}:2`,
`add:${DINGTALK_DONE_REACTION_NAME}`,
]);
assert.equal(bridge.status.reactionErrors, 1);
assert.equal(bridge.status.reactionsRemoved, 1);
assert.equal(bridge.status.reactionsAdded, 2);
});
test('senders in the bot visibility scope enter Harness without local approval', async () => {
const fixture = stateFixture();
const sent = [];