@xmanrui/dsh-im 1.3.0 → 1.5.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.
@@ -457,6 +457,123 @@ function validateLoginResponse(value) {
457
457
  export function createWeixinApi({ fetchImpl = fetch } = {}) {
458
458
  if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
459
459
 
460
+ async function sendArtifact({
461
+ baseUrl,
462
+ token,
463
+ toUserId,
464
+ file,
465
+ contextToken,
466
+ runId,
467
+ signal,
468
+ }, { mediaType, createItem }) {
469
+ const recipient = nonEmptyString(toUserId);
470
+ if (!recipient || !file || typeof file !== 'object'
471
+ || typeof file.fileName !== 'string' || !file.fileName
472
+ || !Buffer.isBuffer(file.bytes)) {
473
+ throw new TypeError('toUserId and a file are required');
474
+ }
475
+ signal?.throwIfAborted();
476
+ const fileKey = randomBytes(16).toString('hex');
477
+ const aesKey = randomBytes(16);
478
+ const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
479
+ let upload;
480
+ try {
481
+ upload = await requestJson(fetchImpl, {
482
+ method: 'POST',
483
+ baseUrl,
484
+ endpoint: 'ilink/bot/getuploadurl',
485
+ token,
486
+ signal,
487
+ body: {
488
+ filekey: fileKey,
489
+ media_type: mediaType,
490
+ to_user_id: recipient,
491
+ rawsize: file.bytes.byteLength,
492
+ rawfilemd5: rawMd5,
493
+ filesize: aesEcbPaddedSize(file.bytes.byteLength),
494
+ no_need_thumb: true,
495
+ aeskey: aesKey.toString('hex'),
496
+ base_info: baseInfo(),
497
+ },
498
+ });
499
+ } catch (error) {
500
+ if (signal?.aborted) throw abortError(signal);
501
+ const status = Number(error?.status);
502
+ const fallback = error?.code === 'http-error' && status >= 400 && status < 500
503
+ ? 'artifact-provider-rejected'
504
+ : 'artifact-provider-failed';
505
+ throw weixinArtifactError(error, { fallback });
506
+ }
507
+ const uploadRejection = rejectedProviderResponse(upload);
508
+ if (uploadRejection) {
509
+ throw weixinArtifactError(new WeixinApiError(
510
+ 'upload-url-rejected',
511
+ '微信服务拒绝了文件上传请求。',
512
+ { providerCode: uploadRejection },
513
+ ));
514
+ }
515
+ const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
516
+ const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
517
+ let downloadParam;
518
+ try {
519
+ downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
520
+ } catch (error) {
521
+ if (signal?.aborted) throw abortError(signal);
522
+ const status = Number(error?.status);
523
+ const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
524
+ ? 'artifact-provider-rejected'
525
+ : 'artifact-provider-failed';
526
+ throw weixinArtifactError(error, { fallback });
527
+ }
528
+ signal?.throwIfAborted();
529
+ const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
530
+ ?? randomUUID();
531
+ const clientIdSeed = mediaType === 3 ? deliverySeed : `${deliverySeed}\u0000${mediaType}`;
532
+ const clientId = `dsh-weixin-${createHash('sha256')
533
+ .update(clientIdSeed)
534
+ .digest('hex')
535
+ .slice(0, 32)}`;
536
+ const media = {
537
+ encrypt_query_param: downloadParam,
538
+ aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
539
+ encrypt_type: 1,
540
+ };
541
+ let response;
542
+ try {
543
+ response = await requestJson(fetchImpl, {
544
+ method: 'POST',
545
+ baseUrl,
546
+ endpoint: 'ilink/bot/sendmessage',
547
+ token,
548
+ signal,
549
+ body: {
550
+ msg: {
551
+ from_user_id: '',
552
+ to_user_id: recipient,
553
+ client_id: clientId,
554
+ message_type: 2,
555
+ message_state: 2,
556
+ item_list: [createItem({ file, media, ciphertextSize: ciphertext.byteLength })],
557
+ ...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
558
+ ...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
559
+ },
560
+ base_info: baseInfo(),
561
+ },
562
+ });
563
+ } catch (error) {
564
+ throw classifyWeixinFinalDeliveryError(error, signal);
565
+ }
566
+ const sendRejection = rejectedProviderResponse(response);
567
+ if (sendRejection) {
568
+ throw weixinArtifactError(new WeixinApiError(
569
+ 'send-rejected',
570
+ '微信服务拒绝了文件消息。',
571
+ { providerCode: sendRejection },
572
+ ));
573
+ }
574
+ return { messageId: clientId };
575
+ }
576
+
460
577
  return Object.freeze({
461
578
  inboundImages(message) {
462
579
  return extractWeixinImages(message, { fetchImpl });
@@ -549,115 +666,31 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
549
666
  return true;
550
667
  },
551
668
 
552
- async sendFile({ baseUrl, token, toUserId, file, contextToken, runId, signal }) {
553
- const recipient = nonEmptyString(toUserId);
554
- if (!recipient || !file || typeof file !== 'object'
555
- || typeof file.fileName !== 'string' || !file.fileName
556
- || !Buffer.isBuffer(file.bytes)) {
557
- throw new TypeError('toUserId and a file are required');
558
- }
559
- signal?.throwIfAborted();
560
- const fileKey = randomBytes(16).toString('hex');
561
- const aesKey = randomBytes(16);
562
- const rawMd5 = createHash('md5').update(file.bytes).digest('hex');
563
- let upload;
564
- try {
565
- upload = await requestJson(fetchImpl, {
566
- method: 'POST',
567
- baseUrl,
568
- endpoint: 'ilink/bot/getuploadurl',
569
- token,
570
- signal,
571
- body: {
572
- filekey: fileKey,
573
- media_type: 3,
574
- to_user_id: recipient,
575
- rawsize: file.bytes.byteLength,
576
- rawfilemd5: rawMd5,
577
- filesize: aesEcbPaddedSize(file.bytes.byteLength),
578
- no_need_thumb: true,
579
- aeskey: aesKey.toString('hex'),
580
- base_info: baseInfo(),
669
+ async sendFile(request) {
670
+ return sendArtifact(request, {
671
+ mediaType: 3,
672
+ createItem: ({ file, media }) => ({
673
+ type: 4,
674
+ file_item: {
675
+ media,
676
+ file_name: file.fileName,
677
+ len: String(file.bytes.byteLength),
581
678
  },
582
- });
583
- } catch (error) {
584
- if (signal?.aborted) throw abortError(signal);
585
- const status = Number(error?.status);
586
- const fallback = error?.code === 'http-error' && status >= 400 && status < 500
587
- ? 'artifact-provider-rejected'
588
- : 'artifact-provider-failed';
589
- throw weixinArtifactError(error, { fallback });
590
- }
591
- const uploadRejection = rejectedProviderResponse(upload);
592
- if (uploadRejection) {
593
- throw weixinArtifactError(new WeixinApiError(
594
- 'upload-url-rejected',
595
- '微信服务拒绝了文件上传请求。',
596
- { providerCode: uploadRejection },
597
- ));
598
- }
599
- const uploadUrl = weixinCdnUploadUrl(upload, fileKey);
600
- const ciphertext = encryptWeixinUpload(file.bytes, aesKey);
601
- let downloadParam;
602
- try {
603
- downloadParam = await uploadWeixinCdn(fetchImpl, uploadUrl, ciphertext, { signal });
604
- } catch (error) {
605
- if (signal?.aborted) throw abortError(signal);
606
- const status = Number(error?.status);
607
- const fallback = error?.code === 'upload-rejected' || (status >= 400 && status < 500)
608
- ? 'artifact-provider-rejected'
609
- : 'artifact-provider-failed';
610
- throw weixinArtifactError(error, { fallback });
611
- }
612
- signal?.throwIfAborted();
613
- const deliverySeed = nonEmptyString(file.deliveryKey) ?? nonEmptyString(file.artifactId)
614
- ?? randomUUID();
615
- const clientId = `dsh-weixin-${createHash('sha256').update(deliverySeed).digest('hex').slice(0, 32)}`;
616
- let response;
617
- try {
618
- response = await requestJson(fetchImpl, {
619
- method: 'POST',
620
- baseUrl,
621
- endpoint: 'ilink/bot/sendmessage',
622
- token,
623
- signal,
624
- body: {
625
- msg: {
626
- from_user_id: '',
627
- to_user_id: recipient,
628
- client_id: clientId,
629
- message_type: 2,
630
- message_state: 2,
631
- item_list: [{
632
- type: 4,
633
- file_item: {
634
- media: {
635
- encrypt_query_param: downloadParam,
636
- aes_key: Buffer.from(aesKey.toString('hex')).toString('base64'),
637
- encrypt_type: 1,
638
- },
639
- file_name: file.fileName,
640
- len: String(file.bytes.byteLength),
641
- },
642
- }],
643
- ...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
644
- ...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}),
645
- },
646
- base_info: baseInfo(),
679
+ }),
680
+ });
681
+ },
682
+
683
+ async sendImage(request) {
684
+ return sendArtifact(request, {
685
+ mediaType: 1,
686
+ createItem: ({ media, ciphertextSize }) => ({
687
+ type: 2,
688
+ image_item: {
689
+ media,
690
+ mid_size: ciphertextSize,
647
691
  },
648
- });
649
- } catch (error) {
650
- throw classifyWeixinFinalDeliveryError(error, signal);
651
- }
652
- const sendRejection = rejectedProviderResponse(response);
653
- if (sendRejection) {
654
- throw weixinArtifactError(new WeixinApiError(
655
- 'send-rejected',
656
- '微信服务拒绝了文件消息。',
657
- { providerCode: sendRejection },
658
- ));
659
- }
660
- return { messageId: clientId };
692
+ }),
693
+ });
661
694
  },
662
695
 
663
696
  async notifyStart({ baseUrl, token, signal }) {
@@ -38,14 +38,9 @@ import {
38
38
  prefetchInboundFiles,
39
39
  } from '../shared/inbound-file.mjs';
40
40
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
41
+ import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
41
42
  import {
42
- materializeOutboundArtifact,
43
- releaseOutboundArtifact,
44
- } from '../shared/semantic/artifact.mjs';
45
- import {
46
- createArtifactFailureReceipt,
47
43
  createDeliveryReceipt,
48
- mergeDeliveryReceipts,
49
44
  providerMessageIdsFor,
50
45
  } from '../shared/semantic/delivery.mjs';
51
46
 
@@ -869,73 +864,40 @@ export class WeixinHarnessBridge {
869
864
  }
870
865
 
871
866
  async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
872
- const receipts = baseReceipt ? [baseReceipt] : [];
873
- let userVisible = Boolean(baseReceipt);
874
- for (const artifact of artifacts) {
875
- this.#signal?.throwIfAborted();
876
- try {
877
- if (typeof this.#api.sendFile !== 'function') {
878
- const unavailable = new Error('Weixin file delivery is unavailable');
879
- unavailable.code = 'artifact-provider-unavailable';
880
- throw unavailable;
881
- }
882
- const file = await materializeOutboundArtifact(artifact, {
883
- signal: this.#signal,
884
- });
885
- const result = await this.#api.sendFile({
886
- baseUrl: this.#baseUrl,
887
- token: this.#token,
888
- toUserId,
889
- file,
890
- contextToken,
891
- runId,
892
- signal: this.#signal,
893
- });
894
- receipts.push(createDeliveryReceipt({
895
- deliveryId: file.deliveryKey,
896
- presentation: 'weixin-file',
897
- providerMessageIds: providerMessageIdsFor(result),
898
- artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
899
- }));
900
- userVisible = true;
901
- this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
902
- } catch (error) {
903
- if (this.#signal?.aborted) throw error;
904
- this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
905
- this.#logger.warn?.(
906
- `[dsh-weixin] result file delivery failed (${error?.code ?? 'unknown'})`,
907
- );
908
- let noticeSent = false;
909
- const providerMessageIds = await this.#send(
910
- toUserId,
911
- artifactFailureText(artifact?.fileName, error),
912
- contextToken,
913
- runId,
914
- ).then((ids) => {
915
- noticeSent = true;
916
- return ids;
917
- }).catch(() => []);
918
- const failureReceipt = createArtifactFailureReceipt({
919
- artifactId: artifact?.artifactId ?? 'unknown',
920
- deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
921
- error,
922
- providerMessageIds,
923
- });
924
- receipts.push(failureReceipt);
925
- if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
926
- } finally {
927
- releaseOutboundArtifact(artifact);
928
- }
929
- }
930
- const receipt = receipts.length === 0
931
- ? null
932
- : receipts.length === 1
933
- ? receipts[0]
934
- : mergeDeliveryReceipts({
935
- deliveryId: replyTo,
936
- presentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
937
- receipts,
938
- });
939
- return { receipt, userVisible };
867
+ const sendArtifact = (method, file) => this.#api[method]({
868
+ baseUrl: this.#baseUrl,
869
+ token: this.#token,
870
+ toUserId,
871
+ file,
872
+ contextToken,
873
+ runId,
874
+ signal: this.#signal,
875
+ });
876
+ const delivery = await deliverOutboundArtifacts({
877
+ artifacts,
878
+ baseReceipt,
879
+ deliveryId: replyTo,
880
+ aggregatePresentation: baseReceipt ? 'weixin-text-and-files' : 'weixin-files',
881
+ channelKey: 'weixin',
882
+ signal: this.#signal,
883
+ sendImage: typeof this.#api.sendImage === 'function'
884
+ ? (file) => sendArtifact('sendImage', file)
885
+ : undefined,
886
+ sendFile: typeof this.#api.sendFile === 'function'
887
+ ? (file) => sendArtifact('sendFile', file)
888
+ : undefined,
889
+ sendFailureNotice: (artifact, error) => this.#send(
890
+ toUserId,
891
+ artifactFailureText(artifact?.fileName, error),
892
+ contextToken,
893
+ runId,
894
+ ),
895
+ logger: this.#logger,
896
+ });
897
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
898
+ + delivery.artifactsSent;
899
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
900
+ + delivery.artifactSendErrors;
901
+ return { receipt: delivery.receipt, userVisible: delivery.userVisible };
940
902
  }
941
903
  }
@@ -313,12 +313,31 @@ function waitWithSignal(promise, signal) {
313
313
 
314
314
  function uncertainArtifactDelivery(error) {
315
315
  if (error?.code === 'artifact-delivery-uncertain') return error;
316
- const uncertain = new Error('WhatsApp could not confirm result file delivery.');
316
+ const uncertain = new Error('WhatsApp could not confirm artifact delivery.');
317
317
  uncertain.code = 'artifact-delivery-uncertain';
318
318
  uncertain.cause = error;
319
319
  return uncertain;
320
320
  }
321
321
 
322
+ function whatsappArtifactError(error) {
323
+ if (error?.code?.startsWith?.('artifact-')) return error;
324
+ const status = error?.output?.statusCode
325
+ ?? error?.data?.statusCode
326
+ ?? error?.statusCode;
327
+ let code;
328
+ if (status === 401 || status === 403) code = 'artifact-permission-required';
329
+ else if (status === 413) code = 'artifact-too-large';
330
+ else if (status === 429) code = 'artifact-rate-limited';
331
+ else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
332
+ code = 'artifact-provider-rejected';
333
+ }
334
+ if (!code) return uncertainArtifactDelivery(error);
335
+ const wrapped = new Error('WhatsApp rejected artifact delivery.');
336
+ wrapped.code = code;
337
+ wrapped.cause = error;
338
+ return wrapped;
339
+ }
340
+
322
341
  export class WhatsappBotClient {
323
342
  #socket;
324
343
  #outboundIds;
@@ -354,14 +373,32 @@ export class WhatsappBotClient {
354
373
  }
355
374
 
356
375
  async sendFile(target, file) {
376
+ return this.#sendArtifact(target, file, {
377
+ document: file.bytes,
378
+ mimetype: file.mediaType ?? 'application/octet-stream',
379
+ fileName: file.fileName,
380
+ }, 'file');
381
+ }
382
+
383
+ async sendImage(target, file) {
384
+ return this.#sendArtifact(target, file, {
385
+ image: file.bytes,
386
+ mimetype: file.mediaType ?? 'image/jpeg',
387
+ }, 'image');
388
+ }
389
+
390
+ async #sendArtifact(target, file, content, presentation) {
357
391
  this.#signal?.throwIfAborted();
358
392
  await this.#stopTyping(target.jid);
359
393
  this.#signal?.throwIfAborted();
360
394
  const deliverySeed = typeof file.deliveryKey === 'string' && file.deliveryKey
361
395
  ? file.deliveryKey
362
396
  : file.artifactId;
363
- const messageId = typeof deliverySeed === 'string' && deliverySeed
364
- ? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 20).toUpperCase()
397
+ const messageIdSeed = presentation === 'image'
398
+ ? `${deliverySeed}:image`
399
+ : deliverySeed;
400
+ const messageId = typeof messageIdSeed === 'string' && messageIdSeed
401
+ ? createHash('sha256').update(messageIdSeed).digest('hex').slice(0, 20).toUpperCase()
365
402
  : undefined;
366
403
  const options = {
367
404
  ...(target.quoted ? { quoted: target.quoted } : {}),
@@ -375,11 +412,7 @@ export class WhatsappBotClient {
375
412
  try {
376
413
  const pending = this.#socket.sendMessage(
377
414
  target.jid,
378
- {
379
- document: file.bytes,
380
- mimetype: file.mediaType ?? 'application/octet-stream',
381
- fileName: file.fileName,
382
- },
415
+ content,
383
416
  options,
384
417
  );
385
418
  trackOutboundArtifactProviderPromise(file, pending);
@@ -390,7 +423,7 @@ export class WhatsappBotClient {
390
423
  result = await waitWithSignal(pending, waitSignal);
391
424
  } catch (error) {
392
425
  if (this.#signal?.aborted) throw abortReason(this.#signal);
393
- throw uncertainArtifactDelivery(error);
426
+ throw whatsappArtifactError(error);
394
427
  }
395
428
  this.#signal?.throwIfAborted();
396
429
  this.#outboundIds.remember(result?.key?.id);