mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-14 16:50:47 +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
|
|
@ -5,6 +5,9 @@ import {
|
|||
createDingtalkApi,
|
||||
DINGTALK_AI_CARD_TEMPLATE_ID,
|
||||
DINGTALK_API_BASE_URL,
|
||||
DINGTALK_DONE_REACTION_NAME,
|
||||
DINGTALK_ERROR_REACTION_NAME,
|
||||
DINGTALK_THINKING_REACTION_NAME,
|
||||
DingtalkApiError,
|
||||
normalizeDingtalkCardMarkdown,
|
||||
normalizeDingtalkSessionWebhook,
|
||||
|
|
@ -114,6 +117,120 @@ test('session replies use the fixed token endpoint, reject redirects, and cache
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk adds, recalls, and replaces its native status reactions', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const request = {
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
messageId: 'open-message-one',
|
||||
conversationId: 'open-conversation-one',
|
||||
};
|
||||
|
||||
await api.addReaction({ ...request, reactionName: DINGTALK_THINKING_REACTION_NAME });
|
||||
await api.recallReaction({ ...request, reactionName: DINGTALK_THINKING_REACTION_NAME });
|
||||
await api.addReaction({ ...request, reactionName: DINGTALK_DONE_REACTION_NAME });
|
||||
await api.addReaction({ ...request, reactionName: DINGTALK_ERROR_REACTION_NAME });
|
||||
|
||||
assert.equal(calls.filter(({ url }) => url.includes('/oauth2/accessToken')).length, 1);
|
||||
assert.deepEqual(calls.slice(1).map(({ url, options }) => ({
|
||||
path: new URL(url).pathname,
|
||||
token: options.headers['x-acs-dingtalk-access-token'],
|
||||
body: JSON.parse(options.body),
|
||||
})), [
|
||||
['reply', DINGTALK_THINKING_REACTION_NAME],
|
||||
['recall', DINGTALK_THINKING_REACTION_NAME],
|
||||
['reply', DINGTALK_DONE_REACTION_NAME],
|
||||
['reply', DINGTALK_ERROR_REACTION_NAME],
|
||||
].map(([action, reactionName]) => ({
|
||||
path: `/v1.0/robot/emotion/${action}`,
|
||||
token: 'reaction-access-token',
|
||||
body: {
|
||||
robotCode: 'robot-from-callback',
|
||||
openMsgId: 'open-message-one',
|
||||
openConversationId: 'open-conversation-one',
|
||||
emotionType: 2,
|
||||
emotionName: reactionName,
|
||||
textEmotion: {
|
||||
emotionId: '2659900',
|
||||
emotionName: reactionName,
|
||||
text: reactionName,
|
||||
backgroundId: 'im_bg_1',
|
||||
},
|
||||
},
|
||||
})));
|
||||
});
|
||||
|
||||
test('DingTalk treats a success=false emotion response as rejected', async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: async (url) => url.pathname.endsWith('/oauth2/accessToken')
|
||||
? jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 })
|
||||
: jsonResponse({ success: false, code: 'emotion_not_supported' }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.addReaction({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
messageId: 'open-message-one',
|
||||
conversationId: 'open-conversation-one',
|
||||
reactionName: DINGTALK_DONE_REACTION_NAME,
|
||||
}),
|
||||
(error) => error instanceof DingtalkApiError
|
||||
&& error.code === 'reaction-rejected'
|
||||
&& error.providerCode === 'emotion_not_supported',
|
||||
);
|
||||
});
|
||||
|
||||
test('cold reactions share their token request without delaying the normal reply token request', async () => {
|
||||
const firstToken = deferred();
|
||||
const calls = [];
|
||||
let tokenRequests = 0;
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (new URL(url).pathname.endsWith('/oauth2/accessToken')) {
|
||||
tokenRequests += 1;
|
||||
if (tokenRequests === 1) return firstToken.promise;
|
||||
return jsonResponse({ accessToken: 'normal-access-token', expireIn: 7_200 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const reactions = Array.from({ length: 10 }, (_unused, index) => (
|
||||
api.addReaction({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
messageId: `open-message-${index + 1}`,
|
||||
conversationId: 'open-conversation-one',
|
||||
reactionName: DINGTALK_THINKING_REACTION_NAME,
|
||||
})
|
||||
));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(tokenRequests, 1, 'concurrent reactions must share their own cold token request');
|
||||
|
||||
await api.sendText({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=normal',
|
||||
text: '正常回复',
|
||||
});
|
||||
assert.equal(tokenRequests, 2);
|
||||
|
||||
firstToken.resolve(jsonResponse({ accessToken: 'reaction-access-token', expireIn: 7_200 }));
|
||||
await Promise.all(reactions);
|
||||
const normalSend = calls.find(({ url }) => url.includes('ticket=normal'));
|
||||
assert.equal(normalSend.options.headers['x-acs-dingtalk-access-token'], 'normal-access-token');
|
||||
});
|
||||
|
||||
test('DingTalk uploads and sends a native file message to the exact robot conversation', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
|
|
|
|||
|
|
@ -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