@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -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 { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
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 }) => {