mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-17 02:30:45 +08:00
feat: add status reactions across messaging channels
This commit is contained in:
parent
aa8fd71b93
commit
da01e3acee
30 changed files with 1853 additions and 274 deletions
|
|
@ -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 = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue