mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 09:20:52 +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 = [];
|
||||
|
|
|
|||
|
|
@ -165,6 +165,32 @@ test('Discord API gets a channel and starts one thread from a source message', a
|
|||
assert.equal(requests.length, 2);
|
||||
});
|
||||
|
||||
test('Discord API adds and removes the current bot reaction with an encoded emoji', async () => {
|
||||
const requests = [];
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
const target = {
|
||||
channelId: '123456789012345678',
|
||||
messageId: '987654321012345678',
|
||||
};
|
||||
|
||||
assert.equal(await api.addOwnReaction({ ...target, emoji: '👀' }), '👀');
|
||||
await api.removeOwnReaction({ ...target, emoji: '👀' });
|
||||
|
||||
assert.deepEqual(requests.map(({ options }) => options.method), ['PUT', 'DELETE']);
|
||||
assert.equal(
|
||||
decodeURIComponent(requests[0].url.pathname),
|
||||
'/api/v10/channels/123456789012345678/messages/987654321012345678/reactions/👀/@me',
|
||||
);
|
||||
assert.equal(requests[1].url.pathname, requests[0].url.pathname);
|
||||
assert.equal(requests[0].options.body, undefined);
|
||||
});
|
||||
|
||||
test('Discord API uploads a result file as a native attachment and preserves the reply', async () => {
|
||||
let request;
|
||||
const api = new DiscordApi({
|
||||
|
|
@ -440,6 +466,10 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
|||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.plainText, true);
|
||||
assert.deepEqual(direct.connectionTestTarget, { channelId: '222222222222222222' });
|
||||
assert.deepEqual(direct.reactionTarget, {
|
||||
channelId: '222222222222222222',
|
||||
messageId: '111111111111111111',
|
||||
});
|
||||
|
||||
const sticker = normalizeDiscordMessage({
|
||||
id: '111111111111111115',
|
||||
|
|
@ -599,6 +629,10 @@ test('Discord routes mentioned text and announcement messages into native thread
|
|||
});
|
||||
assert.equal(route.conversationId, message.id);
|
||||
assert.deepEqual(route.replyTarget, { channelId: message.id });
|
||||
assert.deepEqual(route.reactionTarget, {
|
||||
channelId: message.channel_id,
|
||||
messageId: message.id,
|
||||
});
|
||||
assert.deepEqual(route.conversationRoute, {
|
||||
peerId: message.channel_id,
|
||||
threadId: message.id,
|
||||
|
|
@ -912,6 +946,37 @@ test('Discord merges a deterministic Thread fallback notice into one delivered a
|
|||
assert.equal(edits[0].content, `${streamTarget.notice}\n\nstreamed answer`);
|
||||
});
|
||||
|
||||
test('Discord bot client exposes cancellable add and remove reaction operations', async () => {
|
||||
const operations = [];
|
||||
const api = {
|
||||
async addOwnReaction(options) {
|
||||
operations.push({ operation: 'add', ...options });
|
||||
return options.emoji;
|
||||
},
|
||||
async removeOwnReaction(options) {
|
||||
operations.push({ operation: 'remove', ...options });
|
||||
},
|
||||
};
|
||||
const client = new DiscordBotClient({ api });
|
||||
const target = {
|
||||
channelId: '222222222222222266',
|
||||
messageId: '111111111111111166',
|
||||
};
|
||||
const controller = new AbortController();
|
||||
|
||||
const reactionKey = await client.addReaction(target, '👀', { signal: controller.signal });
|
||||
await client.removeReaction(target, reactionKey, { signal: controller.signal });
|
||||
|
||||
assert.equal(reactionKey, '👀');
|
||||
assert.deepEqual(operations.map(({ operation, channelId, messageId, emoji, signal }) => ({
|
||||
operation, channelId, messageId, emoji, signal,
|
||||
})), [{
|
||||
operation: 'add', ...target, emoji: '👀', signal: controller.signal,
|
||||
}, {
|
||||
operation: 'remove', ...target, emoji: '👀', signal: controller.signal,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Discord keeps streamed text and result files on the final created Thread target', async () => {
|
||||
const botId = '1234567890123456789';
|
||||
const message = {
|
||||
|
|
|
|||
|
|
@ -2349,6 +2349,7 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
client: {},
|
||||
channel: {
|
||||
addReaction: async () => { throw new Error('reaction unavailable'); },
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||||
},
|
||||
harness: {
|
||||
|
|
@ -2371,10 +2372,51 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(status.messagesReplied, 1);
|
||||
await eventually(() => status.reactionErrors === 2);
|
||||
assert.equal(status.reactionErrors, 2);
|
||||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('a hanging Feishu reaction does not delay the message promise or the next queued turn', async () => {
|
||||
const seen = new Set();
|
||||
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||
let asks = 0;
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: {},
|
||||
channel: {
|
||||
addReaction: () => new Promise(() => {}),
|
||||
removeReaction: async () => undefined,
|
||||
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => `正常回答 ${++asks}`,
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
status,
|
||||
logger: { debug() {}, error() {}, warn() {} },
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
Promise.all([
|
||||
bridge.accept(event('reaction-hang-one', '第一条')),
|
||||
bridge.accept(event('reaction-hang-two', '第二条')),
|
||||
]),
|
||||
new Promise((_, reject) => setTimeout(
|
||||
() => reject(new Error('Feishu message flow waited for a reaction request')),
|
||||
100,
|
||||
)),
|
||||
]);
|
||||
|
||||
assert.equal(asks, 2);
|
||||
assert.equal(status.messagesReplied, 2);
|
||||
});
|
||||
|
||||
test('Feishu routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
|
|
@ -2737,7 +2779,7 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
|
|||
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
|
||||
test('Feishu returns the receipt independently and emits one safe notice when text and file delivery fail', async (t) => {
|
||||
for (const errorCode of ['artifact-invalid', 'artifact-unavailable']) {
|
||||
await t.test(errorCode, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, `${errorCode}.txt`, 'file bytes', errorCode);
|
||||
|
|
|
|||
79
test/channels/shared/status-reaction.test.mjs
Normal file
79
test/channels/shared/status-reaction.test.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { beginStatusReaction } from '../../../src/channels/shared/status-reaction.mjs';
|
||||
|
||||
test('status reaction replaces processing with the successful terminal reaction in order', async () => {
|
||||
const calls = [];
|
||||
const status = {};
|
||||
const reaction = beginStatusReaction({
|
||||
adapter: {
|
||||
addReaction: async (target, emoji) => {
|
||||
calls.push(['add', target, emoji]);
|
||||
return `key:${emoji}`;
|
||||
},
|
||||
removeReaction: async (target, key) => calls.push(['remove', target, key]),
|
||||
},
|
||||
target: 'source-message',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
status,
|
||||
logger: { warn() {} },
|
||||
});
|
||||
|
||||
reaction.success();
|
||||
await reaction.settled();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['add', 'source-message', 'eyes'],
|
||||
['remove', 'source-message', 'key:eyes'],
|
||||
['add', 'source-message', 'done'],
|
||||
]);
|
||||
assert.equal(status.reactionsAdded, 2);
|
||||
assert.equal(status.reactionsRemoved, 1);
|
||||
assert.equal(status.reactionErrors ?? 0, 0);
|
||||
});
|
||||
|
||||
test('status reaction has a hard timeout and absorbs provider failures', async () => {
|
||||
const status = {};
|
||||
const warnings = [];
|
||||
const reaction = beginStatusReaction({
|
||||
adapter: {
|
||||
addReaction: () => new Promise(() => {}),
|
||||
removeReaction: async () => undefined,
|
||||
},
|
||||
target: 'source-message',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
status,
|
||||
logger: { warn: (...args) => warnings.push(args) },
|
||||
timeoutMs: 20,
|
||||
});
|
||||
|
||||
reaction.error();
|
||||
await reaction.settled();
|
||||
|
||||
assert.equal(status.reactionErrors, 2);
|
||||
assert.equal(status.reactionsAdded ?? 0, 0);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
|
||||
test('the first terminal reaction wins', async () => {
|
||||
const added = [];
|
||||
const reaction = beginStatusReaction({
|
||||
adapter: {
|
||||
addReaction: async (_target, emoji) => {
|
||||
added.push(emoji);
|
||||
return emoji;
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
},
|
||||
target: 'source-message',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
logger: { warn() {} },
|
||||
});
|
||||
|
||||
reaction.error();
|
||||
reaction.success();
|
||||
reaction.clear();
|
||||
await reaction.settled();
|
||||
|
||||
assert.deepEqual(added, ['eyes', 'error']);
|
||||
});
|
||||
|
|
@ -187,6 +187,163 @@ async function committedArtifact(t, fileName, content, suffix) {
|
|||
return artifact;
|
||||
}
|
||||
|
||||
test('status reactions never delay safe errors, the conversation queue, or waitForIdle', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
let asks = 0;
|
||||
let reactionAdds = 0;
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: {
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
},
|
||||
state: fixture.state,
|
||||
status: createTextBridgeStatus(),
|
||||
bot: {
|
||||
addReaction: () => {
|
||||
reactionAdds += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
removeReaction: async () => undefined,
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-sidecar',
|
||||
ask: async () => {
|
||||
asks += 1;
|
||||
if (asks === 1) throw new Error('private failure detail');
|
||||
return '第二条正常完成';
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('reaction-hang-one', '第一条', {
|
||||
reactionTarget: { id: 'source-one' },
|
||||
}));
|
||||
const second = bridge.accept(message('reaction-hang-two', '第二条', {
|
||||
reactionTarget: { id: 'source-two' },
|
||||
}));
|
||||
|
||||
await within(Promise.all([first, second, bridge.waitForIdle()]), 100,
|
||||
'a hanging status reaction blocked normal message processing');
|
||||
|
||||
assert.equal(asks, 2);
|
||||
assert.equal(reactionAdds, 2);
|
||||
assert.equal(sent.some((text) => text.includes('第二条正常完成')), true);
|
||||
assert.equal(sent.some((text) => text.includes('private failure detail')), false);
|
||||
});
|
||||
|
||||
test('shared status reactions replace processing with success without joining the main task', async () => {
|
||||
const fixture = stateFixture();
|
||||
const reactions = [];
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: {
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
},
|
||||
state: fixture.state,
|
||||
bot: {
|
||||
addReaction: async (target, emoji) => {
|
||||
reactions.push(['add', target.id, emoji]);
|
||||
return emoji;
|
||||
},
|
||||
removeReaction: async (target, emoji) => {
|
||||
reactions.push(['remove', target.id, emoji]);
|
||||
},
|
||||
sendText: async () => undefined,
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-success',
|
||||
ask: async () => '完成',
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('reaction-success', '执行', {
|
||||
reactionTarget: { id: 'source-success' },
|
||||
}));
|
||||
await eventually(() => reactions.length === 3);
|
||||
|
||||
assert.deepEqual(reactions, [
|
||||
['add', 'source-success', 'eyes'],
|
||||
['remove', 'source-success', 'eyes'],
|
||||
['add', 'source-success', 'done'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
|
||||
const fixture = stateFixture();
|
||||
const controller = new AbortController();
|
||||
const invalidStarted = deferred();
|
||||
const releaseInvalid = deferred();
|
||||
const originalMarkSeen = fixture.state.markSeen.bind(fixture.state);
|
||||
fixture.state.markSeen = async (messageId) => {
|
||||
await originalMarkSeen(messageId);
|
||||
if (messageId === 'blocking-invalid') {
|
||||
invalidStarted.resolve();
|
||||
await releaseInvalid.promise;
|
||||
}
|
||||
};
|
||||
const sent = [];
|
||||
const reactions = [];
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: {
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
reactions: { processing: 'eyes', success: 'done', error: 'error' },
|
||||
},
|
||||
state: fixture.state,
|
||||
signal: controller.signal,
|
||||
bot: {
|
||||
addReaction: async (target, emoji) => {
|
||||
reactions.push(['add', target.id, emoji]);
|
||||
return emoji;
|
||||
},
|
||||
removeReaction: async (target, emoji) => {
|
||||
reactions.push(['remove', target.id, emoji]);
|
||||
},
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => false,
|
||||
createSession: async () => 'session-reaction-abort',
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction(questionInteraction({ sessionId }));
|
||||
await new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const processing = bridge.accept(message('interaction-start', '启动交互'));
|
||||
await eventually(() => sent.some((text) => text.includes('请回答')));
|
||||
const invalid = bridge.accept(message('blocking-invalid', ''));
|
||||
await invalidStarted.promise;
|
||||
const answer = bridge.accept(message('queued-answer', '有效回答', {
|
||||
reactionTarget: { id: 'source-answer' },
|
||||
}));
|
||||
await eventually(() => reactions.some((call) => call[0] === 'add'));
|
||||
|
||||
controller.abort(new DOMException('runtime stopped', 'AbortError'));
|
||||
releaseInvalid.resolve();
|
||||
await Promise.all([processing, invalid, answer]);
|
||||
await eventually(() => reactions.some((call) => call[0] === 'remove'));
|
||||
|
||||
assert.deepEqual(reactions, [
|
||||
['add', 'source-answer', 'eyes'],
|
||||
['remove', 'source-answer', 'eyes'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('all four shared text channels execute /compact outside the model prompt path', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
validSlackBotToken,
|
||||
} from '../../../src/channels/slack/slack-api.mjs';
|
||||
import {
|
||||
SlackBotClient,
|
||||
SlackRuntime,
|
||||
isSlackToolProgress,
|
||||
normalizeSlackEvent,
|
||||
|
|
@ -150,6 +151,79 @@ test('Slack API uses native streaming methods and suppresses generated mass ment
|
|||
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
|
||||
});
|
||||
|
||||
test('Slack API and bot client add and remove reactions on the source message', async () => {
|
||||
const calls = [];
|
||||
const requestAbort = new AbortController();
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({
|
||||
method: url.pathname.split('/').pop(),
|
||||
body: JSON.parse(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
});
|
||||
await api.addReaction({
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.123',
|
||||
emojiName: 'eyes',
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await api.removeReaction({
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.123',
|
||||
emojiName: 'eyes',
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.deepEqual(calls.map(({ method, body }) => ({ method, body })), [{
|
||||
method: 'reactions.add',
|
||||
body: { channel: 'C12345678', timestamp: '1700000000.123', name: 'eyes' },
|
||||
}, {
|
||||
method: 'reactions.remove',
|
||||
body: { channel: 'C12345678', timestamp: '1700000000.123', name: 'eyes' },
|
||||
}]);
|
||||
requestAbort.abort();
|
||||
assert.equal(calls.every(({ signal }) => signal.aborted), true);
|
||||
|
||||
const adapterCalls = [];
|
||||
const runtimeAbort = new AbortController();
|
||||
const sidecarAbort = new AbortController();
|
||||
const client = new SlackBotClient({
|
||||
api: {
|
||||
addReaction: async (payload) => adapterCalls.push({ action: 'add', ...payload }),
|
||||
removeReaction: async (payload) => adapterCalls.push({ action: 'remove', ...payload }),
|
||||
},
|
||||
signal: runtimeAbort.signal,
|
||||
});
|
||||
const target = { channelId: 'C12345678', messageTs: '1700000000.456' };
|
||||
assert.equal(await client.addReaction(target, 'white_check_mark', {
|
||||
signal: sidecarAbort.signal,
|
||||
}), 'white_check_mark');
|
||||
await client.removeReaction(target, 'white_check_mark', { signal: sidecarAbort.signal });
|
||||
assert.deepEqual(adapterCalls.map(({ action, channelId, messageTs, emojiName }) => ({
|
||||
action, channelId, messageTs, emojiName,
|
||||
})), [{
|
||||
action: 'add',
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.456',
|
||||
emojiName: 'white_check_mark',
|
||||
}, {
|
||||
action: 'remove',
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.456',
|
||||
emojiName: 'white_check_mark',
|
||||
}]);
|
||||
runtimeAbort.abort();
|
||||
assert.equal(adapterCalls.every(({ signal }) => signal.aborted), false);
|
||||
sidecarAbort.abort();
|
||||
assert.equal(adapterCalls.every(({ signal }) => signal.aborted), true);
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- reactions:write\n/);
|
||||
});
|
||||
|
||||
test('Slack API completes the native external-upload flow in the original thread', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
|
|
@ -792,6 +866,24 @@ test('Slack normalizes direct messages and addressed channel events', () => {
|
|||
assert.equal(group.conversationId, 'C12345678:1700000000.002');
|
||||
assert.equal(group.replyTarget.threadTs, '1700000000.002');
|
||||
|
||||
const threaded = normalizeSlackEvent({
|
||||
event_id: 'Ev002-threaded',
|
||||
team_id: 'T12345678',
|
||||
event: {
|
||||
type: 'app_mention',
|
||||
channel: 'C12345678',
|
||||
user: 'U87654321',
|
||||
ts: '1700000000.003',
|
||||
thread_ts: '1700000000.002',
|
||||
text: '<@U12345678> continue',
|
||||
},
|
||||
}, 'U12345678');
|
||||
assert.equal(threaded.replyTarget.threadTs, '1700000000.002');
|
||||
assert.deepEqual(threaded.reactionTarget, {
|
||||
channelId: 'C12345678',
|
||||
messageTs: '1700000000.003',
|
||||
});
|
||||
|
||||
const botMessage = normalizeSlackEvent({
|
||||
event_id: 'Ev003',
|
||||
event: {
|
||||
|
|
|
|||
|
|
@ -305,6 +305,68 @@ test('Telegram API preserves the legacy plain send and edit payloads', async ()
|
|||
assert.equal(Object.hasOwn(calls[1].body, 'parse_mode'), false);
|
||||
});
|
||||
|
||||
test('Telegram API and bot client set and clear one reaction on the source message', async () => {
|
||||
const calls = [];
|
||||
const requestAbort = new AbortController();
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({
|
||||
method: url.pathname.split('/').at(-1),
|
||||
body: JSON.parse(options.body),
|
||||
signal: options.signal,
|
||||
});
|
||||
return jsonResponse({ ok: true, result: true });
|
||||
},
|
||||
});
|
||||
await api.setMessageReaction({
|
||||
chatId: -100123,
|
||||
messageId: 44,
|
||||
emoji: ' 👀 ',
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await api.setMessageReaction({
|
||||
chatId: -100123,
|
||||
messageId: 44,
|
||||
signal: requestAbort.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
assert.deepEqual(calls.map(({ method, body }) => ({ method, body })), [{
|
||||
method: 'setMessageReaction',
|
||||
body: {
|
||||
chat_id: -100123,
|
||||
message_id: 44,
|
||||
reaction: [{ type: 'emoji', emoji: '👀' }],
|
||||
},
|
||||
}, {
|
||||
method: 'setMessageReaction',
|
||||
body: { chat_id: -100123, message_id: 44, reaction: [] },
|
||||
}]);
|
||||
requestAbort.abort();
|
||||
assert.equal(calls.every(({ signal }) => signal.aborted), true);
|
||||
|
||||
const adapterCalls = [];
|
||||
const runtimeAbort = new AbortController();
|
||||
const sidecarAbort = new AbortController();
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
setMessageReaction: async (payload) => adapterCalls.push(payload),
|
||||
},
|
||||
signal: runtimeAbort.signal,
|
||||
});
|
||||
const target = { chatId: -100123, messageId: 45 };
|
||||
assert.equal(await client.addReaction(target, '👍', { signal: sidecarAbort.signal }), '👍');
|
||||
await client.removeReaction(target, '👍', { signal: sidecarAbort.signal });
|
||||
assert.deepEqual(adapterCalls.map(({ chatId, messageId, emoji }) => ({
|
||||
chatId, messageId, emoji,
|
||||
})), [{ chatId: -100123, messageId: 45, emoji: '👍' }, {
|
||||
chatId: -100123, messageId: 45, emoji: undefined,
|
||||
}]);
|
||||
sidecarAbort.abort();
|
||||
assert.equal(adapterCalls.every(({ signal }) => signal.aborted), true);
|
||||
});
|
||||
|
||||
test('Telegram plain delivery keeps the 4000 boundary, reply, topic, and content', async () => {
|
||||
const calls = [];
|
||||
let nextMessageId = 600;
|
||||
|
|
@ -967,6 +1029,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.equal(privateMessage.kind, 'direct');
|
||||
assert.equal(privateMessage.addressed, true);
|
||||
assert.equal(privateMessage.replyTarget.chatType, 'private');
|
||||
assert.deepEqual(privateMessage.reactionTarget, { chatId: 88, messageId: 4 });
|
||||
assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined });
|
||||
|
||||
const groupMessage = normalizeTelegramUpdate({
|
||||
|
|
@ -1011,6 +1074,8 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.notEqual(topicOne.conversationId, topicTwo.conversationId);
|
||||
assert.equal(topicOne.replyTarget.messageThreadId, 100);
|
||||
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
|
||||
assert.deepEqual(topicOne.reactionTarget, { chatId: -1001, messageId: 6 });
|
||||
assert.deepEqual(topicTwo.reactionTarget, { chatId: -1001, messageId: 7 });
|
||||
});
|
||||
|
||||
test('Telegram compatible mode preserves old routing and private allowlist mode restricts inbound messages', () => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ async function within(promise, timeoutMs, message) {
|
|||
}
|
||||
}
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail('condition was not met before timeout');
|
||||
}
|
||||
|
||||
async function committedArtifact(t, {
|
||||
suffix,
|
||||
fileName = 'result.txt',
|
||||
|
|
@ -323,13 +332,26 @@ test('WhatsApp media downloader supplies Baileys reupload context', async () =>
|
|||
});
|
||||
|
||||
test('WhatsApp normalizes direct, linked-account, and explicitly mentioned group messages', () => {
|
||||
const directKey = {
|
||||
remoteJid: '16505550999@s.whatsapp.net',
|
||||
remoteJidAlt: '987654321098765@lid',
|
||||
participantAlt: '123456789012345@lid',
|
||||
addressingMode: 'lid',
|
||||
id: 'direct-1',
|
||||
fromMe: false,
|
||||
};
|
||||
const direct = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
|
||||
key: directKey,
|
||||
message: { conversation: 'hello' },
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.content, 'hello');
|
||||
assert.equal(direct.reactionTarget.key, directKey);
|
||||
assert.deepEqual(direct.reactionTarget, {
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
key: directKey,
|
||||
});
|
||||
|
||||
const group = normalizeWhatsappMessage({
|
||||
key: {
|
||||
|
|
@ -660,10 +682,12 @@ test('WhatsApp open mode answers linked-account group messages without processin
|
|||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content, options = {}) => {
|
||||
sent.push({ jid, content, options });
|
||||
replyEchoTask = callbacks.onMessage({
|
||||
key: { remoteJid: groupJid, id: options.messageId, fromMe: true },
|
||||
message: { conversation: content.text },
|
||||
});
|
||||
if (typeof content.text === 'string') {
|
||||
replyEchoTask = callbacks.onMessage({
|
||||
key: { remoteJid: groupJid, id: options.messageId, fromMe: true },
|
||||
message: { conversation: content.text },
|
||||
});
|
||||
}
|
||||
return { key: { id: options.messageId } };
|
||||
},
|
||||
};
|
||||
|
|
@ -698,13 +722,19 @@ test('WhatsApp open mode answers linked-account group messages without processin
|
|||
};
|
||||
await callbacks.onMessage(inbound);
|
||||
await replyEchoTask;
|
||||
await eventually(() => sent.filter(({ content }) => content.react).length === 2);
|
||||
|
||||
assert.equal(askCount, 1);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].jid, groupJid);
|
||||
assert.equal(sent[0].content.text, 'Harness group answer');
|
||||
assert.equal(sent[0].options.quoted, inbound);
|
||||
assert.match(sent[0].options.messageId, /^[0-9A-F]{20}$/);
|
||||
const textSends = sent.filter(({ content }) => typeof content.text === 'string');
|
||||
assert.equal(textSends.length, 1);
|
||||
assert.equal(textSends[0].jid, groupJid);
|
||||
assert.equal(textSends[0].content.text, 'Harness group answer');
|
||||
assert.equal(textSends[0].options.quoted, inbound);
|
||||
assert.match(textSends[0].options.messageId, /^[0-9A-F]{20}$/);
|
||||
const reactionSends = sent.filter(({ content }) => content.react);
|
||||
assert.deepEqual(reactionSends.map(({ content }) => content.react.text), ['👀', '']);
|
||||
assert.equal(reactionSends.every(({ jid }) => jid === groupJid), true);
|
||||
assert.equal(reactionSends.every(({ content }) => content.react.key === inbound.key), true);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime sends result files with native metadata, quote, stable id, and upload timeout', async (t) => {
|
||||
|
|
@ -818,6 +848,67 @@ test('WhatsApp bot client sends native images with stable id and early echo supp
|
|||
assert.deepEqual(remembered, [expectedMessageId, 'provider-image-message']);
|
||||
});
|
||||
|
||||
test('WhatsApp bot client adds and clears a reaction against the full source key', async () => {
|
||||
const calls = [];
|
||||
const reserved = [];
|
||||
const remembered = [];
|
||||
const sourceKey = {
|
||||
remoteJid: '120363000000000000@g.us',
|
||||
participant: '16505550999@s.whatsapp.net',
|
||||
participantAlt: '987654321098765@lid',
|
||||
addressingMode: 'lid',
|
||||
id: 'reaction-source-1',
|
||||
fromMe: false,
|
||||
};
|
||||
const socket = {
|
||||
sendMessage: async (jid, content, options) => {
|
||||
calls.push({ jid, content, options });
|
||||
return { key: { id: `reaction-result-${calls.length}` } };
|
||||
},
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, {
|
||||
reserve: (id) => reserved.push(id),
|
||||
remember: (id) => remembered.push(id),
|
||||
});
|
||||
const target = { jid: sourceKey.remoteJid, key: sourceKey };
|
||||
|
||||
const reactionKey = await client.addReaction(target, '👀');
|
||||
await client.removeReaction(target, reactionKey);
|
||||
|
||||
assert.equal(reactionKey, '👀');
|
||||
assert.deepEqual(calls.map(({ jid, content }) => ({ jid, content })), [{
|
||||
jid: sourceKey.remoteJid,
|
||||
content: { react: { text: '👀', key: sourceKey } },
|
||||
}, {
|
||||
jid: sourceKey.remoteJid,
|
||||
content: { react: { text: '', key: sourceKey } },
|
||||
}]);
|
||||
assert.equal(calls.every(({ content }) => content.react.key === sourceKey), true);
|
||||
assert.equal(calls.every(({ options }) => /^[0-9A-F]{20}$/.test(options.messageId)), true);
|
||||
assert.notEqual(calls[0].options.messageId, calls[1].options.messageId);
|
||||
assert.deepEqual(reserved, calls.map(({ options }) => options.messageId));
|
||||
assert.deepEqual(remembered, ['reaction-result-1', 'reaction-result-2']);
|
||||
});
|
||||
|
||||
test('WhatsApp reaction operations obey an upper-layer hard timeout', async () => {
|
||||
const socket = {
|
||||
sendMessage: async () => new Promise(() => {}),
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, {
|
||||
reserve() {},
|
||||
remember() {},
|
||||
});
|
||||
|
||||
await assert.rejects(() => client.addReaction({
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
key: {
|
||||
remoteJid: '16505550999@s.whatsapp.net',
|
||||
id: 'reaction-timeout-source',
|
||||
fromMe: false,
|
||||
},
|
||||
}, '👀', { signal: AbortSignal.timeout(10) }), (error) => error.name === 'TimeoutError');
|
||||
});
|
||||
|
||||
test('WhatsApp classifies a definite image rejection and uses a distinct fallback file id', async () => {
|
||||
const calls = [];
|
||||
const deliveryKey = 'whatsapp-image-fallback-delivery';
|
||||
|
|
@ -1189,16 +1280,25 @@ test('WhatsApp runtime answers self-chat without processing its own reply echo',
|
|||
},
|
||||
});
|
||||
await runtime.start();
|
||||
await callbacks.onMessage({
|
||||
const inbound = {
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'owner-message-1', fromMe: true },
|
||||
message: { conversation: 'hello from message yourself' },
|
||||
});
|
||||
};
|
||||
await callbacks.onMessage(inbound);
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'bot-reply-1', fromMe: true },
|
||||
message: { conversation: 'Harness self-chat answer' },
|
||||
});
|
||||
await eventually(() => sent.filter(([, content]) => content.react).length === 2);
|
||||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(sent, [[ACCOUNT_JID, { text: 'Harness self-chat answer' }]]);
|
||||
assert.deepEqual(
|
||||
sent.filter(([, content]) => typeof content.text === 'string'),
|
||||
[[ACCOUNT_JID, { text: 'Harness self-chat answer' }]],
|
||||
);
|
||||
const reactionSends = sent.filter(([, content]) => content.react);
|
||||
assert.deepEqual(reactionSends.map(([, content]) => content.react.text), ['👀', '']);
|
||||
assert.equal(reactionSends.every(([jid]) => jid === ACCOUNT_JID), true);
|
||||
assert.equal(reactionSends.every(([, content]) => content.react.key === inbound.key), true);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue