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

@ -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) => {

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 = [];