@xmanrui/dsh-im 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -3
- package/README.md +5 -3
- package/lib/client.js +212 -0
- package/lib/index.js +152 -152
- package/package.json +1 -1
- package/plugin-src/client/channels/whatsapp/api.js +11 -0
- package/plugin-src/client/channels/whatsapp/index.js +125 -0
- package/plugin-src/client/channels/whatsapp/styles.js +25 -0
- package/plugin-src/client/i18n.js +18 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
- package/src/channels/whatsapp/config-store.mjs +43 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
- package/src/channels/whatsapp/whatsapp-runtime.mjs +41 -0
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|
|
9
9
|
cancelProvisioning: 'provision.cancel',
|
|
10
10
|
reconnectBot: 'bot.reconnect',
|
|
11
11
|
deleteBot: 'bot.delete',
|
|
12
|
+
setAccessPolicy: 'bot.access-policy.set',
|
|
12
13
|
setWorkspace: 'bot.workspace.set',
|
|
13
14
|
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
|
14
15
|
});
|
|
@@ -86,6 +87,16 @@ function normalizeBot(value) {
|
|
|
86
87
|
state: connected ? 'connected' : state,
|
|
87
88
|
workspace: text(value.workspace, '', 4_096),
|
|
88
89
|
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
|
90
|
+
accessPolicy: {
|
|
91
|
+
accessMode: ['self-only', 'private-allowlist', 'open'].includes(
|
|
92
|
+
value.accessPolicy?.accessMode,
|
|
93
|
+
) ? value.accessPolicy.accessMode : 'self-only',
|
|
94
|
+
allowedNumbers: Array.isArray(value.accessPolicy?.allowedNumbers)
|
|
95
|
+
? [...new Set(value.accessPolicy.allowedNumbers.filter((entry) => (
|
|
96
|
+
typeof entry === 'string' && /^[1-9]\d{4,14}$/.test(entry)
|
|
97
|
+
)))]
|
|
98
|
+
: [],
|
|
99
|
+
},
|
|
89
100
|
bot: {
|
|
90
101
|
name: text(value.bot?.name, 'WhatsApp机器人', 100),
|
|
91
102
|
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),
|
|
@@ -25,6 +25,119 @@ import { installWhatsappStyles } from './styles.js';
|
|
|
25
25
|
|
|
26
26
|
const ACTIVE_STATES = new Set(['pending', 'connecting']);
|
|
27
27
|
|
|
28
|
+
function accessPolicyFor(account) {
|
|
29
|
+
const accessMode = ['self-only', 'private-allowlist', 'open'].includes(
|
|
30
|
+
account?.accessPolicy?.accessMode,
|
|
31
|
+
) ? account.accessPolicy.accessMode : 'self-only';
|
|
32
|
+
return {
|
|
33
|
+
accessMode,
|
|
34
|
+
allowedNumbers: Array.isArray(account?.accessPolicy?.allowedNumbers)
|
|
35
|
+
? account.accessPolicy.allowedNumbers : [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function allowedNumbersFromText(value) {
|
|
40
|
+
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
|
|
41
|
+
const normalized = entries.map((entry) => entry.replace(/^\+/, ''));
|
|
42
|
+
if (normalized.some((entry) => !/^[1-9]\d{4,14}$/.test(entry))) {
|
|
43
|
+
throw new TypeError('电话号码必须包含国家或地区代码,每行一个。');
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(normalized)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function WhatsappAccessSettings({ account, busy = false, onSave }) {
|
|
49
|
+
const policy = accessPolicyFor(account);
|
|
50
|
+
const sourceNumbers = policy.allowedNumbers.join('\n');
|
|
51
|
+
const helpId = React.useId();
|
|
52
|
+
const [accessMode, setAccessMode] = React.useState(policy.accessMode);
|
|
53
|
+
const [allowedNumbers, setAllowedNumbers] = React.useState(sourceNumbers);
|
|
54
|
+
const [error, setError] = React.useState(null);
|
|
55
|
+
|
|
56
|
+
React.useEffect(() => {
|
|
57
|
+
setAccessMode(policy.accessMode);
|
|
58
|
+
setAllowedNumbers(sourceNumbers);
|
|
59
|
+
setError(null);
|
|
60
|
+
}, [policy.accessMode, sourceNumbers]);
|
|
61
|
+
|
|
62
|
+
const save = async (event) => {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
setError(null);
|
|
65
|
+
try {
|
|
66
|
+
const normalized = allowedNumbersFromText(allowedNumbers);
|
|
67
|
+
if (typeof onSave !== 'function') throw new Error('WhatsApp 访问设置暂不可用。');
|
|
68
|
+
await onSave({ accessMode, allowedNumbers: normalized });
|
|
69
|
+
} catch (caught) {
|
|
70
|
+
setError(caught?.message ?? 'WhatsApp 访问设置保存失败。');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const allowlistEnabled = accessMode === 'private-allowlist';
|
|
75
|
+
const labels = {
|
|
76
|
+
'self-only': '仅自己模式',
|
|
77
|
+
'private-allowlist': '指定联系人模式',
|
|
78
|
+
open: '开放响应模式',
|
|
79
|
+
};
|
|
80
|
+
return h('form', { className: 'dwa-access', onSubmit: save },
|
|
81
|
+
h('div', { className: 'dwa-accessHeading' },
|
|
82
|
+
h('strong', null, '访问设置'),
|
|
83
|
+
h('span', { className: 'dwa-accessStatus' },
|
|
84
|
+
h('span', { className: 'dwa-accessBadge', 'data-mode': policy.accessMode },
|
|
85
|
+
['已生效:', labels[policy.accessMode]]),
|
|
86
|
+
h('span', { className: 'dwa-accessHelp' },
|
|
87
|
+
h('button', {
|
|
88
|
+
type: 'button',
|
|
89
|
+
className: 'dwa-accessHelpButton',
|
|
90
|
+
'aria-label': '查看 WhatsApp 访问模式说明',
|
|
91
|
+
'aria-describedby': helpId,
|
|
92
|
+
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
|
93
|
+
h('span', { id: helpId, className: 'dwa-accessTooltip', role: 'tooltip' },
|
|
94
|
+
h('span', { className: 'dwa-accessTooltipItem' },
|
|
95
|
+
h('strong', null, '仅自己模式'),
|
|
96
|
+
h('span', null, '只响应已绑定 WhatsApp 账号的自聊消息。')),
|
|
97
|
+
h('span', { className: 'dwa-accessTooltipItem' },
|
|
98
|
+
h('strong', null, '指定联系人模式'),
|
|
99
|
+
h('span', null, '响应自聊和白名单联系人的私聊,忽略群聊。')),
|
|
100
|
+
h('span', { className: 'dwa-accessTooltipItem' },
|
|
101
|
+
h('strong', null, '开放响应模式'),
|
|
102
|
+
h('span', null, '响应所有私聊,以及群聊中的提及或回复。')))))),
|
|
103
|
+
h('label', { className: 'dwa-accessField' },
|
|
104
|
+
h('span', null, '模式'),
|
|
105
|
+
h('select', {
|
|
106
|
+
value: accessMode,
|
|
107
|
+
disabled: busy,
|
|
108
|
+
'aria-label': 'WhatsApp 访问模式',
|
|
109
|
+
onChange: (event) => { setAccessMode(event.target.value); setError(null); },
|
|
110
|
+
},
|
|
111
|
+
h('option', { value: 'self-only' }, '仅自己模式(默认)'),
|
|
112
|
+
h('option', { value: 'private-allowlist' }, '指定联系人模式'),
|
|
113
|
+
h('option', { value: 'open' }, '开放响应模式'))),
|
|
114
|
+
allowlistEnabled
|
|
115
|
+
? h('label', { className: 'dwa-accessField' },
|
|
116
|
+
h('span', null, '允许私聊的 WhatsApp 电话号码'),
|
|
117
|
+
h('textarea', {
|
|
118
|
+
value: allowedNumbers,
|
|
119
|
+
disabled: busy,
|
|
120
|
+
rows: 3,
|
|
121
|
+
placeholder: '每行一个含国家或地区代码的号码',
|
|
122
|
+
'aria-label': '允许私聊的 WhatsApp 电话号码',
|
|
123
|
+
onChange: (event) => { setAllowedNumbers(event.target.value); setError(null); },
|
|
124
|
+
}),
|
|
125
|
+
h('small', null, '可以包含开头的 +,保存时会自动移除。'))
|
|
126
|
+
: null,
|
|
127
|
+
allowlistEnabled && allowedNumbers.trim() === ''
|
|
128
|
+
? h('p', { className: 'dwa-accessWarning', role: 'status' },
|
|
129
|
+
'白名单为空;保存后将只接受自聊消息。')
|
|
130
|
+
: null,
|
|
131
|
+
error ? h('p', { className: 'dwa-accessError', role: 'alert' }, error) : null,
|
|
132
|
+
h('div', { className: 'dwa-accessActions' },
|
|
133
|
+
h('button', {
|
|
134
|
+
type: 'submit',
|
|
135
|
+
className: 'ddt-button',
|
|
136
|
+
'data-kind': 'secondary',
|
|
137
|
+
disabled: busy,
|
|
138
|
+
}, busy ? '正在保存…' : '保存访问设置')));
|
|
139
|
+
}
|
|
140
|
+
|
|
28
141
|
const Button = React.forwardRef(function Button(
|
|
29
142
|
{ children, kind = 'secondary', className = '', ...props },
|
|
30
143
|
ref,
|
|
@@ -180,6 +293,7 @@ export function WhatsappAccountCard({
|
|
|
180
293
|
onReconnect,
|
|
181
294
|
onWorkspaceSave,
|
|
182
295
|
onAgentPresetSave,
|
|
296
|
+
onAccessPolicySave,
|
|
183
297
|
onRequestRemove,
|
|
184
298
|
onConfirmRemove,
|
|
185
299
|
onCancelRemove,
|
|
@@ -216,6 +330,11 @@ export function WhatsappAccountCard({
|
|
|
216
330
|
disabled: Boolean(busy),
|
|
217
331
|
onSave: onAgentPresetSave,
|
|
218
332
|
}),
|
|
333
|
+
h(WhatsappAccessSettings, {
|
|
334
|
+
account,
|
|
335
|
+
busy: Boolean(busy),
|
|
336
|
+
onSave: onAccessPolicySave,
|
|
337
|
+
}),
|
|
219
338
|
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
|
220
339
|
h('div', { className: 'dim-cardFooterLayout' },
|
|
221
340
|
h('div', { className: 'ddt-actions dim-cardActions' },
|
|
@@ -469,6 +588,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|
|
469
588
|
WHATSAPP_ENDPOINTS.setAgentPreset,
|
|
470
589
|
{ botId: account.botId, agentPreset },
|
|
471
590
|
),
|
|
591
|
+
onAccessPolicySave: (accessPolicy) => botAction(
|
|
592
|
+
account,
|
|
593
|
+
'access',
|
|
594
|
+
WHATSAPP_ENDPOINTS.setAccessPolicy,
|
|
595
|
+
{ botId: account.botId, ...accessPolicy },
|
|
596
|
+
),
|
|
472
597
|
onRequestRemove: () => setRemoveTarget(account.botId),
|
|
473
598
|
onCancelRemove: () => setRemoveTarget(null),
|
|
474
599
|
onConfirmRemove: async () => {
|
|
@@ -4,6 +4,31 @@ const CSS = String.raw`
|
|
|
4
4
|
.dwa-page { --ddt-accent: #25d366; --ddt-accent-deep: #128c7e; --ddt-accent-wash: #eafbf0; }
|
|
5
5
|
.dwa-avatar { color: #fff; background: #25d366; }
|
|
6
6
|
.dwa-avatar svg { display: block; }
|
|
7
|
+
.dwa-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
|
|
8
|
+
.dwa-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
9
|
+
.dwa-accessHeading > strong { font-size: 13px; }
|
|
10
|
+
.dwa-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
11
|
+
.dwa-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #08785f; background: #eafbf0; font-size: 11px; font-weight: 700; }
|
|
12
|
+
.dwa-accessBadge[data-mode="private-allowlist"] { color: #0f6f8f; background: #eaf7fd; }
|
|
13
|
+
.dwa-accessBadge[data-mode="open"] { color: #a15c00; background: #fff3d6; }
|
|
14
|
+
.dwa-accessHelp { position: relative; display: inline-flex; flex: none; }
|
|
15
|
+
.dwa-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #25d366 34%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #128c7e; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; }
|
|
16
|
+
.dwa-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 270px; max-width: min(290px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
|
|
17
|
+
.dwa-accessTooltipItem { display: grid; gap: 2px; }
|
|
18
|
+
.dwa-accessTooltipItem + .dwa-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
|
|
19
|
+
.dwa-accessTooltipItem strong { font-size: 12px; line-height: 17px; }
|
|
20
|
+
.dwa-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
|
|
21
|
+
.dwa-accessHelp:hover .dwa-accessTooltip, .dwa-accessHelp:focus-within .dwa-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
|
|
22
|
+
.dwa-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
|
|
23
|
+
.dwa-accessField select, .dwa-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
|
|
24
|
+
.dwa-accessField select { height: 34px; padding: 0 9px; }
|
|
25
|
+
.dwa-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
26
|
+
.dwa-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; }
|
|
27
|
+
.dwa-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
|
|
28
|
+
.dwa-accessWarning, .dwa-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
|
|
29
|
+
.dwa-accessWarning { color: #a15c00; }
|
|
30
|
+
.dwa-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
|
|
31
|
+
.dwa-accessActions { display: flex; justify-content: flex-end; }
|
|
7
32
|
`;
|
|
8
33
|
|
|
9
34
|
export function installWhatsappStyles() {
|
|
@@ -360,6 +360,24 @@ const EN = Object.freeze({
|
|
|
360
360
|
'正在建立安全的关联设备会话。': 'Creating a secure linked-device session.',
|
|
361
361
|
'关联设备正在接入 DeepSeek Harness。': 'Linking the device to DeepSeek Harness.',
|
|
362
362
|
'WhatsApp Web 关联设备运行正常': 'WhatsApp linked device is healthy',
|
|
363
|
+
'查看 WhatsApp 访问模式说明': 'View WhatsApp access mode details',
|
|
364
|
+
'WhatsApp 访问模式': 'WhatsApp access mode',
|
|
365
|
+
'仅自己模式': 'Only me',
|
|
366
|
+
'指定联系人模式': 'Selected contacts',
|
|
367
|
+
'开放响应模式': 'Open responses',
|
|
368
|
+
'仅自己模式(默认)': 'Only me (default)',
|
|
369
|
+
'已生效:': 'Active: ',
|
|
370
|
+
'只响应已绑定 WhatsApp 账号的自聊消息。': 'Only respond to self-chat messages from the linked WhatsApp account.',
|
|
371
|
+
'响应自聊和白名单联系人的私聊,忽略群聊。': 'Respond to self-chat and allowlisted direct messages; ignore group messages.',
|
|
372
|
+
'响应所有私聊,以及群聊中的提及或回复。': 'Respond to all direct messages and to group mentions or replies.',
|
|
373
|
+
'允许私聊的 WhatsApp 电话号码': 'WhatsApp phone numbers allowed to send direct messages',
|
|
374
|
+
'每行一个含国家或地区代码的号码': 'One number with country or region code per line',
|
|
375
|
+
'可以包含开头的 +,保存时会自动移除。': 'A leading + is allowed and removed when saved.',
|
|
376
|
+
'仅指定联系人模式使用白名单,切换模式时会保留。': 'Only Selected contacts uses the allowlist; it is retained when modes change.',
|
|
377
|
+
'白名单为空;保存后将只接受自聊消息。': 'The allowlist is empty; only self-chat messages will be accepted after saving.',
|
|
378
|
+
'电话号码必须包含国家或地区代码,每行一个。': 'Each phone number must include a country or region code on its own line.',
|
|
379
|
+
'WhatsApp 访问设置暂不可用。': 'WhatsApp access settings are currently unavailable.',
|
|
380
|
+
'WhatsApp 访问设置保存失败。': 'Could not save WhatsApp access settings.',
|
|
363
381
|
'Bot API 长轮询': 'Bot API long polling',
|
|
364
382
|
' Gateway 长连接': ' Gateway persistent connection',
|
|
365
383
|
'Gateway 长连接': 'Gateway persistent connection',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import QRCode from 'qrcode';
|
|
2
2
|
|
|
3
3
|
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
|
4
|
+
import { normalizeWhatsappAccessPolicy } from '../../../../src/channels/whatsapp/config-store.mjs';
|
|
4
5
|
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
|
5
6
|
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
|
6
7
|
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
|
@@ -13,6 +14,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|
|
13
14
|
cancelProvisioning: 'provision.cancel',
|
|
14
15
|
reconnectBot: 'bot.reconnect',
|
|
15
16
|
deleteBot: 'bot.delete',
|
|
17
|
+
setAccessPolicy: 'bot.access-policy.set',
|
|
16
18
|
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
|
17
19
|
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
|
18
20
|
});
|
|
@@ -50,6 +52,17 @@ function payloadFailure(endpoint, payload) {
|
|
|
50
52
|
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
|
|
51
53
|
&& payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
|
|
52
54
|
}
|
|
55
|
+
if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
|
|
56
|
+
if (!exactKeys(payload, ['botId', 'accessMode', 'allowedNumbers'])
|
|
57
|
+
|| Object.keys(payload).length !== 3
|
|
58
|
+
|| !validId(payload.botId)) return '请输入有效的 WhatsApp 访问模式和电话号码。';
|
|
59
|
+
try {
|
|
60
|
+
normalizeWhatsappAccessPolicy(payload);
|
|
61
|
+
return null;
|
|
62
|
+
} catch {
|
|
63
|
+
return '请输入有效的 WhatsApp 访问模式和电话号码。';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
53
66
|
if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
|
|
54
67
|
return validWorkspacePayload(payload)
|
|
55
68
|
? null : '请输入工作区绝对路径。';
|
|
@@ -92,7 +105,7 @@ async function publicStatus(value, encodeQr) {
|
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|
95
|
-
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
|
|
108
|
+
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot', 'setAccessPolicy']) {
|
|
96
109
|
if (typeof controller?.[method] !== 'function') {
|
|
97
110
|
throw new TypeError(`A complete WhatsApp controller is required (${method})`);
|
|
98
111
|
}
|
|
@@ -166,6 +179,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|
|
166
179
|
await controller.updateAgentPreset(payload.botId, payload.agentPreset),
|
|
167
180
|
cachedEncode,
|
|
168
181
|
);
|
|
182
|
+
} else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
|
|
183
|
+
value = await publicStatus(
|
|
184
|
+
await controller.setAccessPolicy(payload.botId, normalizeWhatsappAccessPolicy(payload)),
|
|
185
|
+
cachedEncode,
|
|
186
|
+
);
|
|
169
187
|
} else {
|
|
170
188
|
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
|
|
171
189
|
}
|
|
@@ -5,6 +5,13 @@ import { dirname } from 'node:path';
|
|
|
5
5
|
const EMPTY_DOCUMENT = Object.freeze({ version: 2, bots: Object.freeze([]) });
|
|
6
6
|
const BOT_ID_PATTERN = /^whatsapp_[a-f0-9]{24}$/;
|
|
7
7
|
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
|
8
|
+
const WHATSAPP_PHONE_NUMBER = /^[1-9]\d{4,14}$/;
|
|
9
|
+
|
|
10
|
+
export const WHATSAPP_ACCESS_MODES = Object.freeze({
|
|
11
|
+
selfOnly: 'self-only',
|
|
12
|
+
privateAllowlist: 'private-allowlist',
|
|
13
|
+
open: 'open',
|
|
14
|
+
});
|
|
8
15
|
|
|
9
16
|
function cleanString(value) {
|
|
10
17
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -28,6 +35,35 @@ export function maskWhatsappAccount(accountJid) {
|
|
|
28
35
|
return `${digits.slice(0, 4)}••••${digits.slice(-4)}`;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
export function normalizeWhatsappAllowedNumbers(value) {
|
|
39
|
+
if (value === undefined) return Object.freeze([]);
|
|
40
|
+
if (!Array.isArray(value)) {
|
|
41
|
+
throw new TypeError('allowedNumbers must be an array of WhatsApp phone numbers');
|
|
42
|
+
}
|
|
43
|
+
const normalized = value.map((entry) => {
|
|
44
|
+
const number = typeof entry === 'string' ? entry.trim().replace(/^\+/, '') : '';
|
|
45
|
+
if (!WHATSAPP_PHONE_NUMBER.test(number)) {
|
|
46
|
+
throw new TypeError('allowedNumbers contains an invalid WhatsApp phone number');
|
|
47
|
+
}
|
|
48
|
+
return number;
|
|
49
|
+
});
|
|
50
|
+
return Object.freeze([...new Set(normalized)]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeWhatsappAccessPolicy(value = {}) {
|
|
54
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
55
|
+
throw new TypeError('WhatsApp access policy must be an object');
|
|
56
|
+
}
|
|
57
|
+
const accessMode = value.accessMode ?? WHATSAPP_ACCESS_MODES.selfOnly;
|
|
58
|
+
if (!Object.values(WHATSAPP_ACCESS_MODES).includes(accessMode)) {
|
|
59
|
+
throw new TypeError('WhatsApp accessMode is invalid');
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
accessMode,
|
|
63
|
+
allowedNumbers: normalizeWhatsappAllowedNumbers(value.allowedNumbers),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
31
67
|
export class WhatsappConfigStore {
|
|
32
68
|
#path;
|
|
33
69
|
#value = EMPTY_DOCUMENT;
|
|
@@ -112,6 +148,12 @@ export class WhatsappConfigStore {
|
|
|
112
148
|
if (!accountJid || !botId || !authDirectory || !name
|
|
113
149
|
|| !BOT_ID_PATTERN.test(botId) || !AUTH_DIRECTORY_PATTERN.test(authDirectory)
|
|
114
150
|
|| deriveWhatsappBotId(accountJid) !== botId) return null;
|
|
151
|
+
let accessPolicy;
|
|
152
|
+
try {
|
|
153
|
+
accessPolicy = normalizeWhatsappAccessPolicy(value);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
115
157
|
return Object.freeze({
|
|
116
158
|
botId,
|
|
117
159
|
accountJid,
|
|
@@ -119,6 +161,7 @@ export class WhatsappConfigStore {
|
|
|
119
161
|
name,
|
|
120
162
|
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
|
121
163
|
connectedAt: cleanString(value.connectedAt),
|
|
164
|
+
...accessPolicy,
|
|
122
165
|
});
|
|
123
166
|
}
|
|
124
167
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
deriveWhatsappBotId,
|
|
6
|
+
maskWhatsappAccount,
|
|
7
|
+
normalizeWhatsappAccessPolicy,
|
|
8
|
+
} from './config-store.mjs';
|
|
5
9
|
|
|
6
10
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
|
7
11
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
|
@@ -218,6 +222,20 @@ export class WhatsappController {
|
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
224
|
|
|
225
|
+
async setAccessPolicy(botId, value) {
|
|
226
|
+
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
|
227
|
+
const accessPolicy = normalizeWhatsappAccessPolicy(value);
|
|
228
|
+
await this.#withBotTransition(botId, async () => {
|
|
229
|
+
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
|
230
|
+
const config = this.#configStore.get(botId);
|
|
231
|
+
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
232
|
+
const saved = await this.#configStore.save({ ...config, ...accessPolicy });
|
|
233
|
+
this.#runtimes.get(botId)?.setAccessPolicy?.(saved);
|
|
234
|
+
this.#touch();
|
|
235
|
+
});
|
|
236
|
+
return this.status();
|
|
237
|
+
}
|
|
238
|
+
|
|
221
239
|
async deleteBot(botId) {
|
|
222
240
|
const config = this.#configStore.get(botId);
|
|
223
241
|
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
@@ -266,6 +284,7 @@ export class WhatsappController {
|
|
|
266
284
|
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
267
285
|
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
268
286
|
},
|
|
287
|
+
accessPolicy: normalizeWhatsappAccessPolicy(config),
|
|
269
288
|
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
270
289
|
};
|
|
271
290
|
});
|
|
@@ -310,6 +329,8 @@ export class WhatsappController {
|
|
|
310
329
|
name: identity.name,
|
|
311
330
|
createdAt: previous?.createdAt ?? new Date().toISOString(),
|
|
312
331
|
connectedAt: new Date().toISOString(),
|
|
332
|
+
accessMode: previous?.accessMode,
|
|
333
|
+
allowedNumbers: previous?.allowedNumbers,
|
|
313
334
|
};
|
|
314
335
|
try {
|
|
315
336
|
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
|
|
@@ -10,6 +10,10 @@ import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
|
10
10
|
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
11
11
|
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
|
12
12
|
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
|
|
13
|
+
import {
|
|
14
|
+
WHATSAPP_ACCESS_MODES,
|
|
15
|
+
normalizeWhatsappAccessPolicy,
|
|
16
|
+
} from './config-store.mjs';
|
|
13
17
|
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
|
|
14
18
|
|
|
15
19
|
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
@@ -181,6 +185,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|
|
181
185
|
&& [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
|
|
182
186
|
if (fromMe && !selfChat) return null;
|
|
183
187
|
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
|
|
188
|
+
const senderAlternateJid = group ? message.key.participantAlt : alternateRemoteJid;
|
|
184
189
|
if (typeof senderJid !== 'string' || !senderJid) return null;
|
|
185
190
|
const viewOnce = hasViewOnceWrapper(message.message);
|
|
186
191
|
const content = normalizeMessageContent(message.message);
|
|
@@ -194,6 +199,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|
|
194
199
|
messageId: `${remoteJid}:${messageId}`,
|
|
195
200
|
providerMessageId: messageId,
|
|
196
201
|
senderId: senderJid,
|
|
202
|
+
senderAlternateId: typeof senderAlternateJid === 'string' ? senderAlternateJid : '',
|
|
197
203
|
senderIsBot: false,
|
|
198
204
|
kind: group ? 'group' : 'direct',
|
|
199
205
|
conversationId: remoteJid,
|
|
@@ -205,6 +211,22 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|
|
205
211
|
};
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
export function whatsappInboundAllowed(message, {
|
|
215
|
+
accessMode = WHATSAPP_ACCESS_MODES.selfOnly,
|
|
216
|
+
allowedNumbers = new Set(),
|
|
217
|
+
} = {}) {
|
|
218
|
+
if (accessMode === WHATSAPP_ACCESS_MODES.open) return true;
|
|
219
|
+
if (message?.kind !== 'direct') return false;
|
|
220
|
+
if (message.selfChat === true) return true;
|
|
221
|
+
if (accessMode !== WHATSAPP_ACCESS_MODES.privateAllowlist
|
|
222
|
+
|| !(allowedNumbers instanceof Set)) return false;
|
|
223
|
+
const senderJids = [message.senderId, message.senderAlternateId]
|
|
224
|
+
.filter((jid) => typeof jid === 'string' && jid.endsWith('@s.whatsapp.net'));
|
|
225
|
+
return [...allowedNumbers].some((number) => senderJids.some((jid) => (
|
|
226
|
+
areJidsSameUser(jid, `${number}@s.whatsapp.net`)
|
|
227
|
+
)));
|
|
228
|
+
}
|
|
229
|
+
|
|
208
230
|
class RecentWhatsappOutboundIds {
|
|
209
231
|
#ids = new Map();
|
|
210
232
|
|
|
@@ -390,6 +412,8 @@ export class WhatsappRuntime {
|
|
|
390
412
|
#replyTimeoutMs;
|
|
391
413
|
#connectTimeoutMs;
|
|
392
414
|
#mediaUploadTimeoutMs;
|
|
415
|
+
#accessMode;
|
|
416
|
+
#allowedPrivateNumbers;
|
|
393
417
|
#createSession;
|
|
394
418
|
#status = createWhatsappRuntimeStatus();
|
|
395
419
|
#abortController = null;
|
|
@@ -427,12 +451,21 @@ export class WhatsappRuntime {
|
|
|
427
451
|
WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
|
|
428
452
|
);
|
|
429
453
|
this.#createSession = createSession;
|
|
454
|
+
this.setAccessPolicy(config);
|
|
430
455
|
}
|
|
431
456
|
|
|
432
457
|
get status() {
|
|
433
458
|
return structuredClone(this.#status);
|
|
434
459
|
}
|
|
435
460
|
|
|
461
|
+
setAccessPolicy(value) {
|
|
462
|
+
const policy = normalizeWhatsappAccessPolicy(value);
|
|
463
|
+
this.#accessMode = policy.accessMode;
|
|
464
|
+
this.#allowedPrivateNumbers = new Set(policy.allowedNumbers);
|
|
465
|
+
this.#config = { ...this.#config, ...policy };
|
|
466
|
+
return policy;
|
|
467
|
+
}
|
|
468
|
+
|
|
436
469
|
async start() {
|
|
437
470
|
if (this.#status.ready && this.#session) return this.status;
|
|
438
471
|
if (this.#starting) return this.#starting;
|
|
@@ -466,6 +499,14 @@ export class WhatsappRuntime {
|
|
|
466
499
|
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
|
|
467
500
|
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
|
|
468
501
|
this.#status.lastCheckedAt = Date.now();
|
|
502
|
+
if (!whatsappInboundAllowed(message, {
|
|
503
|
+
accessMode: this.#accessMode,
|
|
504
|
+
allowedNumbers: this.#allowedPrivateNumbers,
|
|
505
|
+
})) {
|
|
506
|
+
this.#status.messagesRejected += 1;
|
|
507
|
+
this.#status.lastRejectedAt = new Date().toISOString();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
469
510
|
await this.#bridge.accept(message);
|
|
470
511
|
},
|
|
471
512
|
onDisconnect: ({ error }) => {
|