zapo-js 1.5.0 → 1.6.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 (34) hide show
  1. package/dist/client/WaClient.d.ts +1 -1
  2. package/dist/client/WaClient.js +1 -1
  3. package/dist/client/WaClientFactory.js +1 -0
  4. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +2 -2
  5. package/dist/client/coordinators/WaMessageCoordinator.d.ts +69 -1
  6. package/dist/client/coordinators/WaMessageCoordinator.js +76 -1
  7. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +1 -1
  8. package/dist/client/media.d.ts +1 -0
  9. package/dist/client/media.js +1 -0
  10. package/dist/client/messaging/messages.d.ts +45 -1
  11. package/dist/client/messaging/messages.js +57 -45
  12. package/dist/esm/client/WaClient.js +1 -1
  13. package/dist/esm/client/WaClientFactory.js +1 -0
  14. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +2 -2
  15. package/dist/esm/client/coordinators/WaMessageCoordinator.js +78 -3
  16. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +1 -1
  17. package/dist/esm/client/media.js +1 -1
  18. package/dist/esm/client/messaging/messages.js +56 -45
  19. package/dist/esm/index.js +2 -0
  20. package/dist/esm/signal/api/SignalDeviceSyncApi.js +133 -63
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.js +6 -2
  23. package/dist/signal/api/SignalDeviceSyncApi.d.ts +29 -2
  24. package/dist/signal/api/SignalDeviceSyncApi.js +131 -61
  25. package/package.json +1 -1
  26. package/spec/appstate/index.d.ts +5 -4
  27. package/spec/appstate/index.js +21 -8
  28. package/spec/mex/index.d.ts +335 -73
  29. package/spec/mex/index.js +29 -21
  30. package/spec/proto/index.d.ts +833 -69
  31. package/spec/proto/index.js +1 -1
  32. package/spec/version/index.d.ts +1 -1
  33. package/spec/version/index.js +1 -1
  34. package/spec/version/version.json +1 -1
@@ -131,7 +131,7 @@ declare class WaClientImpl extends EventEmitter {
131
131
  ignoreKey(input: WaIgnoreKey | WaIgnoreKeyPredicate): () => void;
132
132
  /** Auth client: pairing, credentials, registration state. */
133
133
  get auth(): WaAuthClient;
134
- /** Message coordinator: send/receive, receipts, addons, media download. */
134
+ /** Message coordinator: send/receive, receipts, addons, media upload/download. */
135
135
  get message(): WaMessageCoordinator;
136
136
  /** Presence coordinator: own/peer presence subscriptions. */
137
137
  get presence(): WaPresenceCoordinator;
@@ -414,7 +414,7 @@ class WaClientImpl extends node_events_1.EventEmitter {
414
414
  get auth() {
415
415
  return this.deps.authClient;
416
416
  }
417
- /** Message coordinator: send/receive, receipts, addons, media download. */
417
+ /** Message coordinator: send/receive, receipts, addons, media upload/download. */
418
418
  get message() {
419
419
  return this.deps.messageCoordinator;
420
420
  }
@@ -504,6 +504,7 @@ function buildWaClientDependencies(input) {
504
504
  const messageCoordinator = new WaMessageCoordinator_1.WaMessageCoordinator({
505
505
  messageDispatch,
506
506
  mediaTransfer,
507
+ mediaUploadOptions: mediaMessageBuildOptions,
507
508
  logger,
508
509
  messageStore: sessionStore.messages,
509
510
  messageSecretStore: sessionStore.messageSecret,
@@ -761,8 +761,8 @@ class WaAppStateMutationCoordinator {
761
761
  statusPrivacy: {
762
762
  mode: modeValue,
763
763
  userJid,
764
- ...(input.shareToFB === undefined ? {} : { shareToFB: input.shareToFB }),
765
- ...(input.shareToIG === undefined ? {} : { shareToIG: input.shareToIG })
764
+ ...(input.shareToFB === undefined ? {} : { shareToFb: input.shareToFB }),
765
+ ...(input.shareToIG === undefined ? {} : { shareToIg: input.shareToIG })
766
766
  }
767
767
  };
768
768
  const timestamp = this.serverClock.nowMs();
@@ -1,9 +1,12 @@
1
1
  import type { Readable } from 'node:stream';
2
2
  import type { WaMessageDispatchCoordinator } from '../coordinators/WaMessageDispatchCoordinator';
3
3
  import type { WaTrustedContactTokenCoordinator } from '../coordinators/WaTrustedContactTokenCoordinator';
4
+ import { type WaUploadMediaSource } from '../media';
5
+ import { type UploadResult, type WaMediaMessageOptions } from '../messaging/messages';
4
6
  import type { WaDownloadMediaOptions, WaIncomingAddonEvent, WaIncomingMessageEvent, WaSendMessageOptions } from '../types';
5
7
  import type { Logger } from '../../infra/log/types';
6
8
  import type { WaMediaTransferClient } from '../../media/transfer/WaMediaTransferClient';
9
+ import type { MediaKind } from '../../media/types';
7
10
  import type { PeerDataOperationRequester } from '../../message/primitives/peer-data-operation';
8
11
  import type { WaMessagePublishResult, WaSendMessageContent, WaSendReceiptEventOptions, WaSendReceiptOptions } from '../../message/types';
9
12
  import { type Proto } from '../../proto';
@@ -13,6 +16,8 @@ import { type WaMexQuerySocket } from '../../transport/node/mex/client';
13
16
  export interface WaMessageCoordinatorDeps {
14
17
  readonly messageDispatch: WaMessageDispatchCoordinator;
15
18
  readonly mediaTransfer: WaMediaTransferClient;
19
+ /** Media upload wiring shared with the send path; backs {@link WaMessageCoordinator.upload}. */
20
+ readonly mediaUploadOptions: WaMediaMessageOptions;
16
21
  readonly logger: Logger;
17
22
  readonly messageStore: WaMessageStore;
18
23
  readonly messageSecretStore: WaMessageSecretStore;
@@ -58,14 +63,47 @@ export interface WaMessageCappingInfo {
58
63
  readonly mvStatus: string | null;
59
64
  readonly cappingStatus: string | null;
60
65
  }
66
+ /**
67
+ * Media kinds accepted by {@link WaMessageCoordinator.upload}. `gif` is a
68
+ * GIF-playback video, `ptt` a voice note, `ptv` a round video note.
69
+ */
70
+ export type WaUploadMediaType = MediaKind | 'gif' | 'ptt';
71
+ /** Options for {@link WaMessageCoordinator.upload}. */
72
+ export interface WaUploadMediaOptions {
73
+ /** Media type: sets the encryption context and CDN upload path. */
74
+ readonly type: WaUploadMediaType;
75
+ /** `Content-Type` for the upload and the mimetype for the message proto. */
76
+ readonly mimetype?: string;
77
+ /** Reuse a 32-byte media key instead of generating one. */
78
+ readonly mediaKey?: Uint8Array;
79
+ /** Override the streaming sidecar (default on for video/ptv/audio/gif/ptt). */
80
+ readonly sidecar?: boolean;
81
+ /** Animated-sticker first-frame length for the first-frame sidecar. */
82
+ readonly firstFrameLength?: number;
83
+ /** Per-upload transfer timeout override (ms). */
84
+ readonly timeoutMs?: number;
85
+ /** Cancellation signal forwarded to the CDN request. */
86
+ readonly signal?: AbortSignal;
87
+ }
88
+ /**
89
+ * Reusable descriptor returned by {@link WaMessageCoordinator.upload}: the
90
+ * {@link UploadResult} fields plus the media-key timestamp and echoed mimetype.
91
+ */
92
+ export interface WaMediaUploadResult extends UploadResult {
93
+ /** Unix seconds the media key was minted; belongs on the message proto. */
94
+ readonly mediaKeyTimestamp: number;
95
+ /** Echo of the `mimetype` option when provided. */
96
+ readonly mimetype?: string;
97
+ }
61
98
  /**
62
99
  * Coordinates outbound message sending, receipts, addon decryption, media
63
- * download, and the related MEX account queries. Accessed via
100
+ * upload/download, and the related MEX account queries. Accessed via
64
101
  * {@link WaClient.message}.
65
102
  */
66
103
  export declare class WaMessageCoordinator {
67
104
  private readonly messageDispatch;
68
105
  private readonly mediaTransfer;
106
+ private readonly mediaUploadOptions;
69
107
  private readonly logger;
70
108
  private readonly messageStore;
71
109
  private readonly messageSecretStore;
@@ -183,6 +221,36 @@ export declare class WaMessageCoordinator {
183
221
  */
184
222
  sendReceipt(target: WaIncomingMessageEvent | readonly WaIncomingMessageEvent[], options?: WaSendReceiptEventOptions): Promise<void>;
185
223
  sendReceipt(jid: string, ids: string | readonly string[], options?: WaSendReceiptOptions): Promise<void>;
224
+ /**
225
+ * Encrypts and uploads standalone media to the WhatsApp CDN and returns the
226
+ * reusable descriptor, without sending a message - pre-upload once and
227
+ * reference it across sends, or build custom protos. Needs a connected
228
+ * session (the host token comes from a `media_conn` IQ). `source` is bytes,
229
+ * a file path, or a `Readable`. To send the result, spread its fields onto
230
+ * the matching proto message and pass that to {@link send}.
231
+ *
232
+ * @throws when `source` is unsupported, the file is unreadable, or the upload fails.
233
+ * @example
234
+ * ```ts
235
+ * const media = await client.message.upload(await readFile('photo.jpg'), {
236
+ * type: 'image',
237
+ * mimetype: 'image/jpeg'
238
+ * })
239
+ * await client.message.send(jid, {
240
+ * imageMessage: {
241
+ * url: media.url,
242
+ * directPath: media.directPath,
243
+ * mediaKey: media.mediaKey,
244
+ * fileSha256: media.fileSha256,
245
+ * fileEncSha256: media.fileEncSha256,
246
+ * fileLength: media.fileLength,
247
+ * mediaKeyTimestamp: media.mediaKeyTimestamp,
248
+ * mimetype: media.mimetype
249
+ * }
250
+ * })
251
+ * ```
252
+ */
253
+ upload(source: WaUploadMediaSource, options: WaUploadMediaOptions): Promise<WaMediaUploadResult>;
186
254
  /**
187
255
  * Resolves the media payload inside `source` and returns a `Readable`
188
256
  * stream of the decrypted bytes. Throws when the message has no
@@ -5,6 +5,8 @@ const node_fs_1 = require("node:fs");
5
5
  const promises_1 = require("node:stream/promises");
6
6
  const receipt_1 = require("../events/receipt");
7
7
  const media_1 = require("../media");
8
+ const messages_1 = require("../messaging/messages");
9
+ const constants_1 = require("../../media/constants");
8
10
  const addon_crypto_1 = require("../../message/crypto/addon-crypto");
9
11
  const content_1 = require("../../message/encode/content");
10
12
  const _proto_1 = require("../../proto");
@@ -34,15 +36,36 @@ function parseMessageCappingMexResponse(data) {
34
36
  cappingStatus: (0, coercion_1.tryAsString)(root?.capping_status)
35
37
  };
36
38
  }
39
+ const SIDECAR_UPLOAD_TYPES = new Set([
40
+ 'video',
41
+ 'ptv',
42
+ 'audio',
43
+ 'gif',
44
+ 'ptt'
45
+ ]);
46
+ async function normalizeUploadSource(source) {
47
+ if (source instanceof Uint8Array) {
48
+ return source;
49
+ }
50
+ if (typeof source === 'string') {
51
+ await (0, media_1.assertReadableFile)(source);
52
+ return (0, node_fs_1.createReadStream)(source);
53
+ }
54
+ if ((0, media_1.isReadableStream)(source)) {
55
+ return source;
56
+ }
57
+ throw new Error('media upload received unsupported source type');
58
+ }
37
59
  /**
38
60
  * Coordinates outbound message sending, receipts, addon decryption, media
39
- * download, and the related MEX account queries. Accessed via
61
+ * upload/download, and the related MEX account queries. Accessed via
40
62
  * {@link WaClient.message}.
41
63
  */
42
64
  class WaMessageCoordinator {
43
65
  constructor(deps) {
44
66
  this.messageDispatch = deps.messageDispatch;
45
67
  this.mediaTransfer = deps.mediaTransfer;
68
+ this.mediaUploadOptions = deps.mediaUploadOptions;
46
69
  this.logger = deps.logger;
47
70
  this.messageStore = deps.messageStore;
48
71
  this.messageSecretStore = deps.messageSecretStore;
@@ -222,6 +245,58 @@ class WaMessageCoordinator {
222
245
  });
223
246
  }
224
247
  }
248
+ /**
249
+ * Encrypts and uploads standalone media to the WhatsApp CDN and returns the
250
+ * reusable descriptor, without sending a message - pre-upload once and
251
+ * reference it across sends, or build custom protos. Needs a connected
252
+ * session (the host token comes from a `media_conn` IQ). `source` is bytes,
253
+ * a file path, or a `Readable`. To send the result, spread its fields onto
254
+ * the matching proto message and pass that to {@link send}.
255
+ *
256
+ * @throws when `source` is unsupported, the file is unreadable, or the upload fails.
257
+ * @example
258
+ * ```ts
259
+ * const media = await client.message.upload(await readFile('photo.jpg'), {
260
+ * type: 'image',
261
+ * mimetype: 'image/jpeg'
262
+ * })
263
+ * await client.message.send(jid, {
264
+ * imageMessage: {
265
+ * url: media.url,
266
+ * directPath: media.directPath,
267
+ * mediaKey: media.mediaKey,
268
+ * fileSha256: media.fileSha256,
269
+ * fileEncSha256: media.fileEncSha256,
270
+ * fileLength: media.fileLength,
271
+ * mediaKeyTimestamp: media.mediaKeyTimestamp,
272
+ * mimetype: media.mimetype
273
+ * }
274
+ * })
275
+ * ```
276
+ */
277
+ async upload(source, options) {
278
+ const uploadPath = constants_1.MEDIA_UPLOAD_PATHS[options.type];
279
+ if (!uploadPath) {
280
+ throw new Error(`unknown media upload type: ${String(options.type)}`);
281
+ }
282
+ const result = await (0, messages_1.uploadMedia)(this.mediaUploadOptions, {
283
+ source: await normalizeUploadSource(source),
284
+ cryptoType: options.type,
285
+ uploadPath,
286
+ contentType: options.mimetype,
287
+ mediaKey: options.mediaKey,
288
+ sidecar: options.sidecar ?? SIDECAR_UPLOAD_TYPES.has(options.type),
289
+ firstFrameLength: options.firstFrameLength,
290
+ timeoutMs: options.timeoutMs,
291
+ signal: options.signal,
292
+ logLabel: 'user media upload'
293
+ });
294
+ return {
295
+ ...result,
296
+ mediaKeyTimestamp: this.mediaUploadOptions.serverClock.nowSeconds(),
297
+ mimetype: options.mimetype
298
+ };
299
+ }
225
300
  /**
226
301
  * Resolves the media payload inside `source` and returns a `Readable`
227
302
  * stream of the decrypted bytes. Throws when the message has no
@@ -318,7 +318,7 @@ class WaMessageDispatchCoordinator {
318
318
  }
319
319
  try {
320
320
  const results = await this.deps.signalDeviceSync.queryLidsByPhoneJids([pnUserJid]);
321
- const match = results.find((entry) => entry.phoneJid === pnUserJid);
321
+ const match = results.find((entry) => entry.queriedJid === pnUserJid);
322
322
  if (match?.lidJid)
323
323
  return match.lidJid;
324
324
  }
@@ -57,6 +57,7 @@ export interface ResolvedMediaInputs {
57
57
  readonly uploadMedia: Uint8Array | Readable;
58
58
  readonly tempFilePath?: string;
59
59
  }
60
+ export declare function assertReadableFile(filePath: string): Promise<void>;
60
61
  export declare function resolveMediaInputs(shouldProcess: boolean, raw: Uint8Array | ArrayBuffer | Readable | string): Promise<ResolvedMediaInputs>;
61
62
  export declare function shouldNormalizeVoiceNote(media: WaMediaOptions | undefined, content: WaSendMediaMessage): content is WaSendMediaMessage & {
62
63
  type: 'audio';
@@ -9,6 +9,7 @@ exports.buildMediaUploadUrl = buildMediaUploadUrl;
9
9
  exports.assertMediaUploadStatus = assertMediaUploadStatus;
10
10
  exports.parseMediaUploadJsonBody = parseMediaUploadJsonBody;
11
11
  exports.performPlaintextMediaUpload = performPlaintextMediaUpload;
12
+ exports.assertReadableFile = assertReadableFile;
12
13
  exports.resolveMediaInputs = resolveMediaInputs;
13
14
  exports.shouldNormalizeVoiceNote = shouldNormalizeVoiceNote;
14
15
  exports.hasMediaProcessingTasks = hasMediaProcessingTasks;
@@ -1,8 +1,9 @@
1
+ import { Readable } from 'node:stream';
1
2
  import type { ResolvedLinkPreviewResult } from '../messaging/link-preview';
2
3
  import type { WaMediaOptions } from '../types';
3
4
  import type { Logger } from '../../infra/log/types';
4
5
  import type { WaMediaTransferClient } from '../../media/transfer/WaMediaTransferClient';
5
- import type { WaMediaConn } from '../../media/types';
6
+ import type { MediaCryptoType, WaMediaConn } from '../../media/types';
6
7
  import type { WaMessageBuildResult, WaSendMessageContent, WaSendTextMessage } from '../../message/types';
7
8
  import type { BinaryNode } from '../../transport/types';
8
9
  import type { ServerClock } from '../../util/clock';
@@ -26,3 +27,46 @@ export interface WaBuildMessageContext {
26
27
  }
27
28
  export declare function buildMediaMessageContent(options: WaMediaMessageOptions, content: WaSendMessageContent, ctx?: WaBuildMessageContext): Promise<WaMessageBuildResult>;
28
29
  export declare function getMediaConn(options: WaMediaMessageOptions, forceRefresh?: boolean): Promise<WaMediaConn>;
30
+ /** Descriptor produced by {@link uploadMedia}: CDN location, media key, hashes, and optional sidecars. */
31
+ export interface UploadResult {
32
+ /** Absolute CDN URL of the uploaded ciphertext. */
33
+ readonly url: string;
34
+ /** Host-relative path; pairs with a media host or feeds `downloadMediaMessage`. */
35
+ readonly directPath: string;
36
+ /** 32-byte media key used to encrypt (and later decrypt) the payload. */
37
+ readonly mediaKey: Uint8Array;
38
+ /** SHA-256 of the plaintext. */
39
+ readonly fileSha256: Uint8Array;
40
+ /** SHA-256 of the encrypted `ciphertext||mac`. */
41
+ readonly fileEncSha256: Uint8Array;
42
+ /** Plaintext byte length. */
43
+ readonly fileLength: number;
44
+ /** Server metadata URL, when the CDN returns one (video). */
45
+ readonly metadataUrl?: string;
46
+ /** Streaming sidecar for seekable playback, when computed. */
47
+ readonly streamingSidecar?: Uint8Array;
48
+ /** First-frame sidecar for animated stickers, when computed. */
49
+ readonly firstFrameSidecar?: Uint8Array;
50
+ /** First-frame length echoed back for animated stickers. */
51
+ readonly firstFrameLength?: number;
52
+ }
53
+ /** Fully-resolved input for {@link uploadMedia} (crypto type, path, sidecar already decided). */
54
+ export interface WaMediaUploadInput {
55
+ readonly source: Uint8Array | Readable;
56
+ readonly cryptoType: MediaCryptoType;
57
+ readonly uploadPath: string;
58
+ readonly contentType?: string;
59
+ readonly mediaKey?: Uint8Array;
60
+ readonly sidecar: boolean;
61
+ readonly firstFrameLength?: number;
62
+ readonly logLabel?: string;
63
+ readonly timeoutMs?: number;
64
+ readonly signal?: AbortSignal;
65
+ }
66
+ /**
67
+ * Shared media upload primitive: derives (or reuses) a media key, encrypts,
68
+ * fetches a media connection, and POSTs the ciphertext. Bytes take a
69
+ * zero-temp-file fast path; streams stage to a temp file first. Backs both the
70
+ * send path and `WaMessageCoordinator.upload`.
71
+ */
72
+ export declare function uploadMedia(options: WaMediaMessageOptions, input: WaMediaUploadInput): Promise<UploadResult>;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildMediaMessageContent = buildMediaMessageContent;
4
4
  exports.getMediaConn = getMediaConn;
5
+ exports.uploadMedia = uploadMedia;
5
6
  const node_fs_1 = require("node:fs");
6
7
  const node_stream_1 = require("node:stream");
7
8
  const media_1 = require("../media");
@@ -345,9 +346,15 @@ async function buildMediaMessage(options, content) {
345
346
  const firstFrameLength = content.type === 'sticker'
346
347
  ? (content.firstFrameLength ?? detectedFirstFrameLength)
347
348
  : undefined;
348
- const uploadPromise = (0, media_1.isReadableStream)(resolved.uploadMedia)
349
- ? uploadMediaStream(options, content, resolved.uploadMedia, firstFrameLength, mimetype)
350
- : uploadMediaBytes(options, content, resolved.uploadMedia, firstFrameLength, mimetype);
349
+ const uploadType = resolveUploadType(content);
350
+ const uploadPromise = uploadMedia(options, {
351
+ source: resolved.uploadMedia,
352
+ cryptoType: uploadType,
353
+ uploadPath: resolveUploadPath(uploadType),
354
+ contentType: mimetype,
355
+ sidecar: needsSidecar(content),
356
+ firstFrameLength
357
+ });
351
358
  const processPromise = (0, media_1.runMediaProcessor)(options.media, resolved.processorInput, content, options.logger);
352
359
  const [uploadResult, processResult] = await Promise.allSettled([
353
360
  uploadPromise,
@@ -508,29 +515,42 @@ function parseUploadResponse(body, status) {
508
515
  ...(parsed.metadata_url ? { metadataUrl: parsed.metadata_url } : {})
509
516
  };
510
517
  }
511
- async function uploadMediaBytes(options, content, mediaBytes, firstFrameLength, mimetype) {
512
- const uploadType = resolveUploadType(content);
513
- const mediaKey = await WaMediaCrypto_1.WaMediaCrypto.generateMediaKey();
518
+ /**
519
+ * Shared media upload primitive: derives (or reuses) a media key, encrypts,
520
+ * fetches a media connection, and POSTs the ciphertext. Bytes take a
521
+ * zero-temp-file fast path; streams stage to a temp file first. Backs both the
522
+ * send path and `WaMessageCoordinator.upload`.
523
+ */
524
+ async function uploadMedia(options, input) {
525
+ const mediaKey = input.mediaKey ?? (await WaMediaCrypto_1.WaMediaCrypto.generateMediaKey());
526
+ if (input.source instanceof Uint8Array) {
527
+ return uploadMediaBytes(options, input, mediaKey, input.source);
528
+ }
529
+ return uploadMediaStream(options, input, mediaKey, input.source);
530
+ }
531
+ async function uploadMediaBytes(options, input, mediaKey, mediaBytes) {
514
532
  const [encrypted, mediaConn] = await Promise.all([
515
- WaMediaCrypto_1.WaMediaCrypto.encryptBytes(uploadType, mediaKey, mediaBytes, {
516
- sidecar: needsSidecar(content),
517
- firstFrameLength
533
+ WaMediaCrypto_1.WaMediaCrypto.encryptBytes(input.cryptoType, mediaKey, mediaBytes, {
534
+ sidecar: input.sidecar,
535
+ firstFrameLength: input.firstFrameLength
518
536
  }),
519
537
  getMediaConn(options)
520
538
  ]);
521
539
  const selectedHost = (0, media_1.selectMediaUploadHost)(mediaConn);
522
- const uploadUrl = (0, media_1.buildMediaUploadUrl)(selectedHost, resolveUploadPath(uploadType), mediaConn.auth, encrypted.fileEncSha256);
523
- options.logger.debug('sending media upload request', {
524
- mediaType: content.type,
525
- uploadType,
526
- host: selectedHost
540
+ const uploadUrl = (0, media_1.buildMediaUploadUrl)(selectedHost, input.uploadPath, mediaConn.auth, encrypted.fileEncSha256);
541
+ options.logger.debug(input.logLabel ?? 'sending media upload request', {
542
+ cryptoType: input.cryptoType,
543
+ host: selectedHost,
544
+ size: mediaBytes.byteLength
527
545
  });
528
546
  const uploadResponse = await options.mediaTransfer.uploadStream({
529
547
  url: uploadUrl,
530
548
  method: 'POST',
531
549
  body: encrypted.ciphertextHmac,
532
550
  contentLength: encrypted.ciphertextHmac.byteLength,
533
- contentType: mimetype
551
+ contentType: input.contentType,
552
+ timeoutMs: input.timeoutMs,
553
+ signal: input.signal
534
554
  });
535
555
  const responseBody = await options.mediaTransfer.readResponseBytes(uploadResponse);
536
556
  const parsed = parseUploadResponse(responseBody, uploadResponse.status);
@@ -542,19 +562,22 @@ async function uploadMediaBytes(options, content, mediaBytes, firstFrameLength,
542
562
  fileLength: mediaBytes.byteLength,
543
563
  streamingSidecar: encrypted.streamingSidecar,
544
564
  firstFrameSidecar: encrypted.firstFrameSidecar,
545
- firstFrameLength
565
+ firstFrameLength: input.firstFrameLength
546
566
  };
547
567
  }
548
- async function uploadEncryptedStream(options, input) {
549
- const encResult = await WaMediaCrypto_1.WaMediaCrypto.encryptToFile(input.cryptoType, input.mediaKey, input.plaintext, { sidecar: input.sidecar, firstFrameLength: input.firstFrameLength });
568
+ async function uploadMediaStream(options, input, mediaKey, plaintext) {
569
+ const encResult = await WaMediaCrypto_1.WaMediaCrypto.encryptToFile(input.cryptoType, mediaKey, plaintext, {
570
+ sidecar: input.sidecar,
571
+ firstFrameLength: input.firstFrameLength
572
+ });
550
573
  let readStream;
551
574
  try {
552
575
  const mediaConn = await getMediaConn(options);
553
576
  const selectedHost = (0, media_1.selectMediaUploadHost)(mediaConn);
554
577
  const uploadUrl = (0, media_1.buildMediaUploadUrl)(selectedHost, input.uploadPath, mediaConn.auth, encResult.fileEncSha256);
555
- options.logger.debug(input.logLabel, {
578
+ options.logger.debug(input.logLabel ?? 'sending media stream upload request', {
579
+ cryptoType: input.cryptoType,
556
580
  host: selectedHost,
557
- uploadPath: input.uploadPath,
558
581
  plaintextLength: encResult.plaintextLength,
559
582
  encryptedSize: encResult.fileSize
560
583
  });
@@ -564,13 +587,15 @@ async function uploadEncryptedStream(options, input) {
564
587
  method: 'POST',
565
588
  body: readStream,
566
589
  contentLength: encResult.fileSize,
567
- contentType: input.contentType
590
+ contentType: input.contentType,
591
+ timeoutMs: input.timeoutMs,
592
+ signal: input.signal
568
593
  });
569
594
  const responseBody = await options.mediaTransfer.readResponseBytes(uploadResponse);
570
595
  const parsed = parseUploadResponse(responseBody, uploadResponse.status);
571
596
  return {
572
597
  ...parsed,
573
- mediaKey: input.mediaKey,
598
+ mediaKey,
574
599
  fileSha256: encResult.fileSha256,
575
600
  fileEncSha256: encResult.fileEncSha256,
576
601
  fileLength: encResult.plaintextLength,
@@ -589,19 +614,6 @@ async function uploadEncryptedStream(options, input) {
589
614
  await WaMediaCrypto_1.WaMediaCrypto.cleanupEncryptedFile(encResult.filePath);
590
615
  }
591
616
  }
592
- async function uploadMediaStream(options, content, stream, firstFrameLength, mimetype) {
593
- const cryptoType = resolveUploadType(content);
594
- return uploadEncryptedStream(options, {
595
- plaintext: stream,
596
- mediaKey: await WaMediaCrypto_1.WaMediaCrypto.generateMediaKey(),
597
- cryptoType,
598
- uploadPath: resolveUploadPath(cryptoType),
599
- contentType: mimetype,
600
- logLabel: 'sending media stream upload request',
601
- sidecar: needsSidecar(content),
602
- firstFrameLength
603
- });
604
- }
605
617
  function openStickerPackInputStream(media) {
606
618
  return typeof media === 'string' ? (0, node_fs_1.createReadStream)(media) : node_stream_1.Readable.from([media]);
607
619
  }
@@ -613,23 +625,23 @@ async function buildStickerPackMediaMessage(options, content) {
613
625
  const coverThumbnail = content.coverThumbnail;
614
626
  const mediaKey = await WaMediaCrypto_1.WaMediaCrypto.generateMediaKey();
615
627
  const [bundle, cover] = await Promise.all([
616
- uploadEncryptedStream(options, {
617
- plaintext: (0, sticker_pack_1.createStickerPackZipStream)((0, sticker_pack_2.toStickerPackZipEntries)(content)),
618
- mediaKey,
628
+ uploadMedia(options, {
629
+ source: (0, sticker_pack_1.createStickerPackZipStream)((0, sticker_pack_2.toStickerPackZipEntries)(content)),
619
630
  cryptoType: 'sticker-pack',
620
631
  uploadPath: constants_1.MEDIA_UPLOAD_PATHS['sticker-pack'],
621
632
  contentType: 'application/zip',
622
- logLabel: 'sending sticker pack bundle upload',
623
- sidecar: false
624
- }),
625
- uploadEncryptedStream(options, {
626
- plaintext: openStickerPackInputStream(coverThumbnail),
627
633
  mediaKey,
634
+ sidecar: false,
635
+ logLabel: 'sending sticker pack bundle upload'
636
+ }),
637
+ uploadMedia(options, {
638
+ source: openStickerPackInputStream(coverThumbnail),
628
639
  cryptoType: 'thumbnail-sticker-pack',
629
640
  uploadPath: constants_1.MEDIA_UPLOAD_PATHS['thumbnail-sticker-pack'],
630
641
  contentType: 'image/jpeg',
631
- logLabel: 'sending sticker pack thumbnail upload',
632
- sidecar: false
642
+ mediaKey,
643
+ sidecar: false,
644
+ logLabel: 'sending sticker pack thumbnail upload'
633
645
  })
634
646
  ]);
635
647
  if (cover.fileLength === 0) {
@@ -411,7 +411,7 @@ class WaClientImpl extends EventEmitter {
411
411
  get auth() {
412
412
  return this.deps.authClient;
413
413
  }
414
- /** Message coordinator: send/receive, receipts, addons, media download. */
414
+ /** Message coordinator: send/receive, receipts, addons, media upload/download. */
415
415
  get message() {
416
416
  return this.deps.messageCoordinator;
417
417
  }
@@ -500,6 +500,7 @@ export function buildWaClientDependencies(input) {
500
500
  const messageCoordinator = new WaMessageCoordinator({
501
501
  messageDispatch,
502
502
  mediaTransfer,
503
+ mediaUploadOptions: mediaMessageBuildOptions,
503
504
  logger,
504
505
  messageStore: sessionStore.messages,
505
506
  messageSecretStore: sessionStore.messageSecret,
@@ -758,8 +758,8 @@ export class WaAppStateMutationCoordinator {
758
758
  statusPrivacy: {
759
759
  mode: modeValue,
760
760
  userJid,
761
- ...(input.shareToFB === undefined ? {} : { shareToFB: input.shareToFB }),
762
- ...(input.shareToIG === undefined ? {} : { shareToIG: input.shareToIG })
761
+ ...(input.shareToFB === undefined ? {} : { shareToFb: input.shareToFB }),
762
+ ...(input.shareToIG === undefined ? {} : { shareToIg: input.shareToIG })
763
763
  }
764
764
  };
765
765
  const timestamp = this.serverClock.nowMs();
@@ -1,7 +1,9 @@
1
- import { createWriteStream } from 'node:fs';
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
2
  import { pipeline } from 'node:stream/promises';
3
3
  import { aggregateReceiptTargets } from '../events/receipt.js';
4
- import { downloadMediaMessage } from '../media.js';
4
+ import { assertReadableFile, downloadMediaMessage, isReadableStream } from '../media.js';
5
+ import { uploadMedia } from '../messaging/messages.js';
6
+ import { MEDIA_UPLOAD_PATHS } from '../../media/constants.js';
5
7
  import { buildAddonAdditionalData, decodeAddonPlaintext, decryptAddonPayload, identifyEncryptedAddon, resolveParentMessageSecret, resolvePollOptionNames, shouldUseAddonAdditionalData } from '../../message/crypto/addon-crypto.js';
6
8
  import { unwrapMessage } from '../../message/encode/content.js';
7
9
  import { proto } from '../../proto.js';
@@ -31,15 +33,36 @@ function parseMessageCappingMexResponse(data) {
31
33
  cappingStatus: tryAsString(root?.capping_status)
32
34
  };
33
35
  }
36
+ const SIDECAR_UPLOAD_TYPES = new Set([
37
+ 'video',
38
+ 'ptv',
39
+ 'audio',
40
+ 'gif',
41
+ 'ptt'
42
+ ]);
43
+ async function normalizeUploadSource(source) {
44
+ if (source instanceof Uint8Array) {
45
+ return source;
46
+ }
47
+ if (typeof source === 'string') {
48
+ await assertReadableFile(source);
49
+ return createReadStream(source);
50
+ }
51
+ if (isReadableStream(source)) {
52
+ return source;
53
+ }
54
+ throw new Error('media upload received unsupported source type');
55
+ }
34
56
  /**
35
57
  * Coordinates outbound message sending, receipts, addon decryption, media
36
- * download, and the related MEX account queries. Accessed via
58
+ * upload/download, and the related MEX account queries. Accessed via
37
59
  * {@link WaClient.message}.
38
60
  */
39
61
  export class WaMessageCoordinator {
40
62
  constructor(deps) {
41
63
  this.messageDispatch = deps.messageDispatch;
42
64
  this.mediaTransfer = deps.mediaTransfer;
65
+ this.mediaUploadOptions = deps.mediaUploadOptions;
43
66
  this.logger = deps.logger;
44
67
  this.messageStore = deps.messageStore;
45
68
  this.messageSecretStore = deps.messageSecretStore;
@@ -219,6 +242,58 @@ export class WaMessageCoordinator {
219
242
  });
220
243
  }
221
244
  }
245
+ /**
246
+ * Encrypts and uploads standalone media to the WhatsApp CDN and returns the
247
+ * reusable descriptor, without sending a message - pre-upload once and
248
+ * reference it across sends, or build custom protos. Needs a connected
249
+ * session (the host token comes from a `media_conn` IQ). `source` is bytes,
250
+ * a file path, or a `Readable`. To send the result, spread its fields onto
251
+ * the matching proto message and pass that to {@link send}.
252
+ *
253
+ * @throws when `source` is unsupported, the file is unreadable, or the upload fails.
254
+ * @example
255
+ * ```ts
256
+ * const media = await client.message.upload(await readFile('photo.jpg'), {
257
+ * type: 'image',
258
+ * mimetype: 'image/jpeg'
259
+ * })
260
+ * await client.message.send(jid, {
261
+ * imageMessage: {
262
+ * url: media.url,
263
+ * directPath: media.directPath,
264
+ * mediaKey: media.mediaKey,
265
+ * fileSha256: media.fileSha256,
266
+ * fileEncSha256: media.fileEncSha256,
267
+ * fileLength: media.fileLength,
268
+ * mediaKeyTimestamp: media.mediaKeyTimestamp,
269
+ * mimetype: media.mimetype
270
+ * }
271
+ * })
272
+ * ```
273
+ */
274
+ async upload(source, options) {
275
+ const uploadPath = MEDIA_UPLOAD_PATHS[options.type];
276
+ if (!uploadPath) {
277
+ throw new Error(`unknown media upload type: ${String(options.type)}`);
278
+ }
279
+ const result = await uploadMedia(this.mediaUploadOptions, {
280
+ source: await normalizeUploadSource(source),
281
+ cryptoType: options.type,
282
+ uploadPath,
283
+ contentType: options.mimetype,
284
+ mediaKey: options.mediaKey,
285
+ sidecar: options.sidecar ?? SIDECAR_UPLOAD_TYPES.has(options.type),
286
+ firstFrameLength: options.firstFrameLength,
287
+ timeoutMs: options.timeoutMs,
288
+ signal: options.signal,
289
+ logLabel: 'user media upload'
290
+ });
291
+ return {
292
+ ...result,
293
+ mediaKeyTimestamp: this.mediaUploadOptions.serverClock.nowSeconds(),
294
+ mimetype: options.mimetype
295
+ };
296
+ }
222
297
  /**
223
298
  * Resolves the media payload inside `source` and returns a `Readable`
224
299
  * stream of the decrypted bytes. Throws when the message has no