@xmanrui/dsh-im 1.1.0 → 1.3.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.
Files changed (40) hide show
  1. package/README.en.md +5 -3
  2. package/README.md +5 -3
  3. package/lib/client.js +212 -0
  4. package/lib/index.js +166 -163
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  7. package/plugin-src/client/channels/whatsapp/index.js +125 -0
  8. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  9. package/plugin-src/client/i18n.js +18 -0
  10. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  11. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  12. package/plugin-src/host/channels/qq/production.mjs +3 -1
  13. package/plugin-src/host/channels/shared/production.mjs +3 -1
  14. package/plugin-src/host/channels/slack/production.mjs +3 -1
  15. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  16. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  17. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  18. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  19. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  20. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  22. package/src/channels/discord/discord-runtime.mjs +23 -0
  23. package/src/channels/feishu/bridge.mjs +18 -10
  24. package/src/channels/feishu/message-utils.mjs +47 -0
  25. package/src/channels/qq/qq-bridge.mjs +80 -28
  26. package/src/channels/shared/file-download.mjs +64 -0
  27. package/src/channels/shared/harness-client.mjs +45 -0
  28. package/src/channels/shared/inbound-file.mjs +206 -0
  29. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  30. package/src/channels/slack/slack-api.mjs +27 -4
  31. package/src/channels/slack/slack-runtime.mjs +55 -5
  32. package/src/channels/telegram/telegram-api.mjs +21 -6
  33. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  34. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  35. package/src/channels/weixin/weixin-api.mjs +45 -0
  36. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  37. package/src/channels/whatsapp/config-store.mjs +43 -0
  38. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  39. package/src/channels/whatsapp/whatsapp-runtime.mjs +83 -2
  40. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -1,4 +1,5 @@
1
1
  import {
2
+ extractWeixinFiles,
2
3
  extractWeixinImages,
3
4
  extractWeixinText,
4
5
  splitWeixinText,
@@ -31,6 +32,11 @@ import {
31
32
  imagePromptUserMessage,
32
33
  promptContentForMessage,
33
34
  } from '../shared/image-prompt.mjs';
35
+ import {
36
+ hasInboundFiles,
37
+ inboundFileUserMessage,
38
+ prefetchInboundFiles,
39
+ } from '../shared/inbound-file.mjs';
34
40
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
35
41
  import {
36
42
  materializeOutboundArtifact,
@@ -49,7 +55,7 @@ const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
49
55
  const HELP_TEXT = [
50
56
  '微信已连接 DeepSeek Harness。',
51
57
  '',
52
- '直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
58
+ '直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。',
53
59
  '/new 开启一个全新会话',
54
60
  '/compact 压缩当前会话的较早上下文',
55
61
  '/workspace 工作区绝对路径 切换工作区',
@@ -77,15 +83,33 @@ function nonEmptyString(value) {
77
83
  return typeof value === 'string' && value.trim() ? value.trim() : null;
78
84
  }
79
85
 
86
+ export function weixinInboundMessage(message, api) {
87
+ return {
88
+ content: extractWeixinText(message) ?? '',
89
+ images: typeof api?.inboundImages === 'function'
90
+ ? api.inboundImages(message)
91
+ : extractWeixinImages(message),
92
+ files: typeof api?.inboundFiles === 'function'
93
+ ? api.inboundFiles(message)
94
+ : extractWeixinFiles(message),
95
+ };
96
+ }
97
+
80
98
  function hasWeixinImageItems(message) {
81
99
  return Array.isArray(message?.item_list)
82
100
  && message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
83
101
  }
84
102
 
103
+ function hasWeixinFileItems(message) {
104
+ return Array.isArray(message?.item_list)
105
+ && message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
106
+ }
107
+
85
108
  function canClaimInteractionReply(message, pending) {
86
109
  return pending.questions[pending.index]
87
110
  && nonEmptyString(message?.from_user_id) === pending.actor
88
111
  && !hasWeixinImageItems(message)
112
+ && !hasWeixinFileItems(message)
89
113
  && nonEmptyString(extractWeixinText(message));
90
114
  }
91
115
 
@@ -204,7 +228,7 @@ export class WeixinHarnessBridge {
204
228
  const runId = nonEmptyString(message?.run_id) ?? undefined;
205
229
  const pending = this.#pendingInteractions.get(key);
206
230
  const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
207
- const commandRunner = isControlCommand(commandText)
231
+ const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
208
232
  ? runControlCommand
209
233
  : (isModelCommand(commandText)
210
234
  ? runModelCommand
@@ -238,7 +262,9 @@ export class WeixinHarnessBridge {
238
262
  key,
239
263
  actor: sender,
240
264
  messageId,
241
- text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
265
+ text: hasWeixinImageItems(message) || hasWeixinFileItems(message)
266
+ ? ''
267
+ : extractWeixinText(message),
242
268
  addressed: true,
243
269
  hasPendingQuestion: Boolean(pending),
244
270
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -290,10 +316,16 @@ export class WeixinHarnessBridge {
290
316
  releaseMessageId = true,
291
317
  alreadyRecorded = false,
292
318
  } = {}) {
319
+ const preparedMessage = message.from_user_id === this.#ownerUserId
320
+ ? prefetchInboundFiles(
321
+ weixinInboundMessage(message, this.#api),
322
+ { signal: this.#signal },
323
+ )
324
+ : undefined;
293
325
  const previous = this.#queues.get(key) ?? Promise.resolve();
294
326
  const current = previous
295
327
  .catch(() => undefined)
296
- .then(() => this.#process(message, key, { alreadyRecorded }))
328
+ .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
297
329
  .finally(() => {
298
330
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
299
331
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -331,6 +363,7 @@ export class WeixinHarnessBridge {
331
363
  const result = await runner(text, this.#harness, this.#state, key, {
332
364
  signal: this.#signal,
333
365
  hasImages: hasWeixinImageItems(message),
366
+ hasFiles: hasWeixinFileItems(message),
334
367
  pendingInteraction: this.#pendingInteractions.has(key)
335
368
  || this.#approvals.hasPending(key),
336
369
  control: { owner: this, key },
@@ -348,7 +381,7 @@ export class WeixinHarnessBridge {
348
381
  this.#status.lastMessageError = null;
349
382
  }
350
383
 
351
- async #process(message, key, { alreadyRecorded = false } = {}) {
384
+ async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
352
385
  this.#signal?.throwIfAborted();
353
386
  const messageId = weixinMessageId(message);
354
387
  const sender = nonEmptyString(message?.from_user_id);
@@ -366,38 +399,36 @@ export class WeixinHarnessBridge {
366
399
 
367
400
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
368
401
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
369
- const text = extractWeixinText(message) ?? '';
370
402
  try {
371
- const images = typeof this.#api.inboundImages === 'function'
372
- ? this.#api.inboundImages(message)
373
- : extractWeixinImages(message);
374
- const promptMessage = { content: text, images };
403
+ const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
404
+ const text = promptMessage.content;
375
405
  const hasImages = hasInboundImages(promptMessage);
376
- if (!text && !hasImages) {
377
- await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
406
+ const hasFiles = hasInboundFiles(promptMessage);
407
+ if (!text && !hasImages && !hasFiles) {
408
+ await this.#send(sender, '目前支持文字、图片、文件,以及微信已转成文字的语音消息。', contextToken, runId);
378
409
  await this.#state.markSeen(messageId);
379
410
  return;
380
411
  }
381
412
 
382
413
  const command = text.trim().toLowerCase();
383
- if (!hasImages && command === '/help') {
414
+ if (!hasImages && !hasFiles && command === '/help') {
384
415
  await this.#send(sender, HELP_TEXT, contextToken, runId);
385
416
  await this.#state.markSeen(messageId);
386
417
  return;
387
418
  }
388
- if (!hasImages && command === '/status') {
419
+ if (!hasImages && !hasFiles && command === '/status') {
389
420
  await this.#harness.ensureRunning({ signal: this.#signal });
390
421
  await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
391
422
  await this.#state.markSeen(messageId);
392
423
  return;
393
424
  }
394
- if (!hasImages && command === '/new') {
425
+ if (!hasImages && !hasFiles && command === '/new') {
395
426
  await this.#state.clearSession(key);
396
427
  await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
397
428
  await this.#state.markSeen(messageId);
398
429
  return;
399
430
  }
400
- const workspaceCommand = hasImages
431
+ const workspaceCommand = hasImages || hasFiles
401
432
  ? null
402
433
  : await runWorkspaceCommand(text, this.#harness, key);
403
434
  if (workspaceCommand) {
@@ -407,7 +438,7 @@ export class WeixinHarnessBridge {
407
438
  await this.#state.markSeen(messageId);
408
439
  return;
409
440
  }
410
- const compactCommand = hasImages
441
+ const compactCommand = hasImages || hasFiles
411
442
  ? null
412
443
  : await runCompactCommand(
413
444
  text,
@@ -446,6 +477,7 @@ export class WeixinHarnessBridge {
446
477
  runId,
447
478
  }),
448
479
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
480
+ files: promptMessage.files,
449
481
  },
450
482
  }));
451
483
  } finally {
@@ -490,7 +522,9 @@ export class WeixinHarnessBridge {
490
522
  }
491
523
  if (this.#signal?.aborted) return;
492
524
  this.#status.lastError = error?.message ?? String(error);
493
- const userMessage = imagePromptUserMessage(error) ?? GENERIC_PROCESSING_ERROR;
525
+ const userMessage = inboundFileUserMessage(error)
526
+ ?? imagePromptUserMessage(error)
527
+ ?? GENERIC_PROCESSING_ERROR;
494
528
  this.#status.lastMessageError = safeMessageError(error, userMessage);
495
529
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
496
530
  try {
@@ -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']);
@@ -166,6 +170,39 @@ function whatsappImageSource(message, content, download, { viewOnce = false } =
166
170
  };
167
171
  }
168
172
 
173
+ function whatsappFileSource(message, content, download) {
174
+ const media = content?.documentMessage;
175
+ if (!media) return null;
176
+ const mediaType = typeof media.mimetype === 'string' && media.mimetype
177
+ ? media.mimetype.toLowerCase() : undefined;
178
+ if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
179
+ return {
180
+ name: typeof media.fileName === 'string' && media.fileName
181
+ ? media.fileName : 'whatsapp-file',
182
+ ...(mediaType ? { mediaType } : {}),
183
+ size: mediaSize(media.fileLength),
184
+ async load({ signal } = {}) {
185
+ signal?.throwIfAborted();
186
+ const stream = await download(message, 'stream', { options: { signal } });
187
+ signal?.throwIfAborted();
188
+ return { stream };
189
+ },
190
+ };
191
+ }
192
+
193
+ export function createWhatsappMediaDownloader({
194
+ socket,
195
+ logger = console,
196
+ download = downloadMediaMessage,
197
+ } = {}) {
198
+ if (typeof download !== 'function') throw new TypeError('WhatsApp media downloader is required');
199
+ if (typeof socket?.updateMediaMessage !== 'function') return download;
200
+ return (message, type, options) => download(message, type, options, {
201
+ logger,
202
+ reuploadRequest: (candidate) => socket.updateMediaMessage(candidate),
203
+ });
204
+ }
205
+
169
206
  export function normalizeWhatsappMessage(message, accountJid, {
170
207
  download = downloadMediaMessage,
171
208
  } = {}) {
@@ -181,6 +218,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
181
218
  && [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
182
219
  if (fromMe && !selfChat) return null;
183
220
  const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
221
+ const senderAlternateJid = group ? message.key.participantAlt : alternateRemoteJid;
184
222
  if (typeof senderJid !== 'string' || !senderJid) return null;
185
223
  const viewOnce = hasViewOnceWrapper(message.message);
186
224
  const content = normalizeMessageContent(message.message);
@@ -190,21 +228,40 @@ export function normalizeWhatsappMessage(message, accountJid, {
190
228
  const replyToSelf = typeof context?.participant === 'string'
191
229
  && areJidsSameUser(context.participant, accountJid);
192
230
  const image = whatsappImageSource(message, content, download, { viewOnce });
231
+ const file = whatsappFileSource(message, content, download);
193
232
  return {
194
233
  messageId: `${remoteJid}:${messageId}`,
195
234
  providerMessageId: messageId,
196
235
  senderId: senderJid,
236
+ senderAlternateId: typeof senderAlternateJid === 'string' ? senderAlternateJid : '',
197
237
  senderIsBot: false,
198
238
  kind: group ? 'group' : 'direct',
199
239
  conversationId: remoteJid,
200
240
  content: messageText(content),
201
241
  images: image ? [image] : [],
242
+ files: file ? [file] : [],
202
243
  addressed: !group || mentioned || replyToSelf,
203
244
  selfChat,
204
245
  replyTarget: { jid: remoteJid, quoted: message, selfChat },
205
246
  };
206
247
  }
207
248
 
249
+ export function whatsappInboundAllowed(message, {
250
+ accessMode = WHATSAPP_ACCESS_MODES.selfOnly,
251
+ allowedNumbers = new Set(),
252
+ } = {}) {
253
+ if (accessMode === WHATSAPP_ACCESS_MODES.open) return true;
254
+ if (message?.kind !== 'direct') return false;
255
+ if (message.selfChat === true) return true;
256
+ if (accessMode !== WHATSAPP_ACCESS_MODES.privateAllowlist
257
+ || !(allowedNumbers instanceof Set)) return false;
258
+ const senderJids = [message.senderId, message.senderAlternateId]
259
+ .filter((jid) => typeof jid === 'string' && jid.endsWith('@s.whatsapp.net'));
260
+ return [...allowedNumbers].some((number) => senderJids.some((jid) => (
261
+ areJidsSameUser(jid, `${number}@s.whatsapp.net`)
262
+ )));
263
+ }
264
+
208
265
  class RecentWhatsappOutboundIds {
209
266
  #ids = new Map();
210
267
 
@@ -390,6 +447,8 @@ export class WhatsappRuntime {
390
447
  #replyTimeoutMs;
391
448
  #connectTimeoutMs;
392
449
  #mediaUploadTimeoutMs;
450
+ #accessMode;
451
+ #allowedPrivateNumbers;
393
452
  #createSession;
394
453
  #status = createWhatsappRuntimeStatus();
395
454
  #abortController = null;
@@ -427,12 +486,21 @@ export class WhatsappRuntime {
427
486
  WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
428
487
  );
429
488
  this.#createSession = createSession;
489
+ this.setAccessPolicy(config);
430
490
  }
431
491
 
432
492
  get status() {
433
493
  return structuredClone(this.#status);
434
494
  }
435
495
 
496
+ setAccessPolicy(value) {
497
+ const policy = normalizeWhatsappAccessPolicy(value);
498
+ this.#accessMode = policy.accessMode;
499
+ this.#allowedPrivateNumbers = new Set(policy.allowedNumbers);
500
+ this.#config = { ...this.#config, ...policy };
501
+ return policy;
502
+ }
503
+
436
504
  async start() {
437
505
  if (this.#status.ready && this.#session) return this.status;
438
506
  if (this.#starting) return this.#starting;
@@ -462,10 +530,23 @@ export class WhatsappRuntime {
462
530
  new Error('WhatsApp linked-device session must be scanned again'),
463
531
  { code: 'relink-required' },
464
532
  )),
465
- onMessage: async (raw) => {
466
- const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
533
+ onMessage: async (raw, context) => {
534
+ const message = normalizeWhatsappMessage(raw, this.#config.accountJid, {
535
+ download: createWhatsappMediaDownloader({
536
+ socket: context?.socket,
537
+ logger: this.#logger,
538
+ }),
539
+ });
467
540
  if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
468
541
  this.#status.lastCheckedAt = Date.now();
542
+ if (!whatsappInboundAllowed(message, {
543
+ accessMode: this.#accessMode,
544
+ allowedNumbers: this.#allowedPrivateNumbers,
545
+ })) {
546
+ this.#status.messagesRejected += 1;
547
+ this.#status.lastRejectedAt = new Date().toISOString();
548
+ return;
549
+ }
469
550
  await this.#bridge.accept(message);
470
551
  },
471
552
  onDisconnect: ({ error }) => {
@@ -190,7 +190,7 @@ export async function createWhatsappWebSession({
190
190
  const timestamp = messageTimestampMs(message?.messageTimestamp);
191
191
  if (timestamp === null || timestamp < sessionStartedAt - APPEND_RECENT_GRACE_MS) continue;
192
192
  }
193
- Promise.resolve(onMessage(message)).catch(() => {
193
+ Promise.resolve(onMessage(message, { socket: nextSocket })).catch(() => {
194
194
  logger.error?.('[dsh-im:whatsapp] failed to process an inbound WhatsApp message');
195
195
  });
196
196
  }