dsh-im-ops/test/channels/whatsapp/whatsapp.test.mjs
xmanrui 1ce8126edb feat(settings): move IM bot to top-level menu
Remove the legacy Plugins-tab registrations, keep the channel-logo navigation, and harden upgrade/package verification.
2026-08-25 22:42:41 +08:00

1571 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
import {
mkdtemp,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { DisconnectReason } from '@whiskeysockets/baileys';
import {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
WHATSAPP_ACCESS_MODES,
WhatsappConfigStore,
deriveWhatsappBotId,
normalizeWhatsappAccessPolicy,
} from '../../../src/channels/whatsapp/config-store.mjs';
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
import {
WhatsappBotClient,
WhatsappRuntime,
createWhatsappMediaDownloader,
normalizeWhatsappMessage,
whatsappInboundAllowed,
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
import {
WHATSAPP_ENDPOINTS,
createWhatsappRpcHandler,
} from '../../../plugin-src/host/channels/whatsapp/rpc.mjs';
const ACCOUNT_JID = '16505550123@s.whatsapp.net';
const AUTH_DIRECTORY = '7fe8c17e-4fb7-4c5b-a9dc-c36525575dd1';
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function within(promise, timeoutMs, message) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
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',
content = 'WhatsApp result file',
} = {}) {
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-whatsapp-artifact-${suffix}-`));
t.after(() => rm(workspace, { recursive: true, force: true }));
const sessionId = `session-whatsapp-artifact-${suffix}`;
const rpcId = `rpc-whatsapp-artifact-${suffix}`;
let nextId = 0;
const ids = [];
const registry = new OutboundArtifactRegistry({
uuid: () => {
const id = `${suffix}-${++nextId}`;
ids.push(id);
return id;
},
});
t.after(() => registry.clear());
const agent = {
session: {
header: { id: sessionId, cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
],
},
};
await writeFile(join(workspace, fileName), content);
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix}`,
rootCallId: `call-${suffix}`,
token: Symbol(`call-${suffix}`),
agent,
};
await tool.definition.execute({ path: fileName }, exec);
tool.onResult(exec, { isError: false });
return {
artifact: registry.take(sessionId, 1)[0],
deliveryKey: ids[1],
};
}
function artifactState(sessionId = 'session-whatsapp-artifact') {
const seen = new Set();
return {
hasSeen: (messageId) => seen.has(messageId),
markSeen: async (messageId) => { seen.add(messageId); },
sessionFor: () => sessionId,
sessionExists: async () => true,
setSession: async () => {},
clearSession: async () => {},
};
}
function linkedConfig(overrides = {}) {
return {
botId: deriveWhatsappBotId(ACCOUNT_JID),
accountJid: ACCOUNT_JID,
authDirectory: AUTH_DIRECTORY,
name: 'Harness WhatsApp',
accessMode: WHATSAPP_ACCESS_MODES.open,
allowedNumbers: [],
createdAt: new Date().toISOString(),
connectedAt: new Date().toISOString(),
...overrides,
};
}
test('WhatsApp config stores only linked-device metadata with restrictive permissions', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-config-'));
const path = join(root, 'config.json');
const store = await new WhatsappConfigStore(path).load();
await store.save(linkedConfig());
assert.equal(store.list()[0].accountJid, ACCOUNT_JID);
assert.equal(store.list()[0].accessMode, WHATSAPP_ACCESS_MODES.open);
assert.equal((await stat(path)).mode & 0o777, 0o600);
await assert.rejects(() => store.save(linkedConfig({ botId: 'whatsapp_invalid' })));
});
test('WhatsApp migrates existing bots to self-only mode and validates access policies', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-access-migration-'));
const path = join(root, 'config.json');
const legacy = linkedConfig();
delete legacy.accessMode;
delete legacy.allowedNumbers;
await writeFile(path, `${JSON.stringify({ version: 2, bots: [legacy] })}\n`);
const store = await new WhatsappConfigStore(path).load();
assert.deepEqual(store.get(legacy.botId), {
...legacy,
accessMode: WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers: [],
});
assert.deepEqual(normalizeWhatsappAccessPolicy({
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999', '16505550999'],
}), {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
});
assert.throws(() => normalizeWhatsappAccessPolicy({
accessMode: 'compatible',
allowedNumbers: [],
}), /accessMode/);
});
test('WhatsApp Web session reports QR and linked identity without printing either', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-session-'));
const events = new EventEmitter();
let ended = false;
const socket = {
ev: events,
user: { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' },
end: async () => { ended = true; },
logout: async () => {},
};
const qrValues = [];
const session = await createWhatsappWebSession({
authDir: root,
onQr: (value) => qrValues.push(value),
makeSocket: () => socket,
loadAuthState: async () => ({
state: {
creds: { me: socket.user },
keys: { get: async () => ({}), set: async () => {} },
},
saveCreds: async () => {},
}),
});
events.emit('connection.update', { qr: 'host-only-qr-value' });
events.emit('connection.update', { connection: 'open' });
assert.deepEqual(qrValues, ['host-only-qr-value']);
assert.deepEqual(await session.ready, {
accountJid: ACCOUNT_JID,
name: 'Harness WhatsApp',
});
assert.equal((await stat(root)).mode & 0o777, 0o700);
await session.close();
assert.equal(ended, true);
});
test('WhatsApp Web session restarts the socket after first-time QR pairing', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-restart-'));
const sockets = Array.from({ length: 2 }, (_, index) => ({
ev: new EventEmitter(),
user: index === 1
? { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' }
: undefined,
end: async () => {},
logout: async () => {},
}));
let socketIndex = 0;
let saveCount = 0;
const authState = {
creds: {},
keys: { get: async () => ({}), set: async () => {} },
};
const session = await createWhatsappWebSession({
authDir: root,
onQr: () => {},
makeSocket: () => sockets[socketIndex++],
loadAuthState: async () => ({
state: authState,
saveCreds: async () => { saveCount += 1; },
}),
});
authState.creds.me = sockets[1].user;
sockets[0].ev.emit('creds.update', { me: sockets[1].user });
sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: DisconnectReason.restartRequired } } },
});
for (let index = 0; index < 20 && socketIndex < 2; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(socketIndex, 2);
assert.equal(saveCount, 1);
assert.equal(session.socket, sockets[1]);
sockets[1].ev.emit('connection.update', { connection: 'open' });
assert.deepEqual(await session.ready, {
accountJid: ACCOUNT_JID,
name: 'Harness WhatsApp',
});
await session.close();
});
test('WhatsApp Web session accepts recent append events without replaying stale history', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
const events = new EventEmitter();
const received = [];
const contexts = [];
const socket = {
ev: events,
updateMediaMessage: async () => {},
end: async () => {},
logout: async () => {},
};
const session = await createWhatsappWebSession({
authDir: root,
onQr: () => {},
onMessage: async (message, context) => {
received.push(message.key.id);
contexts.push(context);
},
makeSocket: () => socket,
loadAuthState: async () => ({
state: {
creds: {},
keys: { get: async () => ({}), set: async () => {} },
},
saveCreds: async () => {},
}),
});
void session.ready.catch(() => undefined);
events.emit('messages.upsert', {
type: 'append',
messages: [
{ key: { id: 'recent' }, messageTimestamp: Math.floor(Date.now() / 1_000) },
{ key: { id: 'stale' }, messageTimestamp: Math.floor(Date.now() / 1_000) - 300 },
],
});
events.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'notify' } }],
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(received, ['recent', 'notify']);
assert.equal(contexts[0].socket, socket);
assert.equal(contexts[1].socket, socket);
await session.close();
});
test('WhatsApp media downloader supplies Baileys reupload context', async () => {
const reuploaded = [];
const socket = {
updateMediaMessage: async (message) => {
reuploaded.push(message);
return { ...message, reuploaded: true };
},
};
let receivedContext;
const download = createWhatsappMediaDownloader({
socket,
logger: { info() {} },
download: async (_message, _type, _options, context) => {
receivedContext = context;
return context.reuploadRequest({ key: { id: 'expired-media' } });
},
});
assert.equal((await download({}, 'stream', {})).reuploaded, true);
assert.equal(typeof receivedContext.logger.info, 'function');
assert.deepEqual(reuploaded, [{ key: { id: 'expired-media' } }]);
});
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: 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: {
remoteJid: '120363000000000000@g.us',
participant: '16505550999@s.whatsapp.net',
id: 'group-1',
fromMe: false,
},
message: {
extendedTextMessage: {
text: 'question',
contextInfo: { mentionedJid: [ACCOUNT_JID] },
},
},
}, ACCOUNT_JID);
assert.equal(group.kind, 'group');
assert.equal(group.addressed, true);
assert.equal(normalizeWhatsappMessage({
key: { remoteJid: 'status@broadcast', id: 'ignored', fromMe: false },
message: { conversation: 'ignored' },
}, ACCOUNT_JID), null);
const selfChat = normalizeWhatsappMessage({
key: { remoteJid: ACCOUNT_JID, id: 'self-1', fromMe: true },
message: { conversation: 'message yourself' },
}, ACCOUNT_JID);
assert.equal(selfChat.selfChat, true);
assert.equal(selfChat.addressed, true);
const linkedAccountGroup = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000001@g.us',
id: 'owner-group-1',
fromMe: true,
},
message: { conversation: 'message from linked account in a group' },
}, ACCOUNT_JID);
assert.equal(linkedAccountGroup.kind, 'group');
assert.equal(linkedAccountGroup.senderId, ACCOUNT_JID);
assert.equal(linkedAccountGroup.addressed, true);
assert.equal(linkedAccountGroup.selfChat, false);
assert.equal(normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'outbound-1', fromMe: true },
message: { conversation: 'ordinary outbound message' },
}, ACCOUNT_JID), null);
});
test('WhatsApp access modes allow self-chat, selected contacts, or the existing open behavior', () => {
const direct = normalizeWhatsappMessage({
key: {
remoteJid: '987654321098765@lid',
remoteJidAlt: '16505550999@s.whatsapp.net',
id: 'access-direct',
fromMe: false,
},
message: { conversation: 'hello' },
}, ACCOUNT_JID);
const group = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000000@g.us',
participant: '16505550999@s.whatsapp.net',
id: 'access-group',
fromMe: false,
},
message: { conversation: 'hello' },
}, ACCOUNT_JID);
const selfChat = normalizeWhatsappMessage({
key: { remoteJid: ACCOUNT_JID, id: 'access-self', fromMe: true },
message: { conversation: 'hello' },
}, ACCOUNT_JID);
const linkedAccountGroup = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000001@g.us',
id: 'access-owner-group',
fromMe: true,
},
message: { conversation: 'hello from the linked account' },
}, ACCOUNT_JID);
assert.equal(whatsappInboundAllowed(selfChat), true);
assert.equal(whatsappInboundAllowed(direct), false);
assert.equal(whatsappInboundAllowed(group), false);
assert.equal(whatsappInboundAllowed(linkedAccountGroup), false);
assert.equal(whatsappInboundAllowed(direct, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: new Set(['16505550999']),
}), true);
assert.equal(whatsappInboundAllowed(direct, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: new Set(['16505550000']),
}), false);
assert.equal(whatsappInboundAllowed(group, {
accessMode: WHATSAPP_ACCESS_MODES.open,
}), true);
assert.equal(whatsappInboundAllowed(linkedAccountGroup, {
accessMode: WHATSAPP_ACCESS_MODES.open,
}), true);
});
test('WhatsApp keeps native and document images as images and exposes ordinary documents as files', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const calls = [];
const controller = new AbortController();
const group = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000000@g.us',
participant: '16505550999@s.whatsapp.net',
id: 'group-image-1',
fromMe: false,
},
message: {
imageMessage: {
mimetype: 'image/png',
caption: '看看这张图',
fileLength: { toString: () => String(png.length) },
url: 'https://mmg.whatsapp.net/image',
contextInfo: { mentionedJid: [ACCOUNT_JID] },
},
},
}, ACCOUNT_JID, {
download: async (raw, type, options) => {
calls.push({ raw, type, options });
return {
async *[Symbol.asyncIterator]() { yield png; },
};
},
});
assert.equal(group.addressed, true);
assert.equal(group.content, '看看这张图');
assert.equal(group.images.length, 1);
assert.equal(group.images[0].size, png.length);
assert.deepEqual(await group.images[0].load({ signal: controller.signal, maxBytes: 100 }), png);
assert.equal(calls[0].type, 'stream');
assert.equal(calls[0].options.options.signal instanceof AbortSignal, true);
const downloadStarted = Promise.withResolvers();
const lateStream = Promise.withResolvers();
let lateStreamDestroyed = false;
const cancelled = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'cancelled-1', fromMe: false },
message: {
imageMessage: { mimetype: 'image/png', url: 'https://mmg.whatsapp.net/cancelled' },
},
}, ACCOUNT_JID, {
download: async () => {
downloadStarted.resolve();
return lateStream.promise;
},
});
const cancelledController = new AbortController();
const cancelledLoad = cancelled.images[0].load({
signal: cancelledController.signal,
maxBytes: 100,
});
await downloadStarted.promise;
cancelledController.abort(new DOMException('Stopped', 'AbortError'));
await assert.rejects(cancelledLoad, { name: 'AbortError' });
lateStream.resolve({ destroy() { lateStreamDestroyed = true; } });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(lateStreamDestroyed, true);
const document = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'document-image-1', fromMe: false },
message: {
documentMessage: {
mimetype: 'image/webp', fileName: 'diagram.webp', fileLength: 2_000,
url: 'https://mmg.whatsapp.net/document',
},
},
}, ACCOUNT_JID, {
download: async (raw, type, options) => {
calls.push({ raw, type, options });
return {
async *[Symbol.asyncIterator]() { yield Buffer.from('document'); },
};
},
});
assert.equal(document.images[0].name, 'diagram.webp');
assert.equal(document.images[0].mediaType, 'image/webp');
assert.equal(document.images[0].size, 2_000);
assert.deepEqual(document.files, []);
const documentController = new AbortController();
assert.equal((await document.images[0].load({
signal: documentController.signal,
maxBytes: 5_000,
})).toString(), 'document');
assert.equal(calls[1].type, 'stream');
assert.equal(calls[1].options.options.signal instanceof AbortSignal, true);
const ordinaryDocument = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'document-pdf-1', fromMe: false },
message: {
documentMessage: {
mimetype: 'application/pdf', fileName: 'report.pdf', fileLength: 4_000,
url: 'https://mmg.whatsapp.net/document-pdf',
},
},
}, ACCOUNT_JID, {
download: async (raw, type, options) => {
calls.push({ raw, type, options });
return {
async *[Symbol.asyncIterator]() { yield Buffer.from('pdf'); },
};
},
});
assert.deepEqual(ordinaryDocument.images, []);
assert.equal(ordinaryDocument.files[0].name, 'report.pdf');
assert.equal(ordinaryDocument.files[0].mediaType, 'application/pdf');
assert.equal(ordinaryDocument.files[0].size, 4_000);
const loadedDocument = await ordinaryDocument.files[0].load({ signal: documentController.signal });
const documentChunks = [];
for await (const chunk of loadedDocument.stream) documentChunks.push(Buffer.from(chunk));
assert.equal(Buffer.concat(documentChunks).toString(), 'pdf');
assert.equal(calls[2].type, 'stream');
assert.equal(calls[2].options.options.signal, documentController.signal);
for (const [index, wrapper] of [
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
].entries()) {
const wrappedMessage = {
[wrapper]: {
message: {
imageMessage: {
mimetype: 'image/jpeg', url: `https://mmg.whatsapp.net/view-once-${index}`,
},
},
},
};
const viewOnce = normalizeWhatsappMessage({
key: {
remoteJid: '16505550999@s.whatsapp.net',
id: `view-once-${index}`,
fromMe: false,
},
message: index === 1
? { ephemeralMessage: { message: wrappedMessage } }
: wrappedMessage,
}, ACCOUNT_JID);
assert.deepEqual(viewOnce.images, []);
}
const oversized = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'oversized-1', fromMe: false },
message: {
imageMessage: { mimetype: 'image/jpeg', url: 'https://mmg.whatsapp.net/oversized' },
},
}, ACCOUNT_JID, {
download: async () => ({
async *[Symbol.asyncIterator]() {
yield Buffer.alloc(4);
yield Buffer.alloc(4);
},
destroy() {},
}),
});
await assert.rejects(() => oversized.images[0].load({ maxBytes: 5 }), (error) => {
assert.equal(error.code, 'image-too-large');
return true;
});
});
test('WhatsApp runtime filters messages before the bridge and applies policy updates live', async () => {
let callbacks;
const calls = [];
const socket = {
sendPresenceUpdate: async (...args) => calls.push(['presence', ...args]),
readMessages: async () => {},
sendMessage: async (jid, content) => {
calls.push(['message', jid, content]);
return { key: { id: 'reply-1' } };
},
};
const state = {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-1',
sessionExists: async () => true,
};
const harness = {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => 'Harness answer',
};
const runtime = new WhatsappRuntime({
config: linkedConfig({ accessMode: WHATSAPP_ACCESS_MODES.selfOnly }),
authDir: '/tmp/test-whatsapp-auth',
harness,
state,
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-2', fromMe: false },
message: { conversation: 'hello' },
});
assert.equal(runtime.status.ready, true);
assert.equal(runtime.status.messagesRejected, 1);
assert.equal(calls.length, 0);
runtime.setAccessPolicy({ accessMode: WHATSAPP_ACCESS_MODES.open, allowedNumbers: [] });
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-3', fromMe: false },
message: { conversation: 'hello again' },
});
assert.ok(calls.some((call) => call[0] === 'presence' && call[1] === 'composing'));
assert.ok(calls.some((call) => call[0] === 'message' && call[2].text === 'Harness answer'));
await runtime.stop();
});
test('WhatsApp open mode answers linked-account group messages without processing reply echoes', async (t) => {
const groupJid = '120363000000000001@g.us';
let callbacks;
let replyEchoTask;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content, options = {}) => {
sent.push({ jid, content, options });
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 } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig({ accessMode: WHATSAPP_ACCESS_MODES.open }),
authDir: '/tmp/test-whatsapp-linked-account-group',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => {
askCount += 1;
return 'Harness group answer';
},
},
state: artifactState('session-linked-account-group'),
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
t.after(() => runtime.stop());
await runtime.start();
const inbound = {
key: { remoteJid: groupJid, id: 'linked-account-group-1', fromMe: true },
message: { conversation: 'hello from my group' },
};
await callbacks.onMessage(inbound);
await replyEchoTask;
await eventually(() => sent.filter(({ content }) => content.react).length === 2);
assert.equal(askCount, 1);
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) => {
const { artifact, deliveryKey } = await committedArtifact(t, {
suffix: 'native-file',
fileName: 'report.txt',
content: 'native WhatsApp artifact',
});
let callbacks;
const calls = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content, options) => {
calls.push({ jid, content, options });
return { key: { id: content.document ? 'file-message-1' : 'text-message-1' } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-native-file',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
assert.equal(typeof options.onArtifact, 'function');
await options.onArtifact(artifact);
return '结果文件如下。';
},
},
state: artifactState(),
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
t.after(() => runtime.stop());
await runtime.start();
const inbound = {
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'native-file-1', fromMe: false },
message: { conversation: '生成结果文件' },
};
await callbacks.onMessage(inbound);
const textCall = calls.find((call) => call.content.text === '结果文件如下。');
const fileCall = calls.find((call) => call.content.document);
assert.ok(textCall);
assert.ok(fileCall);
assert.equal(fileCall.jid, '16505550999@s.whatsapp.net');
assert.equal(fileCall.content.document.toString(), 'native WhatsApp artifact');
assert.equal(fileCall.content.mimetype, 'text/plain');
assert.equal(fileCall.content.fileName, 'report.txt');
assert.equal(fileCall.options.quoted, inbound);
assert.equal(fileCall.options.mediaUploadTimeoutMs, 120_000);
assert.equal(
fileCall.options.messageId,
createHash('sha256').update(deliveryKey).digest('hex').slice(0, 20).toUpperCase(),
);
assert.equal(fileCall.options.messageId.length, 20);
assert.equal(calls.indexOf(textCall) < calls.indexOf(fileCall), true);
});
test('WhatsApp bot client sends native images with stable id and early echo suppression', async () => {
const remembered = [];
const calls = [];
const outboundIds = {
remember: (id) => remembered.push(id),
};
const quoted = { key: { id: 'quoted-image-message' } };
const deliveryKey = 'whatsapp-native-image-delivery';
const expectedMessageId = createHash('sha256')
.update(`${deliveryKey}:image`)
.digest('hex')
.slice(0, 20)
.toUpperCase();
const socket = {
sendPresenceUpdate: async () => {},
sendMessage: async (jid, content, options) => {
assert.equal(remembered.includes(options.messageId), true);
calls.push({ jid, content, options });
return { key: { id: 'provider-image-message' } };
},
};
const client = new WhatsappBotClient(socket, outboundIds);
const bytes = Buffer.from('whatsapp-image');
assert.deepEqual(await client.sendImage({
jid: '16505550999@s.whatsapp.net',
quoted,
}, {
deliveryKey,
fileName: 'result.png',
mediaType: 'image/png',
bytes,
}), { key: { id: 'provider-image-message' } });
assert.equal(calls.length, 1);
assert.equal(calls[0].jid, '16505550999@s.whatsapp.net');
assert.equal(calls[0].content.image, bytes);
assert.equal(calls[0].content.mimetype, 'image/png');
assert.equal(calls[0].content.document, undefined);
assert.equal(calls[0].options.quoted, quoted);
assert.equal(calls[0].options.messageId, expectedMessageId);
assert.equal(calls[0].options.mediaUploadTimeoutMs, 120_000);
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';
const socket = {
sendPresenceUpdate: async () => {},
sendMessage: async (_jid, content, options) => {
calls.push({ content, options });
if (content.image) {
const error = new Error('unsupported image');
error.output = { statusCode: 415 };
throw error;
}
return { key: { id: 'provider-file-fallback' } };
},
};
const client = new WhatsappBotClient(socket, { remember() {} });
const file = {
artifactId: 'whatsapp-image-fallback',
deliveryKey,
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('whatsapp-image'),
};
await assert.rejects(
() => client.sendImage({ jid: '16505550999@s.whatsapp.net' }, file),
(error) => error.code === 'artifact-provider-rejected',
);
assert.deepEqual(
await client.sendFile({ jid: '16505550999@s.whatsapp.net' }, file),
{ key: { id: 'provider-file-fallback' } },
);
assert.equal(calls.length, 2);
assert.equal(calls[0].options.messageId, createHash('sha256')
.update(`${deliveryKey}:image`).digest('hex').slice(0, 20).toUpperCase());
assert.equal(calls[1].options.messageId, createHash('sha256')
.update(deliveryKey).digest('hex').slice(0, 20).toUpperCase());
assert.notEqual(calls[0].options.messageId, calls[1].options.messageId);
});
test('WhatsApp native image timeout remains an uncertain artifact delivery', async () => {
const socket = {
sendPresenceUpdate: async () => {},
sendMessage: async () => new Promise(() => {}),
};
const client = new WhatsappBotClient(socket, { remember() {} }, {
mediaUploadTimeoutMs: 10,
});
await assert.rejects(() => client.sendImage({
jid: '16505550999@s.whatsapp.net',
}, {
artifactId: 'whatsapp-image-timeout',
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('whatsapp-image'),
}), (error) => error.code === 'artifact-delivery-uncertain'
&& error.cause?.name === 'TimeoutError');
});
test('WhatsApp bridge routes outbound image artifacts natively with safe file fallback', async (t) => {
const scenarios = [{
name: 'native image',
suffix: 'native-image-route',
fileName: 'result.png',
content: Buffer.from([1, 2, 3]),
expectedCalls: ['image:result.png:image/png'],
expectedOutcome: 'sent',
}, {
name: 'ordinary file',
suffix: 'ordinary-file-route',
fileName: 'result.txt',
content: 'ordinary file',
expectedCalls: ['file:result.txt:text/plain'],
expectedOutcome: 'sent',
}, {
name: 'definitive image rejection',
suffix: 'image-fallback-route',
fileName: 'result.webp',
content: Buffer.from([4, 5, 6]),
imageErrorCode: 'artifact-provider-rejected',
expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'],
expectedOutcome: 'sent',
}, {
name: 'uncertain image result',
suffix: 'image-uncertain-route',
fileName: 'result.gif',
content: Buffer.from([7, 8, 9]),
imageErrorCode: 'artifact-delivery-uncertain',
expectedCalls: ['image:result.gif:image/gif'],
expectedOutcome: 'unknown',
}];
for (const scenario of scenarios) {
const { artifact } = await committedArtifact(t, scenario);
const calls = [];
const bot = {
sendText: async () => ({ key: { id: `text-${scenario.suffix}` } }),
sendImage: async (_target, file) => {
calls.push(`image:${file.fileName}:${file.mediaType}`);
if (scenario.imageErrorCode) {
const error = new Error(scenario.imageErrorCode);
error.code = scenario.imageErrorCode;
throw error;
}
return { key: { id: `image-${scenario.suffix}` } };
},
sendFile: async (_target, file) => {
calls.push(`file:${file.fileName}:${file.mediaType}`);
return { key: { id: `file-${scenario.suffix}` } };
},
};
const bridge = new WhatsappHarnessBridge({
bot,
state: artifactState(`session-${scenario.suffix}`),
logger: { warn() {}, error() {} },
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
});
const receipt = await bridge.accept({
messageId: `whatsapp:artifact-${scenario.suffix}`,
senderId: '16505550999@s.whatsapp.net',
kind: 'direct',
conversationId: `whatsapp-artifact-${scenario.suffix}`,
content: '生成结果',
addressed: true,
replyTarget: { jid: '16505550999@s.whatsapp.net' },
});
assert.deepEqual(calls, scenario.expectedCalls, scenario.name);
assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name);
}
});
test('WhatsApp suppresses a self-chat file echo that arrives before the provider ACK', async (t) => {
const { artifact } = await committedArtifact(t, {
suffix: 'early-self-file-echo',
fileName: 'self-report.txt',
content: 'self chat artifact',
});
let callbacks;
let echoTask;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content, options = {}) => {
sent.push({ jid, content, options });
if (content.document) {
echoTask = callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: options.messageId, fromMe: true },
message: {
documentMessage: {
fileName: content.fileName,
mimetype: content.mimetype,
},
},
});
return { key: { id: options.messageId } };
}
return { key: { id: `text-${sent.length}` } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-early-self-file-echo',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
askCount += 1;
await options.onArtifact(artifact);
return '结果文件如下。';
},
},
state: artifactState('session-whatsapp-early-self-file-echo'),
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
t.after(() => runtime.stop());
await runtime.start();
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'self-file-request-1', fromMe: true },
message: { conversation: '生成结果文件' },
});
await echoTask;
assert.equal(askCount, 1);
assert.equal(sent.filter(({ content }) => content.document).length, 1);
assert.equal(sent.some(({ content }) => content.text === '目前支持文字和图片消息。'), false);
});
test('WhatsApp runtime treats a lost file-send response as uncertain delivery', async (t) => {
const { artifact } = await committedArtifact(t, {
suffix: 'uncertain-file',
fileName: 'uncertain.txt',
});
let callbacks;
const warnings = [];
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (_jid, content) => {
sent.push(content);
if (content.document) return new Promise(() => {});
return { key: { id: `text-${sent.length}` } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-uncertain-file',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '已生成文件。';
},
},
state: artifactState('session-whatsapp-uncertain'),
logger: {
warn: (...args) => warnings.push(args.join(' ')),
error() {},
},
mediaUploadTimeoutMs: 20,
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
t.after(() => runtime.stop());
await runtime.start();
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'uncertain-file-1', fromMe: false },
message: { conversation: '生成文件' },
});
assert.equal(sent.filter((content) => content.document).length, 1);
assert.equal(warnings.some((warning) => warning.includes('artifact-delivery-uncertain')), true);
assert.equal(sent.some((content) => content.text?.includes('发送结果未能确认')), true);
});
test('stopping WhatsApp aborts a pending upload without another file or failure notice', async (t) => {
const first = await committedArtifact(t, {
suffix: 'cancel-first',
fileName: 'first.txt',
});
const second = await committedArtifact(t, {
suffix: 'cancel-second',
fileName: 'second.txt',
});
let callbacks;
const uploadStarted = deferred();
const uploadResponse = deferred();
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (_jid, content) => {
sent.push(content);
if (content.document) {
uploadStarted.resolve();
return uploadResponse.promise;
}
return { key: { id: `text-${sent.length}` } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-cancel-upload',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(first.artifact);
await options.onArtifact(second.artifact);
return '结果文件如下。';
},
},
state: artifactState('session-whatsapp-cancel'),
logger: { warn() {}, error() {} },
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
const processing = callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'cancel-upload-1', fromMe: false },
message: { conversation: '生成两个文件' },
});
await uploadStarted.promise;
await within(runtime.stop(), 500, 'WhatsApp runtime did not stop a pending upload promptly');
await within(processing, 500, 'WhatsApp message processing remained blocked after stop');
uploadResponse.resolve({ key: { id: 'late-provider-response' } });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
sent.filter((content) => content.document).map((content) => content.fileName),
['first.txt'],
);
assert.equal(sent.some((content) => content.text?.includes('暂时未能发送')), false);
assert.equal(sent.some((content) => content.text === '消息处理失败,请稍后重试。'), false);
});
test('WhatsApp runtime answers self-chat without processing its own reply echo', async () => {
let callbacks;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => { throw new Error('self-chat must not send a read receipt'); },
sendMessage: async (jid, content) => {
sent.push([jid, content]);
return { key: { id: 'bot-reply-1' } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-self-chat',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => { askCount += 1; return 'Harness self-chat answer'; },
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-self',
sessionExists: async () => true,
},
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
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.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();
});
test('WhatsApp runtime sends a connection test to self and suppresses its outbound echo', async () => {
let callbacks;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content) => {
sent.push([jid, content]);
return { key: { id: 'connection-test-1' } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-connection-test',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => { askCount += 1; return 'unexpected'; },
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-connection-test',
sessionExists: async () => true,
},
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
message: { conversation: '连接测试' },
});
assert.equal(askCount, 0);
await runtime.stop();
});
test('WhatsApp controller delegates connection test copy to the current runtime', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-test-message-'));
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
const config = await configStore.save(linkedConfig());
const sent = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
createSession: async () => { throw new Error('not used'); },
createRuntime: async () => ({
status: {
ready: true,
connectionState: 'connected',
harnessReachable: true,
},
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => {
sent.push(text);
return { sent: true };
},
}),
});
t.after(() => controller.close());
await controller.initialize();
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
assert.deepEqual(sent, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
]);
});
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {
const botId = deriveWhatsappBotId(ACCOUNT_JID);
let connected = true;
let sendFailure = false;
let sendCalls = 0;
const snapshot = () => ({
schemaVersion: 1,
revision: 1,
bots: [{
botId,
state: connected ? 'connected' : 'offline',
connected,
configured: true,
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
health: { summary: 'status', lastCheckedAt: Date.now() },
}],
totals: { configured: 1, connected: connected ? 1 : 0 },
});
const controller = {
status: async () => snapshot(),
startProvisioning: async () => null,
registrationStatus: async () => null,
cancelProvisioning: async () => null,
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
setAccessPolicy: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
if (sendFailure) throw new Error('private provider failure');
return { sent: true };
},
};
const handler = createWhatsappRpcHandler(controller);
const legacy = await handler(WHATSAPP_ENDPOINTS.reconnectBot, { botId });
assert.equal(legacy.ok, true);
assert.equal('testMessage' in legacy.value, false);
assert.equal(sendCalls, 0);
const success = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.equal(sendCalls, 1);
sendFailure = true;
const failedSend = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.equal(failedSend.ok, true);
assert.deepEqual(failedSend.value.testMessage, {
sent: false,
code: 'test-message-failed',
});
assert.doesNotMatch(JSON.stringify(failedSend), /private provider failure/);
connected = false;
const unavailable = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.equal(unavailable.ok, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false,
code: 'test-target-unavailable',
});
assert.equal(sendCalls, 2);
const invalid = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: 'yes' },
);
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
});
test('WhatsApp RPC never sends a connection test after reconnect is cancelled', async () => {
let resolveReconnect;
let sendCalls = 0;
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
const botId = deriveWhatsappBotId(ACCOUNT_JID);
const controller = {
status: async () => ({ bots: [] }),
startProvisioning: async () => null,
registrationStatus: async () => null,
cancelProvisioning: async () => null,
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
setAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
botId,
sendTest: true,
}, abort.signal);
abort.abort();
resolveReconnect({ bots: [{ botId, connected: true }] });
assert.deepEqual(await result, {
ok: false,
error: { code: 'cancelled', message: 'The request was cancelled.' },
});
assert.equal(sendCalls, 0);
});
test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-only', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-controller-'));
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
let sessionOptions;
let resolveReady;
const ready = new Promise((resolve) => { resolveReady = resolve; });
const deletedAuth = [];
const appliedPolicies = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
createSession: async (options) => {
sessionOptions = options;
queueMicrotask(() => options.onQr('raw-linked-device-qr'));
return { ready, close: async () => {} };
},
createRuntime: async () => ({
status: {
ready: true,
connectionState: 'connected',
harnessReachable: true,
lastCheckedAt: Date.now(),
},
start: async () => {},
stop: async () => {},
setAccessPolicy: (value) => appliedPolicies.push({
accessMode: value.accessMode,
allowedNumbers: value.allowedNumbers,
}),
}),
deleteAuth: async (name) => deletedAuth.push(name),
});
t.after(() => controller.close());
const handler = createWhatsappRpcHandler(controller, {
encodeQr: async () => 'data:image/png;base64,QUJDRA==',
});
const started = await handler(WHATSAPP_ENDPOINTS.beginProvisioning, {});
assert.equal(started.ok, true);
assert.match(started.value.qrCodeDataUrl, /^data:image\/png/);
assert.doesNotMatch(JSON.stringify(started.value), /raw-linked-device-qr|accountJid|authDirectory/);
resolveReady({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' });
let status;
for (let index = 0; index < 20; index += 1) {
status = await handler(WHATSAPP_ENDPOINTS.status, {});
if (status.value?.bots?.length) break;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(status.ok, true);
assert.equal(status.value.bots[0].connected, true);
assert.deepEqual(status.value.bots[0].accessPolicy, {
accessMode: WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers: [],
});
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
const updated = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999'],
});
assert.equal(updated.ok, true);
assert.deepEqual(updated.value.bots[0].accessPolicy, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
});
assert.deepEqual(appliedPolicies, [{
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
}]);
const invalidPolicy = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: 'compatible',
allowedNumbers: [],
});
assert.equal(invalidPolicy.ok, false);
assert.equal(invalidPolicy.error.code, 'bad-request');
assert.equal(sessionOptions.signal.aborted, false);
assert.deepEqual(deletedAuth, []);
});