mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 07:43:15 +08:00
feat(whatsapp): add access modes
This commit is contained in:
parent
b0e2c266c5
commit
9b65dd3d6b
15 changed files with 860 additions and 162 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
EmptyView,
|
||||
ProvisionView,
|
||||
QrPanel,
|
||||
WhatsappAccessSettings,
|
||||
WhatsappAccountCard,
|
||||
WhatsappSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/whatsapp/index.js';
|
||||
|
|
@ -71,15 +72,78 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
|
|||
assert.match(markup, /检查连接/);
|
||||
assert.match(markup, /移除接入/);
|
||||
assert.match(markup, /class="dim-presetSelect"/);
|
||||
assert.match(markup, /仅自己模式(默认)/);
|
||||
assert.match(markup, /指定联系人模式/);
|
||||
assert.match(markup, /开放响应模式/);
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('WhatsApp access settings save a normalized selected-contact allowlist', async () => {
|
||||
const saved = [];
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WhatsappAccessSettings, {
|
||||
account: {
|
||||
accessPolicy: { accessMode: 'self-only', allowedNumbers: [] },
|
||||
},
|
||||
onSave: async (value) => saved.push(value),
|
||||
}));
|
||||
});
|
||||
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'private-allowlist' } });
|
||||
});
|
||||
const textarea = renderer.root.findByProps({
|
||||
'aria-label': '允许私聊的 WhatsApp 电话号码',
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: '+16505550999\n16505550999' } });
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.deepEqual(saved, [{
|
||||
accessMode: 'private-allowlist',
|
||||
allowedNumbers: ['16505550999'],
|
||||
}]);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('WhatsApp access settings only show the allowlist for selected contacts', async () => {
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WhatsappAccessSettings, {
|
||||
account: {
|
||||
accessPolicy: { accessMode: 'self-only', allowedNumbers: ['16505550999'] },
|
||||
},
|
||||
onSave: async () => {},
|
||||
}));
|
||||
});
|
||||
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
|
||||
const allowlistFields = () => renderer.root.findAllByProps({
|
||||
'aria-label': '允许私聊的 WhatsApp 电话号码',
|
||||
});
|
||||
|
||||
assert.equal(allowlistFields().length, 0);
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'private-allowlist' } });
|
||||
});
|
||||
assert.equal(allowlistFields().length, 1);
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'open' } });
|
||||
});
|
||||
assert.equal(allowlistFields().length, 0);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/whatsapp/index.js',
|
||||
import.meta.url,
|
||||
), 'utf8');
|
||||
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
|
||||
assert.match(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
|
||||
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,13 +19,16 @@ import {
|
|||
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 {
|
||||
WhatsappRuntime,
|
||||
normalizeWhatsappMessage,
|
||||
whatsappInboundAllowed,
|
||||
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
|
||||
import {
|
||||
|
|
@ -123,6 +126,8 @@ function linkedConfig(overrides = {}) {
|
|||
accountJid: ACCOUNT_JID,
|
||||
authDirectory: AUTH_DIRECTORY,
|
||||
name: 'Harness WhatsApp',
|
||||
accessMode: WHATSAPP_ACCESS_MODES.open,
|
||||
allowedNumbers: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
|
|
@ -135,10 +140,37 @@ test('WhatsApp config stores only linked-device metadata with restrictive permis
|
|||
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();
|
||||
|
|
@ -299,6 +331,46 @@ test('WhatsApp normalizes direct and explicitly mentioned group messages', () =>
|
|||
}, 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);
|
||||
|
||||
assert.equal(whatsappInboundAllowed(selfChat), true);
|
||||
assert.equal(whatsappInboundAllowed(direct), false);
|
||||
assert.equal(whatsappInboundAllowed(group), 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);
|
||||
});
|
||||
|
||||
test('WhatsApp exposes image media through a bounded Baileys download stream', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
|
|
@ -420,7 +492,7 @@ test('WhatsApp exposes image media through a bounded Baileys download stream', a
|
|||
});
|
||||
});
|
||||
|
||||
test('WhatsApp runtime connects a linked device and replies through Harness', async () => {
|
||||
test('WhatsApp runtime filters messages before the bridge and applies policy updates live', async () => {
|
||||
let callbacks;
|
||||
const calls = [];
|
||||
const socket = {
|
||||
|
|
@ -443,7 +515,7 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
|
|||
ask: async () => 'Harness answer',
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
config: linkedConfig({ accessMode: WHATSAPP_ACCESS_MODES.selfOnly }),
|
||||
authDir: '/tmp/test-whatsapp-auth',
|
||||
harness,
|
||||
state,
|
||||
|
|
@ -463,6 +535,13 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
|
|||
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();
|
||||
|
|
@ -882,6 +961,7 @@ test('WhatsApp reconnect RPC sends tests only for the connected target and keeps
|
|||
cancelProvisioning: async () => null,
|
||||
reconnectBot: async () => snapshot(),
|
||||
deleteBot: async () => snapshot(),
|
||||
setAccessPolicy: async () => snapshot(),
|
||||
sendConnectionTest: async () => {
|
||||
sendCalls += 1;
|
||||
if (sendFailure) throw new Error('private provider failure');
|
||||
|
|
@ -947,6 +1027,7 @@ test('WhatsApp RPC never sends a connection test after reconnect is cancelled',
|
|||
reconnectBot: async () => reconnect,
|
||||
sendConnectionTest: async () => { sendCalls += 1; },
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
setAccessPolicy: async () => ({ bots: [] }),
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
|
||||
|
|
@ -971,6 +1052,7 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
|
|||
let resolveReady;
|
||||
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
||||
const deletedAuth = [];
|
||||
const appliedPolicies = [];
|
||||
const controller = new WhatsappController({
|
||||
configStore,
|
||||
authPath: (name) => join(root, 'auth', name),
|
||||
|
|
@ -988,6 +1070,10 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
|
|||
},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
setAccessPolicy: (value) => appliedPolicies.push({
|
||||
accessMode: value.accessMode,
|
||||
allowedNumbers: value.allowedNumbers,
|
||||
}),
|
||||
}),
|
||||
deleteAuth: async (name) => deletedAuth.push(name),
|
||||
});
|
||||
|
|
@ -1008,7 +1094,32 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
|
|||
}
|
||||
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, []);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue