@xmanrui/dsh-im 1.0.2 → 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.
Files changed (52) hide show
  1. package/README.en.md +23 -3
  2. package/README.md +23 -3
  3. package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
  4. package/assets/logo_cn.png +0 -0
  5. package/lib/client.js +815 -560
  6. package/lib/index.js +163 -163
  7. package/package.json +1 -1
  8. package/plugin-src/client/agent-preset.js +15 -6
  9. package/plugin-src/client/channel-card-meta.js +48 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -19
  11. package/plugin-src/client/channels/dingtalk/styles.js +0 -6
  12. package/plugin-src/client/channels/feishu/index.js +41 -35
  13. package/plugin-src/client/channels/feishu/styles.js +0 -5
  14. package/plugin-src/client/channels/qq/index.js +24 -16
  15. package/plugin-src/client/channels/shared/token-channel.js +32 -24
  16. package/plugin-src/client/channels/wecom/index.js +24 -16
  17. package/plugin-src/client/channels/weixin/index.js +29 -23
  18. package/plugin-src/client/channels/weixin/styles.js +0 -5
  19. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  20. package/plugin-src/client/channels/whatsapp/index.js +152 -23
  21. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  22. package/plugin-src/client/i18n.js +20 -0
  23. package/plugin-src/client/styles.js +23 -8
  24. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  25. package/plugin-src/host/index.mjs +14 -1
  26. package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
  28. package/src/channels/discord/discord-api.mjs +134 -6
  29. package/src/channels/discord/discord-runtime.mjs +15 -4
  30. package/src/channels/feishu/bridge.mjs +223 -15
  31. package/src/channels/feishu/feishu-channel.mjs +227 -1
  32. package/src/channels/feishu/plugin-controller.mjs +1 -0
  33. package/src/channels/qq/qq-bridge.mjs +217 -10
  34. package/src/channels/shared/editable-message-stream.mjs +18 -1
  35. package/src/channels/shared/harness-client.mjs +99 -7
  36. package/src/channels/shared/semantic/artifact.mjs +748 -0
  37. package/src/channels/shared/semantic/delivery.mjs +153 -0
  38. package/src/channels/shared/text-harness-bridge.mjs +149 -3
  39. package/src/channels/shared/workspace-session.mjs +15 -1
  40. package/src/channels/slack/manifest.mjs +1 -0
  41. package/src/channels/slack/slack-api.mjs +167 -4
  42. package/src/channels/slack/slack-runtime.mjs +21 -5
  43. package/src/channels/telegram/telegram-api.mjs +111 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +18 -4
  45. package/src/channels/wecom/wecom-bridge.mjs +260 -12
  46. package/src/channels/weixin/weixin-api.mjs +268 -2
  47. package/src/channels/weixin/weixin-bridge.mjs +134 -3
  48. package/src/channels/weixin/weixin-controller.mjs +5 -1
  49. package/src/channels/weixin/weixin-runtime.mjs +5 -1
  50. package/src/channels/whatsapp/config-store.mjs +43 -0
  51. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  52. package/src/channels/whatsapp/whatsapp-runtime.mjs +149 -5
@@ -1,4 +1,10 @@
1
- import { createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ randomUUID,
7
+ } from 'node:crypto';
2
8
 
3
9
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
4
10
 
@@ -13,6 +19,7 @@ const ILINK_APP_ID = 'bot';
13
19
  const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
14
20
  const DEFAULT_TIMEOUT_MS = 15_000;
15
21
  const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
22
+ const WEIXIN_CDN_UPLOAD_RETRIES = 3;
16
23
  const LOGIN_STATUSES = new Set([
17
24
  'wait',
18
25
  'scaned',
@@ -30,6 +37,7 @@ export class WeixinApiError extends Error {
30
37
  this.name = 'WeixinApiError';
31
38
  this.code = code;
32
39
  this.status = options.status;
40
+ this.providerCode = options.providerCode;
33
41
  }
34
42
  }
35
43
 
@@ -37,6 +45,70 @@ function nonEmptyString(value) {
37
45
  return typeof value === 'string' && value.trim() ? value.trim() : null;
38
46
  }
39
47
 
48
+ function safeProviderCode(value) {
49
+ const code = value === undefined || value === null ? null : String(value).trim();
50
+ return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
51
+ }
52
+
53
+ function preserveArtifactMetadata(target, source) {
54
+ if (Number.isInteger(source?.status)) target.status = source.status;
55
+ if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
56
+ return target;
57
+ }
58
+
59
+ function weixinArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
60
+ if (cause?.code?.startsWith?.('artifact-')) return cause;
61
+ const status = Number(cause?.status);
62
+ const providerCode = safeProviderCode(cause?.providerCode);
63
+ const providerText = providerCode ?? '';
64
+ let code = fallback;
65
+ let message = 'Weixin could not prepare the file for delivery.';
66
+ if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
67
+ || /(?:permission|forbidden|unauthor|access.?denied)/i.test(providerText)) {
68
+ code = 'artifact-permission-required';
69
+ message = 'Weixin denied permission to send the file.';
70
+ } else if (status === 413 || providerCode === '413'
71
+ || /(?:too.?large|size.?limit)/i.test(providerText)) {
72
+ code = 'artifact-too-large';
73
+ message = 'The file exceeds Weixin\'s size limit.';
74
+ } else if (status === 429 || providerCode === '429'
75
+ || /(?:rate.?limit|too.?many)/i.test(providerText)) {
76
+ code = 'artifact-rate-limited';
77
+ message = 'Weixin rate-limited file delivery.';
78
+ } else if (fallback === 'artifact-provider-rejected') {
79
+ message = 'Weixin rejected the file message.';
80
+ }
81
+ const error = new Error(message, { cause });
82
+ error.code = code;
83
+ return preserveArtifactMetadata(error, cause);
84
+ }
85
+
86
+ function uncertainWeixinDelivery(cause) {
87
+ const error = new Error('Weixin file delivery result is uncertain', { cause });
88
+ error.code = 'artifact-delivery-uncertain';
89
+ return preserveArtifactMetadata(error, cause);
90
+ }
91
+
92
+ function rejectedProviderResponse(value) {
93
+ if (!value || typeof value !== 'object') return null;
94
+ for (const field of ['ret', 'errcode']) {
95
+ if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
96
+ return safeProviderCode(value[field]) ?? 'rejected';
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function classifyWeixinFinalDeliveryError(error, signal) {
103
+ if (signal?.aborted) throw abortError(signal);
104
+ const status = Number(error?.status);
105
+ if (error?.code === 'network-error' || error?.code === 'timeout'
106
+ || error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
107
+ return uncertainWeixinDelivery(error);
108
+ }
109
+ return weixinArtifactError(error);
110
+ }
111
+
40
112
  function strictBase64(value) {
41
113
  const text = nonEmptyString(value);
42
114
  if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
@@ -184,10 +256,93 @@ function authenticatedHeaders(token) {
184
256
  function baseInfo() {
185
257
  return {
186
258
  channel_version: WEIXIN_PROTOCOL_VERSION,
187
- bot_agent: 'DeepSeekHarness/1.0.2',
259
+ bot_agent: 'DeepSeekHarness/1.1.0',
188
260
  };
189
261
  }
190
262
 
263
+ function aesEcbPaddedSize(size) {
264
+ return Math.ceil((size + 1) / 16) * 16;
265
+ }
266
+
267
+ function trustedWeixinCdnUploadUrl(value) {
268
+ let url;
269
+ try {
270
+ url = new URL(value);
271
+ } catch {
272
+ throw new WeixinApiError('invalid-upload-url', '微信服务返回了无效的文件上传地址。');
273
+ }
274
+ if (url.protocol !== 'https:' || url.hostname !== WEIXIN_CDN_HOST
275
+ || (url.port && url.port !== '443') || url.pathname !== '/c2c/upload'
276
+ || url.username || url.password) {
277
+ throw new WeixinApiError('untrusted-upload-url', '微信服务返回了不受信任的文件上传地址。');
278
+ }
279
+ url.hash = '';
280
+ return url;
281
+ }
282
+
283
+ function weixinCdnUploadUrl(response, fileKey) {
284
+ const fullUrl = nonEmptyString(response?.upload_full_url);
285
+ if (fullUrl) return trustedWeixinCdnUploadUrl(fullUrl);
286
+ const uploadParam = nonEmptyString(response?.upload_param);
287
+ if (!uploadParam) {
288
+ throw new WeixinApiError('missing-upload-url', '微信服务没有返回文件上传地址。');
289
+ }
290
+ const url = new URL(`${WEIXIN_CDN_BASE_URL}/upload`);
291
+ url.searchParams.set('encrypted_query_param', uploadParam);
292
+ url.searchParams.set('filekey', fileKey);
293
+ return trustedWeixinCdnUploadUrl(url);
294
+ }
295
+
296
+ function encryptWeixinUpload(bytes, key) {
297
+ const cipher = createCipheriv('aes-128-ecb', key, null);
298
+ return Buffer.concat([cipher.update(bytes), cipher.final()]);
299
+ }
300
+
301
+ async function uploadWeixinCdn(fetchImpl, url, ciphertext, { signal } = {}) {
302
+ let lastError;
303
+ for (let attempt = 1; attempt <= WEIXIN_CDN_UPLOAD_RETRIES; attempt += 1) {
304
+ signal?.throwIfAborted();
305
+ try {
306
+ const response = await fetchImpl(url, {
307
+ method: 'POST',
308
+ headers: { 'content-type': 'application/octet-stream' },
309
+ body: ciphertext,
310
+ signal: signal
311
+ ? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
312
+ : AbortSignal.timeout(60_000),
313
+ redirect: 'error',
314
+ });
315
+ if (response.status >= 400 && response.status < 500) {
316
+ throw new WeixinApiError(
317
+ 'upload-rejected',
318
+ `微信文件上传被拒绝(HTTP ${response.status})。`,
319
+ { status: response.status },
320
+ );
321
+ }
322
+ if (response.status !== 200) {
323
+ throw new WeixinApiError(
324
+ 'upload-failed',
325
+ `微信文件上传失败(HTTP ${response.status})。`,
326
+ { status: response.status },
327
+ );
328
+ }
329
+ const downloadParam = nonEmptyString(response.headers.get('x-encrypted-param'));
330
+ await response.body?.cancel?.().catch(() => undefined);
331
+ if (!downloadParam) {
332
+ throw new WeixinApiError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
333
+ }
334
+ return downloadParam;
335
+ } catch (error) {
336
+ if (signal?.aborted) throw abortError(signal);
337
+ if (error instanceof WeixinApiError
338
+ && (error.code === 'upload-rejected' || error.status < 500)) throw error;
339
+ lastError = error;
340
+ }
341
+ }
342
+ if (lastError instanceof WeixinApiError) throw lastError;
343
+ throw new WeixinApiError('upload-failed', '微信文件上传失败。', { cause: lastError });
344
+ }
345
+
191
346
  function abortError(signal) {
192
347
  if (signal?.reason instanceof Error) return signal.reason;
193
348
  return new DOMException('The operation was aborted', 'AbortError');
@@ -349,6 +504,117 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
349
504
  return true;
350
505
  },
351
506
 
507
+ async sendFile({ baseUrl, token, toUserId, file, contextToken, runId, signal }) {
508
+ const recipient = nonEmptyString(toUserId);
509
+ if (!recipient || !file || typeof file !== 'object'
510
+ || typeof file.fileName !== 'string' || !file.fileName
511
+ || !Buffer.isBuffer(file.bytes)) {
512
+ throw new TypeError('toUserId and a file are required');
513
+ }
514
+ signal?.throwIfAborted();
515
+ const fileKey = randomBytes(16).toString('hex');
516
+ const aesKey = randomBytes(16);
517
+ const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
518
+ let upload;
519
+ try {
520
+ upload = await requestJson(fetchImpl, {
521
+ method: 'POST',
522
+ baseUrl,
523
+ endpoint: 'ilink/bot/getuploadurl',
524
+ token,
525
+ signal,
526
+ body: {
527
+ filekey: fileKey,
528
+ media_type: 3,
529
+ to_user_id: recipient,
530
+ rawsize: file.bytes.byteLength,
531
+ rawfilemd5: rawMd5,
532
+ filesize: aesEcbPaddedSize(file.bytes.byteLength),
533
+ no_need_thumb: true,
534
+ aeskey: aesKey.toString('hex'),
535
+ base_info: baseInfo(),
536
+ },
537
+ });
538
+ } catch (error) {
539
+ if (signal?.aborted) throw abortError(signal);
540
+ const status = Number(error?.status);
541
+ const fallback = error?.code === 'http-error' && status >= 400 && status < 500
542
+ ? 'artifact-provider-rejected'
543
+ : 'artifact-provider-failed';
544
+ throw weixinArtifactError(error, { fallback });
545
+ }
546
+ const uploadRejection = rejectedProviderResponse(upload);
547
+ if (uploadRejection) {
548
+ throw weixinArtifactError(new WeixinApiError(
549
+ 'upload-url-rejected',
550
+ '微信服务拒绝了文件上传请求。',
551
+ { providerCode: uploadRejection },
552
+ ));
553
+ }
554
+ const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
555
+ const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
556
+ let downloadParam;
557
+ try {
558
+ downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
559
+ } catch (error) {
560
+ if (signal?.aborted) throw abortError(signal);
561
+ const status = Number(error?.status);
562
+ const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
563
+ ? 'artifact-provider-rejected'
564
+ : 'artifact-provider-failed';
565
+ throw weixinArtifactError(error, { fallback });
566
+ }
567
+ signal?.throwIfAborted();
568
+ const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
569
+ ?? randomUUID();
570
+ const clientId = `dsh-weixin-${createHash('sha256').update(deliverySeed).digest('hex').slice(0, 32)}`;
571
+ let response;
572
+ try {
573
+ response = await requestJson(fetchImpl, {
574
+ method: 'POST',
575
+ baseUrl,
576
+ endpoint: 'ilink/bot/sendmessage',
577
+ token,
578
+ signal,
579
+ body: {
580
+ msg: {
581
+ from_user_id: '',
582
+ to_user_id: recipient,
583
+ client_id: clientId,
584
+ message_type: 2,
585
+ message_state: 2,
586
+ item_list: [{
587
+ type: 4,
588
+ file_item: {
589
+ media: {
590
+ encrypt_query_param: downloadParam,
591
+ aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
592
+ encrypt_type: 1,
593
+ },
594
+ file_name: file.fileName,
595
+ len: String(file.bytes.byteLength),
596
+ },
597
+ }],
598
+ ...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
599
+ ...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
600
+ },
601
+ base_info: baseInfo(),
602
+ },
603
+ });
604
+ } catch (error) {
605
+ throw classifyWeixinFinalDeliveryError(error, signal);
606
+ }
607
+ const sendRejection = rejectedProviderResponse(response);
608
+ if (sendRejection) {
609
+ throw weixinArtifactError(new WeixinApiError(
610
+ 'send-rejected',
611
+ '微信服务拒绝了文件消息。',
612
+ { providerCode: sendRejection },
613
+ ));
614
+ }
615
+ return { messageId: clientId };
616
+ },
617
+
352
618
  async notifyStart({ baseUrl, token, signal }) {
353
619
  const response = await requestJson(fetchImpl, {
354
620
  method: 'POST',
@@ -32,6 +32,16 @@ import {
32
32
  promptContentForMessage,
33
33
  } from '../shared/image-prompt.mjs';
34
34
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
35
+ import {
36
+ materializeOutboundArtifact,
37
+ releaseOutboundArtifact,
38
+ } from '../shared/semantic/artifact.mjs';
39
+ import {
40
+ createArtifactFailureReceipt,
41
+ createDeliveryReceipt,
42
+ mergeDeliveryReceipts,
43
+ providerMessageIdsFor,
44
+ } from '../shared/semantic/delivery.mjs';
35
45
 
36
46
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
37
47
  const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
@@ -89,6 +99,28 @@ function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR) {
89
99
  };
90
100
  }
91
101
 
102
+ function artifactFailureText(fileName, error) {
103
+ const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
104
+ switch (error?.code) {
105
+ case 'artifact-delivery-uncertain':
106
+ return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
107
+ case 'artifact-permission-required':
108
+ return `结果文件「${name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。`;
109
+ case 'artifact-too-large':
110
+ return `结果文件「${name}」超过当前微信会话可发送的文件大小,未发送。`;
111
+ case 'artifact-rate-limited':
112
+ return `结果文件「${name}」暂时被微信限流,未能发送,请稍后重试。`;
113
+ case 'artifact-provider-rejected':
114
+ return `结果文件「${name}」已生成,但微信拒绝了该文件消息。`;
115
+ case 'artifact-invalid':
116
+ case 'artifact-changed':
117
+ case 'artifact-unavailable':
118
+ return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
119
+ default:
120
+ return `结果文件「${name}」已生成,但暂时未能通过微信发送,请稍后重试。`;
121
+ }
122
+ }
123
+
92
124
  export function createWeixinBridgeStatus() {
93
125
  return {
94
126
  messagesReceived: 0,
@@ -394,8 +426,9 @@ export class WeixinHarnessBridge {
394
426
  ? await promptContentForMessage(promptMessage, { signal: this.#signal })
395
427
  : undefined;
396
428
  let answer;
429
+ let artifacts = [];
397
430
  try {
398
- ({ answer } = await askInWorkspaceSession({
431
+ ({ answer, artifacts = [] } = await askInWorkspaceSession({
399
432
  harness: this.#harness,
400
433
  state: this.#state,
401
434
  key,
@@ -421,12 +454,35 @@ export class WeixinHarnessBridge {
421
454
  this.#approvals.closeRoute(key),
422
455
  ]);
423
456
  }
424
- await this.#send(sender, answer, contextToken, runId);
457
+ const answerText = typeof answer === 'string' && answer.trim()
458
+ ? answer
459
+ : artifacts.length > 0 ? '结果文件已生成。' : answer;
460
+ let textDeliveryError = null;
461
+ let textReceipt = null;
462
+ try {
463
+ textReceipt = createDeliveryReceipt({
464
+ deliveryId: messageId,
465
+ presentation: 'weixin-text',
466
+ providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
467
+ });
468
+ } catch (error) {
469
+ textDeliveryError = error;
470
+ }
471
+ const delivery = await this.#deliverArtifacts(
472
+ sender,
473
+ messageId,
474
+ artifacts,
475
+ contextToken,
476
+ runId,
477
+ textReceipt,
478
+ );
479
+ if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
425
480
  await this.#state.markSeen(messageId);
426
481
  this.#status.messagesReplied += 1;
427
482
  this.#status.lastReplyAt = new Date().toISOString();
428
483
  this.#status.lastError = null;
429
484
  this.#status.lastMessageError = null;
485
+ return delivery.receipt;
430
486
  } catch (error) {
431
487
  if (error?.code === 'turn-stopped') {
432
488
  await this.#state.markSeen(messageId);
@@ -762,15 +818,90 @@ export class WeixinHarnessBridge {
762
818
  }
763
819
 
764
820
  async #send(toUserId, text, contextToken, runId) {
821
+ const providerMessageIds = [];
765
822
  for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
766
- await this.#api.sendText({
823
+ const result = await this.#api.sendText({
767
824
  baseUrl: this.#baseUrl,
768
825
  token: this.#token,
769
826
  toUserId,
770
827
  text: chunk,
771
828
  contextToken,
772
829
  runId,
830
+ signal: this.#signal,
773
831
  });
832
+ providerMessageIds.push(...providerMessageIdsFor(result));
833
+ }
834
+ return providerMessageIds;
835
+ }
836
+
837
+ async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
838
+ const receipts = baseReceipt ? [baseReceipt] : [];
839
+ let userVisible = Boolean(baseReceipt);
840
+ for (const artifact of artifacts) {
841
+ this.#signal?.throwIfAborted();
842
+ try {
843
+ if (typeof this.#api.sendFile !== 'function') {
844
+ const unavailable = new Error('Weixin file delivery is unavailable');
845
+ unavailable.code = 'artifact-provider-unavailable';
846
+ throw unavailable;
847
+ }
848
+ const file = await materializeOutboundArtifact(artifact, {
849
+ signal: this.#signal,
850
+ });
851
+ const result = await this.#api.sendFile({
852
+ baseUrl: this.#baseUrl,
853
+ token: this.#token,
854
+ toUserId,
855
+ file,
856
+ contextToken,
857
+ runId,
858
+ signal: this.#signal,
859
+ });
860
+ receipts.push(createDeliveryReceipt({
861
+ deliveryId: file.deliveryKey,
862
+ presentation: 'weixin-file',
863
+ providerMessageIds: providerMessageIdsFor(result),
864
+ artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
865
+ }));
866
+ userVisible = true;
867
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
868
+ } catch (error) {
869
+ if (this.#signal?.aborted) throw error;
870
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
871
+ this.#logger.warn?.(
872
+ `[dsh-weixin] result file delivery failed (${error?.code ?? 'unknown'})`,
873
+ );
874
+ let noticeSent = false;
875
+ const providerMessageIds = await this.#send(
876
+ toUserId,
877
+ artifactFailureText(artifact?.fileName, error),
878
+ contextToken,
879
+ runId,
880
+ ).then((ids) => {
881
+ noticeSent = true;
882
+ return ids;
883
+ }).catch(() => []);
884
+ const failureReceipt = createArtifactFailureReceipt({
885
+ artifactId: artifact?.artifactId ?? 'unknown',
886
+ deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
887
+ error,
888
+ providerMessageIds,
889
+ });
890
+ receipts.push(failureReceipt);
891
+ if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
892
+ } finally {
893
+ releaseOutboundArtifact(artifact);
894
+ }
774
895
  }
896
+ const receipt = receipts.length === 0
897
+ ? null
898
+ : receipts.length === 1
899
+ ? receipts[0]
900
+ : mergeDeliveryReceipts({
901
+ deliveryId: replyTo,
902
+ presentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
903
+ receipts,
904
+ });
905
+ return { receipt, userVisible };
775
906
  }
776
907
  }
@@ -27,7 +27,11 @@ const ACTIVATION_ERROR_MESSAGES = Object.freeze({
27
27
  'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
28
28
  'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
29
29
  'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
30
- 'harness-access-denied': '微信已授权,但 Harness 拒绝了本机健康检查。请检查 Host 信任配置。',
30
+ 'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
31
+ 'harness-proxy-auth-required': '微信已授权,但本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。',
32
+ 'harness-loopback-forbidden': '微信已授权,但 Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。',
33
+ 'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
34
+ 'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
31
35
  'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
32
36
  'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
33
37
  'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
@@ -9,7 +9,11 @@ const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
9
9
  const HARNESS_HEALTH_ERROR_CODES = new Set([
10
10
  'harness-connect-failed',
11
11
  'harness-timeout',
12
- 'harness-access-denied',
12
+ 'harness-auth-required',
13
+ 'harness-proxy-auth-required',
14
+ 'harness-loopback-forbidden',
15
+ 'harness-host-untrusted',
16
+ 'harness-request-forbidden',
13
17
  'harness-api-not-found',
14
18
  'harness-http-failed',
15
19
  'harness-response-invalid',
@@ -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' });